From e0be718a941f4656d427707f01f46a79d98425cb Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 6 Nov 2025 08:40:00 +0100 Subject: [PATCH 01/52] wip --- .../Expressions/FixedOrderExpression.php | 4 +- src/Database/Expressions/JsonExtract.php | 30 + .../OrderByPlaceholderExpression.php | 14 + .../Queries/Concerns/ElementQueryTrait.php | 782 ++++++++++++++++++ .../Queries/Concerns/QueriesCustomFields.php | 242 ++++++ .../Concerns/QueriesDraftsAndRevisions.php | 409 +++++++++ .../Queries/Concerns/QueriesEagerly.php | 197 +++++ .../Concerns/QueriesPlaceholderElements.php | 77 ++ .../Concerns/QueriesRelatedElements.php | 171 ++++ .../Queries/Concerns/QueriesSites.php | 169 ++++ .../Queries/Concerns/QueriesStatuses.php | 146 ++++ .../Queries/Concerns/QueriesStructures.php | 491 +++++++++++ .../Concerns/QueriesUniqueElements.php | 107 +++ src/Database/Queries/ElementQuery.php | 692 ++++++++++++++++ .../Queries/ElementQueryInterface.php | 369 +++++++++ src/Database/Queries/EntryQuery.php | 8 + .../Exceptions/ElementNotFoundException.php | 71 ++ .../Exceptions/QueryAbortedException.php | 10 + src/Entry/Models/Entry.php | 7 + tests/Database/Queries/ElementQueryTest.php | 82 ++ .../legacy/elements/db/EntryQuery.php | 12 - yii2-adapter/legacy/services/Search.php | 4 +- 22 files changed, 4078 insertions(+), 16 deletions(-) create mode 100644 src/Database/Expressions/JsonExtract.php create mode 100644 src/Database/Expressions/OrderByPlaceholderExpression.php create mode 100644 src/Database/Queries/Concerns/ElementQueryTrait.php create mode 100644 src/Database/Queries/Concerns/QueriesCustomFields.php create mode 100644 src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php create mode 100644 src/Database/Queries/Concerns/QueriesEagerly.php create mode 100644 src/Database/Queries/Concerns/QueriesPlaceholderElements.php create mode 100644 src/Database/Queries/Concerns/QueriesRelatedElements.php create mode 100644 src/Database/Queries/Concerns/QueriesSites.php create mode 100644 src/Database/Queries/Concerns/QueriesStatuses.php create mode 100644 src/Database/Queries/Concerns/QueriesStructures.php create mode 100644 src/Database/Queries/Concerns/QueriesUniqueElements.php create mode 100644 src/Database/Queries/ElementQuery.php create mode 100644 src/Database/Queries/ElementQueryInterface.php create mode 100644 src/Database/Queries/EntryQuery.php create mode 100644 src/Database/Queries/Exceptions/ElementNotFoundException.php create mode 100644 src/Database/Queries/Exceptions/QueryAbortedException.php create mode 100644 tests/Database/Queries/ElementQueryTest.php diff --git a/src/Database/Expressions/FixedOrderExpression.php b/src/Database/Expressions/FixedOrderExpression.php index 555d00fc6fc..b270230b1d1 100644 --- a/src/Database/Expressions/FixedOrderExpression.php +++ b/src/Database/Expressions/FixedOrderExpression.php @@ -28,9 +28,9 @@ public function getValue(Grammar $grammar): string $key = -1; foreach ($this->values as $key => $value) { - $cases[] = new CaseRule(new Value($key), new Equal($this->column, new Value($value))); + $cases[] = new CaseRule(result: new Value($key), condition: new Equal($this->column, new Value($value))); } - return new CaseGroup($cases, new Value($key))->getValue($grammar); + return new CaseGroup(when: $cases, else: new Value($key))->getValue($grammar); } } diff --git a/src/Database/Expressions/JsonExtract.php b/src/Database/Expressions/JsonExtract.php new file mode 100644 index 00000000000..ae08a36db28 --- /dev/null +++ b/src/Database/Expressions/JsonExtract.php @@ -0,0 +1,30 @@ +stringize($grammar, $this->expression); + + return match ($this->identify($grammar)) { + 'mariadb' => "JSON_UNQUOTE(JSON_EXTRACT($expression, $this->path))", + default => "($expression->>$this->path)", + }; + } +} diff --git a/src/Database/Expressions/OrderByPlaceholderExpression.php b/src/Database/Expressions/OrderByPlaceholderExpression.php new file mode 100644 index 00000000000..981b1bc0ada --- /dev/null +++ b/src/Database/Expressions/OrderByPlaceholderExpression.php @@ -0,0 +1,14 @@ + SORT_DESC, + 'elements.id' => SORT_DESC, + ]; + + // For internal use + // ------------------------------------------------------------------------- + + /** + * @var string[]|null + * @see getCacheTags() + */ + private array|null $cacheTags = null; + + /** + * @var array> Column alias => name mapping + * @see prepare() + * @see joinElementTable() + * @see applyOrderByParams() + * @see applySelectParam() + */ + private array $columnMap; + + /** + * @var bool Whether an element table has been joined for the query + * @see prepare() + * @see joinElementTable() + */ + private bool $joinedElementTable = false; + + /** + * @var array|null + * @see applySearchParam() + * @see applyOrderByParams() + * @see populate() + */ + private ?array $searchResults = null; + + /** + * @inheritdoc + * @uses $id + */ + public function id($value): static + { + $this->id = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $uid + */ + public function uid($value): static + { + $this->uid = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $siteSettingsId + */ + public function siteSettingsId($value): static + { + $this->siteSettingsId = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $trashed + */ + public function trashed(?bool $value = true): static + { + $this->trashed = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $dateCreated + */ + public function dateCreated(mixed $value): static + { + $this->dateCreated = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $dateUpdated + */ + public function dateUpdated(mixed $value): static + { + $this->dateUpdated = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $title + */ + public function title($value): static + { + $this->title = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $slug + */ + public function slug($value): static + { + $this->slug = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $uri + */ + public function uri($value): static + { + $this->uri = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $search + */ + public function search($value): static + { + $this->search = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $inBulkOp + */ + public function inBulkOp(?string $value): static + { + $this->inBulkOp = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $ref + */ + public function ref($value): static + { + $this->ref = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $fixedOrder + */ + public function fixedOrder(bool $value = true): static + { + $this->fixedOrder = $value; + + return $this; + } + + public function inReverse(bool $value = true): static + { + $this->inReverse = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $asArray + */ + public function asArray(bool $value = true): static + { + $this->asArray = $value; + + return $this; + } + + + // Internal Methods + // ------------------------------------------------------------------------- + + protected function elementQueryBeforeQuery(Builder $query): void + { + // Is the query already doomed? + if (isset($this->id) && empty($this->id)) { + throw new QueryAbortedException(); + } + + // Clear out the previous cache tags + $this->cacheTags = null; + + // Give other classes a chance to make changes up front + /*if (!$this->beforePrepare()) { + throw new QueryAbortedException(); + }*/ + + $this->subQuery + ->addSelect([ + 'elements.id as elementsId', + 'elements_sites.id as siteSettingsId', + ]) + ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.elementId', 'elements.id'); + // @TODO: Params? + // ->addParams($this->params); + + if ($this->id) { + foreach (DbHelper::parseNumericParam('elements.id', $this->id) as $column => $values) { + $this->subQuery->whereIn($column, Arr::wrap($values)); + } + } + + if ($this->uid) { + foreach (DbHelper::parseParam('elements.uid', $this->uid) as $column => $values) { + $this->subQuery->whereIn($column, Arr::wrap($values)); + } + } + + if ($this->siteSettingsId) { + foreach (DbHelper::parseNumericParam('elements_sites.id', $this->siteSettingsId) as $column => $values) { + $this->subQuery->whereIn($column, Arr::wrap($values)); + } + } + + match($this->trashed) { + true => $this->subQuery->whereNotNull('elements.dateDeleted'), + false => $this->subQuery->whereNull('elements.dateDeleted'), + default => null, + }; + + if ($this->dateCreated) { + $parsed = DbHelper::parseDateParam('elements.dateCreated', $this->dateCreated); + + $operator = $parsed[0]; + $column = $parsed[1]; + $value = $parsed[2] ?? null; + + if (is_null($value)) { + $value = $column; + $column = $operator; + $operator = '='; + } + + $this->subQuery->where($column, $operator, $value); + } + + if ($this->dateUpdated) { + $this->subQuery->where(DbHelper::parseDateParam('elements.dateUpdated', $this->dateUpdated)); + } + + if (isset($this->title) && $this->title !== '' && $this->elementType::hasTitles()) { + if (is_string($this->title)) { + $this->title = DbHelper::escapeCommas($this->title); + } + + $this->subQuery->where(DbHelper::parseParam('elements_sites.title', $this->title, '=', true)); + } + + if ($this->slug) { + $this->subQuery->where(DbHelper::parseParam('elements_sites.slug', $this->slug)); + } + + if ($this->uri) { + $this->subQuery->where(DbHelper::parseParam('elements_sites.uri', $this->uri, '=', true)); + } + + if ($this->inBulkOp) { + $this->subQuery + ->join(new Alias(Table::ELEMENTS_BULKOPS, 'elements_bulkops'), 'elements_bulkops.elementId', 'elements.id') + ->where('elements_bulkops.key', $this->inBulkOp); + } + + $this->applySearchParam($query); + $this->applyOrderByParams($query); + $this->applySelectParams($query); + + // If an element table was never joined in, explicitly filter based on the element type + if (! $this->joinedElementTable && $this->elementType !== Element::class) { + try { + $ref = new ReflectionClass($this->elementType); + } catch (ReflectionException) { + $ref = null; + } + + if ($ref && !$ref->isAbstract()) { + $this->subQuery->where('elements.type', $this->elementType); + } + } + + /** @var \Illuminate\Database\Query\JoinClause $join */ + foreach ($this->query->joins ?? [] as $join) { + $this->subQuery->joins[] = $join; + } + + $query + ->fromSub($this->subQuery, 'subquery') + ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.id', 'subquery.siteSettingsId') + ->join(new Alias(Table::ELEMENTS, 'elements'), 'elements.id', 'subquery.elementsId'); + } + + protected function elementQueryAfterQuery(Collection $collection): void + { + $elementsService = \Craft::$app->getElements(); + + if ($elementsService->getIsCollectingCacheInfo()) { + $elementsService->collectCacheTags($this->getCacheTags()); + } + } + + /** + * @return string[] + */ + public function getCacheTags(): array + { + if (! is_null($this->cacheTags)) { + return $this->cacheTags; + } + + $modelClass = $this->elementType; + + $this->cacheTags = [ + 'element', + "element::{$modelClass}", + ]; + + // If (<= 100) specific IDs were requested, then use those + if (is_numeric($this->id) || + (is_array($this->id) && count($this->id) <= 100 && Arr::isNumeric($this->id)) + ) { + array_push($this->cacheTags, ...array_map(fn($id) => "element::$id", Arr::wrap($this->id))); + + return $this->cacheTags; + } + + $queryTags = $this->cacheTags(); + + // Fire a 'defineCacheTags' event + /*if ($this->hasEventHandlers(self::EVENT_DEFINE_CACHE_TAGS)) { + $event = new DefineValueEvent(['value' => $queryTags]); + $this->trigger(self::EVENT_DEFINE_CACHE_TAGS, $event); + $queryTags = $event->value; + }*/ + + if (! empty($queryTags)) { + if ($this->drafts !== false) { + $queryTags[] = 'drafts'; + } + + if ($this->revisions !== false) { + $queryTags[] = 'revisions'; + } + } else { + $queryTags[] = '*'; + } + + foreach ($queryTags as $tag) { + // tags can be provided fully-formed, or relative to the element type + if (! str_starts_with($tag, 'element::')) { + $tag = sprintf('element::%s::%s', $this->elementType, $tag); + } + + $this->cacheTags[] = $tag; + } + + return $this->cacheTags; + } + + /** + * Returns any cache invalidation tags that caches involving this element query should use as dependencies. + * + * Use the most specific tag(s) possible, to reduce the likelihood of pointless cache clearing. + * + * When elements are created/updated/deleted, their [[ElementInterface::getCacheTags()]] method will be called, + * and any caches that have those tags listed as dependencies will be invalidated. + * + * @return string[] + */ + protected function cacheTags(): array + { + return []; + } + + /** + * Joins in a table with an `id` column that has a foreign key pointing to `elements.id`. + * + * The table will be joined with an alias based on the unprefixed table name. For example, + * if `{{%entries}}` is passed, the table will be aliased to `entries`. + * + * @param string $table The table name, e.g. `entries` or `{{%entries}}` + */ + protected function joinElementTable(string $table, ?string $alias = null): void + { + $this->query->join(new Alias($table, $alias ?? $table), "$alias.id", 'subquery.elementsId'); + $this->subQuery->join(new Alias($table, $alias ?? $table), "$alias.id", 'elements.id'); + $this->joinedElementTable = true; + + // Add element table cols to the column map + foreach (Schema::getColumnListing($table) as $column) { + $name = $column['name']; + + if (! isset($this->columnMap[$name])) { + $this->columnMap[$name] = "$alias.$name"; + } + } + } + + /** + * Applies the 'search' param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applySearchParam(Builder $query): void + { + $this->searchResults = null; + + if (! $this->search) { + return; + } + + $searchService = \Craft::$app->getSearch(); + + $scoreOrder = Arr::first($query->orders, fn($order) => $order['column'] === 'score'); + + if ($scoreOrder || $searchService->shouldCallSearchElements($this)) { + // Get the scored results up front + $searchResults = $searchService->searchElements($this); + + if ($scoreOrder['direction'] === 'asc') { + $searchResults = array_reverse($searchResults, true); + } + + if (($this->orders[0]['column'] ?? null) === 'score') { + // Only use the portion we're actually querying for + if (is_int($this->offset) && $this->offset !== 0) { + $searchResults = array_slice($searchResults, $this->offset, null, true); + $this->subQuery->offset = null; + } + if (is_int($this->limit) && $this->limit !== 0) { + $searchResults = array_slice($searchResults, 0, $this->limit, true); + $this->subQuery->limit = null; + } + } + + if (empty($searchResults)) { + throw new QueryAbortedException(); + } + + $this->searchResults = $searchResults; + + $elementIdsBySiteId = []; + foreach (array_keys($searchResults) as $key) { + [$elementId, $siteId] = explode('-', $key, 2); + $elementIdsBySiteId[$siteId][] = $elementId; + } + + $this->subQuery->where(function (Builder $query) use ($elementIdsBySiteId) { + foreach ($elementIdsBySiteId as $siteId => $elementIds) { + $query->orWhere(function (Builder $query) use ($siteId, $elementIds) { + $query->where('elements_sites.siteId', $siteId) + ->whereIn('elements.id', $elementIds); + }); + } + }); + + return; + } + + // Just filter the main query by the search query + $searchQuery = $searchService->createDbQuery($this->search, $this); + + if ($searchQuery === false) { + throw new QueryAbortedException(); + } + + $this->subQuery->whereIn('elements.id', $searchQuery->select('elementId')); + } + + private function applyOrderByParams(Builder $query): void + { + $orders = $query->orders; + + // Only set to the default order if `orderBy` is null + if (is_null($orders)) { + if ($this->fixedOrder) { + if (empty($this->id)) { + throw new QueryAbortedException(); + } + + $ids = $this->id; + if (!is_array($ids)) { + $ids = is_string($ids) ? str($ids)->explode(',')->all() : [$ids]; + } + + $query->orderBy(new FixedOrderExpression('elements.id', $ids)); + } elseif ($this->revisions) { + $query->orderByDesc('num'); + } elseif ($this->shouldJoinStructureData()) { + $query->orderBy('structureelements.lft'); + + foreach ($this->defaultOrderBy as $column => $direction) { + $query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); + } + } elseif (!empty($this->defaultOrderBy)) { + foreach ($this->defaultOrderBy as $column => $direction) { + $query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); + } + } else { + return; + } + } else { + $orders = array_filter($orders, fn($order) => $order['column'] !== ''); + foreach ($orders as $order) { + $query->orderBy($order['column'], $order['direction']); + } + } + + + if ($this->inReverse) { + $orders = $query->orders; + + $query->reorder(); + + foreach (array_reverse($orders) as $order) { + $query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); + } + } + } + + /** + * Applies the 'select' param to the query being executed. + */ + private function applySelectParams(Builder $query): void + { + // Select all columns defined by [[select]], swapping out any mapped column names + $select = []; + $includeDefaults = false; + + foreach ((array)$this->columns as $column) { + [$column, $alias] = explode(' as ', $column, 2) + [1 => null]; + + $alias ??= $column; + + if ($column === '**') { + $includeDefaults = true; + } else { + // Is this a mapped column name? + if (is_string($column) && isset($this->columnMap[$column])) { + $column = $this->resolveColumnMapping($column); + + // Completely ditch the mapped name if instantiated elements are going to be returned + if (! $this->asArray && is_string($column)) { + $alias = $column; + } + } + + if (is_array($column)) { + $select[] = new Alias(new Coalesce($column), $alias); + } else { + $select[] = new Alias($column, $alias); + } + } + } + + // Is there still a ** placeholder param? + if (! $includeDefaults) { + $query->columns = $select; + + return; + } + + // Merge in the default columns + $select = array_merge($select, [ + 'elements.id', + 'elements.canonicalId', + 'elements.fieldLayoutId', + 'elements.uid', + 'elements.enabled', + 'elements.archived', + 'elements.dateLastMerged', + 'elements.dateCreated', + 'elements.dateUpdated', + new Alias('elements_sites.id', 'siteSettingsId'), + 'elements_sites.siteId', + 'elements_sites.title', + 'elements_sites.slug', + 'elements_sites.uri', + 'elements_sites.content', + new Alias('elements_sites.enabled', 'enabledForSite'), + ]); + + // If the query includes soft-deleted elements, include the date deleted + if ($this->trashed !== false) { + $select[] = 'elements.dateDeleted'; + } + + $query->columns = $select; + } + + private function resolveColumnMapping(string $key): string|array + { + if (!isset($this->columnMap[$key])) { + throw new InvalidArgumentException("Invalid column map key: $key"); + } + + // make sure it's not still a callback + if (is_callable($this->columnMap[$key])) { + $this->columnMap[$key] = $this->columnMap[$key](); + } elseif (is_array($this->columnMap[$key])) { + foreach ($this->columnMap[$key] as $i => $mapping) { + if (is_callable($mapping)) { + $this->columnMap[$key][$i] = $mapping(); + } + } + } + + return $this->columnMap[$key]; + } +} diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php new file mode 100644 index 00000000000..a731bfbee53 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -0,0 +1,242 @@ + Column alias => cast type + * @see prepare() + * @see _applyOrderByParams() + */ + private array $columnsToCast = []; + + protected function initializeQueriesCustomFields(): void + { + // Gather custom fields and generated field handles + $this->customFields = []; + $this->generatedFields = []; + + if ($this->withCustomFields) { + foreach ($this->fieldLayouts() as $fieldLayout) { + foreach ($fieldLayout->getCustomFields() as $field) { + $this->customFields[] = $field; + } + foreach ($fieldLayout->getGeneratedFields() as $field) { + $this->generatedFields[] = $field; + } + } + } + + // Map custom field handles to their content values + $this->addCustomFieldsToColumnMap(); + + $this->query->beforeQuery(function () { + $this->applyCustomFieldParams(); + }); + } + + /** + * @inheritdoc + * @uses $withCustomFields + */ + public function withCustomFields(bool $value = true): static + { + $this->withCustomFields = $value; + + return $this; + } + + /** + * Returns the field layouts whose custom fields should be returned by [[customFields()]]. + * + * @return FieldLayout[] + */ + protected function fieldLayouts(): array + { + return \Craft::$app->getFields()->getLayoutsByType($this->elementType); + } + + /** + * @inheritdoc + */ + public function getFieldLayouts(): array + { + return $this->fieldLayouts(); + } + + /** + * Include custom fields in the column map + */ + private function addCustomFieldsToColumnMap(): void + { + foreach ($this->customFields as $field) { + $dbTypes = $field::dbType(); + + if ($dbTypes !== null) { + if (is_string($dbTypes)) { + $dbTypes = ['*' => $dbTypes]; + } else { + $dbTypes = [ + '*' => reset($dbTypes), + ...$dbTypes, + ]; + } + + foreach ($dbTypes as $key => $dbType) { + $alias = $field->handle . ($key !== '*' ? ".$key" : ''); + $resolver = fn() => $field->getValueSql($key !== '*' ? $key : null); + + $this->addToColumnMap($alias, $resolver); + + // for mysql, we have to make sure text column type is cast to char, otherwise it won't be sorted correctly + // see https://github.com/craftcms/cms/issues/15609 + if ($this->getConnection()->getDriverName() === 'mysql' && DbHelper::parseColumnType($dbType) === Schema::TYPE_TEXT) { + $this->columnsToCast[$alias] = 'CHAR(255)'; + } + } + } + } + + if (!empty($this->generatedFields)) { + foreach ($this->generatedFields as $field) { + if (($field['handle'] ?? '') !== '') { + $this->addToColumnMap($field['handle'], new JsonExtract('elements_sites.content', '$.'.$field['uid'])); + } + } + } + } + + private function addToColumnMap(string $alias, string|callable|Expression $column): void + { + if (! isset($this->columnMap[$alias])) { + $this->columnMap[$alias] = []; + } + + if (! is_array($this->columnMap[$alias])) { + $this->columnMap[$alias] = [$this->columnMap[$alias]]; + } + + $this->columnMap[$alias][] = $column; + } + + /** + * Allow the custom fields to modify the query. + * + * @throws QueryAbortedException + */ + private function applyCustomFieldParams(): void + { + if (empty($this->customFields) && empty($this->generatedFields)) { + return; + } + + $fieldAttributes = $this->getBehavior('customFields'); + /** @var FieldInterface[][][] $fieldsByHandle */ + $fieldsByHandle = []; + + if (!empty($this->customFields)) { + // Group the fields by handle and field UUID + foreach ($this->customFields as $field) { + $fieldsByHandle[$field->handle][$field->uid][] = $field; + } + + foreach (array_keys(CustomFieldBehavior::$fieldHandles) as $handle) { + // $fieldAttributes->$handle will return true even if it's set to null, so can't use isset() here + if ($handle === 'owner' || ($fieldAttributes->$handle ?? null) === null) { + continue; + } + + // Make sure the custom field exists in one of the field layouts + if (!isset($fieldsByHandle[$handle])) { + // If it looks like null/:empty: is a valid option, let it slide + $value = is_array($fieldAttributes->$handle) && isset($fieldAttributes->$handle['value']) + ? $fieldAttributes->$handle['value'] + : $fieldAttributes->$handle; + + if (is_array($value) && in_array(null, $value, true)) { + $values = [...$value]; + $operator = QueryParam::extractOperator($values) ?? QueryParam::OR; + if ($operator === QueryParam::OR) { + continue; + } + } + + throw new QueryAbortedException("No custom field with the handle \"$handle\" exists in the field layouts involved with this element query."); + } + + $conditions = []; + $params = []; + + foreach ($fieldsByHandle[$handle] as $instances) { + $firstInstance = $instances[0]; + $condition = $firstInstance::queryCondition($instances, $fieldAttributes->$handle, $params); + + // aborting? + if ($condition === false) { + throw new QueryAbortedException(); + } + + if ($condition !== null) { + $conditions[] = $condition; + } + } + + if (!empty($conditions)) { + if (count($conditions) === 1) { + $this->subQuery->andWhere(reset($conditions), $params); + } else { + $this->subQuery->andWhere(['or', ...$conditions], $params); + } + } + } + } + + if (!empty($this->generatedFields)) { + $generatedFieldColumns = []; + + foreach ($this->generatedFields as $field) { + $handle = $field['handle'] ?? ''; + if ($handle !== '' && isset($fieldAttributes->$handle) && !isset($fieldsByHandle[$handle])) { + $generatedFieldColumns[$handle][] = new JsonExtract('elements_sites.content', '$.'.$field['uid']); + } + } + + foreach ($generatedFieldColumns as $handle => $columns) { + $column = count($columns) === 1 + ? $columns[0] + : new Coalesce($columns)->getValue($this->subQuery->getGrammar()); + + $this->subQuery->where(DbHelper::parseParam($column, $fieldAttributes->$handle)); + } + } + } +} diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php new file mode 100644 index 00000000000..6001dbd1d2e --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -0,0 +1,409 @@ +query->beforeQuery(function (Builder $query) { + $this->applyDraftParams($query); + $this->applyRevisionParams($query); + }); + } + + private function applyDraftParams(Builder $query): void + { + if ($this->drafts === false) { + $this->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.draftId'))); + + return; + } + + $joinType = $this->drafts === true ? 'inner' : 'left'; + $this->subQuery->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); + $query->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); + + $query->addSelect([ + 'elements.draftId', + 'drafts.creatorId as draftCreatorId', + 'drafts.provisional as isProvisionalDraft', + 'drafts.name as draftName', + 'drafts.notes as draftNotes', + ]); + + if ($this->draftId) { + $this->subQuery->where('elements.draftId', $this->draftId); + } + + if ($this->draftOf === '*') { + $this->subQuery->whereNotNull('elements.canonicalId'); + } elseif (isset($this->draftOf)) { + if ($this->draftOf === false) { + $this->subQuery->whereNull('elements.canonicalId', null); + } else { + $this->subQuery->whereIn('elements.canonicalId', $this->draftOf); + } + } + + if ($this->draftCreator) { + $this->subQuery->where('drafts.creatorId', $this->draftCreator); + } + + if (isset($this->provisionalDrafts)) { + $this->subQuery->where(function (Builder $query) { + $query->whereNull('elements.draftId') + ->orWhere('drafts.provisional', $this->provisionalDrafts); + }); + } + + if ($this->canonicalsOnly) { + $this->subQuery->where(function (Builder $query) { + $query->whereNull('elements.draftId') + ->orWhere(function (Builder $query) { + $query + ->whereNull('elements.canonicalId') + ->when( + $this->savedDraftsOnly, + fn(Builder $q) => $q->where('drafts.saved', true) + ); + }); + }); + } elseif ($this->savedDraftsOnly) { + $this->subQuery->where(function (Builder $query) { + $query->whereNull('elements.draftId') + ->orWhereNotNull('elements.canonicalId') + ->orWhere('drafts.saved', true); + }); + } + } + + private function applyRevisionParams(Builder $query): void + { + if ($this->revisions === false) { + $this->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.revisionId'))); + + return; + } + + $joinType = $this->revisions === true ? 'inner' : 'left'; + $this->subQuery->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); + $query->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); + + $query->addSelect([ + 'elements.revisionId', + 'revisions.creatorId as revisionCreatorId', + 'revisions.num as revisionNum', + 'revisions.notes as revisionNotes', + ]); + + if ($this->revisionId) { + $this->subQuery->where('elements.revisionId', $this->revisionId); + } + + if ($this->revisionOf) { + $this->subQuery->where('elements.canonicalId', $this->revisionOf); + } + + if ($this->revisionCreator) { + $this->subQuery->where('revisions.creatorId', $this->revisionCreator); + } + } + + /** + * @inheritdoc + * @uses $drafts + */ + public function drafts(?bool $value = true): static + { + $this->drafts = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $withProvisionalDrafts + */ + public function withProvisionalDrafts(bool $value = true): static + { + $this->withProvisionalDrafts = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $draftId + * @uses $drafts + */ + public function draftId(?int $value = null): static + { + $this->draftId = $value; + + if ($value !== null && $this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + + /** + * @inheritdoc + * @uses $draftOf + * @uses $drafts + */ + public function draftOf($value): static + { + if ($value instanceof ElementInterface) { + $this->draftOf = $value->getCanonicalId(); + + if ($this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + + if ( + is_numeric($value) || + (is_array($value) && Arr::isNumeric($value)) || + $value === '*' || + $value === false || + $value === null + ) { + $this->draftOf = $value; + + if ($value !== null && $this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + + if (is_array($value) && !empty($value)) { + $c = Collection::make($value); + if ($c->every(fn($v) => $v instanceof ElementInterface || is_numeric($v))) { + $this->draftOf = $c->map(fn($v) => $v instanceof ElementInterface ? $v->id : $v)->all(); + + if ($this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + } + + throw new InvalidArgumentException('Invalid draftOf value'); + } + + /** + * @inheritdoc + * @uses $draftCreator + * @uses $drafts + */ + public function draftCreator($value): static + { + $this->draftCreator = match(true) { + $value instanceof User => $value->id, + is_numeric($value) || $value === null => $value, + default => throw new InvalidArgumentException('Invalid draftCreator value'), + }; + + if ($this->draftCreator !== null && $this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + + /** + * @inheritdoc + * @uses $provisionalDrafts + * @uses $drafts + */ + public function provisionalDrafts(?bool $value = true): static + { + $this->provisionalDrafts = $value; + + if ($value === true && $this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + + /** + * @inheritdoc + * @uses $canonicalsOnly + */ + public function canonicalsOnly(bool $value = true): static + { + $this->canonicalsOnly = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $savedDraftsOnly + */ + public function savedDraftsOnly(bool $value = true): static + { + $this->savedDraftsOnly = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $revisions + */ + public function revisions(?bool $value = true): static + { + $this->revisions = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $revisionId + * @uses $revisions + */ + public function revisionId(?int $value = null): static + { + $this->revisionId = $value; + + if ($value !== null && $this->revisions === false) { + $this->revisions = true; + } + + return $this; + } + + /** + * @inheritdoc + * @uses $revisionOf + * @uses $revisions + */ + public function revisionOf($value): static + { + $this->revisionOf = match(true) { + $value instanceof ElementInterface => $value->getCanonicalId(), + is_numeric($value) || $value === null => $value, + default => throw new InvalidArgumentException('Invalid revisionOf value'), + }; + + if ($this->revisionOf !== null && $this->revisions === false) { + $this->revisions = true; + } + + return $this; + } + + /** + * @inheritdoc + * @uses $revisionCreator + * @uses $revisions + */ + public function revisionCreator($value): static + { + $this->revisionCreator = match(true) { + $value instanceof User => $value->id, + is_numeric($value) || $value === null => $value, + default => throw new InvalidArgumentException('Invalid revisionCreator value'), + }; + + if ($this->revisionCreator !== null && $this->revisions === false) { + $this->revisions = true; + } + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php new file mode 100644 index 00000000000..e603e634693 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -0,0 +1,197 @@ +query->afterQuery(function (Collection $elements) { + if ($this->with) { + $elementsService = \Craft::$app->getElements(); + $elementsService->eagerLoadElements($this->elementType, $elements->all(), $this->with); + } + + return $elements; + }); + } + + /** + * @inheritdoc + * @uses $with + */ + public function with(array|string|null $value): static + { + $this->with = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $with + */ + public function andWith(array|string|null $value): static + { + if (empty($this->with)) { + $this->with = [$value]; + + return $this; + } + + if (is_string($this->with)) { + $this->with = str($this->with)->explode(',')->all(); + } + + $this->with[] = $value; + + return $this; + } + + /** + * @inheritdoc + */ + public function eagerly(string|bool $value = true): static + { + $this->eagerly = $value !== false; + $this->eagerLoadAlias = is_string($value) ? $value : null; + + return $this; + } + + /** + * @inheritdoc + */ + public function prepForEagerLoading(string $handle, ElementInterface $sourceElement): static + { + // Prefix the handle with the provider's handle, if there is one + $providerHandle = $sourceElement->getFieldLayout()?->provider?->getHandle(); + $this->eagerLoadHandle = $providerHandle ? "$providerHandle:$handle" : $handle; + + $this->eagerLoadSourceElement = $sourceElement; + + return $this; + } + + /** + * @inheritdoc + */ + public function wasEagerLoaded(?string $alias = null): bool + { + if (!isset($this->eagerLoadHandle, $this->eagerLoadSourceElement)) { + return false; + } + + if ($alias !== null) { + return $this->eagerLoadSourceElement->hasEagerLoadedElements($alias); + } + + $planHandle = $this->eagerLoadHandle; + if (str_contains($planHandle, ':')) { + $planHandle = explode(':', $planHandle, 2)[1]; + } + return $this->eagerLoadSourceElement->hasEagerLoadedElements($planHandle); + } + + /** + * @inheritdoc + */ + public function wasCountEagerLoaded(?string $alias = null): bool + { + if (!isset($this->eagerLoadHandle, $this->eagerLoadSourceElement)) { + return false; + } + + if ($alias !== null) { + return $this->eagerLoadSourceElement->getEagerLoadedElementCount($alias) !== null; + } + + $planHandle = $this->eagerLoadHandle; + if (str_contains($planHandle, ':')) { + $planHandle = explode(':', $planHandle, 2)[1]; + } + return $this->eagerLoadSourceElement->getEagerLoadedElementCount($planHandle) !== null; + } + + private function eagerLoad(bool $count = false, array $criteria = []): Collection|int|null + { + if ( + !$this->eagerly || + !isset($this->eagerLoadSourceElement->elementQueryResult, $this->eagerLoadHandle) || + count($this->eagerLoadSourceElement->elementQueryResult) < 2 + ) { + return null; + } + + $alias = $this->eagerLoadAlias ?? "eagerly:$this->eagerLoadHandle"; + + // see if it was already eager-loaded + $eagerLoaded = match ($count) { + true => $this->wasCountEagerLoaded($alias), + false => $this->wasEagerLoaded($alias), + }; + + if (!$eagerLoaded) { + \Craft::$app->getElements()->eagerLoadElements( + $this->eagerLoadSourceElement::class, + $this->eagerLoadSourceElement->elementQueryResult, + [ + new EagerLoadPlan([ + 'handle' => $this->eagerLoadHandle, + 'alias' => $alias, + 'criteria' => $criteria + $this->getCriteria() + ['with' => $this->with], + 'all' => !$count, + 'count' => $count, + 'lazy' => true, + ]), + ], + ); + } + + if ($count) { + return $this->eagerLoadSourceElement->getEagerLoadedElementCount($alias); + } + + return $this->eagerLoadSourceElement->getEagerLoadedElements($alias); + } +} diff --git a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php new file mode 100644 index 00000000000..90f5384fd4a --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php @@ -0,0 +1,77 @@ +ignorePlaceholders = $value; + + return $this; + } + + /** + * Combines the given condition with an alternative condition if there are any relevant placeholder elements. + * + * @param Closure(Builder): Builder $condition + * @return Closure(Builder): Builder + */ + protected function placeholderCondition(Closure $condition): Closure + { + if ($this->ignorePlaceholders) { + return $condition; + } + + if (!isset($this->placeholderCondition) || $this->siteId !== $this->placeholderSiteIds) { + $placeholderSourceIds = []; + $placeholderElements = \Craft::$app->getElements()->getPlaceholderElements(); + if (!empty($placeholderElements)) { + $siteIds = array_flip((array)$this->siteId); + foreach ($placeholderElements as $element) { + if ($element instanceof $this->elementType && isset($siteIds[$element->siteId])) { + $placeholderSourceIds[] = $element->getCanonicalId(); + } + } + } + + if (!empty($placeholderSourceIds)) { + $this->placeholderCondition = fn (Builder $q) => $q->whereIn('elements.id', $placeholderSourceIds); + } else { + $this->placeholderCondition = false; + } + $this->placeholderSiteIds = is_array($this->siteId) ? array_merge($this->siteId) : $this->siteId; + } + + if ($this->placeholderCondition === false) { + return $condition; + } + + return fn (Builder $q) => $q->where($condition($q))->orWhere($this->placeholderCondition); + } +} diff --git a/src/Database/Queries/Concerns/QueriesRelatedElements.php b/src/Database/Queries/Concerns/QueriesRelatedElements.php new file mode 100644 index 00000000000..1e62addf8f8 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -0,0 +1,171 @@ +applyRelatedToParam(); + $this->applyNotRelatedToParam(); + } + + private function applyRelatedToParam(): void + { + if (!$this->relatedTo) { + return; + } + + $this->query->beforeQuery(function (Builder $query) { + $parser = new ElementRelationParamParser([ + 'fields' => $this->customFields ? Arr::keyBy( + $this->customFields, + fn(FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, + ) : [], + ]); + + $condition = $parser->parse($this->relatedTo, $this->siteId !== '*' ? $this->siteId : null); + + if ($condition === false) { + throw new QueryAbortedException(); + } + + $this->subQuery->where($condition); + }); + } + + private function applyNotRelatedToParam(): void + { + if (! $this->notRelatedTo) { + return; + } + + $this->query->beforeQuery(function () { + $notRelatedToParam = $this->notRelatedTo; + + $parser = new ElementRelationParamParser([ + 'fields' => $this->customFields ? Arr::keyBy( + $this->customFields, + fn(FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, + ) : [], + ]); + + $condition = $parser->parse($notRelatedToParam, $this->siteId !== '*' ? $this->siteId : null); + + if ($condition === false) { + // just don't modify the query + return; + } + + $this->subQuery->whereNot($condition); + }); + } + + /** + * @inheritdoc + * @uses $notRelatedTo + */ + public function notRelatedTo($value): static + { + $this->notRelatedTo = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $notRelatedTo + */ + public function andNotRelatedTo($value): static + { + $relatedTo = $this->_andRelatedToCriteria($value, $this->notRelatedTo); + + if ($relatedTo === false) { + return $this; + } + + return $this->notRelatedTo($relatedTo); + } + + /** + * @inheritdoc + * @uses $relatedTo + */ + public function relatedTo($value): static + { + $this->relatedTo = $value; + return $this; + } + + /** + * @inheritdoc + * @throws NotSupportedException + * @uses $relatedTo + */ + public function andRelatedTo($value): static + { + $relatedTo = $this->_andRelatedToCriteria($value, $this->relatedTo); + + if ($relatedTo === false) { + return $this; + } + + return $this->relatedTo($relatedTo); + } + + /** + * @param $value + * @param $currentValue + * + * @return mixed + * @throws NotSupportedException + */ + private function _andRelatedToCriteria($value, $currentValue): mixed + { + if (!$value) { + return false; + } + + if (!$currentValue) { + return $value; + } + + // Normalize so element/targetElement/sourceElement values get pushed down to the 2nd level + $relatedTo = ElementRelationParamParser::normalizeRelatedToParam($currentValue); + $criteriaCount = count($relatedTo) - 1; + + // Not possible to switch from `or` to `and` if there are multiple criteria + if ($relatedTo[0] === 'or' && $criteriaCount > 1) { + throw new NotSupportedException('It’s not possible to combine “or” and “and” relatedTo conditions.'); + } + + $relatedTo[0] = $criteriaCount > 0 ? 'and' : 'or'; + $relatedTo[] = ElementRelationParamParser::normalizeRelatedToCriteria($value); + + return $relatedTo; + } + +} diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php new file mode 100644 index 00000000000..79b475f4a4e --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -0,0 +1,169 @@ +query->beforeQuery(function () { + // Make sure the siteId param is set + try { + if (!$this->elementType::isLocalized()) { + // The criteria *must* be set to the primary site ID + $this->siteId = \Craft::$app->getSites()->getPrimarySite()->id; + } else { + $this->normalizeSiteId(); + } + } catch (SiteNotFoundException $e) { + // Fail silently if Craft isn't installed yet or is in the middle of updating + if (\Craft::$app->getIsInstalled() && !\Craft::$app->getUpdates()->getIsCraftUpdatePending()) { + throw $e; + } + + throw new QueryAbortedException($e->getMessage(), 0, $e); + } + + if (\Craft::$app->getIsMultiSite(false, true)) { + $this->subQuery->where('elements_sites.siteId', $this->siteId); + } + }); + } + + /** + * @inheritdoc + * @throws InvalidArgumentException if $value is invalid + * @uses $siteId + */ + public function site($value): static + { + if ($value === null) { + $this->siteId = null; + } elseif ($value === '*') { + $this->siteId = \Craft::$app->getSites()->getAllSiteIds(); + } elseif ($value instanceof Site) { + $this->siteId = $value->id; + } elseif (is_string($value)) { + $this->siteId = \Craft::$app->getSites()->getSiteByHandle($value)?->id ?? throw new InvalidArgumentException('Invalid site handle: ' . $value); + } else { + if ($not = (strtolower(reset($value)) === 'not')) { + array_shift($value); + } + + $this->siteId = []; + + foreach (\Craft::$app->getSites()->getAllSites() as $site) { + if (in_array($site->handle, $value, true) === !$not) { + $this->siteId[] = $site->id; + } + } + + if (empty($this->siteId)) { + throw new InvalidArgumentException('Invalid site param: [' . ($not ? 'not, ' : '') . implode(', ', + $value) . ']'); + } + } + + return $this; + } + + /** + * @inheritdoc + * @uses $siteId + */ + public function siteId($value): static + { + if (is_array($value) && strtolower(reset($value)) === 'not') { + array_shift($value); + + $this->siteId = []; + + foreach (\Craft::$app->getSites()->getAllSites() as $site) { + if (!in_array($site->id, $value)) { + $this->siteId[] = $site->id; + } + } + + return $this; + } + + $this->siteId = $value; + + return $this; + } + + /** + * @inheritdoc + * @return static + * @uses $siteId + */ + public function language($value): self + { + if (is_string($value)) { + $sites = \Craft::$app->getSites()->getSitesByLanguage($value); + + if (empty($sites)) { + throw new InvalidArgumentException("Invalid language: $value"); + } + + $this->siteId = array_map(fn(Site $site) => $site->id, $sites); + + return $this; + } + + if ($not = (strtolower(reset($value)) === 'not')) { + array_shift($value); + } + + $this->siteId = []; + + foreach (\Craft::$app->getSites()->getAllSites() as $site) { + if (in_array($site->language, $value, true) === !$not) { + $this->siteId[] = $site->id; + } + } + + if (empty($this->siteId)) { + throw new InvalidArgumentException('Invalid language param: [' . ($not ? 'not, ' : '') . implode(', ', + $value) . ']'); + } + + return $this; + } + + /** + * Normalizes the siteId param value. + */ + private function normalizeSiteId(): void + { + $sitesService = \Craft::$app->getSites(); + if (!$this->siteId) { + // Default to the current site + $this->siteId = $sitesService->getCurrentSite()->id; + } elseif ($this->siteId === '*') { + $this->siteId = $sitesService->getAllSiteIds(); + } elseif (is_numeric($this->siteId) || Arr::isNumeric($this->siteId)) { + // Filter out any invalid site IDs + $siteIds = Collection::make((array)$this->siteId) + ->filter(fn($siteId) => $sitesService->getSiteById($siteId, true) !== null) + ->all(); + if (empty($siteIds)) { + throw new QueryAbortedException(); + } + $this->siteId = is_array($this->siteId) ? $siteIds : reset($siteIds); + } + } +} diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php new file mode 100644 index 00000000000..439d79d15cf --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -0,0 +1,146 @@ +query->beforeQuery(function () { + if ($this->archived) { + $this->subQuery->where('elements.archived', true); + return; + } + + $this->applyStatusParam(); + + // only set archived=false if 'archived' doesn't show up in the status param + // (_applyStatusParam() will normalize $this->status to an array if applicable) + if (!is_array($this->status) || !in_array($this->elementType::STATUS_ARCHIVED, $this->status)) { + $this->subQuery->where('elements.archived', false); + } + }); + } + + /** + * @inheritdoc + * @uses $archived + */ + public function archived(bool $value = true): static + { + $this->archived = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $status + */ + public function status(array|string|null $value): static + { + $this->status = $value; + + return $this; + } + + /** + * Applies the 'status' param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applyStatusParam(): void + { + if (!$this->status || !$this->elementType::hasStatuses()) { + return; + } + + // Normalize the status param + if (! is_array($this->status)) { + $this->status = str($this->status)->explode(',')->all(); + } + + $statuses = array_merge($this->status); + + $firstVal = strtolower(reset($statuses)); + if (in_array($firstVal, ['not', 'or'])) { + $glue = $firstVal; + array_shift($statuses); + if (!$statuses) { + return; + } + } else { + $glue = 'or'; + } + + if ($negate = ($glue === 'not')) { + $glue = 'and'; + } + + $this->subQuery->where(function (Builder $query) use ($statuses, $negate, $glue) { + foreach ($statuses as $status) { + $query->where( + column: $this->placeholderCondition($this->statusCondition($status)), + operator: $negate ? '!=' : '=', + boolean: $glue, + ); + } + }); + } + + /** + * Returns the condition that should be applied to the element query for a given status. + * + * For example, if you support a status called “pending”, which maps back to a `pending` database column that will + * either be 0 or 1, this method could do this: + * + * ```php + * protected function statusCondition($status) + * { + * switch ($status) { + * case 'pending': + * return fn (Builder $q) => $q->where('mytable.pending', true); + * default: + * return parent::statusCondition($status); + * } + * ``` + * + * @param string $status The status + * + * @return Closure(Builder): Builder The status condition, or false if $status is an unsupported status + * @throws \CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException on unsupported status. + */ + protected function statusCondition(string $status): Closure + { + $status = strtolower($status); + + return match ($status) { + Element::STATUS_ENABLED => fn (Builder $q) => $q->where('elements.enabled', true)->where('elements_sites.enabled', true), + Element::STATUS_DISABLED => fn (Builder $q) => $q->where('elements.enabled', false)->orWhere('elements_sites.enabled', false), + Element::STATUS_ARCHIVED => fn (Builder $q) => $q->where('elements.archived', true), + default => throw new QueryAbortedException('Unsupported status: ' . $status), + }; + } +} diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php new file mode 100644 index 00000000000..c1383d422ed --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -0,0 +1,491 @@ +query->beforeQuery(function (Builder $query) { + $this->applyStructureParams($query); + }); + + $this->query->afterQuery(function (Collection $collection) { + if ($this->structureId) { + return $collection->map(function ($element) { + $element->structureId = $this->structureId; + }); + } + + return $collection; + }); + } + + /** + * @inheritdoc + * @uses $withStructure + */ + public function withStructure(bool $value = true): static + { + $this->withStructure = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $structureId + */ + public function structureId(?int $value = null): static + { + $this->structureId = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $level + */ + public function level($value = null): static + { + $this->level = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $hasDescendants + */ + public function hasDescendants(bool $value = true): static + { + $this->hasDescendants = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $leaves + */ + public function leaves(bool $value = true): static + { + $this->leaves = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $ancestorOf + */ + public function ancestorOf(ElementInterface|int|null $value): static + { + $this->ancestorOf = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $ancestorDist + */ + public function ancestorDist(?int $value = null): static + { + $this->ancestorDist = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $descendantOf + */ + public function descendantOf(ElementInterface|int|null $value): static + { + $this->descendantOf = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $descendantDist + */ + public function descendantDist(?int $value = null): static + { + $this->descendantDist = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $siblingOf + */ + public function siblingOf(ElementInterface|int|null $value): static + { + $this->siblingOf = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $prevSiblingOf + */ + public function prevSiblingOf(ElementInterface|int|null $value): static + { + $this->prevSiblingOf = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $nextSiblingOf + */ + public function nextSiblingOf(ElementInterface|int|null $value): static + { + $this->nextSiblingOf = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $positionedBefore + */ + public function positionedBefore(ElementInterface|int|null $value): static + { + $this->positionedBefore = $value; + return $this; + } + + /** + * @inheritdoc + * @uses $positionedAfter + */ + public function positionedAfter(ElementInterface|int|null $value): static + { + $this->positionedAfter = $value; + return $this; + } + + private function shouldJoinStructureData(): bool + { + return ( + !$this->revisions && + ($this->withStructure ?? ($this->structureId && !$this->trashed)) + ); + } + + private function applyStructureParams(Builder $query): void + { + if (! $this->shouldJoinStructureData()) { + $structureParams = [ + 'hasDescendants', + 'ancestorOf', + 'descendantOf', + 'siblingOf', + 'prevSiblingOf', + 'nextSiblingOf', + 'positionedBefore', + 'positionedAfter', + 'level', + ]; + + foreach ($structureParams as $param) { + if ($this->$param !== null) { + throw new QueryAbortedException("Unable to apply the '$param' param because 'structureId' isn't set"); + } + } + + return; + } + + $this->query->addSelect([ + 'structureelements.root', + 'structureelements.lft', + 'structureelements.rgt', + 'structureelements.level', + ]); + + if ($this->structureId) { + $this->query->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join + ->whereColumn('structureelements.elementId', 'subquery.elementsId') + ->where('structureelements.structureId', $this->structureId)); + + $this->subQuery->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join + ->whereColumn('structureelements.elementId', 'subquery.elementsId') + ->where('structureelements.structureId', $this->structureId)); + } else { + $this->query + ->addSelect('structureelements.structureId') + ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join + ->whereColumn('structureelements.elementId', 'subquery.elementsId') + ->whereColumn('structureelements.structureId', 'subquery.structureId')); + + $existsQuery = DB::table(Table::STRUCTURES) + // Use index hints to specify index so Mysql does not select the less + // performant one (dateDeleted). + ->when( + DB::getDriverName() === 'mysql', + fn (Builder $query) => $query->useIndex('primary'), + ) + ->whereColumn('id', 'structureelements.structureId') + ->whereNull('dateDeleted'); + + $this->subQuery + ->addSelect('structureelements.structureId as sructureId') + ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join + ->whereColumn('structureelements.elementId', 'elements.id') + ->whereExists($existsQuery) + ); + } + + if (isset($this->hasDescendants)) { + $this->subQuery->when( + $this->hasDescendants, + fn(Builder $query) => $query->where('structureelements.rgt', '>', DB::raw('structureelements.lft + 1')), + fn(Builder $query) => $query->where('structureelements.rgt', '=', DB::raw('structureelements.lft + 1')), + ); + } + + if ($this->ancestorOf) { + $ancestorOf = $this->normalizeStructureParamValue('ancestorOf'); + + $this->subQuery + ->where('structureelements.lft', '<', $ancestorOf->lft) + ->where('structureelements.rgt', '>', $ancestorOf->rgt) + ->where('structureelements.root', '>', $ancestorOf->root) + ->when( + $this->ancestorDist, + fn (Builder $q) => $q->where('structureelements.level', '>=', $ancestorOf->level - $this->ancestorDist) + ); + } + + if ($this->descendantOf) { + $descendantOf = $this->normalizeStructureParamValue('descendantOf'); + + $this->subQuery + ->where('structureelements.lft', '>', $descendantOf->lft) + ->where('structureelements.rgt', '<', $descendantOf->rgt) + ->where('structureelements.root', $descendantOf->root) + ->when( + $this->descendantDist, + fn (Builder $q) => $q->where('structureelements.level', '<=', $descendantOf->level + $this->descendantDist) + ); + } + + foreach (['siblingOf', 'prevSiblingOf', 'nextSiblingOf'] as $param) { + if (! $this->$param) { + continue; + } + + $siblingOf = $this->normalizeStructureParamValue($param); + + $this->subQuery + ->where('structureelements.level', $siblingOf->level) + ->where('structureelements.root', $siblingOf->root) + ->whereNot('structureelements.elementId', $siblingOf->id); + + if ($siblingOf->level !== 1) { + $parent = $siblingOf->getParent(); + + if (! $parent) { + throw new QueryAbortedException(); + } + + $this->subQuery + ->where('structureelements.lft', '>', $parent->lft) + ->where('structureelements.rgt', '>', $parent->rgt); + } + + switch ($param) { + case 'prevSiblingOf': + $this->query->orderByDesc('structureelements.lft'); + $this->subQuery + ->where('structureelements.lft', '<', $siblingOf->lft) + ->orderByDesc('structureelements.lft') + ->limit(1); + break; + case 'nextSiblingOf': + $this->query->orderBy('structureelements.lft'); + $this->subQuery + ->where('structureelements.lft', '>', $siblingOf->lft) + ->orderBy('structureelements.lft') + ->limit(1); + break; + } + } + + if ($this->positionedBefore) { + $positionedBefore = $this->normalizeStructureParamValue('positionedBefore'); + + $this->subQuery + ->where('structureelements.lft', '<', $positionedBefore->lft) + ->where('structureelements.root', $positionedBefore->root); + } + + if ($this->positionedAfter) { + $positionedAfter = $this->normalizeStructureParamValue('positionedAfter'); + + $this->subQuery + ->where('structureelements.lft', '>', $positionedAfter->rgt) + ->where('structureelements.root', $positionedAfter->root); + } + + if ($this->level) { + $allowNull = is_array($this->level) && in_array(null, $this->level, true); + + $this->subQuery->when( + $allowNull, + fn (Builder $q) => $q->where(function (Builder $q) { + $q->where(Db::parseNumericParam('structureelements.level', array_filter($this->level, fn($v) => $v !== null))) + ->orWhereNull('structureelements.level'); + }), + fn (Builder $q) => Db::parseNumericParam('structureelements.level', $this->level), + ); + } + + if ($this->leaves) { + $this->subQuery->where('structureelements.rgt', DB::raw('structureelements.lft + 1')); + } + } + + /** + * Normalizes a structure param value to either an Element object or false. + * + * @param string $property The parameter’s property name. + * @param class-string $class The element class + * @return ElementInterface The normalized element + * @throws QueryAbortedException if the element can't be found + */ + private function normalizeStructureParamValue(string $property): ElementInterface + { + $element = $this->$property; + + if ($element === false) { + throw new QueryAbortedException(); + } + + if ($element instanceof ElementInterface && !$element->lft) { + $element = $element->getCanonicalId(); + + if ($element === null) { + throw new QueryAbortedException(); + } + } + + if (!$element instanceof ElementInterface) { + $element = \Craft::$app->getElements()->getElementById($element, $this->elementType, $this->siteId, [ + 'structureId' => $this->structureId, + ]); + + if ($element === null) { + $this->$property = false; + throw new QueryAbortedException(); + } + } + + if (! $element->lft) { + if ($element->getIsDerivative()) { + $element = $element->getCanonical(true); + } + + if (! $element->lft) { + $this->$property = false; + throw new QueryAbortedException(); + } + } + + return $this->$property = $element; + } +} diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php new file mode 100644 index 00000000000..58e36c805a6 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -0,0 +1,107 @@ +unique || + !\Craft::$app->getIsMultiSite(false, true) || + ( + $this->siteId && + (!is_array($this->siteId) || count($this->siteId) === 1) + ) + ) { + return; + } + + $sitesService = \Craft::$app->getSites(); + + if (!$this->preferSites) { + $preferSites = [$sitesService->getCurrentSite()->id]; + } else { + $preferSites = []; + foreach ($this->preferSites as $preferSite) { + if (is_numeric($preferSite)) { + $preferSites[] = $preferSite; + } elseif ($site = $sitesService->getSiteByHandle($preferSite)) { + $preferSites[] = $site->id; + } + } + } + + $cases = []; + + foreach ($preferSites as $index => $siteId) { + $cases[] = new CaseRule(new Value($index), new Equal('elements_sites.siteId', new Value($siteId))); + } + + $caseGroup = new CaseGroup($cases, new Value(count($preferSites))); + + $subSelectSql = $this->subQuery->clone() + ->select(['elements_sites.id']) + ->whereColumn('subElements.id', 'tmpElements.id') + ->orderBy($caseGroup) + ->orderBy('elements_sites.id') + ->offset(0) + ->limit(1) + ->toRawSql(); + + // `elements` => `subElements` + $qElements = DB::getTablePrefix() . 'Concerns' . Table::ELEMENTS; + $qSubElements = DB::getTablePrefix() . '.subElements'; + $qTmpElements = DB::getTablePrefix() . '.tmpElements'; + $q = $qElements[0]; + $subSelectSql = str_replace("$qElements.", "$qSubElements.", $subSelectSql); + $subSelectSql = str_replace("$q $qElements", "$q $qSubElements", $subSelectSql); + $subSelectSql = str_replace($qTmpElements, $qElements, $subSelectSql); + + $this->subQuery->where(DB::raw("elements_sites.id = ($subSelectSql)")); + } + + /** + * @inheritdoc + * @return static + * @uses $unique + */ + public function unique(bool $value = true): static + { + $this->unique = $value; + + return $this; + } + + /** + * @inheritdoc + * @uses $preferSites + */ + public function preferSites(?array $value = null): static + { + $this->preferSites = $value; + + return $this; + } +} diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php new file mode 100644 index 00000000000..5333a7ebd4e --- /dev/null +++ b/src/Database/Queries/ElementQuery.php @@ -0,0 +1,692 @@ + */ + use BuildsQueries; + use ForwardsCalls; + + use Concerns\QueriesCustomFields; + use Concerns\QueriesDraftsAndRevisions; + use Concerns\QueriesEagerly; + use Concerns\QueriesPlaceholderElements; + use Concerns\QueriesRelatedElements; + use Concerns\QueriesSites; + use Concerns\QueriesStatuses; + use Concerns\QueriesStructures; + use Concerns\QueriesUniqueElements; + use Concerns\ElementQueryTrait; + + /** + * The base query builder instance. + */ + protected Builder $query; + + /** + * The subquery that the main query will select from. + */ + protected Builder $subQuery; + + /** + * The element being queried. + * + * @var class-string + */ + protected string $elementType; + + /** + * The table to be joined to elements. + */ + protected string $table = Table::ELEMENTS; + + /** + * All of the globally registered builder macros. + */ + protected static array $macros = []; + + /** + * All of the locally registered builder macros. + */ + protected array $localMacros = []; + + /** + * The properties that should be returned from query builder. + * + * @var string[] + * @see \Illuminate\Database\Eloquent\Builder::$propertyPassthru for inspiration. + */ + protected array $propertyPassthru = [ + 'from', + ]; + + /** + * The methods that should be returned from query builder. + * + * @var string[] + * @see \Illuminate\Database\Eloquent\Builder::$passthru for inspiration. + */ + protected array $passthru = [ + 'aggregate', + 'average', + 'avg', + 'count', + 'dd', + 'ddrawsql', + 'doesntexist', + 'doesntexistor', + 'dump', + 'dumprawsql', + 'exists', + 'existsor', + 'explain', + 'getbindings', + 'getconnection', + 'getcountforpagination', + 'getgrammar', + 'getrawbindings', + 'implode', + 'insert', + 'insertgetid', + 'insertorignore', + 'insertusing', + 'insertorignoreusing', + 'max', + 'min', + 'numericaggregate', + 'pluck', + 'raw', + 'rawvalue', + 'sum', + 'tosql', + 'torawsql', + 'value', + ]; + + /** + * The callbacks that should be invoked after retrieving data from the database. + */ + protected array $afterQueryCallbacks = []; + + /** + * The callbacks that should be invoked on clone. + */ + protected array $onCloneCallbacks = []; + + // Use ** as a placeholder for "all the default columns" + protected array $columns = ['**']; + + /** + * Create a new Element query instance. + * + * @param class-string $elementType + */ + public function __construct(string $elementType) + { + $this->elementType = $elementType; + + $this->query = DB::query(); + + // Build the query + // --------------------------------------------------------------------- + $this->subQuery = DB::table(Table::ELEMENTS, 'elements'); + + // Prepare a new column mapping + // (for use in SELECT and ORDER BY clauses) + $this->columnMap = [ + 'id' => 'elements.id', + 'enabled' => 'elements.enabled', + 'dateCreated' => 'elements.dateCreated', + 'dateUpdated' => 'elements.dateUpdated', + 'uid' => 'elements.uid', + ]; + + if ($this->elementType::hasTitles()) { + $this->columnMap[] = 'elements_sites.title as title'; + } + + $this->query->beforeQuery(fn (Builder $builder) => $this->elementQueryBeforeQuery($builder)); + + $this->initializeTraits(); + + $this->query->afterQuery(fn (Collection $collection) => $this->elementQueryAfterQuery($collection)); + } + + protected function initializeTraits(): void + { + $class = static::class; + + $uses = class_uses_recursive($class); + + $conventionalInitMethods = array_map(static fn ($trait) => 'initialize'.class_basename($trait), $uses); + + foreach (new ReflectionClass($class)->getMethods() as $method) { + if (in_array($method->getName(), $conventionalInitMethods)) { + $this->{$method->getName()}(); + } + } + } + + /** + * Create a collection of elements from plain arrays. + * + * @param array $items + * @return \Illuminate\Database\Eloquent\Collection + */ + public function hydrate(array $items): Collection + { + return new Collection(array_map(function ($item) { + // @TODO: Actually populate + return new $this->elementType; + }, $items)); + } + + /** + * Create a collection of models from a raw query. + * + * @param string $query + * @param array $bindings + * @return Collection + */ + public function fromQuery($query, $bindings = []): Collection + { + return $this->hydrate( + $this->query->getConnection()->select($query, $bindings) + ); + } + + /** + * Find a model by its primary key. + * + * @param mixed $id + * @param array|string $columns + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TElement|null) + */ + public function find($id, $columns = ['*']): ElementInterface|Collection|null + { + if (is_array($id) || $id instanceof Arrayable) { + return $this->findMany($id, $columns); + } + + return $this->where('id', $id)->first($columns); + } + + /** + * Find multiple elements by their primary keys. + * + * @param \Illuminate\Contracts\Support\Arrayable|array $ids + * @param array|string $columns + * @return \Illuminate\Database\Eloquent\Collection + */ + public function findMany($ids, $columns = ['*']): Collection + { + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return new Collection; + } + + return $this->whereIn('id', $ids)->get($columns); + } + + /** + * Find a model by its primary key or throw an exception. + * + * @param mixed $id + * @param array|string $columns + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TElement) + * + * @throws ElementNotFoundException + */ + public function findOrFail($id, $columns = ['*']): ElementInterface|Collection + { + $result = $this->find($id, $columns); + + $id = $id instanceof Arrayable ? $id->toArray() : $id; + + if (is_array($id)) { + if (count($result) !== count(array_unique($id))) { + throw (new ElementNotFoundException)->setElement( + $this->elementType, array_diff($id, $result->modelKeys()) + ); + } + + return $result; + } + + if (is_null($result)) { + throw (new ElementNotFoundException)->setElement( + $this->elementType, $id + ); + } + + return $result; + } + + /** + * Find a model by its primary key or call a callback. + * + * @template TValue + * + * @param mixed $id + * @param (\Closure(): TValue)|list|string $columns + * @param (\Closure(): TValue)|null $callback + * @return ( + * $id is (\Illuminate\Contracts\Support\Arrayable|array) + * ? \Illuminate\Database\Eloquent\Collection + * : TElement|TValue + * ) + */ + public function findOr($id, $columns = ['*'], ?Closure $callback = null): mixed + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->find($id, $columns))) { + return $model; + } + + return $callback(); + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|string $columns + * @return TElement + * + * @throws ElementNotFoundException + */ + public function firstOrFail($columns = ['*']) + { + if (! is_null($model = $this->first($columns))) { + return $model; + } + + throw (new ElementNotFoundException)->setElement($this->elementType); + } + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (\Closure(): TValue)|list $columns + * @param (\Closure(): TValue)|null $callback + * @return TElement|TValue + */ + public function firstOr($columns = ['*'], ?Closure $callback = null): mixed + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->first($columns))) { + return $model; + } + + return $callback(); + } + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|string $columns + * @return TElement + * + * @throws ElementNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function sole($columns = ['*']): mixed + { + try { + return $this->baseSole($columns); + } catch (RecordsNotFoundException) { + throw (new ElementNotFoundException)->setElement($this->elementType); + } + } + + /** + * Execute the query as a "select" statement. + * + * @param array|string $columns + * @return \Illuminate\Database\Eloquent\Collection + */ + public function get($columns = ['*']): Collection + { + $models = $this->getModels($columns); + + return $this->applyAfterQueryCallbacks(new Collection($models)); + } + + /** + * Get the hydrated elements + * + * @param array|string $columns + * @return array + */ + public function getModels($columns = ['*']): array + { + return $this->hydrate( + $this->query->get($columns)->all() + )->all(); + } + + public function all(array|string $columns = ['*']): Collection + { + return $this->get($columns); + } + + public function one(array|string $columns = ['*']): ?ElementInterface + { + return $this->first($columns); + } + + public function pluck($column, $key = null) + { + $column = $this->columnMap[$column] ?? $column; + + return $this->query->pluck($column, $key); + } + + /** + * Register a closure to be invoked after the query is executed. + * + * @param \Closure $callback + * @return $this + */ + public function afterQuery(Closure $callback): self + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + * + * @param mixed $result + * @return mixed + */ + public function applyAfterQueryCallbacks(mixed $result): mixed + { + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; + } + + return $result; + } + + /** + * Get a lazy collection for the given query. + * + * @return \Illuminate\Support\LazyCollection + */ + public function cursor(): LazyCollection + { + return $this->applyScopes()->query->cursor()->map(function ($record) { + // @TODO: Actually populate + $model = new $this->elementType; + + return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); + })->reject(fn ($model) => is_null($model)); + } + + /** + * Get the underlying query builder instance. + * + * @return \Illuminate\Database\Query\Builder + */ + public function getQuery(): Builder + { + return $this->query; + } + + /** + * Get the underlying subquery builder instance. + * + * @return \Illuminate\Database\Query\Builder + */ + public function getSubQuery(): Builder + { + return $this->subQuery; + } + + /** + * Get the "limit" value from the query or null if it's not set. + * + * @return mixed + */ + public function getLimit(): mixed + { + return $this->subQuery->getLimit(); + } + + /** + * Get the "offset" value from the query or null if it's not set. + * + * @return mixed + */ + public function getOffset(): mixed + { + return $this->subQuery->getOffset(); + } + + /** + * Get the given macro by name. + * + * @param string $name + * @return \Closure + */ + public function getMacro($name): Closure + { + return Arr::get($this->localMacros, $name); + } + + /** + * Checks if a macro is registered. + * + * @param string $name + * @return bool + */ + public function hasMacro($name): bool + { + return isset($this->localMacros[$name]); + } + + /** + * Get the given global macro by name. + * + * @param string $name + * @return \Closure + */ + public static function getGlobalMacro($name): Closure + { + return Arr::get(static::$macros, $name); + } + + /** + * Checks if a global macro is registered. + * + * @param string $name + * @return bool + */ + public static function hasGlobalMacro($name): bool + { + return isset(static::$macros[$name]); + } + + /** + * Dynamically access builder proxies. + * + * @param string $key + * @return mixed + * + * @throws \Exception + */ + public function __get($key): mixed + { + if (in_array($key, $this->propertyPassthru)) { + return $this->getQuery()->{$key}; + } + + throw new Exception("Property [{$key}] does not exist on the Element query instance."); + } + + /** + * Dynamically handle calls into the query instance. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters): mixed + { + if ($method === 'macro') { + $this->localMacros[$parameters[0]] = $parameters[1]; + + return null; + } + + if ($this->hasMacro($method)) { + array_unshift($parameters, $this); + + return $this->localMacros[$method](...$parameters); + } + + if (static::hasGlobalMacro($method)) { + $callable = static::$macros[$method]; + + if ($callable instanceof Closure) { + $callable = $callable->bindTo($this, static::class); + } + + return $callable(...$parameters); + } + + if (in_array(strtolower($method), $this->passthru)) { + return $this->getQuery()->{$method}(...$parameters); + } + + $this->forwardCallTo($this->subQuery, $method, $parameters); + + return $this; + } + + /** + * Dynamically handle calls into the query instance. + * + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws \BadMethodCallException + */ + public static function __callStatic($method, $parameters): mixed + { + if ($method === 'macro') { + static::$macros[$parameters[0]] = $parameters[1]; + + return null; + } + + if ($method === 'mixin') { + return static::registerMixin($parameters[0], $parameters[1] ?? true); + } + + if (! static::hasGlobalMacro($method)) { + static::throwBadMethodCallException($method); + } + + $callable = static::$macros[$method]; + + if ($callable instanceof Closure) { + $callable = $callable->bindTo(null, static::class); + } + + return $callable(...$parameters); + } + + /** + * Register the given mixin with the builder. + * + * @param string $mixin + * @param bool $replace + * @return void + */ + protected static function registerMixin(string $mixin, bool $replace = true) + { + $methods = new ReflectionClass($mixin)->getMethods( + ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED + ); + + foreach ($methods as $method) { + if ($replace || ! static::hasGlobalMacro($method->name)) { + static::macro($method->name, $method->invoke($mixin)); + } + } + } + + public function clone(): self + { + return clone $this; + } + + /** + * Register a closure to be invoked on a clone. + * + * @param \Closure $callback + * @return $this + */ + public function onClone(Closure $callback): self + { + $this->onCloneCallbacks[] = $callback; + + return $this; + } + + /** + * Force a clone of the underlying query builder when cloning. + * + * @return void + */ + public function __clone() + { + $this->query = clone $this->query; + + foreach ($this->onCloneCallbacks as $onCloneCallback) { + $onCloneCallback($this); + } + } +} diff --git a/src/Database/Queries/ElementQueryInterface.php b/src/Database/Queries/ElementQueryInterface.php new file mode 100644 index 00000000000..b6b4e93cc14 --- /dev/null +++ b/src/Database/Queries/ElementQueryInterface.php @@ -0,0 +1,369 @@ + + */ + public function all(array|string $columns = ['*']): Collection; + + /** + * Causes the query results to be returned in reverse order. + * + * --- + * + * ```twig + * {# Fetch {elements} in reverse #} + * {% set {elements-var} = {twig-method} + * .inReverse() + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} in reverse + * ${elements-var} = {php-method} + * ->inReverse() + * ->all(); + * ``` + * + * @param bool $value The property value + * @return static self reference + */ + public function inReverse(bool $value = true): static; + + /** + * Causes the query to return provisional drafts for the matching elements, + * when they exist for the current user. + * + * @param bool $value The property value (defaults to true) + * @return static self reference + */ + public function withProvisionalDrafts(bool $value = true): static; + + /** + * Narrows the query results to only drafts {elements}. + * + * --- + * + * ```twig + * {# Fetch a draft {element} #} + * {% set {elements-var} = {twig-method} + * .drafts() + * .id(123) + * .one() %} + * ``` + * + * ```php + * // Fetch a draft {element} + * ${elements-var} = {element-class}::find() + * ->drafts() + * ->id(123) + * ->one(); + * ``` + * + * @param bool|null $value The property value (defaults to true) + * @return static self reference + */ + public function drafts(?bool $value = true): static; + + /** + * Narrows the query results based on the {elements}’ draft’s ID (from the `drafts` table). + * + * Possible values include: + * + * | Value | Fetches drafts… + * | - | - + * | `1` | for the draft with an ID of 1. + * + * --- + * + * ```twig + * {# Fetch a draft #} + * {% set {elements-var} = {twig-method} + * .draftId(10) + * .all() %} + * ``` + * + * ```php + * // Fetch a draft + * ${elements-var} = {php-method} + * ->draftId(10) + * ->all(); + * ``` + * + * @param int|null $value The property value + * @return static self reference + */ + public function draftId(?int $value = null): static; + + /** + * Narrows the query results to only drafts of a given {element}. + * + * Possible values include: + * + * | Value | Fetches drafts… + * | - | - + * | `1` | for the {element} with an ID of 1. + * | `[1, 2]` | for the {elements} with an ID of 1 or 2. + * | a [[{element-class}]] object | for the {element} represented by the object. + * | an array of [[{element-class}]] objects | for the {elements} represented by the objects. + * | `'*'` | for any {element} + * | `false` | that aren’t associated with a published {element} + * + * --- + * + * ```twig + * {# Fetch drafts of the {element} #} + * {% set {elements-var} = {twig-method} + * .draftOf({myElement}) + * .all() %} + * ``` + * + * ```php + * // Fetch drafts of the {element} + * ${elements-var} = {php-method} + * ->draftOf(${myElement}) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + */ + public function draftOf(mixed $value): static; + + /** + * Narrows the query results to only drafts created by a given user. + * + * Possible values include: + * + * | Value | Fetches drafts… + * | - | - + * | `1` | created by the user with an ID of 1. + * | a [[User]] object | created by the user represented by the object. + * + * --- + * + * ```twig + * {# Fetch drafts by the current user #} + * {% set {elements-var} = {twig-method} + * .draftCreator(currentUser) + * .all() %} + * ``` + * + * ```php + * // Fetch drafts by the current user + * ${elements-var} = {php-method} + * ->draftCreator(Craft::$app->user->identity) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + */ + public function draftCreator(mixed $value): static; + + /** + * Narrows the query results to only provisional drafts. + * + * --- + * + * ```twig + * {# Fetch provisional drafts created by the current user #} + * {% set {elements-var} = {twig-method} + * .provisionalDrafts() + * .draftCreator(currentUser) + * .all() %} + * ``` + * + * ```php + * // Fetch provisional drafts created by the current user + * ${elements-var} = {php-method} + * ->provisionalDrafts() + * ->draftCreator(Craft::$app->user->identity) + * ->all(); + * ``` + * + * @param bool|null $value The property value + * @return static self reference + */ + public function provisionalDrafts(?bool $value = true): static; + + /** + * Narrows the query results to only canonical elements, including elements + * that reference another canonical element via `canonicalId` so long as they + * aren’t a draft. + * + * Unpublished drafts can be included as well if `drafts(null)` and + * `draftOf(false)` are also passed. + * + * @param bool $value The property value + * @return static self reference + */ + public function canonicalsOnly(bool $value = true): static; + + /** + * Narrows the query results to only unpublished drafts which have been saved after initial creation. + * + * --- + * + * ```twig + * {# Fetch saved, unpublished draft {elements} #} + * {% set {elements-var} = {twig-method} + * .draftOf(false) + * .savedDraftsOnly() + * .all() %} + * ``` + * + * ```php + * // Fetch saved, unpublished draft {elements} + * ${elements-var} = {element-class}::find() + * ->draftOf(false) + * ->savedDraftsOnly() + * ->all(); + * ``` + * + * @param bool $value The property value (defaults to true) + * @return static self reference + */ + public function savedDraftsOnly(bool $value = true): static; + + /** + * Narrows the query results to only revision {elements}. + * + * --- + * + * ```twig + * {# Fetch a revision {element} #} + * {% set {elements-var} = {twig-method} + * .revisions() + * .id(123) + * .one() %} + * ``` + * + * ```php + * // Fetch a revision {element} + * ${elements-var} = {element-class}::find() + * ->revisions() + * ->id(123) + * ->one(); + * ``` + * + * @param bool|null $value The property value (defaults to true) + * @return static self reference + */ + public function revisions(?bool $value = true): static; + + /** + * Narrows the query results based on the {elements}’ revision’s ID (from the `revisions` table). + * + * Possible values include: + * + * | Value | Fetches revisions… + * | - | - + * | `1` | for the revision with an ID of 1. + * + * --- + * + * ```twig + * {# Fetch a revision #} + * {% set {elements-var} = {twig-method} + * .revisionId(10) + * .all() %} + * ``` + * + * ```php + * // Fetch a revision + * ${elements-var} = {php-method} + * ->revisionIf(10) + * ->all(); + * ``` + * + * @param int|null $value The property value + * @return static self reference + */ + public function revisionId(?int $value = null): static; + + /** + * Narrows the query results to only revisions of a given {element}. + * + * Possible values include: + * + * | Value | Fetches revisions… + * | - | - + * | `1` | for the {element} with an ID of 1. + * | a [[{element-class}]] object | for the {element} represented by the object. + * + * --- + * + * ```twig + * {# Fetch revisions of the {element} #} + * {% set {elements-var} = {twig-method} + * .revisionOf({myElement}) + * .all() %} + * ``` + * + * ```php + * // Fetch revisions of the {element} + * ${elements-var} = {php-method} + * ->revisionOf(${myElement}) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + */ + public function revisionOf(mixed $value): static; + + /** + * Narrows the query results to only revisions created by a given user. + * + * Possible values include: + * + * | Value | Fetches revisions… + * | - | - + * | `1` | created by the user with an ID of 1. + * | a [[User]] object | created by the user represented by the object. + * + * --- + * + * ```twig + * {# Fetch revisions by the current user #} + * {% set {elements-var} = {twig-method} + * .revisionCreator(currentUser) + * .all() %} + * ``` + * + * ```php + * // Fetch revisions by the current user + * ${elements-var} = {php-method} + * ->revisionCreator(Craft::$app->user->identity) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + */ + public function revisionCreator(mixed $value): static; +} + diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php new file mode 100644 index 00000000000..af0287fb079 --- /dev/null +++ b/src/Database/Queries/EntryQuery.php @@ -0,0 +1,8 @@ + + */ + protected string $element; + + /** + * The affected element IDs. + * + * @var array + */ + protected array $ids; + + /** + * Set the affected Eloquent model and instance ids. + * + * @param class-string $element + * @param array|int|string $ids + * @return $this + */ + public function setElement(string $element, array $ids = []): self + { + $this->element = $element; + $this->ids = Arr::wrap($ids); + + $this->message = "No query results for element [{$element}]"; + + if (count($this->ids) > 0) { + $this->message .= ' '.implode(', ', $this->ids); + } else { + $this->message .= '.'; + } + + return $this; + } + + /** + * Get the affected element. + * + * @return class-string + */ + public function getModel(): string + { + return $this->element; + } + + /** + * Get the affected element IDs. + * + * @return array + */ + public function getIds(): array + { + return $this->ids; + } +} diff --git a/src/Database/Queries/Exceptions/QueryAbortedException.php b/src/Database/Queries/Exceptions/QueryAbortedException.php new file mode 100644 index 00000000000..f71a4c4cfc2 --- /dev/null +++ b/src/Database/Queries/Exceptions/QueryAbortedException.php @@ -0,0 +1,10 @@ +belongsTo(EntryType::class, 'typeId'); } + + public static function elementQuery(): EntryQuery + { + return new EntryQuery(\craft\elements\Entry::class); + } } diff --git a/tests/Database/Queries/ElementQueryTest.php b/tests/Database/Queries/ElementQueryTest.php new file mode 100644 index 00000000000..8a9e4a52bb8 --- /dev/null +++ b/tests/Database/Queries/ElementQueryTest.php @@ -0,0 +1,82 @@ +all())->toBeEmpty(); + + Element::factory(5)->create(); + + expect(query()->all())->toHaveCount(5); + expect(query()->get())->toHaveCount(5); + expect(query()->one())->toBeInstanceOf(Entry::class); + expect(query()->first())->toBeInstanceOf(Entry::class); + expect(query()->limit(3)->get())->toHaveCount(3); + expect(query()->offset(4)->limit(10)->get())->toHaveCount(1); +}); + +test('id', function () { + [$element1, $element2] = Element::factory(3)->create(); + + expect(query()->id($element1->id)->get())->toHaveCount(1); + expect(query()->id([$element1->id, $element2->id])->get())->toHaveCount(2); + expect(query()->id(implode(',', [$element1->id, $element2->id]))->get())->toHaveCount(2); + expect(query()->id(implode(', ', [$element1->id, $element2->id]))->get())->toHaveCount(2); +}); + +test('uid', function () { + [$element1, $element2] = Element::factory(3)->create(); + + expect(query()->uid($element1->uid)->get())->toHaveCount(1); + expect(query()->uid([$element1->uid, $element2->uid])->get())->toHaveCount(2); + expect(query()->uid(implode(',', [$element1->uid, $element2->uid]))->get())->toHaveCount(2); + expect(query()->uid(implode(', ', [$element1->uid, $element2->uid]))->get())->toHaveCount(2); +}); + +test('trashed', function () { + Element::factory(2)->create(); + Element::factory(2)->trashed()->create(); + + expect(query()->count())->toBe(2); + expect(query()->trashed(true)->count())->toBe(2); + expect(query()->trashed(null)->count())->toBe(4); +}); + +test('dateCreated', function () { + $timezone = app()->getTimezone(); + + Date::setTestNow(Date::now($timezone)->startOfDay()); + + Element::factory()->create([ + 'dateCreated' => Date::now()->subDays(2), + ]); + + Element::factory()->create([ + 'dateCreated' => Date::now()->subDay(), + ]); + + Element::factory()->create([ + 'dateCreated' => Date::now(), + ]); + + expect(query()->count())->toBe(3); + expect(query()->dateCreated('>= ' . Date::now()->subDay()->toIso8601String())->count())->toBe(2); + expect(query()->dateCreated('> ' . Date::now()->subDay()->toIso8601String())->count())->toBe(1); +}); + +test('reverse', function () { + [$element1, $element2, $element3] = Element::factory(3)->create([ + 'dateCreated' => now(), + ]); + + expect(query()->pluck('id')->all())->toBe([$element3->id, $element2->id, $element1->id]); + expect(query()->inReverse()->pluck('id')->all())->toBe([$element1->id, $element2->id, $element3->id]); +}); diff --git a/yii2-adapter/legacy/elements/db/EntryQuery.php b/yii2-adapter/legacy/elements/db/EntryQuery.php index 3d315fe2bbb..badf4aae580 100644 --- a/yii2-adapter/legacy/elements/db/EntryQuery.php +++ b/yii2-adapter/legacy/elements/db/EntryQuery.php @@ -252,18 +252,6 @@ public function __set($name, $value) } } - /** - * @inheritdoc - */ - public function init(): void - { - if (!isset($this->withStructure)) { - $this->withStructure = true; - } - - parent::init(); - } - /** * Sets the [[$editable]] property. * diff --git a/yii2-adapter/legacy/services/Search.php b/yii2-adapter/legacy/services/Search.php index 05d74786a9b..a76ca8b4f60 100644 --- a/yii2-adapter/legacy/services/Search.php +++ b/yii2-adapter/legacy/services/Search.php @@ -390,7 +390,7 @@ private function isIndexJobPending(int $jobId): bool * @return bool * @since 4.8.0 */ - public function shouldCallSearchElements(ElementQuery $elementQuery): bool + public function shouldCallSearchElements(ElementQuery|\CraftCms\Cms\Element\Queries\ElementQuery $elementQuery): bool { return false; } @@ -402,7 +402,7 @@ public function shouldCallSearchElements(ElementQuery $elementQuery): bool * @return array The element scores (descending) indexed by element ID and site ID (e.g. `'100-1'`). * @since 3.7.14 */ - public function searchElements(ElementQuery $elementQuery): array + public function searchElements(ElementQuery|\CraftCms\Cms\Element\Queries\ElementQuery $elementQuery): array { $searchQuery = $this->normalizeSearchQuery($elementQuery->search); From 23ef579d47058ee8006094215e54fea2cf64d870 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 6 Nov 2025 11:31:17 +0100 Subject: [PATCH 02/52] QueriesStatuses --- .../Queries/Concerns/ElementQueryTrait.php | 782 ------------------ .../Queries/Concerns/QueriesStatuses.php | 78 +- .../Queries/Concerns/QueriesStatusesTest.php | 62 ++ 3 files changed, 119 insertions(+), 803 deletions(-) delete mode 100644 src/Database/Queries/Concerns/ElementQueryTrait.php create mode 100644 tests/Database/Queries/Concerns/QueriesStatusesTest.php diff --git a/src/Database/Queries/Concerns/ElementQueryTrait.php b/src/Database/Queries/Concerns/ElementQueryTrait.php deleted file mode 100644 index d44a7f75598..00000000000 --- a/src/Database/Queries/Concerns/ElementQueryTrait.php +++ /dev/null @@ -1,782 +0,0 @@ - SORT_DESC, - 'elements.id' => SORT_DESC, - ]; - - // For internal use - // ------------------------------------------------------------------------- - - /** - * @var string[]|null - * @see getCacheTags() - */ - private array|null $cacheTags = null; - - /** - * @var array> Column alias => name mapping - * @see prepare() - * @see joinElementTable() - * @see applyOrderByParams() - * @see applySelectParam() - */ - private array $columnMap; - - /** - * @var bool Whether an element table has been joined for the query - * @see prepare() - * @see joinElementTable() - */ - private bool $joinedElementTable = false; - - /** - * @var array|null - * @see applySearchParam() - * @see applyOrderByParams() - * @see populate() - */ - private ?array $searchResults = null; - - /** - * @inheritdoc - * @uses $id - */ - public function id($value): static - { - $this->id = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $uid - */ - public function uid($value): static - { - $this->uid = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $siteSettingsId - */ - public function siteSettingsId($value): static - { - $this->siteSettingsId = $value; - return $this; - } - - /** - * @inheritdoc - * @uses $trashed - */ - public function trashed(?bool $value = true): static - { - $this->trashed = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $dateCreated - */ - public function dateCreated(mixed $value): static - { - $this->dateCreated = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $dateUpdated - */ - public function dateUpdated(mixed $value): static - { - $this->dateUpdated = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $title - */ - public function title($value): static - { - $this->title = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $slug - */ - public function slug($value): static - { - $this->slug = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $uri - */ - public function uri($value): static - { - $this->uri = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $search - */ - public function search($value): static - { - $this->search = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $inBulkOp - */ - public function inBulkOp(?string $value): static - { - $this->inBulkOp = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $ref - */ - public function ref($value): static - { - $this->ref = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $fixedOrder - */ - public function fixedOrder(bool $value = true): static - { - $this->fixedOrder = $value; - - return $this; - } - - public function inReverse(bool $value = true): static - { - $this->inReverse = $value; - - return $this; - } - - /** - * @inheritdoc - * @uses $asArray - */ - public function asArray(bool $value = true): static - { - $this->asArray = $value; - - return $this; - } - - - // Internal Methods - // ------------------------------------------------------------------------- - - protected function elementQueryBeforeQuery(Builder $query): void - { - // Is the query already doomed? - if (isset($this->id) && empty($this->id)) { - throw new QueryAbortedException(); - } - - // Clear out the previous cache tags - $this->cacheTags = null; - - // Give other classes a chance to make changes up front - /*if (!$this->beforePrepare()) { - throw new QueryAbortedException(); - }*/ - - $this->subQuery - ->addSelect([ - 'elements.id as elementsId', - 'elements_sites.id as siteSettingsId', - ]) - ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.elementId', 'elements.id'); - // @TODO: Params? - // ->addParams($this->params); - - if ($this->id) { - foreach (DbHelper::parseNumericParam('elements.id', $this->id) as $column => $values) { - $this->subQuery->whereIn($column, Arr::wrap($values)); - } - } - - if ($this->uid) { - foreach (DbHelper::parseParam('elements.uid', $this->uid) as $column => $values) { - $this->subQuery->whereIn($column, Arr::wrap($values)); - } - } - - if ($this->siteSettingsId) { - foreach (DbHelper::parseNumericParam('elements_sites.id', $this->siteSettingsId) as $column => $values) { - $this->subQuery->whereIn($column, Arr::wrap($values)); - } - } - - match($this->trashed) { - true => $this->subQuery->whereNotNull('elements.dateDeleted'), - false => $this->subQuery->whereNull('elements.dateDeleted'), - default => null, - }; - - if ($this->dateCreated) { - $parsed = DbHelper::parseDateParam('elements.dateCreated', $this->dateCreated); - - $operator = $parsed[0]; - $column = $parsed[1]; - $value = $parsed[2] ?? null; - - if (is_null($value)) { - $value = $column; - $column = $operator; - $operator = '='; - } - - $this->subQuery->where($column, $operator, $value); - } - - if ($this->dateUpdated) { - $this->subQuery->where(DbHelper::parseDateParam('elements.dateUpdated', $this->dateUpdated)); - } - - if (isset($this->title) && $this->title !== '' && $this->elementType::hasTitles()) { - if (is_string($this->title)) { - $this->title = DbHelper::escapeCommas($this->title); - } - - $this->subQuery->where(DbHelper::parseParam('elements_sites.title', $this->title, '=', true)); - } - - if ($this->slug) { - $this->subQuery->where(DbHelper::parseParam('elements_sites.slug', $this->slug)); - } - - if ($this->uri) { - $this->subQuery->where(DbHelper::parseParam('elements_sites.uri', $this->uri, '=', true)); - } - - if ($this->inBulkOp) { - $this->subQuery - ->join(new Alias(Table::ELEMENTS_BULKOPS, 'elements_bulkops'), 'elements_bulkops.elementId', 'elements.id') - ->where('elements_bulkops.key', $this->inBulkOp); - } - - $this->applySearchParam($query); - $this->applyOrderByParams($query); - $this->applySelectParams($query); - - // If an element table was never joined in, explicitly filter based on the element type - if (! $this->joinedElementTable && $this->elementType !== Element::class) { - try { - $ref = new ReflectionClass($this->elementType); - } catch (ReflectionException) { - $ref = null; - } - - if ($ref && !$ref->isAbstract()) { - $this->subQuery->where('elements.type', $this->elementType); - } - } - - /** @var \Illuminate\Database\Query\JoinClause $join */ - foreach ($this->query->joins ?? [] as $join) { - $this->subQuery->joins[] = $join; - } - - $query - ->fromSub($this->subQuery, 'subquery') - ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.id', 'subquery.siteSettingsId') - ->join(new Alias(Table::ELEMENTS, 'elements'), 'elements.id', 'subquery.elementsId'); - } - - protected function elementQueryAfterQuery(Collection $collection): void - { - $elementsService = \Craft::$app->getElements(); - - if ($elementsService->getIsCollectingCacheInfo()) { - $elementsService->collectCacheTags($this->getCacheTags()); - } - } - - /** - * @return string[] - */ - public function getCacheTags(): array - { - if (! is_null($this->cacheTags)) { - return $this->cacheTags; - } - - $modelClass = $this->elementType; - - $this->cacheTags = [ - 'element', - "element::{$modelClass}", - ]; - - // If (<= 100) specific IDs were requested, then use those - if (is_numeric($this->id) || - (is_array($this->id) && count($this->id) <= 100 && Arr::isNumeric($this->id)) - ) { - array_push($this->cacheTags, ...array_map(fn($id) => "element::$id", Arr::wrap($this->id))); - - return $this->cacheTags; - } - - $queryTags = $this->cacheTags(); - - // Fire a 'defineCacheTags' event - /*if ($this->hasEventHandlers(self::EVENT_DEFINE_CACHE_TAGS)) { - $event = new DefineValueEvent(['value' => $queryTags]); - $this->trigger(self::EVENT_DEFINE_CACHE_TAGS, $event); - $queryTags = $event->value; - }*/ - - if (! empty($queryTags)) { - if ($this->drafts !== false) { - $queryTags[] = 'drafts'; - } - - if ($this->revisions !== false) { - $queryTags[] = 'revisions'; - } - } else { - $queryTags[] = '*'; - } - - foreach ($queryTags as $tag) { - // tags can be provided fully-formed, or relative to the element type - if (! str_starts_with($tag, 'element::')) { - $tag = sprintf('element::%s::%s', $this->elementType, $tag); - } - - $this->cacheTags[] = $tag; - } - - return $this->cacheTags; - } - - /** - * Returns any cache invalidation tags that caches involving this element query should use as dependencies. - * - * Use the most specific tag(s) possible, to reduce the likelihood of pointless cache clearing. - * - * When elements are created/updated/deleted, their [[ElementInterface::getCacheTags()]] method will be called, - * and any caches that have those tags listed as dependencies will be invalidated. - * - * @return string[] - */ - protected function cacheTags(): array - { - return []; - } - - /** - * Joins in a table with an `id` column that has a foreign key pointing to `elements.id`. - * - * The table will be joined with an alias based on the unprefixed table name. For example, - * if `{{%entries}}` is passed, the table will be aliased to `entries`. - * - * @param string $table The table name, e.g. `entries` or `{{%entries}}` - */ - protected function joinElementTable(string $table, ?string $alias = null): void - { - $this->query->join(new Alias($table, $alias ?? $table), "$alias.id", 'subquery.elementsId'); - $this->subQuery->join(new Alias($table, $alias ?? $table), "$alias.id", 'elements.id'); - $this->joinedElementTable = true; - - // Add element table cols to the column map - foreach (Schema::getColumnListing($table) as $column) { - $name = $column['name']; - - if (! isset($this->columnMap[$name])) { - $this->columnMap[$name] = "$alias.$name"; - } - } - } - - /** - * Applies the 'search' param to the query being prepared. - * - * @throws QueryAbortedException - */ - private function applySearchParam(Builder $query): void - { - $this->searchResults = null; - - if (! $this->search) { - return; - } - - $searchService = \Craft::$app->getSearch(); - - $scoreOrder = Arr::first($query->orders, fn($order) => $order['column'] === 'score'); - - if ($scoreOrder || $searchService->shouldCallSearchElements($this)) { - // Get the scored results up front - $searchResults = $searchService->searchElements($this); - - if ($scoreOrder['direction'] === 'asc') { - $searchResults = array_reverse($searchResults, true); - } - - if (($this->orders[0]['column'] ?? null) === 'score') { - // Only use the portion we're actually querying for - if (is_int($this->offset) && $this->offset !== 0) { - $searchResults = array_slice($searchResults, $this->offset, null, true); - $this->subQuery->offset = null; - } - if (is_int($this->limit) && $this->limit !== 0) { - $searchResults = array_slice($searchResults, 0, $this->limit, true); - $this->subQuery->limit = null; - } - } - - if (empty($searchResults)) { - throw new QueryAbortedException(); - } - - $this->searchResults = $searchResults; - - $elementIdsBySiteId = []; - foreach (array_keys($searchResults) as $key) { - [$elementId, $siteId] = explode('-', $key, 2); - $elementIdsBySiteId[$siteId][] = $elementId; - } - - $this->subQuery->where(function (Builder $query) use ($elementIdsBySiteId) { - foreach ($elementIdsBySiteId as $siteId => $elementIds) { - $query->orWhere(function (Builder $query) use ($siteId, $elementIds) { - $query->where('elements_sites.siteId', $siteId) - ->whereIn('elements.id', $elementIds); - }); - } - }); - - return; - } - - // Just filter the main query by the search query - $searchQuery = $searchService->createDbQuery($this->search, $this); - - if ($searchQuery === false) { - throw new QueryAbortedException(); - } - - $this->subQuery->whereIn('elements.id', $searchQuery->select('elementId')); - } - - private function applyOrderByParams(Builder $query): void - { - $orders = $query->orders; - - // Only set to the default order if `orderBy` is null - if (is_null($orders)) { - if ($this->fixedOrder) { - if (empty($this->id)) { - throw new QueryAbortedException(); - } - - $ids = $this->id; - if (!is_array($ids)) { - $ids = is_string($ids) ? str($ids)->explode(',')->all() : [$ids]; - } - - $query->orderBy(new FixedOrderExpression('elements.id', $ids)); - } elseif ($this->revisions) { - $query->orderByDesc('num'); - } elseif ($this->shouldJoinStructureData()) { - $query->orderBy('structureelements.lft'); - - foreach ($this->defaultOrderBy as $column => $direction) { - $query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); - } - } elseif (!empty($this->defaultOrderBy)) { - foreach ($this->defaultOrderBy as $column => $direction) { - $query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); - } - } else { - return; - } - } else { - $orders = array_filter($orders, fn($order) => $order['column'] !== ''); - foreach ($orders as $order) { - $query->orderBy($order['column'], $order['direction']); - } - } - - - if ($this->inReverse) { - $orders = $query->orders; - - $query->reorder(); - - foreach (array_reverse($orders) as $order) { - $query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); - } - } - } - - /** - * Applies the 'select' param to the query being executed. - */ - private function applySelectParams(Builder $query): void - { - // Select all columns defined by [[select]], swapping out any mapped column names - $select = []; - $includeDefaults = false; - - foreach ((array)$this->columns as $column) { - [$column, $alias] = explode(' as ', $column, 2) + [1 => null]; - - $alias ??= $column; - - if ($column === '**') { - $includeDefaults = true; - } else { - // Is this a mapped column name? - if (is_string($column) && isset($this->columnMap[$column])) { - $column = $this->resolveColumnMapping($column); - - // Completely ditch the mapped name if instantiated elements are going to be returned - if (! $this->asArray && is_string($column)) { - $alias = $column; - } - } - - if (is_array($column)) { - $select[] = new Alias(new Coalesce($column), $alias); - } else { - $select[] = new Alias($column, $alias); - } - } - } - - // Is there still a ** placeholder param? - if (! $includeDefaults) { - $query->columns = $select; - - return; - } - - // Merge in the default columns - $select = array_merge($select, [ - 'elements.id', - 'elements.canonicalId', - 'elements.fieldLayoutId', - 'elements.uid', - 'elements.enabled', - 'elements.archived', - 'elements.dateLastMerged', - 'elements.dateCreated', - 'elements.dateUpdated', - new Alias('elements_sites.id', 'siteSettingsId'), - 'elements_sites.siteId', - 'elements_sites.title', - 'elements_sites.slug', - 'elements_sites.uri', - 'elements_sites.content', - new Alias('elements_sites.enabled', 'enabledForSite'), - ]); - - // If the query includes soft-deleted elements, include the date deleted - if ($this->trashed !== false) { - $select[] = 'elements.dateDeleted'; - } - - $query->columns = $select; - } - - private function resolveColumnMapping(string $key): string|array - { - if (!isset($this->columnMap[$key])) { - throw new InvalidArgumentException("Invalid column map key: $key"); - } - - // make sure it's not still a callback - if (is_callable($this->columnMap[$key])) { - $this->columnMap[$key] = $this->columnMap[$key](); - } elseif (is_array($this->columnMap[$key])) { - foreach ($this->columnMap[$key] as $i => $mapping) { - if (is_callable($mapping)) { - $this->columnMap[$key][$i] = $mapping(); - } - } - } - - return $this->columnMap[$key]; - } -} diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php index 439d79d15cf..02e0b176411 100644 --- a/src/Database/Queries/Concerns/QueriesStatuses.php +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -1,5 +1,7 @@ query->beforeQuery(function () { + $this->beforeQuery(function () { if ($this->archived) { $this->subQuery->where('elements.archived', true); + return; } @@ -38,15 +45,17 @@ protected function initializeQueriesStatuses(): void // only set archived=false if 'archived' doesn't show up in the status param // (_applyStatusParam() will normalize $this->status to an array if applicable) - if (!is_array($this->status) || !in_array($this->elementType::STATUS_ARCHIVED, $this->status)) { + if (! is_array($this->status) || ! in_array($this->elementType::STATUS_ARCHIVED, $this->status)) { $this->subQuery->where('elements.archived', false); } }); } /** - * @inheritdoc - * @uses $archived + * Sets the [[$archived]] property. + * + * @param bool $value The property value (defaults to true) + * @return static self reference */ public function archived(bool $value = true): static { @@ -56,8 +65,34 @@ public function archived(bool $value = true): static } /** - * @inheritdoc - * @uses $status + * Narrows the query results based on the {elements}’ statuses. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'enabled'` _(default)_ | that are enabled. + * | `'disabled'` | that are disabled. + * | `['not', 'disabled']` | that are not disabled. + * + * --- + * + * ```twig + * {# Fetch disabled {elements} #} + * {% set {elements-var} = {twig-method} + * .status('disabled') + * .all() %} + * ``` + * + * ```php + * // Fetch disabled {elements} + * ${elements-var} = {php-method} + * ->status('disabled') + * ->all(); + * ``` + * + * @param string|string[]|null $value The property value + * @return static self reference */ public function status(array|string|null $value): static { @@ -73,7 +108,7 @@ public function status(array|string|null $value): static */ private function applyStatusParam(): void { - if (!$this->status || !$this->elementType::hasStatuses()) { + if (! $this->status || ! $this->elementType::hasStatuses()) { return; } @@ -83,16 +118,16 @@ private function applyStatusParam(): void } $statuses = array_merge($this->status); + $firstVal = strtolower((string) reset($statuses)); + $glue = 'or'; - $firstVal = strtolower(reset($statuses)); if (in_array($firstVal, ['not', 'or'])) { $glue = $firstVal; array_shift($statuses); - if (!$statuses) { - return; - } - } else { - $glue = 'or'; + } + + if (! $statuses) { + return; } if ($negate = ($glue === 'not')) { @@ -101,11 +136,12 @@ private function applyStatusParam(): void $this->subQuery->where(function (Builder $query) use ($statuses, $negate, $glue) { foreach ($statuses as $status) { - $query->where( - column: $this->placeholderCondition($this->statusCondition($status)), - operator: $negate ? '!=' : '=', - boolean: $glue, - ); + match (true) { + $glue === 'or' && $negate === false => $query->orWhere($this->placeholderCondition($this->statusCondition($status))), + $glue === 'or' && $negate === true => $query->orWhereNot($this->placeholderCondition($this->statusCondition($status))), + $negate === false => $query->where($this->placeholderCondition($this->statusCondition($status))), + $negate === true => $query->whereNot($this->placeholderCondition($this->statusCondition($status))), + }; } }); } @@ -127,9 +163,9 @@ private function applyStatusParam(): void * } * ``` * - * @param string $status The status - * + * @param string $status The status * @return Closure(Builder): Builder The status condition, or false if $status is an unsupported status + * * @throws \CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException on unsupported status. */ protected function statusCondition(string $status): Closure @@ -140,7 +176,7 @@ protected function statusCondition(string $status): Closure Element::STATUS_ENABLED => fn (Builder $q) => $q->where('elements.enabled', true)->where('elements_sites.enabled', true), Element::STATUS_DISABLED => fn (Builder $q) => $q->where('elements.enabled', false)->orWhere('elements_sites.enabled', false), Element::STATUS_ARCHIVED => fn (Builder $q) => $q->where('elements.archived', true), - default => throw new QueryAbortedException('Unsupported status: ' . $status), + default => throw new QueryAbortedException('Unsupported status: '.$status), }; } } diff --git a/tests/Database/Queries/Concerns/QueriesStatusesTest.php b/tests/Database/Queries/Concerns/QueriesStatusesTest.php new file mode 100644 index 00000000000..a9b92b1ae9b --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesStatusesTest.php @@ -0,0 +1,62 @@ +create([ + 'enabled' => true, + ]); + + Element::factory()->create(['enabled' => false]); + + $element3 = Element::factory()->create([ + 'enabled' => true, + ]); + DB::table(Table::ELEMENTS_SITES)->where('elementId', $element3->id)->update([ + 'enabled' => false, + ]); + + expect(query()->count())->toBe(1); + expect(query()->firstOrFail()->id)->toBe($element1->id); +}); + +it('can query archived and statuses', function () { + $element1 = Element::factory()->create([ + 'enabled' => true, + ]); + + $element2 = Element::factory()->create([ + 'enabled' => true, + 'archived' => true, + ]); + + expect(query()->count())->toBe(1); + expect(query()->first()->id)->toBe($element1->id); + + expect(query()->archived()->count())->toBe(1); + expect(query()->archived()->first()->id)->toBe($element2->id); + + expect(query()->status([ + \craft\base\Element::STATUS_ENABLED, + \craft\base\Element::STATUS_ARCHIVED, + ])->count())->toBe(2); + + expect(query()->status([ + \craft\base\Element::STATUS_ARCHIVED, + ])->count())->toBe(1); + + // Does not fail but doesn't apply parameters + expect(query()->status(['not'])->count())->toBe(1); + + expect(query()->status(['not', \craft\base\Element::STATUS_ENABLED])->count())->toBe(0); + expect(query()->status(['not', \craft\base\Element::STATUS_ARCHIVED])->count())->toBe(1); +}); From 1a5628c45c10ca55c277562954e7a5d06139900d Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 6 Nov 2025 15:30:44 +0100 Subject: [PATCH 03/52] Lots of refactoring --- database/Factories/EntryFactory.php | 41 ++ src/Database/Expressions/JsonExtract.php | 11 +- .../Queries/Concerns/CollectsCacheTags.php | 115 ++++++ .../Queries/Concerns/FormatsResults.php | 232 +++++++++++ .../Queries/Concerns/QueriesCustomFields.php | 41 +- .../Concerns/QueriesDraftsAndRevisions.php | 103 ++--- .../Queries/Concerns/QueriesEagerly.php | 52 ++- .../Queries/Concerns/QueriesFields.php | 323 ++++++++++++++++ .../Concerns/QueriesPlaceholderElements.php | 23 +- .../Concerns/QueriesRelatedElements.php | 43 ++- .../Queries/Concerns/QueriesSites.php | 59 +-- .../Queries/Concerns/QueriesStatuses.php | 2 + .../Queries/Concerns/QueriesStructures.php | 162 +++++--- .../Concerns/QueriesUniqueElements.php | 30 +- .../Queries/Concerns/SearchesElements.php | 105 +++++ src/Database/Queries/ElementQuery.php | 359 ++++++++++++++---- .../Queries/ElementQueryInterface.php | 34 +- src/Database/Queries/EntryQuery.php | 74 ++++ .../Queries/Events/DefineCacheTags.php | 16 + .../Exceptions/QueryAbortedException.php | 7 +- src/Element/Models/Element.php | 29 +- src/Element/Models/ElementSiteSettings.php | 36 ++ src/Entry/Models/Entry.php | 1 - src/Site/Models/Site.php | 25 ++ src/Structure/Models/StructureElement.php | 2 + .../Concerns/CollectsCacheTagsTest.php | 38 ++ .../Queries/Concerns/FormatsResultsTest.php | 34 ++ .../Queries/Concerns/QueriesStatusesTest.php | 65 ++-- tests/Database/Queries/ElementQueryTest.php | 97 ++--- tests/Pest.php | 6 + 30 files changed, 1757 insertions(+), 408 deletions(-) create mode 100644 src/Database/Queries/Concerns/CollectsCacheTags.php create mode 100644 src/Database/Queries/Concerns/FormatsResults.php create mode 100644 src/Database/Queries/Concerns/QueriesFields.php create mode 100644 src/Database/Queries/Concerns/SearchesElements.php create mode 100644 src/Database/Queries/Events/DefineCacheTags.php create mode 100644 src/Element/Models/ElementSiteSettings.php create mode 100644 tests/Database/Queries/Concerns/CollectsCacheTagsTest.php create mode 100644 tests/Database/Queries/Concerns/FormatsResultsTest.php diff --git a/database/Factories/EntryFactory.php b/database/Factories/EntryFactory.php index e98a5dfdef5..361ce0d8e52 100644 --- a/database/Factories/EntryFactory.php +++ b/database/Factories/EntryFactory.php @@ -27,4 +27,45 @@ public function definition(): array 'dateUpdated' => $created, ]; } + + #[\Override] + public function configure(): self + { + $this->afterCreating(function (Entry $entry) { + $entry->element->update([ + 'dateCreated' => $entry->dateCreated, + 'dateUpdated' => $entry->dateUpdated, + ]); + }); + + return $this; + } + + public function trashed(bool $trashed = true): self + { + return $this->state(fn (array $attributes) => [ + 'id' => $attributes['id']->trashed($trashed), + ]); + } + + public function archived(bool $archived = true): self + { + return $this->state(fn (array $attributes) => [ + 'id' => $attributes['id']->set('archived', $archived), + ]); + } + + public function enabled(bool $enabled = true): self + { + return $this->state(fn (array $attributes) => [ + 'id' => $attributes['id']->set('enabled', $enabled), + ]); + } + + public function disabled(bool $disabled = true): self + { + return $this->state(fn (array $attributes) => [ + 'id' => $attributes['id']->set('enabled', ! $disabled), + ]); + } } diff --git a/src/Database/Expressions/JsonExtract.php b/src/Database/Expressions/JsonExtract.php index ae08a36db28..a9a9aed04bd 100644 --- a/src/Database/Expressions/JsonExtract.php +++ b/src/Database/Expressions/JsonExtract.php @@ -1,5 +1,7 @@ beforeQuery(function () { + $this->cacheTags = null; + }); + + $this->afterQuery(function () { + if (empty($cacheTags = $this->getCacheTags())) { + return; + } + + $elementsService = \Craft::$app->getElements(); + + if ($elementsService->getIsCollectingCacheInfo()) { + $elementsService->collectCacheTags($cacheTags); + } + }); + } + + /** + * @return string[] + */ + public function getCacheTags(): array + { + if (! is_null($this->cacheTags)) { + return $this->cacheTags; + } + + $modelClass = $this->elementType; + + $this->cacheTags = [ + 'element', + "element::{$modelClass}", + ]; + + // If (<= 100) specific IDs were requested, then use those + if (is_numeric($this->id) || + (is_array($this->id) && count($this->id) <= 100 && Arr::isNumeric($this->id)) + ) { + array_push($this->cacheTags, ...array_map(fn ($id) => "element::$id", Arr::wrap($this->id))); + + return $this->cacheTags; + } + + $queryTags = $this->cacheTags(); + + if (Event::hasListeners(DefineCacheTags::class)) { + Event::dispatch($event = new DefineCacheTags($this, $queryTags)); + + $queryTags = $event->tags; + } + + if (! empty($queryTags)) { + if ($this->drafts !== false) { + $queryTags[] = 'drafts'; + } + + if ($this->revisions !== false) { + $queryTags[] = 'revisions'; + } + } else { + $queryTags[] = '*'; + } + + foreach ($queryTags as $tag) { + // tags can be provided fully-formed, or relative to the element type + if (! str_starts_with((string) $tag, 'element::')) { + $tag = sprintf('element::%s::%s', $this->elementType, $tag); + } + + $this->cacheTags[] = $tag; + } + + return $this->cacheTags; + } + + /** + * Returns any cache invalidation tags that caches involving this element query should use as dependencies. + * + * Use the most specific tag(s) possible, to reduce the likelihood of pointless cache clearing. + * + * When elements are created/updated/deleted, their [[ElementInterface::getCacheTags()]] method will be called, + * and any caches that have those tags listed as dependencies will be invalidated. + * + * @return string[] + */ + protected function cacheTags(): array + { + return []; + } +} diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php new file mode 100644 index 00000000000..5ea26e65c91 --- /dev/null +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -0,0 +1,232 @@ + SORT_DESC, + 'elements.id' => SORT_DESC, + ]; + + /** + * @var bool Whether the results should be queried in reverse. + * + * @used-by inReverse() + */ + protected bool $inReverse = false; + + /** + * @var bool Whether to return each element as an array. If false (default), an object + * of [[elementType]] will be created to represent each element. + * + * @used-by asArray() + */ + public bool $asArray = false; + + /** + * @var bool Whether results should be returned in the order specified by [[id]]. + * + * @used-by fixedOrder() + */ + public bool $fixedOrder = false; + + /** + * Causes the query results to be returned in reverse order. + * + * --- + * + * ```twig + * {# Fetch {elements} in reverse #} + * {% set {elements-var} = {twig-method} + * .inReverse() + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} in reverse + * ${elements-var} = {php-method} + * ->inReverse() + * ->all(); + * ``` + * + * @param bool $value The property value + * @return static self reference + */ + public function inReverse(bool $value = true): static + { + $this->inReverse = $value; + + return $this; + } + + /** + * Causes the query to return matching {elements} as arrays of data, rather than [[{element-class}]] objects. + * + * --- + * + * ```twig + * {# Fetch {elements} as arrays #} + * {% set {elements-var} = {twig-method} + * .asArray() + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} as arrays + * ${elements-var} = {php-method} + * ->asArray() + * ->all(); + * ``` + * + * @param bool $value The property value (defaults to true) + * @return static self reference + */ + public function asArray(bool $value = true): static + { + $this->asArray = $value; + + return $this; + } + + /** + * Causes the query results to be returned in the order specified by [[id()]]. + * + * ::: tip + * If no IDs were passed to [[id()]], setting this to `true` will result in an empty result set. + * ::: + * + * --- + * + * ```twig + * {# Fetch {elements} in a specific order #} + * {% set {elements-var} = {twig-method} + * .id([1, 2, 3, 4, 5]) + * .fixedOrder() + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} in a specific order + * ${elements-var} = {php-method} + * ->id([1, 2, 3, 4, 5]) + * ->fixedOrder() + * ->all(); + * ``` + * + * @param bool $value The property value (defaults to true) + * @return static self reference + */ + public function fixedOrder(bool $value = true): static + { + $this->fixedOrder = $value; + + return $this; + } + + protected function initializeFormatsResults(): void + { + $this->query->orderBy(new OrderByPlaceholderExpression); + + $this->beforeQuery(function (ElementQuery $query) { + $this->applyDefaultOrder($query); + + if ($this->inReverse) { + $orders = $query->query->orders; + + $query->query->reorder(); + + foreach (array_reverse($orders) as $order) { + $query->query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); + } + } + + $this->parseOrderColumnMappings($query); + }); + } + + private function applyDefaultOrder(ElementQuery $query): void + { + $orders = $query->query->orders; + + if (is_null($orders)) { + return; + } + + $query->query->orders = array_filter( + array: $orders, + callback: fn ($order) => ! $order['column'] instanceof OrderByPlaceholderExpression, + ); + + // Order by was set + if (count($query->query->orders) > 0) { + return; + } + + if ($this->fixedOrder) { + throw_if(empty($this->id), QueryAbortedException::class); + + if (! is_array($ids = $this->id)) { + $ids = is_string($ids) ? str($ids)->explode(',')->all() : [$ids]; + } + + $query->query->orderBy(new FixedOrderExpression('elements.id', $ids)); + + return; + } + + if ($this->revisions) { + $query->query->orderByDesc('num'); + + return; + } + + if ($this->shouldJoinStructureData()) { + $query->query->orderBy('structureelements.lft'); + + foreach ($this->defaultOrderBy as $column => $direction) { + $query->query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); + } + + return; + } + + foreach ($this->defaultOrderBy as $column => $direction) { + $query->query->orderBy($column, match ($direction) { + SORT_ASC, 'asc' => 'asc', + SORT_DESC, 'desc' => 'desc', + default => throw new QueryAbortedException('Invalid sort direction: '.$direction), + }); + } + } + + private function parseOrderColumnMappings(ElementQuery $query): void + { + $orders = $query->query->orders; + + if (is_null($orders)) { + return; + } + + $query->query->orders = array_map(function ($order) { + $order['column'] = $this->columnMap[$order['column']] ?? $order['column']; + + return $order; + }, $orders); + } +} diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index a731bfbee53..c1db6be444b 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -1,5 +1,7 @@ Column alias => cast type + * * @see prepare() * @see _applyOrderByParams() */ @@ -59,13 +67,14 @@ protected function initializeQueriesCustomFields(): void // Map custom field handles to their content values $this->addCustomFieldsToColumnMap(); - $this->query->beforeQuery(function () { + $this->beforeQuery(function () { $this->applyCustomFieldParams(); }); } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $withCustomFields */ public function withCustomFields(bool $value = true): static @@ -86,7 +95,7 @@ protected function fieldLayouts(): array } /** - * @inheritdoc + * {@inheritdoc} */ public function getFieldLayouts(): array { @@ -112,8 +121,8 @@ private function addCustomFieldsToColumnMap(): void } foreach ($dbTypes as $key => $dbType) { - $alias = $field->handle . ($key !== '*' ? ".$key" : ''); - $resolver = fn() => $field->getValueSql($key !== '*' ? $key : null); + $alias = $field->handle.($key !== '*' ? ".$key" : ''); + $resolver = fn () => $field->getValueSql($key !== '*' ? $key : null); $this->addToColumnMap($alias, $resolver); @@ -126,7 +135,7 @@ private function addCustomFieldsToColumnMap(): void } } - if (!empty($this->generatedFields)) { + if (! empty($this->generatedFields)) { foreach ($this->generatedFields as $field) { if (($field['handle'] ?? '') !== '') { $this->addToColumnMap($field['handle'], new JsonExtract('elements_sites.content', '$.'.$field['uid'])); @@ -163,7 +172,7 @@ private function applyCustomFieldParams(): void /** @var FieldInterface[][][] $fieldsByHandle */ $fieldsByHandle = []; - if (!empty($this->customFields)) { + if (! empty($this->customFields)) { // Group the fields by handle and field UUID foreach ($this->customFields as $field) { $fieldsByHandle[$field->handle][$field->uid][] = $field; @@ -171,12 +180,14 @@ private function applyCustomFieldParams(): void foreach (array_keys(CustomFieldBehavior::$fieldHandles) as $handle) { // $fieldAttributes->$handle will return true even if it's set to null, so can't use isset() here - if ($handle === 'owner' || ($fieldAttributes->$handle ?? null) === null) { + if ($handle === 'owner') { + continue; + } + if (($fieldAttributes->$handle ?? null) === null) { continue; } - // Make sure the custom field exists in one of the field layouts - if (!isset($fieldsByHandle[$handle])) { + if (! isset($fieldsByHandle[$handle])) { // If it looks like null/:empty: is a valid option, let it slide $value = is_array($fieldAttributes->$handle) && isset($fieldAttributes->$handle['value']) ? $fieldAttributes->$handle['value'] @@ -202,7 +213,7 @@ private function applyCustomFieldParams(): void // aborting? if ($condition === false) { - throw new QueryAbortedException(); + throw new QueryAbortedException; } if ($condition !== null) { @@ -210,7 +221,7 @@ private function applyCustomFieldParams(): void } } - if (!empty($conditions)) { + if (! empty($conditions)) { if (count($conditions) === 1) { $this->subQuery->andWhere(reset($conditions), $params); } else { @@ -220,12 +231,12 @@ private function applyCustomFieldParams(): void } } - if (!empty($this->generatedFields)) { + if (! empty($this->generatedFields)) { $generatedFieldColumns = []; foreach ($this->generatedFields as $field) { $handle = $field['handle'] ?? ''; - if ($handle !== '' && isset($fieldAttributes->$handle) && !isset($fieldsByHandle[$handle])) { + if ($handle !== '' && isset($fieldAttributes->$handle) && ! isset($fieldsByHandle[$handle])) { $generatedFieldColumns[$handle][] = new JsonExtract('elements_sites.content', '$.'.$field['uid']); } } diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php index 6001dbd1d2e..687d6fd71d2 100644 --- a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -1,9 +1,12 @@ query->beforeQuery(function (Builder $query) { + $this->beforeQuery(function (ElementQuery $query) { $this->applyDraftParams($query); $this->applyRevisionParams($query); }); } - private function applyDraftParams(Builder $query): void + private function applyDraftParams(ElementQuery $query): void { if ($this->drafts === false) { - $this->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.draftId'))); + $query->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.draftId'))); return; } $joinType = $this->drafts === true ? 'inner' : 'left'; - $this->subQuery->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); + $query->subQuery->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); $query->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); $query->addSelect([ @@ -118,44 +123,44 @@ private function applyDraftParams(Builder $query): void ]); if ($this->draftId) { - $this->subQuery->where('elements.draftId', $this->draftId); + $query->subQuery->where('elements.draftId', $this->draftId); } if ($this->draftOf === '*') { - $this->subQuery->whereNotNull('elements.canonicalId'); + $query->subQuery->whereNotNull('elements.canonicalId'); } elseif (isset($this->draftOf)) { if ($this->draftOf === false) { - $this->subQuery->whereNull('elements.canonicalId', null); + $query->subQuery->whereNull('elements.canonicalId', null); } else { - $this->subQuery->whereIn('elements.canonicalId', $this->draftOf); + $query->subQuery->whereIn('elements.canonicalId', $this->draftOf); } } if ($this->draftCreator) { - $this->subQuery->where('drafts.creatorId', $this->draftCreator); + $query->subQuery->where('drafts.creatorId', $this->draftCreator); } if (isset($this->provisionalDrafts)) { - $this->subQuery->where(function (Builder $query) { + $query->subQuery->where(function (Builder $query) { $query->whereNull('elements.draftId') ->orWhere('drafts.provisional', $this->provisionalDrafts); }); } if ($this->canonicalsOnly) { - $this->subQuery->where(function (Builder $query) { + $query->subQuery->where(function (Builder $query) { $query->whereNull('elements.draftId') ->orWhere(function (Builder $query) { $query ->whereNull('elements.canonicalId') ->when( $this->savedDraftsOnly, - fn(Builder $q) => $q->where('drafts.saved', true) + fn (Builder $q) => $q->where('drafts.saved', true) ); }); }); } elseif ($this->savedDraftsOnly) { - $this->subQuery->where(function (Builder $query) { + $query->subQuery->where(function (Builder $query) { $query->whereNull('elements.draftId') ->orWhereNotNull('elements.canonicalId') ->orWhere('drafts.saved', true); @@ -163,16 +168,16 @@ private function applyDraftParams(Builder $query): void } } - private function applyRevisionParams(Builder $query): void + private function applyRevisionParams(ElementQuery $query): void { if ($this->revisions === false) { - $this->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.revisionId'))); + $query->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.revisionId'))); return; } $joinType = $this->revisions === true ? 'inner' : 'left'; - $this->subQuery->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); + $query->subQuery->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); $query->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); $query->addSelect([ @@ -183,20 +188,21 @@ private function applyRevisionParams(Builder $query): void ]); if ($this->revisionId) { - $this->subQuery->where('elements.revisionId', $this->revisionId); + $query->subQuery->where('elements.revisionId', $this->revisionId); } if ($this->revisionOf) { - $this->subQuery->where('elements.canonicalId', $this->revisionOf); + $query->subQuery->where('elements.canonicalId', $this->revisionOf); } if ($this->revisionCreator) { - $this->subQuery->where('revisions.creatorId', $this->revisionCreator); + $query->subQuery->where('revisions.creatorId', $this->revisionCreator); } } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $drafts */ public function drafts(?bool $value = true): static @@ -207,7 +213,8 @@ public function drafts(?bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $withProvisionalDrafts */ public function withProvisionalDrafts(bool $value = true): static @@ -218,7 +225,8 @@ public function withProvisionalDrafts(bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $draftId * @uses $drafts */ @@ -234,7 +242,8 @@ public function draftId(?int $value = null): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $draftOf * @uses $drafts */ @@ -266,10 +275,10 @@ public function draftOf($value): static return $this; } - if (is_array($value) && !empty($value)) { + if (is_array($value) && ! empty($value)) { $c = Collection::make($value); - if ($c->every(fn($v) => $v instanceof ElementInterface || is_numeric($v))) { - $this->draftOf = $c->map(fn($v) => $v instanceof ElementInterface ? $v->id : $v)->all(); + if ($c->every(fn ($v) => $v instanceof ElementInterface || is_numeric($v))) { + $this->draftOf = $c->map(fn ($v) => $v instanceof ElementInterface ? $v->id : $v)->all(); if ($this->drafts === false) { $this->drafts = true; @@ -283,13 +292,14 @@ public function draftOf($value): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $draftCreator * @uses $drafts */ public function draftCreator($value): static { - $this->draftCreator = match(true) { + $this->draftCreator = match (true) { $value instanceof User => $value->id, is_numeric($value) || $value === null => $value, default => throw new InvalidArgumentException('Invalid draftCreator value'), @@ -303,7 +313,8 @@ public function draftCreator($value): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $provisionalDrafts * @uses $drafts */ @@ -319,7 +330,8 @@ public function provisionalDrafts(?bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $canonicalsOnly */ public function canonicalsOnly(bool $value = true): static @@ -330,7 +342,8 @@ public function canonicalsOnly(bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $savedDraftsOnly */ public function savedDraftsOnly(bool $value = true): static @@ -341,7 +354,8 @@ public function savedDraftsOnly(bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $revisions */ public function revisions(?bool $value = true): static @@ -352,7 +366,8 @@ public function revisions(?bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $revisionId * @uses $revisions */ @@ -368,13 +383,14 @@ public function revisionId(?int $value = null): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $revisionOf * @uses $revisions */ public function revisionOf($value): static { - $this->revisionOf = match(true) { + $this->revisionOf = match (true) { $value instanceof ElementInterface => $value->getCanonicalId(), is_numeric($value) || $value === null => $value, default => throw new InvalidArgumentException('Invalid revisionOf value'), @@ -388,13 +404,14 @@ public function revisionOf($value): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $revisionCreator * @uses $revisions */ public function revisionCreator($value): static { - $this->revisionCreator = match(true) { + $this->revisionCreator = match (true) { $value instanceof User => $value->id, is_numeric($value) || $value === null => $value, default => throw new InvalidArgumentException('Invalid revisionCreator value'), diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php index e603e634693..f0008b47bb6 100644 --- a/src/Database/Queries/Concerns/QueriesEagerly.php +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -1,11 +1,18 @@ query->afterQuery(function (Collection $elements) { + $this->afterQuery(function (Collection $elements) { if ($this->with) { $elementsService = \Craft::$app->getElements(); $elementsService->eagerLoadElements($this->elementType, $elements->all(), $this->with); @@ -57,7 +69,8 @@ protected function initializeQueriesEagerly(): void } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $with */ public function with(array|string|null $value): static @@ -68,7 +81,8 @@ public function with(array|string|null $value): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $with */ public function andWith(array|string|null $value): static @@ -89,7 +103,7 @@ public function andWith(array|string|null $value): static } /** - * @inheritdoc + * {@inheritdoc} */ public function eagerly(string|bool $value = true): static { @@ -100,7 +114,7 @@ public function eagerly(string|bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} */ public function prepForEagerLoading(string $handle, ElementInterface $sourceElement): static { @@ -114,11 +128,11 @@ public function prepForEagerLoading(string $handle, ElementInterface $sourceElem } /** - * @inheritdoc + * {@inheritdoc} */ public function wasEagerLoaded(?string $alias = null): bool { - if (!isset($this->eagerLoadHandle, $this->eagerLoadSourceElement)) { + if (! isset($this->eagerLoadHandle, $this->eagerLoadSourceElement)) { return false; } @@ -127,18 +141,19 @@ public function wasEagerLoaded(?string $alias = null): bool } $planHandle = $this->eagerLoadHandle; - if (str_contains($planHandle, ':')) { - $planHandle = explode(':', $planHandle, 2)[1]; + if (str_contains((string) $planHandle, ':')) { + $planHandle = explode(':', (string) $planHandle, 2)[1]; } + return $this->eagerLoadSourceElement->hasEagerLoadedElements($planHandle); } /** - * @inheritdoc + * {@inheritdoc} */ public function wasCountEagerLoaded(?string $alias = null): bool { - if (!isset($this->eagerLoadHandle, $this->eagerLoadSourceElement)) { + if (! isset($this->eagerLoadHandle, $this->eagerLoadSourceElement)) { return false; } @@ -147,17 +162,18 @@ public function wasCountEagerLoaded(?string $alias = null): bool } $planHandle = $this->eagerLoadHandle; - if (str_contains($planHandle, ':')) { - $planHandle = explode(':', $planHandle, 2)[1]; + if (str_contains((string) $planHandle, ':')) { + $planHandle = explode(':', (string) $planHandle, 2)[1]; } + return $this->eagerLoadSourceElement->getEagerLoadedElementCount($planHandle) !== null; } private function eagerLoad(bool $count = false, array $criteria = []): Collection|int|null { if ( - !$this->eagerly || - !isset($this->eagerLoadSourceElement->elementQueryResult, $this->eagerLoadHandle) || + ! $this->eagerly || + ! isset($this->eagerLoadSourceElement->elementQueryResult, $this->eagerLoadHandle) || count($this->eagerLoadSourceElement->elementQueryResult) < 2 ) { return null; @@ -171,7 +187,7 @@ private function eagerLoad(bool $count = false, array $criteria = []): Collectio false => $this->wasEagerLoaded($alias), }; - if (!$eagerLoaded) { + if (! $eagerLoaded) { \Craft::$app->getElements()->eagerLoadElements( $this->eagerLoadSourceElement::class, $this->eagerLoadSourceElement->elementQueryResult, @@ -180,7 +196,7 @@ private function eagerLoad(bool $count = false, array $criteria = []): Collectio 'handle' => $this->eagerLoadHandle, 'alias' => $alias, 'criteria' => $criteria + $this->getCriteria() + ['with' => $this->with], - 'all' => !$count, + 'all' => ! $count, 'count' => $count, 'lazy' => true, ]), diff --git a/src/Database/Queries/Concerns/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php new file mode 100644 index 00000000000..248a060a2ac --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -0,0 +1,323 @@ +subQuery->beforeQuery(function (Builder $query) { + if ($this->id) { + foreach (DbHelper::parseNumericParam('elements.id', $this->id) as $column => $values) { + $query->whereIn($column, Arr::wrap($values)); + } + } + + if ($this->uid) { + foreach (DbHelper::parseParam('elements.uid', $this->uid) as $column => $values) { + $query->whereIn($column, Arr::wrap($values)); + } + } + + if ($this->siteSettingsId) { + foreach (DbHelper::parseNumericParam('elements_sites.id', $this->siteSettingsId) as $column => $values) { + $query->whereIn($column, Arr::wrap($values)); + } + } + + match ($this->trashed) { + true => $query->whereNotNull('elements.dateDeleted'), + false => $query->whereNull('elements.dateDeleted'), + default => null, + }; + + if ($this->dateCreated) { + $parsed = DbHelper::parseDateParam('elements.dateCreated', $this->dateCreated); + + $operator = $parsed[0]; + $column = $parsed[1]; + $value = $parsed[2] ?? null; + + if (is_null($value)) { + $value = $column; + $column = $operator; + $operator = '='; + } + + $query->where($column, $operator, $value); + } + + if ($this->dateUpdated) { + $query->where(DbHelper::parseDateParam('elements.dateUpdated', $this->dateUpdated)); + } + + if (isset($this->title) && $this->title !== '' && $this->elementType::hasTitles()) { + if (is_string($this->title)) { + $this->title = DbHelper::escapeCommas($this->title); + } + + $query->where(DbHelper::parseParam('elements_sites.title', $this->title, '=', true)); + } + + if ($this->slug) { + $query->where(DbHelper::parseParam('elements_sites.slug', $this->slug)); + } + + if ($this->uri) { + $query->where(DbHelper::parseParam('elements_sites.uri', $this->uri, '=', true)); + } + + if ($this->inBulkOp) { + $query + ->join(new Alias(Table::ELEMENTS_BULKOPS, 'elements_bulkops'), 'elements_bulkops.elementId', 'elements.id') + ->where('elements_bulkops.key', $this->inBulkOp); + } + }); + } + + /** + * {@inheritdoc} + * + * @uses $id + */ + public function id($value): static + { + $this->id = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $uid + */ + public function uid($value): static + { + $this->uid = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $siteSettingsId + */ + public function siteSettingsId($value): static + { + $this->siteSettingsId = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $trashed + */ + public function trashed(?bool $value = true): static + { + $this->trashed = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $dateCreated + */ + public function dateCreated(mixed $value): static + { + $this->dateCreated = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $dateUpdated + */ + public function dateUpdated(mixed $value): static + { + $this->dateUpdated = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $title + */ + public function title($value): static + { + $this->title = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $slug + */ + public function slug($value): static + { + $this->slug = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $uri + */ + public function uri($value): static + { + $this->uri = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $search + */ + public function search($value): static + { + $this->search = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $inBulkOp + */ + public function inBulkOp(?string $value): static + { + $this->inBulkOp = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $ref + */ + public function ref($value): static + { + $this->ref = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php index 90f5384fd4a..0507e939ed4 100644 --- a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php +++ b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php @@ -1,32 +1,43 @@ placeholderCondition) || $this->siteId !== $this->placeholderSiteIds) { + if (! isset($this->placeholderCondition) || $this->siteId !== $this->placeholderSiteIds) { $placeholderSourceIds = []; $placeholderElements = \Craft::$app->getElements()->getPlaceholderElements(); - if (!empty($placeholderElements)) { - $siteIds = array_flip((array)$this->siteId); + if (! empty($placeholderElements)) { + $siteIds = array_flip((array) $this->siteId); foreach ($placeholderElements as $element) { if ($element instanceof $this->elementType && isset($siteIds[$element->siteId])) { $placeholderSourceIds[] = $element->getCanonicalId(); @@ -60,7 +71,7 @@ protected function placeholderCondition(Closure $condition): Closure } } - if (!empty($placeholderSourceIds)) { + if (! empty($placeholderSourceIds)) { $this->placeholderCondition = fn (Builder $q) => $q->whereIn('elements.id', $placeholderSourceIds); } else { $this->placeholderCondition = false; diff --git a/src/Database/Queries/Concerns/QueriesRelatedElements.php b/src/Database/Queries/Concerns/QueriesRelatedElements.php index 1e62addf8f8..ec7ac5929d9 100644 --- a/src/Database/Queries/Concerns/QueriesRelatedElements.php +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -1,5 +1,7 @@ relatedTo) { + if (! $this->relatedTo) { return; } - $this->query->beforeQuery(function (Builder $query) { + $this->beforeQuery(function (Builder $query) { $parser = new ElementRelationParamParser([ 'fields' => $this->customFields ? Arr::keyBy( $this->customFields, - fn(FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, + fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, ) : [], ]); $condition = $parser->parse($this->relatedTo, $this->siteId !== '*' ? $this->siteId : null); if ($condition === false) { - throw new QueryAbortedException(); + throw new QueryAbortedException; } $this->subQuery->where($condition); @@ -64,13 +71,13 @@ private function applyNotRelatedToParam(): void return; } - $this->query->beforeQuery(function () { + $this->beforeQuery(function () { $notRelatedToParam = $this->notRelatedTo; $parser = new ElementRelationParamParser([ 'fields' => $this->customFields ? Arr::keyBy( $this->customFields, - fn(FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, + fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, ) : [], ]); @@ -86,17 +93,20 @@ private function applyNotRelatedToParam(): void } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $notRelatedTo */ public function notRelatedTo($value): static { $this->notRelatedTo = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $notRelatedTo */ public function andNotRelatedTo($value): static @@ -111,18 +121,22 @@ public function andNotRelatedTo($value): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $relatedTo */ public function relatedTo($value): static { $this->relatedTo = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @throws NotSupportedException + * * @uses $relatedTo */ public function andRelatedTo($value): static @@ -137,19 +151,15 @@ public function andRelatedTo($value): static } /** - * @param $value - * @param $currentValue - * - * @return mixed * @throws NotSupportedException */ private function _andRelatedToCriteria($value, $currentValue): mixed { - if (!$value) { + if (! $value) { return false; } - if (!$currentValue) { + if (! $currentValue) { return $value; } @@ -167,5 +177,4 @@ private function _andRelatedToCriteria($value, $currentValue): mixed return $relatedTo; } - } diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index 79b475f4a4e..556384fa950 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -1,5 +1,7 @@ query->beforeQuery(function () { + $this->beforeQuery(function () { // Make sure the siteId param is set try { - if (!$this->elementType::isLocalized()) { + if (! $this->elementType::isLocalized()) { // The criteria *must* be set to the primary site ID $this->siteId = \Craft::$app->getSites()->getPrimarySite()->id; } else { @@ -30,7 +38,7 @@ protected function initializeQueriesSites(): void } } catch (SiteNotFoundException $e) { // Fail silently if Craft isn't installed yet or is in the middle of updating - if (\Craft::$app->getIsInstalled() && !\Craft::$app->getUpdates()->getIsCraftUpdatePending()) { + if (\Craft::$app->getIsInstalled() && ! \Craft::$app->getUpdates()->getIsCraftUpdatePending()) { throw $e; } @@ -44,8 +52,10 @@ protected function initializeQueriesSites(): void } /** - * @inheritdoc + * {@inheritdoc} + * * @throws InvalidArgumentException if $value is invalid + * * @uses $siteId */ public function site($value): static @@ -57,23 +67,23 @@ public function site($value): static } elseif ($value instanceof Site) { $this->siteId = $value->id; } elseif (is_string($value)) { - $this->siteId = \Craft::$app->getSites()->getSiteByHandle($value)?->id ?? throw new InvalidArgumentException('Invalid site handle: ' . $value); + $this->siteId = \Craft::$app->getSites()->getSiteByHandle($value)?->id ?? throw new InvalidArgumentException('Invalid site handle: '.$value); } else { - if ($not = (strtolower(reset($value)) === 'not')) { + if ($not = (strtolower((string) reset($value)) === 'not')) { array_shift($value); } $this->siteId = []; foreach (\Craft::$app->getSites()->getAllSites() as $site) { - if (in_array($site->handle, $value, true) === !$not) { + if (in_array($site->handle, $value, true) === ! $not) { $this->siteId[] = $site->id; } } if (empty($this->siteId)) { - throw new InvalidArgumentException('Invalid site param: [' . ($not ? 'not, ' : '') . implode(', ', - $value) . ']'); + throw new InvalidArgumentException('Invalid site param: ['.($not ? 'not, ' : '').implode(', ', + $value).']'); } } @@ -81,18 +91,19 @@ public function site($value): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $siteId */ public function siteId($value): static { - if (is_array($value) && strtolower(reset($value)) === 'not') { + if (is_array($value) && strtolower((string) reset($value)) === 'not') { array_shift($value); $this->siteId = []; foreach (\Craft::$app->getSites()->getAllSites() as $site) { - if (!in_array($site->id, $value)) { + if (! in_array($site->id, $value)) { $this->siteId[] = $site->id; } } @@ -106,8 +117,10 @@ public function siteId($value): static } /** - * @inheritdoc + * {@inheritdoc} + * * @return static + * * @uses $siteId */ public function language($value): self @@ -119,26 +132,26 @@ public function language($value): self throw new InvalidArgumentException("Invalid language: $value"); } - $this->siteId = array_map(fn(Site $site) => $site->id, $sites); + $this->siteId = array_map(fn (Site $site) => $site->id, $sites); return $this; } - if ($not = (strtolower(reset($value)) === 'not')) { + if ($not = (strtolower((string) reset($value)) === 'not')) { array_shift($value); } $this->siteId = []; foreach (\Craft::$app->getSites()->getAllSites() as $site) { - if (in_array($site->language, $value, true) === !$not) { + if (in_array($site->language, $value, true) === ! $not) { $this->siteId[] = $site->id; } } if (empty($this->siteId)) { - throw new InvalidArgumentException('Invalid language param: [' . ($not ? 'not, ' : '') . implode(', ', - $value) . ']'); + throw new InvalidArgumentException('Invalid language param: ['.($not ? 'not, ' : '').implode(', ', + $value).']'); } return $this; @@ -150,18 +163,18 @@ public function language($value): self private function normalizeSiteId(): void { $sitesService = \Craft::$app->getSites(); - if (!$this->siteId) { + if (! $this->siteId) { // Default to the current site $this->siteId = $sitesService->getCurrentSite()->id; } elseif ($this->siteId === '*') { $this->siteId = $sitesService->getAllSiteIds(); } elseif (is_numeric($this->siteId) || Arr::isNumeric($this->siteId)) { // Filter out any invalid site IDs - $siteIds = Collection::make((array)$this->siteId) - ->filter(fn($siteId) => $sitesService->getSiteById($siteId, true) !== null) + $siteIds = Collection::make((array) $this->siteId) + ->filter(fn ($siteId) => $sitesService->getSiteById($siteId, true) !== null) ->all(); if (empty($siteIds)) { - throw new QueryAbortedException(); + throw new QueryAbortedException; } $this->siteId = is_array($this->siteId) ? $siteIds : reset($siteIds); } diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php index 02e0b176411..fd42f2020c3 100644 --- a/src/Database/Queries/Concerns/QueriesStatuses.php +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -11,6 +11,8 @@ /** * @mixin \CraftCms\Cms\Database\Queries\ElementQuery + * + * @internal */ trait QueriesStatuses { diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index c1383d422ed..cc8670db4dc 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -1,10 +1,13 @@ query->beforeQuery(function (Builder $query) { + $this->beforeQuery(function (ElementQuery $query) { $this->applyStructureParams($query); }); - $this->query->afterQuery(function (Collection $collection) { + $this->afterQuery(function (Collection $collection) { if ($this->structureId) { return $collection->map(function ($element) { $element->structureId = $this->structureId; @@ -119,154 +137,181 @@ protected function initializeQueriesStructures(): void } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $withStructure */ public function withStructure(bool $value = true): static { $this->withStructure = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $structureId */ public function structureId(?int $value = null): static { $this->structureId = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $level */ public function level($value = null): static { $this->level = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $hasDescendants */ public function hasDescendants(bool $value = true): static { $this->hasDescendants = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $leaves */ public function leaves(bool $value = true): static { $this->leaves = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $ancestorOf */ public function ancestorOf(ElementInterface|int|null $value): static { $this->ancestorOf = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $ancestorDist */ public function ancestorDist(?int $value = null): static { $this->ancestorDist = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $descendantOf */ public function descendantOf(ElementInterface|int|null $value): static { $this->descendantOf = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $descendantDist */ public function descendantDist(?int $value = null): static { $this->descendantDist = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $siblingOf */ public function siblingOf(ElementInterface|int|null $value): static { $this->siblingOf = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $prevSiblingOf */ public function prevSiblingOf(ElementInterface|int|null $value): static { $this->prevSiblingOf = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $nextSiblingOf */ public function nextSiblingOf(ElementInterface|int|null $value): static { $this->nextSiblingOf = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $positionedBefore */ public function positionedBefore(ElementInterface|int|null $value): static { $this->positionedBefore = $value; + return $this; } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $positionedAfter */ public function positionedAfter(ElementInterface|int|null $value): static { $this->positionedAfter = $value; + return $this; } private function shouldJoinStructureData(): bool { - return ( - !$this->revisions && - ($this->withStructure ?? ($this->structureId && !$this->trashed)) - ); + return + ! $this->revisions && + ($this->withStructure ?? ($this->structureId && ! $this->trashed)); } - private function applyStructureParams(Builder $query): void + private function applyStructureParams(ElementQuery $query): void { if (! $this->shouldJoinStructureData()) { $structureParams = [ @@ -290,7 +335,7 @@ private function applyStructureParams(Builder $query): void return; } - $this->query->addSelect([ + $query->query->addSelect([ 'structureelements.root', 'structureelements.lft', 'structureelements.rgt', @@ -298,17 +343,17 @@ private function applyStructureParams(Builder $query): void ]); if ($this->structureId) { - $this->query->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join + $query->query->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'subquery.elementsId') ->where('structureelements.structureId', $this->structureId)); - $this->subQuery->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join - ->whereColumn('structureelements.elementId', 'subquery.elementsId') + $query->subQuery->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join + ->whereColumn('structureelements.elementId', 'elements.id') ->where('structureelements.structureId', $this->structureId)); } else { - $this->query + $query->query ->addSelect('structureelements.structureId') - ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join + ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'subquery.elementsId') ->whereColumn('structureelements.structureId', 'subquery.structureId')); @@ -322,26 +367,26 @@ private function applyStructureParams(Builder $query): void ->whereColumn('id', 'structureelements.structureId') ->whereNull('dateDeleted'); - $this->subQuery - ->addSelect('structureelements.structureId as sructureId') - ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn(JoinClause $join) => $join + $query->subQuery + ->addSelect('structureelements.structureId as structureId') + ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'elements.id') ->whereExists($existsQuery) ); } if (isset($this->hasDescendants)) { - $this->subQuery->when( + $query->subQuery->when( $this->hasDescendants, - fn(Builder $query) => $query->where('structureelements.rgt', '>', DB::raw('structureelements.lft + 1')), - fn(Builder $query) => $query->where('structureelements.rgt', '=', DB::raw('structureelements.lft + 1')), + fn (Builder $query) => $query->where('structureelements.rgt', '>', DB::raw('structureelements.lft + 1')), + fn (Builder $query) => $query->where('structureelements.rgt', '=', DB::raw('structureelements.lft + 1')), ); } if ($this->ancestorOf) { $ancestorOf = $this->normalizeStructureParamValue('ancestorOf'); - $this->subQuery + $query->subQuery ->where('structureelements.lft', '<', $ancestorOf->lft) ->where('structureelements.rgt', '>', $ancestorOf->rgt) ->where('structureelements.root', '>', $ancestorOf->root) @@ -354,7 +399,7 @@ private function applyStructureParams(Builder $query): void if ($this->descendantOf) { $descendantOf = $this->normalizeStructureParamValue('descendantOf'); - $this->subQuery + $query->subQuery ->where('structureelements.lft', '>', $descendantOf->lft) ->where('structureelements.rgt', '<', $descendantOf->rgt) ->where('structureelements.root', $descendantOf->root) @@ -371,7 +416,7 @@ private function applyStructureParams(Builder $query): void $siblingOf = $this->normalizeStructureParamValue($param); - $this->subQuery + $query->subQuery ->where('structureelements.level', $siblingOf->level) ->where('structureelements.root', $siblingOf->root) ->whereNot('structureelements.elementId', $siblingOf->id); @@ -380,25 +425,25 @@ private function applyStructureParams(Builder $query): void $parent = $siblingOf->getParent(); if (! $parent) { - throw new QueryAbortedException(); + throw new QueryAbortedException; } - $this->subQuery + $query->subQuery ->where('structureelements.lft', '>', $parent->lft) ->where('structureelements.rgt', '>', $parent->rgt); } switch ($param) { case 'prevSiblingOf': - $this->query->orderByDesc('structureelements.lft'); - $this->subQuery + $query->orderByDesc('structureelements.lft'); + $query->subQuery ->where('structureelements.lft', '<', $siblingOf->lft) ->orderByDesc('structureelements.lft') ->limit(1); break; case 'nextSiblingOf': - $this->query->orderBy('structureelements.lft'); - $this->subQuery + $query->orderBy('structureelements.lft'); + $query->subQuery ->where('structureelements.lft', '>', $siblingOf->lft) ->orderBy('structureelements.lft') ->limit(1); @@ -409,7 +454,7 @@ private function applyStructureParams(Builder $query): void if ($this->positionedBefore) { $positionedBefore = $this->normalizeStructureParamValue('positionedBefore'); - $this->subQuery + $query->subQuery ->where('structureelements.lft', '<', $positionedBefore->lft) ->where('structureelements.root', $positionedBefore->root); } @@ -417,7 +462,7 @@ private function applyStructureParams(Builder $query): void if ($this->positionedAfter) { $positionedAfter = $this->normalizeStructureParamValue('positionedAfter'); - $this->subQuery + $query->subQuery ->where('structureelements.lft', '>', $positionedAfter->rgt) ->where('structureelements.root', $positionedAfter->root); } @@ -425,10 +470,10 @@ private function applyStructureParams(Builder $query): void if ($this->level) { $allowNull = is_array($this->level) && in_array(null, $this->level, true); - $this->subQuery->when( + $query->subQuery->when( $allowNull, fn (Builder $q) => $q->where(function (Builder $q) { - $q->where(Db::parseNumericParam('structureelements.level', array_filter($this->level, fn($v) => $v !== null))) + $q->where(Db::parseNumericParam('structureelements.level', array_filter($this->level, fn ($v) => $v !== null))) ->orWhereNull('structureelements.level'); }), fn (Builder $q) => Db::parseNumericParam('structureelements.level', $this->level), @@ -436,16 +481,17 @@ private function applyStructureParams(Builder $query): void } if ($this->leaves) { - $this->subQuery->where('structureelements.rgt', DB::raw('structureelements.lft + 1')); + $query->subQuery->where('structureelements.rgt', DB::raw('structureelements.lft + 1')); } } /** * Normalizes a structure param value to either an Element object or false. * - * @param string $property The parameter’s property name. - * @param class-string $class The element class + * @param string $property The parameter’s property name. + * @param class-string $class The element class * @return ElementInterface The normalized element + * * @throws QueryAbortedException if the element can't be found */ private function normalizeStructureParamValue(string $property): ElementInterface @@ -453,25 +499,25 @@ private function normalizeStructureParamValue(string $property): ElementInterfac $element = $this->$property; if ($element === false) { - throw new QueryAbortedException(); + throw new QueryAbortedException; } - if ($element instanceof ElementInterface && !$element->lft) { + if ($element instanceof ElementInterface && ! $element->lft) { $element = $element->getCanonicalId(); if ($element === null) { - throw new QueryAbortedException(); + throw new QueryAbortedException; } } - if (!$element instanceof ElementInterface) { + if (! $element instanceof ElementInterface) { $element = \Craft::$app->getElements()->getElementById($element, $this->elementType, $this->siteId, [ 'structureId' => $this->structureId, ]); if ($element === null) { $this->$property = false; - throw new QueryAbortedException(); + throw new QueryAbortedException; } } @@ -482,7 +528,7 @@ private function normalizeStructureParamValue(string $property): ElementInterfac if (! $element->lft) { $this->$property = false; - throw new QueryAbortedException(); + throw new QueryAbortedException; } } diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php index 58e36c805a6..3fcb26e7d68 100644 --- a/src/Database/Queries/Concerns/QueriesUniqueElements.php +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -1,5 +1,7 @@ unique || - !\Craft::$app->getIsMultiSite(false, true) || + ! $this->unique || + ! \Craft::$app->getIsMultiSite(false, true) || ( $this->siteId && - (!is_array($this->siteId) || count($this->siteId) === 1) + (! is_array($this->siteId) || count($this->siteId) === 1) ) ) { return; @@ -40,7 +49,7 @@ protected function initializeQueriesUniqueElements(): void $sitesService = \Craft::$app->getSites(); - if (!$this->preferSites) { + if (! $this->preferSites) { $preferSites = [$sitesService->getCurrentSite()->id]; } else { $preferSites = []; @@ -71,9 +80,9 @@ protected function initializeQueriesUniqueElements(): void ->toRawSql(); // `elements` => `subElements` - $qElements = DB::getTablePrefix() . 'Concerns' . Table::ELEMENTS; - $qSubElements = DB::getTablePrefix() . '.subElements'; - $qTmpElements = DB::getTablePrefix() . '.tmpElements'; + $qElements = DB::getTablePrefix().'Concerns'.Table::ELEMENTS; + $qSubElements = DB::getTablePrefix().'.subElements'; + $qTmpElements = DB::getTablePrefix().'.tmpElements'; $q = $qElements[0]; $subSelectSql = str_replace("$qElements.", "$qSubElements.", $subSelectSql); $subSelectSql = str_replace("$q $qElements", "$q $qSubElements", $subSelectSql); @@ -83,8 +92,8 @@ protected function initializeQueriesUniqueElements(): void } /** - * @inheritdoc - * @return static + * {@inheritdoc} + * * @uses $unique */ public function unique(bool $value = true): static @@ -95,7 +104,8 @@ public function unique(bool $value = true): static } /** - * @inheritdoc + * {@inheritdoc} + * * @uses $preferSites */ public function preferSites(?array $value = null): static diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php new file mode 100644 index 00000000000..3664a3baa23 --- /dev/null +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -0,0 +1,105 @@ +|null + * + * @see applySearchParam() + * @see applyOrderByParams() + * @see populate() + */ + private ?array $searchResults = null; + + protected function initializeSearchesElements(): void + { + $this->beforeQuery(function (ElementQuery $query) { + $this->applySearchParam($query); + }); + } + + /** + * Applies the 'search' param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applySearchParam(ElementQuery $query): void + { + $this->searchResults = null; + + if (! $query->search) { + return; + } + + $searchService = \Craft::$app->getSearch(); + + $scoreOrder = Arr::first($query->query->orders, fn ($order) => $order['column'] === 'score'); + + if ($scoreOrder || $searchService->shouldCallSearchElements($this)) { + // Get the scored results up front + $searchResults = $searchService->searchElements($this); + + if ($scoreOrder['direction'] === 'asc') { + $searchResults = array_reverse($searchResults, true); + } + + if (($query->query->orders[0]['column'] ?? null) === 'score') { + // Only use the portion we're actually querying for + if (is_int($query->query->offset) && $query->query->offset !== 0) { + $searchResults = array_slice($searchResults, $query->query->offset, null, true); + $query->subQuery->offset = null; + } + if (is_int($query->query->limit) && $query->query->limit !== 0) { + $searchResults = array_slice($searchResults, 0, $query->query->limit, true); + $query->subQuery->limit = null; + } + } + + if (empty($searchResults)) { + throw new QueryAbortedException; + } + + $this->searchResults = $searchResults; + + $elementIdsBySiteId = []; + foreach (array_keys($searchResults) as $key) { + [$elementId, $siteId] = explode('-', (string) $key, 2); + $elementIdsBySiteId[$siteId][] = $elementId; + } + + $query->subQuery->where(function (Builder $query) use ($elementIdsBySiteId) { + foreach ($elementIdsBySiteId as $siteId => $elementIds) { + $query->orWhere(function (Builder $query) use ($siteId, $elementIds) { + $query->where('elements_sites.siteId', $siteId) + ->whereIn('elements.id', $elementIds); + }); + } + }); + + return; + } + + // Just filter the main query by the search query + $searchQuery = $searchService->createDbQuery($this->search, $this); + + if ($searchQuery === false) { + throw new QueryAbortedException; + } + + $query->subQuery->whereIn('elements.id', $searchQuery->select('elementId')); + } +} diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 5333a7ebd4e..892195088e6 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -1,26 +1,33 @@ */ - use BuildsQueries; - use ForwardsCalls; + use BuildsQueries { BuildsQueries::sole as baseSole; } + use Concerns\CollectsCacheTags; + use Concerns\FormatsResults; use Concerns\QueriesCustomFields; use Concerns\QueriesDraftsAndRevisions; use Concerns\QueriesEagerly; + use Concerns\QueriesFields; use Concerns\QueriesPlaceholderElements; use Concerns\QueriesRelatedElements; use Concerns\QueriesSites; use Concerns\QueriesStatuses; use Concerns\QueriesStructures; use Concerns\QueriesUniqueElements; - use Concerns\ElementQueryTrait; + use Concerns\SearchesElements; + use ForwardsCalls; /** * The base query builder instance. @@ -54,18 +64,6 @@ class ElementQuery implements ElementQueryInterface */ protected Builder $subQuery; - /** - * The element being queried. - * - * @var class-string - */ - protected string $elementType; - - /** - * The table to be joined to elements. - */ - protected string $table = Table::ELEMENTS; - /** * All of the globally registered builder macros. */ @@ -80,6 +78,7 @@ class ElementQuery implements ElementQueryInterface * The properties that should be returned from query builder. * * @var string[] + * * @see \Illuminate\Database\Eloquent\Builder::$propertyPassthru for inspiration. */ protected array $propertyPassthru = [ @@ -90,6 +89,7 @@ class ElementQuery implements ElementQueryInterface * The methods that should be returned from query builder. * * @var string[] + * * @see \Illuminate\Database\Eloquent\Builder::$passthru for inspiration. */ protected array $passthru = [ @@ -129,6 +129,23 @@ class ElementQuery implements ElementQueryInterface 'value', ]; + protected array $passthruAggregates = [ + 'aggregate', + 'average', + 'avg', + 'count', + 'getcountforpagination', + 'max', + 'min', + 'numericaggregate', + 'sum', + ]; + + /** + * The callbacks that should be invoked before retrieving data from the database. + */ + protected array $beforeQueryCallbacks = []; + /** * The callbacks that should be invoked after retrieving data from the database. */ @@ -142,20 +159,49 @@ class ElementQuery implements ElementQueryInterface // Use ** as a placeholder for "all the default columns" protected array $columns = ['**']; + // For internal use + // ------------------------------------------------------------------------- + + /** + * @var array> Column alias => name mapping + * + * @see joinElementTable() + * @see applyOrderByParams() + * @see applySelectParam() + */ + private array $columnMap; + + /** + * @var bool Whether an element table has been joined for the query + * + * @see prepare() + * @see joinElementTable() + */ + private bool $joinedElementTable = false; + /** * Create a new Element query instance. * - * @param class-string $elementType + * @param class-string $elementType */ - public function __construct(string $elementType) - { - $this->elementType = $elementType; + public function __construct( + /** @var class-string */ + protected string $elementType = Element::class, + protected array $config = [], + ) { + Typecast::properties(static::class, $config); - $this->query = DB::query(); + foreach ($config as $key => $value) { + $this->{$key} = $value; + } - // Build the query - // --------------------------------------------------------------------- - $this->subQuery = DB::table(Table::ELEMENTS, 'elements'); + $this->query = DB::query()->select('**'); + $this->subQuery = DB::table(Table::ELEMENTS, 'elements') + ->select([ + 'elements.id as elementsId', + 'elements_sites.id as siteSettingsId', + ]) + ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.elementId', 'elements.id'); // Prepare a new column mapping // (for use in SELECT and ORDER BY clauses) @@ -168,14 +214,14 @@ public function __construct(string $elementType) ]; if ($this->elementType::hasTitles()) { - $this->columnMap[] = 'elements_sites.title as title'; + $this->columnMap['title'] = 'elements_sites.title'; } - $this->query->beforeQuery(fn (Builder $builder) => $this->elementQueryBeforeQuery($builder)); - $this->initializeTraits(); - $this->query->afterQuery(fn (Collection $collection) => $this->elementQueryAfterQuery($collection)); + $this->query->beforeQuery(function () { + $this->applyBeforeQueryCallbacks(); + }); } protected function initializeTraits(): void @@ -196,15 +242,15 @@ protected function initializeTraits(): void /** * Create a collection of elements from plain arrays. * - * @param array $items * @return \Illuminate\Database\Eloquent\Collection */ public function hydrate(array $items): Collection { - return new Collection(array_map(function ($item) { + return new Collection(array_map(fn ($item) => // @TODO: Actually populate - return new $this->elementType; - }, $items)); + new $this->elementType([ + 'id' => $item->id, + ]), $items)); } /** @@ -234,7 +280,7 @@ public function find($id, $columns = ['*']): ElementInterface|Collection|null return $this->findMany($id, $columns); } - return $this->where('id', $id)->first($columns); + return $this->where('elements.id', $id)->first($columns); } /** @@ -242,9 +288,9 @@ public function find($id, $columns = ['*']): ElementInterface|Collection|null * * @param \Illuminate\Contracts\Support\Arrayable|array $ids * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection|array */ - public function findMany($ids, $columns = ['*']): Collection + public function findMany($ids, $columns = ['*']): Collection|array { $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; @@ -252,7 +298,7 @@ public function findMany($ids, $columns = ['*']): Collection return new Collection; } - return $this->whereIn('id', $ids)->get($columns); + return $this->whereIn('elements.id', $ids)->get($columns); } /** @@ -381,13 +427,14 @@ public function sole($columns = ['*']): mixed * Execute the query as a "select" statement. * * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection|array */ - public function get($columns = ['*']): Collection + public function get($columns = ['*']): Collection|array { $models = $this->getModels($columns); - return $this->applyAfterQueryCallbacks(new Collection($models)); + return $this->applyAfterQueryCallbacks(new Collection($models)) + ->when($this->asArray, fn (Collection $collection) => $collection->all()); } /** @@ -403,7 +450,12 @@ public function getModels($columns = ['*']): array )->all(); } - public function all(array|string $columns = ['*']): Collection + /** + * Execute the query as a "select" statement. + * + * @return \Illuminate\Database\Eloquent\Collection|array + */ + public function all(array|string $columns = ['*']): Collection|array { return $this->get($columns); } @@ -413,17 +465,17 @@ public function one(array|string $columns = ['*']): ?ElementInterface return $this->first($columns); } - public function pluck($column, $key = null) + public function pluck($column, $key = null): Collection|array { $column = $this->columnMap[$column] ?? $column; - return $this->query->pluck($column, $key); + return $this->query->pluck($column, $key) + ->when($this->asArray, fn (Collection $collection) => $collection->all()); } /** * Register a closure to be invoked after the query is executed. * - * @param \Closure $callback * @return $this */ public function afterQuery(Closure $callback): self @@ -435,9 +487,6 @@ public function afterQuery(Closure $callback): self /** * Invoke the "after query" modification callbacks. - * - * @param mixed $result - * @return mixed */ public function applyAfterQueryCallbacks(mixed $result): mixed { @@ -457,7 +506,7 @@ public function cursor(): LazyCollection { return $this->applyScopes()->query->cursor()->map(function ($record) { // @TODO: Actually populate - $model = new $this->elementType; + $model = new $this->elementType(['id' => $record->id]); return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); })->reject(fn ($model) => is_null($model)); @@ -465,8 +514,6 @@ public function cursor(): LazyCollection /** * Get the underlying query builder instance. - * - * @return \Illuminate\Database\Query\Builder */ public function getQuery(): Builder { @@ -475,8 +522,6 @@ public function getQuery(): Builder /** * Get the underlying subquery builder instance. - * - * @return \Illuminate\Database\Query\Builder */ public function getSubQuery(): Builder { @@ -485,8 +530,6 @@ public function getSubQuery(): Builder /** * Get the "limit" value from the query or null if it's not set. - * - * @return mixed */ public function getLimit(): mixed { @@ -495,8 +538,6 @@ public function getLimit(): mixed /** * Get the "offset" value from the query or null if it's not set. - * - * @return mixed */ public function getOffset(): mixed { @@ -507,7 +548,6 @@ public function getOffset(): mixed * Get the given macro by name. * * @param string $name - * @return \Closure */ public function getMacro($name): Closure { @@ -518,7 +558,6 @@ public function getMacro($name): Closure * Checks if a macro is registered. * * @param string $name - * @return bool */ public function hasMacro($name): bool { @@ -529,7 +568,6 @@ public function hasMacro($name): bool * Get the given global macro by name. * * @param string $name - * @return \Closure */ public static function getGlobalMacro($name): Closure { @@ -540,7 +578,6 @@ public static function getGlobalMacro($name): Closure * Checks if a global macro is registered. * * @param string $name - * @return bool */ public static function hasGlobalMacro($name): bool { @@ -551,7 +588,6 @@ public static function hasGlobalMacro($name): bool * Dynamically access builder proxies. * * @param string $key - * @return mixed * * @throws \Exception */ @@ -569,7 +605,6 @@ public function __get($key): mixed * * @param string $method * @param array $parameters - * @return mixed */ public function __call($method, $parameters): mixed { @@ -596,9 +631,19 @@ public function __call($method, $parameters): mixed } if (in_array(strtolower($method), $this->passthru)) { + if (in_array(strtolower($method), $this->passthruAggregates)) { + $this->applyBeforeQueryCallbacks(); + } + return $this->getQuery()->{$method}(...$parameters); } + if (in_array(strtolower($method), ['orderby'])) { + $this->forwardCallTo($this->query, $method, $parameters); + + return $this; + } + $this->forwardCallTo($this->subQuery, $method, $parameters); return $this; @@ -609,7 +654,6 @@ public function __call($method, $parameters): mixed * * @param string $method * @param array $parameters - * @return mixed * * @throws \BadMethodCallException */ @@ -641,8 +685,6 @@ public static function __callStatic($method, $parameters): mixed /** * Register the given mixin with the builder. * - * @param string $mixin - * @param bool $replace * @return void */ protected static function registerMixin(string $mixin, bool $replace = true) @@ -666,7 +708,6 @@ public function clone(): self /** * Register a closure to be invoked on a clone. * - * @param \Closure $callback * @return $this */ public function onClone(Closure $callback): self @@ -677,16 +718,190 @@ public function onClone(Closure $callback): self } /** - * Force a clone of the underlying query builder when cloning. - * - * @return void + * Force a clone of the underlying query builders when cloning. */ - public function __clone() + public function __clone(): void { $this->query = clone $this->query; + $this->subQuery = clone $this->subQuery; foreach ($this->onCloneCallbacks as $onCloneCallback) { $onCloneCallback($this); } } + + /** + * Register a closure to be invoked after the query is executed. + * + * @return $this + */ + public function beforeQuery(Closure $callback): self + { + $this->beforeQueryCallbacks[] = $callback; + + return $this; + } + + public function applyBeforeQueryCallbacks(): void + { + foreach ($this->beforeQueryCallbacks as $callback) { + $callback($this); + } + + $this->beforeQueryCallbacks = []; + + $this->elementQueryBeforeQuery(); + } + + protected function elementQueryBeforeQuery(): void + { + // Is the query already doomed? + throw_if(isset($this->id) && empty($this->id), QueryAbortedException::class); + + // Give other classes a chance to make changes up front + /*if (!$this->beforePrepare()) { + throw new QueryAbortedException(); + }*/ + + // @TODO: Params? + // ->addParams($this->params); + + $this->applySelectParams(); + + // If an element table was never joined in, explicitly filter based on the element type + if (! $this->joinedElementTable && $this->elementType !== Element::class) { + try { + $ref = new ReflectionClass($this->elementType); + } catch (ReflectionException) { + $ref = null; + } + + if ($ref && ! $ref->isAbstract()) { + $this->subQuery->where('elements.type', $this->elementType); + } + } + + $this->query + ->fromSub($this->subQuery, 'subquery') + ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.id', 'subquery.siteSettingsId') + ->join(new Alias(Table::ELEMENTS, 'elements'), 'elements.id', 'subquery.elementsId'); + } + + /** + * Joins in a table with an `id` column that has a foreign key pointing to `elements.id`. + * + * The table will be joined with an alias based on the unprefixed table name. For example, + * if `{{%entries}}` is passed, the table will be aliased to `entries`. + * + * @param string $table The table name, e.g. `entries` or `{{%entries}}` + */ + protected function joinElementTable(string $table, ?string $alias = null): void + { + $alias ??= $table; + + $this->query->join(new Alias($table, $alias), "$alias.id", 'subquery.elementsId'); + $this->subQuery->join(new Alias($table, $alias), "$alias.id", 'elements.id'); + $this->joinedElementTable = true; + + // Add element table cols to the column map + foreach (Schema::getColumnListing($table) as $column) { + if (! isset($this->columnMap[$column])) { + $this->columnMap[$column] = "$alias.$column"; + } + } + } + + /** + * Applies the 'select' param to the query being executed. + */ + private function applySelectParams(): void + { + // Select all columns defined by [[select]], swapping out any mapped column names + $select = []; + $includeDefaults = false; + + foreach ($this->query->columns as $column) { + if ($column instanceof Alias) { + $column = $column->getValue($this->getGrammar()); + } + + [$column, $alias] = explode(' as ', $column, 2) + [1 => null]; + + $alias ??= $column; + + if ($column === '**') { + $includeDefaults = true; + } else { + // Is this a mapped column name? + if (is_string($column) && isset($this->columnMap[$column])) { + $column = $this->resolveColumnMapping($column); + + // Completely ditch the mapped name if instantiated elements are going to be returned + if (! $this->asArray && is_string($column)) { + $alias = $column; + } + } + + if (is_array($column)) { + $select[] = new Alias(new Coalesce($column), $alias); + } else { + $select[] = new Alias($column, $alias); + } + } + } + + // Is there still a ** placeholder param? + if (! $includeDefaults) { + $this->query->columns = $select; + + return; + } + + // Merge in the default columns + $select = array_merge($select, [ + 'elements.id', + 'elements.canonicalId', + 'elements.fieldLayoutId', + 'elements.uid', + 'elements.enabled', + 'elements.archived', + 'elements.dateLastMerged', + 'elements.dateCreated', + 'elements.dateUpdated', + 'elements_sites.id as siteSettingsId', + 'elements_sites.siteId', + 'elements_sites.title', + 'elements_sites.slug', + 'elements_sites.uri', + 'elements_sites.content', + 'elements_sites.enabled as enabledForSite', + ]); + + // If the query includes soft-deleted elements, include the date deleted + if ($this->trashed !== false) { + $select[] = 'elements.dateDeleted'; + } + + $this->query->columns = $select; + } + + private function resolveColumnMapping(string $key): string|array + { + if (! isset($this->columnMap[$key])) { + throw new InvalidArgumentException("Invalid column map key: $key"); + } + + // make sure it's not still a callback + if (is_callable($this->columnMap[$key])) { + $this->columnMap[$key] = $this->columnMap[$key](); + } elseif (is_array($this->columnMap[$key])) { + foreach ($this->columnMap[$key] as $i => $mapping) { + if (is_callable($mapping)) { + $this->columnMap[$key][$i] = $mapping(); + } + } + } + + return $this->columnMap[$key]; + } } diff --git a/src/Database/Queries/ElementQueryInterface.php b/src/Database/Queries/ElementQueryInterface.php index b6b4e93cc14..fa44473917f 100644 --- a/src/Database/Queries/ElementQueryInterface.php +++ b/src/Database/Queries/ElementQueryInterface.php @@ -15,18 +15,15 @@ interface ElementQueryInterface extends Builder { /** * Execute the query and get the first result. - * - * @param array|string $columns - * @return ElementInterface|null */ public function one(array|string $columns = ['*']): ?ElementInterface; /** * Execute the query as a "select" statement. * - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection|array */ - public function all(array|string $columns = ['*']): Collection; + public function all(array|string $columns = ['*']): Collection|array; /** * Causes the query results to be returned in reverse order. @@ -47,7 +44,7 @@ public function all(array|string $columns = ['*']): Collection; * ->all(); * ``` * - * @param bool $value The property value + * @param bool $value The property value * @return static self reference */ public function inReverse(bool $value = true): static; @@ -56,7 +53,7 @@ public function inReverse(bool $value = true): static; * Causes the query to return provisional drafts for the matching elements, * when they exist for the current user. * - * @param bool $value The property value (defaults to true) + * @param bool $value The property value (defaults to true) * @return static self reference */ public function withProvisionalDrafts(bool $value = true): static; @@ -82,7 +79,7 @@ public function withProvisionalDrafts(bool $value = true): static; * ->one(); * ``` * - * @param bool|null $value The property value (defaults to true) + * @param bool|null $value The property value (defaults to true) * @return static self reference */ public function drafts(?bool $value = true): static; @@ -112,7 +109,7 @@ public function drafts(?bool $value = true): static; * ->all(); * ``` * - * @param int|null $value The property value + * @param int|null $value The property value * @return static self reference */ public function draftId(?int $value = null): static; @@ -147,7 +144,7 @@ public function draftId(?int $value = null): static; * ->all(); * ``` * - * @param mixed $value The property value + * @param mixed $value The property value * @return static self reference */ public function draftOf(mixed $value): static; @@ -178,7 +175,7 @@ public function draftOf(mixed $value): static; * ->all(); * ``` * - * @param mixed $value The property value + * @param mixed $value The property value * @return static self reference */ public function draftCreator(mixed $value): static; @@ -204,7 +201,7 @@ public function draftCreator(mixed $value): static; * ->all(); * ``` * - * @param bool|null $value The property value + * @param bool|null $value The property value * @return static self reference */ public function provisionalDrafts(?bool $value = true): static; @@ -217,7 +214,7 @@ public function provisionalDrafts(?bool $value = true): static; * Unpublished drafts can be included as well if `drafts(null)` and * `draftOf(false)` are also passed. * - * @param bool $value The property value + * @param bool $value The property value * @return static self reference */ public function canonicalsOnly(bool $value = true): static; @@ -243,7 +240,7 @@ public function canonicalsOnly(bool $value = true): static; * ->all(); * ``` * - * @param bool $value The property value (defaults to true) + * @param bool $value The property value (defaults to true) * @return static self reference */ public function savedDraftsOnly(bool $value = true): static; @@ -269,7 +266,7 @@ public function savedDraftsOnly(bool $value = true): static; * ->one(); * ``` * - * @param bool|null $value The property value (defaults to true) + * @param bool|null $value The property value (defaults to true) * @return static self reference */ public function revisions(?bool $value = true): static; @@ -299,7 +296,7 @@ public function revisions(?bool $value = true): static; * ->all(); * ``` * - * @param int|null $value The property value + * @param int|null $value The property value * @return static self reference */ public function revisionId(?int $value = null): static; @@ -330,7 +327,7 @@ public function revisionId(?int $value = null): static; * ->all(); * ``` * - * @param mixed $value The property value + * @param mixed $value The property value * @return static self reference */ public function revisionOf(mixed $value): static; @@ -361,9 +358,8 @@ public function revisionOf(mixed $value): static; * ->all(); * ``` * - * @param mixed $value The property value + * @param mixed $value The property value * @return static self reference */ public function revisionCreator(mixed $value): static; } - diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index af0287fb079..27e525c7ec2 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -1,8 +1,82 @@ beforeQuery(function (self $query) { + $this->joinElementTable(Table::ENTRIES); + + $query->query->addSelect([ + 'entries.sectionId', + 'entries.fieldId', + 'entries.primaryOwnerId', + 'entries.typeId', + 'entries.postDate', + 'entries.expiryDate', + ]); + + if (Cms::config()->staticStatuses) { + $query->query->addSelect(['entries.status']); + } + }); + } + + protected function statusCondition(string $status): Closure + { + if ( + Cms::config()->staticStatuses && + in_array($status, [Entry::STATUS_LIVE, Entry::STATUS_PENDING, Entry::STATUS_EXPIRED]) + ) { + return fn (Builder $query) => $query->where('elements.enabled', true)->where('elements_sites.enabled', true)->where('entries.status', $status); + } + + // Always consider “now” to be the current time @ 59 seconds into the minute. + // This makes entry queries more cacheable, since they only change once every minute (https://github.com/craftcms/cms/issues/5389), + // while not excluding any entries that may have just been published in the past minute (https://github.com/craftcms/cms/issues/7853). + $currentTime = Date::now()->endOfMinute(); + + return match ($status) { + Entry::STATUS_LIVE => fn (Builder $query) => $query + ->where('elements.enabled', true) + ->where('elements_sites.enabled', true) + ->where('entries.postDate', '<=', $currentTime) + ->where(function (Builder $query) use ($currentTime) { + $query->whereNull('entries.expiryDate') + ->orWhere('entries.expiryDate', '>', $currentTime); + }), + Entry::STATUS_PENDING => fn (Builder $query) => $query + ->where('elements.enabled', true) + ->where('elements_sites.enabled', true) + ->where('entries.postDate', '>', $currentTime), + Entry::STATUS_EXPIRED => fn (Builder $query) => $query + ->where('elements.enabled', true) + ->where('elements_sites.enabled', true) + ->whereNotNull('entries.expiryDate') + ->where('entries.expiryDate', '<=', $currentTime), + default => parent::statusCondition($status), + }; + } } diff --git a/src/Database/Queries/Events/DefineCacheTags.php b/src/Database/Queries/Events/DefineCacheTags.php new file mode 100644 index 00000000000..6e7f8cda84c --- /dev/null +++ b/src/Database/Queries/Events/DefineCacheTags.php @@ -0,0 +1,16 @@ + + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\CraftCms\Cms\Site\Models\Site, $this, \Illuminate\Database\Eloquent\Relations\Pivot> */ public function sites(): BelongsToMany { - return $this->belongsToMany(Site::class, Table::ELEMENTS_SITES, 'elementId', 'siteId') - ->withPivot([ - 'title', - 'slug', - 'uri', - 'content', - 'enabled', - 'dateCreated', - 'dateUpdated', - 'uid', - ]); + return $this->belongsToMany( + related: Site::class, + table: Table::ELEMENTS_SITES, + foreignPivotKey: 'elementId', + relatedPivotKey: 'siteId' + )->using(ElementSiteSettings::class); + } + + /** + * @return HasMany + */ + public function siteSettings(): HasMany + { + return $this->hasMany(ElementSiteSettings::class, 'elementId'); } } diff --git a/src/Element/Models/ElementSiteSettings.php b/src/Element/Models/ElementSiteSettings.php new file mode 100644 index 00000000000..111cd1933fd --- /dev/null +++ b/src/Element/Models/ElementSiteSettings.php @@ -0,0 +1,36 @@ + 'bool', + 'content' => 'json', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Element\Models\Element, $this> + */ + public function element(): BelongsTo + { + return $this->belongsTo(Element::class, 'elementId'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Site\Models\Site, $this> + */ + public function site(): BelongsTo + { + return $this->belongsTo(Site::class, 'siteId'); + } +} diff --git a/src/Entry/Models/Entry.php b/src/Entry/Models/Entry.php index 483e70738bf..ca7d1a44bc6 100644 --- a/src/Entry/Models/Entry.php +++ b/src/Entry/Models/Entry.php @@ -12,7 +12,6 @@ use CraftCms\Cms\Shared\BaseModel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Support\Facades\DB; final class Entry extends BaseModel { diff --git a/src/Site/Models/Site.php b/src/Site/Models/Site.php index 341f384c604..7f5dbb86538 100644 --- a/src/Site/Models/Site.php +++ b/src/Site/Models/Site.php @@ -5,10 +5,14 @@ namespace CraftCms\Cms\Site\Models; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Models\Element; +use CraftCms\Cms\Element\Models\ElementSiteSettings; use CraftCms\Cms\Shared\BaseModel; use CraftCms\Cms\Shared\Concerns\HasUid; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; final class Site extends BaseModel @@ -34,4 +38,25 @@ public function siteGroup(): BelongsTo { return $this->belongsTo(SiteGroup::class, 'groupId'); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\CraftCms\Cms\Element\Models\Element, $this, \Illuminate\Database\Eloquent\Relations\Pivot> + */ + public function elements(): BelongsToMany + { + return $this->belongsToMany( + related: Element::class, + table: Table::ELEMENTS_SITES, + foreignPivotKey: 'siteId', + relatedPivotKey: 'elementId' + )->using(ElementSiteSettings::class); + } + + /** + * @return HasMany + */ + public function siteSettings(): HasMany + { + return $this->hasMany(ElementSiteSettings::class, 'siteId'); + } } diff --git a/src/Structure/Models/StructureElement.php b/src/Structure/Models/StructureElement.php index 6ec4724be82..ad8f32418c4 100644 --- a/src/Structure/Models/StructureElement.php +++ b/src/Structure/Models/StructureElement.php @@ -9,10 +9,12 @@ use CraftCms\Cms\Shared\BaseModel; use CraftCms\Cms\Shared\Concerns\HasUid; use CraftCms\Cms\Structure\Concerns\StructureNode; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; final class StructureElement extends BaseModel { + use HasFactory; use HasUid; use StructureNode; diff --git a/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php new file mode 100644 index 00000000000..5a0d6a40d1e --- /dev/null +++ b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php @@ -0,0 +1,38 @@ +getElements()->startCollectingCacheInfo(); + + $entry = Entry::factory()->create(); + + entryQuery()->id($entry->id)->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->toContain('element::'.$entry->id); +}); + +it('can define extra cache tags', function () { + Craft::$app->getElements()->startCollectingCacheInfo(); + + Event::listen(DefineCacheTags::class, function (DefineCacheTags $event) { + $event->tags[] = 'foo'; + }); + + entryQuery()->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->toContain(sprintf( + 'element::%s::%s', + EntryElement::class, + 'foo', + )); +}); diff --git a/tests/Database/Queries/Concerns/FormatsResultsTest.php b/tests/Database/Queries/Concerns/FormatsResultsTest.php new file mode 100644 index 00000000000..def50cf8fc9 --- /dev/null +++ b/tests/Database/Queries/Concerns/FormatsResultsTest.php @@ -0,0 +1,34 @@ +create([ + 'dateCreated' => now(), + ]); + + expect(entryQuery()->orderBy('id')->pluck('id')->all())->toBe([$element1->id, $element2->id, $element3->id]); + expect(entryQuery()->orderBy('id')->inReverse()->pluck('id')->all())->toBe([$element3->id, $element2->id, $element1->id]); +}); + +test('asArray', function () { + $element = Entry::factory()->create(); + + expect(entryQuery()->get())->toBeInstanceOf(Collection::class); + expect(entryQuery()->asArray()->get())->toBeArray(); + + expect(entryQuery()->pluck('id'))->toBeInstanceOf(Collection::class); + expect(entryQuery()->asArray()->pluck('id'))->toBeArray(); + + expect(entryQuery()->findMany([$element->id]))->toBeInstanceOf(Collection::class); + expect(entryQuery()->asArray()->findMany([$element->id]))->toBeArray(); +}); + +test('fixedOrder', function () { + [$element1, $element2, $element3] = Entry::factory(3)->create(); + + expect(entryQuery()->id([$element2->id, $element3->id, $element1->id])->fixedOrder()->pluck('id')->all())->toBe([$element2->id, $element3->id, $element1->id]); + expect(entryQuery()->id([$element3->id, $element1->id, $element2->id])->fixedOrder()->pluck('id')->all())->toBe([$element3->id, $element1->id, $element2->id]); + expect(entryQuery()->id(implode(', ', [$element3->id, $element1->id, $element2->id]))->fixedOrder()->pluck('id')->all())->toBe([$element3->id, $element1->id, $element2->id]); +}); diff --git a/tests/Database/Queries/Concerns/QueriesStatusesTest.php b/tests/Database/Queries/Concerns/QueriesStatusesTest.php index a9b92b1ae9b..255ea038fed 100644 --- a/tests/Database/Queries/Concerns/QueriesStatusesTest.php +++ b/tests/Database/Queries/Concerns/QueriesStatusesTest.php @@ -1,62 +1,43 @@ create([ - 'enabled' => true, - ]); + $element1 = Entry::factory()->enabled()->create(); - Element::factory()->create(['enabled' => false]); + Entry::factory()->disabled()->create(); - $element3 = Element::factory()->create([ - 'enabled' => true, - ]); - DB::table(Table::ELEMENTS_SITES)->where('elementId', $element3->id)->update([ - 'enabled' => false, - ]); + $element3 = Entry::factory()->enabled()->create(); + // Disabled in site + $element3->element->siteSettings->first()->update(['enabled' => false]); - expect(query()->count())->toBe(1); - expect(query()->firstOrFail()->id)->toBe($element1->id); + expect(entryQuery()->count())->toBe(1); + expect(entryQuery()->firstOrFail()->id)->toBe($element1->id); }); it('can query archived and statuses', function () { - $element1 = Element::factory()->create([ - 'enabled' => true, - ]); - - $element2 = Element::factory()->create([ - 'enabled' => true, - 'archived' => true, - ]); + $element1 = Entry::factory()->create(); + $element2 = Entry::factory()->archived()->create(); - expect(query()->count())->toBe(1); - expect(query()->first()->id)->toBe($element1->id); + expect(entryQuery()->count())->toBe(1); + expect(entryQuery()->first()->id)->toBe($element1->id); - expect(query()->archived()->count())->toBe(1); - expect(query()->archived()->first()->id)->toBe($element2->id); + expect(entryQuery()->archived()->count())->toBe(1); + expect(entryQuery()->archived()->first()->id)->toBe($element2->id); - expect(query()->status([ - \craft\base\Element::STATUS_ENABLED, - \craft\base\Element::STATUS_ARCHIVED, + expect(entryQuery()->status([ + Element::STATUS_ENABLED, + Element::STATUS_ARCHIVED, ])->count())->toBe(2); - expect(query()->status([ - \craft\base\Element::STATUS_ARCHIVED, + expect(entryQuery()->status([ + Element::STATUS_ARCHIVED, ])->count())->toBe(1); // Does not fail but doesn't apply parameters - expect(query()->status(['not'])->count())->toBe(1); + expect(entryQuery()->status(['not'])->count())->toBe(1); - expect(query()->status(['not', \craft\base\Element::STATUS_ENABLED])->count())->toBe(0); - expect(query()->status(['not', \craft\base\Element::STATUS_ARCHIVED])->count())->toBe(1); + expect(entryQuery()->status(['not', Element::STATUS_ENABLED])->count())->toBe(0); + expect(entryQuery()->status(['not', Element::STATUS_ARCHIVED])->count())->toBe(1); }); diff --git a/tests/Database/Queries/ElementQueryTest.php b/tests/Database/Queries/ElementQueryTest.php index 8a9e4a52bb8..fcbd1904d86 100644 --- a/tests/Database/Queries/ElementQueryTest.php +++ b/tests/Database/Queries/ElementQueryTest.php @@ -1,82 +1,55 @@ all())->toBeEmpty(); + expect(entryQuery()->all())->toBeEmpty(); + + $elements = EntryModel::factory(5)->create(); - Element::factory(5)->create(); + expect(entryQuery()->all())->toHaveCount(5); + expect(entryQuery()->get())->toHaveCount(5); + expect(entryQuery()->one())->toBeInstanceOf(Entry::class); + expect(entryQuery()->first())->toBeInstanceOf(Entry::class); + expect(entryQuery()->firstOrFail())->toBeInstanceOf(Entry::class); + expect(entryQuery()->limit(3)->get())->toHaveCount(3); + expect(entryQuery()->find($elements[0]->id))->toBeInstanceOf(Entry::class); + expect(entryQuery()->where('elements.id', $elements[0]->id))->sole()->toBeInstanceOf(Entry::class); + expect(entryQuery()->offset(4)->limit(10)->get())->toHaveCount(1); - expect(query()->all())->toHaveCount(5); - expect(query()->get())->toHaveCount(5); - expect(query()->one())->toBeInstanceOf(Entry::class); - expect(query()->first())->toBeInstanceOf(Entry::class); - expect(query()->limit(3)->get())->toHaveCount(3); - expect(query()->offset(4)->limit(10)->get())->toHaveCount(1); + $this->expectException(MultipleRecordsFoundException::class); + entryQuery()->sole(); + + $this->expectException(ModelNotFoundException::class); + entryQuery()->findOrFail(999); }); test('id', function () { - [$element1, $element2] = Element::factory(3)->create(); + [$element1, $element2] = EntryModel::factory(3)->create(); - expect(query()->id($element1->id)->get())->toHaveCount(1); - expect(query()->id([$element1->id, $element2->id])->get())->toHaveCount(2); - expect(query()->id(implode(',', [$element1->id, $element2->id]))->get())->toHaveCount(2); - expect(query()->id(implode(', ', [$element1->id, $element2->id]))->get())->toHaveCount(2); + expect(entryQuery()->id($element1->id)->get())->toHaveCount(1); + expect(entryQuery()->id([$element1->id, $element2->id])->get())->toHaveCount(2); + expect(entryQuery()->id(implode(',', [$element1->id, $element2->id]))->get())->toHaveCount(2); + expect(entryQuery()->id(implode(', ', [$element1->id, $element2->id]))->get())->toHaveCount(2); }); test('uid', function () { - [$element1, $element2] = Element::factory(3)->create(); + [$element1, $element2] = EntryModel::factory(3)->create(); - expect(query()->uid($element1->uid)->get())->toHaveCount(1); - expect(query()->uid([$element1->uid, $element2->uid])->get())->toHaveCount(2); - expect(query()->uid(implode(',', [$element1->uid, $element2->uid]))->get())->toHaveCount(2); - expect(query()->uid(implode(', ', [$element1->uid, $element2->uid]))->get())->toHaveCount(2); + expect(entryQuery()->uid($element1->element->uid)->get())->toHaveCount(1); + expect(entryQuery()->uid([$element1->element->uid, $element2->element->uid])->get())->toHaveCount(2); + expect(entryQuery()->uid(implode(',', [$element1->element->uid, $element2->element->uid]))->get())->toHaveCount(2); + expect(entryQuery()->uid(implode(', ', [$element1->element->uid, $element2->element->uid]))->get())->toHaveCount(2); }); test('trashed', function () { - Element::factory(2)->create(); - Element::factory(2)->trashed()->create(); - - expect(query()->count())->toBe(2); - expect(query()->trashed(true)->count())->toBe(2); - expect(query()->trashed(null)->count())->toBe(4); -}); - -test('dateCreated', function () { - $timezone = app()->getTimezone(); - - Date::setTestNow(Date::now($timezone)->startOfDay()); - - Element::factory()->create([ - 'dateCreated' => Date::now()->subDays(2), - ]); - - Element::factory()->create([ - 'dateCreated' => Date::now()->subDay(), - ]); - - Element::factory()->create([ - 'dateCreated' => Date::now(), - ]); - - expect(query()->count())->toBe(3); - expect(query()->dateCreated('>= ' . Date::now()->subDay()->toIso8601String())->count())->toBe(2); - expect(query()->dateCreated('> ' . Date::now()->subDay()->toIso8601String())->count())->toBe(1); -}); - -test('reverse', function () { - [$element1, $element2, $element3] = Element::factory(3)->create([ - 'dateCreated' => now(), - ]); + EntryModel::factory(2)->create(); + EntryModel::factory(2)->trashed()->create(); - expect(query()->pluck('id')->all())->toBe([$element3->id, $element2->id, $element1->id]); - expect(query()->inReverse()->pluck('id')->all())->toBe([$element1->id, $element2->id, $element3->id]); + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->trashed(true)->count())->toBe(2); + expect(entryQuery()->trashed(null)->count())->toBe(4); }); diff --git a/tests/Pest.php b/tests/Pest.php index 0f2986144ed..12ff20918f8 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Database\Queries\EntryQuery; use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Shared\Enums\LicenseKeyStatus; use CraftCms\Cms\Support\Str; @@ -52,3 +53,8 @@ function loadTestPlugin(): void ]); $reflectionClass->getProperty('pluginsLoaded')->setValue($plugins, true); } + +function entryQuery(): EntryQuery +{ + return new EntryQuery; +} From b9bf365d618155edcba75dd9f218362881f52517 Mon Sep 17 00:00:00 2001 From: Rias Date: Sun, 9 Nov 2025 20:20:24 +0100 Subject: [PATCH 04/52] wip --- src/Database/DatabaseServiceProvider.php | 13 + .../Queries/Concerns/QueriesCustomFields.php | 11 +- .../Queries/Concerns/QueriesFields.php | 384 +++++++++++---- .../Queries/Concerns/SearchesElements.php | 46 ++ src/Support/Query.php | 456 ++++++++++++++++++ .../Queries/Concerns/QueriesFieldsTest.php | 47 ++ tests/Database/Queries/ElementQueryTest.php | 18 - tests/Support/QueryTest.php | 9 + 8 files changed, 877 insertions(+), 107 deletions(-) create mode 100644 src/Support/Query.php create mode 100644 tests/Database/Queries/Concerns/QueriesFieldsTest.php create mode 100644 tests/Support/QueryTest.php diff --git a/src/Database/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index 8f7a13fcb24..f4ded65b6d3 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -6,6 +6,7 @@ use CraftCms\Aliases\Aliases; use CraftCms\Cms\Database\Commands\MigrateCommand; +use CraftCms\Cms\Support\Query; use Illuminate\Cache\DatabaseStore; use Illuminate\Contracts\Config\Repository; use Illuminate\Database\Connection; @@ -58,6 +59,18 @@ public function boot(Repository $config, Connection $db, \Illuminate\Cache\Repos public function registerQueryBuilderMacros(): void { + Builder::macro('applyParam', function (string $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null) { + Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType); + }); + + Builder::macro('applyNumericParam', function (string $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER) { + Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType); + }); + + Builder::macro('applyDateParam', function (string $column, mixed $param, string $defaultOperator = '=') { + Query::whereDateParam($this, $column, $param, $defaultOperator); + }); + Builder::macro('idByUid', fn (string $uid): ?int => (int) $this->where('uid', $uid)->value('id') ?: null); Builder::macro('idsByUids', fn (array $uids): array => $this->whereIn('uid', $uids)->pluck('id', 'uid')->all()); Builder::macro('uidById', fn (int $id): ?string => $this->where('id', $id)->value('uid') ?: null); diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index c1db6be444b..b57b2399f11 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -11,6 +11,7 @@ use craft\helpers\Db as DbHelper; use craft\models\FieldLayout; use CraftCms\Cms\Database\Expressions\JsonExtract; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use Illuminate\Contracts\Database\Query\Expression; use Tpetry\QueryExpressions\Function\Conditional\Coalesce; @@ -67,8 +68,8 @@ protected function initializeQueriesCustomFields(): void // Map custom field handles to their content values $this->addCustomFieldsToColumnMap(); - $this->beforeQuery(function () { - $this->applyCustomFieldParams(); + $this->beforeQuery(function (ElementQuery $query) { + $this->applyCustomFieldParams($query); }); } @@ -162,7 +163,7 @@ private function addToColumnMap(string $alias, string|callable|Expression $colum * * @throws QueryAbortedException */ - private function applyCustomFieldParams(): void + private function applyCustomFieldParams(ElementQuery $query): void { if (empty($this->customFields) && empty($this->generatedFields)) { return; @@ -244,9 +245,9 @@ private function applyCustomFieldParams(): void foreach ($generatedFieldColumns as $handle => $columns) { $column = count($columns) === 1 ? $columns[0] - : new Coalesce($columns)->getValue($this->subQuery->getGrammar()); + : new Coalesce($columns)->getValue($query->subQuery->getGrammar()); - $this->subQuery->where(DbHelper::parseParam($column, $fieldAttributes->$handle)); + $query->subQuery->where(DbHelper::parseParam($column, $fieldAttributes->$handle)); } } } diff --git a/src/Database/Queries/Concerns/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php index 248a060a2ac..d0150647d8c 100644 --- a/src/Database/Queries/Concerns/QueriesFields.php +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -4,10 +4,9 @@ namespace CraftCms\Cms\Database\Queries\Concerns; -use craft\helpers\Db as DbHelper; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Support\Arr; -use Illuminate\Database\Query\Builder; +use CraftCms\Cms\Support\Query; use Tpetry\QueryExpressions\Language\Alias; /** @@ -81,15 +80,6 @@ trait QueriesFields */ public mixed $uri = null; - /** - * @var mixed The search term to filter the resulting elements by. - * - * See [Searching](https://craftcms.com/docs/5.x/system/searching.html) for supported syntax options. - * - * @used-by ElementQuery::search() - */ - public mixed $search = null; - /** * @var string|null The bulk element operation key that the resulting elements were involved in. * @@ -108,69 +98,52 @@ trait QueriesFields protected function initializeQueriesFields(): void { - $this->subQuery->beforeQuery(function (Builder $query) { + $this->beforeQuery(function (ElementQuery $query) { if ($this->id) { - foreach (DbHelper::parseNumericParam('elements.id', $this->id) as $column => $values) { - $query->whereIn($column, Arr::wrap($values)); - } + Query::whereNumericParam($query->subQuery, 'elements.id', $this->id); + // $query->subQuery->whereNumericParam('elements.id', $this->id); } if ($this->uid) { - foreach (DbHelper::parseParam('elements.uid', $this->uid) as $column => $values) { - $query->whereIn($column, Arr::wrap($values)); - } + Query::whereParam($query->subQuery, 'elements.uid', $this->uid); } if ($this->siteSettingsId) { - foreach (DbHelper::parseNumericParam('elements_sites.id', $this->siteSettingsId) as $column => $values) { - $query->whereIn($column, Arr::wrap($values)); - } + Query::whereNumericParam($query->subQuery, 'elements_sites.id', $this->siteSettingsId); } match ($this->trashed) { - true => $query->whereNotNull('elements.dateDeleted'), - false => $query->whereNull('elements.dateDeleted'), + true => $query->subQuery->whereNotNull('elements.dateDeleted'), + false => $query->subQuery->whereNull('elements.dateDeleted'), default => null, }; if ($this->dateCreated) { - $parsed = DbHelper::parseDateParam('elements.dateCreated', $this->dateCreated); - - $operator = $parsed[0]; - $column = $parsed[1]; - $value = $parsed[2] ?? null; - - if (is_null($value)) { - $value = $column; - $column = $operator; - $operator = '='; - } - - $query->where($column, $operator, $value); + Query::whereDateParam($query->subQuery, 'elements.dateCreated', $this->dateCreated); } if ($this->dateUpdated) { - $query->where(DbHelper::parseDateParam('elements.dateUpdated', $this->dateUpdated)); + Query::whereDateParam($query->subQuery, 'elements.dateUpdated', $this->dateUpdated); } if (isset($this->title) && $this->title !== '' && $this->elementType::hasTitles()) { if (is_string($this->title)) { - $this->title = DbHelper::escapeCommas($this->title); + $this->title = Query::escapeCommas($this->title); } - $query->where(DbHelper::parseParam('elements_sites.title', $this->title, '=', true)); + Query::whereParam($query->subQuery, 'elements_sites.title', $this->title, caseInsensitive: true); } if ($this->slug) { - $query->where(DbHelper::parseParam('elements_sites.slug', $this->slug)); + Query::whereParam($query->subQuery, 'elements_sites.slug', $this->slug); } if ($this->uri) { - $query->where(DbHelper::parseParam('elements_sites.uri', $this->uri, '=', true)); + Query::whereParam($query->subQuery, 'elements_sites.uri', $this->uri, caseInsensitive: true); } if ($this->inBulkOp) { - $query + $query->subQuery ->join(new Alias(Table::ELEMENTS_BULKOPS, 'elements_bulkops'), 'elements_bulkops.elementId', 'elements.id') ->where('elements_bulkops.key', $this->inBulkOp); } @@ -178,11 +151,43 @@ protected function initializeQueriesFields(): void } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ IDs. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | with an ID of 1. + * | `'not 1'` | not with an ID of 1. + * | `[1, 2]` | with an ID of 1 or 2. + * | `['not', 1, 2]` | not with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch the {element} by its ID #} + * {% set {element-var} = {twig-method} + * .id(1) + * .one() %} + * ``` + * + * ```php + * // Fetch the {element} by its ID + * ${element-var} = {php-method} + * ->id(1) + * ->one(); + * ``` + * + * --- * - * @uses $id + * ::: tip + * This can be combined with [[fixedOrder()]] if you want the results to be returned in a specific order. + * ::: + * + * @param mixed $value The property value + * @return static self reference */ - public function id($value): static + public function id(mixed $value): static { $this->id = $value; @@ -190,11 +195,28 @@ public function id($value): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ UIDs. + * + * --- + * + * ```twig + * {# Fetch the {element} by its UID #} + * {% set {element-var} = {twig-method} + * .uid('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') + * .one() %} + * ``` * - * @uses $uid + * ```php + * // Fetch the {element} by its UID + * ${element-var} = {php-method} + * ->uid('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') + * ->one(); + * ``` + * + * @param mixed $value The property value + * @return static self reference */ - public function uid($value): static + public function uid(mixed $value): static { $this->uid = $value; @@ -202,11 +224,37 @@ public function uid($value): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ IDs in the `elements_sites` table. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | with an `elements_sites` ID of 1. + * | `'not 1'` | not with an `elements_sites` ID of 1. + * | `[1, 2]` | with an `elements_sites` ID of 1 or 2. + * | `['not', 1, 2]` | not with an `elements_sites` ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch the {element} by its ID in the elements_sites table #} + * {% set {element-var} = {twig-method} + * .siteSettingsId(1) + * .one() %} + * ``` + * + * ```php + * // Fetch the {element} by its ID in the elements_sites table + * ${element-var} = {php-method} + * ->siteSettingsId(1) + * ->one(); + * ``` * - * @uses $siteSettingsId + * @param mixed $value The property value + * @return static self reference */ - public function siteSettingsId($value): static + public function siteSettingsId(mixed $value): static { $this->siteSettingsId = $value; @@ -214,9 +262,26 @@ public function siteSettingsId($value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that have been soft-deleted. * - * @uses $trashed + * --- + * + * ```twig + * {# Fetch trashed {elements} #} + * {% set {elements-var} = {twig-method} + * .trashed() + * .all() %} + * ``` + * + * ```php + * // Fetch trashed {elements} + * ${elements-var} = {element-class}::find() + * ->trashed() + * ->all(); + * ``` + * + * @param bool|null $value The property value (defaults to true) + * @return static self reference */ public function trashed(?bool $value = true): static { @@ -226,9 +291,41 @@ public function trashed(?bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ creation dates. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'>= 2018-04-01'` | that were created on or after 2018-04-01. + * | `'< 2018-05-01'` | that were created before 2018-05-01. + * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were created between 2018-04-01 and 2018-05-01. + * | `now`/`today`/`tomorrow`/`yesterday` | that were created at midnight of the specified relative date. + * + * --- + * + * ```twig + * {# Fetch {elements} created last month #} + * {% set start = date('first day of last month')|atom %} + * {% set end = date('first day of this month')|atom %} + * + * {% set {elements-var} = {twig-method} + * .dateCreated(['and', ">= #{start}", "< #{end}"]) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} created last month + * $start = (new \DateTime('first day of last month'))->format(\DateTime::ATOM); + * $end = (new \DateTime('first day of this month'))->format(\DateTime::ATOM); * - * @uses $dateCreated + * ${elements-var} = {php-method} + * ->dateCreated(['and', ">= {$start}", "< {$end}"]) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference */ public function dateCreated(mixed $value): static { @@ -238,9 +335,39 @@ public function dateCreated(mixed $value): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ last-updated dates. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'>= 2018-04-01'` | that were updated on or after 2018-04-01. + * | `'< 2018-05-01'` | that were updated before 2018-05-01. + * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were updated between 2018-04-01 and 2018-05-01. + * | `now`/`today`/`tomorrow`/`yesterday` | that were updated at midnight of the specified relative date. + * + * --- + * + * ```twig + * {# Fetch {elements} updated in the last week #} + * {% set lastWeek = date('1 week ago')|atom %} * - * @uses $dateUpdated + * {% set {elements-var} = {twig-method} + * .dateUpdated(">= #{lastWeek}") + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} updated in the last week + * $lastWeek = (new \DateTime('1 week ago'))->format(\DateTime::ATOM); + * + * ${elements-var} = {php-method} + * ->dateUpdated(">= {$lastWeek}") + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference */ public function dateUpdated(mixed $value): static { @@ -250,11 +377,40 @@ public function dateUpdated(mixed $value): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ titles. + * + * Possible values include: * - * @uses $title + * | Value | Fetches {elements}… + * | - | - + * | `'Foo'` | with a title of `Foo`. + * | `'Foo*'` | with a title that begins with `Foo`. + * | `'*Foo'` | with a title that ends with `Foo`. + * | `'*Foo*'` | with a title that contains `Foo`. + * | `'not *Foo*'` | with a title that doesn’t contain `Foo`. + * | `['*Foo*', '*Bar*']` | with a title that contains `Foo` or `Bar`. + * | `['not', '*Foo*', '*Bar*']` | with a title that doesn’t contain `Foo` or `Bar`. + * + * --- + * + * ```twig + * {# Fetch {elements} with a title that contains "Foo" #} + * {% set {elements-var} = {twig-method} + * .title('*Foo*') + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} with a title that contains "Foo" + * ${elements-var} = {php-method} + * ->title('*Foo*') + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference */ - public function title($value): static + public function title(mixed $value): static { $this->title = $value; @@ -262,11 +418,46 @@ public function title($value): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ slugs. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'foo'` | with a slug of `foo`. + * | `'foo*'` | with a slug that begins with `foo`. + * | `'*foo'` | with a slug that ends with `foo`. + * | `'*foo*'` | with a slug that contains `foo`. + * | `'not *foo*'` | with a slug that doesn’t contain `foo`. + * | `['*foo*', '*bar*']` | with a slug that contains `foo` or `bar`. + * | `['not', '*foo*', '*bar*']` | with a slug that doesn’t contain `foo` or `bar`. + * + * --- + * + * ```twig + * {# Get the requested {element} slug from the URL #} + * {% set requestedSlug = craft.app.request.getSegment(3) %} + * + * {# Fetch the {element} with that slug #} + * {% set {element-var} = {twig-method} + * .slug(requestedSlug|literal) + * .one() %} + * ``` * - * @uses $slug + * ```php + * // Get the requested {element} slug from the URL + * $requestedSlug = \Craft::$app->request->getSegment(3); + * + * // Fetch the {element} with that slug + * ${element-var} = {php-method} + * ->slug(\craft\helpers\Db::escapeParam($requestedSlug)) + * ->one(); + * ``` + * + * @param mixed $value The property value + * @return static self reference */ - public function slug($value): static + public function slug(mixed $value): static { $this->slug = $value; @@ -274,33 +465,57 @@ public function slug($value): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ URIs. * - * @uses $uri - */ - public function uri($value): static - { - $this->uri = $value; - - return $this; - } - - /** - * {@inheritdoc} + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'foo'` | with a URI of `foo`. + * | `'foo*'` | with a URI that begins with `foo`. + * | `'*foo'` | with a URI that ends with `foo`. + * | `'*foo*'` | with a URI that contains `foo`. + * | `'not *foo*'` | with a URI that doesn’t contain `foo`. + * | `['*foo*', '*bar*']` | with a URI that contains `foo` or `bar`. + * | `['not', '*foo*', '*bar*']` | with a URI that doesn’t contain `foo` or `bar`. + * + * --- + * + * ```twig + * {# Get the requested URI #} + * {% set requestedUri = craft.app.request.getPathInfo() %} * - * @uses $search + * {# Fetch the {element} with that URI #} + * {% set {element-var} = {twig-method} + * .uri(requestedUri|literal) + * .one() %} + * ``` + * + * ```php + * // Get the requested URI + * $requestedUri = \Craft::$app->request->getPathInfo(); + * + * // Fetch the {element} with that URI + * ${element-var} = {php-method} + * ->uri(\craft\helpers\Db::escapeParam($requestedUri)) + * ->one(); + * ``` + * + * @param mixed $value The property value + * @return static self reference */ - public function search($value): static + public function uri(mixed $value): static { - $this->search = $value; + $this->uri = $value; return $this; } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that were involved in a bulk element operation. * - * @uses $inBulkOp + * @param string|null $value The property value + * @return static self reference */ public function inBulkOp(?string $value): static { @@ -310,11 +525,12 @@ public function inBulkOp(?string $value): static } /** - * {@inheritdoc} + * Narrows the query results based on a reference string. * - * @uses $ref + * @param mixed $value The property value + * @return static self reference */ - public function ref($value): static + public function ref(mixed $value): static { $this->ref = $value; diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php index 3664a3baa23..f2bc618d10c 100644 --- a/src/Database/Queries/Concerns/SearchesElements.php +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -16,6 +16,15 @@ */ trait SearchesElements { + /** + * @var mixed The search term to filter the resulting elements by. + * + * See [Searching](https://craftcms.com/docs/5.x/system/searching.html) for supported syntax options. + * + * @used-by ElementQuery::search() + */ + public mixed $search = null; + /** * @var array|null * @@ -102,4 +111,41 @@ private function applySearchParam(ElementQuery $query): void $query->subQuery->whereIn('elements.id', $searchQuery->select('elementId')); } + + /** + * Narrows the query results to only {elements} that match a search query. + * + * See [Searching](https://craftcms.com/docs/5.x/system/searching.html) for a full explanation of how to work with this parameter. + * + * --- + * + * ```twig + * {# Get the search query from the 'q' query string param #} + * {% set searchQuery = craft.app.request.getQueryParam('q') %} + * + * {# Fetch all {elements} that match the search query #} + * {% set {elements-var} = {twig-method} + * .search(searchQuery) + * .all() %} + * ``` + * + * ```php + * // Get the search query from the 'q' query string param + * $searchQuery = \Craft::$app->request->getQueryParam('q'); + * + * // Fetch all {elements} that match the search query + * ${elements-var} = {php-method} + * ->search($searchQuery) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + */ + public function search($value): static + { + $this->search = $value; + + return $this; + } } diff --git a/src/Support/Query.php b/src/Support/Query.php new file mode 100644 index 00000000000..446791d4913 --- /dev/null +++ b/src/Support/Query.php @@ -0,0 +1,456 @@ +=', '<', '>', '=']; + + /** + * Parses a query param value and applies it to a query builder. + * + * If the `$value` is a string, it will automatically be converted to an array, split on any commas within the + * string (via [[Arr::toArray()]]). If that is not desired behavior, you can escape the comma + * with a backslash before it. + * + * The first value can be set to either `and`, `or`, or `not` to define whether *all*, *any*, or *none* of the values must match. + * (`or` will be assumed by default.) + * + * Values can begin with the operators `'not '`, `'!='`, `'<='`, `'>='`, `'<'`, `'>'`, or `'='`. If they don’t, + * `'='` will be assumed. + * + * Values can also be set to either `':empty:'` or `':notempty:'` if you want to search for empty or non-empty + * database values. (An “empty” value is either `NULL` or an empty string of text). + * + * @param string $column The database column that the param is targeting. + * @param string|int|array $value The param value(s). + * @param string $defaultOperator The default operator to apply to the values + * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) + * @param bool $caseInsensitive Whether the resulting condition should be case-insensitive + * @param self::TYPE_* $columnType The database column type the param is targeting + */ + public static function whereParam( + Builder $query, + string $column, + mixed $param, + string $defaultOperator = '=', + bool $caseInsensitive = false, + ?string $columnType = null, + ): void { + $parsed = QueryParam::parse($param); + + if (empty($parsed->values)) { + return; + } + + $parsedColumnType = $columnType + ? self::parseColumnType($columnType) + : null; + + $isMysql = $query->getConnection()->getDriverName() === 'mysql'; + + // Only PostgreSQL supports case-sensitive strings on non-JSON column values + if ($isMysql && $columnType !== self::TYPE_JSON) { + $caseInsensitive = false; + } + + $caseColumn = $caseInsensitive + ? $column + : new Lower($column); + + $query->where(function (Builder $query) use ($caseColumn, $isMysql, $parsedColumnType, $columnType, $defaultOperator, $parsed, $column, $caseInsensitive) { + $boolean = match ($parsed->operator) { + QueryParam::AND, QueryParam::NOT => 'and', + default => 'or', + }; + + $inVals = []; + $notInVals = []; + + foreach ($parsed->values as $value) { + $value = self::normalizeEmptyValue($value); + $operator = self::parseParamOperator($value, $defaultOperator, $parsed->operator === QueryParam::NOT); + + if ($columnType !== null) { + if ($parsedColumnType === self::TYPE_BOOLEAN) { + // Convert val to a boolean + $value = ($value && $value !== ':empty:'); + if ($operator === '!=') { + $value = ! $value; + } + $query->where($column, $value, boolean: $boolean); + + continue; + } + + if ( + $value !== ':empty:' && + ! is_numeric($value) && + self::isNumericColumnType($columnType) + ) { + throw new InvalidArgumentException("Invalid numeric value: $value"); + } + } + + if ($value === ':empty:') { + // If this is a textual column type, also check for empty strings + if ( + ($columnType === null && $isMysql) || + ($columnType !== null && self::isTextualColumnType($columnType)) + ) { + $query->where(function (Builder $query) use ($column) { + $query->whereNull($column)->orWhere($column, ''); + }, boolean: $boolean); + + continue; + } + + $query->whereNull($column, boolean: $boolean); + + continue; + } + + if (is_string($value)) { + // Trim any whitespace from the value + $value = trim($value); + + // This could be a LIKE condition + if ($operator === '=' || $operator === '!=') { + $value = preg_replace('/^\*|(?where($caseColumn, $operator, self::escapeForLike($value), boolean: $boolean); + + continue; + } + + $query->where($column, $operator, self::escapeForLike($value), boolean: $boolean); + + continue; + } + + if ($caseInsensitive) { + $value = mb_strtolower($value); + } + } + + // ['or', 1, 2, 3] => IN (1, 2, 3) + if (strtolower($parsed->operator) === QueryParam::OR && $operator === '=') { + $inVals[] = $value; + + continue; + } + + // ['and', '!=1', '!=2', '!=3'] => NOT IN (1, 2, 3) + if (strtolower($parsed->operator) === QueryParam::AND && $operator === '!=') { + $notInVals[] = $value; + + continue; + } + + $query->where($caseColumn, $operator, $value, boolean: $boolean); + } + + if (! empty($inVals)) { + $query->whereIn($caseColumn, $inVals, boolean: $boolean); + } + + if (! empty($notInVals)) { + $query->whereNotIn($caseColumn, $notInVals, boolean: $boolean); + } + }); + } + + /** + * Applies a query param value for a numeric column to a Query builder. + * + * The follow values are supported: + * + * - A number + * - `:empty:` or `:notempty:` + * - `'not x'` or `'!= x'` + * - `'> x'`, `'>= x'`, `'< x'`, or `'<= x'`, or a combination of those + * + * @param string $column The database column that the param is targeting. + * @param string|string[] $value The param value + * @param string $defaultOperator The default operator to apply to the values + * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) + * @param string|null $columnType The database column type the param is targeting + */ + public static function whereNumericParam( + Builder $query, + string $column, + mixed $value, + string $defaultOperator = '=', + ?string $columnType = self::TYPE_INTEGER, + ): void { + self::whereParam($query, $column, $value, $defaultOperator, false, $columnType); + } + + /** + * Parses a query param value for a date/time column, and returns a + * [[\yii\db\QueryInterface::where()]]-compatible condition. + * + * @param string $column The database column that the param is targeting. + * @param string|array|DateTimeInterface $value The param value + * @param string $defaultOperator The default operator to apply to the values + * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) + */ + public static function whereDateParam( + Builder $query, + string $column, + mixed $value, + string $defaultOperator = '=' + ): void { + $param = QueryParam::parse($value); + + if (empty($param->values)) { + return; + } + + $normalizedValues = [$param->operator]; + + foreach ($param->values as $val) { + // Is this an empty value? + $val = self::normalizeEmptyValue($val); + + if ($val === ':empty:' || $val === 'not :empty:') { + $normalizedValues[] = $val; + + // Sneak out early + continue; + } + + $operator = self::parseParamOperator($val, $defaultOperator); + + // Assume that date params are set in the system timezone + $val = Date::parse($val); + + $normalizedValues[] = $operator.$val; + } + + self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME); + } + + /** + * Parses a column type definition and returns just the column type, if it can be determined. + */ + public static function parseColumnType(string $columnType): ?string + { + if (! preg_match('/^\w+/', $columnType, $matches)) { + return null; + } + + return strtolower($matches[0]); + } + + /** + * Normalizes “empty” values. + * + * @param mixed $value The param value. + * @return mixed $value The normalized value. + */ + private static function normalizeEmptyValue(mixed $value): mixed + { + if ($value === null) { + return ':empty:'; + } + + if (! is_string($value) || $value === ':empty:' || $value === 'not :empty:') { + return $value; + } + + $lower = strtolower($value); + + if ($lower === ':empty:') { + return ':empty:'; + } + + if ($lower === ':notempty:' || $lower === 'not :empty:') { + return 'not :empty:'; + } + + return $value; + } + + /** + * Extracts the operator from a DB param and returns it. + * + * @param mixed $value Te param value. + * @param string $default The default operator to use + * @param bool $negate Whether to reverse whatever the selected operator is + * @return string The operator ('!=', '<=', '>=', '<', '>', or '=') + */ + private static function parseParamOperator(mixed &$value, string $default, bool $negate = false): string + { + $op = null; + + if (is_string($value)) { + foreach (self::$operators as $operator) { + // Does the value start with this operator? + if (stripos($value, $operator) === 0) { + $value = mb_substr($value, strlen($operator)); + $op = $operator === 'not ' ? '!=' : $operator; + break; + } + + // Does it start with this operator, but escaped? + if (stripos($value, "\\$operator") === 0) { + $value = substr($value, 1); + break; + } + } + } + + $op ??= trim($default) === 'not' ? '!=' : $default; + + return match (true) { + ! $negate => $op, + $op === '!=' => '=', + $op === '<=' => '>', + $op === '>=' => '<', + $op === '<' => '>=', + $op === '>' => '<=', + $op === '=' => '!=', + }; + } + + /** + * Returns whether the given column type is numeric. + */ + public static function isNumericColumnType(string $columnType): bool + { + return in_array(self::parseColumnType($columnType), self::$numericColumnTypes, true); + } + + /** + * Returns whether the given column type is textual. + */ + public static function isTextualColumnType(string $columnType): bool + { + return in_array(self::parseColumnType($columnType), self::$textualColumnTypes, true); + } + + /** + * Escapes underscores within a value for a `LIKE` condition. + */ + public static function escapeForLike(string $value): string + { + return preg_replace('/(?create(); + + expect(entryQuery()->id('not '.$element1->id.','.$element2->id)->get())->toHaveCount(1); + expect(entryQuery()->id($element1->id)->get())->toHaveCount(1); + expect(entryQuery()->id([$element1->id, $element2->id])->get())->toHaveCount(2); + expect(entryQuery()->id(implode(',', [$element1->id, $element2->id]))->get())->toHaveCount(2); + expect(entryQuery()->id(implode(', ', [$element1->id, $element2->id]))->get())->toHaveCount(2); + + expect(entryQuery()->id("> {$element1->id}")->get())->toHaveCount(2); + expect(entryQuery()->id(">= {$element1->id}")->get())->toHaveCount(3); + expect(entryQuery()->id("and >={$element1->id}, >{$element2->id}")->get())->toHaveCount(1); +}); + +test('uid', function () { + [$element1, $element2] = EntryModel::factory(3)->create(); + + expect(entryQuery()->uid($element1->element->uid)->get())->toHaveCount(1); + expect(entryQuery()->uid('not '.$element1->element->uid)->get())->toHaveCount(2); + expect(entryQuery()->uid([$element1->element->uid, $element2->element->uid])->get())->toHaveCount(2); + expect(entryQuery()->uid(implode(',', [$element1->element->uid, $element2->element->uid]))->get())->toHaveCount(2); + expect(entryQuery()->uid(implode(', ', [$element1->element->uid, $element2->element->uid]))->get())->toHaveCount(2); +}); + +test('siteSettingsId', function () { + [$element1, $element2] = EntryModel::factory(2)->create(); + + expect(entryQuery()->siteSettingsId($element1->element->siteSettings->first()->id)->get())->toHaveCount(1); + expect(entryQuery()->siteSettingsId($element1->element->siteSettings->first()->id)->first()->id)->toBe($element1->id); + expect(entryQuery()->siteSettingsId([$element1->element->siteSettings->first()->id, $element2->element->siteSettings->first()->id])->get())->toHaveCount(2); +}); + +test('dateCreated', function () { + $entry1 = EntryModel::factory()->create([ + 'dateCreated' => now()->subDay(), + ]); + + $entry2 = EntryModel::factory()->create([ + 'dateCreated' => now()->subDays(2), + ]); + + expect(entryQuery()->dateCreated(['and', '<= yesterday'])->count())->toBe(1); +}); diff --git a/tests/Database/Queries/ElementQueryTest.php b/tests/Database/Queries/ElementQueryTest.php index fcbd1904d86..bab58115611 100644 --- a/tests/Database/Queries/ElementQueryTest.php +++ b/tests/Database/Queries/ElementQueryTest.php @@ -27,24 +27,6 @@ entryQuery()->findOrFail(999); }); -test('id', function () { - [$element1, $element2] = EntryModel::factory(3)->create(); - - expect(entryQuery()->id($element1->id)->get())->toHaveCount(1); - expect(entryQuery()->id([$element1->id, $element2->id])->get())->toHaveCount(2); - expect(entryQuery()->id(implode(',', [$element1->id, $element2->id]))->get())->toHaveCount(2); - expect(entryQuery()->id(implode(', ', [$element1->id, $element2->id]))->get())->toHaveCount(2); -}); - -test('uid', function () { - [$element1, $element2] = EntryModel::factory(3)->create(); - - expect(entryQuery()->uid($element1->element->uid)->get())->toHaveCount(1); - expect(entryQuery()->uid([$element1->element->uid, $element2->element->uid])->get())->toHaveCount(2); - expect(entryQuery()->uid(implode(',', [$element1->element->uid, $element2->element->uid]))->get())->toHaveCount(2); - expect(entryQuery()->uid(implode(', ', [$element1->element->uid, $element2->element->uid]))->get())->toHaveCount(2); -}); - test('trashed', function () { EntryModel::factory(2)->create(); EntryModel::factory(2)->trashed()->create(); diff --git a/tests/Support/QueryTest.php b/tests/Support/QueryTest.php new file mode 100644 index 00000000000..866c0fd97c1 --- /dev/null +++ b/tests/Support/QueryTest.php @@ -0,0 +1,9 @@ + Date: Mon, 10 Nov 2025 10:12:26 +0100 Subject: [PATCH 05/52] Move QueryParam --- .../Queries/Concerns/QueriesCustomFields.php | 2 +- src/Database/QueryParam.php | 115 +++++++++++++++++ src/Field/BaseOptionsField.php | 2 +- yii2-adapter/legacy/db/QueryParam.php | 120 ++---------------- .../legacy/elements/db/ElementQuery.php | 2 +- yii2-adapter/legacy/elements/db/UserQuery.php | 2 +- yii2-adapter/legacy/helpers/Db.php | 2 +- 7 files changed, 128 insertions(+), 117 deletions(-) create mode 100644 src/Database/QueryParam.php diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index b57b2399f11..ad58dd8316e 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -7,12 +7,12 @@ use craft\base\FieldInterface; use craft\behaviors\CustomFieldBehavior; use craft\db\mysql\Schema; -use craft\db\QueryParam; use craft\helpers\Db as DbHelper; use craft\models\FieldLayout; use CraftCms\Cms\Database\Expressions\JsonExtract; use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; +use CraftCms\Cms\Database\QueryParam; use Illuminate\Contracts\Database\Query\Expression; use Tpetry\QueryExpressions\Function\Conditional\Coalesce; diff --git a/src/Database/QueryParam.php b/src/Database/QueryParam.php new file mode 100644 index 00000000000..b6cc2158a6f --- /dev/null +++ b/src/Database/QueryParam.php @@ -0,0 +1,115 @@ +operator = self::extractOperator($values) ?? self::OR; + $param->values = $values; + + return $param; + } + + public static function toArray(mixed $value): array + { + if ($value === null) { + return []; + } + + if ($value instanceof DateTime) { + return [$value]; + } + + if (is_string($value)) { + // Split it on the non-escaped commas + $value = preg_split('/(? $val) { + // Remove leading/trailing whitespace + $val = trim($val); + + // Remove any backslashes used to escape commas + $val = str_replace('\,', ',', $val); + + $value[$key] = $val; + } + + // Split the first value if it begins with an operator + $firstValue = $value[0]; + if (str_contains($firstValue, ' ')) { + $parts = explode(' ', $firstValue); + $operator = self::extractOperator($parts); + if ($operator !== null) { + $value[0] = implode(' ', $parts); + array_unshift($value, $operator); + } + } + + // Remove any empty elements and reset the keys + return array_values(Arr::whereNotEmpty($value)); + } + + return Arr::toArray($value); + } + + /** + * Extracts the logic operator (`and`, `or`, or `not`) from the beginning of an array. + */ + public static function extractOperator(array &$values): ?string + { + $firstVal = reset($values); + + if (! is_string($firstVal)) { + return null; + } + + $firstVal = strtolower($firstVal); + + if (! in_array($firstVal, [self::AND, self::OR, self::NOT], true)) { + return null; + } + + array_shift($values); + + return $firstVal; + } +} diff --git a/src/Field/BaseOptionsField.php b/src/Field/BaseOptionsField.php index 77ef759aa2b..1fd4ce34cef 100644 --- a/src/Field/BaseOptionsField.php +++ b/src/Field/BaseOptionsField.php @@ -6,12 +6,12 @@ use Craft; use craft\base\ElementInterface; -use craft\db\QueryParam; use craft\fields\conditions\OptionsFieldConditionRule; use craft\gql\arguments\OptionField as OptionFieldArguments; use craft\gql\resolvers\OptionField as OptionFieldResolver; use craft\helpers\Cp; use craft\helpers\Db; +use CraftCms\Cms\Database\QueryParam; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; use CraftCms\Cms\Field\Contracts\PreviewableFieldInterface; diff --git a/yii2-adapter/legacy/db/QueryParam.php b/yii2-adapter/legacy/db/QueryParam.php index a05a742ccdf..808facce668 100644 --- a/yii2-adapter/legacy/db/QueryParam.php +++ b/yii2-adapter/legacy/db/QueryParam.php @@ -7,121 +7,17 @@ namespace craft\db; -use CraftCms\Cms\Support\Arr; -use DateTime; - -/** - * Class QueryParam - * - * @author Pixel & Tonic, Inc. - * @since 5.0.0 - */ -final class QueryParam -{ - public const AND = 'and'; - public const OR = 'or'; - public const NOT = 'not'; - - /** - * Parses a given query param, separating it into an array of values and the logical operator (`and`, `or`, `not`). - * - * @param mixed $value - * @return self - */ - public static function parse(mixed $value): self - { - $param = new self(); - - if (is_string($value) && preg_match('/^not\s*$/', $value)) { - return $param; - } - - $values = self::toArray($value); - - if (empty($values)) { - return $param; - } - - $param->operator = self::extractOperator($values) ?? self::OR; - $param->values = $values; - return $param; - } - - public static function toArray(mixed $value): array - { - if ($value === null) { - return []; - } - - if ($value instanceof DateTime) { - return [$value]; - } - - if (is_string($value)) { - // Split it on the non-escaped commas - $value = preg_split('/(? $val) { - // Remove leading/trailing whitespace - $val = trim($val); - - // Remove any backslashes used to escape commas - $val = str_replace('\,', ',', $val); - - $value[$key] = $val; - } - - // Split the first value if it begins with an operator - $firstValue = $value[0]; - if (str_contains($firstValue, ' ')) { - $parts = explode(' ', $firstValue); - $operator = self::extractOperator($parts); - if ($operator !== null) { - $value[0] = implode(' ', $parts); - array_unshift($value, $operator); - } - } - - // Remove any empty elements and reset the keys - return array_values(Arr::whereNotEmpty($value)); - } - - return Arr::toArray($value); - } - +if (false) { /** - * Extracts the logic operator (`and`, `or`, or `not`) from the beginning of an array. + * Class QueryParam * - * @param array $values - * @return string|null - * @since 3.7.40 + * @author Pixel & Tonic, Inc. + * @since 5.0.0 + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Database\QueryParam} instead. */ - public static function extractOperator(array &$values): ?string + final class QueryParam { - $firstVal = reset($values); - - if (!is_string($firstVal)) { - return null; - } - - $firstVal = strtolower($firstVal); - - if (!in_array($firstVal, [self::AND, self::OR, self::NOT], true)) { - return null; - } - - array_shift($values); - return $firstVal; } - - /** - * @var array The param values. - */ - public array $values = []; - - /** - * @var string The logical operator that the values should be combined with (`and`, `or`, or `not`). - */ - public string $operator = self::OR; } + +class_alias(\CraftCms\Cms\Database\QueryParam::class, QueryParam::class); diff --git a/yii2-adapter/legacy/elements/db/ElementQuery.php b/yii2-adapter/legacy/elements/db/ElementQuery.php index e06a4860e5e..8e9e5b24d74 100644 --- a/yii2-adapter/legacy/elements/db/ElementQuery.php +++ b/yii2-adapter/legacy/elements/db/ElementQuery.php @@ -18,7 +18,6 @@ use craft\db\FixedOrderExpression; use craft\db\Query; use craft\db\QueryAbortedException; -use craft\db\QueryParam; use craft\db\Table; use craft\elements\ElementCollection; use craft\elements\User; @@ -30,6 +29,7 @@ use craft\helpers\Db; use craft\helpers\ElementHelper; use craft\models\FieldLayout; +use CraftCms\Cms\Database\QueryParam; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Fields; use CraftCms\Cms\Site\Data\Site; diff --git a/yii2-adapter/legacy/elements/db/UserQuery.php b/yii2-adapter/legacy/elements/db/UserQuery.php index f39d70f63a6..98f272d16f4 100644 --- a/yii2-adapter/legacy/elements/db/UserQuery.php +++ b/yii2-adapter/legacy/elements/db/UserQuery.php @@ -10,13 +10,13 @@ use Craft; use craft\db\Query; use craft\db\QueryAbortedException; -use craft\db\QueryParam; use craft\db\Table; use craft\elements\Address; use craft\elements\Entry; use craft\elements\User; use craft\helpers\Db; use craft\models\UserGroup; +use CraftCms\Cms\Database\QueryParam; use CraftCms\Cms\Edition; use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\Facades\Sites; diff --git a/yii2-adapter/legacy/helpers/Db.php b/yii2-adapter/legacy/helpers/Db.php index 555b79c4a56..14f22a492c7 100644 --- a/yii2-adapter/legacy/helpers/Db.php +++ b/yii2-adapter/legacy/helpers/Db.php @@ -13,8 +13,8 @@ use craft\db\mysql\Schema as MysqlSchema; use craft\db\pgsql\Schema as PgsqlSchema; use craft\db\Query; -use craft\db\QueryParam; use craft\db\Table; +use CraftCms\Cms\Database\QueryParam; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Json as JsonHelper; use CraftCms\Cms\Support\Money as MoneyHelper; From 97e944d5a0ff72c2db43c5aa4f6361370eef7489 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 10 Nov 2025 10:13:25 +0100 Subject: [PATCH 06/52] Add tests for Query --- src/Database/DatabaseServiceProvider.php | 16 +- .../OrderByPlaceholderExpression.php | 2 + .../Queries/Concerns/QueriesFields.php | 5 + src/Database/Queries/ElementQuery.php | 17 +- .../Exceptions/ElementNotFoundException.php | 4 +- src/Support/Query.php | 210 +++++++++++++++--- tests/Support/QueryTest.php | 150 ++++++++++++- 7 files changed, 341 insertions(+), 63 deletions(-) diff --git a/src/Database/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index f4ded65b6d3..6e3ee7ac34e 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -59,17 +59,11 @@ public function boot(Repository $config, Connection $db, \Illuminate\Cache\Repos public function registerQueryBuilderMacros(): void { - Builder::macro('applyParam', function (string $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null) { - Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType); - }); - - Builder::macro('applyNumericParam', function (string $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER) { - Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType); - }); - - Builder::macro('applyDateParam', function (string $column, mixed $param, string $defaultOperator = '=') { - Query::whereDateParam($this, $column, $param, $defaultOperator); - }); + Builder::macro('whereParam', fn (string $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null) => Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType)); + Builder::macro('whereNumericParam', fn (string $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER) => Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType)); + Builder::macro('whereDateParam', fn (string $column, mixed $param, string $defaultOperator = '=') => Query::whereDateParam($this, $column, $param, $defaultOperator)); + Builder::macro('whereMoneyParam', fn (string $column, string $currency, mixed $param, string $defaultOperator = '=') => Query::whereMoneyParam($this, $column, $currency, $param, $defaultOperator)); + Builder::macro('whereBooleanParam', fn (string $column, mixed $param, ?bool $defaultValue = null, string $columnType = Query::TYPE_BOOLEAN) => Query::whereBooleanParam($this, $column, $param, $defaultValue, $columnType)); Builder::macro('idByUid', fn (string $uid): ?int => (int) $this->where('uid', $uid)->value('id') ?: null); Builder::macro('idsByUids', fn (array $uids): array => $this->whereIn('uid', $uids)->pluck('id', 'uid')->all()); diff --git a/src/Database/Expressions/OrderByPlaceholderExpression.php b/src/Database/Expressions/OrderByPlaceholderExpression.php index 981b1bc0ada..691ba665729 100644 --- a/src/Database/Expressions/OrderByPlaceholderExpression.php +++ b/src/Database/Expressions/OrderByPlaceholderExpression.php @@ -1,5 +1,7 @@ id = $value; + // Adjust query right away? + // $this->subQuery->whereNumericParam('elements.id', $this->id); + + // Query::whereNumericParam($this->subQuery, 'elements.id', $this->id); + return $this; } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 892195088e6..c4b9d474bb9 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -372,7 +372,7 @@ public function findOr($id, $columns = ['*'], ?Closure $callback = null): mixed * * @throws ElementNotFoundException */ - public function firstOrFail($columns = ['*']) + public function firstOrFail($columns = ['*']): ElementInterface { if (! is_null($model = $this->first($columns))) { return $model; @@ -638,7 +638,7 @@ public function __call($method, $parameters): mixed return $this->getQuery()->{$method}(...$parameters); } - if (in_array(strtolower($method), ['orderby'])) { + if (strtolower($method) === 'orderby') { $this->forwardCallTo($this->query, $method, $parameters); return $this; @@ -684,10 +684,8 @@ public static function __callStatic($method, $parameters): mixed /** * Register the given mixin with the builder. - * - * @return void */ - protected static function registerMixin(string $mixin, bool $replace = true) + protected static function registerMixin(string $mixin, bool $replace = true): void { $methods = new ReflectionClass($mixin)->getMethods( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED @@ -707,8 +705,6 @@ public function clone(): self /** * Register a closure to be invoked on a clone. - * - * @return $this */ public function onClone(Closure $callback): self { @@ -731,9 +727,7 @@ public function __clone(): void } /** - * Register a closure to be invoked after the query is executed. - * - * @return $this + * Register a closure to be invoked before the query is executed. */ public function beforeQuery(Closure $callback): self { @@ -763,9 +757,6 @@ protected function elementQueryBeforeQuery(): void throw new QueryAbortedException(); }*/ - // @TODO: Params? - // ->addParams($this->params); - $this->applySelectParams(); // If an element table was never joined in, explicitly filter based on the element type diff --git a/src/Database/Queries/Exceptions/ElementNotFoundException.php b/src/Database/Queries/Exceptions/ElementNotFoundException.php index 4817dba52a3..511e12c08b3 100644 --- a/src/Database/Queries/Exceptions/ElementNotFoundException.php +++ b/src/Database/Queries/Exceptions/ElementNotFoundException.php @@ -17,14 +17,14 @@ final class ElementNotFoundException extends RecordsNotFoundException * * @var class-string */ - protected string $element; + private string $element; /** * The affected element IDs. * * @var array */ - protected array $ids; + private array $ids; /** * Set the affected Eloquent model and instance ids. diff --git a/src/Support/Query.php b/src/Support/Query.php index 446791d4913..2bf86bedf5b 100644 --- a/src/Support/Query.php +++ b/src/Support/Query.php @@ -4,14 +4,16 @@ namespace CraftCms\Cms\Support; -use craft\db\QueryParam; +use CraftCms\Cms\Database\QueryParam; +use CraftCms\Cms\Support\Money as MoneyHelper; use DateTimeInterface; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Date; use InvalidArgumentException; +use Money\Money; use Tpetry\QueryExpressions\Function\String\Lower; -final class Query +final readonly class Query { const string TYPE_CHAR = 'char'; @@ -57,10 +59,7 @@ final class Query const string TYPE_JSON = 'json'; - /** - * @var string[] Numeric column types - */ - private static array $numericColumnTypes = [ + private const array NUMERIC_COLUMN_TYPES = [ self::TYPE_TINYINT, self::TYPE_SMALLINT, self::TYPE_INTEGER, @@ -70,10 +69,7 @@ final class Query self::TYPE_DECIMAL, ]; - /** - * @var string[] Textual column types - */ - private static array $textualColumnTypes = [ + private const array TEXTUAL_COLUMN_TYPES = [ self::TYPE_CHAR, self::TYPE_STRING, self::TYPE_TEXT, @@ -85,10 +81,7 @@ final class Query self::TYPE_ENUM, ]; - /** - * @var string[] - */ - private static array $operators = ['not ', '!=', '<=', '>=', '<', '>', '=']; + private const array OPERATORS = ['not ', '!=', '<=', '>=', '<', '>', '=']; /** * Parses a query param value and applies it to a query builder. @@ -106,6 +99,7 @@ final class Query * Values can also be set to either `':empty:'` or `':notempty:'` if you want to search for empty or non-empty * database values. (An “empty” value is either `NULL` or an empty string of text). * + * @param Builder $query The query builder to apply the param to. * @param string $column The database column that the param is targeting. * @param string|int|array $value The param value(s). * @param string $defaultOperator The default operator to apply to the values @@ -120,11 +114,11 @@ public static function whereParam( string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null, - ): void { + ): Builder { $parsed = QueryParam::parse($param); if (empty($parsed->values)) { - return; + return $query; } $parsedColumnType = $columnType @@ -139,8 +133,8 @@ public static function whereParam( } $caseColumn = $caseInsensitive - ? $column - : new Lower($column); + ? new Lower($column) + : $column; $query->where(function (Builder $query) use ($caseColumn, $isMysql, $parsedColumnType, $columnType, $defaultOperator, $parsed, $column, $caseInsensitive) { $boolean = match ($parsed->operator) { @@ -182,14 +176,18 @@ public static function whereParam( ($columnType === null && $isMysql) || ($columnType !== null && self::isTextualColumnType($columnType)) ) { - $query->where(function (Builder $query) use ($column) { + $method = $operator === '!=' ? 'whereNot' : 'where'; + + $query->$method(function (Builder $query) use ($column) { $query->whereNull($column)->orWhere($column, ''); }, boolean: $boolean); continue; } - $query->whereNull($column, boolean: $boolean); + $method = $operator === '!=' ? 'whereNotNull' : 'whereNull'; + + $query->$method($column, boolean: $boolean); continue; } @@ -266,6 +264,8 @@ public static function whereParam( $query->whereNotIn($caseColumn, $notInVals, boolean: $boolean); } }); + + return $query; } /** @@ -278,6 +278,7 @@ public static function whereParam( * - `'not x'` or `'!= x'` * - `'> x'`, `'>= x'`, `'< x'`, or `'<= x'`, or a combination of those * + * @param Builder $query The query builder to apply the param to. * @param string $column The database column that the param is targeting. * @param string|string[] $value The param value * @param string $defaultOperator The default operator to apply to the values @@ -290,14 +291,15 @@ public static function whereNumericParam( mixed $value, string $defaultOperator = '=', ?string $columnType = self::TYPE_INTEGER, - ): void { - self::whereParam($query, $column, $value, $defaultOperator, false, $columnType); + ): Builder { + return self::whereParam($query, $column, $value, $defaultOperator, false, $columnType); } /** - * Parses a query param value for a date/time column, and returns a - * [[\yii\db\QueryInterface::where()]]-compatible condition. + * Parses a query param value for a date/time column, + * and applies it to the Query builder. * + * @param Builder $query The query builder to apply the param to. * @param string $column The database column that the param is targeting. * @param string|array|DateTimeInterface $value The param value * @param string $defaultOperator The default operator to apply to the values @@ -308,11 +310,11 @@ public static function whereDateParam( string $column, mixed $value, string $defaultOperator = '=' - ): void { + ): Builder { $param = QueryParam::parse($value); if (empty($param->values)) { - return; + return $query; } $normalizedValues = [$param->operator]; @@ -336,7 +338,126 @@ public static function whereDateParam( $normalizedValues[] = $operator.$val; } - self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME); + return self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME); + } + + /** + * Parses a query param value for a money column, and returns a + * [[\yii\db\QueryInterface::where()]]-compatible condition. + * + * @param Builder $query The query builder to apply the param to. + * @param string $column The database column that the param is targeting. + * @param string $currency The currency code to use for the money object. + * @param string|array|Money $value The param value + * @param string $defaultOperator The default operator to apply to the values + * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) + */ + public static function whereMoneyParam( + Builder $query, + string $column, + string $currency, + mixed $value, + string $defaultOperator = '=', + ): Builder { + $param = QueryParam::parse($value); + + if (empty($param->values)) { + return $query; + } + + $normalizedValues = [$param->operator]; + + foreach ($param->values as $val) { + // Is this an empty value? + $val = self::normalizeEmptyValue($val); + + if ($val === ':empty:' || $val === 'not :empty:') { + $normalizedValues[] = $val; + + // Sneak out early + continue; + } + + $operator = self::parseParamOperator($val, $defaultOperator); + + // Assume that date params are set in the system timezone + $val = MoneyHelper::toMoney(['value' => $val, 'currency' => $currency]); + + $normalizedValues[] = $operator.$val->getAmount(); + } + + return self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME); + } + + /** + * Parses a query param value for a boolean column and returns a + * [[\yii\db\QueryInterface::where()]]-compatible condition. + * + * The follow values are supported: + * + * - `true` or `false` + * - `:empty:` or `:notempty:` (normalizes to `false` and `true`) + * - `'not x'` or `'!= x'` (normalizes to the opposite of the boolean value of `x`) + * - Anything else (normalizes to the boolean value of itself) + * + * If `$defaultValue` is set, and it matches the normalized `$value`, then the resulting condition will match any + * `null` values as well. + * + * @param Builder $query The query builder to apply the param to. + * @param string $column The database column that the param is targeting. + * @param string|bool|null|array $value The param value + * @param bool|null $defaultValue How `null` values should be treated + * @param string $columnType The database column type the param is targeting + */ + public static function whereBooleanParam( + Builder $query, + string $column, + mixed $value, + ?bool $defaultValue = null, + string $columnType = self::TYPE_BOOLEAN, + ): Builder { + if (is_array($value)) { + foreach ($value as $val) { + $query->orWhere(fn (Builder $query) => self::whereBooleanParam($query, $column, $val, $defaultValue, $columnType)); + } + + return $query; + } + + if ($value !== null) { + $value = self::normalizeEmptyValue($value); + $operator = self::parseParamOperator($value, '='); + $value = $value && $value !== ':empty:'; + } else { + $operator = self::parseParamOperator($value, '='); + } + + if ($operator === '!=' && is_bool($value)) { + $value = ! $value; + } + + if ($columnType === self::TYPE_JSON) { + /** @phpstan-ignore-next-line */ + $value = match ($value) { + true => 'true', + false => 'false', + null => null, + }; + $defaultValue = match ($defaultValue) { + true => 'true', + false => 'false', + null => null, + }; + } + + return $query->where(function (Builder $query) use ($column, $value, $defaultValue) { + $query + ->where($column, $value) + ->when( + $defaultValue === $value && $value !== null, + fn (Builder $query) => $query->orWhereNull($column), + ); + }); } /** @@ -393,7 +514,7 @@ private static function parseParamOperator(mixed &$value, string $default, bool $op = null; if (is_string($value)) { - foreach (self::$operators as $operator) { + foreach (self::OPERATORS as $operator) { // Does the value start with this operator? if (stripos($value, $operator) === 0) { $value = mb_substr($value, strlen($operator)); @@ -427,7 +548,7 @@ private static function parseParamOperator(mixed &$value, string $default, bool */ public static function isNumericColumnType(string $columnType): bool { - return in_array(self::parseColumnType($columnType), self::$numericColumnTypes, true); + return in_array(self::parseColumnType($columnType), self::NUMERIC_COLUMN_TYPES, true); } /** @@ -435,7 +556,34 @@ public static function isNumericColumnType(string $columnType): bool */ public static function isTextualColumnType(string $columnType): bool { - return in_array(self::parseColumnType($columnType), self::$textualColumnTypes, true); + return in_array(self::parseColumnType($columnType), self::TEXTUAL_COLUMN_TYPES, true); + } + + /** + * Escapes commas, asterisks, and colons in a string, so they are not treated as special characters in + * [[whereParam()]]. + * + * @param string $value The param value. + * @return string The escaped param value. + */ + public static function escapeParam(string $value): string + { + if (in_array(strtolower($value), [':empty:', 'not :empty:', ':notempty:'])) { + return "\\$value"; + } + + $value = preg_replace('/(?delete(); + + foreach (range(1, 5) as $i) { + DB::table(Table::MIGRATIONS)->insert([ + 'track' => 'test', + 'migration' => 'test-'.$i, + 'batch' => $i, + ]); + } + + $query = DB::table(Table::MIGRATIONS); + + expect($query->whereParam($column, $param)->pluck('batch')->all())->toEqual($expected); +})->with([ + ['batch', '1', [1]], + ['batch', '1, 2', [1, 2]], + ['batch', [1, 2], [1, 2]], + ['batch', 'and >1, <3', [2]], + ['batch', '>1, <3', [1, 2, 3, 4, 5]], // OR + ['batch', '<= 1', [1]], + ['batch', '!= 1', [2, 3, 4, 5]], + ['batch', 'and not 1, not 3', [2, 4, 5]], + ['batch', ':empty:', []], + ['batch', ':notempty:', [1, 2, 3, 4, 5]], + ['batch', ':notempty:', [1, 2, 3, 4, 5]], + ['migration', 'test*', [1, 2, 3, 4, 5]], + ['migration', 'test-1', [1]], +]); + +test('whereNumericParam throws if not numeric', function () { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid numeric value: foo'); + + DB::table(Table::MIGRATIONS)->whereNumericParam('batch', 'foo'); +}); + +test('whereBooleanParam', function () { + DB::table(Table::MIGRATIONS)->insert([ + 'track' => 'false', + 'migration' => 'false', + 'batch' => 0, + ]); + + DB::table(Table::MIGRATIONS)->insert([ + 'track' => 'true', + 'migration' => 'true', + 'batch' => 1, + ]); + + // @TODO: 'false' is considered a true value, should we change this? + foreach ([true, 1, '1', 'true', 'false'] as $param) { + expect(DB::table(Table::MIGRATIONS)->whereBooleanParam('batch', $param)->pluck('track'))->toContain('true'); + expect(DB::table(Table::MIGRATIONS)->whereBooleanParam('batch', $param)->pluck('track'))->not()->toContain('false'); + } + + foreach ([false, 0] as $param) { + expect(DB::table(Table::MIGRATIONS)->whereBooleanParam('batch', $param)->pluck('track'))->not()->toContain('true'); + expect(DB::table(Table::MIGRATIONS)->whereBooleanParam('batch', $param)->pluck('track'))->toContain('false'); + } }); + +test('whereDateParam', function (string $param, array $expected) { + DB::table(Table::SESSIONS)->delete(); + + DB::table(Table::SESSIONS)->insert([ + 'userId' => 1, + 'token' => 'test-today', + 'dateCreated' => today(), + 'dateUpdated' => today(), + 'uid' => Str::uuid()->toString(), + ]); + + DB::table(Table::SESSIONS)->insert([ + 'userId' => 1, + 'token' => 'test-yesterday', + 'dateCreated' => now()->subDay()->startOfDay(), + 'dateUpdated' => now()->subDay()->startOfDay(), + 'uid' => Str::uuid()->toString(), + ]); + + DB::table(Table::SESSIONS)->insert([ + 'userId' => 1, + 'token' => 'test-tomorrow', + 'dateCreated' => now()->addDay()->startOfDay(), + 'dateUpdated' => now()->addDay()->startOfDay(), + 'uid' => Str::uuid()->toString(), + ]); + + $query = DB::table(Table::SESSIONS)->whereDateParam('dateCreated', $param); + + expect($query->pluck('token')->all())->toEqual($expected); +})->with([ + ['today', ['test-today']], + ['tomorrow', ['test-tomorrow']], + ['> today', ['test-tomorrow']], + ['>= today', ['test-today', 'test-tomorrow']], + ['< today, > today', ['test-yesterday', 'test-tomorrow']], +]); + +test('escapeParam', function (string $param, string $expected) { + expect(Query::escapeParam($param))->toBe($expected); +})->with([ + ['*', '\*'], + [',', '\,'], + [',*', '\,\*'], + ['\,\*', '\,\*'], + ['>10', '\>10'], + ['not :empty:', '\not :empty:'], + [':notempty:', '\:notempty:'], + [':empty:', '\:empty:'], + ['NOT :EMPTY:', '\NOT :EMPTY:'], + [':NOTEMPTY:', '\:NOTEMPTY:'], + [':EMPTY:', '\:EMPTY:'], + [':foo:', ':foo:'], +]); + +test('escapeCommas', function (string $param, string $expected) { + expect(Query::escapeCommas($param))->toBe($expected); +})->with([ + ['foo, bar', 'foo\, bar'], + ['foo, bar*', 'foo\, bar*'], + ['foo\, bar', 'foo\, bar'], +]); + +test('escapeForLike', function (string $param, string $expected) { + expect(Query::escapeForLike($param))->toBe($expected); +})->with([ + ['_foo', '\\_foo'], + ['foo_bar', 'foo\\_bar'], + ['foo_', 'foo\\_'], +]); + +test('parseColumnType', function (string $columnType, ?string $expected) { + expect(Query::parseColumnType($columnType))->toBe($expected); +})->with([ + ['STRING(255)', 'string'], + ['DECIMAL(14,4)', 'decimal'], + ['"invalid"', null], +]); From 59c11de6293a505fdc0c9e7cc3116dadb36a8082 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 10 Nov 2025 10:25:00 +0100 Subject: [PATCH 07/52] date queries --- .../Queries/Concerns/FormatsResults.php | 4 +++ .../Queries/Concerns/QueriesFieldsTest.php | 28 ++++++++++++++----- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php index 5ea26e65c91..e341db1b20f 100644 --- a/src/Database/Queries/Concerns/FormatsResults.php +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -224,6 +224,10 @@ private function parseOrderColumnMappings(ElementQuery $query): void } $query->query->orders = array_map(function ($order) { + if (! is_string($order['column'])) { + return $order; + } + $order['column'] = $this->columnMap[$order['column']] ?? $order['column']; return $order; diff --git a/tests/Database/Queries/Concerns/QueriesFieldsTest.php b/tests/Database/Queries/Concerns/QueriesFieldsTest.php index 8e51fb5bba0..83ef5cf4080 100644 --- a/tests/Database/Queries/Concerns/QueriesFieldsTest.php +++ b/tests/Database/Queries/Concerns/QueriesFieldsTest.php @@ -34,14 +34,28 @@ expect(entryQuery()->siteSettingsId([$element1->element->siteSettings->first()->id, $element2->element->siteSettings->first()->id])->get())->toHaveCount(2); }); -test('dateCreated', function () { - $entry1 = EntryModel::factory()->create([ - 'dateCreated' => now()->subDay(), +test('dateCreated & dateUpdated', function (string $column, mixed $param, int $expectedCount) { + // Yesterday + EntryModel::factory()->create()->element->update([ + $column => today()->subDay(), ]); - $entry2 = EntryModel::factory()->create([ - 'dateCreated' => now()->subDays(2), + // Today + EntryModel::factory()->create()->element->update([ + $column => today(), ]); - expect(entryQuery()->dateCreated(['and', '<= yesterday'])->count())->toBe(1); -}); + // Tomorrow + EntryModel::factory()->create()->element->update([ + $column => today()->addDay(), + ]); + + expect(entryQuery()->$column($param)->count())->toBe($expectedCount); +})->with([ + 'dateCreated', + 'dateUpdated', +])->with([ + ['<= yesterday', 1], + [['< today', '> today'], 2], + [['and', '> yesterday', '> today'], 1], +]); From 82af61910dd592be5d20c96f832f462ba7311206 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 10 Nov 2025 13:48:35 +0100 Subject: [PATCH 08/52] Tests for QueriesFields --- .../Queries/Concerns/QueriesFields.php | 22 --------- .../Queries/Concerns/QueriesFieldsTest.php | 47 +++++++++++++++++++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/Database/Queries/Concerns/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php index 16ec02b074b..0672efd58a3 100644 --- a/src/Database/Queries/Concerns/QueriesFields.php +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -87,15 +87,6 @@ trait QueriesFields */ public ?string $inBulkOp = null; - /** - * @var mixed The reference code(s) used to identify the element(s). - * - * This property is set when accessing elements via their reference tags, e.g. `{entry:section/slug}`. - * - * @used-by ElementQuery::ref() - */ - public mixed $ref = null; - protected function initializeQueriesFields(): void { $this->beforeQuery(function (ElementQuery $query) { @@ -528,17 +519,4 @@ public function inBulkOp(?string $value): static return $this; } - - /** - * Narrows the query results based on a reference string. - * - * @param mixed $value The property value - * @return static self reference - */ - public function ref(mixed $value): static - { - $this->ref = $value; - - return $this; - } } diff --git a/tests/Database/Queries/Concerns/QueriesFieldsTest.php b/tests/Database/Queries/Concerns/QueriesFieldsTest.php index 83ef5cf4080..c20da1fb64f 100644 --- a/tests/Database/Queries/Concerns/QueriesFieldsTest.php +++ b/tests/Database/Queries/Concerns/QueriesFieldsTest.php @@ -1,6 +1,8 @@ create(); @@ -34,6 +36,15 @@ expect(entryQuery()->siteSettingsId([$element1->element->siteSettings->first()->id, $element2->element->siteSettings->first()->id])->get())->toHaveCount(2); }); +test('trashed', function () { + EntryModel::factory()->trashed()->create(); + EntryModel::factory()->create(); + + expect(entryQuery()->count())->toBe(1); + expect(entryQuery()->trashed()->count())->toBe(1); + expect(entryQuery()->trashed(null)->count())->toBe(2); +}); + test('dateCreated & dateUpdated', function (string $column, mixed $param, int $expectedCount) { // Yesterday EntryModel::factory()->create()->element->update([ @@ -59,3 +70,39 @@ [['< today', '> today'], 2], [['and', '> yesterday', '> today'], 1], ]); + +test('title, slug & uri', function (string $attribute) { + EntryModel::factory()->create()->element->siteSettings->first()->update([ + $attribute => 'String 1', + ]); + + EntryModel::factory()->create()->element->siteSettings->first()->update([ + $attribute => 'String 2', + ]); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->$attribute('String 1')->count())->toBe(1); + expect(entryQuery()->$attribute('String 2')->count())->toBe(1); + expect(entryQuery()->$attribute('String*')->count())->toBe(2); +})->with([ + 'title', + 'slug', + 'uri', +]); + +test('inBulkOp', function () { + $entry = EntryModel::factory()->create(); + + EntryModel::factory()->create(); + + DB::table(Table::ELEMENTS_BULKOPS) + ->insert([ + 'elementId' => $entry->id, + 'key' => 'foo', + 'timestamp' => now(), + ]); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->inBulkOp('foo')->count())->toBe(1); + expect(entryQuery()->inBulkOp('non-existing')->count())->toBe(0); +}); From 66902ef4bc1cb51aee63f5ac97a495ae9548d295 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 10 Nov 2025 16:35:20 +0100 Subject: [PATCH 09/52] Refactor a bit --- database/Factories/DraftFactory.php | 29 +++++++++++++ src/Database/DatabaseServiceProvider.php | 10 ++--- .../Queries/Concerns/CollectsCacheTags.php | 4 +- .../Queries/Concerns/FormatsResults.php | 2 +- .../Queries/Concerns/QueriesCustomFields.php | 2 +- .../Concerns/QueriesDraftsAndRevisions.php | 10 ++--- .../Queries/Concerns/QueriesEagerly.php | 2 +- .../Queries/Concerns/QueriesFields.php | 24 ++++------- .../Concerns/QueriesRelatedElements.php | 2 +- .../Queries/Concerns/QueriesSites.php | 2 +- .../Queries/Concerns/QueriesStatuses.php | 2 +- .../Queries/Concerns/QueriesStructures.php | 2 +- .../Concerns/QueriesUniqueElements.php | 2 +- .../Queries/Concerns/SearchesElements.php | 2 +- src/Database/Queries/ElementQuery.php | 17 ++++---- src/Element/Models/Draft.php | 43 +++++++++++++++++++ src/Element/Models/Element.php | 33 ++++++++++++++ src/Element/Models/Revision.php | 37 ++++++++++++++++ .../QueriesDraftsAndRevisionsTest.php | 19 ++++++++ 19 files changed, 200 insertions(+), 44 deletions(-) create mode 100644 database/Factories/DraftFactory.php create mode 100644 src/Element/Models/Draft.php create mode 100644 src/Element/Models/Revision.php create mode 100644 tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php diff --git a/database/Factories/DraftFactory.php b/database/Factories/DraftFactory.php new file mode 100644 index 00000000000..8f6b9f99e5d --- /dev/null +++ b/database/Factories/DraftFactory.php @@ -0,0 +1,29 @@ + Element::factory(), + 'creatorId' => User::factory(), + 'provisional' => fake()->boolean(), + 'name' => fake()->words(asText: true), + 'trackChanges' => fake()->boolean(), + 'dateLastMerged' => now(), + 'saved' => fake()->boolean(), + ]; + } +} diff --git a/src/Database/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index 6e3ee7ac34e..379fdeaa33a 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -59,11 +59,11 @@ public function boot(Repository $config, Connection $db, \Illuminate\Cache\Repos public function registerQueryBuilderMacros(): void { - Builder::macro('whereParam', fn (string $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null) => Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType)); - Builder::macro('whereNumericParam', fn (string $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER) => Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType)); - Builder::macro('whereDateParam', fn (string $column, mixed $param, string $defaultOperator = '=') => Query::whereDateParam($this, $column, $param, $defaultOperator)); - Builder::macro('whereMoneyParam', fn (string $column, string $currency, mixed $param, string $defaultOperator = '=') => Query::whereMoneyParam($this, $column, $currency, $param, $defaultOperator)); - Builder::macro('whereBooleanParam', fn (string $column, mixed $param, ?bool $defaultValue = null, string $columnType = Query::TYPE_BOOLEAN) => Query::whereBooleanParam($this, $column, $param, $defaultValue, $columnType)); + Builder::macro('whereParam', fn (string $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null): Builder => Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType)); + Builder::macro('whereNumericParam', fn (string $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER): Builder => Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType)); + Builder::macro('whereDateParam', fn (string $column, mixed $param, string $defaultOperator = '='): Builder => Query::whereDateParam($this, $column, $param, $defaultOperator)); + Builder::macro('whereMoneyParam', fn (string $column, string $currency, mixed $param, string $defaultOperator = '='): Builder => Query::whereMoneyParam($this, $column, $currency, $param, $defaultOperator)); + Builder::macro('whereBooleanParam', fn (string $column, mixed $param, ?bool $defaultValue = null, string $columnType = Query::TYPE_BOOLEAN): Builder => Query::whereBooleanParam($this, $column, $param, $defaultValue, $columnType)); Builder::macro('idByUid', fn (string $uid): ?int => (int) $this->where('uid', $uid)->value('id') ?: null); Builder::macro('idsByUids', fn (array $uids): array => $this->whereIn('uid', $uids)->pluck('id', 'uid')->all()); diff --git a/src/Database/Queries/Concerns/CollectsCacheTags.php b/src/Database/Queries/Concerns/CollectsCacheTags.php index cf359af3e41..96b290abe18 100644 --- a/src/Database/Queries/Concerns/CollectsCacheTags.php +++ b/src/Database/Queries/Concerns/CollectsCacheTags.php @@ -22,7 +22,7 @@ trait CollectsCacheTags */ private ?array $cacheTags = null; - protected function initializeCollectsCacheTags(): void + protected function initCollectsCacheTags(): void { $this->beforeQuery(function () { $this->cacheTags = null; @@ -101,7 +101,7 @@ public function getCacheTags(): array /** * Returns any cache invalidation tags that caches involving this element query should use as dependencies. * - * Use the most specific tag(s) possible, to reduce the likelihood of pointless cache clearing. + * Use the most specific tag(s) possible to reduce the likelihood of pointless cache clearing. * * When elements are created/updated/deleted, their [[ElementInterface::getCacheTags()]] method will be called, * and any caches that have those tags listed as dependencies will be invalidated. diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php index e341db1b20f..fb53a700b71 100644 --- a/src/Database/Queries/Concerns/FormatsResults.php +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -139,7 +139,7 @@ public function fixedOrder(bool $value = true): static return $this; } - protected function initializeFormatsResults(): void + protected function initFormatsResults(): void { $this->query->orderBy(new OrderByPlaceholderExpression); diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index ad58dd8316e..e5ab5605d94 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -48,7 +48,7 @@ trait QueriesCustomFields */ private array $columnsToCast = []; - protected function initializeQueriesCustomFields(): void + protected function initQueriesCustomFields(): void { // Gather custom fields and generated field handles $this->customFields = []; diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php index 687d6fd71d2..c8418fca2f4 100644 --- a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -94,7 +94,7 @@ trait QueriesDraftsAndRevisions */ public ?int $revisionCreator = null; - protected function initializeQueriesDraftsAndRevisions(): void + protected function initQueriesDraftsAndRevisions(): void { $this->beforeQuery(function (ElementQuery $query) { $this->applyDraftParams($query); @@ -112,9 +112,9 @@ private function applyDraftParams(ElementQuery $query): void $joinType = $this->drafts === true ? 'inner' : 'left'; $query->subQuery->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); - $query->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); + $query->query->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); - $query->addSelect([ + $query->query->addSelect([ 'elements.draftId', 'drafts.creatorId as draftCreatorId', 'drafts.provisional as isProvisionalDraft', @@ -178,9 +178,9 @@ private function applyRevisionParams(ElementQuery $query): void $joinType = $this->revisions === true ? 'inner' : 'left'; $query->subQuery->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); - $query->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); + $query->query->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); - $query->addSelect([ + $query->query->addSelect([ 'elements.revisionId', 'revisions.creatorId as revisionCreatorId', 'revisions.num as revisionNum', diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php index f0008b47bb6..abebc520e0b 100644 --- a/src/Database/Queries/Concerns/QueriesEagerly.php +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -56,7 +56,7 @@ trait QueriesEagerly */ public bool $eagerly = false; - protected function initializeQueriesEagerly(): void + protected function initQueriesEagerly(): void { $this->afterQuery(function (Collection $elements) { if ($this->with) { diff --git a/src/Database/Queries/Concerns/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php index 0672efd58a3..997e3fa4116 100644 --- a/src/Database/Queries/Concerns/QueriesFields.php +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -87,20 +87,19 @@ trait QueriesFields */ public ?string $inBulkOp = null; - protected function initializeQueriesFields(): void + protected function initQueriesFields(): void { $this->beforeQuery(function (ElementQuery $query) { if ($this->id) { - Query::whereNumericParam($query->subQuery, 'elements.id', $this->id); - // $query->subQuery->whereNumericParam('elements.id', $this->id); + $query->subQuery->whereNumericParam('elements.id', $this->id); } if ($this->uid) { - Query::whereParam($query->subQuery, 'elements.uid', $this->uid); + $query->subQuery->whereParam('elements.uid', $this->uid); } if ($this->siteSettingsId) { - Query::whereNumericParam($query->subQuery, 'elements_sites.id', $this->siteSettingsId); + $query->subQuery->whereNumericParam('elements_sites.id', $this->siteSettingsId); } match ($this->trashed) { @@ -110,11 +109,11 @@ protected function initializeQueriesFields(): void }; if ($this->dateCreated) { - Query::whereDateParam($query->subQuery, 'elements.dateCreated', $this->dateCreated); + $query->subQuery->whereDateParam('elements.dateCreated', $this->dateCreated); } if ($this->dateUpdated) { - Query::whereDateParam($query->subQuery, 'elements.dateUpdated', $this->dateUpdated); + $query->subQuery->whereDateParam('elements.dateUpdated', $this->dateUpdated); } if (isset($this->title) && $this->title !== '' && $this->elementType::hasTitles()) { @@ -122,15 +121,15 @@ protected function initializeQueriesFields(): void $this->title = Query::escapeCommas($this->title); } - Query::whereParam($query->subQuery, 'elements_sites.title', $this->title, caseInsensitive: true); + $query->subQuery->whereParam('elements_sites.title', $this->title, caseInsensitive: true); } if ($this->slug) { - Query::whereParam($query->subQuery, 'elements_sites.slug', $this->slug); + $query->subQuery->whereParam('elements_sites.slug', $this->slug); } if ($this->uri) { - Query::whereParam($query->subQuery, 'elements_sites.uri', $this->uri, caseInsensitive: true); + $query->subQuery->whereParam('elements_sites.uri', $this->uri, caseInsensitive: true); } if ($this->inBulkOp) { @@ -182,11 +181,6 @@ public function id(mixed $value): static { $this->id = $value; - // Adjust query right away? - // $this->subQuery->whereNumericParam('elements.id', $this->id); - - // Query::whereNumericParam($this->subQuery, 'elements.id', $this->id); - return $this; } diff --git a/src/Database/Queries/Concerns/QueriesRelatedElements.php b/src/Database/Queries/Concerns/QueriesRelatedElements.php index ec7ac5929d9..8748765b929 100644 --- a/src/Database/Queries/Concerns/QueriesRelatedElements.php +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -35,7 +35,7 @@ trait QueriesRelatedElements */ public mixed $notRelatedTo = null; - protected function initializeQueriesRelatedElements(): void + protected function initQueriesRelatedElements(): void { $this->applyRelatedToParam(); $this->applyNotRelatedToParam(); diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index 556384fa950..0d1346bb34c 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -25,7 +25,7 @@ trait QueriesSites */ public mixed $siteId = null; - protected function initializeQueriesSites(): void + protected function initQueriesSites(): void { $this->beforeQuery(function () { // Make sure the siteId param is set diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php index fd42f2020c3..70c3ed917df 100644 --- a/src/Database/Queries/Concerns/QueriesStatuses.php +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -34,7 +34,7 @@ trait QueriesStatuses Element::STATUS_ENABLED, ]; - protected function initializeQueriesStatuses(): void + protected function initQueriesStatuses(): void { $this->beforeQuery(function () { if ($this->archived) { diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index cc8670db4dc..6f149c90adc 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -119,7 +119,7 @@ trait QueriesStructures */ public ElementInterface|int|null $positionedAfter = null; - protected function initializeQueriesStructures(): void + protected function initQueriesStructures(): void { $this->beforeQuery(function (ElementQuery $query) { $this->applyStructureParams($query); diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php index 3fcb26e7d68..936c8f40a2e 100644 --- a/src/Database/Queries/Concerns/QueriesUniqueElements.php +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -34,7 +34,7 @@ trait QueriesUniqueElements */ public ?array $preferSites = null; - protected function initializeQueriesUniqueElements(): void + protected function initQueriesUniqueElements(): void { if ( ! $this->unique || diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php index f2bc618d10c..73f86f54982 100644 --- a/src/Database/Queries/Concerns/SearchesElements.php +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -34,7 +34,7 @@ trait SearchesElements */ private ?array $searchResults = null; - protected function initializeSearchesElements(): void + protected function initSearchesElements(): void { $this->beforeQuery(function (ElementQuery $query) { $this->applySearchParam($query); diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index c4b9d474bb9..4878d1e57fe 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -195,7 +195,11 @@ public function __construct( $this->{$key} = $value; } - $this->query = DB::query()->select('**'); + $this->query = DB::query() + ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.id', 'subquery.siteSettingsId') + ->join(new Alias(Table::ELEMENTS, 'elements'), 'elements.id', 'subquery.elementsId') + ->select('**'); + $this->subQuery = DB::table(Table::ELEMENTS, 'elements') ->select([ 'elements.id as elementsId', @@ -217,20 +221,20 @@ public function __construct( $this->columnMap['title'] = 'elements_sites.title'; } - $this->initializeTraits(); + $this->initTraits(); $this->query->beforeQuery(function () { $this->applyBeforeQueryCallbacks(); }); } - protected function initializeTraits(): void + protected function initTraits(): void { $class = static::class; $uses = class_uses_recursive($class); - $conventionalInitMethods = array_map(static fn ($trait) => 'initialize'.class_basename($trait), $uses); + $conventionalInitMethods = array_map(static fn ($trait) => 'init'.class_basename($trait), $uses); foreach (new ReflectionClass($class)->getMethods() as $method) { if (in_array($method->getName(), $conventionalInitMethods)) { @@ -772,10 +776,7 @@ protected function elementQueryBeforeQuery(): void } } - $this->query - ->fromSub($this->subQuery, 'subquery') - ->join(new Alias(Table::ELEMENTS_SITES, 'elements_sites'), 'elements_sites.id', 'subquery.siteSettingsId') - ->join(new Alias(Table::ELEMENTS, 'elements'), 'elements.id', 'subquery.elementsId'); + $this->query->fromSub($this->subQuery, 'subquery'); } /** diff --git a/src/Element/Models/Draft.php b/src/Element/Models/Draft.php new file mode 100644 index 00000000000..c13b3b5c444 --- /dev/null +++ b/src/Element/Models/Draft.php @@ -0,0 +1,43 @@ + 'bool', + 'trackChanges' => 'bool', + 'dateLastMerged' => 'datetime', + 'saved' => 'bool', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Element\Models\Element, $this> + */ + public function canonical(): BelongsTo + { + return $this->belongsTo(Element::class, 'canonicalId'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\User\Models\User, $this> + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creatorId'); + } +} diff --git a/src/Element/Models/Element.php b/src/Element/Models/Element.php index 5322eba8a6e..2e2a093020e 100644 --- a/src/Element/Models/Element.php +++ b/src/Element/Models/Element.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Shared\Concerns\HasUid; use CraftCms\Cms\Site\Models\Site; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -39,4 +40,36 @@ public function siteSettings(): HasMany { return $this->hasMany(ElementSiteSettings::class, 'elementId'); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Element\Models\Draft, $this> + */ + public function draft(): BelongsTo + { + return $this->belongsTo(Draft::class, 'draftId'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany<\CraftCms\Cms\Element\Models\Draft, $this> + */ + public function drafts(): HasMany + { + return $this->hasMany(Draft::class, 'canonicalId'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Element\Models\Revision, $this> + */ + public function revision(): BelongsTo + { + return $this->belongsTo(Revision::class, 'revisionId'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany<\CraftCms\Cms\Element\Models\Revision, $this> + */ + public function revisions(): HasMany + { + return $this->hasMany(Revision::class, 'canonicalId'); + } } diff --git a/src/Element/Models/Revision.php b/src/Element/Models/Revision.php new file mode 100644 index 00000000000..1849cd75a7e --- /dev/null +++ b/src/Element/Models/Revision.php @@ -0,0 +1,37 @@ + 'integer', + ]; + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Element\Models\Element, $this> + */ + public function canonical(): BelongsTo + { + return $this->belongsTo(Element::class, 'canonicalId'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\User\Models\User, $this> + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creatorId'); + } +} diff --git a/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php new file mode 100644 index 00000000000..5320dc19c6e --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php @@ -0,0 +1,19 @@ +create(); + + $entry = EntryModel::factory()->create(); + $entry->element->update([ + 'draftId' => Draft::factory()->create([ + 'canonicalId' => $entry->id, + ])->id, + ]); + + expect(entryQuery()->drafts()->count())->toBe(1); + expect(entryQuery()->drafts(null)->count())->toBe(2); + expect(entryQuery()->drafts(false)->count())->toBe(1); +}); From 6dd6b14f2dd3ebd16b362854d7d55ff9eff30485 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 10 Nov 2025 17:00:32 +0100 Subject: [PATCH 10/52] Hydrate elements --- .../Queries/Concerns/HydratesElements.php | 168 ++++++++++++++++++ .../Concerns/QueriesDraftsAndRevisions.php | 4 +- .../Queries/Concerns/QueriesStructures.php | 10 +- src/Database/Queries/ElementQuery.php | 18 +- src/Database/Queries/EntryQuery.php | 14 +- .../Queries/Events/ElementHydrated.php | 15 ++ .../Queries/Events/ElementsHydrated.php | 23 +++ .../Queries/Events/HydratingElement.php | 15 ++ 8 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 src/Database/Queries/Concerns/HydratesElements.php create mode 100644 src/Database/Queries/Events/ElementHydrated.php create mode 100644 src/Database/Queries/Events/ElementsHydrated.php create mode 100644 src/Database/Queries/Events/HydratingElement.php diff --git a/src/Database/Queries/Concerns/HydratesElements.php b/src/Database/Queries/Concerns/HydratesElements.php new file mode 100644 index 00000000000..c53c2d77e3d --- /dev/null +++ b/src/Database/Queries/Concerns/HydratesElements.php @@ -0,0 +1,168 @@ + + */ + public function hydrate(array $items): Collection + { + $items = array_map(fn (stdClass $row) => (array) $row, $items); + + $elements = new Collection($items) + ->when($this->searchResults, fn (Collection $collection) => $collection->map(function (array $row) { + if (! isset($row['id'], $row['siteId'])) { + return $row; + } + + $key = sprintf('%s-%s', $row['id'], $row['siteId']); + if (isset($this->searchResults[$key])) { + $row['searchScore'] = (int) round($this->searchResults[$key]); + } + + return $row; + }))->map(fn (array $row) => $this->createElement($row)); + + if ($this->withProvisionalDrafts) { + // @TODO + // ElementHelper::swapInProvisionalDrafts($elements); + } + + if (Event::hasListeners(ElementsHydrated::class)) { + Event::dispatch($event = new ElementsHydrated($elements, $items)); + + return $event->elements; + } + + return $elements; + } + + protected function createElement(array $row): ElementInterface + { + // Do we have a placeholder for this element? + if ( + ! $this->ignorePlaceholders && + isset($row['id'], $row['siteId']) && + ($element = \Craft::$app->getElements()->getPlaceholderElement($row['id'], $row['siteId'])) !== null + ) { + return $element; + } + + /** @var class-string $class */ + $class = $this->elementType; + + // Instantiate the element + if ($this->structureId) { + $row['structureId'] = $this->structureId; + } + + if ($class::hasTitles()) { + // Ensure the title is a string + $row['title'] = (string) ($row['title'] ?? ''); + } + + // Set the field values + $content = Arr::pull($row, 'content'); + $row['fieldValues'] = []; + + if (! empty($content) && (! empty($this->customFields) || ! empty($this->generatedFields))) { + if (is_string($content)) { + $content = Json::decode($content); + } + + foreach ($this->customFields as $field) { + if ($field::dbType() !== null && isset($content[$field->layoutElement->uid])) { + $handle = $field->layoutElement->handle ?? $field->handle; + $row['fieldValues'][$handle] = $content[$field->layoutElement->uid]; + } + } + + foreach ($this->generatedFields as $field) { + if (isset($content[$field['uid']])) { + $row['generatedFieldValues'][$field['uid']] = $content[$field['uid']]; + if (($field['handle'] ?? '') !== '') { + $row['generatedFieldValues'][$field['handle']] = $content[$field['uid']]; + } + } + } + } + + if (array_key_exists('dateDeleted', $row)) { + $row['trashed'] = $row['dateDeleted'] !== null; + } + + if ($this->drafts !== false) { + $row['isProvisionalDraft'] = (bool) ($row['isProvisionalDraft'] ?? false); + + if (! empty($row['draftId'])) { + $row['draftCreatorId'] = Arr::pull($row, 'draftCreatorId'); + $row['draftName'] = Arr::pull($row, 'draftName'); + $row['draftNotes'] = Arr::pull($row, 'draftNotes'); + } else { + unset( + $row['draftCreatorId'], + $row['draftName'], + $row['draftNotes'] + ); + } + } + + if ($this->revisions !== false) { + if (! empty($row['revisionId'])) { + $row['revisionCreatorId'] = Arr::pull($row, 'revisionCreatorId'); + $row['revisionNum'] = Arr::pull($row, 'revisionNum'); + $row['revisionNotes'] = Arr::pull($row, 'revisionNotes'); + } else { + unset( + $row['revisionCreatorId'], + $row['revisionNum'], + $row['revisionNotes'], + ); + } + } + + $element = null; + + if (Event::hasListeners(HydratingElement::class)) { + Event::dispatch($event = new HydratingElement($row)); + + $row = $event->row; + + if (isset($event->element)) { + $element = $event->element; + } + } + + $element ??= new $class($row); + + if (Event::hasListeners(ElementHydrated::class)) { + Event::dispatch($event = new ElementHydrated($element, $row)); + + return $event->element; + } + + return $element; + } +} diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php index c8418fca2f4..8fe463af0f2 100644 --- a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -115,7 +115,7 @@ private function applyDraftParams(ElementQuery $query): void $query->query->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); $query->query->addSelect([ - 'elements.draftId', + 'elements.draftId as draftId', 'drafts.creatorId as draftCreatorId', 'drafts.provisional as isProvisionalDraft', 'drafts.name as draftName', @@ -181,7 +181,7 @@ private function applyRevisionParams(ElementQuery $query): void $query->query->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); $query->query->addSelect([ - 'elements.revisionId', + 'elements.revisionId as revisionId', 'revisions.creatorId as revisionCreatorId', 'revisions.num as revisionNum', 'revisions.notes as revisionNotes', diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index 6f149c90adc..6ca6f3ede1c 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -336,10 +336,10 @@ private function applyStructureParams(ElementQuery $query): void } $query->query->addSelect([ - 'structureelements.root', - 'structureelements.lft', - 'structureelements.rgt', - 'structureelements.level', + 'structureelements.root as root', + 'structureelements.lft as lft', + 'structureelements.rgt as rgt', + 'structureelements.level as level', ]); if ($this->structureId) { @@ -352,7 +352,7 @@ private function applyStructureParams(ElementQuery $query): void ->where('structureelements.structureId', $this->structureId)); } else { $query->query - ->addSelect('structureelements.structureId') + ->addSelect('structureelements.structureId as structureId') ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'subquery.elementsId') ->whereColumn('structureelements.structureId', 'subquery.structureId')); diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 4878d1e57fe..489385a9add 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -41,6 +41,7 @@ class ElementQuery implements ElementQueryInterface use Concerns\CollectsCacheTags; use Concerns\FormatsResults; + use Concerns\HydratesElements; use Concerns\QueriesCustomFields; use Concerns\QueriesDraftsAndRevisions; use Concerns\QueriesEagerly; @@ -243,20 +244,6 @@ protected function initTraits(): void } } - /** - * Create a collection of elements from plain arrays. - * - * @return \Illuminate\Database\Eloquent\Collection - */ - public function hydrate(array $items): Collection - { - return new Collection(array_map(fn ($item) => - // @TODO: Actually populate - new $this->elementType([ - 'id' => $item->id, - ]), $items)); - } - /** * Create a collection of models from a raw query. * @@ -509,8 +496,7 @@ public function applyAfterQueryCallbacks(mixed $result): mixed public function cursor(): LazyCollection { return $this->applyScopes()->query->cursor()->map(function ($record) { - // @TODO: Actually populate - $model = new $this->elementType(['id' => $record->id]); + $model = $this->createElement((array) $record); return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); })->reject(fn ($model) => is_null($model)); diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 27e525c7ec2..2d0ccd27773 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -30,16 +30,16 @@ public function __construct(array $config = []) $this->joinElementTable(Table::ENTRIES); $query->query->addSelect([ - 'entries.sectionId', - 'entries.fieldId', - 'entries.primaryOwnerId', - 'entries.typeId', - 'entries.postDate', - 'entries.expiryDate', + 'entries.sectionId as sectionId', + 'entries.fieldId as fieldId', + 'entries.primaryOwnerId as primaryOwnerId', + 'entries.typeId as typeId', + 'entries.postDate as postDate', + 'entries.expiryDate as expiryDate', ]); if (Cms::config()->staticStatuses) { - $query->query->addSelect(['entries.status']); + $query->query->addSelect(['entries.status as status']); } }); } diff --git a/src/Database/Queries/Events/ElementHydrated.php b/src/Database/Queries/Events/ElementHydrated.php new file mode 100644 index 00000000000..d1e76d6d421 --- /dev/null +++ b/src/Database/Queries/Events/ElementHydrated.php @@ -0,0 +1,15 @@ + The populated elements + */ + public Collection $elements, + + /** + * @var array[] The element query’s raw result data + */ + public array $rows, + ) {} +} diff --git a/src/Database/Queries/Events/HydratingElement.php b/src/Database/Queries/Events/HydratingElement.php new file mode 100644 index 00000000000..3531caabeef --- /dev/null +++ b/src/Database/Queries/Events/HydratingElement.php @@ -0,0 +1,15 @@ + Date: Mon, 10 Nov 2025 19:08:48 +0100 Subject: [PATCH 11/52] Extra test --- .../Concerns/CollectsCacheTagsTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php index 5a0d6a40d1e..5c359fe5812 100644 --- a/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php +++ b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php @@ -18,6 +18,28 @@ expect($dependency->tags)->toContain('element::'.$entry->id); }); +it('only adds ids when less than 100 ids have been requested', function () { + Craft::$app->getElements()->startCollectingCacheInfo(); + + entryQuery()->id(range(1, 100))->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->toContain('element::1'); + expect($dependency->tags)->toContain('element::100'); + + Craft::$app->getElements()->startCollectingCacheInfo(); + + entryQuery()->id(range(1, 101))->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->not()->toContain('element::1'); + expect($dependency->tags)->not()->toContain('element::100'); +}); + it('can define extra cache tags', function () { Craft::$app->getElements()->startCollectingCacheInfo(); From 952fb7d8b00a46d5159e7e4ead2a1b6ed97e0892 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 10 Nov 2025 19:44:30 +0100 Subject: [PATCH 12/52] Add docblocks + cleanup --- .../Queries/Concerns/HydratesElements.php | 31 +- .../Concerns/QueriesDraftsAndRevisions.php | 259 ++++++++++++--- .../Queries/Concerns/QueriesEagerly.php | 48 +-- .../Concerns/QueriesPlaceholderElements.php | 5 +- .../Concerns/QueriesRelatedElements.php | 90 +++++- .../Queries/Concerns/QueriesSites.php | 128 ++++++-- .../Queries/Concerns/QueriesStatuses.php | 6 - .../Queries/Concerns/QueriesStructures.php | 304 ++++++++++++++++-- .../Concerns/QueriesUniqueElements.php | 51 ++- .../Queries/Concerns/SearchesElements.php | 5 +- 10 files changed, 781 insertions(+), 146 deletions(-) diff --git a/src/Database/Queries/Concerns/HydratesElements.php b/src/Database/Queries/Concerns/HydratesElements.php index c53c2d77e3d..4bf1a8461c5 100644 --- a/src/Database/Queries/Concerns/HydratesElements.php +++ b/src/Database/Queries/Concerns/HydratesElements.php @@ -38,6 +38,7 @@ public function hydrate(array $items): Collection } $key = sprintf('%s-%s', $row['id'], $row['siteId']); + if (isset($this->searchResults[$key])) { $row['searchScore'] = (int) round($this->searchResults[$key]); } @@ -46,8 +47,7 @@ public function hydrate(array $items): Collection }))->map(fn (array $row) => $this->createElement($row)); if ($this->withProvisionalDrafts) { - // @TODO - // ElementHelper::swapInProvisionalDrafts($elements); + ElementHelper::swapInProvisionalDrafts($elements); } if (Event::hasListeners(ElementsHydrated::class)) { @@ -65,7 +65,7 @@ protected function createElement(array $row): ElementInterface if ( ! $this->ignorePlaceholders && isset($row['id'], $row['siteId']) && - ($element = \Craft::$app->getElements()->getPlaceholderElement($row['id'], $row['siteId'])) !== null + ! is_null($element = \Craft::$app->getElements()->getPlaceholderElement($row['id'], $row['siteId'])) ) { return $element; } @@ -93,18 +93,27 @@ protected function createElement(array $row): ElementInterface } foreach ($this->customFields as $field) { - if ($field::dbType() !== null && isset($content[$field->layoutElement->uid])) { - $handle = $field->layoutElement->handle ?? $field->handle; - $row['fieldValues'][$handle] = $content[$field->layoutElement->uid]; + if (is_null($field::dbType())) { + continue; + } + + if (! isset($content[$field->layoutElement->uid])) { + continue; } + + $handle = $field->layoutElement->handle ?? $field->handle; + $row['fieldValues'][$handle] = $content[$field->layoutElement->uid]; } foreach ($this->generatedFields as $field) { - if (isset($content[$field['uid']])) { - $row['generatedFieldValues'][$field['uid']] = $content[$field['uid']]; - if (($field['handle'] ?? '') !== '') { - $row['generatedFieldValues'][$field['handle']] = $content[$field['uid']]; - } + if (! isset($content[$field['uid']])) { + continue; + } + + $row['generatedFieldValues'][$field['uid']] = $content[$field['uid']]; + + if (! empty($field['handle'] ?? '')) { + $row['generatedFieldValues'][$field['handle']] = $content[$field['uid']]; } } } diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php index 8fe463af0f2..23259762f5e 100644 --- a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -201,9 +201,25 @@ private function applyRevisionParams(ElementQuery $query): void } /** - * {@inheritdoc} + * Narrows the query results to only drafts {elements}. * - * @uses $drafts + * --- + * + * ```twig + * {# Fetch a draft {element} #} + * {% set {elements-var} = {twig-method} + * .drafts() + * .id(123) + * .one() %} + * ``` + * + * ```php + * // Fetch a draft {element} + * ${elements-var} = {element-class}::find() + * ->drafts() + * ->id(123) + * ->one(); + * ``` */ public function drafts(?bool $value = true): static { @@ -213,9 +229,8 @@ public function drafts(?bool $value = true): static } /** - * {@inheritdoc} - * - * @uses $withProvisionalDrafts + * Causes the query to return provisional drafts for the matching elements, + * when they exist for the current user. */ public function withProvisionalDrafts(bool $value = true): static { @@ -225,10 +240,29 @@ public function withProvisionalDrafts(bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ draft’s ID (from the `drafts` table). + * + * Possible values include: + * + * | Value | Fetches drafts… + * | - | - + * | `1` | for the draft with an ID of 1. * - * @uses $draftId - * @uses $drafts + * --- + * + * ```twig + * {# Fetch a draft #} + * {% set {elements-var} = {twig-method} + * .draftId(10) + * .all() %} + * ``` + * + * ```php + * // Fetch a draft + * ${elements-var} = {php-method} + * ->draftId(10) + * ->all(); + * ``` */ public function draftId(?int $value = null): static { @@ -242,12 +276,36 @@ public function draftId(?int $value = null): static } /** - * {@inheritdoc} + * Narrows the query results to only drafts of a given {element}. + * + * Possible values include: + * + * | Value | Fetches drafts… + * | - | - + * | `1` | for the {element} with an ID of 1. + * | `[1, 2]` | for the {elements} with an ID of 1 or 2. + * | a [[{element-class}]] object | for the {element} represented by the object. + * | an array of [[{element-class}]] objects | for the {elements} represented by the objects. + * | `'*'` | for any {element} + * | `false` | that aren’t associated with a published {element} * - * @uses $draftOf - * @uses $drafts + * --- + * + * ```twig + * {# Fetch drafts of the {element} #} + * {% set {elements-var} = {twig-method} + * .draftOf({myElement}) + * .all() %} + * ``` + * + * ```php + * // Fetch drafts of the {element} + * ${elements-var} = {php-method} + * ->draftOf(${myElement}) + * ->all(); + * ``` */ - public function draftOf($value): static + public function draftOf(mixed $value): static { if ($value instanceof ElementInterface) { $this->draftOf = $value->getCanonicalId(); @@ -292,12 +350,32 @@ public function draftOf($value): static } /** - * {@inheritdoc} + * Narrows the query results to only drafts created by a given user. + * + * Possible values include: + * + * | Value | Fetches drafts… + * | - | - + * | `1` | created by the user with an ID of 1. + * | a [[User]] object | created by the user represented by the object. * - * @uses $draftCreator - * @uses $drafts + * --- + * + * ```twig + * {# Fetch drafts by the current user #} + * {% set {elements-var} = {twig-method} + * .draftCreator(currentUser) + * .all() %} + * ``` + * + * ```php + * // Fetch drafts by the current user + * ${elements-var} = {php-method} + * ->draftCreator(Auth::user()) + * ->all(); + * ``` */ - public function draftCreator($value): static + public function draftCreator(mixed $value): static { $this->draftCreator = match (true) { $value instanceof User => $value->id, @@ -313,10 +391,25 @@ public function draftCreator($value): static } /** - * {@inheritdoc} + * Narrows the query results to only provisional drafts. + * + * --- + * + * ```twig + * {# Fetch provisional drafts created by the current user #} + * {% set {elements-var} = {twig-method} + * .provisionalDrafts() + * .draftCreator(currentUser) + * .all() %} + * ``` * - * @uses $provisionalDrafts - * @uses $drafts + * ```php + * // Fetch provisional drafts created by the current user + * ${elements-var} = {php-method} + * ->provisionalDrafts() + * ->draftCreator(Auth::user()) + * ->all(); + * ``` */ public function provisionalDrafts(?bool $value = true): static { @@ -330,9 +423,12 @@ public function provisionalDrafts(?bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results to only canonical elements, including elements + * that reference another canonical element via `canonicalId` so long as they + * aren’t a draft. * - * @uses $canonicalsOnly + * Unpublished drafts can be included as well if `drafts(null)` and + * `draftOf(false)` are also passed. */ public function canonicalsOnly(bool $value = true): static { @@ -342,9 +438,25 @@ public function canonicalsOnly(bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results to only unpublished drafts which have been saved after initial creation. * - * @uses $savedDraftsOnly + * --- + * + * ```twig + * {# Fetch saved, unpublished draft {elements} #} + * {% set {elements-var} = {twig-method} + * .draftOf(false) + * .savedDraftsOnly() + * .all() %} + * ``` + * + * ```php + * // Fetch saved, unpublished draft {elements} + * ${elements-var} = {element-class}::find() + * ->draftOf(false) + * ->savedDraftsOnly() + * ->all(); + * ``` */ public function savedDraftsOnly(bool $value = true): static { @@ -354,9 +466,25 @@ public function savedDraftsOnly(bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results to only revision {elements}. + * + * --- + * + * ```twig + * {# Fetch a revision {element} #} + * {% set {elements-var} = {twig-method} + * .revisions() + * .id(123) + * .one() %} + * ``` * - * @uses $revisions + * ```php + * // Fetch a revision {element} + * ${elements-var} = {element-class}::find() + * ->revisions() + * ->id(123) + * ->one(); + * ``` */ public function revisions(?bool $value = true): static { @@ -366,10 +494,29 @@ public function revisions(?bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ revision’s ID (from the `revisions` table). + * + * Possible values include: + * + * | Value | Fetches revisions… + * | - | - + * | `1` | for the revision with an ID of 1. + * + * --- + * + * ```twig + * {# Fetch a revision #} + * {% set {elements-var} = {twig-method} + * .revisionId(10) + * .all() %} + * ``` * - * @uses $revisionId - * @uses $revisions + * ```php + * // Fetch a revision + * ${elements-var} = {php-method} + * ->revisionIf(10) + * ->all(); + * ``` */ public function revisionId(?int $value = null): static { @@ -383,12 +530,32 @@ public function revisionId(?int $value = null): static } /** - * {@inheritdoc} + * Narrows the query results to only revisions of a given {element}. * - * @uses $revisionOf - * @uses $revisions + * Possible values include: + * + * | Value | Fetches revisions… + * | - | - + * | `1` | for the {element} with an ID of 1. + * | a [[{element-class}]] object | for the {element} represented by the object. + * + * --- + * + * ```twig + * {# Fetch revisions of the {element} #} + * {% set {elements-var} = {twig-method} + * .revisionOf({myElement}) + * .all() %} + * ``` + * + * ```php + * // Fetch revisions of the {element} + * ${elements-var} = {php-method} + * ->revisionOf(${myElement}) + * ->all(); + * ``` */ - public function revisionOf($value): static + public function revisionOf(mixed $value): static { $this->revisionOf = match (true) { $value instanceof ElementInterface => $value->getCanonicalId(), @@ -404,12 +571,32 @@ public function revisionOf($value): static } /** - * {@inheritdoc} + * Narrows the query results to only revisions created by a given user. + * + * Possible values include: + * + * | Value | Fetches revisions… + * | - | - + * | `1` | created by the user with an ID of 1. + * | a [[User]] object | created by the user represented by the object. + * + * --- + * + * ```twig + * {# Fetch revisions by the current user #} + * {% set {elements-var} = {twig-method} + * .revisionCreator(currentUser) + * .all() %} + * ``` * - * @uses $revisionCreator - * @uses $revisions + * ```php + * // Fetch revisions by the current user + * ${elements-var} = {php-method} + * ->revisionCreator(Auth::user()) + * ->all(); + * ``` */ - public function revisionCreator($value): static + public function revisionCreator(mixed $value): static { $this->revisionCreator = match (true) { $value instanceof User => $value->id, diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php index abebc520e0b..41a493911f4 100644 --- a/src/Database/Queries/Concerns/QueriesEagerly.php +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -27,22 +27,16 @@ trait QueriesEagerly /** * @var ElementInterface|null The source element that this query is fetching relations for. - * - * @since 5.0.0 */ public ?ElementInterface $eagerLoadSourceElement = null; /** * @var string|null The handle that could be used to eager-load the query's target elmeents. - * - * @since 5.0.0 */ public ?string $eagerLoadHandle = null; /** * @var string|null The eager-loading alias that should be used. - * - * @since 5.0.0 */ public ?string $eagerLoadAlias = null; @@ -51,8 +45,6 @@ trait QueriesEagerly * and any other elements in its collection. * * @used-by eagerly() - * - * @since 5.0.0 */ public bool $eagerly = false; @@ -69,9 +61,25 @@ protected function initQueriesEagerly(): void } /** - * {@inheritdoc} + * Causes the query to return matching {elements} eager-loaded with related elements. + * + * See [Eager-Loading Elements](https://craftcms.com/docs/5.x/development/eager-loading.html) for a full explanation of how to work with this parameter. + * + * --- * - * @uses $with + * ```twig + * {# Fetch {elements} eager-loaded with the "Related" field’s relations #} + * {% set {elements-var} = {twig-method} + * .with(['related']) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} eager-loaded with the "Related" field’s relations + * ${elements-var} = {php-method} + * ->with(['related']) + * ->all(); + * ``` */ public function with(array|string|null $value): static { @@ -81,9 +89,7 @@ public function with(array|string|null $value): static } /** - * {@inheritdoc} - * - * @uses $with + * Causes the query to return matching {elements} eager-loaded with related elements, in addition to the elements that were already specified by [[with()]].. */ public function andWith(array|string|null $value): static { @@ -103,7 +109,10 @@ public function andWith(array|string|null $value): static } /** - * {@inheritdoc} + * Causes the query to be used to eager-load results for the query’s source element + * and any other elements in its collection. + * + * @param string|bool $value The property value. If a string, the value will be used as the eager-loading alias. */ public function eagerly(string|bool $value = true): static { @@ -114,21 +123,24 @@ public function eagerly(string|bool $value = true): static } /** - * {@inheritdoc} + * Prepares the query for lazy eager loading. + * + * @param string $handle The eager loading handle the query is for + * @param ElementInterface $sourceElement One of the source elements the query is fetching elements for */ public function prepForEagerLoading(string $handle, ElementInterface $sourceElement): static { // Prefix the handle with the provider's handle, if there is one $providerHandle = $sourceElement->getFieldLayout()?->provider?->getHandle(); - $this->eagerLoadHandle = $providerHandle ? "$providerHandle:$handle" : $handle; + $this->eagerLoadHandle = $providerHandle ? "$providerHandle:$handle" : $handle; $this->eagerLoadSourceElement = $sourceElement; return $this; } /** - * {@inheritdoc} + * Returns whether the query results were already eager loaded by the query's source element. */ public function wasEagerLoaded(?string $alias = null): bool { @@ -149,7 +161,7 @@ public function wasEagerLoaded(?string $alias = null): bool } /** - * {@inheritdoc} + * Returns whether the query result count was already eager loaded by the query's source element. */ public function wasCountEagerLoaded(?string $alias = null): bool { diff --git a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php index 0507e939ed4..bc7a309979d 100644 --- a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php +++ b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php @@ -36,9 +36,8 @@ trait QueriesPlaceholderElements private mixed $placeholderSiteIds = null; /** - * {@inheritdoc} - * - * @uses $ignorePlaceholders + * Causes the query to return matching {elements} as they are stored in the database, ignoring matching placeholder + * elements that were set by [[\craft\services\Elements::setPlaceholderElement()]]. */ public function ignorePlaceholders(bool $value = true): static { diff --git a/src/Database/Queries/Concerns/QueriesRelatedElements.php b/src/Database/Queries/Concerns/QueriesRelatedElements.php index 8748765b929..b38cbc1adef 100644 --- a/src/Database/Queries/Concerns/QueriesRelatedElements.php +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -9,6 +9,7 @@ use craft\elements\db\ElementRelationParamParser; use CraftCms\Cms\Support\Arr; use Illuminate\Database\Query\Builder; +use RuntimeException; /** * @mixin \CraftCms\Cms\Database\Queries\ElementQuery @@ -93,9 +94,25 @@ private function applyNotRelatedToParam(): void } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are not related to certain other elements. * - * @uses $notRelatedTo + * See [Relations](https://craftcms.com/docs/5.x/system/relations.html) for a full explanation of how to work with this parameter. + * + * --- + * + * ```twig + * {# Fetch all {elements} that are related to myEntry #} + * {% set {elements-var} = {twig-method} + * .notRelatedTo(myEntry) + * .all() %} + * ``` + * + * ```php + * // Fetch all {elements} that are related to $myEntry + * ${elements-var} = {php-method} + * ->notRelatedTo($myEntry) + * ->all(); + * ``` */ public function notRelatedTo($value): static { @@ -105,9 +122,27 @@ public function notRelatedTo($value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are not related to certain other elements. + * + * See [Relations](https://craftcms.com/docs/5.x/system/relations.html) for a full explanation of how to work with this parameter. * - * @uses $notRelatedTo + * --- + * + * ```twig + * {# Fetch all {elements} that are related to myCategoryA and not myCategoryB #} + * {% set {elements-var} = {twig-method} + * .relatedTo(myCategoryA) + * .andNotRelatedTo(myCategoryB) + * .all() %} + * ``` + * + * ```php + * // Fetch all {elements} that are related to $myCategoryA and not $myCategoryB + * ${elements-var} = {php-method} + * ->relatedTo($myCategoryA) + * ->andNotRelatedTo($myCategoryB) + * ->all(); + * ``` */ public function andNotRelatedTo($value): static { @@ -121,9 +156,25 @@ public function andNotRelatedTo($value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are related to certain other elements. + * + * See [Relations](https://craftcms.com/docs/5.x/system/relations.html) for a full explanation of how to work with this parameter. * - * @uses $relatedTo + * --- + * + * ```twig + * {# Fetch all {elements} that are related to myCategory #} + * {% set {elements-var} = {twig-method} + * .relatedTo(myCategory) + * .all() %} + * ``` + * + * ```php + * // Fetch all {elements} that are related to $myCategory + * ${elements-var} = {php-method} + * ->relatedTo($myCategory) + * ->all(); + * ``` */ public function relatedTo($value): static { @@ -133,11 +184,27 @@ public function relatedTo($value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are related to certain other elements. + * + * See [Relations](https://craftcms.com/docs/5.x/system/relations.html) for a full explanation of how to work with this parameter. * - * @throws NotSupportedException + * --- * - * @uses $relatedTo + * ```twig + * {# Fetch all {elements} that are related to myCategoryA and myCategoryB #} + * {% set {elements-var} = {twig-method} + * .relatedTo(myCategoryA) + * .andRelatedTo(myCategoryB) + * .all() %} + * ``` + * + * ```php + * // Fetch all {elements} that are related to $myCategoryA and $myCategoryB + * ${elements-var} = {php-method} + * ->relatedTo($myCategoryA) + * ->andRelatedTo($myCategoryB) + * ->all(); + * ``` */ public function andRelatedTo($value): static { @@ -150,9 +217,6 @@ public function andRelatedTo($value): static return $this->relatedTo($relatedTo); } - /** - * @throws NotSupportedException - */ private function _andRelatedToCriteria($value, $currentValue): mixed { if (! $value) { @@ -169,7 +233,7 @@ private function _andRelatedToCriteria($value, $currentValue): mixed // Not possible to switch from `or` to `and` if there are multiple criteria if ($relatedTo[0] === 'or' && $criteriaCount > 1) { - throw new NotSupportedException('It’s not possible to combine “or” and “and” relatedTo conditions.'); + throw new RuntimeException('It’s not possible to combine “or” and “and” relatedTo conditions.'); } $relatedTo[0] = $criteriaCount > 0 ? 'and' : 'or'; diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index 0d1346bb34c..b3b69ce41bb 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -4,9 +4,15 @@ namespace CraftCms\Cms\Database\Queries\Concerns; -use craft\errors\SiteNotFoundException; -use craft\models\Site; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; +use CraftCms\Cms\Shared\Models\Info; +use CraftCms\Cms\Site\Data\Site; +use CraftCms\Cms\Site\Exceptions\SiteNotFoundException; +use CraftCms\Cms\Site\Models\Site as SiteModel; +use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Sites; +use CraftCms\Cms\Support\Facades\Updates; +use Illuminate\Support\Collection; use InvalidArgumentException; /** @@ -32,42 +38,71 @@ protected function initQueriesSites(): void try { if (! $this->elementType::isLocalized()) { // The criteria *must* be set to the primary site ID - $this->siteId = \Craft::$app->getSites()->getPrimarySite()->id; + $this->siteId = Sites::getPrimarySite()->id; } else { $this->normalizeSiteId(); } } catch (SiteNotFoundException $e) { // Fail silently if Craft isn't installed yet or is in the middle of updating - if (\Craft::$app->getIsInstalled() && ! \Craft::$app->getUpdates()->getIsCraftUpdatePending()) { + if (Info::isInstalled() && ! Updates::isCraftUpdatePending()) { throw $e; } throw new QueryAbortedException($e->getMessage(), 0, $e); } - if (\Craft::$app->getIsMultiSite(false, true)) { + if (Sites::isMultiSite(false, true)) { $this->subQuery->where('elements_sites.siteId', $this->siteId); } }); } /** - * {@inheritdoc} + * Determines which site(s) the {elements} should be queried in. * - * @throws InvalidArgumentException if $value is invalid + * The current site will be used by default. * - * @uses $siteId + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'foo'` | from the site with a handle of `foo`. + * | `['foo', 'bar']` | from a site with a handle of `foo` or `bar`. + * | `['not', 'foo', 'bar']` | not in a site with a handle of `foo` or `bar`. + * | a [[Site]] object | from the site represented by the object. + * | `'*'` | from any site. + * + * ::: tip + * If multiple sites are specified, elements that belong to multiple sites will be returned multiple times. If you + * only want unique elements to be returned, use [[unique()]] in conjunction with this. + * ::: + * + * --- + * + * ```twig + * {# Fetch {elements} from the Foo site #} + * {% set {elements-var} = {twig-method} + * .site('foo') + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} from the Foo site + * ${elements-var} = {php-method} + * ->site('foo') + * ->all(); + * ``` */ public function site($value): static { if ($value === null) { $this->siteId = null; } elseif ($value === '*') { - $this->siteId = \Craft::$app->getSites()->getAllSiteIds(); - } elseif ($value instanceof Site) { + $this->siteId = Sites::getAllSiteIds(); + } elseif ($value instanceof Site || $value instanceof SiteModel) { $this->siteId = $value->id; } elseif (is_string($value)) { - $this->siteId = \Craft::$app->getSites()->getSiteByHandle($value)?->id ?? throw new InvalidArgumentException('Invalid site handle: '.$value); + $this->siteId = Sites::getSiteByHandle($value)?->id ?? throw new InvalidArgumentException('Invalid site handle: '.$value); } else { if ($not = (strtolower((string) reset($value)) === 'not')) { array_shift($value); @@ -75,7 +110,7 @@ public function site($value): static $this->siteId = []; - foreach (\Craft::$app->getSites()->getAllSites() as $site) { + foreach (Sites::getAllSites() as $site) { if (in_array($site->handle, $value, true) === ! $not) { $this->siteId[] = $site->id; } @@ -91,9 +126,34 @@ public function site($value): static } /** - * {@inheritdoc} + * Determines which site(s) the {elements} should be queried in, per the site’s ID. + * + * The current site will be used by default. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | from the site with an ID of `1`. + * | `[1, 2]` | from a site with an ID of `1` or `2`. + * | `['not', 1, 2]` | not in a site with an ID of `1` or `2`. + * | `'*'` | from any site. * - * @uses $siteId + * --- + * + * ```twig + * {# Fetch {elements} from the site with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .siteId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} from the site with an ID of 1 + * ${elements-var} = {php-method} + * ->siteId(1) + * ->all(); + * ``` */ public function siteId($value): static { @@ -117,11 +177,36 @@ public function siteId($value): static } /** - * {@inheritdoc} + * Determines which site(s) the {elements} should be queried in, based on their language. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'en'` | from sites with a language of `en`. + * | `['en-GB', 'en-US']` | from sites with a language of `en-GB` or `en-US`. + * | `['not', 'en-GB', 'en-US']` | not in sites with a language of `en-GB` or `en-US`. * - * @return static + * ::: tip + * Elements that belong to multiple sites will be returned multiple times by default. If you + * only want unique elements to be returned, use [[unique()]] in conjunction with this. + * ::: * - * @uses $siteId + * --- + * + * ```twig + * {# Fetch {elements} from English sites #} + * {% set {elements-var} = {twig-method} + * .language('en') + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} from English sites + * ${elements-var} = {php-method} + * ->language('en') + * ->all(); + * ``` */ public function language($value): self { @@ -162,20 +247,21 @@ public function language($value): self */ private function normalizeSiteId(): void { - $sitesService = \Craft::$app->getSites(); if (! $this->siteId) { // Default to the current site - $this->siteId = $sitesService->getCurrentSite()->id; + $this->siteId = Sites::getCurrentSite()->id; } elseif ($this->siteId === '*') { - $this->siteId = $sitesService->getAllSiteIds(); + $this->siteId = Sites::getAllSiteIds(); } elseif (is_numeric($this->siteId) || Arr::isNumeric($this->siteId)) { // Filter out any invalid site IDs $siteIds = Collection::make((array) $this->siteId) - ->filter(fn ($siteId) => $sitesService->getSiteById($siteId, true) !== null) + ->filter(fn ($siteId) => Sites::getSiteById($siteId, true) !== null) ->all(); + if (empty($siteIds)) { throw new QueryAbortedException; } + $this->siteId = is_array($this->siteId) ? $siteIds : reset($siteIds); } } diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php index 70c3ed917df..bb8b36ff99e 100644 --- a/src/Database/Queries/Concerns/QueriesStatuses.php +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -53,12 +53,6 @@ protected function initQueriesStatuses(): void }); } - /** - * Sets the [[$archived]] property. - * - * @param bool $value The property value (defaults to true) - * @return static self reference - */ public function archived(bool $value = true): static { $this->archived = $value; diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index 6ca6f3ede1c..fa8c51e3d71 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -137,9 +137,7 @@ protected function initQueriesStructures(): void } /** - * {@inheritdoc} - * - * @uses $withStructure + * Explicitly determines whether the query should join in the structure data. */ public function withStructure(bool $value = true): static { @@ -149,9 +147,7 @@ public function withStructure(bool $value = true): static } /** - * {@inheritdoc} - * - * @uses $structureId + * Determines which structure data should be joined into the query. */ public function structureId(?int $value = null): static { @@ -161,9 +157,34 @@ public function structureId(?int $value = null): static } /** - * {@inheritdoc} + * Narrows the query results based on the {elements}’ level within the structure. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | with a level of 1. + * | `'not 1'` | not with a level of 1. + * | `'>= 3'` | with a level greater than or equal to 3. + * | `[1, 2]` | with a level of 1 or 2. + * | `[null, 1]` | without a level, or a level of 1. + * | `['not', 1, 2]` | not with level of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch {elements} positioned at level 3 or above #} + * {% set {elements-var} = {twig-method} + * .level('>= 3') + * .all() %} + * ``` * - * @uses $level + * ```php + * // Fetch {elements} positioned at level 3 or above + * ${elements-var} = {php-method} + * ->level('>= 3') + * ->all(); + * ``` */ public function level($value = null): static { @@ -173,9 +194,25 @@ public function level($value = null): static } /** - * {@inheritdoc} + * Narrows the query results based on whether the {elements} have any descendants in their structure. * - * @uses $hasDescendants + * (This has the opposite effect of calling [[leaves()]].) + * + * --- + * + * ```twig + * {# Fetch {elements} that have descendants #} + * {% set {elements-var} = {twig-method} + * .hasDescendants() + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} that have descendants + * ${elements-var} = {php-method} + * ->hasDescendants() + * ->all(); + * ``` */ public function hasDescendants(bool $value = true): static { @@ -185,9 +222,25 @@ public function hasDescendants(bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results based on whether the {elements} are “leaves” ({elements} with no descendants). + * + * (This has the opposite effect of calling [[hasDescendants()]].) + * + * --- + * + * ```twig + * {# Fetch {elements} that have no descendants #} + * {% set {elements-var} = {twig-method} + * .leaves() + * .all() %} + * ``` * - * @uses $leaves + * ```php + * // Fetch {elements} that have no descendants + * ${elements-var} = {php-method} + * ->leaves() + * ->all(); + * ``` */ public function leaves(bool $value = true): static { @@ -197,9 +250,36 @@ public function leaves(bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are ancestors of another {element} in its structure. * - * @uses $ancestorOf + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | above the {element} with an ID of 1. + * | a [[{element-class}]] object | above the {element} represented by the object. + * + * --- + * + * ```twig + * {# Fetch {elements} above this one #} + * {% set {elements-var} = {twig-method} + * .ancestorOf({myElement}) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} above this one + * ${elements-var} = {php-method} + * ->ancestorOf(${myElement}) + * ->all(); + * ``` + * + * --- + * + * ::: tip + * This can be combined with [[ancestorDist()]] if you want to limit how far away the ancestor {elements} can be. + * ::: */ public function ancestorOf(ElementInterface|int|null $value): static { @@ -209,9 +289,25 @@ public function ancestorOf(ElementInterface|int|null $value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are up to a certain distance away from the {element} specified by [[ancestorOf()]]. * - * @uses $ancestorDist + * --- + * + * ```twig + * {# Fetch {elements} above this one #} + * {% set {elements-var} = {twig-method} + * .ancestorOf({myElement}) + * .ancestorDist(3) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} above this one + * ${elements-var} = {php-method} + * ->ancestorOf(${myElement}) + * ->ancestorDist(3) + * ->all(); + * ``` */ public function ancestorDist(?int $value = null): static { @@ -221,9 +317,36 @@ public function ancestorDist(?int $value = null): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are descendants of another {element} in its structure. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | below the {element} with an ID of 1. + * | a [[{element-class}]] object | below the {element} represented by the object. + * + * --- + * + * ```twig + * {# Fetch {elements} below this one #} + * {% set {elements-var} = {twig-method} + * .descendantOf({myElement}) + * .all() %} + * ``` * - * @uses $descendantOf + * ```php + * // Fetch {elements} below this one + * ${elements-var} = {php-method} + * ->descendantOf(${myElement}) + * ->all(); + * ``` + * + * --- + * + * ::: tip + * This can be combined with [[descendantDist()]] if you want to limit how far away the descendant {elements} can be. + * ::: */ public function descendantOf(ElementInterface|int|null $value): static { @@ -233,9 +356,25 @@ public function descendantOf(ElementInterface|int|null $value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are up to a certain distance away from the {element} specified by [[descendantOf()]]. + * + * --- * - * @uses $descendantDist + * ```twig + * {# Fetch {elements} below this one #} + * {% set {elements-var} = {twig-method} + * .descendantOf({myElement}) + * .descendantDist(3) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} below this one + * ${elements-var} = {php-method} + * ->descendantOf(${myElement}) + * ->descendantDist(3) + * ->all(); + * ``` */ public function descendantDist(?int $value = null): static { @@ -245,9 +384,30 @@ public function descendantDist(?int $value = null): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are siblings of another {element} in its structure. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | beside the {element} with an ID of 1. + * | a [[{element-class}]] object | beside the {element} represented by the object. * - * @uses $siblingOf + * --- + * + * ```twig + * {# Fetch {elements} beside this one #} + * {% set {elements-var} = {twig-method} + * .siblingOf({myElement}) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} beside this one + * ${elements-var} = {php-method} + * ->siblingOf(${myElement}) + * ->all(); + * ``` */ public function siblingOf(ElementInterface|int|null $value): static { @@ -257,9 +417,30 @@ public function siblingOf(ElementInterface|int|null $value): static } /** - * {@inheritdoc} + * Narrows the query results to only the {element} that comes immediately before another {element} in its structure. + * + * Possible values include: + * + * | Value | Fetches the {element}… + * | - | - + * | `1` | before the {element} with an ID of 1. + * | a [[{element-class}]] object | before the {element} represented by the object. + * + * --- + * + * ```twig + * {# Fetch the previous {element} #} + * {% set {element-var} = {twig-method} + * .prevSiblingOf({myElement}) + * .one() %} + * ``` * - * @uses $prevSiblingOf + * ```php + * // Fetch the previous {element} + * ${element-var} = {php-method} + * ->prevSiblingOf(${myElement}) + * ->one(); + * ``` */ public function prevSiblingOf(ElementInterface|int|null $value): static { @@ -269,9 +450,30 @@ public function prevSiblingOf(ElementInterface|int|null $value): static } /** - * {@inheritdoc} + * Narrows the query results to only the {element} that comes immediately after another {element} in its structure. * - * @uses $nextSiblingOf + * Possible values include: + * + * | Value | Fetches the {element}… + * | - | - + * | `1` | after the {element} with an ID of 1. + * | a [[{element-class}]] object | after the {element} represented by the object. + * + * --- + * + * ```twig + * {# Fetch the next {element} #} + * {% set {element-var} = {twig-method} + * .nextSiblingOf({myElement}) + * .one() %} + * ``` + * + * ```php + * // Fetch the next {element} + * ${element-var} = {php-method} + * ->nextSiblingOf(${myElement}) + * ->one(); + * ``` */ public function nextSiblingOf(ElementInterface|int|null $value): static { @@ -281,9 +483,30 @@ public function nextSiblingOf(ElementInterface|int|null $value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are positioned before another {element} in its structure. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | before the {element} with an ID of 1. + * | a [[{element-class}]] object | before the {element} represented by the object. * - * @uses $positionedBefore + * --- + * + * ```twig + * {# Fetch {elements} before this one #} + * {% set {elements-var} = {twig-method} + * .positionedBefore({myElement}) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} before this one + * ${elements-var} = {php-method} + * ->positionedBefore(${myElement}) + * ->all(); + * ``` */ public function positionedBefore(ElementInterface|int|null $value): static { @@ -293,9 +516,30 @@ public function positionedBefore(ElementInterface|int|null $value): static } /** - * {@inheritdoc} + * Narrows the query results to only {elements} that are positioned after another {element} in its structure. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | after the {element} with an ID of 1. + * | a [[{element-class}]] object | after the {element} represented by the object. + * + * --- + * + * ```twig + * {# Fetch {elements} after this one #} + * {% set {elements-var} = {twig-method} + * .positionedAfter({myElement}) + * .all() %} + * ``` * - * @uses $positionedAfter + * ```php + * // Fetch {elements} after this one + * ${elements-var} = {php-method} + * ->positionedAfter(${myElement}) + * ->all(); + * ``` */ public function positionedAfter(ElementInterface|int|null $value): static { diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php index 936c8f40a2e..ff6ce64a589 100644 --- a/src/Database/Queries/Concerns/QueriesUniqueElements.php +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -92,9 +92,28 @@ protected function initQueriesUniqueElements(): void } /** - * {@inheritdoc} + * Determines whether only elements with unique IDs should be returned by the query. * - * @uses $unique + * This should be used when querying elements from multiple sites at the same time, if “duplicate” results is not + * desired. + * + * --- + * + * ```twig + * {# Fetch unique {elements} across all sites #} + * {% set {elements-var} = {twig-method} + * .site('*') + * .unique() + * .all() %} + * ``` + * + * ```php + * // Fetch unique {elements} across all sites + * ${elements-var} = {php-method} + * ->site('*') + * ->unique() + * ->all(); + * ``` */ public function unique(bool $value = true): static { @@ -104,9 +123,33 @@ public function unique(bool $value = true): static } /** - * {@inheritdoc} + * If [[unique()]] is set, this determines which site should be selected when querying multi-site elements. + * + * For example, if element “Foo” exists in Site A and Site B, and element “Bar” exists in Site B and Site C, + * and this is set to `['c', 'b', 'a']`, then Foo will be returned for Site B, and Bar will be returned + * for Site C. + * + * If this isn’t set, then preference goes to the current site. + * + * --- + * + * ```twig + * {# Fetch unique {elements} from Site A, or Site B if they don’t exist in Site A #} + * {% set {elements-var} = {twig-method} + * .site('*') + * .unique() + * .preferSites(['a', 'b']) + * .all() %} + * ``` * - * @uses $preferSites + * ```php + * // Fetch unique {elements} from Site A, or Site B if they don’t exist in Site A + * ${elements-var} = {php-method} + * ->site('*') + * ->unique() + * ->preferSites(['a', 'b']) + * ->all(); + * ``` */ public function preferSites(?array $value = null): static { diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php index 73f86f54982..d5fc2712372 100644 --- a/src/Database/Queries/Concerns/SearchesElements.php +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -138,11 +138,8 @@ private function applySearchParam(ElementQuery $query): void * ->search($searchQuery) * ->all(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ - public function search($value): static + public function search(mixed $value): static { $this->search = $value; From 087aeec78eb1cbb87eefb2bf1278bdf1859ffa7b Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 10 Nov 2025 19:56:58 +0100 Subject: [PATCH 13/52] Initial test for search --- .../Queries/Concerns/SearchesElements.php | 12 ++++++---- .../Queries/Concerns/SearchesElementsTest.php | 24 +++++++++++++++++++ yii2-adapter/legacy/services/Search.php | 6 ++--- 3 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 tests/Database/Queries/Concerns/SearchesElementsTest.php diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php index d5fc2712372..9640624ab45 100644 --- a/src/Database/Queries/Concerns/SearchesElements.php +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -68,13 +68,15 @@ private function applySearchParam(ElementQuery $query): void if (($query->query->orders[0]['column'] ?? null) === 'score') { // Only use the portion we're actually querying for - if (is_int($query->query->offset) && $query->query->offset !== 0) { - $searchResults = array_slice($searchResults, $query->query->offset, null, true); + if (is_int($query->query->getOffset()) && $query->query->getOffset() !== 0) { + $searchResults = array_slice($searchResults, $query->query->getOffset(), null, true); $query->subQuery->offset = null; + $query->subQuery->unionOffset = null; } - if (is_int($query->query->limit) && $query->query->limit !== 0) { - $searchResults = array_slice($searchResults, 0, $query->query->limit, true); + if (is_int($query->query->getLimit()) && $query->query->getLimit() !== 0) { + $searchResults = array_slice($searchResults, 0, $query->query->getLimit(), true); $query->subQuery->limit = null; + $query->subQuery->unionLimit = null; } } @@ -109,7 +111,7 @@ private function applySearchParam(ElementQuery $query): void throw new QueryAbortedException; } - $query->subQuery->whereIn('elements.id', $searchQuery->select('elementId')); + $query->subQuery->whereIn('elements.id', $searchQuery->select('elementId')->all()); } /** diff --git a/tests/Database/Queries/Concerns/SearchesElementsTest.php b/tests/Database/Queries/Concerns/SearchesElementsTest.php new file mode 100644 index 00000000000..32377bba426 --- /dev/null +++ b/tests/Database/Queries/Concerns/SearchesElementsTest.php @@ -0,0 +1,24 @@ +create(); + $entry1->element->siteSettings->first()->update([ + 'title' => 'Foo', + ]); + + $entry2 = EntryModel::factory()->create(); + $entry2->element->siteSettings->first()->update([ + 'title' => 'Bar', + ]); + + $element1 = Craft::$app->getElements()->getElementById($entry1->id); + $element2 = Craft::$app->getElements()->getElementById($entry2->id); + + Craft::$app->getSearch()->indexElementAttributes($element1); + Craft::$app->getSearch()->indexElementAttributes($element2); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->search('Foo')->count())->toBe(1); +}); diff --git a/yii2-adapter/legacy/services/Search.php b/yii2-adapter/legacy/services/Search.php index a76ca8b4f60..d0f03ea0e2c 100644 --- a/yii2-adapter/legacy/services/Search.php +++ b/yii2-adapter/legacy/services/Search.php @@ -390,7 +390,7 @@ private function isIndexJobPending(int $jobId): bool * @return bool * @since 4.8.0 */ - public function shouldCallSearchElements(ElementQuery|\CraftCms\Cms\Element\Queries\ElementQuery $elementQuery): bool + public function shouldCallSearchElements(ElementQuery|\CraftCms\Cms\Database\Queries\ElementQuery $elementQuery): bool { return false; } @@ -402,7 +402,7 @@ public function shouldCallSearchElements(ElementQuery|\CraftCms\Cms\Element\Quer * @return array The element scores (descending) indexed by element ID and site ID (e.g. `'100-1'`). * @since 3.7.14 */ - public function searchElements(ElementQuery|\CraftCms\Cms\Element\Queries\ElementQuery $elementQuery): array + public function searchElements(ElementQuery|\CraftCms\Cms\Database\Queries\ElementQuery $elementQuery): array { $searchQuery = $this->normalizeSearchQuery($elementQuery->search); @@ -468,7 +468,7 @@ public function searchElements(ElementQuery|\CraftCms\Cms\Element\Queries\Elemen * @return Query|false * @since 4.6.0 */ - public function createDbQuery(string|array|SearchQuery $searchQuery, ElementQuery $elementQuery): Query|false + public function createDbQuery(string|array|SearchQuery $searchQuery, ElementQuery|\CraftCms\Cms\Database\Queries\ElementQuery $elementQuery): Query|false { $searchQuery = $this->normalizeSearchQuery($searchQuery); From b252993c5e81801729191bfb517feeaa2cdd2372 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 11 Nov 2025 09:20:48 +0100 Subject: [PATCH 14/52] More refactoring + get search in a working state --- .../Queries/Concerns/FormatsResults.php | 109 +++++++++++++----- .../Queries/Concerns/QueriesCustomFields.php | 46 ++++---- .../Concerns/QueriesDraftsAndRevisions.php | 88 +++++++------- .../Queries/Concerns/QueriesFields.php | 50 ++++---- .../Queries/Concerns/QueriesSites.php | 33 +++--- .../Queries/Concerns/QueriesStatuses.php | 25 ++-- .../Queries/Concerns/QueriesStructures.php | 100 ++++++++-------- .../Concerns/QueriesUniqueElements.php | 11 +- .../Queries/Concerns/SearchesElements.php | 45 ++++---- src/Database/Queries/ElementQuery.php | 106 +++++++---------- src/Database/Queries/EntryQuery.php | 26 ++--- .../QueriesDraftsAndRevisionsTest.php | 1 + .../Queries/Concerns/SearchesElementsTest.php | 23 ++++ tests/Database/Queries/ElementQueryTest.php | 7 ++ tests/Pest.php | 4 +- yii2-adapter/legacy/services/Search.php | 14 ++- 16 files changed, 383 insertions(+), 305 deletions(-) diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php index fb53a700b71..38cd03db298 100644 --- a/src/Database/Queries/Concerns/FormatsResults.php +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -8,6 +8,13 @@ use CraftCms\Cms\Database\Expressions\OrderByPlaceholderExpression; use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; +use Illuminate\Database\Query\Expression; +use Tpetry\QueryExpressions\Language\CaseGroup; +use Tpetry\QueryExpressions\Language\CaseRule; +use Tpetry\QueryExpressions\Operator\Comparison\Equal; +use Tpetry\QueryExpressions\Operator\Logical\CondAnd; +use Tpetry\QueryExpressions\Value\Value; +use yii\base\InvalidValueException; /** * @mixin \CraftCms\Cms\Database\Queries\ElementQuery @@ -143,71 +150,79 @@ protected function initFormatsResults(): void { $this->query->orderBy(new OrderByPlaceholderExpression); - $this->beforeQuery(function (ElementQuery $query) { - $this->applyDefaultOrder($query); + $this->beforeQuery(function (ElementQuery $elementQuery) { + $this->orderBySearchResults($elementQuery); + $this->applyDefaultOrder($elementQuery); - if ($this->inReverse) { - $orders = $query->query->orders; + if ($elementQuery->inReverse) { + $orders = $elementQuery->query->orders; - $query->query->reorder(); + $elementQuery->query->reorder(); foreach (array_reverse($orders) as $order) { - $query->query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); + // If it's an expression we can't reverse it + if ($order['column'] instanceof Expression) { + $elementQuery->query->orderBy($order['column']); + + continue; + } + + $elementQuery->query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); } } - $this->parseOrderColumnMappings($query); + $this->parseOrderColumnMappings($elementQuery); }); } - private function applyDefaultOrder(ElementQuery $query): void + private function applyDefaultOrder(ElementQuery $elementQuery): void { - $orders = $query->query->orders; + $orders = $elementQuery->query->orders; if (is_null($orders)) { return; } - $query->query->orders = array_filter( + $elementQuery->query->orders = array_filter( array: $orders, callback: fn ($order) => ! $order['column'] instanceof OrderByPlaceholderExpression, ); // Order by was set - if (count($query->query->orders) > 0) { + if (count($elementQuery->query->orders) > 0) { return; } - if ($this->fixedOrder) { - throw_if(empty($this->id), QueryAbortedException::class); + if ($elementQuery->fixedOrder) { + throw_if(empty($elementQuery->id), QueryAbortedException::class); - if (! is_array($ids = $this->id)) { + if (! is_array($ids = $elementQuery->id)) { $ids = is_string($ids) ? str($ids)->explode(',')->all() : [$ids]; } - $query->query->orderBy(new FixedOrderExpression('elements.id', $ids)); + $elementQuery->query->orderBy(new FixedOrderExpression('elements.id', $ids)); return; } - if ($this->revisions) { - $query->query->orderByDesc('num'); + if ($elementQuery->revisions) { + $elementQuery->query->orderByDesc('num'); return; } - if ($this->shouldJoinStructureData()) { - $query->query->orderBy('structureelements.lft'); + if ($elementQuery->shouldJoinStructureData()) { + $elementQuery->query->orderBy('structureelements.lft'); - foreach ($this->defaultOrderBy as $column => $direction) { - $query->query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); + foreach ($elementQuery->defaultOrderBy as $column => $direction) { + $elementQuery->query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); } return; } - foreach ($this->defaultOrderBy as $column => $direction) { - $query->query->orderBy($column, match ($direction) { + foreach ($elementQuery->defaultOrderBy as $column => $direction) { + $elementQuery->query->orderBy($column, match ($direction) { SORT_ASC, 'asc' => 'asc', SORT_DESC, 'desc' => 'desc', default => throw new QueryAbortedException('Invalid sort direction: '.$direction), @@ -215,22 +230,62 @@ private function applyDefaultOrder(ElementQuery $query): void } } - private function parseOrderColumnMappings(ElementQuery $query): void + private function parseOrderColumnMappings(ElementQuery $elementQuery): void { - $orders = $query->query->orders; + $orders = $elementQuery->query->orders; if (is_null($orders)) { return; } - $query->query->orders = array_map(function ($order) { + $elementQuery->query->orders = array_map(function ($order) use ($elementQuery) { if (! is_string($order['column'])) { return $order; } - $order['column'] = $this->columnMap[$order['column']] ?? $order['column']; + $order['column'] = $elementQuery->columnMap[$order['column']] ?? $order['column']; return $order; }, $orders); } + + private function orderBySearchResults(ElementQuery $elementQuery): void + { + if (! $elementQuery->searchResults) { + $elementQuery->query->orders = array_filter( + $elementQuery->query->orders, + fn (array $order) => $order['column'] !== 'score', + ); + + return; + } + + $keys = array_keys($elementQuery->searchResults); + + if ($elementQuery->inReverse) { + $keys = array_reverse($keys); + } + + $i = -1; + + $rules = []; + + foreach ($keys as $i => $key) { + [$elementId, $siteId] = array_pad(explode('-', (string) $key, 2), 2, null); + + if ($siteId === null) { + throw new InvalidValueException("Invalid element search score key: \"$key\". Search scores should be indexed by element ID and site ID (e.g. \"100-1\")."); + } + + $rules[] = new CaseRule( + result: new Value($i), + condition: new CondAnd( + value1: new Equal('elements.id', new Value($elementId)), + value2: new Equal('elements_sites.siteId', new Value($siteId)) + ) + ); + } + + $elementQuery->subQuery->orderBy(new CaseGroup($rules, new Value($i + 1))); + } } diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index e5ab5605d94..ef721e63028 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -50,26 +50,26 @@ trait QueriesCustomFields protected function initQueriesCustomFields(): void { - // Gather custom fields and generated field handles - $this->customFields = []; - $this->generatedFields = []; - - if ($this->withCustomFields) { - foreach ($this->fieldLayouts() as $fieldLayout) { - foreach ($fieldLayout->getCustomFields() as $field) { - $this->customFields[] = $field; - } - foreach ($fieldLayout->getGeneratedFields() as $field) { - $this->generatedFields[] = $field; + $this->beforeQuery(function (ElementQuery $elementQuery) { + // Gather custom fields and generated field handles + $elementQuery->customFields = []; + $elementQuery->generatedFields = []; + + if ($elementQuery->withCustomFields) { + foreach ($elementQuery->fieldLayouts() as $fieldLayout) { + foreach ($fieldLayout->getCustomFields() as $field) { + $elementQuery->customFields[] = $field; + } + foreach ($fieldLayout->getGeneratedFields() as $field) { + $elementQuery->generatedFields[] = $field; + } } } - } - // Map custom field handles to their content values - $this->addCustomFieldsToColumnMap(); + // Map custom field handles to their content values + $this->addCustomFieldsToColumnMap(); - $this->beforeQuery(function (ElementQuery $query) { - $this->applyCustomFieldParams($query); + $this->applyCustomFieldParams($elementQuery); }); } @@ -165,7 +165,7 @@ private function addToColumnMap(string $alias, string|callable|Expression $colum */ private function applyCustomFieldParams(ElementQuery $query): void { - if (empty($this->customFields) && empty($this->generatedFields)) { + if (empty($query->customFields) && empty($query->generatedFields)) { return; } @@ -173,9 +173,9 @@ private function applyCustomFieldParams(ElementQuery $query): void /** @var FieldInterface[][][] $fieldsByHandle */ $fieldsByHandle = []; - if (! empty($this->customFields)) { + if (! empty($query->customFields)) { // Group the fields by handle and field UUID - foreach ($this->customFields as $field) { + foreach ($query->customFields as $field) { $fieldsByHandle[$field->handle][$field->uid][] = $field; } @@ -224,18 +224,18 @@ private function applyCustomFieldParams(ElementQuery $query): void if (! empty($conditions)) { if (count($conditions) === 1) { - $this->subQuery->andWhere(reset($conditions), $params); + $query->subQuery->andWhere(reset($conditions), $params); } else { - $this->subQuery->andWhere(['or', ...$conditions], $params); + $query->subQuery->andWhere(['or', ...$conditions], $params); } } } } - if (! empty($this->generatedFields)) { + if (! empty($query->generatedFields)) { $generatedFieldColumns = []; - foreach ($this->generatedFields as $field) { + foreach ($query->generatedFields as $field) { $handle = $field['handle'] ?? ''; if ($handle !== '' && isset($fieldAttributes->$handle) && ! isset($fieldsByHandle[$handle])) { $generatedFieldColumns[$handle][] = new JsonExtract('elements_sites.content', '$.'.$field['uid']); diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php index 23259762f5e..39d61fac5cd 100644 --- a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -96,25 +96,25 @@ trait QueriesDraftsAndRevisions protected function initQueriesDraftsAndRevisions(): void { - $this->beforeQuery(function (ElementQuery $query) { - $this->applyDraftParams($query); - $this->applyRevisionParams($query); + $this->beforeQuery(function (ElementQuery $elementQuery) { + $this->applyDraftParams($elementQuery); + $this->applyRevisionParams($elementQuery); }); } - private function applyDraftParams(ElementQuery $query): void + private function applyDraftParams(ElementQuery $elementQuery): void { - if ($this->drafts === false) { - $query->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.draftId'))); + if ($elementQuery->drafts === false) { + $elementQuery->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.draftId'))); return; } - $joinType = $this->drafts === true ? 'inner' : 'left'; - $query->subQuery->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); - $query->query->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); + $joinType = $elementQuery->drafts === true ? 'inner' : 'left'; + $elementQuery->subQuery->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); + $elementQuery->query->join(new Alias(Table::DRAFTS, 'drafts'), 'drafts.id', 'elements.draftId', type: $joinType); - $query->query->addSelect([ + $elementQuery->query->addSelect([ 'elements.draftId as draftId', 'drafts.creatorId as draftCreatorId', 'drafts.provisional as isProvisionalDraft', @@ -122,45 +122,45 @@ private function applyDraftParams(ElementQuery $query): void 'drafts.notes as draftNotes', ]); - if ($this->draftId) { - $query->subQuery->where('elements.draftId', $this->draftId); + if ($elementQuery->draftId) { + $elementQuery->subQuery->where('elements.draftId', $elementQuery->draftId); } - if ($this->draftOf === '*') { - $query->subQuery->whereNotNull('elements.canonicalId'); - } elseif (isset($this->draftOf)) { - if ($this->draftOf === false) { - $query->subQuery->whereNull('elements.canonicalId', null); + if ($elementQuery->draftOf === '*') { + $elementQuery->subQuery->whereNotNull('elements.canonicalId'); + } elseif (isset($elementQuery->draftOf)) { + if ($elementQuery->draftOf === false) { + $elementQuery->subQuery->whereNull('elements.canonicalId', null); } else { - $query->subQuery->whereIn('elements.canonicalId', $this->draftOf); + $elementQuery->subQuery->whereIn('elements.canonicalId', $elementQuery->draftOf); } } - if ($this->draftCreator) { - $query->subQuery->where('drafts.creatorId', $this->draftCreator); + if ($elementQuery->draftCreator) { + $elementQuery->subQuery->where('drafts.creatorId', $elementQuery->draftCreator); } - if (isset($this->provisionalDrafts)) { - $query->subQuery->where(function (Builder $query) { - $query->whereNull('elements.draftId') - ->orWhere('drafts.provisional', $this->provisionalDrafts); + if (isset($elementQuery->provisionalDrafts)) { + $elementQuery->subQuery->where(function (Builder $q) use ($elementQuery) { + $q->whereNull('elements.draftId') + ->orWhere('drafts.provisional', $elementQuery->provisionalDrafts); }); } - if ($this->canonicalsOnly) { - $query->subQuery->where(function (Builder $query) { + if ($elementQuery->canonicalsOnly) { + $elementQuery->subQuery->where(function (Builder $query) use ($elementQuery) { $query->whereNull('elements.draftId') - ->orWhere(function (Builder $query) { - $query + ->orWhere(function (Builder $q) use ($elementQuery) { + $q ->whereNull('elements.canonicalId') ->when( - $this->savedDraftsOnly, + $elementQuery->savedDraftsOnly, fn (Builder $q) => $q->where('drafts.saved', true) ); }); }); - } elseif ($this->savedDraftsOnly) { - $query->subQuery->where(function (Builder $query) { + } elseif ($elementQuery->savedDraftsOnly) { + $elementQuery->subQuery->where(function (Builder $query) { $query->whereNull('elements.draftId') ->orWhereNotNull('elements.canonicalId') ->orWhere('drafts.saved', true); @@ -168,35 +168,35 @@ private function applyDraftParams(ElementQuery $query): void } } - private function applyRevisionParams(ElementQuery $query): void + private function applyRevisionParams(ElementQuery $elementQuery): void { - if ($this->revisions === false) { - $query->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.revisionId'))); + if ($elementQuery->revisions === false) { + $elementQuery->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.revisionId'))); return; } - $joinType = $this->revisions === true ? 'inner' : 'left'; - $query->subQuery->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); - $query->query->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); + $joinType = $elementQuery->revisions === true ? 'inner' : 'left'; + $elementQuery->subQuery->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); + $elementQuery->query->join(new Alias(Table::REVISIONS, 'revisions'), 'revisions.id', 'elements.revisionId', type: $joinType); - $query->query->addSelect([ + $elementQuery->query->addSelect([ 'elements.revisionId as revisionId', 'revisions.creatorId as revisionCreatorId', 'revisions.num as revisionNum', 'revisions.notes as revisionNotes', ]); - if ($this->revisionId) { - $query->subQuery->where('elements.revisionId', $this->revisionId); + if ($elementQuery->revisionId) { + $elementQuery->subQuery->where('elements.revisionId', $elementQuery->revisionId); } - if ($this->revisionOf) { - $query->subQuery->where('elements.canonicalId', $this->revisionOf); + if ($elementQuery->revisionOf) { + $elementQuery->subQuery->where('elements.canonicalId', $elementQuery->revisionOf); } - if ($this->revisionCreator) { - $query->subQuery->where('revisions.creatorId', $this->revisionCreator); + if ($elementQuery->revisionCreator) { + $elementQuery->subQuery->where('revisions.creatorId', $elementQuery->revisionCreator); } } diff --git a/src/Database/Queries/Concerns/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php index 997e3fa4116..fe80987016e 100644 --- a/src/Database/Queries/Concerns/QueriesFields.php +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -89,53 +89,53 @@ trait QueriesFields protected function initQueriesFields(): void { - $this->beforeQuery(function (ElementQuery $query) { - if ($this->id) { - $query->subQuery->whereNumericParam('elements.id', $this->id); + $this->beforeQuery(function (ElementQuery $elementQuery) { + if ($elementQuery->id) { + $elementQuery->subQuery->whereNumericParam('elements.id', $elementQuery->id); } - if ($this->uid) { - $query->subQuery->whereParam('elements.uid', $this->uid); + if ($elementQuery->uid) { + $elementQuery->subQuery->whereParam('elements.uid', $elementQuery->uid); } - if ($this->siteSettingsId) { - $query->subQuery->whereNumericParam('elements_sites.id', $this->siteSettingsId); + if ($elementQuery->siteSettingsId) { + $elementQuery->subQuery->whereNumericParam('elements_sites.id', $elementQuery->siteSettingsId); } - match ($this->trashed) { - true => $query->subQuery->whereNotNull('elements.dateDeleted'), - false => $query->subQuery->whereNull('elements.dateDeleted'), + match ($elementQuery->trashed) { + true => $elementQuery->subQuery->whereNotNull('elements.dateDeleted'), + false => $elementQuery->subQuery->whereNull('elements.dateDeleted'), default => null, }; - if ($this->dateCreated) { - $query->subQuery->whereDateParam('elements.dateCreated', $this->dateCreated); + if ($elementQuery->dateCreated) { + $elementQuery->subQuery->whereDateParam('elements.dateCreated', $elementQuery->dateCreated); } - if ($this->dateUpdated) { - $query->subQuery->whereDateParam('elements.dateUpdated', $this->dateUpdated); + if ($elementQuery->dateUpdated) { + $elementQuery->subQuery->whereDateParam('elements.dateUpdated', $elementQuery->dateUpdated); } - if (isset($this->title) && $this->title !== '' && $this->elementType::hasTitles()) { - if (is_string($this->title)) { - $this->title = Query::escapeCommas($this->title); + if (isset($elementQuery->title) && $elementQuery->title !== '' && $elementQuery->elementType::hasTitles()) { + if (is_string($elementQuery->title)) { + $elementQuery->title = Query::escapeCommas($elementQuery->title); } - $query->subQuery->whereParam('elements_sites.title', $this->title, caseInsensitive: true); + $elementQuery->subQuery->whereParam('elements_sites.title', $elementQuery->title, caseInsensitive: true); } - if ($this->slug) { - $query->subQuery->whereParam('elements_sites.slug', $this->slug); + if ($elementQuery->slug) { + $elementQuery->subQuery->whereParam('elements_sites.slug', $elementQuery->slug); } - if ($this->uri) { - $query->subQuery->whereParam('elements_sites.uri', $this->uri, caseInsensitive: true); + if ($elementQuery->uri) { + $elementQuery->subQuery->whereParam('elements_sites.uri', $elementQuery->uri, caseInsensitive: true); } - if ($this->inBulkOp) { - $query->subQuery + if ($elementQuery->inBulkOp) { + $elementQuery->subQuery ->join(new Alias(Table::ELEMENTS_BULKOPS, 'elements_bulkops'), 'elements_bulkops.elementId', 'elements.id') - ->where('elements_bulkops.key', $this->inBulkOp); + ->where('elements_bulkops.key', $elementQuery->inBulkOp); } }); } diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index b3b69ce41bb..cae240c1ef2 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Database\Queries\Concerns; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Shared\Models\Info; use CraftCms\Cms\Site\Data\Site; @@ -33,14 +34,14 @@ trait QueriesSites protected function initQueriesSites(): void { - $this->beforeQuery(function () { + $this->beforeQuery(function (ElementQuery $elementQuery) { // Make sure the siteId param is set try { - if (! $this->elementType::isLocalized()) { + if (! $elementQuery->elementType::isLocalized()) { // The criteria *must* be set to the primary site ID - $this->siteId = Sites::getPrimarySite()->id; + $elementQuery->siteId = Sites::getPrimarySite()->id; } else { - $this->normalizeSiteId(); + $elementQuery->siteId = $this->normalizeSiteId($elementQuery); } } catch (SiteNotFoundException $e) { // Fail silently if Craft isn't installed yet or is in the middle of updating @@ -52,7 +53,7 @@ protected function initQueriesSites(): void } if (Sites::isMultiSite(false, true)) { - $this->subQuery->where('elements_sites.siteId', $this->siteId); + $elementQuery->subQuery->where('elements_sites.siteId', $elementQuery->siteId); } }); } @@ -245,16 +246,20 @@ public function language($value): self /** * Normalizes the siteId param value. */ - private function normalizeSiteId(): void + private function normalizeSiteId(ElementQuery $query): mixed { - if (! $this->siteId) { + if (! $query->siteId) { // Default to the current site - $this->siteId = Sites::getCurrentSite()->id; - } elseif ($this->siteId === '*') { - $this->siteId = Sites::getAllSiteIds(); - } elseif (is_numeric($this->siteId) || Arr::isNumeric($this->siteId)) { + return Sites::getCurrentSite()->id; + } + + if ($query->siteId === '*') { + return Sites::getAllSiteIds(); + } + + if (is_numeric($query->siteId) || Arr::isNumeric($query->siteId)) { // Filter out any invalid site IDs - $siteIds = Collection::make((array) $this->siteId) + $siteIds = Collection::make((array) $query->siteId) ->filter(fn ($siteId) => Sites::getSiteById($siteId, true) !== null) ->all(); @@ -262,7 +267,9 @@ private function normalizeSiteId(): void throw new QueryAbortedException; } - $this->siteId = is_array($this->siteId) ? $siteIds : reset($siteIds); + return is_array($query->siteId) ? $siteIds : reset($siteIds); } + + return $query->siteId; } } diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php index bb8b36ff99e..04671f147ba 100644 --- a/src/Database/Queries/Concerns/QueriesStatuses.php +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -6,6 +6,7 @@ use Closure; use craft\base\Element; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use Illuminate\Database\Query\Builder; @@ -36,19 +37,19 @@ trait QueriesStatuses protected function initQueriesStatuses(): void { - $this->beforeQuery(function () { - if ($this->archived) { - $this->subQuery->where('elements.archived', true); + $this->beforeQuery(function (ElementQuery $elementQuery) { + if ($elementQuery->archived) { + $elementQuery->subQuery->where('elements.archived', true); return; } - $this->applyStatusParam(); + $this->applyStatusParam($elementQuery); // only set archived=false if 'archived' doesn't show up in the status param // (_applyStatusParam() will normalize $this->status to an array if applicable) - if (! is_array($this->status) || ! in_array($this->elementType::STATUS_ARCHIVED, $this->status)) { - $this->subQuery->where('elements.archived', false); + if (! is_array($elementQuery->status) || ! in_array($elementQuery->elementType::STATUS_ARCHIVED, $elementQuery->status)) { + $elementQuery->subQuery->where('elements.archived', false); } }); } @@ -102,18 +103,18 @@ public function status(array|string|null $value): static * * @throws QueryAbortedException */ - private function applyStatusParam(): void + private function applyStatusParam(ElementQuery $elementQuery): void { - if (! $this->status || ! $this->elementType::hasStatuses()) { + if (! $elementQuery->status || ! $elementQuery->elementType::hasStatuses()) { return; } // Normalize the status param - if (! is_array($this->status)) { - $this->status = str($this->status)->explode(',')->all(); + if (! is_array($elementQuery->status)) { + $elementQuery->status = str($elementQuery->status)->explode(',')->all(); } - $statuses = array_merge($this->status); + $statuses = array_merge($elementQuery->status); $firstVal = strtolower((string) reset($statuses)); $glue = 'or'; @@ -130,7 +131,7 @@ private function applyStatusParam(): void $glue = 'and'; } - $this->subQuery->where(function (Builder $query) use ($statuses, $negate, $glue) { + $elementQuery->subQuery->where(function (Builder $query) use ($statuses, $negate, $glue) { foreach ($statuses as $status) { match (true) { $glue === 'or' && $negate === false => $query->orWhere($this->placeholderCondition($this->statusCondition($status))), diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index fa8c51e3d71..ecd80d93c65 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -121,8 +121,8 @@ trait QueriesStructures protected function initQueriesStructures(): void { - $this->beforeQuery(function (ElementQuery $query) { - $this->applyStructureParams($query); + $this->beforeQuery(function (ElementQuery $elementQuery) { + $this->applyStructureParams($elementQuery); }); $this->afterQuery(function (Collection $collection) { @@ -555,9 +555,9 @@ private function shouldJoinStructureData(): bool ($this->withStructure ?? ($this->structureId && ! $this->trashed)); } - private function applyStructureParams(ElementQuery $query): void + private function applyStructureParams(ElementQuery $elementQuery): void { - if (! $this->shouldJoinStructureData()) { + if (! $elementQuery->shouldJoinStructureData()) { $structureParams = [ 'hasDescendants', 'ancestorOf', @@ -571,7 +571,7 @@ private function applyStructureParams(ElementQuery $query): void ]; foreach ($structureParams as $param) { - if ($this->$param !== null) { + if ($elementQuery->$param !== null) { throw new QueryAbortedException("Unable to apply the '$param' param because 'structureId' isn't set"); } } @@ -579,23 +579,23 @@ private function applyStructureParams(ElementQuery $query): void return; } - $query->query->addSelect([ + $elementQuery->query->addSelect([ 'structureelements.root as root', 'structureelements.lft as lft', 'structureelements.rgt as rgt', 'structureelements.level as level', ]); - if ($this->structureId) { - $query->query->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join + if ($elementQuery->structureId) { + $elementQuery->query->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'subquery.elementsId') - ->where('structureelements.structureId', $this->structureId)); + ->where('structureelements.structureId', $elementQuery->structureId)); - $query->subQuery->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join + $elementQuery->subQuery->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'elements.id') - ->where('structureelements.structureId', $this->structureId)); + ->where('structureelements.structureId', $elementQuery->structureId)); } else { - $query->query + $elementQuery->query ->addSelect('structureelements.structureId as structureId') ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'subquery.elementsId') @@ -611,7 +611,7 @@ private function applyStructureParams(ElementQuery $query): void ->whereColumn('id', 'structureelements.structureId') ->whereNull('dateDeleted'); - $query->subQuery + $elementQuery->subQuery ->addSelect('structureelements.structureId as structureId') ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join ->whereColumn('structureelements.elementId', 'elements.id') @@ -619,48 +619,48 @@ private function applyStructureParams(ElementQuery $query): void ); } - if (isset($this->hasDescendants)) { - $query->subQuery->when( - $this->hasDescendants, - fn (Builder $query) => $query->where('structureelements.rgt', '>', DB::raw('structureelements.lft + 1')), - fn (Builder $query) => $query->where('structureelements.rgt', '=', DB::raw('structureelements.lft + 1')), + if (isset($elementQuery->hasDescendants)) { + $elementQuery->subQuery->when( + $elementQuery->hasDescendants, + fn (Builder $query) => $elementQuery->where('structureelements.rgt', '>', DB::raw('structureelements.lft + 1')), + fn (Builder $query) => $elementQuery->where('structureelements.rgt', '=', DB::raw('structureelements.lft + 1')), ); } - if ($this->ancestorOf) { - $ancestorOf = $this->normalizeStructureParamValue('ancestorOf'); + if ($elementQuery->ancestorOf) { + $ancestorOf = $elementQuery->normalizeStructureParamValue('ancestorOf'); - $query->subQuery + $elementQuery->subQuery ->where('structureelements.lft', '<', $ancestorOf->lft) ->where('structureelements.rgt', '>', $ancestorOf->rgt) ->where('structureelements.root', '>', $ancestorOf->root) ->when( - $this->ancestorDist, - fn (Builder $q) => $q->where('structureelements.level', '>=', $ancestorOf->level - $this->ancestorDist) + $elementQuery->ancestorDist, + fn (Builder $q) => $q->where('structureelements.level', '>=', $ancestorOf->level - $elementQuery->ancestorDist) ); } - if ($this->descendantOf) { - $descendantOf = $this->normalizeStructureParamValue('descendantOf'); + if ($elementQuery->descendantOf) { + $descendantOf = $elementQuery->normalizeStructureParamValue('descendantOf'); - $query->subQuery + $elementQuery->subQuery ->where('structureelements.lft', '>', $descendantOf->lft) ->where('structureelements.rgt', '<', $descendantOf->rgt) ->where('structureelements.root', $descendantOf->root) ->when( - $this->descendantDist, - fn (Builder $q) => $q->where('structureelements.level', '<=', $descendantOf->level + $this->descendantDist) + $elementQuery->descendantDist, + fn (Builder $q) => $q->where('structureelements.level', '<=', $descendantOf->level + $elementQuery->descendantDist) ); } foreach (['siblingOf', 'prevSiblingOf', 'nextSiblingOf'] as $param) { - if (! $this->$param) { + if (! $elementQuery->$param) { continue; } - $siblingOf = $this->normalizeStructureParamValue($param); + $siblingOf = $elementQuery->normalizeStructureParamValue($param); - $query->subQuery + $elementQuery->subQuery ->where('structureelements.level', $siblingOf->level) ->where('structureelements.root', $siblingOf->root) ->whereNot('structureelements.elementId', $siblingOf->id); @@ -672,22 +672,22 @@ private function applyStructureParams(ElementQuery $query): void throw new QueryAbortedException; } - $query->subQuery + $elementQuery->subQuery ->where('structureelements.lft', '>', $parent->lft) ->where('structureelements.rgt', '>', $parent->rgt); } switch ($param) { case 'prevSiblingOf': - $query->orderByDesc('structureelements.lft'); - $query->subQuery + $elementQuery->orderByDesc('structureelements.lft'); + $elementQuery->subQuery ->where('structureelements.lft', '<', $siblingOf->lft) ->orderByDesc('structureelements.lft') ->limit(1); break; case 'nextSiblingOf': - $query->orderBy('structureelements.lft'); - $query->subQuery + $elementQuery->orderBy('structureelements.lft'); + $elementQuery->subQuery ->where('structureelements.lft', '>', $siblingOf->lft) ->orderBy('structureelements.lft') ->limit(1); @@ -695,37 +695,37 @@ private function applyStructureParams(ElementQuery $query): void } } - if ($this->positionedBefore) { - $positionedBefore = $this->normalizeStructureParamValue('positionedBefore'); + if ($elementQuery->positionedBefore) { + $positionedBefore = $elementQuery->normalizeStructureParamValue('positionedBefore'); - $query->subQuery + $elementQuery->subQuery ->where('structureelements.lft', '<', $positionedBefore->lft) ->where('structureelements.root', $positionedBefore->root); } - if ($this->positionedAfter) { - $positionedAfter = $this->normalizeStructureParamValue('positionedAfter'); + if ($elementQuery->positionedAfter) { + $positionedAfter = $elementQuery->normalizeStructureParamValue('positionedAfter'); - $query->subQuery + $elementQuery->subQuery ->where('structureelements.lft', '>', $positionedAfter->rgt) ->where('structureelements.root', $positionedAfter->root); } - if ($this->level) { - $allowNull = is_array($this->level) && in_array(null, $this->level, true); + if ($elementQuery->level) { + $allowNull = is_array($elementQuery->level) && in_array(null, $elementQuery->level, true); - $query->subQuery->when( + $elementQuery->subQuery->when( $allowNull, - fn (Builder $q) => $q->where(function (Builder $q) { - $q->where(Db::parseNumericParam('structureelements.level', array_filter($this->level, fn ($v) => $v !== null))) + fn (Builder $q) => $q->where(function (Builder $q) use ($elementQuery) { + $q->where(Db::parseNumericParam('structureelements.level', array_filter($elementQuery->level, fn ($v) => $v !== null))) ->orWhereNull('structureelements.level'); }), - fn (Builder $q) => Db::parseNumericParam('structureelements.level', $this->level), + fn (Builder $q) => Db::parseNumericParam('structureelements.level', $elementQuery->level), ); } - if ($this->leaves) { - $query->subQuery->where('structureelements.rgt', DB::raw('structureelements.lft + 1')); + if ($elementQuery->leaves) { + $elementQuery->subQuery->where('structureelements.rgt', DB::raw('structureelements.lft + 1')); } } diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php index ff6ce64a589..3bb4d111549 100644 --- a/src/Database/Queries/Concerns/QueriesUniqueElements.php +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -4,7 +4,8 @@ namespace CraftCms\Cms\Database\Queries\Concerns; -use CraftCms\Cms\Db\Table; +use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Support\Facades\Sites; use Illuminate\Support\Facades\DB; use Tpetry\QueryExpressions\Language\CaseGroup; use Tpetry\QueryExpressions\Language\CaseRule; @@ -38,7 +39,7 @@ protected function initQueriesUniqueElements(): void { if ( ! $this->unique || - ! \Craft::$app->getIsMultiSite(false, true) || + ! Sites::isMultiSite(false, true) || ( $this->siteId && (! is_array($this->siteId) || count($this->siteId) === 1) @@ -47,16 +48,14 @@ protected function initQueriesUniqueElements(): void return; } - $sitesService = \Craft::$app->getSites(); - if (! $this->preferSites) { - $preferSites = [$sitesService->getCurrentSite()->id]; + $preferSites = [Sites::getCurrentSite()->id]; } else { $preferSites = []; foreach ($this->preferSites as $preferSite) { if (is_numeric($preferSite)) { $preferSites[] = $preferSite; - } elseif ($site = $sitesService->getSiteByHandle($preferSite)) { + } elseif ($site = Sites::getSiteByHandle($preferSite)) { $preferSites[] = $site->id; } } diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php index 9640624ab45..988664026be 100644 --- a/src/Database/Queries/Concerns/SearchesElements.php +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -30,14 +30,14 @@ trait SearchesElements * * @see applySearchParam() * @see applyOrderByParams() - * @see populate() + * @see hydrate() */ private ?array $searchResults = null; protected function initSearchesElements(): void { - $this->beforeQuery(function (ElementQuery $query) { - $this->applySearchParam($query); + $this->beforeQuery(function (ElementQuery $elementQuery) { + $this->applySearchParam($elementQuery); }); } @@ -46,37 +46,38 @@ protected function initSearchesElements(): void * * @throws QueryAbortedException */ - private function applySearchParam(ElementQuery $query): void + private function applySearchParam(ElementQuery $elementQuery): void { - $this->searchResults = null; + $elementQuery->searchResults = null; - if (! $query->search) { + if (! $elementQuery->search) { return; } $searchService = \Craft::$app->getSearch(); - $scoreOrder = Arr::first($query->query->orders, fn ($order) => $order['column'] === 'score'); + $scoreOrder = Arr::first($elementQuery->query->orders ?? [], fn ($order) => $order['column'] === 'score'); - if ($scoreOrder || $searchService->shouldCallSearchElements($this)) { + if ($scoreOrder || $searchService->shouldCallSearchElements($elementQuery)) { // Get the scored results up front - $searchResults = $searchService->searchElements($this); + $searchResults = $searchService->searchElements($elementQuery); if ($scoreOrder['direction'] === 'asc') { $searchResults = array_reverse($searchResults, true); } - if (($query->query->orders[0]['column'] ?? null) === 'score') { + if (($elementQuery->query->orders[0]['column'] ?? null) === 'score') { // Only use the portion we're actually querying for - if (is_int($query->query->getOffset()) && $query->query->getOffset() !== 0) { - $searchResults = array_slice($searchResults, $query->query->getOffset(), null, true); - $query->subQuery->offset = null; - $query->subQuery->unionOffset = null; + if (is_int($elementQuery->query->getOffset()) && $elementQuery->query->getOffset() !== 0) { + $searchResults = array_slice($searchResults, $elementQuery->query->getOffset(), null, true); + $elementQuery->subQuery->offset = null; + $elementQuery->subQuery->unionOffset = null; } - if (is_int($query->query->getLimit()) && $query->query->getLimit() !== 0) { - $searchResults = array_slice($searchResults, 0, $query->query->getLimit(), true); - $query->subQuery->limit = null; - $query->subQuery->unionLimit = null; + + if (is_int($elementQuery->query->getLimit()) && $elementQuery->query->getLimit() !== 0) { + $searchResults = array_slice($searchResults, 0, $elementQuery->query->getLimit(), true); + $elementQuery->subQuery->limit = null; + $elementQuery->subQuery->unionLimit = null; } } @@ -84,7 +85,7 @@ private function applySearchParam(ElementQuery $query): void throw new QueryAbortedException; } - $this->searchResults = $searchResults; + $elementQuery->searchResults = $searchResults; $elementIdsBySiteId = []; foreach (array_keys($searchResults) as $key) { @@ -92,7 +93,7 @@ private function applySearchParam(ElementQuery $query): void $elementIdsBySiteId[$siteId][] = $elementId; } - $query->subQuery->where(function (Builder $query) use ($elementIdsBySiteId) { + $elementQuery->subQuery->where(function (Builder $query) use ($elementIdsBySiteId) { foreach ($elementIdsBySiteId as $siteId => $elementIds) { $query->orWhere(function (Builder $query) use ($siteId, $elementIds) { $query->where('elements_sites.siteId', $siteId) @@ -105,13 +106,13 @@ private function applySearchParam(ElementQuery $query): void } // Just filter the main query by the search query - $searchQuery = $searchService->createDbQuery($this->search, $this); + $searchQuery = $searchService->createDbQuery($elementQuery->search, $elementQuery); if ($searchQuery === false) { throw new QueryAbortedException; } - $query->subQuery->whereIn('elements.id', $searchQuery->select('elementId')->all()); + $elementQuery->subQuery->whereIn('elements.id', $searchQuery->select('elementId')->all()); } /** diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 489385a9add..c524cdecef8 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -84,6 +84,7 @@ class ElementQuery implements ElementQueryInterface */ protected array $propertyPassthru = [ 'from', + 'orders', ]; /** @@ -130,18 +131,6 @@ class ElementQuery implements ElementQueryInterface 'value', ]; - protected array $passthruAggregates = [ - 'aggregate', - 'average', - 'avg', - 'count', - 'getcountforpagination', - 'max', - 'min', - 'numericaggregate', - 'sum', - ]; - /** * The callbacks that should be invoked before retrieving data from the database. */ @@ -223,10 +212,6 @@ public function __construct( } $this->initTraits(); - - $this->query->beforeQuery(function () { - $this->applyBeforeQueryCallbacks(); - }); } protected function initTraits(): void @@ -244,28 +229,12 @@ protected function initTraits(): void } } - /** - * Create a collection of models from a raw query. - * - * @param string $query - * @param array $bindings - * @return Collection - */ - public function fromQuery($query, $bindings = []): Collection - { - return $this->hydrate( - $this->query->getConnection()->select($query, $bindings) - ); - } - /** * Find a model by its primary key. * - * @param mixed $id - * @param array|string $columns * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TElement|null) */ - public function find($id, $columns = ['*']): ElementInterface|Collection|null + public function find(mixed $id, array|string $columns = ['*']): ElementInterface|Collection|null { if (is_array($id) || $id instanceof Arrayable) { return $this->findMany($id, $columns); @@ -278,10 +247,9 @@ public function find($id, $columns = ['*']): ElementInterface|Collection|null * Find multiple elements by their primary keys. * * @param \Illuminate\Contracts\Support\Arrayable|array $ids - * @param array|string $columns * @return \Illuminate\Database\Eloquent\Collection|array */ - public function findMany($ids, $columns = ['*']): Collection|array + public function findMany(mixed $ids, array|string $columns = ['*']): Collection|array { $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; @@ -295,13 +263,11 @@ public function findMany($ids, $columns = ['*']): Collection|array /** * Find a model by its primary key or throw an exception. * - * @param mixed $id - * @param array|string $columns * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TElement) * * @throws ElementNotFoundException */ - public function findOrFail($id, $columns = ['*']): ElementInterface|Collection + public function findOrFail(mixed $id, array|string $columns = ['*']): ElementInterface|Collection { $result = $this->find($id, $columns); @@ -331,7 +297,6 @@ public function findOrFail($id, $columns = ['*']): ElementInterface|Collection * * @template TValue * - * @param mixed $id * @param (\Closure(): TValue)|list|string $columns * @param (\Closure(): TValue)|null $callback * @return ( @@ -340,7 +305,7 @@ public function findOrFail($id, $columns = ['*']): ElementInterface|Collection * : TElement|TValue * ) */ - public function findOr($id, $columns = ['*'], ?Closure $callback = null): mixed + public function findOr(mixed $id, array|string|Closure $columns = ['*'], ?Closure $callback = null): mixed { if ($columns instanceof Closure) { $callback = $columns; @@ -358,12 +323,11 @@ public function findOr($id, $columns = ['*'], ?Closure $callback = null): mixed /** * Execute the query and get the first result or throw an exception. * - * @param array|string $columns * @return TElement * * @throws ElementNotFoundException */ - public function firstOrFail($columns = ['*']): ElementInterface + public function firstOrFail(array|string $columns = ['*']): ElementInterface { if (! is_null($model = $this->first($columns))) { return $model; @@ -381,7 +345,7 @@ public function firstOrFail($columns = ['*']): ElementInterface * @param (\Closure(): TValue)|null $callback * @return TElement|TValue */ - public function firstOr($columns = ['*'], ?Closure $callback = null): mixed + public function firstOr(array|string|Closure $columns = ['*'], ?Closure $callback = null): mixed { if ($columns instanceof Closure) { $callback = $columns; @@ -399,13 +363,12 @@ public function firstOr($columns = ['*'], ?Closure $callback = null): mixed /** * Execute the query and get the first result if it's the sole matching record. * - * @param array|string $columns * @return TElement * * @throws ElementNotFoundException * @throws \Illuminate\Database\MultipleRecordsFoundException */ - public function sole($columns = ['*']): mixed + public function sole(array|string $columns = ['*']): ElementInterface { try { return $this->baseSole($columns); @@ -417,10 +380,9 @@ public function sole($columns = ['*']): mixed /** * Execute the query as a "select" statement. * - * @param array|string $columns * @return \Illuminate\Database\Eloquent\Collection|array */ - public function get($columns = ['*']): Collection|array + public function get(array|string $columns = ['*']): Collection|array { $models = $this->getModels($columns); @@ -431,11 +393,12 @@ public function get($columns = ['*']): Collection|array /** * Get the hydrated elements * - * @param array|string $columns * @return array */ - public function getModels($columns = ['*']): array + public function getModels(array|string $columns = ['*']): array { + $this->applyBeforeQueryCallbacks(); + return $this->hydrate( $this->query->get($columns)->all() )->all(); @@ -458,6 +421,8 @@ public function one(array|string $columns = ['*']): ?ElementInterface public function pluck($column, $key = null): Collection|array { + $this->applyBeforeQueryCallbacks(); + $column = $this->columnMap[$column] ?? $column; return $this->query->pluck($column, $key) @@ -466,8 +431,6 @@ public function pluck($column, $key = null): Collection|array /** * Register a closure to be invoked after the query is executed. - * - * @return $this */ public function afterQuery(Closure $callback): self { @@ -495,6 +458,8 @@ public function applyAfterQueryCallbacks(mixed $result): mixed */ public function cursor(): LazyCollection { + $this->applyBeforeQueryCallbacks(); + return $this->applyScopes()->query->cursor()->map(function ($record) { $model = $this->createElement((array) $record); @@ -536,30 +501,24 @@ public function getOffset(): mixed /** * Get the given macro by name. - * - * @param string $name */ - public function getMacro($name): Closure + public function getMacro(string $name): Closure { return Arr::get($this->localMacros, $name); } /** * Checks if a macro is registered. - * - * @param string $name */ - public function hasMacro($name): bool + public function hasMacro(string $name): bool { return isset($this->localMacros[$name]); } /** * Get the given global macro by name. - * - * @param string $name */ - public static function getGlobalMacro($name): Closure + public static function getGlobalMacro(string $name): Closure { return Arr::get(static::$macros, $name); } @@ -590,6 +549,23 @@ public function __get($key): mixed throw new Exception("Property [{$key}] does not exist on the Element query instance."); } + public function __set(string $name, $value): void + { + if (method_exists($this, $name)) { + $this->{$name}($value); + + return; + } + + if (in_array($name, $this->propertyPassthru)) { + $this->getQuery()->{$name} = $value; + + return; + } + + throw new Exception("Property [{$name}] does not exist on the Element query instance."); + } + /** * Dynamically handle calls into the query instance. * @@ -621,14 +597,12 @@ public function __call($method, $parameters): mixed } if (in_array(strtolower($method), $this->passthru)) { - if (in_array(strtolower($method), $this->passthruAggregates)) { - $this->applyBeforeQueryCallbacks(); - } + $this->applyBeforeQueryCallbacks(); return $this->getQuery()->{$method}(...$parameters); } - if (strtolower($method) === 'orderby') { + if (in_array(strtolower($method), ['orderby', 'select', 'reorder'])) { $this->forwardCallTo($this->query, $method, $parameters); return $this; @@ -728,8 +702,10 @@ public function beforeQuery(Closure $callback): self public function applyBeforeQueryCallbacks(): void { - foreach ($this->beforeQueryCallbacks as $callback) { + foreach ($this->beforeQueryCallbacks as $i => $callback) { $callback($this); + + unset($this->beforeQueryCallbacks[$i]); } $this->beforeQueryCallbacks = []; diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 2d0ccd27773..a6663f49ce0 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -26,22 +26,20 @@ public function __construct(array $config = []) parent::__construct(Entry::class, $config); - $this->beforeQuery(function (self $query) { - $this->joinElementTable(Table::ENTRIES); + $this->joinElementTable(Table::ENTRIES); - $query->query->addSelect([ - 'entries.sectionId as sectionId', - 'entries.fieldId as fieldId', - 'entries.primaryOwnerId as primaryOwnerId', - 'entries.typeId as typeId', - 'entries.postDate as postDate', - 'entries.expiryDate as expiryDate', - ]); + $this->query->addSelect([ + 'entries.sectionId as sectionId', + 'entries.fieldId as fieldId', + 'entries.primaryOwnerId as primaryOwnerId', + 'entries.typeId as typeId', + 'entries.postDate as postDate', + 'entries.expiryDate as expiryDate', + ]); - if (Cms::config()->staticStatuses) { - $query->query->addSelect(['entries.status as status']); - } - }); + if (Cms::config()->staticStatuses) { + $this->query->addSelect(['entries.status as status']); + } } protected function statusCondition(string $status): Closure diff --git a/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php index 5320dc19c6e..3f6c26601e2 100644 --- a/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php +++ b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php @@ -10,6 +10,7 @@ $entry->element->update([ 'draftId' => Draft::factory()->create([ 'canonicalId' => $entry->id, + 'provisional' => false, ])->id, ]); diff --git a/tests/Database/Queries/Concerns/SearchesElementsTest.php b/tests/Database/Queries/Concerns/SearchesElementsTest.php index 32377bba426..51de46d9bf3 100644 --- a/tests/Database/Queries/Concerns/SearchesElementsTest.php +++ b/tests/Database/Queries/Concerns/SearchesElementsTest.php @@ -22,3 +22,26 @@ expect(entryQuery()->count())->toBe(2); expect(entryQuery()->search('Foo')->count())->toBe(1); }); + +test('search with score', function () { + $entry1 = EntryModel::factory()->create(); + $entry1->element->siteSettings->first()->update([ + 'title' => 'Foo', + 'content' => '', + ]); + + $entry2 = EntryModel::factory()->create(); + $entry2->element->siteSettings->first()->update([ + 'title' => 'Bar', + 'content' => 'foo', + ]); + + $element1 = Craft::$app->getElements()->getElementById($entry1->id); + $element2 = Craft::$app->getElements()->getElementById($entry2->id); + + Craft::$app->getSearch()->indexElementAttributes($element1); + Craft::$app->getSearch()->indexElementAttributes($element2); + + expect(entryQuery()->orderBy('score')->count())->toBe(2); + expect(entryQuery()->search('Foo')->orderBy('score')->count())->toBe(1); +}); diff --git a/tests/Database/Queries/ElementQueryTest.php b/tests/Database/Queries/ElementQueryTest.php index bab58115611..c87fffa43e6 100644 --- a/tests/Database/Queries/ElementQueryTest.php +++ b/tests/Database/Queries/ElementQueryTest.php @@ -27,6 +27,13 @@ entryQuery()->findOrFail(999); }); +it('can create with an array of parameters', function () { + EntryModel::factory()->create(); + $entry = EntryModel::factory()->create(); + + expect(entryQuery(['id' => $entry->id])->count())->toBe(1); +}); + test('trashed', function () { EntryModel::factory(2)->create(); EntryModel::factory(2)->trashed()->create(); diff --git a/tests/Pest.php b/tests/Pest.php index 12ff20918f8..6ff37fe0aed 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -54,7 +54,7 @@ function loadTestPlugin(): void $reflectionClass->getProperty('pluginsLoaded')->setValue($plugins, true); } -function entryQuery(): EntryQuery +function entryQuery(array $config = []): EntryQuery { - return new EntryQuery; + return new EntryQuery($config); } diff --git a/yii2-adapter/legacy/services/Search.php b/yii2-adapter/legacy/services/Search.php index d0f03ea0e2c..259731834b8 100644 --- a/yii2-adapter/legacy/services/Search.php +++ b/yii2-adapter/legacy/services/Search.php @@ -428,8 +428,18 @@ public function searchElements(ElementQuery|\CraftCms\Cms\Database\Queries\Eleme return []; } + if ($elementQuery instanceof \CraftCms\Cms\Database\Queries\ElementQuery) { + $elementQuery->reorder(); + $elementQuery->select('elements.id as id'); + $elementQuery->getSubQuery()->offset = null; + $elementQuery->getSubQuery()->limit = null; + $ids = $elementQuery->pluck('id')->all(); + } else { + $ids = $elementQuery; + } + $results = $query - ->andWhere(['elementId' => $elementQuery]) + ->andWhere(['elementId' => $ids]) ->cache() ->all(); @@ -509,7 +519,7 @@ public function createDbQuery(string|array|SearchQuery $searchQuery, ElementQuer return $query; } - private function _scoreResults(array $results, SearchQuery $searchQuery, ElementQuery $elementQuery): array + private function _scoreResults(array $results, SearchQuery $searchQuery, ElementQuery|\CraftCms\Cms\Database\Queries\ElementQuery $elementQuery): array { // Fire a 'beforeScoreResults' event if ($this->hasEventHandlers(self::EVENT_BEFORE_SCORE_RESULTS)) { From 06ea60f92034e45f69ec958bb06233e4674f93e0 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 11 Nov 2025 09:57:42 +0100 Subject: [PATCH 15/52] More search --- .../Queries/Concerns/FormatsResults.php | 55 ++++++++----------- src/Database/Queries/ElementQuery.php | 4 +- .../Queries/Concerns/SearchesElementsTest.php | 19 ++++++- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php index 38cd03db298..3bc7edb379f 100644 --- a/src/Database/Queries/Concerns/FormatsResults.php +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -149,30 +149,31 @@ public function fixedOrder(bool $value = true): static protected function initFormatsResults(): void { $this->query->orderBy(new OrderByPlaceholderExpression); + } - $this->beforeQuery(function (ElementQuery $elementQuery) { - $this->orderBySearchResults($elementQuery); - $this->applyDefaultOrder($elementQuery); - - if ($elementQuery->inReverse) { - $orders = $elementQuery->query->orders; + protected function applyOrderByParams(ElementQuery $elementQuery): void + { + $this->orderBySearchResults($elementQuery); + $this->applyDefaultOrder($elementQuery); - $elementQuery->query->reorder(); + if ($elementQuery->inReverse) { + $orders = $elementQuery->query->orders; - foreach (array_reverse($orders) as $order) { - // If it's an expression we can't reverse it - if ($order['column'] instanceof Expression) { - $elementQuery->query->orderBy($order['column']); + $elementQuery->query->reorder(); - continue; - } + foreach (array_reverse($orders) as $order) { + // If it's an expression we can't reverse it + if ($order['column'] instanceof Expression) { + $elementQuery->query->orderBy($order['column']); - $elementQuery->query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); + continue; } + + $elementQuery->query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); } + } - $this->parseOrderColumnMappings($elementQuery); - }); + $this->parseOrderColumnMappings($elementQuery); } private function applyDefaultOrder(ElementQuery $elementQuery): void @@ -213,12 +214,6 @@ private function applyDefaultOrder(ElementQuery $elementQuery): void if ($elementQuery->shouldJoinStructureData()) { $elementQuery->query->orderBy('structureelements.lft'); - - foreach ($elementQuery->defaultOrderBy as $column => $direction) { - $elementQuery->query->orderBy($column, $direction === SORT_ASC ? 'asc' : 'desc'); - } - - return; } foreach ($elementQuery->defaultOrderBy as $column => $direction) { @@ -251,21 +246,17 @@ private function parseOrderColumnMappings(ElementQuery $elementQuery): void private function orderBySearchResults(ElementQuery $elementQuery): void { - if (! $elementQuery->searchResults) { - $elementQuery->query->orders = array_filter( - $elementQuery->query->orders, - fn (array $order) => $order['column'] !== 'score', - ); + $elementQuery->query->orders = array_filter( + $elementQuery->query->orders ?? [], + fn (array $order) => $order['column'] !== 'score', + ); + if (! $elementQuery->searchResults) { return; } $keys = array_keys($elementQuery->searchResults); - if ($elementQuery->inReverse) { - $keys = array_reverse($keys); - } - $i = -1; $rules = []; @@ -286,6 +277,6 @@ private function orderBySearchResults(ElementQuery $elementQuery): void ); } - $elementQuery->subQuery->orderBy(new CaseGroup($rules, new Value($i + 1))); + $elementQuery->query->orderBy(new CaseGroup($rules, new Value($i + 1))); } } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index c524cdecef8..bbf7664c154 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -602,7 +602,7 @@ public function __call($method, $parameters): mixed return $this->getQuery()->{$method}(...$parameters); } - if (in_array(strtolower($method), ['orderby', 'select', 'reorder'])) { + if (in_array(strtolower($method), ['orderby', 'orderbydesc', 'select', 'reorder'])) { $this->forwardCallTo($this->query, $method, $parameters); return $this; @@ -738,6 +738,8 @@ protected function elementQueryBeforeQuery(): void } } + $this->applyOrderByParams($this); + $this->query->fromSub($this->subQuery, 'subquery'); } diff --git a/tests/Database/Queries/Concerns/SearchesElementsTest.php b/tests/Database/Queries/Concerns/SearchesElementsTest.php index 51de46d9bf3..e86a593c2ba 100644 --- a/tests/Database/Queries/Concerns/SearchesElementsTest.php +++ b/tests/Database/Queries/Concerns/SearchesElementsTest.php @@ -33,7 +33,7 @@ $entry2 = EntryModel::factory()->create(); $entry2->element->siteSettings->first()->update([ 'title' => 'Bar', - 'content' => 'foo', + 'slug' => 'Foo', ]); $element1 = Craft::$app->getElements()->getElementById($entry1->id); @@ -43,5 +43,20 @@ Craft::$app->getSearch()->indexElementAttributes($element2); expect(entryQuery()->orderBy('score')->count())->toBe(2); - expect(entryQuery()->search('Foo')->orderBy('score')->count())->toBe(1); + expect(entryQuery()->search('Foo')->orderBy('score')->count())->toBe(2); + + $results = entryQuery()->search('Foo')->orderBy('score')->get(); + + expect($results[0]->id)->toBe($entry2->id); + expect($results[1]->id)->toBe($entry1->id); + + $results = entryQuery()->search('Foo')->orderByDesc('score')->get(); + + expect($results[0]->id)->toBe($entry1->id); + expect($results[1]->id)->toBe($entry2->id); + + $results = entryQuery()->search('Foo')->orderBy('score')->inReverse()->get(); + + expect($results[0]->id)->toBe($entry1->id); + expect($results[1]->id)->toBe($entry2->id); }); From e6cdb764ebf5635496fc83c7da92b8450ce0a2c0 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 11 Nov 2025 19:19:32 +0100 Subject: [PATCH 16/52] Add some more tests --- .../Queries/Concerns/FormatsResultsTest.php | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/Database/Queries/Concerns/FormatsResultsTest.php b/tests/Database/Queries/Concerns/FormatsResultsTest.php index def50cf8fc9..a50cfa73ad5 100644 --- a/tests/Database/Queries/Concerns/FormatsResultsTest.php +++ b/tests/Database/Queries/Concerns/FormatsResultsTest.php @@ -32,3 +32,63 @@ expect(entryQuery()->id([$element3->id, $element1->id, $element2->id])->fixedOrder()->pluck('id')->all())->toBe([$element3->id, $element1->id, $element2->id]); expect(entryQuery()->id(implode(', ', [$element3->id, $element1->id, $element2->id]))->fixedOrder()->pluck('id')->all())->toBe([$element3->id, $element1->id, $element2->id]); }); + +test('it applies a default order when no orderBy is specified', function () { + $query = entryQuery(); + $query->applyBeforeQueryCallbacks(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'elements.dateCreated') + ->where('direction', 'desc') + ->first() + )->not()->toBeNull(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'elements.id') + ->where('direction', 'desc') + ->first() + )->not()->toBeNull(); + + $query = entryQuery()->orderBy('slug'); + $query->applyBeforeQueryCallbacks(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'elements.dateCreated') + ->where('direction', 'desc') + ->first() + )->toBeNull(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'elements.id') + ->where('direction', 'desc') + ->first() + )->toBeNull(); +}); + +it('orders by revisions when revisions are requested', function () { + $query = entryQuery()->revisions(); + $query->applyBeforeQueryCallbacks(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'num') + ->where('direction', 'desc') + ->first() + )->not()->toBeNull(); +}); + +it('adds a sort on structureelements.lft when the element has structures', function () { + $query = entryQuery(); + $query->applyBeforeQueryCallbacks(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'structureelements.lft') + ->where('direction', 'asc') + ->first() + )->not()->toBeNull(); +}); From 83fd0171627bbcf0a497658e4ece78691a11276d Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 12 Nov 2025 13:44:17 +0100 Subject: [PATCH 17/52] Tests for drafts & custom fields --- database/Factories/DraftFactory.php | 14 ++ .../Queries/Concerns/QueriesCustomFields.php | 237 ++++++++++-------- .../Concerns/QueriesDraftsAndRevisions.php | 2 +- src/Database/Queries/ElementQuery.php | 16 ++ src/Element/Models/Draft.php | 8 + src/Entry/EntryTypes.php | 2 +- src/Field/Fields.php | 2 +- src/Support/Query.php | 93 +++++++ .../Concerns/QueriesCustomFieldsTest.php | 68 +++++ .../QueriesDraftsAndRevisionsTest.php | 77 +++++- 10 files changed, 409 insertions(+), 110 deletions(-) create mode 100644 tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php diff --git a/database/Factories/DraftFactory.php b/database/Factories/DraftFactory.php index 8f6b9f99e5d..cdfa05a8123 100644 --- a/database/Factories/DraftFactory.php +++ b/database/Factories/DraftFactory.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Database\Factories; +use craft\elements\Entry; use CraftCms\Cms\Element\Models\Draft; use CraftCms\Cms\Element\Models\Element; use CraftCms\Cms\User\Models\User; @@ -26,4 +27,17 @@ public function definition(): array 'saved' => fake()->boolean(), ]; } + + #[\Override] + public function configure(): self + { + return $this->afterCreating(function (Draft $draft) { + $element = Element::create([ + 'type' => Entry::class, + 'canonicalId' => $draft->canonicalId, + ]); + + $draft->update(['id' => $element->id]); + }); + } } diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index ef721e63028..9ac6a10f2bd 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -4,16 +4,19 @@ namespace CraftCms\Cms\Database\Queries\Concerns; -use craft\base\FieldInterface; use craft\behaviors\CustomFieldBehavior; -use craft\db\mysql\Schema; -use craft\helpers\Db as DbHelper; use craft\models\FieldLayout; use CraftCms\Cms\Database\Expressions\JsonExtract; use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\QueryParam; +use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Field\Contracts\FieldInterface; +use CraftCms\Cms\Support\Facades\Fields; +use CraftCms\Cms\Support\Query; use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Database\Query\Builder; +use Illuminate\Support\Collection; use Tpetry\QueryExpressions\Function\Conditional\Coalesce; /** @@ -48,8 +51,14 @@ trait QueriesCustomFields */ private array $columnsToCast = []; + private array $customFieldValues = []; + protected function initQueriesCustomFields(): void { + foreach (array_keys(CustomFieldBehavior::$fieldHandles) as $handle) { + $this->customFieldValues[$handle] = null; + } + $this->beforeQuery(function (ElementQuery $elementQuery) { // Gather custom fields and generated field handles $elementQuery->customFields = []; @@ -68,15 +77,15 @@ protected function initQueriesCustomFields(): void // Map custom field handles to their content values $this->addCustomFieldsToColumnMap(); + $this->addGeneratedFieldsToColumnMap(); $this->applyCustomFieldParams($elementQuery); + $this->applyGeneratedFieldParams($elementQuery); }); } /** - * {@inheritdoc} - * - * @uses $withCustomFields + * Sets whether custom fields should be factored into the query. */ public function withCustomFields(bool $value = true): static { @@ -88,17 +97,19 @@ public function withCustomFields(bool $value = true): static /** * Returns the field layouts whose custom fields should be returned by [[customFields()]]. * - * @return FieldLayout[] + * @return Collection */ - protected function fieldLayouts(): array + protected function fieldLayouts(): Collection { - return \Craft::$app->getFields()->getLayoutsByType($this->elementType); + return Fields::getLayoutsByType($this->elementType); } /** - * {@inheritdoc} + * Returns the field layouts that could be associated with the resulting elements. + * + * @return Collection */ - public function getFieldLayouts(): array + public function getFieldLayouts(): Collection { return $this->fieldLayouts(); } @@ -111,37 +122,48 @@ private function addCustomFieldsToColumnMap(): void foreach ($this->customFields as $field) { $dbTypes = $field::dbType(); - if ($dbTypes !== null) { - if (is_string($dbTypes)) { - $dbTypes = ['*' => $dbTypes]; - } else { - $dbTypes = [ - '*' => reset($dbTypes), - ...$dbTypes, - ]; - } + if (is_null($dbTypes)) { + continue; + } + + if (is_string($dbTypes)) { + $dbTypes = ['*' => $dbTypes]; + } else { + $dbTypes = [ + '*' => reset($dbTypes), + ...$dbTypes, + ]; + } - foreach ($dbTypes as $key => $dbType) { - $alias = $field->handle.($key !== '*' ? ".$key" : ''); - $resolver = fn () => $field->getValueSql($key !== '*' ? $key : null); + foreach ($dbTypes as $key => $dbType) { + $alias = $field->handle.($key !== '*' ? ".$key" : ''); + $resolver = fn () => $field->getValueSql($key !== '*' ? $key : null); - $this->addToColumnMap($alias, $resolver); + $this->addToColumnMap($alias, $resolver); - // for mysql, we have to make sure text column type is cast to char, otherwise it won't be sorted correctly - // see https://github.com/craftcms/cms/issues/15609 - if ($this->getConnection()->getDriverName() === 'mysql' && DbHelper::parseColumnType($dbType) === Schema::TYPE_TEXT) { - $this->columnsToCast[$alias] = 'CHAR(255)'; - } + // for mysql, we have to make sure text column type is cast to char, otherwise it won't be sorted correctly + // see https://github.com/craftcms/cms/issues/15609 + if ($this->query->getConnection()->getDriverName() === 'mysql' && Query::parseColumnType($dbType) === Query::TYPE_TEXT) { + $this->columnsToCast[$alias] = 'CHAR(255)'; } } } + } - if (! empty($this->generatedFields)) { - foreach ($this->generatedFields as $field) { - if (($field['handle'] ?? '') !== '') { - $this->addToColumnMap($field['handle'], new JsonExtract('elements_sites.content', '$.'.$field['uid'])); - } + /** + * Include custom fields in the column map + */ + private function addGeneratedFieldsToColumnMap(): void + { + foreach ($this->generatedFields as $field) { + if (empty($field['handle'] ?? '')) { + continue; } + + $this->addToColumnMap( + $field['handle'], + new JsonExtract(Table::ELEMENTS_SITES.'.content', '$.'.$field['uid']), + ); } } @@ -163,92 +185,111 @@ private function addToColumnMap(string $alias, string|callable|Expression $colum * * @throws QueryAbortedException */ - private function applyCustomFieldParams(ElementQuery $query): void + private function applyCustomFieldParams(ElementQuery $elementQuery): void { - if (empty($query->customFields) && empty($query->generatedFields)) { + if (empty($elementQuery->customFields)) { return; } - $fieldAttributes = $this->getBehavior('customFields'); - /** @var FieldInterface[][][] $fieldsByHandle */ - $fieldsByHandle = []; + $fieldsByHandle = $this->fieldsByHandle($elementQuery); - if (! empty($query->customFields)) { - // Group the fields by handle and field UUID - foreach ($query->customFields as $field) { - $fieldsByHandle[$field->handle][$field->uid][] = $field; + foreach (array_keys(CustomFieldBehavior::$fieldHandles) as $handle) { + // $fieldAttributes->$handle will return true even if it's set to null, so can't use isset() here + if ($handle === 'owner') { + continue; } - - foreach (array_keys(CustomFieldBehavior::$fieldHandles) as $handle) { - // $fieldAttributes->$handle will return true even if it's set to null, so can't use isset() here - if ($handle === 'owner') { - continue; - } - if (($fieldAttributes->$handle ?? null) === null) { - continue; - } - // Make sure the custom field exists in one of the field layouts - if (! isset($fieldsByHandle[$handle])) { - // If it looks like null/:empty: is a valid option, let it slide - $value = is_array($fieldAttributes->$handle) && isset($fieldAttributes->$handle['value']) - ? $fieldAttributes->$handle['value'] - : $fieldAttributes->$handle; - - if (is_array($value) && in_array(null, $value, true)) { - $values = [...$value]; - $operator = QueryParam::extractOperator($values) ?? QueryParam::OR; - if ($operator === QueryParam::OR) { - continue; - } + if (($elementQuery->customFieldValues[$handle] ?? null) === null) { + continue; + } + // Make sure the custom field exists in one of the field layouts + if (! isset($fieldsByHandle[$handle])) { + // If it looks like null/:empty: is a valid option, let it slide + $value = is_array($elementQuery->customFieldValues[$handle]) && isset($elementQuery->customFieldValues[$handle]['value']) + ? $elementQuery->customFieldValues[$handle]['value'] + : $elementQuery->customFieldValues[$handle]; + + if (is_array($value) && in_array(null, $value, true)) { + $values = [...$value]; + $operator = QueryParam::extractOperator($values) ?? QueryParam::OR; + if ($operator === QueryParam::OR) { + continue; } - - throw new QueryAbortedException("No custom field with the handle \"$handle\" exists in the field layouts involved with this element query."); } - $conditions = []; - $params = []; + throw new QueryAbortedException("No custom field with the handle \"$handle\" exists in the field layouts involved with this element query."); + } - foreach ($fieldsByHandle[$handle] as $instances) { - $firstInstance = $instances[0]; - $condition = $firstInstance::queryCondition($instances, $fieldAttributes->$handle, $params); + $conditions = []; + $params = []; - // aborting? - if ($condition === false) { - throw new QueryAbortedException; - } + foreach ($fieldsByHandle[$handle] as $instances) { + $firstInstance = $instances[0]; + $condition = $firstInstance::queryCondition($instances, $elementQuery->customFieldValues[$handle], $params); - if ($condition !== null) { - $conditions[] = $condition; - } + // aborting? + if ($condition === false) { + throw new QueryAbortedException; } - if (! empty($conditions)) { - if (count($conditions) === 1) { - $query->subQuery->andWhere(reset($conditions), $params); - } else { - $query->subQuery->andWhere(['or', ...$conditions], $params); - } + if ($condition !== null) { + $conditions[] = $condition; } } - } - if (! empty($query->generatedFields)) { - $generatedFieldColumns = []; + if (empty($conditions)) { + return; + } - foreach ($query->generatedFields as $field) { - $handle = $field['handle'] ?? ''; - if ($handle !== '' && isset($fieldAttributes->$handle) && ! isset($fieldsByHandle[$handle])) { - $generatedFieldColumns[$handle][] = new JsonExtract('elements_sites.content', '$.'.$field['uid']); + $elementQuery->subQuery->where(function (Builder $query) use ($handle, $elementQuery, $conditions) { + if (count($conditions) === 1) { + Query::applyConditions($query, reset($conditions)); + } else { + $glue = $elementQuery->customFieldValues[$handle] === ':empty:' ? QueryParam::AND : QueryParam::OR; + Query::applyConditions($query, [$glue, ...$conditions]); } - } + }); + } + } + + private function applyGeneratedFieldParams(ElementQuery $elementQuery): void + { + if (empty($elementQuery->generatedFields)) { + return; + } + + $fieldsByHandle = $this->fieldsByHandle($elementQuery); - foreach ($generatedFieldColumns as $handle => $columns) { - $column = count($columns) === 1 - ? $columns[0] - : new Coalesce($columns)->getValue($query->subQuery->getGrammar()); + $generatedFieldColumns = []; - $query->subQuery->where(DbHelper::parseParam($column, $fieldAttributes->$handle)); + foreach ($elementQuery->generatedFields as $field) { + $handle = $field['handle'] ?? ''; + if ($handle !== '' && isset($elementQuery->customFieldValues[$handle]) && ! isset($fieldsByHandle[$handle])) { + $generatedFieldColumns[$handle][] = new JsonExtract('elements_sites.content', '$.'.$field['uid']); } } + + foreach ($generatedFieldColumns as $handle => $columns) { + $column = count($columns) === 1 + ? $columns[0] + : new Coalesce($columns); + + $elementQuery->subQuery->whereParam($column, $elementQuery->customFieldValues[$handle]); + } + } + + /** + * @return FieldInterface[][][] + */ + private function fieldsByHandle(ElementQuery $elementQuery): array + { + /** @var FieldInterface[][][] $fieldsByHandle */ + $fieldsByHandle = []; + + // Group the fields by handle and field UUID + foreach ($elementQuery->customFields as $field) { + $fieldsByHandle[$field->handle][$field->uid][] = $field; + } + + return $fieldsByHandle; } } diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php index 39d61fac5cd..4f3c55058c2 100644 --- a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -132,7 +132,7 @@ private function applyDraftParams(ElementQuery $elementQuery): void if ($elementQuery->draftOf === false) { $elementQuery->subQuery->whereNull('elements.canonicalId', null); } else { - $elementQuery->subQuery->whereIn('elements.canonicalId', $elementQuery->draftOf); + $elementQuery->subQuery->whereIn('elements.canonicalId', Arr::wrap($elementQuery->draftOf)); } } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index bbf7664c154..8fb6e51e14f 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -542,6 +542,10 @@ public static function hasGlobalMacro($name): bool */ public function __get($key): mixed { + if (array_key_exists($key, $this->customFieldValues)) { + return $this->customFieldValues[$key]; + } + if (in_array($key, $this->propertyPassthru)) { return $this->getQuery()->{$key}; } @@ -551,6 +555,12 @@ public function __get($key): mixed public function __set(string $name, $value): void { + if (array_key_exists($name, $this->customFieldValues)) { + $this->customFieldValues[$name] = $value; + + return; + } + if (method_exists($this, $name)) { $this->{$name}($value); @@ -580,6 +590,12 @@ public function __call($method, $parameters): mixed return null; } + if (array_key_exists($method, $this->customFieldValues)) { + $this->customFieldValues[$method] = $parameters[0]; + + return $this; + } + if ($this->hasMacro($method)) { array_unshift($parameters, $this); diff --git a/src/Element/Models/Draft.php b/src/Element/Models/Draft.php index c13b3b5c444..b7647b66ed3 100644 --- a/src/Element/Models/Draft.php +++ b/src/Element/Models/Draft.php @@ -25,6 +25,14 @@ final class Draft extends BaseModel 'saved' => 'bool', ]; + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Element\Models\Element, $this> + */ + public function element(): BelongsTo + { + return $this->belongsTo(Element::class, 'id'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\CraftCms\Cms\Element\Models\Element, $this> */ diff --git a/src/Entry/EntryTypes.php b/src/Entry/EntryTypes.php index 2b68ab7b4d8..0da4e5ebb0f 100644 --- a/src/Entry/EntryTypes.php +++ b/src/Entry/EntryTypes.php @@ -178,7 +178,7 @@ public function getEntryTypeById(int $entryTypeId, bool $withTrashed = false): ? if (! $entryType && $withTrashed) { $record = $this->getEntryTypeModel($entryTypeId, true); if ($record->exists) { - return new EntryType(...Arr::only($record->toArray(), [ + return EntryType::from(Arr::only($record->toArray(), [ 'id', 'fieldLayoutId', 'name', diff --git a/src/Field/Fields.php b/src/Field/Fields.php index df7b813b4b6..46842fc2482 100644 --- a/src/Field/Fields.php +++ b/src/Field/Fields.php @@ -754,7 +754,7 @@ private function _layouts(): MemoizableArray return $this->_layouts; } - if (Craft::$app->getIsInstalled()) { + if (Info::isInstalled()) { $layoutConfigs = $this->_createLayoutQuery()->get()->all(); } else { $layoutConfigs = []; diff --git a/src/Support/Query.php b/src/Support/Query.php index 2bf86bedf5b..e1f72bbe5bc 100644 --- a/src/Support/Query.php +++ b/src/Support/Query.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Support\Money as MoneyHelper; use DateTimeInterface; use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\Expression; use Illuminate\Support\Facades\Date; use InvalidArgumentException; use Money\Money; @@ -83,6 +84,98 @@ private const array OPERATORS = ['not ', '!=', '<=', '>=', '<', '>', '=']; + /** + * Recursively applies Yii-style condition arrays to a Laravel query builder instance. + * + * This helper applies Yii condition arrays onto a Laravel Query Builder instance + * `where` / `orWhere` clauses. It supports nested logical groups (`and`, `or`), + * comparison operators (`=`, `!=`, `>`, `>=`, `<`, `<=`), negations (`not`), + * and automatic handling of `IN` / `NOT IN` conditions for array values. + * + * Example Yii-style input: + * + * [ + * 'and', + * ['>=', 'users.age', 18], + * ['or', + * ['status' => 'active'], + * ['not', ['status' => ['banned', 'suspended']]] + * ] + * ] + * + * This would translate to roughly: + * + * $query->where(function($q) { + * $q->where('users.age', '>=', 18) + * ->where(function($q2) { + * $q2->where('status', 'active') + * ->orWhereNotIn('status', ['banned', 'suspended']); + * }); + * }); + */ + public static function applyConditions(Builder $query, array|string|false|null $conditions): Builder + { + if ($conditions === false) { + return $query; + } + + // Condition is an operator-style array like ['>=', 'field', value] + if (is_array($conditions) && isset($conditions[0]) && is_string($conditions[0])) { + $operator = strtolower($conditions[0]); + + switch ($operator) { + case 'and': + case 'or': + // Group of conditions + $method = $operator === 'and' ? 'where' : 'orWhere'; + + return $query->$method(function ($q) use ($conditions) { + foreach (array_slice($conditions, 1) as $subCondition) { + self::applyConditions($q, $subCondition); + } + }); + + case 'not': + // NOT inside Yii usually means != or NOT IN + foreach ($conditions[1] as $field => $value) { + if (is_array($value)) { + return $query->whereNotIn(new Expression($field), $value); + } + + return $query->where(new Expression($field), '!=', $value); + } + + case '=': + case '!=': + case '>': + case '>=': + case '<': + case '<=': + case 'like': + return $query->where(new Expression($conditions[1]), $operator, $conditions[2]); + + default: + // If operator unknown, treat as field = value map. + return self::applyConditions($query, $conditions); + } + } + + // Handle "field => value" or "field => [values]" style arrays + foreach ($conditions as $field => $value) { + if (is_array($value)) { + // IN condition + $query->whereIn(new Expression($field), $value); + + continue; + } + + // Simple equals + $query->where(new Expression($field), '=', $value); + } + + return $query; + } + /** * Parses a query param value and applies it to a query builder. * diff --git a/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php b/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php new file mode 100644 index 00000000000..c6fc92f5e65 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php @@ -0,0 +1,68 @@ +create([ + 'handle' => 'textField', + 'type' => PlainText::class, + ]); + + $fieldLayout = FieldLayout::create([ + 'type' => Entry::class, + 'config' => [ + 'tabs' => [ + [ + 'uid' => \Illuminate\Support\Str::uuid()->toString(), + 'name' => 'Tab 1', + 'elements' => [ + [ + 'uid' => \Illuminate\Support\Str::uuid()->toString(), + 'type' => CustomField::class, + 'fieldUid' => $field->uid, + ], + ], + ], + ], + ], + ]); + + $entryModel = EntryModel::factory()->create(); + $entryModel->element->update([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + + $entryModel->entryType->update([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + + CustomFieldBehavior::$fieldHandles[$field->handle] = true; + + Fields::refreshFields(); + + /** @var \craft\elements\Entry $entry */ + $entry = entryQuery()->first(); + $entry->title = 'Test entry'; + $entry->setFieldValue('textField', 'Foo'); + + Craft::$app->getElements()->saveElement($entry); + + expect(entryQuery()->textField('Foo')->count())->toBe(1); + expect(entryQuery()->textField('Fo*')->count())->toBe(1); + expect(entryQuery()->textField([ + 'value' => 'fo*', + 'caseInsensitive' => true, + ])->count())->toBe(1); + expect(entryQuery()->textField([ + 'value' => 'fo*', + 'caseInsensitive' => false, + ])->count())->toBe(0); + expect(entryQuery()->textField('bar')->count())->toBe(0); +}); diff --git a/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php index 3f6c26601e2..4c578e9609d 100644 --- a/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php +++ b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php @@ -1,20 +1,79 @@ create(); $entry = EntryModel::factory()->create(); - $entry->element->update([ - 'draftId' => Draft::factory()->create([ - 'canonicalId' => $entry->id, - 'provisional' => false, - ])->id, - ]); + $element = Craft::$app->getElements()->getElementById($entry->id); + $draft = app(Drafts::class)->createDraft($element); expect(entryQuery()->drafts()->count())->toBe(1); - expect(entryQuery()->drafts(null)->count())->toBe(2); - expect(entryQuery()->drafts(false)->count())->toBe(1); + expect(entryQuery()->drafts(null)->count())->toBe(3); + expect(entryQuery()->drafts(false)->count())->toBe(2); + expect(entryQuery()->drafts()->pluck('id'))->toContain($draft->id); + expect(entryQuery()->draftId($element->draftId)->first())->not()->toBeNull(); +}); + +test('draftOf', function () { + $entry = EntryModel::factory()->create(); + + $element = Craft::$app->getElements()->getElementById($entry->id); + + app(Drafts::class)->createDraft($element); + + expect(entryQuery()->draftOf($element->id)->count())->toBe(1); + expect(entryQuery()->draftOf([$element->id])->count())->toBe(1); + expect(entryQuery()->draftOf([$element])->count())->toBe(1); + expect(entryQuery()->draftOf($element)->count())->toBe(1); + expect(entryQuery()->draftOf(999)->count())->toBe(0); + + $this->expectException(InvalidArgumentException::class); + entryQuery()->draftOf('foo')->count(); +}); + +test('draftCreator', function () { + $user = User::first(); + + $entry = EntryModel::factory()->create(); + $element = Craft::$app->getElements()->getElementById($entry->id); + app(Drafts::class)->createDraft($element, $user->id); + + expect(entryQuery()->draftCreator($user->id)->count())->toBe(1); + expect(entryQuery()->draftCreator($user)->count())->toBe(1); + expect(entryQuery()->draftCreator(999)->count())->toBe(0); + + $this->expectException(InvalidArgumentException::class); + entryQuery()->draftOf('foo')->count(); +}); + +test('provisionalDrafts', function () { + $entry = EntryModel::factory()->create(); + $element = Craft::$app->getElements()->getElementById($entry->id); + app(Drafts::class)->createDraft($element, provisional: true); + + expect(entryQuery()->drafts()->count())->toBe(0); + expect(entryQuery()->drafts()->provisionalDrafts()->count())->toBe(1); + expect(entryQuery()->drafts()->provisionalDrafts(null)->count())->toBe(1); +}); + +test('canonicalsOnly', function () { + $entry = EntryModel::factory()->create(); + $element = Craft::$app->getElements()->getElementById($entry->id); + app(Drafts::class)->createDraft($element); + + expect(entryQuery()->canonicalsOnly()->count())->toBe(1); + expect(entryQuery()->drafts(null)->canonicalsOnly()->count())->toBe(1); + expect(entryQuery()->drafts()->canonicalsOnly()->count())->toBe(0); +}); + +test('savedDraftsOnly', function () { + $entry = EntryModel::factory()->create(); + $element = Craft::$app->getElements()->getElementById($entry->id); + app(Drafts::class)->createDraft($element); + + expect(entryQuery()->savedDraftsOnly()->count())->toBe(1); }); From a5fdc4ce54ee8ec2d02f9c5f5bad18b00ea91e5a Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 12 Nov 2025 14:09:46 +0100 Subject: [PATCH 18/52] Tests for revisions --- .../QueriesDraftsAndRevisionsTest.php | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php index 4c578e9609d..5f72c35e331 100644 --- a/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php +++ b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php @@ -1,6 +1,7 @@ draftCreator($user->id)->count())->toBe(1); expect(entryQuery()->draftCreator($user)->count())->toBe(1); expect(entryQuery()->draftCreator(999)->count())->toBe(0); - - $this->expectException(InvalidArgumentException::class); - entryQuery()->draftOf('foo')->count(); }); test('provisionalDrafts', function () { @@ -77,3 +75,30 @@ expect(entryQuery()->savedDraftsOnly()->count())->toBe(1); }); + +test('revisions', function () { + EntryModel::factory()->create(); + + $entry = EntryModel::factory()->create(); + $element = Craft::$app->getElements()->getElementById($entry->id); + $revision = app(Revisions::class)->createRevision($element); + + expect(entryQuery()->revisions()->count())->toBe(1); + expect(entryQuery()->revisions(null)->count())->toBe(3); + expect(entryQuery()->revisions(false)->count())->toBe(2); + expect(entryQuery()->revisions()->pluck('id'))->toContain($revision); + expect(entryQuery()->revisionId($element->revisionId)->first())->not()->toBeNull(); + expect(entryQuery()->revisionOf($element)->first())->not()->toBeNull(); +}); + +test('revisionCreator', function () { + $user = User::first(); + + $entry = EntryModel::factory()->create(); + $element = Craft::$app->getElements()->getElementById($entry->id); + app(Revisions::class)->createRevision($element, $user->id); + + expect(entryQuery()->revisionCreator($user->id)->count())->toBe(1); + expect(entryQuery()->revisionCreator($user)->count())->toBe(1); + expect(entryQuery()->revisionCreator(999)->count())->toBe(0); +}); From f31faa777f6dbeef3807e85725913fc58660b2ea Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 12 Nov 2025 20:57:32 +0100 Subject: [PATCH 19/52] Replace queryCondition with modifyQuery on fields --- src/Database/DatabaseServiceProvider.php | 17 ++- src/Database/Expressions/Cast.php | 80 +++++++++++ src/Database/Expressions/JsonExtract.php | 4 +- .../Queries/Concerns/QueriesCustomFields.php | 36 ++--- .../Queries/Concerns/QueriesEagerly.php | 38 +++-- src/Field/Addresses.php | 36 +++-- src/Field/BaseOptionsField.php | 49 ++++--- src/Field/BaseRelationField.php | 73 ++++------ src/Field/Contracts/FieldInterface.php | 15 +- src/Field/Date.php | 5 +- src/Field/Field.php | 55 ++++---- src/Field/Lightswitch.php | 7 +- src/Field/Matrix.php | 28 ++-- src/Field/Money.php | 6 +- src/Field/Number.php | 14 +- src/Field/Range.php | 13 +- src/Support/Query.php | 131 +++--------------- .../Queries/Concerns/QueriesEagerlyTest.php | 3 + yii2-adapter/legacy/base/Field.php | 29 +++- .../legacy/elements/db/ElementQuery.php | 18 ++- 20 files changed, 330 insertions(+), 327 deletions(-) create mode 100644 src/Database/Expressions/Cast.php create mode 100644 tests/Database/Queries/Concerns/QueriesEagerlyTest.php diff --git a/src/Database/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index 379fdeaa33a..5235acfd57b 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -9,10 +9,10 @@ use CraftCms\Cms\Support\Query; use Illuminate\Cache\DatabaseStore; use Illuminate\Contracts\Config\Repository; +use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Connection; use Illuminate\Database\Migrations\MigrationRepositoryInterface; use Illuminate\Database\Query\Builder; -use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Builder as SchemaBuilder; use Illuminate\Support\Facades\DB; @@ -59,11 +59,16 @@ public function boot(Repository $config, Connection $db, \Illuminate\Cache\Repos public function registerQueryBuilderMacros(): void { - Builder::macro('whereParam', fn (string $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null): Builder => Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType)); - Builder::macro('whereNumericParam', fn (string $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER): Builder => Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType)); - Builder::macro('whereDateParam', fn (string $column, mixed $param, string $defaultOperator = '='): Builder => Query::whereDateParam($this, $column, $param, $defaultOperator)); - Builder::macro('whereMoneyParam', fn (string $column, string $currency, mixed $param, string $defaultOperator = '='): Builder => Query::whereMoneyParam($this, $column, $currency, $param, $defaultOperator)); - Builder::macro('whereBooleanParam', fn (string $column, mixed $param, ?bool $defaultValue = null, string $columnType = Query::TYPE_BOOLEAN): Builder => Query::whereBooleanParam($this, $column, $param, $defaultValue, $columnType)); + Builder::macro('whereParam', fn (string|Expression $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null, string $boolean = 'and'): Builder => Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType, $boolean)); + Builder::macro('orWhereParam', fn (string|Expression $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null): Builder => Query::whereParam($this, $column, $param, $defaultOperator, $caseInsensitive, $columnType, 'or')); + Builder::macro('whereNumericParam', fn (string|Expression $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER, string $boolean = 'and'): Builder => Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType, $boolean)); + Builder::macro('orWhereNumericParam', fn (string|Expression $column, mixed $param, string $defaultOperator = '=', ?string $columnType = Query::TYPE_INTEGER): Builder => Query::whereNumericParam($this, $column, $param, $defaultOperator, $columnType, 'or')); + Builder::macro('whereDateParam', fn (string|Expression $column, mixed $param, string $defaultOperator = '=', string $boolean = 'and'): Builder => Query::whereDateParam($this, $column, $param, $defaultOperator, $boolean)); + Builder::macro('orWhereDateParam', fn (string|Expression $column, mixed $param, string $defaultOperator = '='): Builder => Query::whereDateParam($this, $column, $param, $defaultOperator, 'or')); + Builder::macro('whereMoneyParam', fn (string|Expression $column, string $currency, mixed $param, string $defaultOperator = '=', string $boolean = 'and'): Builder => Query::whereMoneyParam($this, $column, $currency, $param, $defaultOperator, $boolean)); + Builder::macro('orWhereMoneyParam', fn (string|Expression $column, string $currency, mixed $param, string $defaultOperator = '='): Builder => Query::whereMoneyParam($this, $column, $currency, $param, $defaultOperator, 'or')); + Builder::macro('whereBooleanParam', fn (string|Expression $column, mixed $param, ?bool $defaultValue = null, string $columnType = Query::TYPE_BOOLEAN, string $boolean = 'and'): Builder => Query::whereBooleanParam($this, $column, $param, $defaultValue, $columnType, $boolean)); + Builder::macro('orWhereBooleanParam', fn (string|Expression $column, mixed $param, ?bool $defaultValue = null, string $columnType = Query::TYPE_BOOLEAN): Builder => Query::whereBooleanParam($this, $column, $param, $defaultValue, $columnType, 'or')); Builder::macro('idByUid', fn (string $uid): ?int => (int) $this->where('uid', $uid)->value('id') ?: null); Builder::macro('idsByUids', fn (array $uids): array => $this->whereIn('uid', $uids)->pluck('id', 'uid')->all()); diff --git a/src/Database/Expressions/Cast.php b/src/Database/Expressions/Cast.php new file mode 100644 index 00000000000..1d3e3efd558 --- /dev/null +++ b/src/Database/Expressions/Cast.php @@ -0,0 +1,80 @@ +stringize($grammar, $this->expression); + + return match ($this->identify($grammar)) { + 'mariadb', 'mysql' => $this->castMysql($expression), + 'pgsql' => $this->castPgsql($expression), + 'sqlite' => $this->castSqlite($expression), + 'sqlsrv' => $this->castSqlsrv($expression), + }; + } + + private function castMysql(float|int|string $expression): string + { + // MySQL 5.7 does not support casting to floating-point numbers. So the workaround is to multiply with one to + // trigger MySQL's automatic type conversion. Technically, this will always produce a double value and never a + // float one, but it will be silently downsized to a float when stored in a table. + return match ($this->type) { + 'bigint', 'int' => "cast({$expression} as signed)", + 'float', 'double' => "(({$expression})*1.0)", + default => "cast({$expression} as {$this->type})", + }; + } + + private function castPgsql(float|int|string $expression): string + { + return match ($this->type) { + 'bigint' => "cast({$expression} as bigint)", + 'float' => "cast({$expression} as real)", + 'double' => "cast({$expression} as double precision)", + 'int' => "cast({$expression} as int)", + default => "cast({$expression} as {$this->type})", + }; + } + + private function castSqlite(float|int|string $expression): string + { + return match ($this->type) { + 'bigint', 'int' => "cast({$expression} as integer)", + 'float', 'double' => "cast({$expression} as real)", + default => "cast({$expression} as {$this->type})", + }; + } + + private function castSqlsrv(float|int|string $expression): string + { + return match ($this->type) { + 'bigint' => "cast({$expression} as bigint)", + 'float' => "cast({$expression} as float(24))", + 'double' => "cast({$expression} as float(53))", + 'int' => "(({$expression})*1)", + default => "cast({$expression} as {$this->type})", + }; + } +} diff --git a/src/Database/Expressions/JsonExtract.php b/src/Database/Expressions/JsonExtract.php index a9a9aed04bd..9958aaaa72c 100644 --- a/src/Database/Expressions/JsonExtract.php +++ b/src/Database/Expressions/JsonExtract.php @@ -24,8 +24,8 @@ public function getValue(Grammar $grammar): string $expression = $this->stringize($grammar, $this->expression); return match ($this->identify($grammar)) { - 'mariadb' => "JSON_UNQUOTE(JSON_EXTRACT($expression, $this->path))", - default => "($expression->>$this->path)", + 'mariadb' => "JSON_UNQUOTE(JSON_EXTRACT($expression, '$this->path'))", + default => "($expression->>'$this->path')", }; } } diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index 9ac6a10f2bd..c98cbf64d1f 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -219,33 +219,15 @@ private function applyCustomFieldParams(ElementQuery $elementQuery): void throw new QueryAbortedException("No custom field with the handle \"$handle\" exists in the field layouts involved with this element query."); } - $conditions = []; - $params = []; - - foreach ($fieldsByHandle[$handle] as $instances) { - $firstInstance = $instances[0]; - $condition = $firstInstance::queryCondition($instances, $elementQuery->customFieldValues[$handle], $params); - - // aborting? - if ($condition === false) { - throw new QueryAbortedException; - } - - if ($condition !== null) { - $conditions[] = $condition; - } - } - - if (empty($conditions)) { - return; - } - - $elementQuery->subQuery->where(function (Builder $query) use ($handle, $elementQuery, $conditions) { - if (count($conditions) === 1) { - Query::applyConditions($query, reset($conditions)); - } else { - $glue = $elementQuery->customFieldValues[$handle] === ':empty:' ? QueryParam::AND : QueryParam::OR; - Query::applyConditions($query, [$glue, ...$conditions]); + $glue = $elementQuery->customFieldValues[$handle] === ':empty:' + ? QueryParam::AND + : QueryParam::OR; + + $this->subQuery->where(function (Builder $query) use ($fieldsByHandle, $glue, $handle, $elementQuery) { + foreach ($fieldsByHandle[$handle] as $instances) { + $query->where(function (Builder $query) use ($handle, $elementQuery, $instances) { + $instances[0]::modifyQuery($query, $instances, $elementQuery->customFieldValues[$handle]); + }, boolean: $glue); } }); } diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php index 41a493911f4..2c33b30b433 100644 --- a/src/Database/Queries/Concerns/QueriesEagerly.php +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -51,11 +51,13 @@ trait QueriesEagerly protected function initQueriesEagerly(): void { $this->afterQuery(function (Collection $elements) { - if ($this->with) { - $elementsService = \Craft::$app->getElements(); - $elementsService->eagerLoadElements($this->elementType, $elements->all(), $this->with); + if (! $this->with) { + return $elements; } + $elementsService = \Craft::$app->getElements(); + $elementsService->eagerLoadElements($this->elementType, $elements->all(), $this->with); + return $elements; }); } @@ -83,7 +85,23 @@ protected function initQueriesEagerly(): void */ public function with(array|string|null $value): static { - $this->with = $value; + if (is_null($value)) { + $this->with = null; + + return $this; + } + + $this->with ??= []; + + if (is_string($this->with)) { + $this->with = str($this->with)->explode(',')->all(); + } + + if (! is_array($value)) { + $value = str($value)->explode(',')->all(); + } + + $this->with = array_merge($this->with, $value); return $this; } @@ -93,19 +111,11 @@ public function with(array|string|null $value): static */ public function andWith(array|string|null $value): static { - if (empty($this->with)) { - $this->with = [$value]; - + if (! is_null($value)) { return $this; } - if (is_string($this->with)) { - $this->with = str($this->with)->explode(',')->all(); - } - - $this->with[] = $value; - - return $this; + return $this->with($value); } /** diff --git a/src/Field/Addresses.php b/src/Field/Addresses.php index b3b0f93fd12..68274ae3e86 100644 --- a/src/Field/Addresses.php +++ b/src/Field/Addresses.php @@ -9,8 +9,6 @@ use craft\base\ElementInterface; use craft\base\NestedElementInterface; use craft\behaviors\EventBehavior; -use craft\db\Query; -use craft\db\Table as DbTable; use craft\elements\Address; use craft\elements\db\AddressQuery; use craft\elements\db\ElementQuery; @@ -29,6 +27,7 @@ use craft\helpers\Gql; use craft\validators\ArrayValidator; use craft\web\assets\cp\CpAsset; +use CraftCms\Cms\Database\Table as DbTable; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Field\Contracts\EagerLoadingFieldInterface; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; @@ -38,13 +37,13 @@ use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Str; use GraphQL\Type\Definition\Type; +use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Tpetry\QueryExpressions\Language\Alias; use yii\base\InvalidConfigException; -use yii\db\Expression; use function CraftCms\Cms\t; @@ -116,29 +115,26 @@ public static function dbType(): array|string|null * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { /** @var self $field */ $field = reset($instances); $ns = $field->handle.'_'.Str::random(5); - $existsQuery = (new Query) - ->from(["addresses_$ns" => DbTable::ADDRESSES]) - ->innerJoin(["elements_$ns" => DbTable::ELEMENTS], "[[elements_$ns.id]] = [[addresses_$ns.id]]") - ->innerJoin(["elements_owners_$ns" => DbTable::ELEMENTS_OWNERS], "[[elements_owners_$ns.elementId]] = [[elements_$ns.id]]") - ->andWhere([ - "addresses_$ns.fieldId" => $field->id, - "elements_$ns.enabled" => true, - "elements_$ns.dateDeleted" => null, - "[[elements_owners_$ns.ownerId]]" => new Expression('[[elements.id]]'), - ]); + $exists = DB::table(DbTable::ADDRESSES, "addresses_$ns") + ->join(new Alias(DbTable::ELEMENTS, "elements_$ns"), "elements_$ns.id", '=', "addresses_$ns.id") + ->join(new Alias(DbTable::ELEMENTS_OWNERS, "elements_owners_$ns"), "elements_owners_$ns.elementId", '=', "elements_$ns.id") + ->where("addresses_$ns.fieldId", $field->id) + ->where("elements_$ns.enabled", true) + ->whereNull("elements_$ns.dateDeleted") + ->whereColumn("elements_owners_$ns.ownerId", 'elements.id'); if ($value === 'not :empty:') { $value = ':notempty:'; } if ($value === ':empty:') { - return ['not exists', $existsQuery]; + return $query->whereNotExists($exists); } if ($value !== ':notempty:') { @@ -149,10 +145,10 @@ public static function queryCondition(array $instances, mixed $value, array &$pa $ids = array_map(fn ($id) => $id instanceof Address ? $id->id : (int) $id, $ids); - $existsQuery->andWhere(["addresses_$ns.id" => $ids]); + $exists->whereIn("addresses_$ns.id", $ids); } - return ['exists', $existsQuery]; + return $query->whereExists($exists); } /** @@ -820,12 +816,12 @@ public function getEagerLoadingMap(array $sourceElements): array } // Return any relation data on these elements, defined with this field - $map = DB::table(\CraftCms\Cms\Database\Table::ADDRESSES, 'addresses') + $map = DB::table(DbTable::ADDRESSES, 'addresses') ->select([ 'elements_owners.ownerId as source', 'addresses.id as target', ]) - ->join(new Alias(\CraftCms\Cms\Database\Table::ELEMENTS_OWNERS, 'elements_owners'), function (JoinClause $join) use ($sourceElementIds) { + ->join(new Alias(DbTable::ELEMENTS_OWNERS, 'elements_owners'), function (JoinClause $join) use ($sourceElementIds) { $join->where('elements_owners.elementId', 'addresses.id') ->whereIn('elements_owners.ownerId', $sourceElementIds); }) @@ -852,7 +848,7 @@ public function getEagerLoadingMap(array $sourceElements): array #[\Override] public function afterMergeFrom(FieldInterface $outgoingField): void { - DB::table(\CraftCms\Cms\Database\Table::ADDRESSES) + DB::table(DbTable::ADDRESSES) ->where('fieldId', $outgoingField->id) ->update(['fieldId' => $this->id]); diff --git a/src/Field/BaseOptionsField.php b/src/Field/BaseOptionsField.php index 1fd4ce34cef..006f5a947fd 100644 --- a/src/Field/BaseOptionsField.php +++ b/src/Field/BaseOptionsField.php @@ -4,13 +4,11 @@ namespace CraftCms\Cms\Field; -use Craft; use craft\base\ElementInterface; use craft\fields\conditions\OptionsFieldConditionRule; use craft\gql\arguments\OptionField as OptionFieldArguments; use craft\gql\resolvers\OptionField as OptionFieldResolver; use craft\helpers\Cp; -use craft\helpers\Db; use CraftCms\Cms\Database\QueryParam; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; @@ -25,6 +23,7 @@ use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; use GraphQL\Type\Definition\Type; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator as ValidatorFacade; use Illuminate\Validation\Validator; @@ -89,41 +88,41 @@ public static function dbType(): string * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): ?array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { - if (static::$multi) { - $param = QueryParam::parse($value); + if (! static::$multi) { + return parent::modifyQuery($query, $instances, $value); + } - if (empty($param->values)) { - return null; - } + $param = QueryParam::parse($value); - if ($param->operator === QueryParam::NOT) { - $param->operator = QueryParam::OR; - $negate = true; - } else { - $negate = false; - } + if (empty($param->values)) { + return $query; + } - $condition = [$param->operator]; - $qb = Craft::$app->getDb()->getQueryBuilder(); - $valueSql = static::valueSql($instances); + if ($param->operator === QueryParam::NOT) { + $param->operator = QueryParam::OR; + $negate = true; + } else { + $negate = false; + } + $valueSql = static::valueSql($instances); + + return $query->where(function (Builder $query) use ($param, $valueSql) { foreach ($param->values as $value) { if ( is_string($value) && in_array(strtolower($value), [':empty:', ':notempty:', 'not :empty:']) ) { - $condition[] = Db::parseParam($valueSql, $value, columnType: Schema::TYPE_JSON); - } else { - $condition[] = $qb->jsonContains($valueSql, $value); - } - } + $query->whereParam($valueSql, $value, columnType: Schema::TYPE_JSON, boolean: $param->operator); - return $negate ? ['not', $condition] : $condition; - } + continue; + } - return parent::queryCondition($instances, $value, $params); + $query->whereJsonContains($valueSql, $value, $param->operator); + } + }, boolean: $negate ? 'and not' : 'and'); } /** diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index 9228e103b2c..f4d565d8cd1 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -48,9 +48,9 @@ use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Tpetry\QueryExpressions\Language\Alias; use yii\base\Event; use yii\base\InvalidConfigException; -use yii\db\Expression; use yii\db\Schema; use yii\validators\NumberValidator; @@ -130,7 +130,7 @@ public static function dbType(): array|string|null * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): array|false + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { /** @var self $field */ $field = reset($instances); @@ -139,18 +139,17 @@ public static function queryCondition(array $instances, mixed $value, array &$pa $value = [$value]; } - $conditions = []; - if (isset($value[0]) && in_array($value[0], [':notempty:', ':empty:', 'not :empty:'])) { $emptyCondition = array_shift($value); if (in_array($emptyCondition, [':notempty:', 'not :empty:'])) { - $conditions[] = static::existsQueryCondition($field); + $query->orWhereExists(static::existsQuery($field)); } else { - $conditions[] = ['not', static::existsQueryCondition($field)]; + $query->orWhereNotExists(static::existsQuery($field)); } } if (! empty($value)) { + /** @TODO Port to Laravel */ $parser = new ElementRelationParamParser([ 'fields' => [ $field->handle => $field, @@ -161,17 +160,17 @@ public static function queryCondition(array $instances, mixed $value, array &$pa 'field' => $field->handle, ]); if ($condition !== false) { - $conditions[] = $condition; - } - } + $params = []; + $sql = Craft::$app->getDb()->getQueryBuilder()->buildCondition($condition, $params); - if (empty($conditions)) { - return false; - } + // Yii uses named parameters, Laravel uses positional + $sql = preg_replace('/:qp\d+/', '?', (string) $sql); - array_unshift($conditions, 'or'); + $query->whereRaw($sql, $params); + } + } - return $conditions; + return $query; } /** @@ -181,46 +180,34 @@ public static function queryCondition(array $instances, mixed $value, array &$pa * @param self $field The relation field * @param bool $enabledOnly Whether to only */ - public static function existsQueryCondition( + public static function existsQuery( self $field, bool $enabledOnly = true, bool $inTargetSiteOnly = true, - ): array { + ): Builder { $ns = sprintf('%s_%s', $field->handle, Str::random(5)); - $query = (new Query) - ->from(["relations_$ns" => DbTable::RELATIONS]) - ->innerJoin(["elements_$ns" => DbTable::ELEMENTS], "[[elements_$ns.id]] = [[relations_$ns.targetId]]") - ->leftJoin(["elements_sites_$ns" => DbTable::ELEMENTS_SITES], - "[[elements_sites_$ns.elementId]] = [[elements_$ns.id]]") - ->where([ - 'and', - "[[relations_$ns.sourceId]] = [[elements.id]]", - [ - "relations_$ns.fieldId" => $field->id, - "elements_$ns.dateDeleted" => null, - ], - [ - 'or', - ["relations_$ns.sourceSiteId" => null], - ["relations_$ns.sourceSiteId" => new Expression('[[elements_sites.siteId]]')], - ], - ]); + $query = DB::table(\CraftCms\Cms\Database\Table::RELATIONS, "relations_$ns") + ->join(new Alias(\CraftCms\Cms\Database\Table::ELEMENTS, "elements_$ns"), "elements_$ns.id", '=', "relations_$ns.targetId") + ->leftJoin(new Alias(\CraftCms\Cms\Database\Table::ELEMENTS_SITES, "elements_sites_$ns"), "elements_sites_$ns.elementId", '=', "elements_$ns.id") + ->whereColumn("relations_$ns.sourceId", 'elements.id') + ->where("relations_$ns.fieldId", $field->id) + ->whereNull("relations_$ns.dateDeleted") + ->where(function (Builder $query) use ($ns) { + $query->whereNull("relations_$ns.sourceSiteId") + ->orWhereColumn("relations_$ns.sourceSiteId", 'elements_sites.siteId'); + }); if ($enabledOnly) { - $query->andWhere([ - "elements_$ns.enabled" => true, - "elements_sites_$ns.enabled" => true, - ]); + $query->where("elements_$ns.enabled", true); + $query->where("elements_sites_$ns.enabled", true); } if ($inTargetSiteOnly) { - $query->andWhere([ - "elements_sites_$ns.siteId" => $field->_targetSiteId() ?? new Expression('[[elements_sites.siteId]]'), - ]); + $query->where("elements_sites_$ns.siteId", $field->_targetSiteId() ?? DB::raw('elements_sites.siteId')); } - return ['exists', $query]; + return $query; } /** @@ -1083,7 +1070,7 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false ]) ->where(fn (Builder $query) => $query ->where('sourceSiteId', $sourceSiteId) - ->orWhereNull('sourceSiteId') + ->orWhereNull('sourceSiteId'), ) ->orderBy('sortOrder') ->get() diff --git a/src/Field/Contracts/FieldInterface.php b/src/Field/Contracts/FieldInterface.php index 35f1dc0a93e..b99fa33b6d7 100644 --- a/src/Field/Contracts/FieldInterface.php +++ b/src/Field/Contracts/FieldInterface.php @@ -17,7 +17,8 @@ use CraftCms\Cms\Element\Enums\AttributeStatus; use DateTime; use GraphQL\Type\Definition\Type; -use yii\db\ExpressionInterface; +use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Database\Query\Builder; /** * FieldInterface defines the common interface to be implemented by field classes. @@ -155,19 +156,19 @@ public static function phpType(): string; public static function dbType(): array|string|null; /** - * Returns a query builder-compatible condition for the given field instances, for a user-provided param value. + * Applies a condition to the query builder for the given field instances, for a user-provided param value. * * If `false` is returned, an always-false condition will be used. * + * @param Builder $query The query instance to modify * @param static[] $instances The field instances to search * @param mixed $value The user-supplied param value - * @param array $params Additional parameters that should be bound to the query via [[\yii\db\Query::addParams()]] */ - public static function queryCondition( + public static function modifyQuery( + Builder $query, array $instances, mixed $value, - array &$params, - ): array|string|ExpressionInterface|false|null; + ): Builder; /** * Returns the orientation the field should use (`ltr` or `rtl`). @@ -478,7 +479,7 @@ public function getElementConditionRuleType(): array|string|null; * * @param string|null $key The data key to fetch, if this field stores multiple values */ - public function getValueSql(?string $key = null): ?string; + public function getValueSql(?string $key = null): string|Expression|null; /** * Modifies an element index query. diff --git a/src/Field/Date.php b/src/Field/Date.php index 311dbf2af48..74bb3592af0 100644 --- a/src/Field/Date.php +++ b/src/Field/Date.php @@ -24,6 +24,7 @@ use DateTime; use DateTimeZone; use GraphQL\Type\Definition\ResolveInfo; +use Illuminate\Database\Query\Builder; use yii\db\Schema; use function CraftCms\Cms\t; @@ -76,11 +77,11 @@ public static function dbType(): array * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): ?array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { $valueSql = self::valueSql($instances); - return Db::parseDateParam($valueSql, $value); + return $query->whereDateParam($valueSql, $value); } /** diff --git a/src/Field/Field.php b/src/Field/Field.php index 904189e3ecd..129f0819c60 100644 --- a/src/Field/Field.php +++ b/src/Field/Field.php @@ -7,7 +7,6 @@ use Craft; use craft\base\ElementInterface; use craft\base\Serializable; -use craft\db\ExpressionInterface; use craft\elements\db\ElementQueryInterface; use craft\fieldlayoutelements\CustomField; use craft\gql\types\QueryArgument; @@ -24,6 +23,8 @@ use CraftCms\Cms\Component\Contracts\Actionable; use CraftCms\Cms\Component\Contracts\Iconic; use CraftCms\Cms\Component\Events\ComponentEvent; +use CraftCms\Cms\Database\Expressions\Cast; +use CraftCms\Cms\Database\Expressions\JsonExtract; use CraftCms\Cms\Element\Enums\AttributeStatus; use CraftCms\Cms\Field\Contracts\EagerLoadingFieldInterface; use CraftCms\Cms\Field\Contracts\FieldInterface; @@ -40,11 +41,14 @@ use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Support\Query; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Typecast; use DateTime; use GraphQL\Type\Definition\Type; +use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -53,6 +57,7 @@ use Illuminate\Validation\Rule; use InvalidArgumentException; use RuntimeException; +use Tpetry\QueryExpressions\Function\Conditional\Coalesce; use yii\db\Schema; use function CraftCms\Cms\t; @@ -426,17 +431,12 @@ public static function dbType(): array|string|null return Schema::TYPE_TEXT; } - /** - * {@inheritdoc} */ - public static function queryCondition( - array $instances, - mixed $value, - array &$params, - ): array|string|ExpressionInterface|false|null { + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder + { $valueSql = static::valueSql($instances); if ($valueSql === null) { - return false; + return $query; } $caseInsensitive = false; @@ -446,11 +446,11 @@ public static function queryCondition( $value = $value['value']; } - return DbHelper::parseParam( + return $query->whereParam( column: $valueSql, - value: $value, + param: $value, caseInsensitive: $caseInsensitive, - columnType: Schema::TYPE_JSON, + columnType: Query::TYPE_JSON, ); } @@ -460,11 +460,11 @@ public static function queryCondition( * @param static[] $instances * @param string|null $key The data key to fetch, if this field stores multiple values */ - protected static function valueSql(array $instances, ?string $key = null): ?string + protected static function valueSql(array $instances, ?string $key = null): string|Expression|null { $valuesSql = array_filter( array_map(fn (self $field) => $field->getValueSql($key), $instances), - fn (?string $valueSql) => $valueSql !== null, + fn (string|Expression|null $valueSql) => $valueSql !== null, ); if (empty($valuesSql)) { @@ -475,7 +475,7 @@ protected static function valueSql(array $instances, ?string $key = null): ?stri return reset($valuesSql); } - return sprintf('COALESCE(%s)', implode(',', $valuesSql)); + return new Coalesce($valuesSql); } /** @@ -1015,7 +1015,7 @@ public function getElementConditionRuleType(): array|string|null } /** {@inheritdoc} */ - public function getValueSql(?string $key = null): ?string + public function getValueSql(?string $key = null): string|Expression|null { if (! isset($this->layoutElement)) { return null; @@ -1027,7 +1027,7 @@ public function getValueSql(?string $key = null): ?string return $this->_valueSql[$cacheKey] ?: null; } - private function _valueSql(?string $key): ?string + private function _valueSql(?string $key): ?\Illuminate\Contracts\Database\Query\Expression { $dbType = $this->dbTypeForValueSql(); @@ -1039,22 +1039,23 @@ private function _valueSql(?string $key): ?string throw new InvalidArgumentException(sprintf('%s doesn’t store values under the key “%s”.', self::class, $key)); } - $db = Craft::$app->getDb(); - $qb = $db->getQueryBuilder(); - $sql = $qb->jsonExtract('elements_sites.content', [$this->layoutElement->uid]); + $sql = new JsonExtract('elements_sites.content', "$.\"{$this->layoutElement->uid}\""); if (is_array($dbType)) { // Get the primary value by default $key ??= array_key_first($dbType); $dbType = $dbType[$key]; - $sql = sprintf('COALESCE(%s, %s)', $qb->jsonExtract( - 'elements_sites.content', - [$this->layoutElement->uid, $key], - ), $sql); + $sql = new Coalesce([ + new JsonExtract( + 'elements_sites.content', + "$.\"{$this->layoutElement->uid}\".\"{$key}\"", + ), + $sql, + ]); } $castType = null; - if ($db->getIsMysql()) { + if (DB::getDriverName() === 'mysql') { // If the field uses an optimized DB type, cast it so its values can be indexed // (see "Functional Key Parts" on https://dev.mysql.com/doc/refman/8.0/en/create-index.html) $castType = match (DbHelper::parseColumnType($dbType)) { @@ -1079,7 +1080,7 @@ private function _valueSql(?string $key): ?string // for pgsql, we have to make sure decimals column type is cast to decimal, otherwise they won't be sorted correctly // see https://github.com/craftcms/cms/issues/15828, https://github.com/craftcms/cms/issues/15973 - if ($db->getIsPgsql()) { + if (DB::getDriverName() === 'pgsql') { $castType = match (DbHelper::parseColumnType($dbType)) { Schema::TYPE_DECIMAL => 'DECIMAL', Schema::TYPE_INTEGER => 'INTEGER', @@ -1099,7 +1100,7 @@ private function _valueSql(?string $key): ?string } } - $sql = "CAST($sql AS $castType)"; + $sql = new Cast($sql, $castType); } return $sql; diff --git a/src/Field/Lightswitch.php b/src/Field/Lightswitch.php index ba49efde9c8..8301b7a759b 100644 --- a/src/Field/Lightswitch.php +++ b/src/Field/Lightswitch.php @@ -9,7 +9,6 @@ use craft\elements\Entry; use craft\fields\conditions\LightswitchFieldConditionRule; use craft\helpers\Cp; -use craft\helpers\Db; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface; use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; @@ -17,7 +16,9 @@ use CraftCms\Cms\Shared\Enums\Color as ColorEnum; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Support\Query; use GraphQL\Type\Definition\Type; +use Illuminate\Database\Query\Builder; use yii\db\Schema; use function CraftCms\Cms\t; @@ -76,7 +77,7 @@ public static function dbType(): string * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { $valueSql = self::valueSql($instances); $strict = false; @@ -88,7 +89,7 @@ public static function queryCondition(array $instances, mixed $value, array &$pa $defaultValue = $strict ? null : $instances[0]->default; - return Db::parseBooleanParam($valueSql, $value, $defaultValue, Schema::TYPE_JSON); + return $query->whereBooleanParam($valueSql, $value, $defaultValue, Query::TYPE_JSON); } /** diff --git a/src/Field/Matrix.php b/src/Field/Matrix.php index 33fe77f6e0e..d371b513141 100644 --- a/src/Field/Matrix.php +++ b/src/Field/Matrix.php @@ -12,7 +12,6 @@ use craft\base\NestedElementInterface; use craft\behaviors\EventBehavior; use craft\db\Query; -use craft\db\Table as DbTable; use craft\elements\db\ElementQuery; use craft\elements\db\ElementQueryInterface; use craft\elements\db\EntryQuery; @@ -59,6 +58,7 @@ use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; use GraphQL\Type\Definition\Type; +use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -67,7 +67,6 @@ use Tpetry\QueryExpressions\Language\Alias; use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; -use yii\db\Expression; use function CraftCms\Cms\t; @@ -143,29 +142,26 @@ public static function dbType(): array|string|null * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { /** @var self $field */ $field = reset($instances); $ns = $field->handle.'_'.Str::random(5); - $existsQuery = (new Query) - ->from(["entries_$ns" => DbTable::ENTRIES]) - ->innerJoin(["elements_$ns" => DbTable::ELEMENTS], "[[elements_$ns.id]] = [[entries_$ns.id]]") - ->innerJoin(["elements_owners_$ns" => DbTable::ELEMENTS_OWNERS], "[[elements_owners_$ns.elementId]] = [[elements_$ns.id]]") - ->andWhere([ - "entries_$ns.fieldId" => $field->id, - "elements_$ns.enabled" => true, - "elements_$ns.dateDeleted" => null, - "[[elements_owners_$ns.ownerId]]" => new Expression('[[elements.id]]'), - ]); + $exists = DB::table(Table::ENTRIES, "entries_$ns") + ->join(new Alias(Table::ELEMENTS, "elements_$ns"), "elements_$ns.id", '=', "entries_$ns.id") + ->join(new Alias(Table::ELEMENTS_OWNERS, "elements_owners_$ns"), "elements_owners_$ns.elementId", '=', "elements_$ns.id") + ->where("entries_$ns.fieldId", $field->id) + ->where("elements_$ns.enabled", true) + ->whereNull("elements_$ns.dateDeleted") + ->whereColumn("elements_owners_$ns.ownerId", 'elements.id'); if ($value === 'not :empty:') { $value = ':notempty:'; } if ($value === ':empty:') { - return ['not exists', $existsQuery]; + return $query->whereNotExists($exists); } if ($value !== ':notempty:') { @@ -176,10 +172,10 @@ public static function queryCondition(array $instances, mixed $value, array &$pa $ids = array_map(fn ($id) => $id instanceof Entry ? $id->id : (int) $id, $ids); - $existsQuery->andWhere(["entries_$ns.id" => $ids]); + $exists->whereIn("entries_$ns.id", $ids); } - return ['exists', $existsQuery]; + return $query->whereExists($exists); } /** diff --git a/src/Field/Money.php b/src/Field/Money.php index 49c290d7b16..b8966a14cb7 100644 --- a/src/Field/Money.php +++ b/src/Field/Money.php @@ -10,7 +10,6 @@ use craft\fields\conditions\MoneyFieldConditionRule; use craft\gql\types\Money as MoneyType; use craft\helpers\Cp; -use craft\helpers\Db; use craft\validators\MoneyValidator; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface; @@ -19,6 +18,7 @@ use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Money as MoneyHelper; use GraphQL\Type\Definition\Type; +use Illuminate\Database\Query\Builder; use Money\Currencies\ISOCurrencies; use Money\Currency; use Money\Exception\ParserException; @@ -178,11 +178,11 @@ public static function dbType(): string * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): ?array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { $valueSql = self::valueSql($instances); - return Db::parseMoneyParam($valueSql, $instances[0]->currency, $value); + return $query->whereMoneyParam($valueSql, $instances[0]->currency, $value); } /** diff --git a/src/Field/Number.php b/src/Field/Number.php index eff7fc3b168..f9b997b63ee 100644 --- a/src/Field/Number.php +++ b/src/Field/Number.php @@ -9,15 +9,17 @@ use craft\elements\Entry; use craft\fields\conditions\NumberFieldConditionRule; use craft\gql\types\Number as NumberType; -use craft\helpers\Db; use craft\helpers\Localization; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface; use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; use CraftCms\Cms\Field\Contracts\SortableFieldInterface; use CraftCms\Cms\Support\Facades\I18N; +use CraftCms\Cms\Support\Query; use CraftCms\Cms\Translation\Locale; use GraphQL\Type\Definition\Type; +use Illuminate\Database\Query\Builder; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Throwable; use yii\base\InvalidArgumentException; @@ -69,22 +71,22 @@ public static function phpType(): string #[\Override] public static function dbType(): string { - if (Craft::$app->getDb()->getIsMysql()) { - return sprintf('%s(65,16)', Schema::TYPE_DECIMAL); + if (DB::getDriverName() === 'mysql') { + return sprintf('%s(65,16)', Query::TYPE_DECIMAL); } - return Schema::TYPE_DECIMAL; + return Query::TYPE_DECIMAL; } /** * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): ?array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { $valueSql = self::valueSql($instances); - return Db::parseNumericParam($valueSql, $value, columnType: self::dbType()); + return $query->whereNumericParam($valueSql, $value, columnType: self::dbType()); } /** diff --git a/src/Field/Range.php b/src/Field/Range.php index a5ec14d24e9..1d4d68c9daa 100644 --- a/src/Field/Range.php +++ b/src/Field/Range.php @@ -10,14 +10,13 @@ use craft\fields\conditions\NumberFieldConditionRule; use craft\gql\types\Number as NumberType; use craft\helpers\Cp; -use craft\helpers\Db; -use craft\helpers\Localization; use CraftCms\Cms\Field\Contracts\InlineEditableFieldInterface; use CraftCms\Cms\Field\Contracts\MergeableFieldInterface; use CraftCms\Cms\Field\Contracts\SortableFieldInterface; use CraftCms\Cms\Support\Facades\I18N; +use CraftCms\Cms\Support\Query; use GraphQL\Type\Definition\Type; -use yii\db\Schema; +use Illuminate\Database\Query\Builder; use function CraftCms\Cms\t; @@ -59,18 +58,18 @@ public static function phpType(): string #[\Override] public static function dbType(): string { - return Schema::TYPE_INTEGER; + return Query::TYPE_INTEGER; } /** * {@inheritdoc} */ #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): ?array + public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { $valueSql = self::valueSql($instances); - return Db::parseNumericParam($valueSql, $value, columnType: self::dbType()); + return $query->whereNumericParam($valueSql, $value, columnType: self::dbType()); } /** @@ -180,7 +179,7 @@ private function _normalizeNumber(mixed $value): int|float|null { // Was this submitted with a locale ID? if (isset($value['locale'])) { - $value = Localization::normalizeNumber($value['value'] ?? 0, $value['locale']); + $value = I18N::normalizeNumber($value['value'] ?? 0, $value['locale']); } if ($value === '') { diff --git a/src/Support/Query.php b/src/Support/Query.php index e1f72bbe5bc..c3b1810b262 100644 --- a/src/Support/Query.php +++ b/src/Support/Query.php @@ -7,8 +7,8 @@ use CraftCms\Cms\Database\QueryParam; use CraftCms\Cms\Support\Money as MoneyHelper; use DateTimeInterface; +use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Query\Builder; -use Illuminate\Database\Query\Expression; use Illuminate\Support\Facades\Date; use InvalidArgumentException; use Money\Money; @@ -84,98 +84,6 @@ private const array OPERATORS = ['not ', '!=', '<=', '>=', '<', '>', '=']; - /** - * Recursively applies Yii-style condition arrays to a Laravel query builder instance. - * - * This helper applies Yii condition arrays onto a Laravel Query Builder instance - * `where` / `orWhere` clauses. It supports nested logical groups (`and`, `or`), - * comparison operators (`=`, `!=`, `>`, `>=`, `<`, `<=`), negations (`not`), - * and automatic handling of `IN` / `NOT IN` conditions for array values. - * - * Example Yii-style input: - * - * [ - * 'and', - * ['>=', 'users.age', 18], - * ['or', - * ['status' => 'active'], - * ['not', ['status' => ['banned', 'suspended']]] - * ] - * ] - * - * This would translate to roughly: - * - * $query->where(function($q) { - * $q->where('users.age', '>=', 18) - * ->where(function($q2) { - * $q2->where('status', 'active') - * ->orWhereNotIn('status', ['banned', 'suspended']); - * }); - * }); - */ - public static function applyConditions(Builder $query, array|string|false|null $conditions): Builder - { - if ($conditions === false) { - return $query; - } - - // Condition is an operator-style array like ['>=', 'field', value] - if (is_array($conditions) && isset($conditions[0]) && is_string($conditions[0])) { - $operator = strtolower($conditions[0]); - - switch ($operator) { - case 'and': - case 'or': - // Group of conditions - $method = $operator === 'and' ? 'where' : 'orWhere'; - - return $query->$method(function ($q) use ($conditions) { - foreach (array_slice($conditions, 1) as $subCondition) { - self::applyConditions($q, $subCondition); - } - }); - - case 'not': - // NOT inside Yii usually means != or NOT IN - foreach ($conditions[1] as $field => $value) { - if (is_array($value)) { - return $query->whereNotIn(new Expression($field), $value); - } - - return $query->where(new Expression($field), '!=', $value); - } - - case '=': - case '!=': - case '>': - case '>=': - case '<': - case '<=': - case 'like': - return $query->where(new Expression($conditions[1]), $operator, $conditions[2]); - - default: - // If operator unknown, treat as field = value map. - return self::applyConditions($query, $conditions); - } - } - - // Handle "field => value" or "field => [values]" style arrays - foreach ($conditions as $field => $value) { - if (is_array($value)) { - // IN condition - $query->whereIn(new Expression($field), $value); - - continue; - } - - // Simple equals - $query->where(new Expression($field), '=', $value); - } - - return $query; - } - /** * Parses a query param value and applies it to a query builder. * @@ -193,7 +101,7 @@ public static function applyConditions(Builder $query, array|string|false|null $ * database values. (An “empty” value is either `NULL` or an empty string of text). * * @param Builder $query The query builder to apply the param to. - * @param string $column The database column that the param is targeting. + * @param string|Expression $column The database column that the param is targeting. * @param string|int|array $value The param value(s). * @param string $defaultOperator The default operator to apply to the values * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) @@ -202,11 +110,12 @@ public static function applyConditions(Builder $query, array|string|false|null $ */ public static function whereParam( Builder $query, - string $column, + string|Expression $column, mixed $param, string $defaultOperator = '=', bool $caseInsensitive = false, ?string $columnType = null, + string $boolean = 'and', ): Builder { $parsed = QueryParam::parse($param); @@ -356,7 +265,7 @@ public static function whereParam( if (! empty($notInVals)) { $query->whereNotIn($caseColumn, $notInVals, boolean: $boolean); } - }); + }, boolean: $boolean); return $query; } @@ -372,7 +281,7 @@ public static function whereParam( * - `'> x'`, `'>= x'`, `'< x'`, or `'<= x'`, or a combination of those * * @param Builder $query The query builder to apply the param to. - * @param string $column The database column that the param is targeting. + * @param string|Expression $column The database column that the param is targeting. * @param string|string[] $value The param value * @param string $defaultOperator The default operator to apply to the values * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) @@ -380,12 +289,13 @@ public static function whereParam( */ public static function whereNumericParam( Builder $query, - string $column, + string|Expression $column, mixed $value, string $defaultOperator = '=', ?string $columnType = self::TYPE_INTEGER, + string $boolean = 'and', ): Builder { - return self::whereParam($query, $column, $value, $defaultOperator, false, $columnType); + return self::whereParam($query, $column, $value, $defaultOperator, false, $columnType, $boolean); } /** @@ -393,16 +303,17 @@ public static function whereNumericParam( * and applies it to the Query builder. * * @param Builder $query The query builder to apply the param to. - * @param string $column The database column that the param is targeting. + * @param string|Expression $column The database column that the param is targeting. * @param string|array|DateTimeInterface $value The param value * @param string $defaultOperator The default operator to apply to the values * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) */ public static function whereDateParam( Builder $query, - string $column, + string|Expression $column, mixed $value, - string $defaultOperator = '=' + string $defaultOperator = '=', + string $boolean = 'and', ): Builder { $param = QueryParam::parse($value); @@ -431,7 +342,7 @@ public static function whereDateParam( $normalizedValues[] = $operator.$val; } - return self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME); + return self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME, $boolean); } /** @@ -439,7 +350,7 @@ public static function whereDateParam( * [[\yii\db\QueryInterface::where()]]-compatible condition. * * @param Builder $query The query builder to apply the param to. - * @param string $column The database column that the param is targeting. + * @param string|Expression $column The database column that the param is targeting. * @param string $currency The currency code to use for the money object. * @param string|array|Money $value The param value * @param string $defaultOperator The default operator to apply to the values @@ -447,10 +358,11 @@ public static function whereDateParam( */ public static function whereMoneyParam( Builder $query, - string $column, + string|Expression $column, string $currency, mixed $value, string $defaultOperator = '=', + string $boolean = 'and', ): Builder { $param = QueryParam::parse($value); @@ -479,7 +391,7 @@ public static function whereMoneyParam( $normalizedValues[] = $operator.$val->getAmount(); } - return self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME); + return self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME, $boolean); } /** @@ -497,17 +409,18 @@ public static function whereMoneyParam( * `null` values as well. * * @param Builder $query The query builder to apply the param to. - * @param string $column The database column that the param is targeting. + * @param string|Expression $column The database column that the param is targeting. * @param string|bool|null|array $value The param value * @param bool|null $defaultValue How `null` values should be treated * @param string $columnType The database column type the param is targeting */ public static function whereBooleanParam( Builder $query, - string $column, + string|Expression $column, mixed $value, ?bool $defaultValue = null, string $columnType = self::TYPE_BOOLEAN, + string $boolean = 'and', ): Builder { if (is_array($value)) { foreach ($value as $val) { @@ -550,7 +463,7 @@ public static function whereBooleanParam( $defaultValue === $value && $value !== null, fn (Builder $query) => $query->orWhereNull($column), ); - }); + }, boolean: $boolean); } /** diff --git a/tests/Database/Queries/Concerns/QueriesEagerlyTest.php b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php new file mode 100644 index 00000000000..f6a84cb9052 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php @@ -0,0 +1,3 @@ +getDb(); + $sql = $db->getQueryBuilder()->buildCondition($condition, $params); + + // Yii uses named parameters, Laravel uses positional + $sql = preg_replace('/:qp\d+/', '?', $sql); + + return $query->whereRaw($sql, array_values($params)); } } - -class_alias(\CraftCms\Cms\Field\Field::class, Field::class); diff --git a/yii2-adapter/legacy/elements/db/ElementQuery.php b/yii2-adapter/legacy/elements/db/ElementQuery.php index 8e9e5b24d74..70c1dfbe6d1 100644 --- a/yii2-adapter/legacy/elements/db/ElementQuery.php +++ b/yii2-adapter/legacy/elements/db/ElementQuery.php @@ -2833,11 +2833,21 @@ private function _applyCustomFieldParams(): void if (isset($fieldsByHandle[$handle])) { foreach ($fieldsByHandle[$handle] as $instances) { $firstInstance = $instances[0]; - $condition = $firstInstance::queryCondition($instances, $fieldAttributes->$handle, $params); - // aborting? - if ($condition === false) { - throw new QueryAbortedException(); + $query = $firstInstance->modifyQuery(\Illuminate\Support\Facades\DB::query(), $instances, $fieldAttributes->$handle); + $condition = $query->toSql(); + $params = collect($query->getBindings())->mapWithKeys(function($binding, $key) { + return [':lqp' . $key => $binding]; + })->all(); + + foreach ($params as $key => $binding) { + $condition = Str::replaceFirst('?', $key, $condition); + } + + $condition = Str::after($condition, 'select * where '); + + if (empty($condition)) { + continue; } if ($condition !== null) { From af3fd0af3cc76bf03dfee28ca260589eec5eb93d Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 13 Nov 2025 11:05:05 +0100 Subject: [PATCH 20/52] Eagerly --- .../Queries/Concerns/FormatsResults.php | 9 +- .../Queries/Concerns/HydratesElements.php | 35 ++- .../Queries/Concerns/QueriesCustomFields.php | 4 +- .../Queries/Concerns/QueriesEagerly.php | 2 +- .../Queries/Concerns/QueriesSites.php | 6 +- src/Database/Queries/ElementQuery.php | 123 +++++++++- src/Database/Queries/EntryQuery.php | 146 +++++++++++ src/Field/BaseRelationField.php | 104 ++++---- .../Concerns/QueriesCustomFieldsTest.php | 5 +- .../Queries/Concerns/QueriesEagerlyTest.php | 111 ++++++++- yii2-adapter/legacy/base/Field.php | 3 + .../legacy/fields/BaseRelationField.php | 226 +++++++++++++++++- yii2-adapter/legacy/services/Elements.php | 8 +- 13 files changed, 705 insertions(+), 77 deletions(-) diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php index 3bc7edb379f..956f340797e 100644 --- a/src/Database/Queries/Concerns/FormatsResults.php +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -146,6 +146,11 @@ public function fixedOrder(bool $value = true): static return $this; } + public function ids(): array + { + return $this->pluck('elements.id')->all(); + } + protected function initFormatsResults(): void { $this->query->orderBy(new OrderByPlaceholderExpression); @@ -272,8 +277,8 @@ private function orderBySearchResults(ElementQuery $elementQuery): void result: new Value($i), condition: new CondAnd( value1: new Equal('elements.id', new Value($elementId)), - value2: new Equal('elements_sites.siteId', new Value($siteId)) - ) + value2: new Equal('elements_sites.siteId', new Value($siteId)), + ), ); } diff --git a/src/Database/Queries/Concerns/HydratesElements.php b/src/Database/Queries/Concerns/HydratesElements.php index 4bf1a8461c5..5b28e921750 100644 --- a/src/Database/Queries/Concerns/HydratesElements.php +++ b/src/Database/Queries/Concerns/HydratesElements.php @@ -4,7 +4,9 @@ namespace CraftCms\Cms\Database\Queries\Concerns; +use Craft; use craft\base\ElementInterface; +use craft\base\ExpirableElementInterface; use craft\helpers\ElementHelper; use CraftCms\Cms\Database\Queries\Events\ElementHydrated; use CraftCms\Cms\Database\Queries\Events\ElementsHydrated; @@ -44,7 +46,38 @@ public function hydrate(array $items): Collection } return $row; - }))->map(fn (array $row) => $this->createElement($row)); + })) + ->map(fn (array $row) => $this->createElement($row)) + ->unless($this->asArray, function (Collection $elements) { + $elementsService = Craft::$app->getElements(); + + $allElements = $elements->all(); + + $elements = $elements->map(function (ElementInterface $element) use ($allElements, $elementsService) { + // Set the full query result on the element, in case it's needed for lazy eager loading + $element->elementQueryResult = $allElements; + + // If we're collecting cache info and the element is expirable, register its expiry date + if ( + $element instanceof ExpirableElementInterface && + $elementsService->getIsCollectingCacheInfo() && + ($expiryDate = $element->getExpiryDate()) !== null + ) { + $elementsService->setCacheExpiryDate($expiryDate); + } + + return $element; + }); + + ElementHelper::setNextPrevOnElements($elements); + + // Should we eager-load some elements onto these? + if ($this->with) { + $elementsService->eagerLoadElements($this->elementType, $elements, $this->with); + } + + return $elements; + }); if ($this->withProvisionalDrafts) { ElementHelper::swapInProvisionalDrafts($elements); diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index c98cbf64d1f..094644d6011 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -119,7 +119,7 @@ public function getFieldLayouts(): Collection */ private function addCustomFieldsToColumnMap(): void { - foreach ($this->customFields as $field) { + foreach ($this->customFields ?? [] as $field) { $dbTypes = $field::dbType(); if (is_null($dbTypes)) { @@ -155,7 +155,7 @@ private function addCustomFieldsToColumnMap(): void */ private function addGeneratedFieldsToColumnMap(): void { - foreach ($this->generatedFields as $field) { + foreach ($this->generatedFields ?? [] as $field) { if (empty($field['handle'] ?? '')) { continue; } diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php index 2c33b30b433..c8b4f397453 100644 --- a/src/Database/Queries/Concerns/QueriesEagerly.php +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -191,7 +191,7 @@ public function wasCountEagerLoaded(?string $alias = null): bool return $this->eagerLoadSourceElement->getEagerLoadedElementCount($planHandle) !== null; } - private function eagerLoad(bool $count = false, array $criteria = []): Collection|int|null + protected function eagerLoad(bool $count = false, array $criteria = []): Collection|int|null { if ( ! $this->eagerly || diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index cae240c1ef2..2f937645978 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -254,7 +254,11 @@ private function normalizeSiteId(ElementQuery $query): mixed } if ($query->siteId === '*') { - return Sites::getAllSiteIds(); + return Sites::getAllSiteIds()->all(); + } + + if ($query->siteId instanceof Collection) { + $query->siteId = $query->siteId->all(); } if (is_numeric($query->siteId) || Arr::isNumeric($query->siteId)) { diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 8fb6e51e14f..53be25cb77b 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -7,11 +7,13 @@ use Closure; use craft\base\Element; use craft\base\ElementInterface; +use craft\helpers\ElementHelper; use CraftCms\Cms\Database\Queries\Exceptions\ElementNotFoundException; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Typecast; +use CraftCms\Cms\Support\Utils; use Exception; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Concerns\BuildsQueries; @@ -28,6 +30,7 @@ use ReflectionMethod; use Tpetry\QueryExpressions\Function\Conditional\Coalesce; use Tpetry\QueryExpressions\Language\Alias; +use Twig\Markup; /** * @template TElement of ElementInterface @@ -37,7 +40,10 @@ class ElementQuery implements ElementQueryInterface { /** @use \Illuminate\Database\Concerns\BuildsQueries */ - use BuildsQueries { BuildsQueries::sole as baseSole; } + use BuildsQueries { + BuildsQueries::sole as baseSole; + BuildsQueries::first as baseFirst; + } use Concerns\CollectsCacheTags; use Concerns\FormatsResults; @@ -229,6 +235,18 @@ protected function initTraits(): void } } + /** + * Executes the query and renders the resulting elements using their partial templates. + * + * If no partial template exists for an element, its string representation will be output instead. + * + * @see ElementHelper::renderElements() + */ + public function render(array $variables = []): Markup + { + return ElementHelper::renderElements($this->all(), $variables); + } + /** * Find a model by its primary key. * @@ -377,6 +395,18 @@ public function sole(array|string $columns = ['*']): ElementInterface } } + public function first($columns = ['*']): ?ElementInterface + { + // Eagerly? + $eagerResult = $this->eagerLoad(criteria: ['limit' => 1]); + + if ($eagerResult !== null) { + return $eagerResult->first(); + } + + return $this->baseFirst($columns); + } + /** * Execute the query as a "select" statement. * @@ -399,7 +429,7 @@ public function getModels(array|string $columns = ['*']): array { $this->applyBeforeQueryCallbacks(); - return $this->hydrate( + return $this->eagerLoad()?->all() ?? $this->hydrate( $this->query->get($columns)->all() )->all(); } @@ -414,6 +444,18 @@ public function all(array|string $columns = ['*']): Collection|array return $this->get($columns); } + /** + * Execute the query as a "select" statement. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function collect(array|string $columns = ['*']): Collection + { + $this->asArray = false; + + return $this->get($columns); + } + public function one(array|string $columns = ['*']): ?ElementInterface { return $this->first($columns); @@ -429,6 +471,32 @@ public function pluck($column, $key = null): Collection|array ->when($this->asArray, fn (Collection $collection) => $collection->all()); } + public function count($columns = '*'): int + { + $eagerLoadedCount = $this->eagerLoad(count: true); + + if ($eagerLoadedCount !== null) { + return $eagerLoadedCount; + } + + return $this->query->count($columns); + } + + public function nth(int $n, array|string $columns = ['*']): ?ElementInterface + { + // Eagerly? + $eagerResult = $this->eagerLoad(criteria: [ + 'offset' => ($this->offset ?: 0) + $n, + 'limit' => 1, + ]); + + if ($eagerResult !== null) { + return $eagerResult->first(); + } + + return $this->query->skip(($this->offset ?: 0) + $n)->first($columns); + } + /** * Register a closure to be invoked after the query is executed. */ @@ -483,6 +551,13 @@ public function getSubQuery(): Builder return $this->subQuery; } + public function limit(?int $value): self + { + $this->subQuery->limit = $value; + + return $this; + } + /** * Get the "limit" value from the query or null if it's not set. */ @@ -491,6 +566,13 @@ public function getLimit(): mixed return $this->subQuery->getLimit(); } + public function offset(?int $value): self + { + $this->subQuery->offset = $value; + + return $this; + } + /** * Get the "offset" value from the query or null if it's not set. */ @@ -499,6 +581,43 @@ public function getOffset(): mixed return $this->subQuery->getOffset(); } + public function getWhereForColumn(string $column): ?array + { + return collect($this->subQuery->wheres) + ->firstWhere('column', $column); + } + + /** + * Returns an array of the current criteria attribute values. + */ + public function getCriteria(): array + { + return collect($this->criteriaAttributes()) + ->mapWithKeys(fn (string $name) => [$name => $this->{$name}]) + ->all(); + } + + /** + * Returns the query's criteria attributes. + * + * @return string[] + */ + public function criteriaAttributes(): array + { + $names = []; + + // By default, include all public, non-static properties that were defined by a sub class, and certain ones in this class + foreach (Utils::getPublicProperties($this, fn (\ReflectionProperty $property) => ! in_array($property->getName(), ['elementType', 'query', 'subQuery', 'customFields', 'asArray', 'with', 'eagerly'], true)) as $name => $value) { + $names[] = $name; + } + + foreach ($this->customFieldValues as $name => $value) { + $names[] = $name; + } + + return $names; + } + /** * Get the given macro by name. */ diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index a6663f49ce0..cd81547e866 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -5,9 +5,14 @@ namespace CraftCms\Cms\Database\Queries; use Closure; +use craft\db\Query; use craft\elements\Entry; +use craft\helpers\Db; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Section\Enums\SectionType; +use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Sections; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Date; @@ -15,6 +20,27 @@ final class EntryQuery extends ElementQuery { public ?bool $withStructure = true; + /** + * @var mixed The section ID(s) that the resulting entries must be in. + * --- + * ```php + * // fetch entries in the News section + * $entries = \craft\elements\Entry::find() + * ->section('news') + * ->all(); + * ``` + * ```twig + * {# fetch entries in the News section #} + * {% set entries = craft.entries() + * .section('news') + * .all() %} + * ``` + * + * @used-by section() + * @used-by sectionId() + */ + public mixed $sectionId = null; + public function __construct(array $config = []) { // Default status @@ -40,6 +66,82 @@ public function __construct(array $config = []) if (Cms::config()->staticStatuses) { $this->query->addSelect(['entries.status as status']); } + + $this->beforeQuery(function (self $query) { + $this->_normalizeSectionId(); + $this->_applySectionIdParam($query); + }); + } + + /** + * Narrows the query results based on the sections the entries belong to. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'foo'` | in a section with a handle of `foo`. + * | `'not foo'` | not in a section with a handle of `foo`. + * | `['foo', 'bar']` | in a section with a handle of `foo` or `bar`. + * | `['not', 'foo', 'bar']` | not in a section with a handle of `foo` or `bar`. + * | a [[Section|Section]] object | in a section represented by the object. + * | `'*'` | in any section. + * + * --- + * + * ```twig + * {# Fetch entries in the Foo section #} + * {% set {elements-var} = {twig-method} + * .section('foo') + * .all() %} + * ``` + * + * ```php + * // Fetch entries in the Foo section + * ${elements-var} = {php-method} + * ->section('foo') + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $sectionId + */ + public function section(mixed $value): static + { + // If the value is a section handle, swap it with the section + if (is_string($value) && ($section = Sections::getSectionByHandle($value))) { + $value = $section; + } + + if ($value instanceof \CraftCms\Cms\Section\Data\Section) { + // Special case for a single section, since we also want to capture the structure ID + $this->sectionId = [$value->id]; + if ($value->structureId) { + $this->structureId = $value->structureId; + } else { + $this->withStructure = false; + } + } elseif ($value === '*') { + $this->sectionId = Sections::getAllSectionIds()->values()->all(); + } elseif (Db::normalizeParam($value, function ($item) { + if (is_string($item)) { + $item = Sections::getSectionByHandle($item); + } + + return $item instanceof \CraftCms\Cms\Section\Data\Section ? $item->id : null; + })) { + $this->sectionId = $value; + } else { + $this->sectionId = new Query() + ->select(['id']) + ->from([\craft\db\Table::SECTIONS]) + ->where(Db::parseParam('handle', $value)) + ->column(); + } + + return $this; } protected function statusCondition(string $status): Closure @@ -77,4 +179,48 @@ protected function statusCondition(string $status): Closure default => parent::statusCondition($status), }; } + + /** + * Applies the 'sectionId' param to the query being prepared. + */ + private function _applySectionIdParam(self $entryQuery): void + { + if (! $this->sectionId) { + return; + } + + $entryQuery->subQuery->where('entries.sectionId', $this->sectionId); + + // Should we set the structureId param? + if ( + $entryQuery->withStructure !== false && + ! isset($entryQuery->structureId) && + count($entryQuery->sectionId) === 1 + ) { + $section = Sections::getSectionById(reset($entryQuery->sectionId)); + if ($section && $section->type === SectionType::Structure) { + $entryQuery->structureId = $section->structureId; + } else { + $entryQuery->withStructure = false; + } + } + } + + /** + * Normalizes the sectionId param to an array of IDs or null + */ + private function _normalizeSectionId(): void + { + if (empty($this->sectionId)) { + $this->sectionId = is_array($this->sectionId) ? [] : null; + } elseif (is_numeric($this->sectionId)) { + $this->sectionId = [$this->sectionId]; + } elseif (! is_array($this->sectionId) || ! Arr::isNumeric($this->sectionId)) { + $this->sectionId = new Query() + ->select(['id']) + ->from([\craft\db\Table::SECTIONS]) + ->where(Db::parseNumericParam('id', $this->sectionId)) + ->column(); + } + } } diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index f4d565d8cd1..06261180042 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -10,18 +10,13 @@ use craft\base\ElementInterface; use craft\base\NestedElementInterface; use craft\behaviors\CustomFieldBehavior; -use craft\behaviors\EventBehavior; -use craft\db\FixedOrderExpression; use craft\db\Query; -use craft\db\Table as DbTable; use craft\elements\conditions\ElementCondition; use craft\elements\conditions\ElementConditionInterface; use craft\elements\db\ElementQuery; use craft\elements\db\ElementQueryInterface; use craft\elements\db\ElementRelationParamParser; -use craft\elements\db\OrderByPlaceholderExpression; use craft\elements\ElementCollection; -use craft\events\CancelableEvent; use craft\events\ElementCriteriaEvent; use craft\fieldlayoutelements\CustomField; use craft\fields\conditions\RelationalFieldConditionRule; @@ -30,6 +25,7 @@ use craft\helpers\Queue; use craft\queue\jobs\LocalizeRelations; use craft\web\assets\cp\CpAsset; +use CraftCms\Cms\Database\Queries\EntryQuery; use CraftCms\Cms\Element\ElementSources; use CraftCms\Cms\Element\Events\DefineElementCriteria; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; @@ -46,6 +42,7 @@ use CraftCms\Cms\Support\Str; use GraphQL\Type\Definition\Type; use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Tpetry\QueryExpressions\Language\Alias; @@ -662,8 +659,8 @@ private static function _validateRelatedElement(ElementInterface $source, Elemen #[\Override] public function isValueEmpty(mixed $value, ElementInterface $element): bool { - /** @var ElementQueryInterface|ElementCollection $value */ - if ($value instanceof ElementQueryInterface) { + /** @var \CraftCms\Cms\Database\Queries\ElementQuery|ElementCollection $value */ + if ($value instanceof \CraftCms\Cms\Database\Queries\ElementQuery) { return ! $this->_all($value, $element)->exists(); } @@ -680,7 +677,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed // only save relations to elements in the current site. // (see https://github.com/craftcms/cms/issues/15459) if ( - $value instanceof ElementQueryInterface && + $value instanceof \CraftCms\Cms\Database\Queries\ElementQuery && $element?->propagating && $element->isNewForSite && ! $element->resaving && @@ -693,20 +690,20 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed ->ids(); } - if ($value instanceof ElementQueryInterface || $value instanceof ElementCollection) { + if ($value instanceof \CraftCms\Cms\Database\Queries\ElementQuery || $value instanceof ElementCollection) { return $value; } $class = static::elementType(); - /** @var ElementQuery $query */ - $query = $class::find() + // TODO: $class::find() + $query = new EntryQuery() ->siteId($this->targetSiteId($element)); if (is_array($value)) { $value = array_values(array_filter($value)); - $query->andWhere(['elements.id' => $value]); + $query->whereIn('elements.id', $value); if (! empty($value)) { - $query->orderBy([new FixedOrderExpression('elements.id', $value, Craft::$app->getDb())]); + $query->orderBy(new \CraftCms\Cms\Database\Expressions\FixedOrderExpression('elements.id', $value)); } } elseif ($value === null && $element?->id && $this->fetchRelationsFromDbTable($element)) { // If $value is null, the element + field haven’t been saved since updating to Craft 5.3+, @@ -726,46 +723,42 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed $relationsAlias = sprintf('relations_%s', Str::random(10)); - $query->attachBehavior(self::class, new EventBehavior([ - ElementQuery::EVENT_AFTER_PREPARE => function ( - CancelableEvent $event, - ElementQuery $query, - ) use ($element, $relationsAlias) { - if ($query->id === null) { - // Make these changes directly on the prepared queries, so `sortOrder` doesn't ever make it into - // the criteria. Otherwise, if the query ends up A) getting executed normally, then B) getting - // eager-loaded with eagerly(), the `orderBy` value referencing the join table will get applied - // to the eager-loading query and cause a SQL error. - foreach ([$query->query, $query->subQuery] as $q) { - $q->innerJoin( - [$relationsAlias => DbTable::RELATIONS], - [ - 'and', - "[[$relationsAlias.targetId]] = [[elements.id]]", - [ - "$relationsAlias.sourceId" => $element->id, - "$relationsAlias.fieldId" => $this->id, - ], - [ - 'or', - ["$relationsAlias.sourceSiteId" => null], - ["$relationsAlias.sourceSiteId" => $element->siteId], - ], - ], - ); - - if ( - $this->sortable && - ! $this->maintainHierarchy && - count($query->orderBy ?? []) === 1 && - ($query->orderBy[0] ?? null) instanceof OrderByPlaceholderExpression - ) { - $q->orderBy(["$relationsAlias.sortOrder" => SORT_ASC]); - } + $query->beforeQuery(function (\CraftCms\Cms\Database\Queries\ElementQuery $elementQuery) use ( + $element, + $relationsAlias) { + if ($elementQuery->id !== null) { + return; + } + + // Make these changes directly on the prepared queries, so `sortOrder` doesn't ever make it into + // the criteria. Otherwise, if the query ends up A) getting executed normally, then B) getting + // eager-loaded with eagerly(), the `orderBy` value referencing the join table will get applied + // to the eager-loading query and cause a SQL error. + /** @var \Illuminate\Database\Query\Builder $q */ + foreach ([$elementQuery->getQuery(), $elementQuery->getSubQuery()] as $q) { + $q->join( + new Alias(\CraftCms\Cms\Database\Table::RELATIONS, $relationsAlias), + function (JoinClause $join) use ($element, $relationsAlias) { + $join->whereColumn("$relationsAlias.targetId", 'elements.id') + ->where("$relationsAlias.sourceId", $element->id) + ->where("$relationsAlias.fieldId", $this->id) + ->where(function (JoinClause $join) use ($element, $relationsAlias) { + $join->whereNull("$relationsAlias.sourceSiteId") + ->orWhere("$relationsAlias.sourceSiteId", $element->siteId); + }); } + ); + + if ( + $this->sortable && + ! $this->maintainHierarchy && + count($query->orderBy ?? []) === 1 && + ($query->orderBy[0]['column'] ?? null) instanceof \CraftCms\Cms\Database\Expressions\OrderByPlaceholderExpression + ) { + $q->orderBy("$relationsAlias.sortOrder"); } - }, - ])); + } + }); } else { $query->id(false); } @@ -1195,10 +1188,10 @@ public function getRelationTargetIds(ElementInterface $element): array ) { $targetIds = $value->id ?: []; } elseif ( - isset($value->where['elements.id']) && - Arr::isNumeric($value->where['elements.id']) + ($where = $value->getWhereForColumn('elements.id')) !== null && + Arr::isNumeric($where['values']) ) { - $targetIds = $value->where['elements.id'] ?: []; + $targetIds = $where['values'] ?? []; } else { // just running $this->_all()->ids() will cause the query to get adjusted // see https://github.com/craftcms/cms/issues/14674 for details @@ -1742,7 +1735,7 @@ protected function availableSources(): array /** * Returns a clone of the element query value, prepped to include disabled and cross-site elements. */ - private function _all(ElementQueryInterface $query, ?ElementInterface $element = null): ElementQueryInterface + private function _all(\CraftCms\Cms\Database\Queries\ElementQuery $query, ?ElementInterface $element = null): \CraftCms\Cms\Database\Queries\ElementQuery { $clone = (clone $query) ->drafts(null) @@ -1751,6 +1744,7 @@ private function _all(ElementQueryInterface $query, ?ElementInterface $element = ->limit(null) ->unique() ->eagerly(false); + if ($element !== null) { $clone->preferSites([$this->targetSiteId($element)]); } diff --git a/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php b/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php index c6fc92f5e65..45fd9e3fcb5 100644 --- a/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php +++ b/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Field\PlainText; use CraftCms\Cms\FieldLayout\Models\FieldLayout; use CraftCms\Cms\Support\Facades\Fields; +use CraftCms\Cms\Support\Str; it('can query custom fields', function () { $field = Field::factory()->create([ @@ -20,11 +21,11 @@ 'config' => [ 'tabs' => [ [ - 'uid' => \Illuminate\Support\Str::uuid()->toString(), + 'uid' => Str::uuid()->toString(), 'name' => 'Tab 1', 'elements' => [ [ - 'uid' => \Illuminate\Support\Str::uuid()->toString(), + 'uid' => Str::uuid()->toString(), 'type' => CustomField::class, 'fieldUid' => $field->uid, ], diff --git a/tests/Database/Queries/Concerns/QueriesEagerlyTest.php b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php index f6a84cb9052..cf1fd4ed3dd 100644 --- a/tests/Database/Queries/Concerns/QueriesEagerlyTest.php +++ b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php @@ -1,3 +1,112 @@ create([ + 'handle' => 'entriesField', + 'type' => Entries::class, + ]); + + $fieldLayout = FieldLayout::create([ + 'type' => Entry::class, + 'config' => [ + 'tabs' => [ + [ + 'uid' => Str::uuid()->toString(), + 'name' => 'Tab 1', + 'elements' => [ + [ + 'uid' => Str::uuid()->toString(), + 'type' => CustomField::class, + 'fieldUid' => $field->uid, + ], + ], + ], + ], + ], + ]); + + $section = Section::factory()->create([ + 'handle' => 'blog', + ]); + + $entryType = EntryType::factory()->create([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + + CustomFieldBehavior::$fieldHandles[$field->handle] = true; + Fields::refreshFields(); + + $entryModels = EntryModel::factory(10)->create([ + 'sectionId' => $section->id, + 'typeId' => $entryType->id, + ]); + + foreach ($entryModels as $model) { + $model->element->update([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + + $relatedEntry = EntryModel::factory()->create(); + + $entryElement = entryQuery()->id($model->id)->firstOrFail(); + $entryElement->title = 'Test entry '.$model->id; + $entryElement->setFieldValue('entriesField', [$relatedEntry->id]); + Craft::$app->getElements()->saveElement($entryElement); + } + + Craft::$app->getElements()->invalidateAllCaches(); + + $this->entryModels = $entryModels; +}); + +test('with', function () { + $result = entryQuery()->id($this->entryModels->first()->id)->first(); + + expect($result->entriesField)->toBeInstanceOf(ElementQuery::class); + + $result = entryQuery()->id($this->entryModels->first()->id)->with('entriesField')->first(); + + expect($result->entriesField)->toBeInstanceOf(ElementCollection::class); +}); + +test('eagerly', function () { + $results = entryQuery()->section('blog')->get(); + + $queryCountWithoutEagerly = 0; + $queryCountWithEagerly = 0; + + \Illuminate\Support\Facades\DB::listen(function ($query) use (&$queryCountWithoutEagerly, &$queryCountWithEagerly) { + $queryCountWithoutEagerly++; + $queryCountWithEagerly++; + }); + + foreach ($results as $result) { + $result->entriesField->first(); + } + + $queryCountWithoutEagerlyResults = $queryCountWithoutEagerly; + + $results = entryQuery()->section('blog')->get(); + + $queryCountWithEagerly = 0; + + foreach ($results as $result) { + $result->entriesField->eagerly()->first(); + } + + expect($queryCountWithEagerly)->toBeLessThan($queryCountWithoutEagerlyResults); +}); diff --git a/yii2-adapter/legacy/base/Field.php b/yii2-adapter/legacy/base/Field.php index 82350e12542..52f9d0d5204 100644 --- a/yii2-adapter/legacy/base/Field.php +++ b/yii2-adapter/legacy/base/Field.php @@ -9,6 +9,9 @@ use Illuminate\Database\Query\Builder; +/** + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Field\Field} instead. + */ abstract class Field extends \CraftCms\Cms\Field\Field { public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder diff --git a/yii2-adapter/legacy/fields/BaseRelationField.php b/yii2-adapter/legacy/fields/BaseRelationField.php index 22b663c33f8..cbfa2a1327c 100644 --- a/yii2-adapter/legacy/fields/BaseRelationField.php +++ b/yii2-adapter/legacy/fields/BaseRelationField.php @@ -7,15 +7,227 @@ namespace craft\fields; -/** @phpstan-ignore-next-line **/ -if (false) { +use Craft; +use craft\base\ElementInterface; +use craft\behaviors\EventBehavior; +use craft\db\FixedOrderExpression; +use craft\db\Table as DbTable; +use craft\elements\db\ElementQuery; +use craft\elements\db\ElementQueryInterface; +use craft\elements\db\OrderByPlaceholderExpression; +use craft\elements\ElementCollection; +use craft\events\CancelableEvent; +use craft\helpers\ElementHelper; +use CraftCms\Cms\Element\ElementSources; +use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Structures; +use CraftCms\Cms\Support\Str; + +/** + * @deprecated 6.0.0 use {@see \CraftCms\Cms\Field\BaseRelationField} instead. + */ +abstract class BaseRelationField extends \CraftCms\Cms\Field\BaseRelationField +{ /** - * @since 3.0.0 - * @deprecated 6.0.0 use {@see \CraftCms\Cms\Field\BaseRelationField} instead. + * {@inheritdoc} */ - abstract class BaseRelationField + #[\Override] + public function isValueEmpty(mixed $value, ElementInterface $element): bool { + /** @var ElementQueryInterface|ElementCollection $value */ + if ($value instanceof ElementQueryInterface) { + return !$this->_all($value, $element)->exists(); + } + + return $value->isEmpty(); } -} -class_alias(\CraftCms\Cms\Field\BaseRelationField::class, BaseRelationField::class); + /** + * {@inheritdoc} + */ + #[\Override] + public function normalizeValue(mixed $value, ?ElementInterface $element): mixed + { + // If we're propagating a value, and we don't show the site menu, + // only save relations to elements in the current site. + // (see https://github.com/craftcms/cms/issues/15459) + if ( + $value instanceof ElementQueryInterface && + $element?->propagating && + $element->isNewForSite && + !$element->resaving && + !$element->isNewSite && + !$this->targetSiteId && + !$this->showSiteMenu + ) { + $value = $this->_all($value, $element) + ->siteId($this->targetSiteId($element)) + ->ids(); + } + + if ($value instanceof ElementQueryInterface || $value instanceof ElementCollection) { + return $value; + } + + $class = static::elementType(); + /** @var ElementQuery $query */ + $query = $class::find() + ->siteId($this->targetSiteId($element)); + + if (is_array($value)) { + $value = array_values(array_filter($value)); + $query->andWhere(['elements.id' => $value]); + if (!empty($value)) { + $query->orderBy([new FixedOrderExpression('elements.id', $value, Craft::$app->getDb())]); + } + } elseif ($value === null && $element?->id && $this->fetchRelationsFromDbTable($element)) { + // If $value is null, the element + field haven’t been saved since updating to Craft 5.3+, + // or since the field was added to the field layout, + // or the value was added to not first instance of the field. + // So only actually look at the `relations` table + // if this is the first instance of the field that was ever added to the field layout + // and none of the other instances (which would have been added later on) have a value. + if (!$this->allowMultipleSources && $this->source) { + $source = ElementHelper::findSource($class, $this->source, ElementSources::CONTEXT_FIELD); + + // Does the source specify any criteria attributes? + if (isset($source['criteria'])) { + Craft::configure($query, $source['criteria']); + } + } + + $relationsAlias = sprintf('relations_%s', Str::random(10)); + + $query->attachBehavior(self::class, new EventBehavior([ + ElementQuery::EVENT_AFTER_PREPARE => function( + CancelableEvent $event, + ElementQuery $query, + ) use ($element, $relationsAlias) { + if ($query->id === null) { + // Make these changes directly on the prepared queries, so `sortOrder` doesn't ever make it into + // the criteria. Otherwise, if the query ends up A) getting executed normally, then B) getting + // eager-loaded with eagerly(), the `orderBy` value referencing the join table will get applied + // to the eager-loading query and cause a SQL error. + foreach ([$query->query, $query->subQuery] as $q) { + $q->innerJoin( + [$relationsAlias => DbTable::RELATIONS], + [ + 'and', + "[[$relationsAlias.targetId]] = [[elements.id]]", + [ + "$relationsAlias.sourceId" => $element->id, + "$relationsAlias.fieldId" => $this->id, + ], + [ + 'or', + ["$relationsAlias.sourceSiteId" => null], + ["$relationsAlias.sourceSiteId" => $element->siteId], + ], + ], + ); + + if ( + $this->sortable && + !$this->maintainHierarchy && + count($query->orderBy ?? []) === 1 && + ($query->orderBy[0] ?? null) instanceof OrderByPlaceholderExpression + ) { + $q->orderBy(["$relationsAlias.sortOrder" => SORT_ASC]); + } + } + } + }, + ])); + } else { + $query->id(false); + } + + // Prepare the query for lazy eager loading, but only when element exists + if ($element !== null) { + $query->prepForEagerLoading($this->handle, $element); + } + + if ($this->allowLimit && $this->maxRelations) { + $query->limit($this->maxRelations); + } + + return $query; + } + + /** + * {@inheritdoc} + */ + public function getRelationTargetIds(ElementInterface $element): array + { + /** @var ElementQueryInterface|ElementCollection $value */ + $value = $element->getFieldValue($this->handle); + + // $value will be an element query and its $id will be set if we're saving new relations + if ($value instanceof ElementCollection) { + $targetIds = $value->map(fn(ElementInterface $element) => $element->id)->all(); + } elseif ( + is_array($value->id) && + Arr::isNumeric($value->id) + ) { + $targetIds = $value->id ?: []; + } elseif ( + isset($value->where['elements.id']) && + Arr::isNumeric($value->where['elements.id']) + ) { + $targetIds = $value->where['elements.id'] ?: []; + } else { + // just running $this->_all()->ids() will cause the query to get adjusted + // see https://github.com/craftcms/cms/issues/14674 for details + $targetIds = $this->_all($value, $element) + ->collect() + ->map(fn(ElementInterface $element) => $element->id) + ->all(); + } + + if ($this->maintainHierarchy) { + $class = static::elementType(); + + /** @var ElementInterface[] $structureElements */ + $structureElements = $class::find() + ->id($targetIds) + ->drafts(null) + ->revisions(null) + ->provisionalDrafts(null) + ->status(null) + ->site('*') + ->unique() + ->all(); + + // Fill in any gaps + Structures::fillGapsInElements($structureElements); + + // Enforce the branch limit + if ($this->branchLimit) { + Structures::applyBranchLimitToElements($structureElements, $this->branchLimit); + } + + $targetIds = array_map(fn(ElementInterface $element) => $element->id, $structureElements); + } + + return $targetIds; + } + + /** + * Returns a clone of the element query value, prepped to include disabled and cross-site elements. + */ + private function _all(ElementQueryInterface $query, ?ElementInterface $element = null): ElementQueryInterface + { + $clone = (clone $query) + ->drafts(null) + ->status(null) + ->site('*') + ->limit(null) + ->unique() + ->eagerly(false); + if ($element !== null) { + $clone->preferSites([$this->targetSiteId($element)]); + } + + return $clone; + } +} diff --git a/yii2-adapter/legacy/services/Elements.php b/yii2-adapter/legacy/services/Elements.php index b0ef4180725..ba37ee2031a 100644 --- a/yii2-adapter/legacy/services/Elements.php +++ b/yii2-adapter/legacy/services/Elements.php @@ -3354,14 +3354,16 @@ public function createEagerLoadingPlans(string|array $with): array * @param ElementInterface[] $elements The root element models that should be updated with the eager-loaded elements * @param array|string|EagerLoadPlan[] $with Dot-delimited paths of the elements that should be eager-loaded into the root elements */ - public function eagerLoadElements(string $elementType, array $elements, array|string $with): void + public function eagerLoadElements(string $elementType, array|Collection $elements, array|string $with): void { + $elements = collect($elements); + // Bail if there aren't even any elements - if (empty($elements)) { + if ($elements->isEmpty()) { return; } - $elementsBySite = Collection::make($elements) + $elementsBySite = $elements ->groupBy(fn(ElementInterface $element) => $element->siteId) ->map(fn(Collection $elements) => $elements->all()) ->all(); From 9a3407693b21459978059b7743370b4cb945f3a5 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 13 Nov 2025 11:35:14 +0100 Subject: [PATCH 21/52] Fix count --- .../Queries/Concerns/QueriesSections.php | 160 ++++++++++++++++++ src/Database/Queries/ElementQuery.php | 3 +- src/Database/Queries/EntryQuery.php | 149 +--------------- tests/TestCase.php | 1 + yii2-adapter/legacy/Craft.php | 3 +- 5 files changed, 168 insertions(+), 148 deletions(-) create mode 100644 src/Database/Queries/Concerns/QueriesSections.php diff --git a/src/Database/Queries/Concerns/QueriesSections.php b/src/Database/Queries/Concerns/QueriesSections.php new file mode 100644 index 00000000000..a6ed30aea74 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesSections.php @@ -0,0 +1,160 @@ +section('news') + * ->all(); + * ``` + * ```twig + * {# fetch entries in the News section #} + * {% set entries = craft.entries() + * .section('news') + * .all() %} + * ``` + * + * @used-by section() + * @used-by sectionId() + */ + public mixed $sectionId = null; + + protected function initQueriesSections(): void + { + $this->beforeQuery(function (EntryQuery $entryQuery) { + $this->normalizeSectionId(); + $this->applySectionIdParam($entryQuery); + }); + } + + /** + * Narrows the query results based on the sections the entries belong to. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'foo'` | in a section with a handle of `foo`. + * | `'not foo'` | not in a section with a handle of `foo`. + * | `['foo', 'bar']` | in a section with a handle of `foo` or `bar`. + * | `['not', 'foo', 'bar']` | not in a section with a handle of `foo` or `bar`. + * | a [[Section|Section]] object | in a section represented by the object. + * | `'*'` | in any section. + * + * --- + * + * ```twig + * {# Fetch entries in the Foo section #} + * {% set {elements-var} = {twig-method} + * .section('foo') + * .all() %} + * ``` + * + * ```php + * // Fetch entries in the Foo section + * ${elements-var} = {php-method} + * ->section('foo') + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $sectionId + */ + public function section(mixed $value): static + { + // If the value is a section handle, swap it with the section + if (is_string($value) && ($section = Sections::getSectionByHandle($value))) { + $value = $section; + } + + if ($value instanceof Section) { + // Special case for a single section, since we also want to capture the structure ID + $this->sectionId = [$value->id]; + if ($value->structureId) { + $this->structureId = $value->structureId; + } else { + $this->withStructure = false; + } + } elseif ($value === '*') { + $this->sectionId = Sections::getAllSectionIds()->values()->all(); + } elseif (DbHelper::normalizeParam($value, function ($item) { + if (is_string($item)) { + $item = Sections::getSectionByHandle($item); + } + + return $item instanceof Section ? $item->id : null; + })) { + $this->sectionId = $value; + } else { + $this->sectionId = DB::table(Table::SECTIONS) + ->whereParam('handle', $value) + ->pluck('id') + ->all(); + } + + return $this; + } + + /** + * Applies the 'sectionId' param to the query being prepared. + */ + private function applySectionIdParam(EntryQuery $entryQuery): void + { + if (! $this->sectionId) { + return; + } + + $entryQuery->subQuery->where('entries.sectionId', $this->sectionId); + + // Should we set the structureId param? + if ( + $entryQuery->withStructure !== false && + ! isset($entryQuery->structureId) && + count($entryQuery->sectionId) === 1 + ) { + $section = Sections::getSectionById(reset($entryQuery->sectionId)); + if ($section && $section->type === SectionType::Structure) { + $entryQuery->structureId = $section->structureId; + } else { + $entryQuery->withStructure = false; + } + } + } + + /** + * Normalizes the sectionId param to an array of IDs or null + */ + private function normalizeSectionId(): void + { + $this->sectionId = match (true) { + empty($this->sectionId) => is_array($this->sectionId) ? [] : null, + is_numeric($this->sectionId) => [$this->sectionId], + ! is_array($this->sectionId) || ! Arr::isNumeric($this->sectionId) => DB::table(Table::SECTIONS) + ->whereNumericParam('id', $this->sectionId) + ->value('id'), + default => $this->sectionId, + }; + } +} diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 53be25cb77b..069705da593 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -104,7 +104,6 @@ class ElementQuery implements ElementQueryInterface 'aggregate', 'average', 'avg', - 'count', 'dd', 'ddrawsql', 'doesntexist', @@ -473,6 +472,8 @@ public function pluck($column, $key = null): Collection|array public function count($columns = '*'): int { + $this->applyBeforeQueryCallbacks(); + $eagerLoadedCount = $this->eagerLoad(count: true); if ($eagerLoadedCount !== null) { diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index cd81547e866..2ef4f9a9b64 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -5,41 +5,18 @@ namespace CraftCms\Cms\Database\Queries; use Closure; -use craft\db\Query; use craft\elements\Entry; -use craft\helpers\Db; use CraftCms\Cms\Cms; +use CraftCms\Cms\Database\Queries\Concerns\QueriesSections; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Section\Enums\SectionType; -use CraftCms\Cms\Support\Arr; -use CraftCms\Cms\Support\Facades\Sections; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Date; final class EntryQuery extends ElementQuery { - public ?bool $withStructure = true; + use QueriesSections; - /** - * @var mixed The section ID(s) that the resulting entries must be in. - * --- - * ```php - * // fetch entries in the News section - * $entries = \craft\elements\Entry::find() - * ->section('news') - * ->all(); - * ``` - * ```twig - * {# fetch entries in the News section #} - * {% set entries = craft.entries() - * .section('news') - * .all() %} - * ``` - * - * @used-by section() - * @used-by sectionId() - */ - public mixed $sectionId = null; + public ?bool $withStructure = true; public function __construct(array $config = []) { @@ -66,82 +43,6 @@ public function __construct(array $config = []) if (Cms::config()->staticStatuses) { $this->query->addSelect(['entries.status as status']); } - - $this->beforeQuery(function (self $query) { - $this->_normalizeSectionId(); - $this->_applySectionIdParam($query); - }); - } - - /** - * Narrows the query results based on the sections the entries belong to. - * - * Possible values include: - * - * | Value | Fetches entries… - * | - | - - * | `'foo'` | in a section with a handle of `foo`. - * | `'not foo'` | not in a section with a handle of `foo`. - * | `['foo', 'bar']` | in a section with a handle of `foo` or `bar`. - * | `['not', 'foo', 'bar']` | not in a section with a handle of `foo` or `bar`. - * | a [[Section|Section]] object | in a section represented by the object. - * | `'*'` | in any section. - * - * --- - * - * ```twig - * {# Fetch entries in the Foo section #} - * {% set {elements-var} = {twig-method} - * .section('foo') - * .all() %} - * ``` - * - * ```php - * // Fetch entries in the Foo section - * ${elements-var} = {php-method} - * ->section('foo') - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - * - * @uses $sectionId - */ - public function section(mixed $value): static - { - // If the value is a section handle, swap it with the section - if (is_string($value) && ($section = Sections::getSectionByHandle($value))) { - $value = $section; - } - - if ($value instanceof \CraftCms\Cms\Section\Data\Section) { - // Special case for a single section, since we also want to capture the structure ID - $this->sectionId = [$value->id]; - if ($value->structureId) { - $this->structureId = $value->structureId; - } else { - $this->withStructure = false; - } - } elseif ($value === '*') { - $this->sectionId = Sections::getAllSectionIds()->values()->all(); - } elseif (Db::normalizeParam($value, function ($item) { - if (is_string($item)) { - $item = Sections::getSectionByHandle($item); - } - - return $item instanceof \CraftCms\Cms\Section\Data\Section ? $item->id : null; - })) { - $this->sectionId = $value; - } else { - $this->sectionId = new Query() - ->select(['id']) - ->from([\craft\db\Table::SECTIONS]) - ->where(Db::parseParam('handle', $value)) - ->column(); - } - - return $this; } protected function statusCondition(string $status): Closure @@ -179,48 +80,4 @@ protected function statusCondition(string $status): Closure default => parent::statusCondition($status), }; } - - /** - * Applies the 'sectionId' param to the query being prepared. - */ - private function _applySectionIdParam(self $entryQuery): void - { - if (! $this->sectionId) { - return; - } - - $entryQuery->subQuery->where('entries.sectionId', $this->sectionId); - - // Should we set the structureId param? - if ( - $entryQuery->withStructure !== false && - ! isset($entryQuery->structureId) && - count($entryQuery->sectionId) === 1 - ) { - $section = Sections::getSectionById(reset($entryQuery->sectionId)); - if ($section && $section->type === SectionType::Structure) { - $entryQuery->structureId = $section->structureId; - } else { - $entryQuery->withStructure = false; - } - } - } - - /** - * Normalizes the sectionId param to an array of IDs or null - */ - private function _normalizeSectionId(): void - { - if (empty($this->sectionId)) { - $this->sectionId = is_array($this->sectionId) ? [] : null; - } elseif (is_numeric($this->sectionId)) { - $this->sectionId = [$this->sectionId]; - } elseif (! is_array($this->sectionId) || ! Arr::isNumeric($this->sectionId)) { - $this->sectionId = new Query() - ->select(['id']) - ->from([\craft\db\Table::SECTIONS]) - ->where(Db::parseNumericParam('id', $this->sectionId)) - ->column(); - } - } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 83d66350cae..0b556e2710e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -109,6 +109,7 @@ protected function migrateDatabases() protected function getEnvironmentSetUp($app) { File::cleanDirectory(config_path('craft/project')); + File::cleanDirectory(storage_path('runtime/compiled_classes')); if (! file_exists(__DIR__.'/.env')) { return; diff --git a/yii2-adapter/legacy/Craft.php b/yii2-adapter/legacy/Craft.php index 0bc26ce7f40..65b7810ffae 100644 --- a/yii2-adapter/legacy/Craft.php +++ b/yii2-adapter/legacy/Craft.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Fields; +use CraftCms\Cms\Shared\Models\Info; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Env; use CraftCms\Cms\Support\Str; @@ -227,7 +228,7 @@ private static function _autoloadCustomFieldBehavior(): void return; } - if (!static::$app->getIsInstalled()) { + if (!Info::isInstalled()) { // Just load an empty CustomFieldBehavior into memory self::_generateCustomFieldBehavior([], [], [], null, false, true); return; From 3ef2fbbeaa614dff060243dc7fcb3f56f95e5865 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 13 Nov 2025 12:04:33 +0100 Subject: [PATCH 22/52] Tests for QueriesSections --- .../Queries/Concerns/QueriesSections.php | 63 +++++++++++++++---- src/Database/Queries/EntryQuery.php | 8 ++- .../Queries/Concerns/FormatsResultsTest.php | 5 +- .../Queries/Concerns/QueriesSectionsTest.php | 23 +++++++ 4 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 tests/Database/Queries/Concerns/QueriesSectionsTest.php diff --git a/src/Database/Queries/Concerns/QueriesSections.php b/src/Database/Queries/Concerns/QueriesSections.php index a6ed30aea74..6d95165017e 100644 --- a/src/Database/Queries/Concerns/QueriesSections.php +++ b/src/Database/Queries/Concerns/QueriesSections.php @@ -42,7 +42,7 @@ trait QueriesSections protected function initQueriesSections(): void { $this->beforeQuery(function (EntryQuery $entryQuery) { - $this->normalizeSectionId(); + $this->normalizeSectionId($entryQuery); $this->applySectionIdParam($entryQuery); }); } @@ -117,16 +117,56 @@ public function section(mixed $value): static return $this; } + /** + * Narrows the query results based on the sections the entries belong to, per the sections’ IDs. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `1` | in a section with an ID of 1. + * | `'not 1'` | not in a section with an ID of 1. + * | `[1, 2]` | in a section with an ID of 1 or 2. + * | `['not', 1, 2]` | not in a section with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch entries in the section with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .sectionId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch entries in the section with an ID of 1 + * ${elements-var} = {php-method} + * ->sectionId(1) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $sectionId + */ + public function sectionId(mixed $value): static + { + $this->sectionId = $value; + + return $this; + } + /** * Applies the 'sectionId' param to the query being prepared. */ private function applySectionIdParam(EntryQuery $entryQuery): void { - if (! $this->sectionId) { + if (! $entryQuery->sectionId) { return; } - $entryQuery->subQuery->where('entries.sectionId', $this->sectionId); + $entryQuery->subQuery->whereIn('entries.sectionId', $entryQuery->sectionId); // Should we set the structureId param? if ( @@ -146,15 +186,16 @@ private function applySectionIdParam(EntryQuery $entryQuery): void /** * Normalizes the sectionId param to an array of IDs or null */ - private function normalizeSectionId(): void + private function normalizeSectionId(EntryQuery $entryQuery): void { - $this->sectionId = match (true) { - empty($this->sectionId) => is_array($this->sectionId) ? [] : null, - is_numeric($this->sectionId) => [$this->sectionId], - ! is_array($this->sectionId) || ! Arr::isNumeric($this->sectionId) => DB::table(Table::SECTIONS) - ->whereNumericParam('id', $this->sectionId) - ->value('id'), - default => $this->sectionId, + $entryQuery->sectionId = match (true) { + empty($entryQuery->sectionId) => is_array($entryQuery->sectionId) ? [] : null, + is_numeric($entryQuery->sectionId) => [$entryQuery->sectionId], + ! is_array($entryQuery->sectionId) || ! Arr::isNumeric($entryQuery->sectionId) => DB::table(Table::SECTIONS) + ->whereNumericParam('id', $entryQuery->sectionId) + ->pluck('id') + ->all(), + default => $entryQuery->sectionId, }; } } diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 2ef4f9a9b64..95e743e21d2 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -16,7 +16,13 @@ final class EntryQuery extends ElementQuery { use QueriesSections; - public ?bool $withStructure = true; + /** + * {@inheritdoc} + */ + protected array $defaultOrderBy = [ + 'entries.postDate' => SORT_DESC, + 'elements.id' => SORT_DESC, + ]; public function __construct(array $config = []) { diff --git a/tests/Database/Queries/Concerns/FormatsResultsTest.php b/tests/Database/Queries/Concerns/FormatsResultsTest.php index a50cfa73ad5..968de7192da 100644 --- a/tests/Database/Queries/Concerns/FormatsResultsTest.php +++ b/tests/Database/Queries/Concerns/FormatsResultsTest.php @@ -39,7 +39,7 @@ expect( collect($query->getQuery()->orders) - ->where('column', 'elements.dateCreated') + ->where('column', 'entries.postDate') ->where('direction', 'desc') ->first() )->not()->toBeNull(); @@ -56,7 +56,7 @@ expect( collect($query->getQuery()->orders) - ->where('column', 'elements.dateCreated') + ->where('column', 'entries.postDate') ->where('direction', 'desc') ->first() )->toBeNull(); @@ -83,6 +83,7 @@ it('adds a sort on structureelements.lft when the element has structures', function () { $query = entryQuery(); + $query->withStructure(); $query->applyBeforeQueryCallbacks(); expect( diff --git a/tests/Database/Queries/Concerns/QueriesSectionsTest.php b/tests/Database/Queries/Concerns/QueriesSectionsTest.php new file mode 100644 index 00000000000..09b7a504729 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesSectionsTest.php @@ -0,0 +1,23 @@ +create(); + $entry2 = Entry::factory()->create(); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->sectionId($entry1->sectionId)->count())->toBe(1); + expect(entryQuery()->sectionId($entry2->sectionId)->count())->toBe(1); + expect(entryQuery()->sectionId([$entry1->sectionId, $entry2->sectionId])->count())->toBe(2); + expect(entryQuery()->sectionId(implode(', ', [$entry1->sectionId, $entry2->sectionId]))->count())->toBe(2); + + expect(entryQuery()->section('*')->count())->toBe(2); + expect(entryQuery()->section($entry1->section->handle)->count())->toBe(1); + expect(entryQuery()->section($entry2->section->handle)->count())->toBe(1); + expect(entryQuery()->section([$entry1->section->handle, $entry2->section->handle])->count())->toBe(2); + expect(entryQuery()->section(implode(', ', [$entry1->section->handle, $entry2->section->handle]))->count())->toBe(2); + + expect(entryQuery()->section('not '.$entry1->section->handle)->count())->toBe(1); + expect(entryQuery()->section('not '.$entry2->section->handle)->count())->toBe(1); +}); From 5038f419aae5aa7236163c3a5ce012ee9fa216d2 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 13 Nov 2025 12:09:14 +0100 Subject: [PATCH 23/52] Move logic in beforeQuery --- .../Concerns/QueriesRelatedElements.php | 38 +++---- .../Concerns/QueriesUniqueElements.php | 101 +++++++++--------- 2 files changed, 71 insertions(+), 68 deletions(-) diff --git a/src/Database/Queries/Concerns/QueriesRelatedElements.php b/src/Database/Queries/Concerns/QueriesRelatedElements.php index b38cbc1adef..17831e5da13 100644 --- a/src/Database/Queries/Concerns/QueriesRelatedElements.php +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -4,11 +4,11 @@ namespace CraftCms\Cms\Database\Queries\Concerns; -use craft\base\FieldInterface; use craft\db\QueryAbortedException; use craft\elements\db\ElementRelationParamParser; +use CraftCms\Cms\Database\Queries\ElementQuery; +use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Support\Arr; -use Illuminate\Database\Query\Builder; use RuntimeException; /** @@ -44,52 +44,52 @@ protected function initQueriesRelatedElements(): void private function applyRelatedToParam(): void { - if (! $this->relatedTo) { - return; - } + $this->beforeQuery(function (ElementQuery $elementQuery) { + if (! $elementQuery->relatedTo) { + return; + } - $this->beforeQuery(function (Builder $query) { $parser = new ElementRelationParamParser([ - 'fields' => $this->customFields ? Arr::keyBy( - $this->customFields, + 'fields' => $elementQuery->customFields ? Arr::keyBy( + $elementQuery->customFields, fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, ) : [], ]); - $condition = $parser->parse($this->relatedTo, $this->siteId !== '*' ? $this->siteId : null); + $condition = $parser->parse($elementQuery->relatedTo, $elementQuery->siteId !== '*' ? $elementQuery->siteId : null); if ($condition === false) { throw new QueryAbortedException; } - $this->subQuery->where($condition); + $elementQuery->subQuery->where($condition); }); } private function applyNotRelatedToParam(): void { - if (! $this->notRelatedTo) { - return; - } + $this->beforeQuery(function (ElementQuery $elementQuery) { + if (! $elementQuery->notRelatedTo) { + return; + } - $this->beforeQuery(function () { - $notRelatedToParam = $this->notRelatedTo; + $notRelatedToParam = $elementQuery->notRelatedTo; $parser = new ElementRelationParamParser([ - 'fields' => $this->customFields ? Arr::keyBy( - $this->customFields, + 'fields' => $elementQuery->customFields ? Arr::keyBy( + $elementQuery->customFields, fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, ) : [], ]); - $condition = $parser->parse($notRelatedToParam, $this->siteId !== '*' ? $this->siteId : null); + $condition = $parser->parse($notRelatedToParam, $elementQuery->siteId !== '*' ? $elementQuery->siteId : null); if ($condition === false) { // just don't modify the query return; } - $this->subQuery->whereNot($condition); + $elementQuery->subQuery->whereNot($condition); }); } diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php index 3bb4d111549..08d843e0022 100644 --- a/src/Database/Queries/Concerns/QueriesUniqueElements.php +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Database\Queries\Concerns; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Support\Facades\Sites; use Illuminate\Support\Facades\DB; @@ -37,57 +38,59 @@ trait QueriesUniqueElements protected function initQueriesUniqueElements(): void { - if ( - ! $this->unique || - ! Sites::isMultiSite(false, true) || - ( - $this->siteId && - (! is_array($this->siteId) || count($this->siteId) === 1) - ) - ) { - return; - } - - if (! $this->preferSites) { - $preferSites = [Sites::getCurrentSite()->id]; - } else { - $preferSites = []; - foreach ($this->preferSites as $preferSite) { - if (is_numeric($preferSite)) { - $preferSites[] = $preferSite; - } elseif ($site = Sites::getSiteByHandle($preferSite)) { - $preferSites[] = $site->id; + $this->beforeQuery(function (ElementQuery $elementQuery) { + if ( + ! $elementQuery->unique || + ! Sites::isMultiSite(false, true) || + ( + $elementQuery->siteId && + (! is_array($elementQuery->siteId) || count($elementQuery->siteId) === 1) + ) + ) { + return; + } + + if (! $elementQuery->preferSites) { + $preferSites = [Sites::getCurrentSite()->id]; + } else { + $preferSites = []; + foreach ($elementQuery->preferSites as $preferSite) { + if (is_numeric($preferSite)) { + $preferSites[] = $preferSite; + } elseif ($site = Sites::getSiteByHandle($preferSite)) { + $preferSites[] = $site->id; + } } } - } - - $cases = []; - - foreach ($preferSites as $index => $siteId) { - $cases[] = new CaseRule(new Value($index), new Equal('elements_sites.siteId', new Value($siteId))); - } - - $caseGroup = new CaseGroup($cases, new Value(count($preferSites))); - - $subSelectSql = $this->subQuery->clone() - ->select(['elements_sites.id']) - ->whereColumn('subElements.id', 'tmpElements.id') - ->orderBy($caseGroup) - ->orderBy('elements_sites.id') - ->offset(0) - ->limit(1) - ->toRawSql(); - - // `elements` => `subElements` - $qElements = DB::getTablePrefix().'Concerns'.Table::ELEMENTS; - $qSubElements = DB::getTablePrefix().'.subElements'; - $qTmpElements = DB::getTablePrefix().'.tmpElements'; - $q = $qElements[0]; - $subSelectSql = str_replace("$qElements.", "$qSubElements.", $subSelectSql); - $subSelectSql = str_replace("$q $qElements", "$q $qSubElements", $subSelectSql); - $subSelectSql = str_replace($qTmpElements, $qElements, $subSelectSql); - - $this->subQuery->where(DB::raw("elements_sites.id = ($subSelectSql)")); + + $cases = []; + + foreach ($preferSites as $index => $siteId) { + $cases[] = new CaseRule(new Value($index), new Equal('elements_sites.siteId', new Value($siteId))); + } + + $caseGroup = new CaseGroup($cases, new Value(count($preferSites))); + + $subSelectSql = $elementQuery->subQuery->clone() + ->select(['elements_sites.id']) + ->whereColumn('subElements.id', 'tmpElements.id') + ->orderBy($caseGroup) + ->orderBy('elements_sites.id') + ->offset(0) + ->limit(1) + ->toRawSql(); + + // `elements` => `subElements` + $qElements = DB::getTablePrefix().'Concerns'.Table::ELEMENTS; + $qSubElements = DB::getTablePrefix().'.subElements'; + $qTmpElements = DB::getTablePrefix().'.tmpElements'; + $q = $qElements[0]; + $subSelectSql = str_replace("$qElements.", "$qSubElements.", $subSelectSql); + $subSelectSql = str_replace("$q $qElements", "$q $qSubElements", $subSelectSql); + $subSelectSql = str_replace($qTmpElements, $qElements, $subSelectSql); + + $elementQuery->subQuery->where(DB::raw("elements_sites.id = ($subSelectSql)")); + }); } /** From d7103ba35074aa5633bddaba216bef4e5b689c6d Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 13 Nov 2025 12:16:05 +0100 Subject: [PATCH 24/52] QueriesEntryTypes --- .../Queries/Concerns/QueriesEntryTypes.php | 168 ++++++++++++++++++ src/Database/Queries/EntryQuery.php | 2 + .../Concerns/QueriesEntryTypesTest.php | 23 +++ 3 files changed, 193 insertions(+) create mode 100644 src/Database/Queries/Concerns/QueriesEntryTypes.php create mode 100644 tests/Database/Queries/Concerns/QueriesEntryTypesTest.php diff --git a/src/Database/Queries/Concerns/QueriesEntryTypes.php b/src/Database/Queries/Concerns/QueriesEntryTypes.php new file mode 100644 index 00000000000..1de77157fec --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesEntryTypes.php @@ -0,0 +1,168 @@ +section('news') + * ->type('article') + * ->all(); + * ``` + * ```twig{4} + * {# fetch entries in the News section #} + * {% set entries = craft.entries() + * .section('news') + * .type('article') + * .all() %} + * ``` + * + * @used-by EntryQuery::type() + * @used-by typeId() + */ + public mixed $typeId = null; + + protected function initQueriesEntryTypes(): void + { + $this->beforeQuery(function (EntryQuery $entryQuery) { + $this->normalizeTypeId($entryQuery); + + if ($entryQuery->typeId === []) { + return; + } + + if (! $entryQuery->typeId) { + return; + } + + $entryQuery->subQuery->whereIn('entries.typeId', $this->typeId); + }); + } + + /** + * Narrows the query results based on the entries’ entry types. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'foo'` | of a type with a handle of `foo`. + * | `'not foo'` | not of a type with a handle of `foo`. + * | `['foo', 'bar']` | of a type with a handle of `foo` or `bar`. + * | `['not', 'foo', 'bar']` | not of a type with a handle of `foo` or `bar`. + * | an [[EntryType|EntryType]] object | of a type represented by the object. + * + * --- + * + * ```twig + * {# Fetch entries in the Foo section with a Bar entry type #} + * {% set {elements-var} = {twig-method} + * .section('foo') + * .type('bar') + * .all() %} + * ``` + * + * ```php + * // Fetch entries in the Foo section with a Bar entry type + * ${elements-var} = {php-method} + * ->section('foo') + * ->type('bar') + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $typeId + */ + public function type(mixed $value): static + { + if (DbHelper::normalizeParam($value, function ($item) { + if (is_string($item)) { + $item = EntryTypes::getEntryTypeByHandle($item); + } + + return $item instanceof EntryType ? $item->id : null; + })) { + $this->typeId = $value; + } else { + $this->typeId = DB::table(Table::ENTRYTYPES) + ->whereParam('handle', $value) + ->pluck('id') + ->all(); + } + + return $this; + } + + /** + * Narrows the query results based on the entries’ entry types, per the types’ IDs. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `1` | of a type with an ID of 1. + * | `'not 1'` | not of a type with an ID of 1. + * | `[1, 2]` | of a type with an ID of 1 or 2. + * | `['not', 1, 2]` | not of a type with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch entries of the entry type with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .typeId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch entries of the entry type with an ID of 1 + * ${elements-var} = {php-method} + * ->typeId(1) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $typeId + */ + public function typeId(mixed $value): static + { + $this->typeId = $value; + + return $this; + } + + private function normalizeTypeId(EntryQuery $entryQuery): void + { + $entryQuery->typeId = match (true) { + empty($entryQuery->typeId) => is_array($entryQuery->typeId) ? [] : null, + is_numeric($entryQuery->typeId) => [$entryQuery->typeId], + ! is_array($entryQuery->typeId) || ! Arr::isNumeric($entryQuery->typeId) => DB::table(Table::ENTRYTYPES) + ->whereNumericParam('id', $entryQuery->typeId) + ->pluck('id') + ->all(), + default => $entryQuery->typeId, + }; + } +} diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 95e743e21d2..37176809e4f 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -7,6 +7,7 @@ use Closure; use craft\elements\Entry; use CraftCms\Cms\Cms; +use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryTypes; use CraftCms\Cms\Database\Queries\Concerns\QueriesSections; use CraftCms\Cms\Database\Table; use Illuminate\Database\Query\Builder; @@ -14,6 +15,7 @@ final class EntryQuery extends ElementQuery { + use QueriesEntryTypes; use QueriesSections; /** diff --git a/tests/Database/Queries/Concerns/QueriesEntryTypesTest.php b/tests/Database/Queries/Concerns/QueriesEntryTypesTest.php new file mode 100644 index 00000000000..eb0162bc75e --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesEntryTypesTest.php @@ -0,0 +1,23 @@ +create(); + $entry2 = Entry::factory()->create(); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->typeId($entry1->typeId)->count())->toBe(1); + expect(entryQuery()->typeId($entry2->typeId)->count())->toBe(1); + expect(entryQuery()->typeId([$entry1->typeId, $entry2->typeId])->count())->toBe(2); + expect(entryQuery()->typeId(implode(', ', [$entry1->typeId, $entry2->typeId]))->count())->toBe(2); + + expect(entryQuery()->type('*')->count())->toBe(2); + expect(entryQuery()->type($entry1->entryType->handle)->count())->toBe(1); + expect(entryQuery()->type($entry2->entryType->handle)->count())->toBe(1); + expect(entryQuery()->type([$entry1->entryType->handle, $entry2->entryType->handle])->count())->toBe(2); + expect(entryQuery()->type(implode(', ', [$entry1->entryType->handle, $entry2->entryType->handle]))->count())->toBe(2); + + expect(entryQuery()->type('not '.$entry1->entryType->handle)->count())->toBe(1); + expect(entryQuery()->type('not '.$entry2->entryType->handle)->count())->toBe(1); +}); From 88237db2547c6ae72e85fad628bd7e91ee465b8b Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 13 Nov 2025 15:48:56 +0100 Subject: [PATCH 25/52] Fix columns not being able to load --- yii2-adapter/legacy/elements/db/ElementQuery.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/yii2-adapter/legacy/elements/db/ElementQuery.php b/yii2-adapter/legacy/elements/db/ElementQuery.php index 70c1dfbe6d1..fc3193f61b7 100644 --- a/yii2-adapter/legacy/elements/db/ElementQuery.php +++ b/yii2-adapter/legacy/elements/db/ElementQuery.php @@ -32,10 +32,12 @@ use CraftCms\Cms\Database\QueryParam; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Fields; +use CraftCms\Cms\Shared\Models\Info; use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Site\Exceptions\SiteNotFoundException; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Sites; +use CraftCms\Cms\Support\Facades\Updates; use CraftCms\Cms\Support\Json; use CraftCms\Cms\Support\Str; use Illuminate\Support\Collection; @@ -1607,7 +1609,7 @@ public function prepare($builder): Query } } catch (SiteNotFoundException $e) { // Fail silently if Craft isn't installed yet or is in the middle of updating - if (Craft::$app->getIsInstalled() && !app(\CraftCms\Cms\Updates\Updates::class)->isCraftUpdatePending()) { + if (Info::isInstalled() && !Updates::isCraftUpdatePending()) { /** @noinspection PhpUnhandledExceptionInspection */ throw $e; } @@ -2656,9 +2658,9 @@ protected function joinElementTable(string $table): void $this->_joinedElementTable = true; // Add element table cols to the column map - foreach (Craft::$app->getDb()->getTableSchema($table)->columns as $column) { - if (!isset($this->_columnMap[$column->name])) { - $this->_columnMap[$column->name] = "$alias.$column->name"; + foreach (\Illuminate\Support\Facades\Schema::getColumns(Table::withoutYiiPlaceholder($table)) as $column) { + if (!isset($this->_columnMap[$column['name']])) { + $this->_columnMap[$column['name']] = "$alias." . $column['name']; } } } From 5350964204851b4f851e2a225fac15c4b218ff34 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 13 Nov 2025 15:49:16 +0100 Subject: [PATCH 26/52] Add QueriesAuthors, QueriesEntryDates & QueriesNestedElements --- .../Queries/Concerns/QueriesAuthors.php | 262 +++++++++++++ .../Queries/Concerns/QueriesEntryDates.php | 266 +++++++++++++ .../Concerns/QueriesNestedElements.php | 355 ++++++++++++++++++ src/Database/Queries/EntryQuery.php | 308 +++++++++++++++ 4 files changed, 1191 insertions(+) create mode 100644 src/Database/Queries/Concerns/QueriesAuthors.php create mode 100644 src/Database/Queries/Concerns/QueriesEntryDates.php create mode 100644 src/Database/Queries/Concerns/QueriesNestedElements.php diff --git a/src/Database/Queries/Concerns/QueriesAuthors.php b/src/Database/Queries/Concerns/QueriesAuthors.php new file mode 100644 index 00000000000..fed57125c83 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesAuthors.php @@ -0,0 +1,262 @@ +authorGroup('authors') + * ->all(); + * ``` + * ```twig + * {# fetch entries authored by people in the Authors group #} + * {% set entries = craft.entries() + * .authorGroup('authors') + * .all() %} + * ``` + * + * @used-by authorGroup() + * @used-by authorGroupId() + */ + public mixed $authorGroupId = null; + + protected function initQueriesAuthors(): void + { + $this->beforeQuery(function (EntryQuery $query) { + if ($this->authorGroupId === []) { + return; + } + + if (Edition::get() === Edition::Solo) { + return; + } + + $this->applyAuthorId($query); + $this->applyAuthorGroupId($query); + }); + } + + private function applyAuthorId(EntryQuery $query): void + { + if (! $query->authorId) { + return; + } + + // Checking multiple authors? + if ( + is_array($query->authorId) && + is_string(reset($query->authorId)) && + strtolower(reset($query->authorId)) === 'and' + ) { + $authorIdChecks = array_slice($query->authorId, 1); + } else { + $authorIdChecks = [$query->authorId]; + } + + foreach ($authorIdChecks as $authorIdCheck) { + if ( + is_array($authorIdCheck) && + is_string(reset($authorIdCheck)) && + strtolower(reset($authorIdCheck)) === 'not' + ) { + $authorIdOperator = 'whereNotExists'; + array_shift($authorIdCheck); + if (empty($authorIdCheck)) { + continue; + } + } else { + $authorIdOperator = 'whereExists'; + } + + $query->subQuery->$authorIdOperator( + DB::table(Table::ENTRIES_AUTHORS, 'entries_authors') + ->whereColumn('entries.id', 'entries_authors.entryId') + ->whereNumericParam('authorId', $authorIdCheck), + ); + } + } + + private function applyAuthorGroupId(EntryQuery $query): void + { + if (! $query->authorGroupId) { + return; + } + + $query->subQuery->whereExists( + DB::table(Table::ENTRIES_AUTHORS, 'entries_authors') + ->join(new Alias(Table::USERGROUPS_USERS, 'usergroups_users'), 'usergroups_users.userId', '=', + 'entries_authors.authorId') + ->whereColumn('entries.id', 'entries_authors.entryId') + ->whereNumericParam('usergroups_users.groupId', $query->authorGroupId), + ); + } + + /** + * Narrows the query results based on the entries’ author ID(s). + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `1` | with an author with an ID of 1. + * | `'not 1'` | not with an author with an ID of 1. + * | `[1, 2]` | with an author with an ID of 1 or 2. + * | `['and', 1, 2]` | with authors with IDs of 1 and 2. + * | `['not', 1, 2]` | not with an author with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch entries with an author with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .authorId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch entries with an author with an ID of 1 + * ${elements-var} = {php-method} + * ->authorId(1) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $authorId + */ + public function authorId(mixed $value): static + { + $this->authorId = $value; + + return $this; + } + + /** + * Narrows the query results based on the user group the entries’ authors belong to. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'foo'` | with an author in a group with a handle of `foo`. + * | `'not foo'` | not with an author in a group with a handle of `foo`. + * | `['foo', 'bar']` | with an author in a group with a handle of `foo` or `bar`. + * | `['not', 'foo', 'bar']` | not with an author in a group with a handle of `foo` or `bar`. + * | a [[UserGroup|UserGroup]] object | with an author in a group represented by the object. + * | an array of [[UserGroup|UserGroup]] objects | with an author in a group represented by the objects. + * + * --- + * + * ```twig + * {# Fetch entries with an author in the Foo user group #} + * {% set {elements-var} = {twig-method} + * .authorGroup('foo') + * .all() %} + * ``` + * + * ```php + * // Fetch entries with an author in the Foo user group + * ${elements-var} = {php-method} + * ->authorGroup('foo') + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $authorGroupId + */ + public function authorGroup(mixed $value): static + { + if ($value instanceof UserGroup) { + $this->authorGroupId = $value->id; + + return $this; + } + + if (is_iterable($value)) { + $collection = collect($value); + if ($collection->every(fn ($v) => $v instanceof UserGroup)) { + $this->authorGroupId = $collection->pluck('id')->all(); + + return $this; + } + } + + if ($value !== null) { + $this->authorGroupId = DB::table(Table::USERGROUPS) + ->whereParam('handle', $value) + ->pluck('id') + ->all(); + } else { + $this->authorGroupId = null; + } + + return $this; + } + + /** + * Narrows the query results based on the user group the entries’ authors belong to, per the groups’ IDs. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `1` | with an author in a group with an ID of 1. + * | `'not 1'` | not with an author in a group with an ID of 1. + * | `[1, 2]` | with an author in a group with an ID of 1 or 2. + * | `['not', 1, 2]` | not with an author in a group with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch entries with an author in a group with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .authorGroupId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch entries with an author in a group with an ID of 1 + * ${elements-var} = {php-method} + * ->authorGroupId(1) + * ->all(); + * ``` + * + * @param mixed $value The property value + * @return static self reference + * + * @uses $authorGroupId + */ + public function authorGroupId(mixed $value): static + { + $this->authorGroupId = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/QueriesEntryDates.php b/src/Database/Queries/Concerns/QueriesEntryDates.php new file mode 100644 index 00000000000..84a0b78c784 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesEntryDates.php @@ -0,0 +1,266 @@ +postDate(['and', '>= 2018-01-01', '< 2019-01-01']) + * ->all(); + * ``` + * ```twig + * {# fetch entries written in 2018 #} + * {% set entries = craft.entries() + * .postDate(['and', '>= 2018-01-01', '< 2019-01-01']) + * .all() %} + * ``` + * + * @used-by postDate() + */ + public mixed $postDate = null; + + /** + * @var mixed The maximum Post Date that resulting entries can have. + * --- + * ```php + * // fetch entries written before 4/4/2018 + * $entries = \craft\elements\Entry::find() + * ->before('2018-04-04') + * ->all(); + * ``` + * ```twig + * {# fetch entries written before 4/4/2018 #} + * {% set entries = craft.entries() + * .before('2018-04-04') + * .all() %} + * ``` + * + * @used-by before() + */ + public mixed $before = null; + + /** + * @var mixed The minimum Post Date that resulting entries can have. + * --- + * ```php + * // fetch entries written in the last 7 days + * $entries = \craft\elements\Entry::find() + * ->after((new \DateTime())->modify('-7 days')) + * ->all(); + * ``` + * ```twig + * {# fetch entries written in the last 7 days #} + * {% set entries = craft.entries() + * .after(now|date_modify('-7 days')) + * .all() %} + * ``` + * + * @used-by after() + */ + public mixed $after = null; + + /** + * @var mixed The Expiry Date that the resulting entries must have. + * + * @used-by expiryDate() + */ + public mixed $expiryDate = null; + + protected function initQueriesEntryDates(): void + { + $this->beforeQuery(function (EntryQuery $query) { + if ($query->postDate) { + $query->subQuery->whereDateParam('entries.postDate', $query->postDate); + } else { + if ($query->before) { + $query->subQuery->whereDateParam('entries.postDate', $query->before, '<'); + } + if ($query->after) { + $query->subQuery->whereDateParam('entries.postDate', $query->after, '>='); + } + } + + if ($query->expiryDate) { + $query->subQuery->whereDateParam('entries.expiryDate', $query->expiryDate); + } + }); + } + + /** + * Narrows the query results based on the entries’ post dates. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'>= 2018-04-01'` | that were posted on or after 2018-04-01. + * | `'< 2018-05-01'` | that were posted before 2018-05-01. + * | `['and', '>= 2018-04-04', '< 2018-05-01']` | that were posted between 2018-04-01 and 2018-05-01. + * | `now`/`today`/`tomorrow`/`yesterday` | that were posted at midnight of the specified relative date. + * + * --- + * + * ```twig + * {# Fetch entries posted last month #} + * {% set start = date('first day of last month')|atom %} + * {% set end = date('first day of this month')|atom %} + * + * {% set {elements-var} = {twig-method} + * .postDate(['and', ">= #{start}", "< #{end}"]) + * .all() %} + * ``` + * + * ```php + * // Fetch entries posted last month + * $start = (new \DateTime('first day of last month'))->format(\DateTime::ATOM); + * $end = (new \DateTime('first day of this month'))->format(\DateTime::ATOM); + * + * ${elements-var} = {php-method} + * ->postDate(['and', ">= {$start}", "< {$end}"]) + * ->all(); + * ``` + * + * @uses $postDate + */ + public function postDate(mixed $value): static + { + $this->postDate = $value; + + return $this; + } + + /** + * Narrows the query results to only entries that were posted before a certain date. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'2018-04-01'` | that were posted before 2018-04-01. + * | a [[\DateTime|DateTime]] object | that were posted before the date represented by the object. + * | `now`/`today`/`tomorrow`/`yesterday` | that were posted before midnight of specified relative date. + * + * --- + * + * ```twig + * {# Fetch entries posted before this month #} + * {% set firstDayOfMonth = date('first day of this month') %} + * + * {% set {elements-var} = {twig-method} + * .before(firstDayOfMonth) + * .all() %} + * ``` + * + * ```php + * // Fetch entries posted before this month + * $firstDayOfMonth = new \DateTime('first day of this month'); + * + * ${elements-var} = {php-method} + * ->before($firstDayOfMonth) + * ->all(); + * ``` + * + * @uses $before + */ + public function before(mixed $value): static + { + $this->before = $value; + + return $this; + } + + /** + * Narrows the query results to only entries that were posted on or after a certain date. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'2018-04-01'` | that were posted on or after 2018-04-01. + * | a [[\DateTime|DateTime]] object | that were posted on or after the date represented by the object. + * | `now`/`today`/`tomorrow`/`yesterday` | that were posted on or after midnight of the specified relative date. + * + * --- + * + * ```twig + * {# Fetch entries posted this month #} + * {% set firstDayOfMonth = date('first day of this month') %} + * + * {% set {elements-var} = {twig-method} + * .after(firstDayOfMonth) + * .all() %} + * ``` + * + * ```php + * // Fetch entries posted this month + * $firstDayOfMonth = new \DateTime('first day of this month'); + * + * ${elements-var} = {php-method} + * ->after($firstDayOfMonth) + * ->all(); + * ``` + * + * @uses $after + */ + public function after(mixed $value): static + { + $this->after = $value; + + return $this; + } + + /** + * Narrows the query results based on the entries’ expiry dates. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `':empty:'` | that don’t have an expiry date. + * | `':notempty:'` | that have an expiry date. + * | `'>= 2020-04-01'` | that will expire on or after 2020-04-01. + * | `'< 2020-05-01'` | that will expire before 2020-05-01 + * | `['and', '>= 2020-04-04', '< 2020-05-01']` | that will expire between 2020-04-01 and 2020-05-01. + * | `now`/`today`/`tomorrow`/`yesterday` | that expire at midnight of the specified relative date. + * + * --- + * + * ```twig + * {# Fetch entries expiring this month #} + * {% set nextMonth = date('first day of next month')|atom %} + * + * {% set {elements-var} = {twig-method} + * .expiryDate("< #{nextMonth}") + * .all() %} + * ``` + * + * ```php + * // Fetch entries expiring this month + * $nextMonth = (new \DateTime('first day of next month'))->format(\DateTime::ATOM); + * + * ${elements-var} = {php-method} + * ->expiryDate("< {$nextMonth}") + * ->all(); + * ``` + * + * @uses $expiryDate + */ + public function expiryDate(mixed $value): static + { + $this->expiryDate = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/QueriesNestedElements.php b/src/Database/Queries/Concerns/QueriesNestedElements.php new file mode 100644 index 00000000000..0f140bbb073 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesNestedElements.php @@ -0,0 +1,355 @@ +beforeQuery(function (ElementQuery $elementQuery) { + /** @var ElementQuery&QueriesNestedElements $elementQuery */ + $this->normalizeNestedElementParams($elementQuery); + + if ($elementQuery->fieldId === false || $elementQuery->primaryOwnerId === false || $elementQuery->ownerId === false) { + throw new QueryAbortedException; + } + + if (empty($elementQuery->fieldId) && empty($elementQuery->ownerId) && empty($elementQuery->primaryOwnerId)) { + return; + } + + $elementQuery->query->addSelect([ + 'elements_owners.ownerId', + 'elements_owners.sortOrder', + ]); + + $joinClause = function (JoinClause $join) use ($elementQuery) { + $join->on('elements_owners.elementId', '=', 'elements.id') + ->when( + $elementQuery->ownerId, + function (JoinClause $join) use ($elementQuery) { + $join->where('elements_owners.ownerId', $elementQuery->ownerId); + }, + function (JoinClause $join) { + $join->whereColumn('elements_owners.ownerId', $this->getPrimaryOwnerIdColumn()); + }, + ); + }; + + // Join in the elements_owners table + $elementQuery->query->join(new Alias(Table::ELEMENTS_OWNERS, 'elements_owners'), $joinClause); + $elementQuery->subQuery->join(new Alias(Table::ELEMENTS_OWNERS, 'elements_owners'), $joinClause); + + if ($elementQuery->fieldId) { + $elementQuery->subQuery->where($this->getFieldIdColumn(), $elementQuery->fieldId); + } + + if ($elementQuery->primaryOwnerId) { + $this->subQuery->where($this->getPrimaryOwnerIdColumn(), $elementQuery->primaryOwnerId); + } + + // Ignore revision/draft blocks by default + $allowOwnerDrafts = $elementQuery->allowOwnerDrafts ?? ($elementQuery->id || $elementQuery->primaryOwnerId || $elementQuery->ownerId); + $allowOwnerRevisions = $elementQuery->allowOwnerRevisions ?? ($elementQuery->id || $elementQuery->primaryOwnerId || $elementQuery->ownerId); + + if (! $allowOwnerDrafts || ! $allowOwnerRevisions) { + $this->subQuery->join( + new Alias(Table::ELEMENTS, 'owners'), + fn (JoinClause $join) => $join->when( + $elementQuery->ownerId, + fn (JoinClause $join) => $join->on('owners.id', '=', 'elements_owners.ownerId'), + fn (JoinClause $join) => $join->on('owners.id', '=', $this->getPrimaryOwnerIdColumn()), + ) + ); + + if (! $allowOwnerDrafts) { + $elementQuery->subQuery->whereNull('owners.draftId'); + } + + if (! $allowOwnerRevisions) { + $elementQuery->subQuery->whereNull('owners.revisionId'); + } + } + + $this->defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; + }); + } + + /** + * {@inheritdoc} + * + * @uses $fieldId + */ + public function field(mixed $value): static + { + if (Db::normalizeParam($value, function ($item) { + if (is_string($item)) { + $item = Fields::getFieldByHandle($item); + } + + return $item instanceof ElementContainerFieldInterface ? $item->id : null; + })) { + $this->fieldId = $value; + } else { + $this->fieldId = false; + } + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $fieldId + */ + public function fieldId(mixed $value): static + { + $this->fieldId = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $primaryOwnerId + */ + public function primaryOwnerId(mixed $value): static + { + $this->primaryOwnerId = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $primaryOwnerId + */ + public function primaryOwner(ElementInterface $primaryOwner): static + { + $this->primaryOwnerId = [$primaryOwner->id]; + $this->siteId = $primaryOwner->siteId; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $ownerId + */ + public function ownerId(mixed $value): static + { + $this->ownerId = $value; + $this->_owner = null; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $ownerId + */ + public function owner(ElementInterface $owner): static + { + $this->ownerId = [$owner->id]; + $this->siteId = $owner->siteId; + $this->_owner = $owner; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $allowOwnerDrafts + */ + public function allowOwnerDrafts(?bool $value = true): static + { + $this->allowOwnerDrafts = $value; + + return $this; + } + + /** + * {@inheritdoc} + * + * @uses $allowOwnerRevisions + */ + public function allowOwnerRevisions(?bool $value = true): static + { + $this->allowOwnerRevisions = $value; + + return $this; + } + + /** + * {@inheritdoc} + */ + protected function cacheTags(): array + { + $tags = []; + + if ($this->fieldId) { + foreach ($this->fieldId as $fieldId) { + $tags[] = "field:$fieldId"; + } + } + + if ($this->primaryOwnerId) { + foreach ($this->primaryOwnerId as $ownerId) { + $tags[] = "element::$ownerId"; + } + } + + if ($this->ownerId) { + foreach ($this->ownerId as $ownerId) { + $tags[] = "element::$ownerId"; + } + } + + return $tags; + } + + /** + * {@inheritdoc} + */ + protected function fieldLayouts(): Collection + { + $this->normalizeFieldId($this); + + if ($this->fieldId) { + $fieldLayouts = []; + + foreach ($this->fieldId as $fieldId) { + $field = app(Fields::class)->getFieldById($fieldId); + if ($field instanceof ElementContainerFieldInterface) { + foreach ($field->getFieldLayoutProviders() as $provider) { + $fieldLayouts[] = $provider->getFieldLayout(); + } + } + } + + return collect($fieldLayouts); + } + + return parent::fieldLayouts(); + } + + /** + * Normalizes the `fieldId`, `primaryOwnerId`, and `ownerId` params. + */ + private function normalizeNestedElementParams(ElementQuery $query): void + { + /** @var ElementQuery&QueriesNestedElements $query */ + $this->normalizeFieldId($query); + $this->primaryOwnerId = $this->normalizeOwnerId($query->primaryOwnerId); + $this->ownerId = $this->normalizeOwnerId($query->ownerId); + } + + /** + * Normalizes the fieldId param to an array of IDs or null + */ + private function normalizeFieldId(ElementQuery $query): void + { + /** @var ElementQuery&QueriesNestedElements $query */ + if ($query->fieldId === false) { + return; + } + + if (empty($query->fieldId)) { + $query->fieldId = is_array($query->fieldId) ? [] : null; + } elseif (is_numeric($query->fieldId)) { + $query->fieldId = [$query->fieldId]; + } elseif (! is_array($query->fieldId) || ! Arr::isNumeric($query->fieldId)) { + $query->fieldId = \Illuminate\Support\Facades\DB::table(Table::FIELDS) + ->whereNumericParam('id', $query->fieldId) + ->pluck('id') + ->all(); + } + } + + /** + * Normalizes the primaryOwnerId param to an array of IDs or null + * + * @return int[]|null|false + */ + private function normalizeOwnerId(mixed $value): array|null|false + { + if (empty($value)) { + return null; + } + if (is_numeric($value)) { + return [$value]; + } + if (! is_array($value) || ! Arr::isNumeric($value)) { + return false; + } + + return $value; + } +} diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 37176809e4f..4374d4d36f5 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -5,17 +5,35 @@ namespace CraftCms\Cms\Database\Queries; use Closure; +use craft\db\Query; +use craft\db\QueryAbortedException; use craft\elements\Entry; use CraftCms\Cms\Cms; +use CraftCms\Cms\Database\Queries\Concerns\QueriesAuthors; +use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryDates; use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryTypes; +use CraftCms\Cms\Database\Queries\Concerns\QueriesNestedElements; use CraftCms\Cms\Database\Queries\Concerns\QueriesSections; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Section\Enums\SectionType; +use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\EntryTypes; +use CraftCms\Cms\Support\Facades\Sections; use Illuminate\Database\Query\Builder; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; +use Tpetry\QueryExpressions\Language\Alias; final class EntryQuery extends ElementQuery { + use QueriesAuthors; + use QueriesEntryDates; use QueriesEntryTypes; + use QueriesNestedElements { + cacheTags as nestedTraitCacheTags; + fieldLayouts as nestedTraitFieldLayouts; + } use QueriesSections; /** @@ -26,6 +44,39 @@ final class EntryQuery extends ElementQuery 'elements.id' => SORT_DESC, ]; + /** + * @var mixed The reference code(s) used to identify the element(s). + * + * This property is set when accessing elements via their reference tags, e.g. `{entry:section/slug}`. + * + * @used-by ElementQuery::ref() + */ + public mixed $ref = null; + + protected function getFieldIdColumn(): string + { + return 'entries.fieldId'; + } + + protected function getPrimaryOwnerIdColumn(): string + { + return 'entries.primaryOwnerId'; + } + + /** + * @var bool|null Whether to only return entries that the user has permission to view. + * + * @used-by editable() + */ + public ?bool $editable = null; + + /** + * @var bool|null Whether to only return entries that the user has permission to save. + * + * @used-by savable() + */ + public ?bool $savable = null; + public function __construct(array $config = []) { // Default status @@ -51,6 +102,12 @@ public function __construct(array $config = []) if (Cms::config()->staticStatuses) { $this->query->addSelect(['entries.status as status']); } + + $this->beforeQuery(function (self $query) { + $this->applyAuthParam($query, $query->editable, 'viewEntries', 'viewPeerEntries', 'viewPeerEntryDrafts'); + $this->applyAuthParam($query, $query->savable, 'saveEntries', 'savePeerEntries', 'savePeerEntryDrafts'); + $this->applyRefParam($query); + }); } protected function statusCondition(string $status): Closure @@ -88,4 +145,255 @@ protected function statusCondition(string $status): Closure default => parent::statusCondition($status), }; } + + /** + * Sets the [[$editable]] property. + * + * @param bool|null $value The property value (defaults to true) + * @return static self reference + * + * @uses $editable + */ + public function editable(?bool $value = true): self + { + $this->editable = $value; + + return $this; + } + + /** + * Sets the [[$savable]] property. + * + * @param bool|null $value The property value (defaults to true) + * @return self self reference + * + * @uses $savable + */ + public function savable(?bool $value = true): self + { + $this->savable = $value; + + return $this; + } + + /** + * Narrows the query results based on the entries’ statuses. + * + * Possible values include: + * + * | Value | Fetches entries… + * | - | - + * | `'live'` _(default)_ | that are live. + * | `'pending'` | that are pending (enabled with a Post Date in the future). + * | `'expired'` | that are expired (enabled with an Expiry Date in the past). + * | `'disabled'` | that are disabled. + * | `['live', 'pending']` | that are live or pending. + * | `['not', 'live', 'pending']` | that are not live or pending. + * + * --- + * + * ```twig + * {# Fetch disabled entries #} + * {% set {elements-var} = {twig-method} + * .status('disabled') + * .all() %} + * ``` + * + * ```php + * // Fetch disabled entries + * ${elements-var} = {element-class}::find() + * ->status('disabled') + * ->all(); + * ``` + */ + public function status(array|string|null $value): static + { + /** @var static */ + return parent::status($value); + } + + /** + * {@inheritdoc} + * + * @uses $ref + */ + public function ref($value): self + { + $this->ref = $value; + + return $this; + } + + /** + * @throws QueryAbortedException + */ + private function applyAuthParam( + self $query, + ?bool $value, + string $permissionPrefix, + string $peerPermissionPrefix, + string $peerDraftPermissionPrefix, + ): void { + if ($value === null) { + return; + } + + $user = Auth::user(); + + if (! $user) { + throw new QueryAbortedException; + } + + $sections = Sections::getAllSections(); + + if ($sections->isEmpty()) { + return; + } + + $query->subQuery->where(function (Builder $query) use ($value, $peerDraftPermissionPrefix, $peerPermissionPrefix, $permissionPrefix, $user, $sections) { + foreach ($sections as $section) { + if (! $user->can("$permissionPrefix:$section->uid")) { + continue; + } + + $excludePeerEntries = $section->type !== SectionType::Single && ! $user->can("$peerPermissionPrefix:$section->uid"); + $excludePeerDrafts = $this->drafts !== false && ! $user->can("$peerDraftPermissionPrefix:$section->uid"); + + if ($excludePeerEntries || $excludePeerDrafts) { + $query->orWhere(function (Builder $query) use ($excludePeerDrafts, $user, $excludePeerEntries, $section) { + $query->where('entries.sectionId', $section->id); + + if ($excludePeerEntries) { + $query->whereExists( + \Illuminate\Support\Facades\DB::table(Table::ENTRIES_AUTHORS, 'entries_authors') + ->whereColumn('entries_authors.entryId', 'entries.id') + ->where('entries_authors.authorId', $user->id) + ); + } + + if ($excludePeerDrafts) { + $query->where(function (Builder $query) use ($user) { + $query->whereNull('elements.draftId') + ->orWhere('drafts.creatorId', $user->id); + }); + } + }); + } else { + $fullyAuthorizedSectionIds[] = $section->id; + } + } + + if (! empty($fullyAuthorizedSectionIds)) { + if (count($fullyAuthorizedSectionIds) === count($sections)) { + // They have access to everything + if (! $value) { + throw new QueryAbortedException; + } + + return; + } + + $query->orWhereIn('entries.sectionId', $fullyAuthorizedSectionIds); + } + }, boolean: $value ? 'and' : 'and not'); + } + + /** + * Applies the 'ref' param to the query being prepared. + */ + private function applyRefParam(self $query): void + { + if (! $query->ref) { + return; + } + + $refs = $query->ref; + if (! is_array($refs)) { + $refs = is_string($refs) ? str($refs)->explode(',') : [$refs]; + } + + $joinSections = false; + $query->subQuery->where(function (Builder $query) use (&$joinSections, $refs) { + foreach ($refs as $ref) { + $parts = array_filter(explode('/', $ref)); + + if (empty($parts)) { + continue; + } + + if (count($parts) === 1) { + $query->orWhereParam('elements_sites.slug', $parts[0]); + + continue; + } + + $query->where(function (Builder $query) use ($parts) { + $query->whereParam('sections.handle', $parts[0]) + ->whereParam('elements_sites.slug', $parts[1]); + }); + + $joinSections = true; + } + }); + + if ($joinSections) { + $this->subQuery->join(new Alias(Table::SECTIONS, 'sections'), 'sections.id', '=', 'entries.sectionId'); + } + } + + /** + * {@inheritdoc} + */ + protected function cacheTags(): array + { + $tags = []; + + // If the type is set, go with that instead of the section + if ($this->typeId) { + foreach ($this->typeId as $typeId) { + $tags[] = "entryType:$typeId"; + } + } elseif ($this->sectionId) { + foreach (Arr::wrap($this->sectionId) as $sectionId) { + $tags[] = "section:$sectionId"; + } + } + + array_push($tags, ...$this->nestedTraitCacheTags()); + + return $tags; + } + + /** + * {@inheritdoc} + */ + protected function fieldLayouts(): Collection + { + $this->normalizeTypeId($this); + $this->normalizeSectionId($this); + + if ($this->typeId || $this->sectionId) { + $fieldLayouts = []; + if ($this->typeId) { + foreach ($this->typeId as $entryTypeId) { + $entryType = EntryTypes::getEntryTypeById($entryTypeId); + if ($entryType) { + $fieldLayouts[] = $entryType->getFieldLayout(); + } + } + } else { + foreach ($this->sectionId as $sectionId) { + if ($section = Sections::getSectionById($sectionId)) { + foreach ($section->getEntryTypes() as $entryType) { + $fieldLayouts[] = $entryType->getFieldLayout(); + } + } + } + } + + return collect($fieldLayouts); + } + + return $this->nestedTraitFieldLayouts(); + } } From 9d9a1bc91afd5fbff0a1ff071af321c552d2f93d Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 17 Nov 2025 09:39:35 +0100 Subject: [PATCH 27/52] Test for QueriesSites --- .../Queries/Concerns/QueriesSites.php | 24 ++++++++++---- src/Site/Sites.php | 11 ++++--- .../Queries/Concerns/QueriesSitesTest.php | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 tests/Database/Queries/Concerns/QueriesSitesTest.php diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index 2f937645978..017d400ffd8 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -17,8 +17,6 @@ use InvalidArgumentException; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesSites @@ -53,7 +51,7 @@ protected function initQueriesSites(): void } if (Sites::isMultiSite(false, true)) { - $elementQuery->subQuery->where('elements_sites.siteId', $elementQuery->siteId); + $elementQuery->subQuery->whereIn('elements_sites.siteId', Arr::wrap($elementQuery->siteId)); } }); } @@ -103,7 +101,12 @@ public function site($value): static } elseif ($value instanceof Site || $value instanceof SiteModel) { $this->siteId = $value->id; } elseif (is_string($value)) { - $this->siteId = Sites::getSiteByHandle($value)?->id ?? throw new InvalidArgumentException('Invalid site handle: '.$value); + $handles = str($value)->explode(',')->map(fn ($handle) => trim($handle))->all(); + + $this->siteId = array_map( + fn (string $handle) => Sites::getSiteByHandle($handle)->id ?? throw new InvalidArgumentException('Invalid site handle: '.$value), + $handles, + ); } else { if ($not = (strtolower((string) reset($value)) === 'not')) { array_shift($value); @@ -163,7 +166,7 @@ public function siteId($value): static $this->siteId = []; - foreach (\Craft::$app->getSites()->getAllSites() as $site) { + foreach (Sites::getAllSites() as $site) { if (! in_array($site->id, $value)) { $this->siteId[] = $site->id; } @@ -212,7 +215,7 @@ public function siteId($value): static public function language($value): self { if (is_string($value)) { - $sites = \Craft::$app->getSites()->getSitesByLanguage($value); + $sites = Sites::getSitesByLanguage($value); if (empty($sites)) { throw new InvalidArgumentException("Invalid language: $value"); @@ -229,7 +232,7 @@ public function language($value): self $this->siteId = []; - foreach (\Craft::$app->getSites()->getAllSites() as $site) { + foreach (Sites::getAllSites() as $site) { if (in_array($site->language, $value, true) === ! $not) { $this->siteId[] = $site->id; } @@ -261,6 +264,13 @@ private function normalizeSiteId(ElementQuery $query): mixed $query->siteId = $query->siteId->all(); } + if (is_string($query->siteId)) { + $query->siteId = str($query->siteId) + ->explode(',') + ->map(fn ($id) => trim($id)) + ->all(); + } + if (is_numeric($query->siteId) || Arr::isNumeric($query->siteId)) { // Filter out any invalid site IDs $siteIds = Collection::make((array) $query->siteId) diff --git a/src/Site/Sites.php b/src/Site/Sites.php index 02245e1c488..ee0969108c5 100644 --- a/src/Site/Sites.php +++ b/src/Site/Sites.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Site; +use Craft; use craft\base\ElementInterface; use craft\elements\Asset; use craft\helpers\Queue; @@ -533,7 +534,7 @@ public function handleChangedSite(ConfigEvent $event): void } // Invalidate all element caches - \Craft::$app->getElements()->invalidateAllCaches(); + Craft::$app->getElements()->invalidateAllCaches(); } /** @@ -767,7 +768,7 @@ public function handleDeletedSite(ConfigEvent $event): void $this->refreshSites(); // Invalidate all element caches - \Craft::$app->getElements()->invalidateAllCaches(); + Craft::$app->getElements()->invalidateAllCaches(); // Was this the current site? if (isset($this->currentSite) && $this->currentSite->id === $site->id) { @@ -779,7 +780,7 @@ public function handleDeletedSite(ConfigEvent $event): void } // Invalidate all element caches - \Craft::$app->getElements()->invalidateAllCaches(); + Craft::$app->getElements()->invalidateAllCaches(); } /** @@ -801,6 +802,8 @@ public function refreshSites(): void $this->allSitesById = null; $this->enabledSitesById = null; $this->editableSiteIds = null; + unset($this->isMultiSite); + unset($this->isMultiSiteWithTrashed); $this->loadAllSites(); $this->isMultiSite(true); } @@ -931,7 +934,7 @@ private function processNewPrimarySite(int $oldPrimarySiteId, int $newPrimarySit // Update all of the non-localized elements $nonLocalizedElementTypes = []; - foreach (\Craft::$app->getElements()->getAllElementTypes() as $elementType) { + foreach (Craft::$app->getElements()->getAllElementTypes() as $elementType) { /** @var class-string $elementType */ if (! $elementType::isLocalized()) { $nonLocalizedElementTypes[] = $elementType; diff --git a/tests/Database/Queries/Concerns/QueriesSitesTest.php b/tests/Database/Queries/Concerns/QueriesSitesTest.php new file mode 100644 index 00000000000..e72717e59b1 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesSitesTest.php @@ -0,0 +1,33 @@ +create(); + + Entry::factory()->create(); + + $entry2 = Entry::factory()->create(); + $entry2->element->siteSettings->first()->update(['siteId' => $site2->id]); + $entry2->section->siteSettings->first()->update(['siteId' => $site2->id]); + + Sites::refreshSites(); + + expect(entryQuery()->count())->toBe(1); // Defaults to current site (1) + expect(entryQuery()->siteId($site1->id)->count())->toBe(1); + expect(entryQuery()->siteId($site2->id)->count())->toBe(1); + expect(entryQuery()->siteId([$site1->id, $site2->id])->count())->toBe(2); + expect(entryQuery()->siteId(implode(', ', [$site1->id, $site2->id]))->count())->toBe(2); + + expect(entryQuery()->site('*')->count())->toBe(2); + expect(entryQuery()->site($site1->handle)->count())->toBe(1); + expect(entryQuery()->site($site2->handle)->count())->toBe(1); + expect(entryQuery()->site([$site1->handle, $site2->handle])->count())->toBe(2); + expect(entryQuery()->site(implode(', ', [$site1->handle, $site2->handle]))->count())->toBe(2); + + expect(entryQuery()->site(['not', $site1->handle])->count())->toBe(1); + expect(entryQuery()->site(['not', $site2->handle])->count())->toBe(1); +}); From 9e7559e3348b554cd4b5ff32629355becb2f2365 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 17 Nov 2025 09:59:52 +0100 Subject: [PATCH 28/52] Test for QueriesAuthors --- database/Factories/UserGroupFactory.php | 26 +++++++ src/Entry/Models/Entry.php | 12 +++ src/User/Models/User.php | 21 +++-- src/User/Models/UserGroup.php | 27 +++++++ .../Queries/Concerns/QueriesAuthorsTest.php | 78 +++++++++++++++++++ 5 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 database/Factories/UserGroupFactory.php create mode 100644 src/User/Models/UserGroup.php create mode 100644 tests/Database/Queries/Concerns/QueriesAuthorsTest.php diff --git a/database/Factories/UserGroupFactory.php b/database/Factories/UserGroupFactory.php new file mode 100644 index 00000000000..1812300275d --- /dev/null +++ b/database/Factories/UserGroupFactory.php @@ -0,0 +1,26 @@ + fake()->words(asText: true), + 'handle' => fake()->slug(), + 'description' => fake()->paragraph(), + 'dateCreated' => $date = fake()->dateTime(), + 'dateUpdated' => $date, + ]; + } +} diff --git a/src/Entry/Models/Entry.php b/src/Entry/Models/Entry.php index ca7d1a44bc6..5f530fa1f8e 100644 --- a/src/Entry/Models/Entry.php +++ b/src/Entry/Models/Entry.php @@ -10,8 +10,11 @@ use CraftCms\Cms\Field\Models\Field; use CraftCms\Cms\Section\Models\Section; use CraftCms\Cms\Shared\BaseModel; +use CraftCms\Cms\User\Models\User; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\Pivot; final class Entry extends BaseModel { @@ -76,6 +79,15 @@ public function entryType(): BelongsTo return $this->belongsTo(EntryType::class, 'typeId'); } + /** + * @return BelongsToMany + */ + public function authors(): BelongsToMany + { + return $this->belongsToMany(User::class, Table::ENTRIES_AUTHORS, 'entryId', 'authorId') + ->withPivot('sortOrder'); + } + public static function elementQuery(): EntryQuery { return new EntryQuery(\craft\elements\Entry::class); diff --git a/src/User/Models/User.php b/src/User/Models/User.php index a4f6a612666..9c4fed2bc69 100644 --- a/src/User/Models/User.php +++ b/src/User/Models/User.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\User\Models; use Craft; +use CraftCms\Cms\Database\Table; use CraftCms\Cms\Edition; use CraftCms\Cms\Shared\BaseModel; use Illuminate\Auth\Authenticatable; @@ -14,8 +15,11 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Foundation\Auth\Access\Authorizable; use Illuminate\Support\Collection; +use Override; class User extends BaseModel implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract { @@ -41,7 +45,7 @@ class User extends BaseModel implements AuthenticatableContract, AuthorizableCon 'admin' => 'boolean', ]; - private ?Collection $userGroups = null; + private ?Collection $userGroupData = null; public function isAdmin(): bool { @@ -55,7 +59,7 @@ public function isAdmin(): bool * * @todo Permissions to Laravel Gates */ - #[\Override] + #[Override] public function can($abilities, $arguments = []): bool { if ( @@ -72,19 +76,26 @@ public function can($abilities, $arguments = []): bool return Craft::$app->getUserPermissions()->doesUserHavePermission($this->id, $abilities); } + /** @return BelongsToMany */ + public function userGroups(): BelongsToMany + { + return $this->belongsToMany(UserGroup::class, Table::USERGROUPS_USERS, 'userId', 'groupId') + ->withPivot(['dateCreated', 'dateUpdated', 'uid']); + } + /** * @return Collection<\craft\models\UserGroup> */ public function getGroups(): Collection { - if (isset($this->userGroups)) { - return $this->userGroups; + if (isset($this->userGroupData)) { + return $this->userGroupData; } if (Edition::get() < Edition::Pro || ! isset($this->id)) { return collect(); } - return $this->userGroups = collect(Craft::$app->getUserGroups()->getGroupsByUserId($this->id)); + return $this->userGroupData = collect(Craft::$app->getUserGroups()->getGroupsByUserId($this->id)); } } diff --git a/src/User/Models/UserGroup.php b/src/User/Models/UserGroup.php new file mode 100644 index 00000000000..05a5c00fbfd --- /dev/null +++ b/src/User/Models/UserGroup.php @@ -0,0 +1,27 @@ + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, Table::USERGROUPS_USERS, 'groupId', 'userId') + ->withPivot(['dateCreated', 'dateUpdated', 'uid']); + } +} diff --git a/tests/Database/Queries/Concerns/QueriesAuthorsTest.php b/tests/Database/Queries/Concerns/QueriesAuthorsTest.php new file mode 100644 index 00000000000..48492a4d5bf --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesAuthorsTest.php @@ -0,0 +1,78 @@ +create(); + $author2 = User::factory()->create(); + + Entry::factory() + ->hasAttached($author1, ['sortOrder' => 0], 'authors') + ->create(); + + Entry::factory() + ->hasAttached($author2, ['sortOrder' => 0], 'authors') + ->create(); + + expect(entryQuery()->count())->toBe(2); + + // Does nothing when edition is solo + expect(entryQuery()->authorId($userGroup1->id)->count())->toBe(2); + + Edition::set(Edition::Pro); + + expect(entryQuery()->authorId($userGroup1->id)->count())->toBe(1); + expect(entryQuery()->authorId($userGroup2->id)->count())->toBe(1); + expect(entryQuery()->authorId([$userGroup1->id, $userGroup2->id])->count())->toBe(2); + expect(entryQuery()->authorId(implode(', ', [$userGroup1->id, $userGroup2->id]))->count())->toBe(2); + expect(entryQuery()->authorId('not '.$userGroup1->id)->count())->toBe(1); +}); + +it('can query entries by author groups', function () { + $author1 = User::factory() + ->hasAttached( + $userGroup1 = UserGroup::factory()->create(), + ['dateCreated' => now(), 'dateUpdated' => now(), 'uid' => \Illuminate\Support\Str::uuid()->toString()], + 'userGroups', + ) + ->create(); + + $author2 = User::factory() + ->hasAttached( + $userGroup2 = UserGroup::factory()->create(), + ['dateCreated' => now(), 'dateUpdated' => now(), 'uid' => \Illuminate\Support\Str::uuid()->toString()], + 'userGroups', + ) + ->create(); + + Entry::factory() + ->hasAttached($author1, ['sortOrder' => 0], 'authors') + ->create(); + + Entry::factory() + ->hasAttached($author2, ['sortOrder' => 0], 'authors') + ->create(); + + expect(entryQuery()->count())->toBe(2); + + // Does nothing when edition is solo + expect(entryQuery()->authorGroupId($userGroup1->id)->count())->toBe(2); + + Edition::set(Edition::Pro); + + expect(entryQuery()->authorGroupId($userGroup1->id)->count())->toBe(1); + expect(entryQuery()->authorGroupId($userGroup2->id)->count())->toBe(1); + expect(entryQuery()->authorGroupId([$userGroup1->id, $userGroup2->id])->count())->toBe(2); + expect(entryQuery()->authorGroupId(implode(', ', [$userGroup1->id, $userGroup2->id]))->count())->toBe(2); + expect(entryQuery()->authorGroupId('not '.$userGroup1->id)->count())->toBe(1); + + expect(entryQuery()->authorGroup('*')->count())->toBe(2); + expect(entryQuery()->authorGroup($userGroup1->handle)->count())->toBe(1); + expect(entryQuery()->authorGroup($userGroup2->handle)->count())->toBe(1); + expect(entryQuery()->authorGroup('not '.$userGroup2->handle)->count())->toBe(1); + expect(entryQuery()->authorGroup([$userGroup1->handle, $userGroup2->handle])->count())->toBe(2); + expect(entryQuery()->authorGroup(implode(', ', [$userGroup1->handle, $userGroup2->handle]))->count())->toBe(2); +}); From e0af913e318f9528b8136919264bcd98cba2fac6 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 17 Nov 2025 10:09:36 +0100 Subject: [PATCH 29/52] Add test for QueriesEntryDates --- database/Factories/EntryFactory.php | 9 ++-- .../Concerns/QueriesEntryDatesTest.php | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/Database/Queries/Concerns/QueriesEntryDatesTest.php diff --git a/database/Factories/EntryFactory.php b/database/Factories/EntryFactory.php index 361ce0d8e52..2d98f6432a8 100644 --- a/database/Factories/EntryFactory.php +++ b/database/Factories/EntryFactory.php @@ -9,12 +9,13 @@ use CraftCms\Cms\Entry\Models\EntryType; use CraftCms\Cms\Section\Models\Section; use Illuminate\Database\Eloquent\Factories\Factory; +use Override; final class EntryFactory extends Factory { protected $model = Entry::class; - #[\Override] + #[Override] public function definition(): array { return [ @@ -28,13 +29,13 @@ public function definition(): array ]; } - #[\Override] + #[Override] public function configure(): self { $this->afterCreating(function (Entry $entry) { $entry->element->update([ - 'dateCreated' => $entry->dateCreated, - 'dateUpdated' => $entry->dateUpdated, + 'dateCreated' => $entry->postDate, + 'dateUpdated' => $entry->postDate, ]); }); diff --git a/tests/Database/Queries/Concerns/QueriesEntryDatesTest.php b/tests/Database/Queries/Concerns/QueriesEntryDatesTest.php new file mode 100644 index 00000000000..7b2554e46a5 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesEntryDatesTest.php @@ -0,0 +1,53 @@ +create([ + $column => today()->subDay(), + ]); + + // Today + EntryModel::factory()->create([ + $column => today(), + ]); + + // Tomorrow + EntryModel::factory()->create([ + $column => today()->addDay(), + ]); + + // status(null) otherwise it filters out the entry from tomorrow + expect(entryQuery()->$column($param)->status(null)->count())->toBe($expectedCount); +})->with([ + 'postDate', + 'expiryDate', +])->with([ + ['<= yesterday', 1], + [['< today', '> today'], 2], + [['and', '> yesterday', '> today'], 1], +]); + +test('before & after', function () { + // Yesterday + EntryModel::factory()->create([ + 'postDate' => today()->subDay(), + ]); + + // Today + EntryModel::factory()->create([ + 'postDate' => today(), + ]); + + // Tomorrow + EntryModel::factory()->create([ + 'postDate' => today()->addDay(), + ]); + + expect(entryQuery()->before('today')->status(null)->count())->toBe(1); + expect(entryQuery()->before('tomorrow')->status(null)->count())->toBe(2); + + expect(entryQuery()->after('today')->status(null)->count())->toBe(2); + expect(entryQuery()->after('tomorrow')->status(null)->count())->toBe(1); +}); From 211057f90b9b3055bdb42e63e1fa3f86c8ea6c95 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 17 Nov 2025 13:16:49 +0100 Subject: [PATCH 30/52] QueriesUniqueElementsTest + pgsql fixes --- src/Database/Expressions/JsonExtract.php | 19 +++- .../Queries/Concerns/QueriesCustomFields.php | 4 +- .../Concerns/QueriesUniqueElements.php | 96 ++++++++----------- src/Database/Queries/ElementQuery.php | 4 +- src/Field/Field.php | 7 +- .../Queries/Concerns/QueriesAuthorsTest.php | 12 +-- .../Concerns/QueriesUniqueElementsTest.php | 31 ++++++ 7 files changed, 103 insertions(+), 70 deletions(-) create mode 100644 tests/Database/Queries/Concerns/QueriesUniqueElementsTest.php diff --git a/src/Database/Expressions/JsonExtract.php b/src/Database/Expressions/JsonExtract.php index 9958aaaa72c..2829331cccc 100644 --- a/src/Database/Expressions/JsonExtract.php +++ b/src/Database/Expressions/JsonExtract.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Database\Expressions; +use CraftCms\Cms\Support\Arr; use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Grammar; use Tpetry\QueryExpressions\Concerns\IdentifiesDriver; @@ -16,16 +17,28 @@ public function __construct( private string|Expression $expression, - private string $path, + private string|array $path, ) {} public function getValue(Grammar $grammar): string { $expression = $this->stringize($grammar, $this->expression); + $path = $this->formatPath($grammar); return match ($this->identify($grammar)) { - 'mariadb' => "JSON_UNQUOTE(JSON_EXTRACT($expression, '$this->path'))", - default => "($expression->>'$this->path')", + 'mariadb' => "JSON_UNQUOTE(JSON_EXTRACT($expression, $path", + 'pgsql' => "($expression#>>$path)", + default => "($expression->>$path)", }; } + + private function formatPath(Grammar $grammar): string + { + $path = Arr::wrap($this->path); + + return $grammar->quoteString(match ($this->identify($grammar)) { + 'pgsql' => sprintf('{%s}', implode(',', array_map(fn (string $seg) => sprintf('"%s"', $seg), $path))), + default => sprintf('$.%s', implode('.', array_map(fn (string $seg) => sprintf('"%s"', $seg), $path))), + }); + } } diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index 094644d6011..50117edf9a1 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -162,7 +162,7 @@ private function addGeneratedFieldsToColumnMap(): void $this->addToColumnMap( $field['handle'], - new JsonExtract(Table::ELEMENTS_SITES.'.content', '$.'.$field['uid']), + new JsonExtract(Table::ELEMENTS_SITES.'.content', $field['uid']), ); } } @@ -246,7 +246,7 @@ private function applyGeneratedFieldParams(ElementQuery $elementQuery): void foreach ($elementQuery->generatedFields as $field) { $handle = $field['handle'] ?? ''; if ($handle !== '' && isset($elementQuery->customFieldValues[$handle]) && ! isset($fieldsByHandle[$handle])) { - $generatedFieldColumns[$handle][] = new JsonExtract('elements_sites.content', '$.'.$field['uid']); + $generatedFieldColumns[$handle][] = new JsonExtract('elements_sites.content', $field['uid']); } } diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php index 08d843e0022..402a750efc5 100644 --- a/src/Database/Queries/Concerns/QueriesUniqueElements.php +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -7,7 +7,6 @@ use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Support\Facades\Sites; -use Illuminate\Support\Facades\DB; use Tpetry\QueryExpressions\Language\CaseGroup; use Tpetry\QueryExpressions\Language\CaseRule; use Tpetry\QueryExpressions\Operator\Comparison\Equal; @@ -36,61 +35,48 @@ trait QueriesUniqueElements */ public ?array $preferSites = null; - protected function initQueriesUniqueElements(): void + protected function applyUniqueParams(ElementQuery $elementQuery): void { - $this->beforeQuery(function (ElementQuery $elementQuery) { - if ( - ! $elementQuery->unique || - ! Sites::isMultiSite(false, true) || - ( - $elementQuery->siteId && - (! is_array($elementQuery->siteId) || count($elementQuery->siteId) === 1) - ) - ) { - return; - } - - if (! $elementQuery->preferSites) { - $preferSites = [Sites::getCurrentSite()->id]; - } else { - $preferSites = []; - foreach ($elementQuery->preferSites as $preferSite) { - if (is_numeric($preferSite)) { - $preferSites[] = $preferSite; - } elseif ($site = Sites::getSiteByHandle($preferSite)) { - $preferSites[] = $site->id; - } - } - } - - $cases = []; - - foreach ($preferSites as $index => $siteId) { - $cases[] = new CaseRule(new Value($index), new Equal('elements_sites.siteId', new Value($siteId))); - } - - $caseGroup = new CaseGroup($cases, new Value(count($preferSites))); - - $subSelectSql = $elementQuery->subQuery->clone() - ->select(['elements_sites.id']) - ->whereColumn('subElements.id', 'tmpElements.id') - ->orderBy($caseGroup) - ->orderBy('elements_sites.id') - ->offset(0) - ->limit(1) - ->toRawSql(); - - // `elements` => `subElements` - $qElements = DB::getTablePrefix().'Concerns'.Table::ELEMENTS; - $qSubElements = DB::getTablePrefix().'.subElements'; - $qTmpElements = DB::getTablePrefix().'.tmpElements'; - $q = $qElements[0]; - $subSelectSql = str_replace("$qElements.", "$qSubElements.", $subSelectSql); - $subSelectSql = str_replace("$q $qElements", "$q $qSubElements", $subSelectSql); - $subSelectSql = str_replace($qTmpElements, $qElements, $subSelectSql); - - $elementQuery->subQuery->where(DB::raw("elements_sites.id = ($subSelectSql)")); - }); + if (! $elementQuery->unique) { + return; + } + + if (! Sites::isMultiSite(false, true)) { + return; + } + + if ($elementQuery->siteId && + (! is_array($elementQuery->siteId) || count($elementQuery->siteId) === 1) + ) { + return; + } + + $preferSites = collect($elementQuery->preferSites ?? Sites::getCurrentSite()->id) + ->map(fn (string|int $preferSite) => match (true) { + is_numeric($preferSite) => $preferSite, + ! is_null($site = Sites::getSiteByHandle($preferSite)) => $site->id, + default => null, + }) + ->filter(); + + $cases = []; + + foreach ($preferSites as $index => $siteId) { + $cases[] = new CaseRule(new Value($index), new Equal('elements_sites.siteId', new Value($siteId))); + } + + $caseGroup = new CaseGroup($cases, new Value($preferSites->count())); + + $subSelect = $elementQuery->subQuery->clone() + ->select(['elements_sites.id']) + ->from(Table::ELEMENTS, 'subElements') + ->whereColumn('subElements.id', 'elements.id') + ->orderBy($caseGroup) + ->orderBy('elements_sites.id') + ->offset(0) + ->limit(1); + + $elementQuery->subQuery->where('elements_sites.id', $subSelect); } /** diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 069705da593..af669571545 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -28,6 +28,7 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; +use ReflectionProperty; use Tpetry\QueryExpressions\Function\Conditional\Coalesce; use Tpetry\QueryExpressions\Language\Alias; use Twig\Markup; @@ -608,7 +609,7 @@ public function criteriaAttributes(): array $names = []; // By default, include all public, non-static properties that were defined by a sub class, and certain ones in this class - foreach (Utils::getPublicProperties($this, fn (\ReflectionProperty $property) => ! in_array($property->getName(), ['elementType', 'query', 'subQuery', 'customFields', 'asArray', 'with', 'eagerly'], true)) as $name => $value) { + foreach (Utils::getPublicProperties($this, fn (ReflectionProperty $property) => ! in_array($property->getName(), ['elementType', 'query', 'subQuery', 'customFields', 'asArray', 'with', 'eagerly'], true)) as $name => $value) { $names[] = $name; } @@ -875,6 +876,7 @@ protected function elementQueryBeforeQuery(): void } $this->applyOrderByParams($this); + $this->applyUniqueParams($this); $this->query->fromSub($this->subQuery, 'subquery'); } diff --git a/src/Field/Field.php b/src/Field/Field.php index 5973fc5027a..e958ca0ad18 100644 --- a/src/Field/Field.php +++ b/src/Field/Field.php @@ -57,12 +57,13 @@ use Illuminate\Validation\Rule; use InvalidArgumentException; use RuntimeException; +use Stringable; use Tpetry\QueryExpressions\Function\Conditional\Coalesce; use yii\db\Schema; use function CraftCms\Cms\t; -abstract class Field implements \Stringable, Actionable, Arrayable, FieldInterface, Iconic +abstract class Field implements Actionable, Arrayable, FieldInterface, Iconic, Stringable { use ConfigurableComponent; use HasComponentEvents; @@ -1027,7 +1028,7 @@ public function getValueSql(?string $key = null): string|Expression|null return $this->_valueSql[$cacheKey] ?: null; } - private function _valueSql(?string $key): ?\Illuminate\Contracts\Database\Query\Expression + private function _valueSql(?string $key): ?Expression { $dbType = $this->dbTypeForValueSql(); @@ -1039,7 +1040,7 @@ private function _valueSql(?string $key): ?\Illuminate\Contracts\Database\Query\ throw new InvalidArgumentException(sprintf('%s doesn’t store values under the key “%s”.', self::class, $key)); } - $sql = new JsonExtract('elements_sites.content', "$.\"{$this->layoutElement->uid}\""); + $sql = new JsonExtract('elements_sites.content', $this->layoutElement->uid); if (is_array($dbType)) { // Get the primary value by default diff --git a/tests/Database/Queries/Concerns/QueriesAuthorsTest.php b/tests/Database/Queries/Concerns/QueriesAuthorsTest.php index 48492a4d5bf..787545fae6d 100644 --- a/tests/Database/Queries/Concerns/QueriesAuthorsTest.php +++ b/tests/Database/Queries/Concerns/QueriesAuthorsTest.php @@ -20,15 +20,15 @@ expect(entryQuery()->count())->toBe(2); // Does nothing when edition is solo - expect(entryQuery()->authorId($userGroup1->id)->count())->toBe(2); + expect(entryQuery()->authorId($author1->id)->count())->toBe(2); Edition::set(Edition::Pro); - expect(entryQuery()->authorId($userGroup1->id)->count())->toBe(1); - expect(entryQuery()->authorId($userGroup2->id)->count())->toBe(1); - expect(entryQuery()->authorId([$userGroup1->id, $userGroup2->id])->count())->toBe(2); - expect(entryQuery()->authorId(implode(', ', [$userGroup1->id, $userGroup2->id]))->count())->toBe(2); - expect(entryQuery()->authorId('not '.$userGroup1->id)->count())->toBe(1); + expect(entryQuery()->authorId($author1->id)->count())->toBe(1); + expect(entryQuery()->authorId($author2->id)->count())->toBe(1); + expect(entryQuery()->authorId([$author1->id, $author2->id])->count())->toBe(2); + expect(entryQuery()->authorId(implode(', ', [$author1->id, $author2->id]))->count())->toBe(2); + expect(entryQuery()->authorId('not '.$author1->id)->count())->toBe(1); }); it('can query entries by author groups', function () { diff --git a/tests/Database/Queries/Concerns/QueriesUniqueElementsTest.php b/tests/Database/Queries/Concerns/QueriesUniqueElementsTest.php new file mode 100644 index 00000000000..10d3423ed79 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesUniqueElementsTest.php @@ -0,0 +1,31 @@ +create(); + + Sites::refreshSites(); + + $entry = Entry::factory()->create(); + $entry->element->siteSettings()->create([ + 'siteId' => $site2->id, + ]); + $entry->section->siteSettings()->create([ + 'siteId' => $site2->id, + ]); + + expect(entryQuery()->site('*')->count())->toBe(2); + expect(entryQuery()->site('*')->unique()->count())->toBe(1); + expect(entryQuery()->site('*')->unique()->first()->siteId)->toBe($site1->id); + + Sites::setCurrentSite($site2->handle); + + expect(entryQuery()->site('*')->unique()->first()->siteId)->toBe($site2->id); + + expect(entryQuery()->site('*')->preferSites([$site2->id, $site1->id])->unique()->first()->siteId)->toBe($site2->id); + expect(entryQuery()->site('*')->preferSites([$site2->handle, $site1->handle])->unique()->first()->siteId)->toBe($site2->id); +}); From d87187eb1a160f099d180cded4ac13273870c119 Mon Sep 17 00:00:00 2001 From: Rias Date: Mon, 17 Nov 2025 15:46:26 +0100 Subject: [PATCH 31/52] Tests for QueriesStructures --- .../Queries/Concerns/HydratesElements.php | 6 +- .../Queries/Concerns/QueriesStructures.php | 29 ++++--- .../Concerns/QueriesStructuresTest.php | 86 +++++++++++++++++++ 3 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 tests/Database/Queries/Concerns/QueriesStructuresTest.php diff --git a/src/Database/Queries/Concerns/HydratesElements.php b/src/Database/Queries/Concerns/HydratesElements.php index 5b28e921750..af26905d137 100644 --- a/src/Database/Queries/Concerns/HydratesElements.php +++ b/src/Database/Queries/Concerns/HydratesElements.php @@ -98,7 +98,7 @@ protected function createElement(array $row): ElementInterface if ( ! $this->ignorePlaceholders && isset($row['id'], $row['siteId']) && - ! is_null($element = \Craft::$app->getElements()->getPlaceholderElement($row['id'], $row['siteId'])) + ! is_null($element = Craft::$app->getElements()->getPlaceholderElement($row['id'], $row['siteId'])) ) { return $element; } @@ -107,10 +107,6 @@ protected function createElement(array $row): ElementInterface $class = $this->elementType; // Instantiate the element - if ($this->structureId) { - $row['structureId'] = $this->structureId; - } - if ($class::hasTitles()) { // Ensure the title is a string $row['title'] = (string) ($row['title'] ?? ''); diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index ecd80d93c65..351e72ec406 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Database\Queries\Concerns; +use Craft; use craft\base\ElementInterface; use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; @@ -129,6 +130,8 @@ protected function initQueriesStructures(): void if ($this->structureId) { return $collection->map(function ($element) { $element->structureId = $this->structureId; + + return $element; }); } @@ -148,6 +151,8 @@ public function withStructure(bool $value = true): static /** * Determines which structure data should be joined into the query. + * + * @internal */ public function structureId(?int $value = null): static { @@ -588,18 +593,18 @@ private function applyStructureParams(ElementQuery $elementQuery): void if ($elementQuery->structureId) { $elementQuery->query->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join - ->whereColumn('structureelements.elementId', 'subquery.elementsId') + ->on('structureelements.elementId', '=', 'subquery.elementsId') ->where('structureelements.structureId', $elementQuery->structureId)); $elementQuery->subQuery->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join - ->whereColumn('structureelements.elementId', 'elements.id') + ->on('structureelements.elementId', '=', 'elements.id') ->where('structureelements.structureId', $elementQuery->structureId)); } else { $elementQuery->query ->addSelect('structureelements.structureId as structureId') ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join - ->whereColumn('structureelements.elementId', 'subquery.elementsId') - ->whereColumn('structureelements.structureId', 'subquery.structureId')); + ->on('structureelements.elementId', '=', 'subquery.elementsId') + ->where('structureelements.structureId', '=', 'subquery.structureId')); $existsQuery = DB::table(Table::STRUCTURES) // Use index hints to specify index so Mysql does not select the less @@ -614,7 +619,7 @@ private function applyStructureParams(ElementQuery $elementQuery): void $elementQuery->subQuery ->addSelect('structureelements.structureId as structureId') ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join - ->whereColumn('structureelements.elementId', 'elements.id') + ->on('structureelements.elementId', '=', 'elements.id') ->whereExists($existsQuery) ); } @@ -633,7 +638,7 @@ private function applyStructureParams(ElementQuery $elementQuery): void $elementQuery->subQuery ->where('structureelements.lft', '<', $ancestorOf->lft) ->where('structureelements.rgt', '>', $ancestorOf->rgt) - ->where('structureelements.root', '>', $ancestorOf->root) + ->where('structureelements.root', $ancestorOf->root) ->when( $elementQuery->ancestorDist, fn (Builder $q) => $q->where('structureelements.level', '>=', $ancestorOf->level - $elementQuery->ancestorDist) @@ -711,16 +716,16 @@ private function applyStructureParams(ElementQuery $elementQuery): void ->where('structureelements.root', $positionedAfter->root); } - if ($elementQuery->level) { + if (isset($elementQuery->level)) { $allowNull = is_array($elementQuery->level) && in_array(null, $elementQuery->level, true); $elementQuery->subQuery->when( - $allowNull, - fn (Builder $q) => $q->where(function (Builder $q) use ($elementQuery) { - $q->where(Db::parseNumericParam('structureelements.level', array_filter($elementQuery->level, fn ($v) => $v !== null))) + value: $allowNull, + callback: fn (Builder $q) => $q->where(function (Builder $q) use ($elementQuery) { + $q->whereNumericParam('structureelements.level', array_filter($elementQuery->level, fn ($v) => $v !== null)) ->orWhereNull('structureelements.level'); }), - fn (Builder $q) => Db::parseNumericParam('structureelements.level', $elementQuery->level), + default: fn (Builder $q) => $q->whereNumericParam('structureelements.level', $elementQuery->level), ); } @@ -755,7 +760,7 @@ private function normalizeStructureParamValue(string $property): ElementInterfac } if (! $element instanceof ElementInterface) { - $element = \Craft::$app->getElements()->getElementById($element, $this->elementType, $this->siteId, [ + $element = Craft::$app->getElements()->getElementById($element, $this->elementType, $this->siteId, [ 'structureId' => $this->structureId, ]); diff --git a/tests/Database/Queries/Concerns/QueriesStructuresTest.php b/tests/Database/Queries/Concerns/QueriesStructuresTest.php new file mode 100644 index 00000000000..4257bf34117 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesStructuresTest.php @@ -0,0 +1,86 @@ +create(); + // Clean structure from factory created elements + $structure1->structureElements()->delete(); + + $structure2 = Structure::factory()->create(); + // Clean structure from factory created elements + $structure2->structureElements()->delete(); + + $entry1 = Entry::factory()->create(); + $entry2 = Entry::factory()->create(); + + new StructureElement([ + 'structureId' => $structure1->id, + 'elementId' => $entry1->id, + ])->makeRoot(); + + new StructureElement([ + 'structureId' => $structure2->id, + 'elementId' => $entry2->id, + ])->makeRoot(); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->structureId($structure1->id)->level(0)->count())->toBe(1); + expect(entryQuery()->structureId($structure2->id)->level(0)->count())->toBe(1); +}); + +test('structure methods', function () { + $structure = Structure::factory()->create(); + // Clean structure from factory created elements + $structure->structureElements()->delete(); + + $entries = Entry::factory(3)->create(); + + foreach ($entries as $index => $entry) { + $structureElement = new StructureElement([ + 'structureId' => $structure->id, + 'elementId' => $entry->id, + ]); + + if ($index === 0) { + $structureElement->makeRoot(); + $root = $structureElement; + + continue; + } + + $structureElement->appendTo($root); + $lastStructureElement = $structureElement; + } + + $child = Entry::factory()->create(); + new StructureElement([ + 'structureId' => $structure->id, + 'elementId' => $child->id, + ])->appendTo($lastStructureElement); + + expect(entryQuery()->count())->toBe(4); + + $query = entryQuery()->structureId($structure->id); + + expect($query->clone()->level(0)->count())->toBe(1); + expect($query->clone()->level('> 0')->count())->toBe(3); + expect($query->clone()->leaves()->count())->toBe(2); + expect($query->clone()->nextSiblingOf($entries[1]->id)->count())->toBe(1); + expect($query->clone()->prevSiblingOf($entries[1]->id)->count())->toBe(0); + expect($query->clone()->positionedBefore($entries[1]->id)->count())->toBe(1); + expect($query->clone()->level(1)->positionedAfter($entries[1]->id)->count())->toBe(1); + expect($query->clone()->level(1)->nextSiblingOf($entries[2]->id)->count())->toBe(0); + expect($query->clone()->level(1)->prevSiblingOf($entries[2]->id)->count())->toBe(1); + expect($query->clone()->level(1)->positionedBefore($entries[2]->id)->count())->toBe(1); + expect($query->clone()->level(1)->positionedAfter($entries[2]->id)->count())->toBe(0); + expect($query->clone()->level(1)->siblingOf($entries[1]->id)->count())->toBe(1); + expect($query->clone()->hasDescendants()->count())->toBe(2); + expect($query->clone()->hasDescendants(false)->count())->toBe(2); + expect($query->clone()->descendantOf($entries[0]->id)->count())->toBe(3); + expect($query->clone()->descendantOf($lastStructureElement->elementId)->count())->toBe(1); + expect($query->clone()->ancestorOf($lastStructureElement->elementId)->count())->toBe(1); + expect($query->clone()->ancestorOf($entries[1]->id)->count())->toBe(1); +}); From d6eedb6d50f9529ce5f273df344522d1de6796ce Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 18 Nov 2025 09:00:35 +0100 Subject: [PATCH 32/52] Fix a few phpstan errors --- .../Queries/Concerns/CollectsCacheTags.php | 5 +- .../Queries/Concerns/FormatsResults.php | 2 - .../Queries/Concerns/HydratesElements.php | 2 - .../Queries/Concerns/QueriesCustomFields.php | 6 +- .../Concerns/QueriesDraftsAndRevisions.php | 4 +- .../Queries/Concerns/QueriesEagerly.php | 7 +- .../Queries/Concerns/QueriesFields.php | 2 - .../Concerns/QueriesNestedElements.php | 4 +- .../Concerns/QueriesPlaceholderElements.php | 5 +- .../Concerns/QueriesRelatedElements.php | 2 - .../Queries/Concerns/QueriesSites.php | 4 +- .../Queries/Concerns/QueriesStatuses.php | 2 - .../Queries/Concerns/QueriesStructures.php | 3 - .../Concerns/QueriesUniqueElements.php | 2 - .../Queries/Concerns/SearchesElements.php | 5 +- src/Database/Queries/ElementQuery.php | 14 +- .../Queries/ElementQueryInterface.php | 365 ------------------ .../Exceptions/ElementNotFoundException.php | 2 +- src/Element/Models/Element.php | 2 +- src/Element/Models/ElementSiteSettings.php | 4 +- src/Entry/Models/Entry.php | 2 +- src/Field/BaseRelationField.php | 49 +-- .../Utilities/MigrationsController.php | 3 +- src/Shared/BasePivot.php | 16 + src/Site/Models/Site.php | 2 +- src/Support/Query.php | 7 +- 26 files changed, 79 insertions(+), 442 deletions(-) delete mode 100644 src/Database/Queries/ElementQueryInterface.php create mode 100644 src/Shared/BasePivot.php diff --git a/src/Database/Queries/Concerns/CollectsCacheTags.php b/src/Database/Queries/Concerns/CollectsCacheTags.php index 96b290abe18..109dfd089d9 100644 --- a/src/Database/Queries/Concerns/CollectsCacheTags.php +++ b/src/Database/Queries/Concerns/CollectsCacheTags.php @@ -4,13 +4,12 @@ namespace CraftCms\Cms\Database\Queries\Concerns; +use Craft; use CraftCms\Cms\Database\Queries\Events\DefineCacheTags; use CraftCms\Cms\Support\Arr; use Illuminate\Support\Facades\Event; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait CollectsCacheTags @@ -33,7 +32,7 @@ protected function initCollectsCacheTags(): void return; } - $elementsService = \Craft::$app->getElements(); + $elementsService = Craft::$app->getElements(); if ($elementsService->getIsCollectingCacheInfo()) { $elementsService->collectCacheTags($cacheTags); diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php index 956f340797e..62be038b2ef 100644 --- a/src/Database/Queries/Concerns/FormatsResults.php +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -17,8 +17,6 @@ use yii\base\InvalidValueException; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait FormatsResults diff --git a/src/Database/Queries/Concerns/HydratesElements.php b/src/Database/Queries/Concerns/HydratesElements.php index af26905d137..1f1c7a22c32 100644 --- a/src/Database/Queries/Concerns/HydratesElements.php +++ b/src/Database/Queries/Concerns/HydratesElements.php @@ -18,8 +18,6 @@ use stdClass; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait HydratesElements diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php index 50117edf9a1..feaea76f301 100644 --- a/src/Database/Queries/Concerns/QueriesCustomFields.php +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -20,8 +20,6 @@ use Tpetry\QueryExpressions\Function\Conditional\Coalesce; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesCustomFields @@ -143,7 +141,9 @@ private function addCustomFieldsToColumnMap(): void // for mysql, we have to make sure text column type is cast to char, otherwise it won't be sorted correctly // see https://github.com/craftcms/cms/issues/15609 - if ($this->query->getConnection()->getDriverName() === 'mysql' && Query::parseColumnType($dbType) === Query::TYPE_TEXT) { + /** @var \Illuminate\Database\Connection $connection */ + $connection = $this->query->getConnection(); + if ($connection->getDriverName() === 'mysql' && Query::parseColumnType($dbType) === Query::TYPE_TEXT) { $this->columnsToCast[$alias] = 'CHAR(255)'; } } diff --git a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php index 4f3c55058c2..f71b6115c8b 100644 --- a/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -15,8 +15,6 @@ use Tpetry\QueryExpressions\Language\Alias; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesDraftsAndRevisions @@ -130,7 +128,7 @@ private function applyDraftParams(ElementQuery $elementQuery): void $elementQuery->subQuery->whereNotNull('elements.canonicalId'); } elseif (isset($elementQuery->draftOf)) { if ($elementQuery->draftOf === false) { - $elementQuery->subQuery->whereNull('elements.canonicalId', null); + $elementQuery->subQuery->whereNull('elements.canonicalId'); } else { $elementQuery->subQuery->whereIn('elements.canonicalId', Arr::wrap($elementQuery->draftOf)); } diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php index c8b4f397453..dfd86e0f31b 100644 --- a/src/Database/Queries/Concerns/QueriesEagerly.php +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -4,13 +4,12 @@ namespace CraftCms\Cms\Database\Queries\Concerns; +use Craft; use craft\base\ElementInterface; use craft\elements\db\EagerLoadPlan; use Illuminate\Support\Collection; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesEagerly @@ -55,7 +54,7 @@ protected function initQueriesEagerly(): void return $elements; } - $elementsService = \Craft::$app->getElements(); + $elementsService = Craft::$app->getElements(); $elementsService->eagerLoadElements($this->elementType, $elements->all(), $this->with); return $elements; @@ -210,7 +209,7 @@ protected function eagerLoad(bool $count = false, array $criteria = []): Collect }; if (! $eagerLoaded) { - \Craft::$app->getElements()->eagerLoadElements( + Craft::$app->getElements()->eagerLoadElements( $this->eagerLoadSourceElement::class, $this->eagerLoadSourceElement->elementQueryResult, [ diff --git a/src/Database/Queries/Concerns/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php index fe80987016e..ffff38166e1 100644 --- a/src/Database/Queries/Concerns/QueriesFields.php +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -10,8 +10,6 @@ use Tpetry\QueryExpressions\Language\Alias; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesFields diff --git a/src/Database/Queries/Concerns/QueriesNestedElements.php b/src/Database/Queries/Concerns/QueriesNestedElements.php index 0f140bbb073..92ff4c13ae6 100644 --- a/src/Database/Queries/Concerns/QueriesNestedElements.php +++ b/src/Database/Queries/Concerns/QueriesNestedElements.php @@ -305,7 +305,7 @@ protected function fieldLayouts(): Collection */ private function normalizeNestedElementParams(ElementQuery $query): void { - /** @var ElementQuery&QueriesNestedElements $query */ + /** @var \CraftCms\Cms\Database\Queries\EntryQuery $query */ $this->normalizeFieldId($query); $this->primaryOwnerId = $this->normalizeOwnerId($query->primaryOwnerId); $this->ownerId = $this->normalizeOwnerId($query->ownerId); @@ -316,7 +316,7 @@ private function normalizeNestedElementParams(ElementQuery $query): void */ private function normalizeFieldId(ElementQuery $query): void { - /** @var ElementQuery&QueriesNestedElements $query */ + /** @var \CraftCms\Cms\Database\Queries\EntryQuery $query */ if ($query->fieldId === false) { return; } diff --git a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php index bc7a309979d..4973369bae0 100644 --- a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php +++ b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php @@ -5,11 +5,10 @@ namespace CraftCms\Cms\Database\Queries\Concerns; use Closure; +use Craft; use Illuminate\Database\Query\Builder; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesPlaceholderElements @@ -60,7 +59,7 @@ protected function placeholderCondition(Closure $condition): Closure if (! isset($this->placeholderCondition) || $this->siteId !== $this->placeholderSiteIds) { $placeholderSourceIds = []; - $placeholderElements = \Craft::$app->getElements()->getPlaceholderElements(); + $placeholderElements = Craft::$app->getElements()->getPlaceholderElements(); if (! empty($placeholderElements)) { $siteIds = array_flip((array) $this->siteId); foreach ($placeholderElements as $element) { diff --git a/src/Database/Queries/Concerns/QueriesRelatedElements.php b/src/Database/Queries/Concerns/QueriesRelatedElements.php index 17831e5da13..b7c71140bb2 100644 --- a/src/Database/Queries/Concerns/QueriesRelatedElements.php +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -12,8 +12,6 @@ use RuntimeException; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesRelatedElements diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index 017d400ffd8..2968f2e47cd 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -217,11 +217,11 @@ public function language($value): self if (is_string($value)) { $sites = Sites::getSitesByLanguage($value); - if (empty($sites)) { + if ($sites->isEmpty()) { throw new InvalidArgumentException("Invalid language: $value"); } - $this->siteId = array_map(fn (Site $site) => $site->id, $sites); + $this->siteId = $sites->pluck('id')->all(); return $this; } diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php index 04671f147ba..3dbb6951077 100644 --- a/src/Database/Queries/Concerns/QueriesStatuses.php +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -11,8 +11,6 @@ use Illuminate\Database\Query\Builder; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesStatuses diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index 351e72ec406..a8e727ca2d5 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -16,8 +16,6 @@ use Tpetry\QueryExpressions\Language\Alias; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesStructures @@ -738,7 +736,6 @@ private function applyStructureParams(ElementQuery $elementQuery): void * Normalizes a structure param value to either an Element object or false. * * @param string $property The parameter’s property name. - * @param class-string $class The element class * @return ElementInterface The normalized element * * @throws QueryAbortedException if the element can't be found diff --git a/src/Database/Queries/Concerns/QueriesUniqueElements.php b/src/Database/Queries/Concerns/QueriesUniqueElements.php index 402a750efc5..c6b05a1ee8b 100644 --- a/src/Database/Queries/Concerns/QueriesUniqueElements.php +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -13,8 +13,6 @@ use Tpetry\QueryExpressions\Value\Value; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait QueriesUniqueElements diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php index 988664026be..edb0e18b5f7 100644 --- a/src/Database/Queries/Concerns/SearchesElements.php +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -4,14 +4,13 @@ namespace CraftCms\Cms\Database\Queries\Concerns; +use Craft; use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Support\Arr; use Illuminate\Database\Query\Builder; /** - * @mixin \CraftCms\Cms\Database\Queries\ElementQuery - * * @internal */ trait SearchesElements @@ -54,7 +53,7 @@ private function applySearchParam(ElementQuery $elementQuery): void return; } - $searchService = \Craft::$app->getSearch(); + $searchService = Craft::$app->getSearch(); $scoreOrder = Arr::first($elementQuery->query->orders ?? [], fn ($order) => $order['column'] === 'score'); diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index af669571545..076cb82451d 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -36,9 +36,9 @@ /** * @template TElement of ElementInterface * - * @mixin \Illuminate\Database\Query\Builder + * @mixin \Illuminate\Database\Query\Builder */ -class ElementQuery implements ElementQueryInterface +class ElementQuery { /** @use \Illuminate\Database\Concerns\BuildsQueries */ use BuildsQueries { @@ -530,10 +530,10 @@ public function cursor(): LazyCollection { $this->applyBeforeQueryCallbacks(); - return $this->applyScopes()->query->cursor()->map(function ($record) { + return $this->query->cursor()->map(function ($record) { $model = $this->createElement((array) $record); - return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); + return $this->applyAfterQueryCallbacks(new Collection([$model]))->first(); })->reject(fn ($model) => is_null($model)); } @@ -767,7 +767,9 @@ public static function __callStatic($method, $parameters): mixed } if ($method === 'mixin') { - return static::registerMixin($parameters[0], $parameters[1] ?? true); + static::registerMixin($parameters[0], $parameters[1] ?? true); + + return null; } if (! static::hasGlobalMacro($method)) { @@ -786,7 +788,7 @@ public static function __callStatic($method, $parameters): mixed /** * Register the given mixin with the builder. */ - protected static function registerMixin(string $mixin, bool $replace = true): void + protected static function registerMixin(object $mixin, bool $replace = true): void { $methods = new ReflectionClass($mixin)->getMethods( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED diff --git a/src/Database/Queries/ElementQueryInterface.php b/src/Database/Queries/ElementQueryInterface.php deleted file mode 100644 index fa44473917f..00000000000 --- a/src/Database/Queries/ElementQueryInterface.php +++ /dev/null @@ -1,365 +0,0 @@ -|array - */ - public function all(array|string $columns = ['*']): Collection|array; - - /** - * Causes the query results to be returned in reverse order. - * - * --- - * - * ```twig - * {# Fetch {elements} in reverse #} - * {% set {elements-var} = {twig-method} - * .inReverse() - * .all() %} - * ``` - * - * ```php - * // Fetch {elements} in reverse - * ${elements-var} = {php-method} - * ->inReverse() - * ->all(); - * ``` - * - * @param bool $value The property value - * @return static self reference - */ - public function inReverse(bool $value = true): static; - - /** - * Causes the query to return provisional drafts for the matching elements, - * when they exist for the current user. - * - * @param bool $value The property value (defaults to true) - * @return static self reference - */ - public function withProvisionalDrafts(bool $value = true): static; - - /** - * Narrows the query results to only drafts {elements}. - * - * --- - * - * ```twig - * {# Fetch a draft {element} #} - * {% set {elements-var} = {twig-method} - * .drafts() - * .id(123) - * .one() %} - * ``` - * - * ```php - * // Fetch a draft {element} - * ${elements-var} = {element-class}::find() - * ->drafts() - * ->id(123) - * ->one(); - * ``` - * - * @param bool|null $value The property value (defaults to true) - * @return static self reference - */ - public function drafts(?bool $value = true): static; - - /** - * Narrows the query results based on the {elements}’ draft’s ID (from the `drafts` table). - * - * Possible values include: - * - * | Value | Fetches drafts… - * | - | - - * | `1` | for the draft with an ID of 1. - * - * --- - * - * ```twig - * {# Fetch a draft #} - * {% set {elements-var} = {twig-method} - * .draftId(10) - * .all() %} - * ``` - * - * ```php - * // Fetch a draft - * ${elements-var} = {php-method} - * ->draftId(10) - * ->all(); - * ``` - * - * @param int|null $value The property value - * @return static self reference - */ - public function draftId(?int $value = null): static; - - /** - * Narrows the query results to only drafts of a given {element}. - * - * Possible values include: - * - * | Value | Fetches drafts… - * | - | - - * | `1` | for the {element} with an ID of 1. - * | `[1, 2]` | for the {elements} with an ID of 1 or 2. - * | a [[{element-class}]] object | for the {element} represented by the object. - * | an array of [[{element-class}]] objects | for the {elements} represented by the objects. - * | `'*'` | for any {element} - * | `false` | that aren’t associated with a published {element} - * - * --- - * - * ```twig - * {# Fetch drafts of the {element} #} - * {% set {elements-var} = {twig-method} - * .draftOf({myElement}) - * .all() %} - * ``` - * - * ```php - * // Fetch drafts of the {element} - * ${elements-var} = {php-method} - * ->draftOf(${myElement}) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function draftOf(mixed $value): static; - - /** - * Narrows the query results to only drafts created by a given user. - * - * Possible values include: - * - * | Value | Fetches drafts… - * | - | - - * | `1` | created by the user with an ID of 1. - * | a [[User]] object | created by the user represented by the object. - * - * --- - * - * ```twig - * {# Fetch drafts by the current user #} - * {% set {elements-var} = {twig-method} - * .draftCreator(currentUser) - * .all() %} - * ``` - * - * ```php - * // Fetch drafts by the current user - * ${elements-var} = {php-method} - * ->draftCreator(Craft::$app->user->identity) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function draftCreator(mixed $value): static; - - /** - * Narrows the query results to only provisional drafts. - * - * --- - * - * ```twig - * {# Fetch provisional drafts created by the current user #} - * {% set {elements-var} = {twig-method} - * .provisionalDrafts() - * .draftCreator(currentUser) - * .all() %} - * ``` - * - * ```php - * // Fetch provisional drafts created by the current user - * ${elements-var} = {php-method} - * ->provisionalDrafts() - * ->draftCreator(Craft::$app->user->identity) - * ->all(); - * ``` - * - * @param bool|null $value The property value - * @return static self reference - */ - public function provisionalDrafts(?bool $value = true): static; - - /** - * Narrows the query results to only canonical elements, including elements - * that reference another canonical element via `canonicalId` so long as they - * aren’t a draft. - * - * Unpublished drafts can be included as well if `drafts(null)` and - * `draftOf(false)` are also passed. - * - * @param bool $value The property value - * @return static self reference - */ - public function canonicalsOnly(bool $value = true): static; - - /** - * Narrows the query results to only unpublished drafts which have been saved after initial creation. - * - * --- - * - * ```twig - * {# Fetch saved, unpublished draft {elements} #} - * {% set {elements-var} = {twig-method} - * .draftOf(false) - * .savedDraftsOnly() - * .all() %} - * ``` - * - * ```php - * // Fetch saved, unpublished draft {elements} - * ${elements-var} = {element-class}::find() - * ->draftOf(false) - * ->savedDraftsOnly() - * ->all(); - * ``` - * - * @param bool $value The property value (defaults to true) - * @return static self reference - */ - public function savedDraftsOnly(bool $value = true): static; - - /** - * Narrows the query results to only revision {elements}. - * - * --- - * - * ```twig - * {# Fetch a revision {element} #} - * {% set {elements-var} = {twig-method} - * .revisions() - * .id(123) - * .one() %} - * ``` - * - * ```php - * // Fetch a revision {element} - * ${elements-var} = {element-class}::find() - * ->revisions() - * ->id(123) - * ->one(); - * ``` - * - * @param bool|null $value The property value (defaults to true) - * @return static self reference - */ - public function revisions(?bool $value = true): static; - - /** - * Narrows the query results based on the {elements}’ revision’s ID (from the `revisions` table). - * - * Possible values include: - * - * | Value | Fetches revisions… - * | - | - - * | `1` | for the revision with an ID of 1. - * - * --- - * - * ```twig - * {# Fetch a revision #} - * {% set {elements-var} = {twig-method} - * .revisionId(10) - * .all() %} - * ``` - * - * ```php - * // Fetch a revision - * ${elements-var} = {php-method} - * ->revisionIf(10) - * ->all(); - * ``` - * - * @param int|null $value The property value - * @return static self reference - */ - public function revisionId(?int $value = null): static; - - /** - * Narrows the query results to only revisions of a given {element}. - * - * Possible values include: - * - * | Value | Fetches revisions… - * | - | - - * | `1` | for the {element} with an ID of 1. - * | a [[{element-class}]] object | for the {element} represented by the object. - * - * --- - * - * ```twig - * {# Fetch revisions of the {element} #} - * {% set {elements-var} = {twig-method} - * .revisionOf({myElement}) - * .all() %} - * ``` - * - * ```php - * // Fetch revisions of the {element} - * ${elements-var} = {php-method} - * ->revisionOf(${myElement}) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function revisionOf(mixed $value): static; - - /** - * Narrows the query results to only revisions created by a given user. - * - * Possible values include: - * - * | Value | Fetches revisions… - * | - | - - * | `1` | created by the user with an ID of 1. - * | a [[User]] object | created by the user represented by the object. - * - * --- - * - * ```twig - * {# Fetch revisions by the current user #} - * {% set {elements-var} = {twig-method} - * .revisionCreator(currentUser) - * .all() %} - * ``` - * - * ```php - * // Fetch revisions by the current user - * ${elements-var} = {php-method} - * ->revisionCreator(Craft::$app->user->identity) - * ->all(); - * ``` - * - * @param mixed $value The property value - * @return static self reference - */ - public function revisionCreator(mixed $value): static; -} diff --git a/src/Database/Queries/Exceptions/ElementNotFoundException.php b/src/Database/Queries/Exceptions/ElementNotFoundException.php index 511e12c08b3..d36abd4715f 100644 --- a/src/Database/Queries/Exceptions/ElementNotFoundException.php +++ b/src/Database/Queries/Exceptions/ElementNotFoundException.php @@ -33,7 +33,7 @@ final class ElementNotFoundException extends RecordsNotFoundException * @param array|int|string $ids * @return $this */ - public function setElement(string $element, array $ids = []): self + public function setElement(string $element, array|int|string $ids = []): self { $this->element = $element; $this->ids = Arr::wrap($ids); diff --git a/src/Element/Models/Element.php b/src/Element/Models/Element.php index 2e2a093020e..7f379bc5df1 100644 --- a/src/Element/Models/Element.php +++ b/src/Element/Models/Element.php @@ -21,7 +21,7 @@ final class Element extends BaseModel use SoftDeletes; /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\CraftCms\Cms\Site\Models\Site, $this, \Illuminate\Database\Eloquent\Relations\Pivot> + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\CraftCms\Cms\Site\Models\Site, $this, ElementSiteSettings> */ public function sites(): BelongsToMany { diff --git a/src/Element/Models/ElementSiteSettings.php b/src/Element/Models/ElementSiteSettings.php index 111cd1933fd..5e1bfa259e3 100644 --- a/src/Element/Models/ElementSiteSettings.php +++ b/src/Element/Models/ElementSiteSettings.php @@ -5,11 +5,11 @@ namespace CraftCms\Cms\Element\Models; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Shared\BaseModel; +use CraftCms\Cms\Shared\BasePivot; use CraftCms\Cms\Site\Models\Site; use Illuminate\Database\Eloquent\Relations\BelongsTo; -final class ElementSiteSettings extends BaseModel +final class ElementSiteSettings extends BasePivot { protected $table = Table::ELEMENTS_SITES; diff --git a/src/Entry/Models/Entry.php b/src/Entry/Models/Entry.php index 5f530fa1f8e..dfee6d3a2a3 100644 --- a/src/Entry/Models/Entry.php +++ b/src/Entry/Models/Entry.php @@ -90,6 +90,6 @@ public function authors(): BelongsToMany public static function elementQuery(): EntryQuery { - return new EntryQuery(\craft\elements\Entry::class); + return new EntryQuery; } } diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index 06261180042..257818f319b 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -25,6 +25,8 @@ use craft\helpers\Queue; use craft\queue\jobs\LocalizeRelations; use craft\web\assets\cp\CpAsset; +use CraftCms\Cms\Database\Expressions\FixedOrderExpression; +use CraftCms\Cms\Database\Expressions\OrderByPlaceholderExpression; use CraftCms\Cms\Database\Queries\EntryQuery; use CraftCms\Cms\Element\ElementSources; use CraftCms\Cms\Element\Events\DefineElementCriteria; @@ -45,6 +47,7 @@ use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use Override; use Tpetry\QueryExpressions\Language\Alias; use yii\base\Event; use yii\base\InvalidConfigException; @@ -107,7 +110,7 @@ public static function defaultSelectionLabel(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function phpType(): string { return sprintf('\\%s|\\%s<\\%s>', ElementQueryInterface::class, ElementCollection::class, @@ -117,7 +120,7 @@ public static function phpType(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function dbType(): array|string|null { return Schema::TYPE_JSON; @@ -126,7 +129,7 @@ public static function dbType(): array|string|null /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { /** @var self $field */ @@ -405,7 +408,7 @@ public function __construct(array $config = []) parent::__construct($config); } - #[\Override] + #[Override] public static function getRules(): array { return array_merge(parent::getRules(), [ @@ -532,7 +535,7 @@ public function getSettingsHtml(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getElementValidationRules(): array { $rules = [ @@ -656,7 +659,7 @@ private static function _validateRelatedElement(ElementInterface $source, Elemen /** * {@inheritdoc} */ - #[\Override] + #[Override] public function isValueEmpty(mixed $value, ElementInterface $element): bool { /** @var \CraftCms\Cms\Database\Queries\ElementQuery|ElementCollection $value */ @@ -670,7 +673,7 @@ public function isValueEmpty(mixed $value, ElementInterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function normalizeValue(mixed $value, ?ElementInterface $element): mixed { // If we're propagating a value, and we don't show the site menu, @@ -703,7 +706,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed $value = array_values(array_filter($value)); $query->whereIn('elements.id', $value); if (! empty($value)) { - $query->orderBy(new \CraftCms\Cms\Database\Expressions\FixedOrderExpression('elements.id', $value)); + $query->orderBy(new FixedOrderExpression('elements.id', $value)); } } elseif ($value === null && $element?->id && $this->fetchRelationsFromDbTable($element)) { // If $value is null, the element + field haven’t been saved since updating to Craft 5.3+, @@ -752,8 +755,8 @@ function (JoinClause $join) use ($element, $relationsAlias) { if ( $this->sortable && ! $this->maintainHierarchy && - count($query->orderBy ?? []) === 1 && - ($query->orderBy[0]['column'] ?? null) instanceof \CraftCms\Cms\Database\Expressions\OrderByPlaceholderExpression + count($q->orderBy ?? []) === 1 && + ($q->orderBy[0]['column'] ?? null) instanceof OrderByPlaceholderExpression ) { $q->orderBy("$relationsAlias.sortOrder"); } @@ -813,7 +816,7 @@ private function fetchRelationsFromDbTable(?Elementinterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function serializeValue(mixed $value, ?ElementInterface $element): mixed { if ($this->maintainHierarchy) { @@ -842,7 +845,7 @@ public function getElementConditionRuleType(): array|string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function modifyElementIndexQuery(ElementQueryInterface $query): void { $criteria = [ @@ -865,7 +868,7 @@ public function modifyElementIndexQuery(ElementQueryInterface $query): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getIsTranslatable(?ElementInterface $element): bool { return $this->localizeRelations; @@ -874,7 +877,7 @@ public function getIsTranslatable(?ElementInterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string { return $this->_inputHtml($value, $element, $inline, false); @@ -883,7 +886,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inl /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getStaticHtml(mixed $value, ElementInterface $element): string { return $this->_inputHtml($value, $element, false, true); @@ -952,7 +955,7 @@ private function normalizeValueForInput( /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function searchKeywords(mixed $value, ElementInterface $element): string { /** @var ElementQuery|ElementCollection $value */ @@ -974,7 +977,7 @@ protected function searchKeywords(mixed $value, ElementInterface $element): stri /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getPreviewHtml(mixed $value, ElementInterface $element): string { /** @var ElementQueryInterface|ElementCollection $value */ @@ -995,7 +998,7 @@ public function getPreviewHtml(mixed $value, ElementInterface $element): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function previewPlaceholderHtml(mixed $value, ?ElementInterface $element): string { $mockup = new (static::elementType()); @@ -1099,7 +1102,7 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getContentGqlMutationArgumentType(): array { return [ @@ -1139,7 +1142,7 @@ protected function gqlFieldArguments(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterSave(bool $isNew): void { // If the propagation method just changed, resave all the elements @@ -1232,7 +1235,7 @@ public function getRelationTargetIds(ElementInterface $element): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterElementSave(ElementInterface $element, bool $isNew): void { // Skip if nothing changed, or the element is just propagating and we're not localizing relations @@ -1413,7 +1416,7 @@ public function getViewModeFieldHtml(): ?string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function useFieldset(): bool { return true; @@ -1735,7 +1738,7 @@ protected function availableSources(): array /** * Returns a clone of the element query value, prepped to include disabled and cross-site elements. */ - private function _all(\CraftCms\Cms\Database\Queries\ElementQuery $query, ?ElementInterface $element = null): \CraftCms\Cms\Database\Queries\ElementQuery + private function _all(\CraftCms\Cms\Database\Queries\ElementQuery|ElementQueryInterface $query, ?ElementInterface $element = null): \CraftCms\Cms\Database\Queries\ElementQuery { $clone = (clone $query) ->drafts(null) diff --git a/src/Http/Controllers/Utilities/MigrationsController.php b/src/Http/Controllers/Utilities/MigrationsController.php index d066881d2c7..29e99bb6ace 100644 --- a/src/Http/Controllers/Utilities/MigrationsController.php +++ b/src/Http/Controllers/Utilities/MigrationsController.php @@ -9,7 +9,6 @@ use CraftCms\Cms\Utility\Utilities; use CraftCms\Cms\Utility\Utilities\Migrations; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; use Throwable; use function CraftCms\Cms\cp_redirect; @@ -30,7 +29,7 @@ public function __invoke(Request $request, Migrator $migrator) $migrator->track('content')->run(); Flash::success(t('Applied new migrations successfully.')); } catch (Throwable $e) { - Log::error($e); + report($e); Flash::fail(t('Couldn’t apply new migrations.')); } diff --git a/src/Shared/BasePivot.php b/src/Shared/BasePivot.php new file mode 100644 index 00000000000..87f0b90f31e --- /dev/null +++ b/src/Shared/BasePivot.php @@ -0,0 +1,16 @@ + + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\CraftCms\Cms\Element\Models\Element, $this, ElementSiteSettings> */ public function elements(): BelongsToMany { diff --git a/src/Support/Query.php b/src/Support/Query.php index c3b1810b262..1b6999cbdd7 100644 --- a/src/Support/Query.php +++ b/src/Support/Query.php @@ -102,7 +102,7 @@ * * @param Builder $query The query builder to apply the param to. * @param string|Expression $column The database column that the param is targeting. - * @param string|int|array $value The param value(s). + * @param string|int|array $param The param value(s). * @param string $defaultOperator The default operator to apply to the values * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) * @param bool $caseInsensitive Whether the resulting condition should be case-insensitive @@ -127,7 +127,9 @@ public static function whereParam( ? self::parseColumnType($columnType) : null; - $isMysql = $query->getConnection()->getDriverName() === 'mysql'; + /** @var \Illuminate\Database\Connection $connection */ + $connection = $query->getConnection(); + $isMysql = $connection->getDriverName() === 'mysql'; // Only PostgreSQL supports case-sensitive strings on non-JSON column values if ($isMysql && $columnType !== self::TYPE_JSON) { @@ -546,6 +548,7 @@ private static function parseParamOperator(mixed &$value, string $default, bool $op === '<' => '>=', $op === '>' => '<=', $op === '=' => '!=', + default => throw new InvalidArgumentException("Invalid operator: $op"), }; } From 7e3cbdfbab73571de4a7c5e4dbc84f306d41c2fc Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 18 Nov 2025 09:39:11 +0100 Subject: [PATCH 33/52] Test for QueriesNestedElements --- .../Queries/Concerns/QueriesEagerly.php | 12 +- .../Concerns/QueriesNestedElements.php | 192 +++++++++++++++--- .../Queries/Concerns/QueriesStructures.php | 10 +- src/Database/Queries/ElementQuery.php | 4 +- src/Support/Query.php | 54 +++++ .../Concerns/QueriesNestedElementsTest.php | 55 +++++ yii2-adapter/legacy/helpers/Db.php | 37 +--- 7 files changed, 292 insertions(+), 72 deletions(-) create mode 100644 tests/Database/Queries/Concerns/QueriesNestedElementsTest.php diff --git a/src/Database/Queries/Concerns/QueriesEagerly.php b/src/Database/Queries/Concerns/QueriesEagerly.php index dfd86e0f31b..c002bb10456 100644 --- a/src/Database/Queries/Concerns/QueriesEagerly.php +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -49,15 +49,19 @@ trait QueriesEagerly protected function initQueriesEagerly(): void { - $this->afterQuery(function (Collection $elements) { + $this->afterQuery(function (mixed $result) { + if (! $result instanceof Collection) { + return $result; + } + if (! $this->with) { - return $elements; + return $result; } $elementsService = Craft::$app->getElements(); - $elementsService->eagerLoadElements($this->elementType, $elements->all(), $this->with); + $elementsService->eagerLoadElements($this->elementType, $result->all(), $this->with); - return $elements; + return $result; }); } diff --git a/src/Database/Queries/Concerns/QueriesNestedElements.php b/src/Database/Queries/Concerns/QueriesNestedElements.php index 92ff4c13ae6..592620db183 100644 --- a/src/Database/Queries/Concerns/QueriesNestedElements.php +++ b/src/Database/Queries/Concerns/QueriesNestedElements.php @@ -5,15 +5,16 @@ namespace CraftCms\Cms\Database\Queries\Concerns; use craft\base\ElementInterface; -use craft\helpers\Db; use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Fields; +use CraftCms\Cms\Support\Query; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Tpetry\QueryExpressions\Language\Alias; trait QueriesNestedElements @@ -46,7 +47,7 @@ trait QueriesNestedElements * * @used-by owner() */ - private ?ElementInterface $_owner = null; + private ?ElementInterface $owner = null; /** * @var bool|null Whether the owner elements can be drafts. @@ -69,7 +70,7 @@ abstract protected function getPrimaryOwnerIdColumn(): string; protected function initQueriesNestedElements(): void { $this->beforeQuery(function (ElementQuery $elementQuery) { - /** @var ElementQuery&QueriesNestedElements $elementQuery */ + /** @var \CraftCms\Cms\Database\Queries\EntryQuery $elementQuery */ $this->normalizeNestedElementParams($elementQuery); if ($elementQuery->fieldId === false || $elementQuery->primaryOwnerId === false || $elementQuery->ownerId === false) { @@ -81,8 +82,8 @@ protected function initQueriesNestedElements(): void } $elementQuery->query->addSelect([ - 'elements_owners.ownerId', - 'elements_owners.sortOrder', + 'elements_owners.ownerId as ownerId', + 'elements_owners.sortOrder as sortOrder', ]); $joinClause = function (JoinClause $join) use ($elementQuery) { @@ -138,13 +139,35 @@ function (JoinClause $join) { } /** - * {@inheritdoc} + * Narrows the query results based on the field the {elements} are contained by. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `'foo'` | in a field with a handle of `foo`. + * | `['foo', 'bar']` | in a field with a handle of `foo` or `bar`. + * | a [[craft\fields\Matrix]] object | in a field represented by the object. + * + * --- + * + * ```twig + * {# Fetch {elements} in the Foo field #} + * {% set {elements-var} = {twig-method} + * .field('foo') + * .all() %} + * ``` * - * @uses $fieldId + * ```php + * // Fetch {elements} in the Foo field + * ${elements-var} = {php-method} + * ->field('foo') + * ->all(); + * ``` */ public function field(mixed $value): static { - if (Db::normalizeParam($value, function ($item) { + if (Query::normalizeParam($value, function ($item) { if (is_string($item)) { $item = Fields::getFieldByHandle($item); } @@ -160,9 +183,32 @@ public function field(mixed $value): static } /** - * {@inheritdoc} + * Narrows the query results based on the field the {elements} are contained by, per the fields’ IDs. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | in a field with an ID of 1. + * | `'not 1'` | not in a field with an ID of 1. + * | `[1, 2]` | in a field with an ID of 1 or 2. + * | `['not', 1, 2]` | not in a field with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch {elements} in the field with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .fieldId(1) + * .all() %} + * ``` * - * @uses $fieldId + * ```php + * // Fetch {elements} in the field with an ID of 1 + * ${elements-var} = {php-method} + * ->fieldId(1) + * ->all(); + * ``` */ public function fieldId(mixed $value): static { @@ -172,9 +218,30 @@ public function fieldId(mixed $value): static } /** - * {@inheritdoc} + * Narrows the query results based on the primary owner element of the {elements}, per the owners’ IDs. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `1` | created for an element with an ID of 1. + * | `[1, 2]` | created for an element with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch {elements} created for an element with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .primaryOwnerId(1) + * .all() %} + * ``` * - * @uses $primaryOwnerId + * ```php + * // Fetch {elements} created for an element with an ID of 1 + * ${elements-var} = {php-method} + * ->primaryOwnerId(1) + * ->all(); + * ``` */ public function primaryOwnerId(mixed $value): static { @@ -184,9 +251,23 @@ public function primaryOwnerId(mixed $value): static } /** - * {@inheritdoc} + * Sets the [[primaryOwnerId()]] and [[siteId()]] parameters based on a given element. + * + * --- + * + * ```twig + * {# Fetch {elements} created for this entry #} + * {% set {elements-var} = {twig-method} + * .primaryOwner(myEntry) + * .all() %} + * ``` * - * @uses $primaryOwnerId + * ```php + * // Fetch {elements} created for this entry + * ${elements-var} = {php-method} + * ->primaryOwner($myEntry) + * ->all(); + * ``` */ public function primaryOwner(ElementInterface $primaryOwner): static { @@ -197,36 +278,76 @@ public function primaryOwner(ElementInterface $primaryOwner): static } /** - * {@inheritdoc} + * Narrows the query results based on the owner element of the {elements}, per the owners’ IDs. + * + * Possible values include: * - * @uses $ownerId + * | Value | Fetches {elements}… + * | - | - + * | `1` | created for an element with an ID of 1. + * | `[1, 2]` | created for an element with an ID of 1 or 2. + * + * --- + * + * ```twig + * {# Fetch {elements} created for an element with an ID of 1 #} + * {% set {elements-var} = {twig-method} + * .ownerId(1) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} created for an element with an ID of 1 + * ${elements-var} = {php-method} + * ->ownerId(1) + * ->all(); + * ``` */ public function ownerId(mixed $value): static { $this->ownerId = $value; - $this->_owner = null; + $this->owner = null; return $this; } /** - * {@inheritdoc} + * Sets the [[ownerId()]] and [[siteId()]] parameters based on a given element. + * + * --- * - * @uses $ownerId + * ```twig + * {# Fetch {elements} created for this entry #} + * {% set {elements-var} = {twig-method} + * .owner(myEntry) + * .all() %} + * ``` + * + * ```php + * // Fetch {elements} created for this entry + * ${elements-var} = {php-method} + * ->owner($myEntry) + * ->all(); + * ``` */ public function owner(ElementInterface $owner): static { $this->ownerId = [$owner->id]; $this->siteId = $owner->siteId; - $this->_owner = $owner; + $this->owner = $owner; return $this; } /** - * {@inheritdoc} + * Narrows the query results based on whether the {elements}’ owners are drafts. + * + * Possible values include: * - * @uses $allowOwnerDrafts + * | Value | Fetches {elements}… + * | - | - + * | `true` | which can belong to a draft. + * | `false` | which cannot belong to a draft. */ public function allowOwnerDrafts(?bool $value = true): static { @@ -236,9 +357,14 @@ public function allowOwnerDrafts(?bool $value = true): static } /** - * {@inheritdoc} + * Narrows the query results based on whether the {elements}’ owners are revisions. + * + * Possible values include: * - * @uses $allowOwnerRevisions + * | Value | Fetches {elements}… + * | - | - + * | `true` | which can belong to a revision. + * | `false` | which cannot belong to a revision. */ public function allowOwnerRevisions(?bool $value = true): static { @@ -286,7 +412,7 @@ protected function fieldLayouts(): Collection $fieldLayouts = []; foreach ($this->fieldId as $fieldId) { - $field = app(Fields::class)->getFieldById($fieldId); + $field = Fields::getFieldById($fieldId); if ($field instanceof ElementContainerFieldInterface) { foreach ($field->getFieldLayoutProviders() as $provider) { $fieldLayouts[] = $provider->getFieldLayout(); @@ -323,10 +449,18 @@ private function normalizeFieldId(ElementQuery $query): void if (empty($query->fieldId)) { $query->fieldId = is_array($query->fieldId) ? [] : null; - } elseif (is_numeric($query->fieldId)) { + + return; + } + + if (is_numeric($query->fieldId)) { $query->fieldId = [$query->fieldId]; - } elseif (! is_array($query->fieldId) || ! Arr::isNumeric($query->fieldId)) { - $query->fieldId = \Illuminate\Support\Facades\DB::table(Table::FIELDS) + + return; + } + + if (! is_array($query->fieldId) || ! Arr::isNumeric($query->fieldId)) { + $query->fieldId = DB::table(Table::FIELDS) ->whereNumericParam('id', $query->fieldId) ->pluck('id') ->all(); @@ -343,9 +477,11 @@ private function normalizeOwnerId(mixed $value): array|null|false if (empty($value)) { return null; } + if (is_numeric($value)) { return [$value]; } + if (! is_array($value) || ! Arr::isNumeric($value)) { return false; } diff --git a/src/Database/Queries/Concerns/QueriesStructures.php b/src/Database/Queries/Concerns/QueriesStructures.php index a8e727ca2d5..eb93e4fdd8f 100644 --- a/src/Database/Queries/Concerns/QueriesStructures.php +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -124,16 +124,20 @@ protected function initQueriesStructures(): void $this->applyStructureParams($elementQuery); }); - $this->afterQuery(function (Collection $collection) { + $this->afterQuery(function (mixed $result) { + if (! $result instanceof Collection) { + return $result; + } + if ($this->structureId) { - return $collection->map(function ($element) { + return $result->map(function ($element) { $element->structureId = $this->structureId; return $element; }); } - return $collection; + return $result; }); } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 076cb82451d..a78791c9339 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -481,7 +481,9 @@ public function count($columns = '*'): int return $eagerLoadedCount; } - return $this->query->count($columns); + $result = $this->query->count($columns); + + return $this->applyAfterQueryCallbacks($result); } public function nth(int $n, array|string $columns = ['*']): ?ElementInterface diff --git a/src/Support/Query.php b/src/Support/Query.php index 1b6999cbdd7..0ab9b077dcd 100644 --- a/src/Support/Query.php +++ b/src/Support/Query.php @@ -480,6 +480,60 @@ public static function parseColumnType(string $columnType): ?string return strtolower($matches[0]); } + /** + * Normalizes a param value with a provided resolver function, unless the resolver function ever returns + * an empty value. + * + * If the original param value began with `and`, `or`, or `not`, that will be preserved. + * + * @param mixed $value The param value to be normalized + * @param callable $resolver Method to resolve non-model values to models + * @return bool Whether the value was normalized + */ + public static function normalizeParam(mixed &$value, callable $resolver): bool + { + if ($value === null) { + return true; + } + + if (! is_array($value)) { + $testValue = [$value]; + if (self::normalizeParam($testValue, $resolver)) { + $value = $testValue; + + return true; + } + + return false; + } + + $normalized = []; + + foreach ($value as $item) { + if ( + empty($normalized) && + is_string($item) && + in_array(strtolower($item), [QueryParam::OR, QueryParam::AND, QueryParam::NOT], true) + ) { + $normalized[] = strtolower($item); + + continue; + } + + $item = $resolver($item); + if (! $item) { + // The value couldn't be normalized in full, so bail + return false; + } + + $normalized[] = $item; + } + + $value = $normalized; + + return true; + } + /** * Normalizes “empty” values. * diff --git a/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php b/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php new file mode 100644 index 00000000000..0aa0560709c --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php @@ -0,0 +1,55 @@ +create([ + 'type' => ContentBlock::class, + ]); + + Fields::refreshFields(); + + $entry = Entry::factory()->create(); + $nested = Entry::factory()->create([ + 'primaryOwnerId' => $entry->id, + 'fieldId' => $field->id, + ]); + + DB::table(Table::ELEMENTS_OWNERS) + ->insert([ + 'elementId' => $nested->id, + 'ownerId' => $entry->id, + 'sortOrder' => 1, + ]); + + expect(entryQuery()->count())->toBe(2); + + expect(entryQuery()->fieldId($field->id)->count())->toBe(1); + expect(entryQuery()->fieldId($field->id)->first()->ownerId)->not()->toBeNull(); + expect(entryQuery()->fieldId($field->id)->first()->sortOrder)->not()->toBeNull(); + + expect(entryQuery()->field($field->handle)->count())->toBe(1); + expect(entryQuery()->field(Fields::getFieldById($field->id))->count())->toBe(1); + + expect(entryQuery()->primaryOwner(Craft::$app->getElements()->getElementById($entry->id))->count())->toBe(1); + expect(entryQuery()->primaryOwnerId($entry->id)->count())->toBe(1); + + expect(entryQuery()->ownerId($entry->id)->count())->toBe(1); + expect(entryQuery()->owner(Craft::$app->getElements()->getElementById($entry->id))->count())->toBe(1); + + Craft::$app->getElements()->startCollectingCacheInfo(); + + entryQuery()->fieldId($field->id)->count(); + entryQuery()->ownerId($entry->id)->count(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->toContain('element::craft\elements\Entry::field:'.$field->id); + expect($dependency->tags)->toContain('element::'.$entry->id); +}); diff --git a/yii2-adapter/legacy/helpers/Db.php b/yii2-adapter/legacy/helpers/Db.php index 14f22a492c7..5dacbf86c2b 100644 --- a/yii2-adapter/legacy/helpers/Db.php +++ b/yii2-adapter/legacy/helpers/Db.php @@ -950,42 +950,7 @@ public static function parseTimestampParam( */ public static function normalizeParam(&$value, callable $resolver): bool { - if ($value === null) { - return true; - } - - if (!is_array($value)) { - $testValue = [$value]; - if (static::normalizeParam($testValue, $resolver)) { - $value = $testValue; - return true; - } - return false; - } - - $normalized = []; - - foreach ($value as $item) { - if ( - empty($normalized) && - is_string($item) && - in_array(strtolower($item), [QueryParam::OR, QueryParam::AND, QueryParam::NOT], true) - ) { - $normalized[] = strtolower($item); - continue; - } - - $item = $resolver($item); - if (!$item) { - // The value couldn't be normalized in full, so bail - return false; - } - - $normalized[] = $item; - } - - $value = $normalized; - return true; + return \CraftCms\Cms\Support\Query::normalizeParam($value, $resolver); } /** From 842eeed0b90d16784903c427a0acafe11ee24fd6 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 18 Nov 2025 10:03:33 +0100 Subject: [PATCH 34/52] Test for QueriesPlaceholders --- .../Concerns/QueriesPlaceholderElements.php | 2 +- .../QueriesPlaceholderElementsTest.php | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/Database/Queries/Concerns/QueriesPlaceholderElementsTest.php diff --git a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php index 4973369bae0..f764ad9988d 100644 --- a/src/Database/Queries/Concerns/QueriesPlaceholderElements.php +++ b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php @@ -81,6 +81,6 @@ protected function placeholderCondition(Closure $condition): Closure return $condition; } - return fn (Builder $q) => $q->where($condition($q))->orWhere($this->placeholderCondition); + return fn (Builder $q) => $q->where($condition)->orWhere($this->placeholderCondition); } } diff --git a/tests/Database/Queries/Concerns/QueriesPlaceholderElementsTest.php b/tests/Database/Queries/Concerns/QueriesPlaceholderElementsTest.php new file mode 100644 index 00000000000..d88d1aa7a01 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesPlaceholderElementsTest.php @@ -0,0 +1,23 @@ +create(); + $entry->element->siteSettings()->first()->update([ + 'title' => 'Old title', + ]); + + $element = Craft::$app->getElements()->getElementById($entry->id); + + expect($element->title)->toBe('Old title'); + + $element->title = 'New title'; + + expect(entryQuery()->id($entry->id)->first()->title)->toBe('Old title'); + + Craft::$app->getElements()->setPlaceholderElement($element); + + expect(entryQuery()->id($entry->id)->first()->title)->toBe('New title'); + expect(entryQuery()->id($entry->id)->ignorePlaceholders()->first()->title)->toBe('Old title'); +}); From f261fb22ab56593e03230d5446199be4fd4b56e8 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 18 Nov 2025 12:19:39 +0100 Subject: [PATCH 35/52] Replace ElementRelationParamParser and add test for QueriesRelatedElements --- src/Database/ElementRelationParamFilter.php | 466 ++++++++++++++++++ .../Concerns/QueriesRelatedElements.php | 63 ++- src/Database/Queries/ElementQuery.php | 3 - .../Concerns/QueriesRelatedElementsTest.php | 61 +++ 4 files changed, 557 insertions(+), 36 deletions(-) create mode 100644 src/Database/ElementRelationParamFilter.php create mode 100644 tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php diff --git a/src/Database/ElementRelationParamFilter.php b/src/Database/ElementRelationParamFilter.php new file mode 100644 index 00000000000..c14bb6a705c --- /dev/null +++ b/src/Database/ElementRelationParamFilter.php @@ -0,0 +1,466 @@ +explode(',')->all(); + } elseif ($relatedToParam instanceof Collection) { + $relatedToParam = $relatedToParam->all(); + } else { + $relatedToParam = [$relatedToParam]; + } + } + + if ( + isset($relatedToParam[0]) && + is_string($relatedToParam[0]) && + in_array($relatedToParam[0], ['and', 'or']) + ) { + $glue = array_shift($relatedToParam); + if ($glue === 'and' && count($relatedToParam) < 2) { + $glue = 'or'; + } + } else { + $glue = 'or'; + } + + if (isset($relatedToParam['element']) || isset($relatedToParam['sourceElement']) || isset($relatedToParam['targetElement'])) { + $relatedToParam = [$relatedToParam]; + } + + $relatedToParam = Collection::make($relatedToParam)->map( + fn ($relatedToParam) => self::normalizeRelatedToCriteria($relatedToParam, $siteId), + )->all(); + + if ($glue === 'or') { + // Group all of the OR elements, so we avoid adding massive JOINs to the query + $orElements = []; + + foreach ($relatedToParam as $i => $relCriteria) { + if ( + isset($relCriteria['element']) && + $relCriteria['element'][0] === 'or' + && $relCriteria['field'] === null && + $relCriteria['sourceSite'] === $siteId + ) { + array_push($orElements, ...array_slice($relCriteria['element'], 1)); + unset($relatedToParam[$i]); + } + } + + if (! empty($orElements)) { + $relatedToParam[] = self::normalizeRelatedToCriteria($orElements, $siteId); + } + } + + array_unshift($relatedToParam, $glue); + + return $relatedToParam; + } + + /** + * Normalizes an individual `relatedTo` criteria. + * + * @param int|string|int[]|null $siteId + * + * @throws InvalidArgumentException if the criteria contains an invalid site handle + */ + public static function normalizeRelatedToCriteria(mixed $relCriteria, array|int|string|null $siteId = null): array + { + if ( + ! is_array($relCriteria) || + (! isset($relCriteria['element']) && ! isset($relCriteria['sourceElement']) && ! isset($relCriteria['targetElement'])) + ) { + $relCriteria = ['element' => $relCriteria]; + } + + // Merge in default criteria params + $relCriteria += [ + 'field' => null, + 'sourceSite' => $siteId, + ]; + + // Normalize the sourceSite param (should be an ID) + if ($relCriteria['sourceSite']) { + if ( + ! is_numeric($relCriteria['sourceSite']) && + (! is_array($relCriteria['sourceSite']) || ! Arr::isNumeric($relCriteria['sourceSite'])) + ) { + if ($relCriteria['sourceSite'] instanceof Site) { + $relCriteria['sourceSite'] = $relCriteria['sourceSite']->id; + } else { + $site = Sites::getSiteByHandle($relCriteria['sourceSite']); + if (! $site) { + // Invalid handle + throw new InvalidArgumentException("Invalid site: {$relCriteria['sourceSite']}"); + } + $relCriteria['sourceSite'] = $site->id; + } + } + } + + // Normalize the elements + foreach (['element', 'sourceElement', 'targetElement'] as $elementParam) { + if (isset($relCriteria[$elementParam])) { + $elements = &$relCriteria[$elementParam]; + + if (! is_array($elements)) { + if (is_string($elements)) { + $elements = str($elements)->explode(','); + } + + if ($elements instanceof Collection) { + $elements = $elements->all(); + } else { + $elements = [$elements]; + } + } + + if ( + isset($elements[0]) && + is_string($elements[0]) && + in_array($elements[0], ['and', 'or']) + ) { + $glue = array_shift($elements); + if ($glue === 'and' && count($elements) < 2) { + $glue = 'or'; + } + } else { + $glue = 'or'; + } + + array_unshift($elements, $glue); + break; + } + } + + return $relCriteria; + } + + /** + * Parses a `relatedTo` element query param and returns the condition that should + * be applied back on the element query, or `false` if there's an issue. + * + * @param int|string|int[]|null $siteId + */ + public function apply(Builder $query, mixed $relatedToParam, array|int|string|null $siteId = null): Builder + { + $relatedToParam = self::normalizeRelatedToParam($relatedToParam, $siteId); + $glue = array_shift($relatedToParam); + + if (empty($relatedToParam)) { + return $query; + } + + return $query->where(function (Builder $query) use ($relatedToParam, $glue) { + foreach ($relatedToParam as $relCriteria) { + $query->where(fn (Builder $query) => $this->subparse($query, $relCriteria), boolean: $glue); + } + }); + } + + /** + * Parses a part of a relatedTo element query param and returns the condition or `false` if there's an issue. + */ + private function subparse(Builder $query, mixed $relCriteria): Builder + { + // Get the element IDs, wherever they are + $relElementIds = []; + $relSourceElementIds = []; + $glue = 'or'; + + $elementParams = ['element', 'sourceElement', 'targetElement']; + + foreach ($elementParams as $elementParam) { + if (! isset($relCriteria[$elementParam])) { + continue; + } + + $elements = $relCriteria[$elementParam]; + $glue = array_shift($elements); + + foreach ($elements as $element) { + if (is_numeric($element)) { + $relElementIds[] = $element; + if ($elementParam === 'element') { + $relSourceElementIds[] = $element; + } + } elseif ($element instanceof ElementInterface) { + if ($elementParam === 'targetElement') { + $relElementIds[] = $element->getCanonicalId(); + } else { + $relElementIds[] = $element->id; + if ($elementParam === 'element') { + $relSourceElementIds[] = $element->getCanonicalId(); + } + } + } elseif ($element instanceof ElementQueryInterface) { + $ids = $element->ids(); + array_push($relElementIds, ...$ids); + if ($elementParam === 'element') { + array_push($relSourceElementIds, ...$ids); + } + } + } + + break; + } + + if (empty($relElementIds)) { + return $query; + } + + // Going both ways? + if ($elementParam === 'element') { + array_unshift($relElementIds, $glue); + + return $this->apply($query, [ + 'or', + [ + 'sourceElement' => $relElementIds, + 'field' => $relCriteria['field'], + 'sourceSite' => $relCriteria['sourceSite'], + ], + [ + 'targetElement' => $relSourceElementIds, + 'field' => $relCriteria['field'], + 'sourceSite' => $relCriteria['sourceSite'], + ], + ]); + } + + // Figure out which direction we’re going + if ($elementParam === 'sourceElement') { + $dir = self::DIR_FORWARD; + } else { + $dir = self::DIR_REVERSE; + } + + // Do we need to check for *all* of the element IDs? + if ($glue === 'and') { + // Spread it across multiple relation sub-params + $newRelatedToParam = ['and']; + + foreach ($relElementIds as $elementId) { + $newRelatedToParam[] = [$elementParam => [$elementId]]; + } + + return $this->apply($query, $newRelatedToParam); + } + $relationFieldIds = []; + + if ($relCriteria['field']) { + // Loop through all of the fields in this rel criteria, create the Matrix-specific + // conditions right away and save the normal field IDs for later + $fields = $relCriteria['field']; + if (! is_array($fields)) { + $fields = is_string($fields) ? str($fields)->explode(',') : [$fields]; + } + + // We only care about the fields provided by the element query if the target elements were specified, + // and the element query is fetching the source elements where the provided field(s) actually exist. + $useElementQueryFields = $dir === self::DIR_REVERSE; + + foreach ($fields as $field) { + if (($fieldModel = $this->getField($field, $fieldHandleParts, $useElementQueryFields)) === null) { + Log::warning('Attempting to load relations for an invalid field: '.$field); + + return $query; + } + + if ($fieldModel instanceof BaseRelationField) { + // We'll deal with normal relation fields all together + $relationFieldIds[] = $fieldModel->id; + } elseif ($fieldModel instanceof Matrix) { + $nestedFieldIds = []; + + // Searching by a specific nested field? + if (isset($fieldHandleParts[1])) { + // There could be more than one field with this handle, so we must loop through all + // the field layouts on this field + foreach ($fieldModel->getEntryTypes() as $entryType) { + $nestedField = $entryType->getFieldLayout()->getFieldByHandle($fieldHandleParts[1]); + if ($nestedField) { + $nestedFieldIds[] = $nestedField->id; + } + } + + if (empty($nestedFieldIds)) { + continue; + } + } + + if ($dir === self::DIR_FORWARD) { + self::$relateSourcesCount++; + self::$relateTargetNestedElementsCount++; + + $sourcesAlias = 'sources'.self::$relateSourcesCount; + $targetNestedElementsAlias = 'target_nestedelements'.self::$relateTargetNestedElementsCount; + $targetContainerElementsAlias = 'target_containerelements'.self::$relateTargetNestedElementsCount; + + $subQuery = DB::table(Table::RELATIONS, $sourcesAlias) + ->select("$sourcesAlias.targetId") + ->join(new Alias(Table::ENTRIES, $targetNestedElementsAlias), "$targetNestedElementsAlias.id", '=', "$sourcesAlias.sourceId") + ->join(new Alias(Table::ELEMENTS, $targetContainerElementsAlias), "$targetContainerElementsAlias.id", '=', "$targetNestedElementsAlias.id") + ->whereIn("$targetNestedElementsAlias.primaryOwnerId", $relElementIds) + ->where("$targetNestedElementsAlias.fieldId", $fieldModel->id) + ->where("$targetContainerElementsAlias.enabled", true) + ->whereNull("$targetContainerElementsAlias.dateDeleted"); + + if ($relCriteria['sourceSite']) { + $subQuery->where(function (Builder $query) use ($relCriteria, $sourcesAlias) { + $query->whereNull("$sourcesAlias.sourceSiteId") + ->orWhere("$sourcesAlias.sourceSiteId", $relCriteria['sourceSite']); + }); + } + + if (! empty($nestedFieldIds)) { + $subQuery->whereIn("$sourcesAlias.fieldId", $nestedFieldIds); + } + } else { + self::$relateSourceNestedElementsCount++; + $sourceNestedElementsAlias = 'source_nestedelements'.self::$relateSourceNestedElementsCount; + $sourceContainerElementsAlias = 'source_containerelements'.self::$relateSourceNestedElementsCount; + $nestedElementTargetsAlias = 'nestedelement_targets'.self::$relateSourceNestedElementsCount; + + $subQuery = DB::table(Table::ENTRIES, $sourceNestedElementsAlias) + ->select("$sourceNestedElementsAlias.primaryOwnerId") + ->join(new Alias(Table::ELEMENTS, $sourceContainerElementsAlias), "$sourceContainerElementsAlias.id", '=', "$sourceNestedElementsAlias.id") + ->join(new Alias(Table::RELATIONS, $nestedElementTargetsAlias), "$nestedElementTargetsAlias.sourceId", '=', "$sourceNestedElementsAlias.id") + ->where("$sourceContainerElementsAlias.enabled", true) + ->whereNull("$sourceContainerElementsAlias.dateDeleted") + ->whereIn("$nestedElementTargetsAlias.targetId", $relElementIds) + ->where("$sourceNestedElementsAlias.fieldId", $fieldModel->id); + + if ($relCriteria['sourceSite']) { + $subQuery->where(function (Builder $query) use ($relCriteria, $nestedElementTargetsAlias) { + $query->whereNull("$nestedElementTargetsAlias.sourceSiteId") + ->orWhere("$nestedElementTargetsAlias.sourceSiteId", $relCriteria['sourceSite']); + }); + } + + if (! empty($nestedFieldIds)) { + $subQuery->whereIn("$nestedElementTargetsAlias.fieldId", $nestedFieldIds); + } + } + + $query->orWhereIn('elements.id', $subQuery); + + unset($subQuery); + } else { + Log::warning('Attempting to load relations for a non-relational field: '.$fieldModel->handle); + + return $query; + } + } + } + + // If there were no fields, or there are some non-Matrix fields, add the + // normal relation condition. (Basically, run this code if the rel criteria wasn't exclusively for + // Matrix fields.) + if (empty($relCriteria['field']) || ! empty($relationFieldIds)) { + if ($dir === self::DIR_FORWARD) { + self::$relateSourcesCount++; + $relTableAlias = 'sources'.self::$relateSourcesCount; + $relConditionColumn = 'sourceId'; + $relElementColumn = 'targetId'; + } else { + self::$relateTargetsCount++; + $relTableAlias = 'targets'.self::$relateTargetsCount; + $relConditionColumn = 'targetId'; + $relElementColumn = 'sourceId'; + } + + $subQuery = DB::table(Table::RELATIONS, $relTableAlias) + ->select("$relTableAlias.$relElementColumn") + ->whereIn("$relTableAlias.$relConditionColumn", $relElementIds); + + if ($relCriteria['sourceSite']) { + $subQuery->where(function (Builder $query) use ($relCriteria, $relTableAlias) { + $query->whereNull("$relTableAlias.sourceSiteId") + ->orWhere("$relTableAlias.sourceSiteId", $relCriteria['sourceSite']); + }); + } + + if (! empty($relationFieldIds)) { + $subQuery->whereIn("$relTableAlias.fieldId", $relationFieldIds); + } + + $query->orWhereIn('elements.id', $subQuery); + } + + return $query; + } + + /** + * Returns a field model based on its handle or ID. + */ + private function getField(mixed $field, ?array &$fieldHandleParts, bool $useElementQueryFields): ?FieldInterface + { + if (is_numeric($field)) { + $fieldHandleParts = null; + + return Fields::getFieldById($field); + } + + $fieldHandleParts = explode('.', (string) $field); + $fieldHandle = $fieldHandleParts[0]; + + if ($useElementQueryFields) { + return $this->fields[$fieldHandle] ?? null; + } + + return Fields::getFieldByHandle($fieldHandle); + } +} diff --git a/src/Database/Queries/Concerns/QueriesRelatedElements.php b/src/Database/Queries/Concerns/QueriesRelatedElements.php index b7c71140bb2..b62502bd571 100644 --- a/src/Database/Queries/Concerns/QueriesRelatedElements.php +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -4,11 +4,11 @@ namespace CraftCms\Cms\Database\Queries\Concerns; -use craft\db\QueryAbortedException; -use craft\elements\db\ElementRelationParamParser; +use CraftCms\Cms\Database\ElementRelationParamFilter; use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Support\Arr; +use Illuminate\Database\Query\Builder; use RuntimeException; /** @@ -47,20 +47,18 @@ private function applyRelatedToParam(): void return; } - $parser = new ElementRelationParamParser([ - 'fields' => $elementQuery->customFields ? Arr::keyBy( - $elementQuery->customFields, - fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, - ) : [], - ]); - - $condition = $parser->parse($elementQuery->relatedTo, $elementQuery->siteId !== '*' ? $elementQuery->siteId : null); - - if ($condition === false) { - throw new QueryAbortedException; - } - - $elementQuery->subQuery->where($condition); + new ElementRelationParamFilter( + fields: $elementQuery->customFields + ? Arr::keyBy( + $elementQuery->customFields, + fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, + ) + : [] + )->apply( + query: $elementQuery->subQuery, + relatedToParam: $elementQuery->relatedTo, + siteId: $elementQuery->siteId !== '*' ? $elementQuery->siteId : null + ); }); } @@ -73,21 +71,20 @@ private function applyNotRelatedToParam(): void $notRelatedToParam = $elementQuery->notRelatedTo; - $parser = new ElementRelationParamParser([ - 'fields' => $elementQuery->customFields ? Arr::keyBy( - $elementQuery->customFields, - fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, - ) : [], - ]); - - $condition = $parser->parse($notRelatedToParam, $elementQuery->siteId !== '*' ? $elementQuery->siteId : null); - - if ($condition === false) { - // just don't modify the query - return; - } - - $elementQuery->subQuery->whereNot($condition); + $elementQuery->subQuery->whereNot(function (Builder $query) use ($notRelatedToParam, $elementQuery) { + new ElementRelationParamFilter( + fields: $elementQuery->customFields + ? Arr::keyBy( + $elementQuery->customFields, + fn (FieldInterface $field) => $field->layoutElement?->getOriginalHandle() ?? $field->handle, + ) + : [] + )->apply( + query: $query, + relatedToParam: $notRelatedToParam, + siteId: $elementQuery->siteId !== '*' ? $elementQuery->siteId : null + ); + }); }); } @@ -226,7 +223,7 @@ private function _andRelatedToCriteria($value, $currentValue): mixed } // Normalize so element/targetElement/sourceElement values get pushed down to the 2nd level - $relatedTo = ElementRelationParamParser::normalizeRelatedToParam($currentValue); + $relatedTo = ElementRelationParamFilter::normalizeRelatedToParam($currentValue); $criteriaCount = count($relatedTo) - 1; // Not possible to switch from `or` to `and` if there are multiple criteria @@ -235,7 +232,7 @@ private function _andRelatedToCriteria($value, $currentValue): mixed } $relatedTo[0] = $criteriaCount > 0 ? 'and' : 'or'; - $relatedTo[] = ElementRelationParamParser::normalizeRelatedToCriteria($value); + $relatedTo[] = ElementRelationParamFilter::normalizeRelatedToCriteria($value); return $relatedTo; } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index a78791c9339..e4c35569c76 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -856,9 +856,6 @@ public function applyBeforeQueryCallbacks(): void protected function elementQueryBeforeQuery(): void { - // Is the query already doomed? - throw_if(isset($this->id) && empty($this->id), QueryAbortedException::class); - // Give other classes a chance to make changes up front /*if (!$this->beforePrepare()) { throw new QueryAbortedException(); diff --git a/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php b/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php new file mode 100644 index 00000000000..d3c0a4aa3c8 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php @@ -0,0 +1,61 @@ +create([ + 'handle' => 'entriesField', + 'type' => Entries::class, + ]); + + $fieldLayout = FieldLayout::create([ + 'type' => Entry::class, + 'config' => [ + 'tabs' => [ + [ + 'uid' => \Illuminate\Support\Str::uuid()->toString(), + 'name' => 'Tab 1', + 'elements' => [ + [ + 'uid' => \Illuminate\Support\Str::uuid()->toString(), + 'type' => CustomField::class, + 'fieldUid' => $field->uid, + ], + ], + ], + ], + ], + ]); + + $entries = EntryModel::factory(3)->create(); + foreach ($entries as $entryModel) { + $entryModel->element->update([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + + $entryModel->entryType->update([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + } + + CustomFieldBehavior::$fieldHandles[$field->handle] = true; + + Fields::refreshFields(); + + $entry = entryQuery()->firstOrFail(); + $entry->title = 'Test entry'; + $entry->setFieldValue('entriesField', $entries[1]->id); + + Craft::$app->getElements()->saveElement($entry); + + expect(entryQuery()->count())->toBe(3); + expect(entryQuery()->relatedTo($entries[1]->id)->count())->toBe(1); + expect(entryQuery()->notRelatedTo($entries[1]->id)->count())->toBe(2); +}); From fb2f0f32a8c9dbdc7e51af3495f236858f111667 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 18 Nov 2025 12:26:55 +0100 Subject: [PATCH 36/52] Avoid extra groups when there is only 1 param --- src/Database/ElementRelationParamFilter.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Database/ElementRelationParamFilter.php b/src/Database/ElementRelationParamFilter.php index c14bb6a705c..6361c161374 100644 --- a/src/Database/ElementRelationParamFilter.php +++ b/src/Database/ElementRelationParamFilter.php @@ -203,6 +203,10 @@ public function apply(Builder $query, mixed $relatedToParam, array|int|string|nu return $query; } + if (count($relatedToParam) === 1) { + return $this->subparse($query, $relatedToParam[0]); + } + return $query->where(function (Builder $query) use ($relatedToParam, $glue) { foreach ($relatedToParam as $relCriteria) { $query->where(fn (Builder $query) => $this->subparse($query, $relCriteria), boolean: $glue); From 24366f260fc40389ee0ad9148e40caeead85f8e4 Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 18 Nov 2025 12:51:24 +0100 Subject: [PATCH 37/52] Add more tests for EntryQuery --- database/Factories/ElementFactory.php | 8 +- database/Factories/EntryFactory.php | 18 +++ src/Database/Queries/Concerns/QueriesRef.php | 77 ++++++++++++ src/Database/Queries/EntryQuery.php | 117 +++++------------- src/User/Models/User.php | 2 +- .../Queries/Concerns/QueriesRefTest.php | 13 ++ tests/Database/Queries/EntryQueryTest.php | 102 +++++++++++++++ 7 files changed, 251 insertions(+), 86 deletions(-) create mode 100644 src/Database/Queries/Concerns/QueriesRef.php create mode 100644 tests/Database/Queries/Concerns/QueriesRefTest.php create mode 100644 tests/Database/Queries/EntryQueryTest.php diff --git a/database/Factories/ElementFactory.php b/database/Factories/ElementFactory.php index a8d33aa5b9c..5dc23dbd68a 100644 --- a/database/Factories/ElementFactory.php +++ b/database/Factories/ElementFactory.php @@ -11,12 +11,13 @@ use CraftCms\Cms\Support\Str; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\DB; +use Override; final class ElementFactory extends Factory { protected $model = Element::class; - #[\Override] + #[Override] public function definition(): array { return [ @@ -27,13 +28,16 @@ public function definition(): array ]; } - #[\Override] + #[Override] public function configure(): self { return $this->afterCreating(function (Element $element) { DB::table(Table::ELEMENTS_SITES) ->insert([ 'elementId' => $element->id, + 'title' => fake()->words(asText: true), + 'slug' => fake()->slug(), + 'uri' => fake()->slug(), 'enabled' => true, 'siteId' => Site::first()->id, 'dateCreated' => $element->dateCreated, diff --git a/database/Factories/EntryFactory.php b/database/Factories/EntryFactory.php index 2d98f6432a8..8d9396ea711 100644 --- a/database/Factories/EntryFactory.php +++ b/database/Factories/EntryFactory.php @@ -69,4 +69,22 @@ public function disabled(bool $disabled = true): self 'id' => $attributes['id']->set('enabled', ! $disabled), ]); } + + public function pending(bool $pending = true): self + { + return $this->state(fn (array $attributes) => [ + 'postDate' => $pending + ? fake()->dateTimeBetween('+1 day', '+1 year') + : fake()->dateTime(), + ]); + } + + public function expired(bool $expired = true): self + { + return $this->state(fn (array $attributes) => [ + 'expiryDate' => $expired + ? fake()->dateTime() + : fake()->dateTimeBetween('+1 day', '+1 year'), + ]); + } } diff --git a/src/Database/Queries/Concerns/QueriesRef.php b/src/Database/Queries/Concerns/QueriesRef.php new file mode 100644 index 00000000000..e4dff261b11 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesRef.php @@ -0,0 +1,77 @@ +beforeQuery(function (EntryQuery $query) { + if (! $query->ref) { + return; + } + + $refs = $query->ref; + if (! is_array($refs)) { + $refs = is_string($refs) ? str($refs)->explode(',') : [$refs]; + } + + $joinSections = false; + $query->subQuery->where(function (Builder $query) use (&$joinSections, $refs) { + foreach ($refs as $ref) { + $parts = array_filter(explode('/', (string) $ref)); + + if (empty($parts)) { + continue; + } + + if (count($parts) === 1) { + $query->orWhereParam('elements_sites.slug', $parts[0]); + + continue; + } + + $query->where(function (Builder $query) use ($parts) { + $query->whereParam('sections.handle', $parts[0]) + ->whereParam('elements_sites.slug', $parts[1]); + }); + + $joinSections = true; + } + }); + + if ($joinSections) { + $this->subQuery->join(new Alias(Table::SECTIONS, 'sections'), 'sections.id', '=', 'entries.sectionId'); + } + }); + } + + /** + * Narrows the query results based on a reference string. + */ + public function ref(mixed $value): self + { + $this->ref = $value; + + return $this; + } +} diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 4374d4d36f5..095a25ef2f6 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -5,15 +5,15 @@ namespace CraftCms\Cms\Database\Queries; use Closure; -use craft\db\Query; -use craft\db\QueryAbortedException; use craft\elements\Entry; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Queries\Concerns\QueriesAuthors; use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryDates; use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryTypes; use CraftCms\Cms\Database\Queries\Concerns\QueriesNestedElements; +use CraftCms\Cms\Database\Queries\Concerns\QueriesRef; use CraftCms\Cms\Database\Queries\Concerns\QueriesSections; +use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Support\Arr; @@ -23,7 +23,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; -use Tpetry\QueryExpressions\Language\Alias; +use Illuminate\Support\Facades\DB; final class EntryQuery extends ElementQuery { @@ -34,6 +34,7 @@ final class EntryQuery extends ElementQuery cacheTags as nestedTraitCacheTags; fieldLayouts as nestedTraitFieldLayouts; } + use QueriesRef; use QueriesSections; /** @@ -44,15 +45,6 @@ final class EntryQuery extends ElementQuery 'elements.id' => SORT_DESC, ]; - /** - * @var mixed The reference code(s) used to identify the element(s). - * - * This property is set when accessing elements via their reference tags, e.g. `{entry:section/slug}`. - * - * @used-by ElementQuery::ref() - */ - public mixed $ref = null; - protected function getFieldIdColumn(): string { return 'entries.fieldId'; @@ -106,7 +98,6 @@ public function __construct(array $config = []) $this->beforeQuery(function (self $query) { $this->applyAuthParam($query, $query->editable, 'viewEntries', 'viewPeerEntries', 'viewPeerEntryDrafts'); $this->applyAuthParam($query, $query->savable, 'saveEntries', 'savePeerEntries', 'savePeerEntryDrafts'); - $this->applyRefParam($query); }); } @@ -116,7 +107,10 @@ protected function statusCondition(string $status): Closure Cms::config()->staticStatuses && in_array($status, [Entry::STATUS_LIVE, Entry::STATUS_PENDING, Entry::STATUS_EXPIRED]) ) { - return fn (Builder $query) => $query->where('elements.enabled', true)->where('elements_sites.enabled', true)->where('entries.status', $status); + return fn (Builder $query) => $query + ->where('elements.enabled', true) + ->where('elements_sites.enabled', true) + ->where('entries.status', $status); } // Always consider “now” to be the current time @ 59 seconds into the minute. @@ -212,18 +206,6 @@ public function status(array|string|null $value): static return parent::status($value); } - /** - * {@inheritdoc} - * - * @uses $ref - */ - public function ref($value): self - { - $this->ref = $value; - - return $this; - } - /** * @throws QueryAbortedException */ @@ -251,6 +233,8 @@ private function applyAuthParam( } $query->subQuery->where(function (Builder $query) use ($value, $peerDraftPermissionPrefix, $peerPermissionPrefix, $permissionPrefix, $user, $sections) { + $partialAccessSections = []; + foreach ($sections as $section) { if (! $user->can("$permissionPrefix:$section->uid")) { continue; @@ -260,12 +244,14 @@ private function applyAuthParam( $excludePeerDrafts = $this->drafts !== false && ! $user->can("$peerDraftPermissionPrefix:$section->uid"); if ($excludePeerEntries || $excludePeerDrafts) { + $partialAccessSections[] = $section->id; + $query->orWhere(function (Builder $query) use ($excludePeerDrafts, $user, $excludePeerEntries, $section) { $query->where('entries.sectionId', $section->id); if ($excludePeerEntries) { $query->whereExists( - \Illuminate\Support\Facades\DB::table(Table::ENTRIES_AUTHORS, 'entries_authors') + DB::table(Table::ENTRIES_AUTHORS, 'entries_authors') ->whereColumn('entries_authors.entryId', 'entries.id') ->where('entries_authors.authorId', $user->id) ); @@ -295,50 +281,12 @@ private function applyAuthParam( $query->orWhereIn('entries.sectionId', $fullyAuthorizedSectionIds); } - }, boolean: $value ? 'and' : 'and not'); - } - - /** - * Applies the 'ref' param to the query being prepared. - */ - private function applyRefParam(self $query): void - { - if (! $query->ref) { - return; - } - $refs = $query->ref; - if (! is_array($refs)) { - $refs = is_string($refs) ? str($refs)->explode(',') : [$refs]; - } - - $joinSections = false; - $query->subQuery->where(function (Builder $query) use (&$joinSections, $refs) { - foreach ($refs as $ref) { - $parts = array_filter(explode('/', $ref)); - - if (empty($parts)) { - continue; - } - - if (count($parts) === 1) { - $query->orWhereParam('elements_sites.slug', $parts[0]); - - continue; - } - - $query->where(function (Builder $query) use ($parts) { - $query->whereParam('sections.handle', $parts[0]) - ->whereParam('elements_sites.slug', $parts[1]); - }); - - $joinSections = true; + // They don't have access to anything + if (empty($partialAccessSections) && $value) { + throw new QueryAbortedException; } - }); - - if ($joinSections) { - $this->subQuery->join(new Alias(Table::SECTIONS, 'sections'), 'sections.id', '=', 'entries.sectionId'); - } + }, boolean: $value ? 'and' : 'and not'); } /** @@ -372,21 +320,24 @@ protected function fieldLayouts(): Collection $this->normalizeTypeId($this); $this->normalizeSectionId($this); - if ($this->typeId || $this->sectionId) { - $fieldLayouts = []; - if ($this->typeId) { - foreach ($this->typeId as $entryTypeId) { - $entryType = EntryTypes::getEntryTypeById($entryTypeId); - if ($entryType) { - $fieldLayouts[] = $entryType->getFieldLayout(); - } + $fieldLayouts = []; + + if ($this->typeId) { + foreach ($this->typeId as $entryTypeId) { + $entryType = EntryTypes::getEntryTypeById($entryTypeId); + if ($entryType) { + $fieldLayouts[] = $entryType->getFieldLayout(); } - } else { - foreach ($this->sectionId as $sectionId) { - if ($section = Sections::getSectionById($sectionId)) { - foreach ($section->getEntryTypes() as $entryType) { - $fieldLayouts[] = $entryType->getFieldLayout(); - } + } + + return collect($fieldLayouts); + } + + if ($this->sectionId) { + foreach ($this->sectionId as $sectionId) { + if ($section = Sections::getSectionById($sectionId)) { + foreach ($section->getEntryTypes() as $entryType) { + $fieldLayouts[] = $entryType->getFieldLayout(); } } } diff --git a/src/User/Models/User.php b/src/User/Models/User.php index 9c4fed2bc69..58d97f1bfc9 100644 --- a/src/User/Models/User.php +++ b/src/User/Models/User.php @@ -49,7 +49,7 @@ class User extends BaseModel implements AuthenticatableContract, AuthorizableCon public function isAdmin(): bool { - return $this->admin; + return (bool) $this->admin; } /** diff --git a/tests/Database/Queries/Concerns/QueriesRefTest.php b/tests/Database/Queries/Concerns/QueriesRefTest.php new file mode 100644 index 00000000000..d695dbc8aba --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesRefTest.php @@ -0,0 +1,13 @@ +create(); + + $element = Craft::$app->getElements()->getElementById($entry->id); + + expect(entryQuery()->count())->toBe(1); + expect(entryQuery()->ref($entry->slug)->count())->toBe(1); + expect(entryQuery()->ref("{$element->section->handle}/{$element->slug}")->count())->toBe(1); +}); diff --git a/tests/Database/Queries/EntryQueryTest.php b/tests/Database/Queries/EntryQueryTest.php new file mode 100644 index 00000000000..a4fbc867c22 --- /dev/null +++ b/tests/Database/Queries/EntryQueryTest.php @@ -0,0 +1,102 @@ +expectException(QueryAbortedException::class); + + entryQuery()->editable()->count(); +}); + +test('editable/savable throws when having no access', function (string $method) { + Edition::set(Edition::Pro); + + actingAs(User::first()); + + EntryModel::factory()->create(); + + Sections::refreshSections(); + + expect(entryQuery()->$method()->count())->toBe(1); + + actingAs(User::factory()->create()); + + // Access to nothing + $this->expectException(QueryAbortedException::class); + + entryQuery()->$method()->count(); +})->with([ + 'editable', + 'savable', +]); + +test('savable requires authentication', function () { + $this->expectException(QueryAbortedException::class); + + entryQuery()->savable()->count(); +}); + +test('savable', function () { + actingAs(User::first()); + + EntryModel::factory()->create(); + + expect(entryQuery()->savable()->count())->toBe(1); +}); + +test('status', function () { + EntryModel::factory()->create(); + EntryModel::factory()->pending()->create(); + EntryModel::factory()->expired()->create(); + + expect(entryQuery()->count())->toBe(1); + expect(entryQuery()->status(Entry::STATUS_PENDING)->count())->toBe(1); + expect(entryQuery()->status(Entry::STATUS_EXPIRED)->count())->toBe(1); +}); + +test('it adds the entry type as a cache tag', function () { + Craft::$app->getElements()->startCollectingCacheInfo(); + + $entry = EntryModel::factory()->create(); + + entryQuery()->typeId($entry->typeId)->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->toContain('element::'.Entry::class.'::entryType:'.$entry->typeId); +}); + +test('it adds the section id as a cache tag', function () { + Craft::$app->getElements()->startCollectingCacheInfo(); + + $entry = EntryModel::factory()->create(); + + entryQuery()->sectionId($entry->sectionId)->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->toContain('element::'.Entry::class.'::section:'.$entry->sectionId); +}); + +test('it only adds the entry type id as a cache tag whyen both section and type are added', function () { + Craft::$app->getElements()->startCollectingCacheInfo(); + + $entry = EntryModel::factory()->create(); + + entryQuery()->typeId($entry->typeId)->sectionId($entry->sectionId)->all(); + + /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ + $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; + + expect($dependency->tags)->not()->toContain('element::'.Entry::class.'::section:'.$entry->sectionId); + expect($dependency->tags)->toContain('element::'.Entry::class.'::entryType:'.$entry->typeId); +}); From f7341cd0a3f5387167e7b4e8cd09c9558cbf3a7f Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 18 Nov 2025 14:01:13 +0100 Subject: [PATCH 38/52] Fix phpstan errors --- .../Queries/Concerns/HydratesElements.php | 17 ++++--- src/Database/Queries/ElementQuery.php | 49 ++++++++++--------- .../Queries/Events/ElementsHydrated.php | 5 +- src/Field/BaseRelationField.php | 3 +- 4 files changed, 40 insertions(+), 34 deletions(-) diff --git a/src/Database/Queries/Concerns/HydratesElements.php b/src/Database/Queries/Concerns/HydratesElements.php index 1f1c7a22c32..d5e8ec9d2d9 100644 --- a/src/Database/Queries/Concerns/HydratesElements.php +++ b/src/Database/Queries/Concerns/HydratesElements.php @@ -7,6 +7,7 @@ use Craft; use craft\base\ElementInterface; use craft\base\ExpirableElementInterface; +use craft\elements\ElementCollection; use craft\helpers\ElementHelper; use CraftCms\Cms\Database\Queries\Events\ElementHydrated; use CraftCms\Cms\Database\Queries\Events\ElementsHydrated; @@ -18,6 +19,8 @@ use stdClass; /** + * @template TValue + * * @internal */ trait HydratesElements @@ -25,13 +28,13 @@ trait HydratesElements /** * Create a collection of elements from plain arrays. * - * @return \Illuminate\Database\Eloquent\Collection + * @return \craft\elements\ElementCollection */ - public function hydrate(array $items): Collection + public function hydrate(array $items): ElementCollection { $items = array_map(fn (stdClass $row) => (array) $row, $items); - $elements = new Collection($items) + $elements = collect($items) ->when($this->searchResults, fn (Collection $collection) => $collection->map(function (array $row) { if (! isset($row['id'], $row['siteId'])) { return $row; @@ -75,7 +78,7 @@ public function hydrate(array $items): Collection } return $elements; - }); + })->all(); if ($this->withProvisionalDrafts) { ElementHelper::swapInProvisionalDrafts($elements); @@ -84,10 +87,10 @@ public function hydrate(array $items): Collection if (Event::hasListeners(ElementsHydrated::class)) { Event::dispatch($event = new ElementsHydrated($elements, $items)); - return $event->elements; + return new ElementCollection($event->elements); } - return $elements; + return new ElementCollection($elements); } protected function createElement(array $row): ElementInterface @@ -160,7 +163,7 @@ protected function createElement(array $row): ElementInterface unset( $row['draftCreatorId'], $row['draftName'], - $row['draftNotes'] + $row['draftNotes'], ); } } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index e4c35569c76..f4c43ae93ad 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -7,9 +7,9 @@ use Closure; use craft\base\Element; use craft\base\ElementInterface; +use craft\elements\ElementCollection; use craft\helpers\ElementHelper; use CraftCms\Cms\Database\Queries\Exceptions\ElementNotFoundException; -use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Typecast; @@ -36,7 +36,7 @@ /** * @template TElement of ElementInterface * - * @mixin \Illuminate\Database\Query\Builder + * @mixin \Illuminate\Database\Query\Builder */ class ElementQuery { @@ -48,7 +48,10 @@ class ElementQuery use Concerns\CollectsCacheTags; use Concerns\FormatsResults; + + /** @use Concerns\HydratesElements */ use Concerns\HydratesElements; + use Concerns\QueriesCustomFields; use Concerns\QueriesDraftsAndRevisions; use Concerns\QueriesEagerly; @@ -159,7 +162,7 @@ class ElementQuery // ------------------------------------------------------------------------- /** - * @var array> Column alias => name mapping + * @var array Column alias => name mapping * * @see joinElementTable() * @see applyOrderByParams() @@ -250,42 +253,42 @@ public function render(array $variables = []): Markup /** * Find a model by its primary key. * - * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TElement|null) + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \craft\elements\ElementCollection : TElement|null) */ - public function find(mixed $id, array|string $columns = ['*']): ElementInterface|Collection|null + public function find(mixed $id, array|string $columns = ['*']): ElementInterface|ElementCollection|null { if (is_array($id) || $id instanceof Arrayable) { return $this->findMany($id, $columns); } - return $this->where('elements.id', $id)->first($columns); + return $this->id($id)->first($columns); } /** * Find multiple elements by their primary keys. * * @param \Illuminate\Contracts\Support\Arrayable|array $ids - * @return \Illuminate\Database\Eloquent\Collection|array + * @return \craft\elements\ElementCollection|array */ - public function findMany(mixed $ids, array|string $columns = ['*']): Collection|array + public function findMany(mixed $ids, array|string $columns = ['*']): ElementCollection|array { $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; if (empty($ids)) { - return new Collection; + return new ElementCollection; } - return $this->whereIn('elements.id', $ids)->get($columns); + return $this->id($ids)->get($columns); } /** * Find a model by its primary key or throw an exception. * - * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TElement) + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \craft\elements\ElementCollection : TElement) * * @throws ElementNotFoundException */ - public function findOrFail(mixed $id, array|string $columns = ['*']): ElementInterface|Collection + public function findOrFail(mixed $id, array|string $columns = ['*']): ElementInterface|ElementCollection { $result = $this->find($id, $columns); @@ -294,7 +297,7 @@ public function findOrFail(mixed $id, array|string $columns = ['*']): ElementInt if (is_array($id)) { if (count($result) !== count(array_unique($id))) { throw (new ElementNotFoundException)->setElement( - $this->elementType, array_diff($id, $result->modelKeys()) + $this->elementType, array_diff($id, $result->pluck('id')->all()) ); } @@ -319,7 +322,7 @@ public function findOrFail(mixed $id, array|string $columns = ['*']): ElementInt * @param (\Closure(): TValue)|null $callback * @return ( * $id is (\Illuminate\Contracts\Support\Arrayable|array) - * ? \Illuminate\Database\Eloquent\Collection + * ? \craft\elements\ElementCollection * : TElement|TValue * ) */ @@ -410,14 +413,14 @@ public function first($columns = ['*']): ?ElementInterface /** * Execute the query as a "select" statement. * - * @return \Illuminate\Database\Eloquent\Collection|array + * @return \craft\elements\ElementCollection|array */ - public function get(array|string $columns = ['*']): Collection|array + public function get(array|string $columns = ['*']): ElementCollection|array { $models = $this->getModels($columns); - return $this->applyAfterQueryCallbacks(new Collection($models)) - ->when($this->asArray, fn (Collection $collection) => $collection->all()); + return $this->applyAfterQueryCallbacks(new ElementCollection($models)) + ->when($this->asArray, fn (ElementCollection $collection) => $collection->all()); } /** @@ -437,9 +440,9 @@ public function getModels(array|string $columns = ['*']): array /** * Execute the query as a "select" statement. * - * @return \Illuminate\Database\Eloquent\Collection|array + * @return \craft\elements\ElementCollection|array */ - public function all(array|string $columns = ['*']): Collection|array + public function all(array|string $columns = ['*']): ElementCollection|array { return $this->get($columns); } @@ -447,9 +450,9 @@ public function all(array|string $columns = ['*']): Collection|array /** * Execute the query as a "select" statement. * - * @return \Illuminate\Database\Eloquent\Collection + * @return \craft\elements\ElementCollection */ - public function collect(array|string $columns = ['*']): Collection + public function collect(array|string $columns = ['*']): ElementCollection { $this->asArray = false; @@ -535,7 +538,7 @@ public function cursor(): LazyCollection return $this->query->cursor()->map(function ($record) { $model = $this->createElement((array) $record); - return $this->applyAfterQueryCallbacks(new Collection([$model]))->first(); + return $this->applyAfterQueryCallbacks(new ElementCollection([$model]))->first(); })->reject(fn ($model) => is_null($model)); } diff --git a/src/Database/Queries/Events/ElementsHydrated.php b/src/Database/Queries/Events/ElementsHydrated.php index 394d05996ea..bb06bb3d7e0 100644 --- a/src/Database/Queries/Events/ElementsHydrated.php +++ b/src/Database/Queries/Events/ElementsHydrated.php @@ -5,15 +5,14 @@ namespace CraftCms\Cms\Database\Queries\Events; use craft\base\ElementInterface; -use Illuminate\Support\Collection; final class ElementsHydrated { public function __construct( /** - * @var Collection The populated elements + * @var array The populated elements */ - public Collection $elements, + public array $elements, /** * @var array[] The element query’s raw result data diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index 257818f319b..bc64eeb70b8 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -749,7 +749,7 @@ function (JoinClause $join) use ($element, $relationsAlias) { $join->whereNull("$relationsAlias.sourceSiteId") ->orWhere("$relationsAlias.sourceSiteId", $element->siteId); }); - } + }, ); if ( @@ -1191,6 +1191,7 @@ public function getRelationTargetIds(ElementInterface $element): array ) { $targetIds = $value->id ?: []; } elseif ( + $value instanceof \CraftCms\Cms\Database\Queries\ElementQuery && ($where = $value->getWhereForColumn('elements.id')) !== null && Arr::isNumeric($where['values']) ) { From b603d2c825179c4860a29bf4f87093cd8a3ebfdb Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 09:42:15 +0100 Subject: [PATCH 39/52] CachesQueries --- .../Queries/Concerns/CachesQueries.php | 60 +++++++++++++++++++ src/Database/Queries/ElementQuery.php | 51 ++++++++++++++-- .../Queries/Concerns/CachesQueriesTest.php | 27 +++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 src/Database/Queries/Concerns/CachesQueries.php create mode 100644 tests/Database/Queries/Concerns/CachesQueriesTest.php diff --git a/src/Database/Queries/Concerns/CachesQueries.php b/src/Database/Queries/Concerns/CachesQueries.php new file mode 100644 index 00000000000..411f5f64cb2 --- /dev/null +++ b/src/Database/Queries/Concerns/CachesQueries.php @@ -0,0 +1,60 @@ +query->toRawSql(); + $config = Json::encode($elementQuery->query->getConnection()->getConfig()); + + return md5($sql.$config.Json::encode($columns)); + } + + public function cache(int $duration = 3600, ?Dependency $dependency = null): static + { + $this->queryCacheDuration = $duration; + $this->queryCacheDependency = $dependency; + + return $this; + } + + public function noCache(): static + { + $this->queryCacheDuration = -1; + + return $this; + } + + protected function getCacheDependency(): Dependency + { + return $this->queryCacheDependency ?? new TagDependency($this->getCacheTags()); + } +} diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index f4c43ae93ad..e5aa567c9b7 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -14,6 +14,7 @@ use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Typecast; use CraftCms\Cms\Support\Utils; +use CraftCms\DependencyAwareCache\Facades\DependencyCache; use Exception; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Concerns\BuildsQueries; @@ -46,6 +47,7 @@ class ElementQuery BuildsQueries::first as baseFirst; } + use Concerns\CachesQueries; use Concerns\CollectsCacheTags; use Concerns\FormatsResults; @@ -432,9 +434,18 @@ public function getModels(array|string $columns = ['*']): array { $this->applyBeforeQueryCallbacks(); - return $this->eagerLoad()?->all() ?? $this->hydrate( - $this->query->get($columns)->all() - )->all(); + if ((int) $this->queryCacheDuration >= 0) { + $result = DependencyCache::remember( + key: $this->queryCacheKey($this, 'all', $columns), + ttl: $this->queryCacheDuration, + callback: fn () => $this->query->get($columns)->all(), + dependency: $this->getCacheDependency(), + ); + } else { + $result = $this->query->get($columns)->all(); + } + + return $this->eagerLoad()?->all() ?? $this->hydrate($result)->all(); } /** @@ -470,8 +481,18 @@ public function pluck($column, $key = null): Collection|array $column = $this->columnMap[$column] ?? $column; - return $this->query->pluck($column, $key) - ->when($this->asArray, fn (Collection $collection) => $collection->all()); + if ((int) $this->queryCacheDuration >= 0) { + $result = DependencyCache::remember( + key: $this->queryCacheKey($this, 'pluck', [$column, $key]), + ttl: $this->queryCacheDuration, + callback: fn () => $this->query->pluck($column, $key), + dependency: $this->getCacheDependency(), + ); + } else { + $result = $this->query->pluck($column, $key); + } + + return $result->when($this->asArray, fn (Collection $collection) => $collection->all()); } public function count($columns = '*'): int @@ -484,7 +505,16 @@ public function count($columns = '*'): int return $eagerLoadedCount; } - $result = $this->query->count($columns); + if ((int) $this->queryCacheDuration >= 0) { + $result = DependencyCache::remember( + key: $this->queryCacheKey($this, 'count', $columns), + ttl: $this->queryCacheDuration, + callback: fn () => $this->query->count($columns), + dependency: $this->getCacheDependency(), + ); + } else { + $result = $this->query->count($columns); + } return $this->applyAfterQueryCallbacks($result); } @@ -741,6 +771,15 @@ public function __call($method, $parameters): mixed if (in_array(strtolower($method), $this->passthru)) { $this->applyBeforeQueryCallbacks(); + if ((int) $this->queryCacheDuration >= 0) { + return DependencyCache::remember( + key: $this->queryCacheKey($this, strtolower($method), $parameters), + ttl: $this->queryCacheDuration, + callback: fn () => $this->getQuery()->{$method}(...$parameters), + dependency: $this->getCacheDependency(), + ); + } + return $this->getQuery()->{$method}(...$parameters); } diff --git a/tests/Database/Queries/Concerns/CachesQueriesTest.php b/tests/Database/Queries/Concerns/CachesQueriesTest.php new file mode 100644 index 00000000000..bd951bb3de6 --- /dev/null +++ b/tests/Database/Queries/Concerns/CachesQueriesTest.php @@ -0,0 +1,27 @@ +create(); + + expect(entryQuery()->cache()->count())->toBe(1); + expect(entryQuery()->cache()->all()->count())->toBe(1); + expect(entryQuery()->cache()->pluck('id')->count())->toBe(1); + + Entry::factory()->create(); + + // Cache is not cleared + expect(entryQuery()->cache()->count())->toBe(1); + expect(entryQuery()->cache()->all()->count())->toBe(1); + expect(entryQuery()->cache()->pluck('id')->count())->toBe(1); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->all()->count())->toBe(2); + + Craft::$app->getElements()->invalidateCachesForElementType(\craft\elements\Entry::class); + + expect(entryQuery()->cache()->count())->toBe(2); + expect(entryQuery()->cache()->all()->count())->toBe(2); + expect(entryQuery()->cache()->pluck('id')->count())->toBe(2); +}); From 6ce3533078c2445ad54f15c69b0de8b5a24e5c4d Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 09:43:20 +0100 Subject: [PATCH 40/52] Add method to key --- src/Database/Queries/Concerns/CachesQueries.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Queries/Concerns/CachesQueries.php b/src/Database/Queries/Concerns/CachesQueries.php index 411f5f64cb2..e751d266ef6 100644 --- a/src/Database/Queries/Concerns/CachesQueries.php +++ b/src/Database/Queries/Concerns/CachesQueries.php @@ -35,7 +35,7 @@ protected function queryCacheKey(ElementQuery $elementQuery, string $method, arr $sql = $elementQuery->query->toRawSql(); $config = Json::encode($elementQuery->query->getConnection()->getConfig()); - return md5($sql.$config.Json::encode($columns)); + return md5($sql.$method.$config.Json::encode($columns)); } public function cache(int $duration = 3600, ?Dependency $dependency = null): static From c38326bf9417803932a60309e9028a421c3546aa Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 09:43:48 +0100 Subject: [PATCH 41/52] Refactor --- src/Database/Queries/Concerns/CachesQueries.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Database/Queries/Concerns/CachesQueries.php b/src/Database/Queries/Concerns/CachesQueries.php index e751d266ef6..633bdb5bc33 100644 --- a/src/Database/Queries/Concerns/CachesQueries.php +++ b/src/Database/Queries/Concerns/CachesQueries.php @@ -30,12 +30,12 @@ trait CachesQueries */ public ?Dependency $queryCacheDependency = null; - protected function queryCacheKey(ElementQuery $elementQuery, string $method, array|string $columns = ['*']): string + protected function queryCacheKey(ElementQuery $elementQuery, string $method, array|string $parameters = []): string { $sql = $elementQuery->query->toRawSql(); $config = Json::encode($elementQuery->query->getConnection()->getConfig()); - return md5($sql.$method.$config.Json::encode($columns)); + return md5($sql.$method.$config.Json::encode($parameters)); } public function cache(int $duration = 3600, ?Dependency $dependency = null): static From 44d7c04783cba6483f16b86075c4afa9bd17d03b Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 10:06:26 +0100 Subject: [PATCH 42/52] Result overrides --- .../Queries/Concerns/OverridesResults.php | 77 +++++++++++++++++++ src/Database/Queries/ElementQuery.php | 24 ++++++ .../Queries/Concerns/OverridesResultsTest.php | 17 ++++ 3 files changed, 118 insertions(+) create mode 100644 src/Database/Queries/Concerns/OverridesResults.php create mode 100644 tests/Database/Queries/Concerns/OverridesResultsTest.php diff --git a/src/Database/Queries/Concerns/OverridesResults.php b/src/Database/Queries/Concerns/OverridesResults.php new file mode 100644 index 00000000000..3fff67dba30 --- /dev/null +++ b/src/Database/Queries/Concerns/OverridesResults.php @@ -0,0 +1,77 @@ +override)) { + return null; + } + + // Make sure the criteria hasn't changed + if ($this->overrideCriteria !== $this->getCriteria()) { + return $this->override = null; + } + + return $this->override; + } + + /** + * Sets the resulting elements. + * + * If this is called, [[all()]] will return these elements rather than initiating a new SQL query, + * as long as none of the parameters have changed since setResultOverride() was called. + * + * @param TValue[] $elements The resulting elements. + * + * @see getCachedResult() + */ + public function setResultOverride(array $elements): void + { + $this->override = $elements; + $this->overrideCriteria = $this->getCriteria(); + } + + /** + * Clears the overridden result. + * + * @see getResultOverride() + * @see setResultOverride() + */ + public function clearOverride(): void + { + $this->override = $this->overrideCriteria = null; + } +} diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index e5aa567c9b7..e8ef984c850 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Database\Queries; use Closure; +use Craft; use craft\base\Element; use craft\base\ElementInterface; use craft\elements\ElementCollection; @@ -54,6 +55,7 @@ class ElementQuery /** @use Concerns\HydratesElements */ use Concerns\HydratesElements; + use Concerns\OverridesResults; use Concerns\QueriesCustomFields; use Concerns\QueriesDraftsAndRevisions; use Concerns\QueriesEagerly; @@ -432,6 +434,14 @@ public function get(array|string $columns = ['*']): ElementCollection|array */ public function getModels(array|string $columns = ['*']): array { + if (! is_null($result = $this->getResultOverride())) { + if ($this->with) { + Craft::$app->getElements()->eagerLoadElements($this->elementType, $result, $this->with); + } + + return $result; + } + $this->applyBeforeQueryCallbacks(); if ((int) $this->queryCacheDuration >= 0) { @@ -477,6 +487,12 @@ public function one(array|string $columns = ['*']): ?ElementInterface public function pluck($column, $key = null): Collection|array { + $column = $this->columnMap[$column] ?? $column; + + if (! is_null($result = $this->getResultOverride())) { + return collect($result)->pluck($column, $key); + } + $this->applyBeforeQueryCallbacks(); $column = $this->columnMap[$column] ?? $column; @@ -497,6 +513,10 @@ public function pluck($column, $key = null): Collection|array public function count($columns = '*'): int { + if (! $this->getOffset() && ! $this->getLimit() && ! is_null($result = $this->getResultOverride())) { + return count($result); + } + $this->applyBeforeQueryCallbacks(); $eagerLoadedCount = $this->eagerLoad(count: true); @@ -521,6 +541,10 @@ public function count($columns = '*'): int public function nth(int $n, array|string $columns = ['*']): ?ElementInterface { + if (! is_null($result = $this->getResultOverride())) { + return $result[$n] ?? null; + } + // Eagerly? $eagerResult = $this->eagerLoad(criteria: [ 'offset' => ($this->offset ?: 0) + $n, diff --git a/tests/Database/Queries/Concerns/OverridesResultsTest.php b/tests/Database/Queries/Concerns/OverridesResultsTest.php new file mode 100644 index 00000000000..83fb78b3836 --- /dev/null +++ b/tests/Database/Queries/Concerns/OverridesResultsTest.php @@ -0,0 +1,17 @@ +create(); + + $element = Craft::$app->getElements()->getElementById($entry->id); + + $query = entryQuery()->id(999); + + $query->setResultOverride([$element]); + + expect($query->count())->toBe(1); + expect($query->get()->count())->toBe(1); + expect($query->pluck('id')->count())->toBe(1); +}); From 49b9e15e5e790434253e5da9b615d3135e5813d4 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 10:08:22 +0100 Subject: [PATCH 43/52] phpstan --- src/Database/Queries/Concerns/CachesQueries.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Database/Queries/Concerns/CachesQueries.php b/src/Database/Queries/Concerns/CachesQueries.php index 633bdb5bc33..f02dc0ce06e 100644 --- a/src/Database/Queries/Concerns/CachesQueries.php +++ b/src/Database/Queries/Concerns/CachesQueries.php @@ -33,7 +33,9 @@ trait CachesQueries protected function queryCacheKey(ElementQuery $elementQuery, string $method, array|string $parameters = []): string { $sql = $elementQuery->query->toRawSql(); - $config = Json::encode($elementQuery->query->getConnection()->getConfig()); + /** @var \Illuminate\Database\Connection $connection */ + $connection = $elementQuery->query->getConnection(); + $config = Json::encode($connection->getConfig()); return md5($sql.$method.$config.Json::encode($parameters)); } From a05e7bd85fc1745eb6a4f863b54cde6fcf5d5cf0 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 14:39:36 +0100 Subject: [PATCH 44/52] phpstan fixes --- src/Dashboard/Widgets/MyDrafts.php | 27 ++-- src/Dashboard/Widgets/RecentEntries.php | 33 +++-- .../Queries/Concerns/FormatsResults.php | 3 - .../Queries/Concerns/QueriesAuthors.php | 7 - .../Queries/Concerns/QueriesEntryTypes.php | 4 - .../Queries/Concerns/QueriesFields.php | 26 ---- src/Database/Queries/Concerns/QueriesRef.php | 2 +- .../Queries/Concerns/QueriesSections.php | 4 - .../Queries/Concerns/QueriesSites.php | 2 +- .../Queries/Concerns/QueriesStatuses.php | 1 - src/Database/Queries/ElementQuery.php | 12 +- src/Database/Queries/EntryQuery.php | 6 +- src/Element/Elements/Entry.php | 19 +++ src/Entry/Commands/MergeEntryTypesCommand.php | 2 +- src/Entry/Entries.php | 18 ++- src/Entry/EntryTypes.php | 9 +- src/Field/BaseRelationField.php | 2 +- src/Field/Matrix.php | 123 +++++++++--------- .../Entries/MoveEntryToSectionController.php | 4 +- .../Entries/StoreEntryController.php | 2 +- src/Section/Sections.php | 53 ++++---- src/Structure/Commands/RepairCommand.php | 81 +++++++----- .../RepairSectionStructureCommand.php | 2 +- src/Structure/Structures.php | 11 +- yii2-adapter/legacy/base/Element.php | 2 +- yii2-adapter/legacy/base/ElementInterface.php | 3 +- yii2-adapter/legacy/base/Field.php | 5 +- .../controllers/ElementsController.php | 2 +- .../console/controllers/EntrifyController.php | 2 +- .../controllers/UpdateStatusesController.php | 38 +++--- .../utils/PruneOrphanedEntriesController.php | 22 ++-- .../legacy/controllers/MatrixController.php | 3 +- yii2-adapter/legacy/db/QueryParam.php | 1 + yii2-adapter/legacy/elements/Entry.php | 2 +- .../legacy/elements/NestedElementManager.php | 2 +- .../legacy/elements/db/ElementQuery.php | 24 ++-- .../conditions/FieldConditionRuleTrait.php | 7 + .../RelationalFieldConditionRule.php | 7 + yii2-adapter/legacy/services/Elements.php | 4 +- yii2-adapter/legacy/services/Entries.php | 9 +- .../RepairCategoryGroupStructureCommand.php | 38 +++++- 41 files changed, 333 insertions(+), 291 deletions(-) create mode 100644 src/Element/Elements/Entry.php diff --git a/src/Dashboard/Widgets/MyDrafts.php b/src/Dashboard/Widgets/MyDrafts.php index 3ced043c250..c83582a127a 100644 --- a/src/Dashboard/Widgets/MyDrafts.php +++ b/src/Dashboard/Widgets/MyDrafts.php @@ -4,11 +4,12 @@ namespace CraftCms\Cms\Dashboard\Widgets; -use Craft; -use craft\elements\Entry; use craft\helpers\Cp; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Support\Html; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Session; +use Override; use function CraftCms\Cms\t; @@ -17,7 +18,7 @@ final class MyDrafts extends Widget /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function displayName(): string { return t('My Drafts'); @@ -26,7 +27,7 @@ public static function displayName(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] protected static function allowMultipleInstances(): bool { return false; @@ -40,13 +41,13 @@ protected static function allowMultipleInstances(): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function icon(): string { return 'scribble'; } - #[\Override] + #[Override] public static function getRules(): array { return [ @@ -57,7 +58,7 @@ public static function getRules(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getSettingsHtml(): string { return Cp::textFieldHtml([ @@ -73,22 +74,22 @@ public function getSettingsHtml(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getBodyHtml(): string { - /** @var Entry[] $drafts */ + /** @var \craft\elements\ElementCollection $drafts */ $drafts = Entry::find() ->drafts() ->status(null) - ->draftCreator(Craft::$app->getUser()->getId()) + ->draftCreator(Auth::user()->id) ->section('*') ->site('*') ->unique() - ->orderBy(['dateUpdated' => SORT_DESC]) + ->orderByDesc('dateUpdated') ->limit($this->limit) - ->all(); + ->get(); - if (empty($drafts)) { + if ($drafts->isEmpty()) { return Html::tag('div', t('You don’t have any active drafts.'), [ 'class' => ['zilch', 'small'], ]); diff --git a/src/Dashboard/Widgets/RecentEntries.php b/src/Dashboard/Widgets/RecentEntries.php index 3fcc011e355..b3ab8565f79 100644 --- a/src/Dashboard/Widgets/RecentEntries.php +++ b/src/Dashboard/Widgets/RecentEntries.php @@ -5,13 +5,14 @@ namespace CraftCms\Cms\Dashboard\Widgets; use Craft; -use craft\elements\Entry; -use craft\models\Section; +use craft\elements\ElementCollection; use craft\web\assets\recententries\RecentEntriesAsset; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Support\Facades\Sections; use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Json; +use Override; use function CraftCms\Cms\t; @@ -20,7 +21,7 @@ final class RecentEntries extends Widget /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function displayName(): string { return t('Recent Entries'); @@ -29,7 +30,7 @@ public static function displayName(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function icon(): string { return 'clock'; @@ -57,7 +58,7 @@ public function __construct(array $config = []) $this->siteId ??= Sites::getCurrentSite()->id; } - #[\Override] + #[Override] public static function getRules(): array { return [ @@ -69,7 +70,7 @@ public static function getRules(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getSettingsHtml(): string { return Craft::$app->getView()->renderTemplate('_components/widgets/RecentEntries/settings.twig', @@ -81,7 +82,7 @@ public function getSettingsHtml(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getTitle(): string { if (is_numeric($this->section) && $section = Sections::getSectionById((int) $this->section)) { @@ -98,7 +99,7 @@ public function getTitle(): string // See if they are pulling entries from a different site $targetSiteId = $this->getTargetSiteId(); - if ($targetSiteId !== null && $targetSiteId != Sites::getCurrentSite()->id) { + if ($targetSiteId !== null && $targetSiteId !== Sites::getCurrentSite()->id) { $site = Sites::getSiteById($targetSiteId); if ($site) { @@ -115,7 +116,7 @@ public function getTitle(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getBodyHtml(): string { $params = []; @@ -134,22 +135,20 @@ public function getBodyHtml(): string return $view->renderTemplate('_components/widgets/RecentEntries/body.twig', [ - 'entries' => $entries, + 'entries' => $entries->all(), ]); } /** * Returns the recent entries, based on the widget settings and user permissions. - * - * @return Entry[] */ - private function getEntries(): array + private function getEntries(): ElementCollection { $targetSiteId = $this->getTargetSiteId(); if ($targetSiteId === null) { // Hopeless - return []; + return new ElementCollection; } // Normalize the target section ID value. @@ -161,7 +160,7 @@ private function getEntries(): array } if (! $targetSectionId) { - return []; + return new ElementCollection; } return Entry::find() @@ -171,8 +170,8 @@ private function getEntries(): array ->siteId($targetSiteId) ->limit($this->limit ?: 100) ->with(['author']) - ->orderBy(['dateCreated' => SORT_DESC]) - ->all(); + ->orderByDesc('dateCreated') + ->get(); } /** diff --git a/src/Database/Queries/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php index 62be038b2ef..79f3ccbe42f 100644 --- a/src/Database/Queries/Concerns/FormatsResults.php +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -71,7 +71,6 @@ trait FormatsResults * ``` * * @param bool $value The property value - * @return static self reference */ public function inReverse(bool $value = true): static { @@ -100,7 +99,6 @@ public function inReverse(bool $value = true): static * ``` * * @param bool $value The property value (defaults to true) - * @return static self reference */ public function asArray(bool $value = true): static { @@ -135,7 +133,6 @@ public function asArray(bool $value = true): static * ``` * * @param bool $value The property value (defaults to true) - * @return static self reference */ public function fixedOrder(bool $value = true): static { diff --git a/src/Database/Queries/Concerns/QueriesAuthors.php b/src/Database/Queries/Concerns/QueriesAuthors.php index fed57125c83..17db7fcb781 100644 --- a/src/Database/Queries/Concerns/QueriesAuthors.php +++ b/src/Database/Queries/Concerns/QueriesAuthors.php @@ -144,9 +144,6 @@ private function applyAuthorGroupId(EntryQuery $query): void * ->all(); * ``` * - * @param mixed $value The property value - * @return static self reference - * * @uses $authorId */ public function authorId(mixed $value): static @@ -186,8 +183,6 @@ public function authorId(mixed $value): static * ->all(); * ``` * - * @param mixed $value The property value - * @return static self reference * * @uses $authorGroupId */ @@ -248,8 +243,6 @@ public function authorGroup(mixed $value): static * ->all(); * ``` * - * @param mixed $value The property value - * @return static self reference * * @uses $authorGroupId */ diff --git a/src/Database/Queries/Concerns/QueriesEntryTypes.php b/src/Database/Queries/Concerns/QueriesEntryTypes.php index 1de77157fec..1c1cf115d6b 100644 --- a/src/Database/Queries/Concerns/QueriesEntryTypes.php +++ b/src/Database/Queries/Concerns/QueriesEntryTypes.php @@ -88,8 +88,6 @@ protected function initQueriesEntryTypes(): void * ->all(); * ``` * - * @param mixed $value The property value - * @return static self reference * * @uses $typeId */ @@ -141,8 +139,6 @@ public function type(mixed $value): static * ->all(); * ``` * - * @param mixed $value The property value - * @return static self reference * * @uses $typeId */ diff --git a/src/Database/Queries/Concerns/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php index ffff38166e1..c403f672074 100644 --- a/src/Database/Queries/Concerns/QueriesFields.php +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -171,9 +171,6 @@ protected function initQueriesFields(): void * ::: tip * This can be combined with [[fixedOrder()]] if you want the results to be returned in a specific order. * ::: - * - * @param mixed $value The property value - * @return static self reference */ public function id(mixed $value): static { @@ -200,9 +197,6 @@ public function id(mixed $value): static * ->uid('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') * ->one(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ public function uid(mixed $value): static { @@ -238,9 +232,6 @@ public function uid(mixed $value): static * ->siteSettingsId(1) * ->one(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ public function siteSettingsId(mixed $value): static { @@ -269,7 +260,6 @@ public function siteSettingsId(mixed $value): static * ``` * * @param bool|null $value The property value (defaults to true) - * @return static self reference */ public function trashed(?bool $value = true): static { @@ -311,9 +301,6 @@ public function trashed(?bool $value = true): static * ->dateCreated(['and', ">= {$start}", "< {$end}"]) * ->all(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ public function dateCreated(mixed $value): static { @@ -353,9 +340,6 @@ public function dateCreated(mixed $value): static * ->dateUpdated(">= {$lastWeek}") * ->all(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ public function dateUpdated(mixed $value): static { @@ -394,9 +378,6 @@ public function dateUpdated(mixed $value): static * ->title('*Foo*') * ->all(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ public function title(mixed $value): static { @@ -441,9 +422,6 @@ public function title(mixed $value): static * ->slug(\craft\helpers\Db::escapeParam($requestedSlug)) * ->one(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ public function slug(mixed $value): static { @@ -488,9 +466,6 @@ public function slug(mixed $value): static * ->uri(\craft\helpers\Db::escapeParam($requestedUri)) * ->one(); * ``` - * - * @param mixed $value The property value - * @return static self reference */ public function uri(mixed $value): static { @@ -503,7 +478,6 @@ public function uri(mixed $value): static * Narrows the query results to only {elements} that were involved in a bulk element operation. * * @param string|null $value The property value - * @return static self reference */ public function inBulkOp(?string $value): static { diff --git a/src/Database/Queries/Concerns/QueriesRef.php b/src/Database/Queries/Concerns/QueriesRef.php index e4dff261b11..f560e507e89 100644 --- a/src/Database/Queries/Concerns/QueriesRef.php +++ b/src/Database/Queries/Concerns/QueriesRef.php @@ -68,7 +68,7 @@ protected function initQueriesRef(): void /** * Narrows the query results based on a reference string. */ - public function ref(mixed $value): self + public function ref(mixed $value): static { $this->ref = $value; diff --git a/src/Database/Queries/Concerns/QueriesSections.php b/src/Database/Queries/Concerns/QueriesSections.php index 6d95165017e..a94eb5dcaf8 100644 --- a/src/Database/Queries/Concerns/QueriesSections.php +++ b/src/Database/Queries/Concerns/QueriesSections.php @@ -77,8 +77,6 @@ protected function initQueriesSections(): void * ->all(); * ``` * - * @param mixed $value The property value - * @return static self reference * * @uses $sectionId */ @@ -145,8 +143,6 @@ public function section(mixed $value): static * ->all(); * ``` * - * @param mixed $value The property value - * @return static self reference * * @uses $sectionId */ diff --git a/src/Database/Queries/Concerns/QueriesSites.php b/src/Database/Queries/Concerns/QueriesSites.php index 2968f2e47cd..0c018aff286 100644 --- a/src/Database/Queries/Concerns/QueriesSites.php +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -212,7 +212,7 @@ public function siteId($value): static * ->all(); * ``` */ - public function language($value): self + public function language($value): static { if (is_string($value)) { $sites = Sites::getSitesByLanguage($value); diff --git a/src/Database/Queries/Concerns/QueriesStatuses.php b/src/Database/Queries/Concerns/QueriesStatuses.php index 3dbb6951077..33d21f59348 100644 --- a/src/Database/Queries/Concerns/QueriesStatuses.php +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -87,7 +87,6 @@ public function archived(bool $value = true): static * ``` * * @param string|string[]|null $value The property value - * @return static self reference */ public function status(array|string|null $value): static { diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index e8ef984c850..23c8bc6e0d3 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -39,8 +39,14 @@ * @template TElement of ElementInterface * * @mixin \Illuminate\Database\Query\Builder + * + * @method self orderByDesc($column) + * @method self where($column, $operator = null, $value = null, $boolean = 'and') + * @method self whereNot($column, $operator = null, $value = null, $boolean = 'and') + * @method self whereNotNull($columns, $boolean = 'and') + * @method self whereNotExists($callback, $boolean = 'and') */ -class ElementQuery +class ElementQuery implements \Illuminate\Contracts\Database\Query\Builder { /** @use \Illuminate\Database\Concerns\BuildsQueries */ use BuildsQueries { @@ -189,7 +195,7 @@ class ElementQuery */ public function __construct( /** @var class-string */ - protected string $elementType = Element::class, + public string $elementType = Element::class, protected array $config = [], ) { Typecast::properties(static::class, $config); @@ -956,7 +962,7 @@ protected function elementQueryBeforeQuery(): void * * @param string $table The table name, e.g. `entries` or `{{%entries}}` */ - protected function joinElementTable(string $table, ?string $alias = null): void + public function joinElementTable(string $table, ?string $alias = null): void { $alias ??= $table; diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 095a25ef2f6..273510e4d32 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -5,7 +5,6 @@ namespace CraftCms\Cms\Database\Queries; use Closure; -use craft\elements\Entry; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Queries\Concerns\QueriesAuthors; use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryDates; @@ -15,6 +14,7 @@ use CraftCms\Cms\Database\Queries\Concerns\QueriesSections; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\EntryTypes; @@ -25,6 +25,9 @@ use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +/** + * @extends ElementQuery + */ final class EntryQuery extends ElementQuery { use QueriesAuthors; @@ -144,7 +147,6 @@ protected function statusCondition(string $status): Closure * Sets the [[$editable]] property. * * @param bool|null $value The property value (defaults to true) - * @return static self reference * * @uses $editable */ diff --git a/src/Element/Elements/Entry.php b/src/Element/Elements/Entry.php new file mode 100644 index 00000000000..9f7bf8cf12c --- /dev/null +++ b/src/Element/Elements/Entry.php @@ -0,0 +1,19 @@ +value('sections.structureId'); } - return \Craft::$app->getElements()->getElementById($entryId, Entry::class, $siteId, $criteria); + return Craft::$app->getElements()->getElementById($entryId, Entry::class, $siteId, $criteria); } /** @@ -193,7 +193,7 @@ public function moveEntryToSection(Entry $entry, Section $section): bool // prevents revision from being created $entry->resaving = true; - $elementsService = \Craft::$app->getElements(); + $elementsService = Craft::$app->getElements(); $elementsService->ensureBulkOp(function () use ( $entry, $section, @@ -230,19 +230,17 @@ public function moveEntryToSection(Entry $entry, Section $section): bool Structures::remove($oldSection->structureId, $entry); // remove drafts and revisions from the structure, too - foreach (DbHelper::each($draftsQuery) as $draft) { - /** @var Entry $draft */ + $draftsQuery->each(function (Entry $draft) use ($oldSection) { if ($draft->lft) { Structures::remove($oldSection->structureId, $draft); } - } + }, 100); - foreach (DbHelper::each($revisionsQuery) as $revision) { - /** @var Entry $revision */ + $revisionsQuery->each(function (Entry $revision) use ($oldSection) { if ($revision->lft) { Structures::remove($oldSection->structureId, $revision); } - } + }, 100); } // if we're moving it to a Structure section, place it at the root diff --git a/src/Entry/EntryTypes.php b/src/Entry/EntryTypes.php index 579b9bde6dd..b02ab07082a 100644 --- a/src/Entry/EntryTypes.php +++ b/src/Entry/EntryTypes.php @@ -6,7 +6,6 @@ use Craft; use craft\base\MemoizableArray; -use craft\elements\Entry; use craft\errors\EntryTypeNotFoundException; use craft\helpers\AdminTable; use craft\helpers\Cp; @@ -14,6 +13,7 @@ use craft\models\FieldLayout; use craft\queue\jobs\ResaveElements; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Entry\Events\ApplyingDeleteEntryType; use CraftCms\Cms\Entry\Events\DeletingEntryType; @@ -381,7 +381,6 @@ public function handleChangedEntryType(ConfigEvent $event): void // Restore the entries at the end of the request in case the section isn't restored yet // (see https://github.com/craftcms/cms/issues/15787) Event::listen(RequestHandled::class, function () use ($entryTypeModel) { - /** @var Entry[] $entries */ $entries = Entry::find() ->typeId($entryTypeModel->id) ->drafts(null) @@ -390,11 +389,11 @@ public function handleChangedEntryType(ConfigEvent $event): void ->trashed() ->site('*') ->unique() - ->andWhere(['entries.deletedWithEntryType' => true]) - ->all(); + ->where('entries.deletedWithEntryType', true) + ->get(); /** @var Entry[][] $entriesBySection */ - $entriesBySection = collect($entries)->groupBy('sectionId')->all(); + $entriesBySection = $entries->groupBy('sectionId')->all(); foreach ($entriesBySection as $sectionEntries) { try { Craft::$app->getElements()->restoreElements($sectionEntries); diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index bc64eeb70b8..569344bf2e4 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -778,7 +778,7 @@ function (JoinClause $join) use ($element, $relationsAlias) { return $query; } - private function fetchRelationsFromDbTable(?Elementinterface $element): bool + protected function fetchRelationsFromDbTable(?Elementinterface $element): bool { if ($this->layoutElement?->uid === null) { return false; diff --git a/src/Field/Matrix.php b/src/Field/Matrix.php index 2d5173597a7..22ade6ffc4e 100644 --- a/src/Field/Matrix.php +++ b/src/Field/Matrix.php @@ -10,18 +10,12 @@ use craft\base\GqlInlineFragmentFieldInterface; use craft\base\GqlInlineFragmentInterface; use craft\base\NestedElementInterface; -use craft\behaviors\EventBehavior; -use craft\db\Query; -use craft\elements\db\ElementQuery; use craft\elements\db\ElementQueryInterface; -use craft\elements\db\EntryQuery; use craft\elements\ElementCollection; -use craft\elements\Entry; use craft\elements\NestedElementManager; use craft\elements\User; use craft\errors\InvalidFieldException; use craft\events\BulkElementsEvent; -use craft\events\CancelableEvent; use craft\fields\conditions\EmptyFieldConditionRule; use craft\gql\arguments\elements\Entry as EntryArguments; use craft\gql\resolvers\elements\Entry as EntryResolver; @@ -38,8 +32,10 @@ use craft\web\assets\cp\CpAsset; use craft\web\assets\matrix\MatrixAsset; use craft\web\View; +use CraftCms\Cms\Database\Queries\EntryQuery; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Drafts; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Element\ElementSources; use CraftCms\Cms\Element\Enums\PropagationMethod; use CraftCms\Cms\Entry\Data\EntryType; @@ -64,6 +60,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; +use Override; use Tpetry\QueryExpressions\Language\Alias; use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; @@ -93,7 +90,7 @@ final class Matrix extends Field implements EagerLoadingFieldInterface, ElementC /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function displayName(): string { return t('Matrix'); @@ -102,7 +99,7 @@ public static function displayName(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function icon(): string { return 'binary'; @@ -111,7 +108,7 @@ public static function icon(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function supportedTranslationMethods(): array { // Don't ever automatically propagate values to other sites. @@ -123,7 +120,7 @@ public static function supportedTranslationMethods(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function phpType(): string { return sprintf('\\%s|\\%s<\\%s>', EntryQuery::class, ElementCollection::class, Entry::class); @@ -132,7 +129,7 @@ public static function phpType(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function dbType(): array|string|null { return null; @@ -141,7 +138,7 @@ public static function dbType(): array|string|null /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function modifyQuery(Builder $query, array $instances, mixed $value): Builder { /** @var self $field */ @@ -355,7 +352,7 @@ public function getSettings(): array return $settings; } - #[\Override] + #[Override] public static function getRules(): array { return array_merge(parent::getRules(), [ @@ -693,7 +690,7 @@ private function settingsHtml(bool $readOnly): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function normalizeValue(mixed $value, ?ElementInterface $element): mixed { return $this->_normalizeValueInternal($value, $element, false); @@ -702,7 +699,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed /** * {@inheritdoc} */ - #[\Override] + #[Override] public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element): mixed { return $this->_normalizeValueInternal($value, $element, true); @@ -719,13 +716,13 @@ private function _normalizeValueInternal(mixed $value, ?ElementInterface $elemen // Set the initially matched elements if $value is already set, which is the case if there was a validation // error or we're loading an entry revision. if ($value === '') { - $query->setCachedResult([]); + $query->setResultOverride([]); } elseif ($value === '*') { // preload the nested entries so NestedElementManager::saveNestedElements() doesn't resave them all $query->drafts(null)->savedDraftsOnly()->status(null)->limit(null); - $query->setCachedResult($query->all()); + $query->setResultOverride($query->all()); } elseif ($element && is_array($value)) { - $query->setCachedResult($this->_createEntriesFromSerializedData($value, $element, $fromRequest)); + $query->setResultOverride($this->_createEntriesFromSerializedData($value, $element, $fromRequest)); } elseif (Craft::$app->getRequest()->getIsPreview()) { $query->withProvisionalDrafts(); } @@ -739,26 +736,21 @@ private function createEntryQuery(?ElementInterface $owner): EntryQuery // Existing element? if ($owner && $owner->id) { - $query->attachBehavior(self::class, new EventBehavior([ - ElementQuery::EVENT_BEFORE_PREPARE => function ( - CancelableEvent $event, - EntryQuery $query, - ) use ($owner) { - $query->ownerId = $owner->id; - - // Clear out id=false if this query was populated previously - if ($query->id === false) { - $query->id = null; - } + $query->beforeQuery(function (EntryQuery $entryQuery) use ($owner) { + $entryQuery->ownerId = $owner->id; - // If the owner is a revision, allow revision entries to be returned as well - if ($owner->getIsRevision()) { - $query - ->revisions(null) - ->trashed(null); - } - }, - ], true)); + // Clear out id=false if this query was populated previously + if ($entryQuery->id === false) { + $entryQuery->id = null; + } + + // If the owner is a revision, allow revision entries to be returned as well + if ($owner->getIsRevision()) { + $entryQuery + ->revisions(null) + ->trashed(null); + } + }); // Prepare the query for lazy eager loading $query->prepForEagerLoading($this->handle, $owner); @@ -776,7 +768,7 @@ private function createEntryQuery(?ElementInterface $owner): EntryQuery /** * {@inheritdoc} */ - #[\Override] + #[Override] public function serializeValue(mixed $value, ?ElementInterface $element): array { /** @var EntryQuery|ElementCollection $value */ @@ -802,7 +794,7 @@ public function serializeValue(mixed $value, ?ElementInterface $element): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function serializeValueForDb(mixed $value, ElementInterface $element): array { /** @var EntryQuery|ElementCollection $value */ @@ -828,7 +820,7 @@ public function serializeValueForDb(mixed $value, ElementInterface $element): ar /** * {@inheritdoc} */ - #[\Override] + #[Override] public function copyValue(ElementInterface $from, ElementInterface $to): void { // We'll do it later from afterElementPropagate() @@ -845,7 +837,7 @@ public function getElementConditionRuleType(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getIsTranslatable(?ElementInterface $element): bool { return $this->entryManager()->getIsTranslatable($element); @@ -854,7 +846,7 @@ public function getIsTranslatable(?ElementInterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function actionMenuItems(): array { $items = match ($this->viewMode) { @@ -988,7 +980,7 @@ private function blockViewActionMenuItems(): array revisionId: element.data('revisionId'), ownerId: element.data('ownerId'), siteId: element.data('siteId'), - }, $baseInfo)); + }, $baseInfo)) }); Craft.cp.copyElements(elementInfo); }); @@ -1006,11 +998,11 @@ private function blockViewActionMenuItems(): array if (blocks.is('.sel')) { copyLabel = Craft.t('app', 'Copy selected {type}', { type: $type, - }); + }) } else { copyLabel = Craft.t('app', 'Copy all {type}', { type: $type, - }); + }) } copyBtn.find('.menu-item-label').text(copyLabel); disclosureMenu.toggleItem(copyBtn[0], !!blocks.length); @@ -1071,7 +1063,7 @@ private function cardViewActionMenuItems(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getTranslationDescription(?ElementInterface $element): ?string { return $this->entryManager()->getTranslationDescription($element); @@ -1082,7 +1074,7 @@ public function getTranslationDescription(?ElementInterface $element): ?string * * @throws InvalidConfigException */ - #[\Override] + #[Override] protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string { return $this->inputHtmlInternal($value, $element, false); @@ -1091,7 +1083,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inl /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getStaticHtml(mixed $value, ElementInterface $element): string { return $this->inputHtmlInternal($value, $element, true); @@ -1123,7 +1115,7 @@ private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?Eleme } if ($value instanceof EntryQuery) { - $value = $value->getCachedResult() ?? (clone $value) + $value = $value->getResultOverride() ?? (clone $value) ->drafts(null) ->canonicalsOnly() ->status(null) @@ -1132,7 +1124,7 @@ private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?Eleme } if ($static && empty($value)) { - return '

'.Craft::t('app', 'No entries.').'

'; + return '

'.t('No entries.').'

'; } $view = Craft::$app->getView(); @@ -1319,7 +1311,7 @@ private function defaultCreateButtonLabel(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getElementValidationRules(): array { return [ @@ -1334,7 +1326,7 @@ public function getElementValidationRules(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function isValueEmpty(mixed $value, ElementInterface $element): bool { /** @var EntryQuery|ElementCollection $value */ @@ -1349,7 +1341,7 @@ private function validateEntries(ElementInterface $element): void if ($value instanceof EntryQuery) { /** @var Entry[] $entries */ - $entries = $value->getCachedResult() ?? (clone $value) + $entries = $value->getResultOverride() ?? (clone $value) ->drafts(null) ->savedDraftsOnly() ->status(null) @@ -1382,7 +1374,7 @@ private function validateEntries(ElementInterface $element): void if (! empty($invalidEntryIds)) { // Just in case the entries weren't already cached - $value->setCachedResult($entries); + $value->setResultOverride($entries); $element->addInvalidNestedElementIds($invalidEntryIds); if ($this->viewMode !== self::VIEW_MODE_BLOCKS) { @@ -1425,7 +1417,7 @@ private function validateEntries(ElementInterface $element): void /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function searchKeywords(mixed $value, ElementInterface $element): string { return $this->entryManager()->getSearchKeywords($element); @@ -1479,7 +1471,7 @@ public function getEagerLoadingMap(array $sourceElements): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function canMergeFrom(FieldInterface $outgoingField, ?string &$reason): bool { if (! $outgoingField instanceof self) { @@ -1504,7 +1496,7 @@ public function canMergeFrom(FieldInterface $outgoingField, ?string &$reason): b /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterMergeFrom(FieldInterface $outgoingField): void { DB::table(Table::ENTRIES) @@ -1520,7 +1512,7 @@ public function afterMergeFrom(FieldInterface $outgoingField): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getContentGqlType(): array { $typeArray = EntryTypeGenerator::generateTypes($this); @@ -1545,7 +1537,7 @@ public function getContentGqlType(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getContentGqlMutationArgumentType(): Type|array { return MatrixInputType::getType($this); @@ -1575,7 +1567,7 @@ public function getGqlFragmentEntityByName(string $fragmentName): GqlInlineFragm /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterSave(bool $isNew): void { // If the propagation method or an entry URI format just changed, resave all the entries @@ -1630,7 +1622,7 @@ public function afterSave(bool $isNew): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterElementPropagate(ElementInterface $element, bool $isNew): void { $this->entryManager()->maintainNestedElements($element, $isNew); @@ -1667,7 +1659,7 @@ public function afterSaveEntries(BulkElementsEvent $event): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function beforeElementDelete(ElementInterface $element): bool { if (! parent::beforeElementDelete($element)) { @@ -1683,7 +1675,7 @@ public function beforeElementDelete(ElementInterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function beforeElementDeleteForSite(ElementInterface $element): bool { $elementsService = Craft::$app->getElements(); @@ -1705,7 +1697,7 @@ public function beforeElementDeleteForSite(ElementInterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterElementRestore(ElementInterface $element): void { // Also restore any entries for this element @@ -1753,7 +1745,8 @@ private function _createEntriesFromSerializedData(array $value, ElementInterface ->siteId($element->siteId) ->drafts(null) ->status(null) - ->indexBy($uids ? 'uid' : 'id') + ->get() + ->keyBy($uids ? 'uid' : 'id') ->all(); } else { $oldEntriesById = []; diff --git a/src/Http/Controllers/Entries/MoveEntryToSectionController.php b/src/Http/Controllers/Entries/MoveEntryToSectionController.php index c7474856b41..114b9ec97f1 100644 --- a/src/Http/Controllers/Entries/MoveEntryToSectionController.php +++ b/src/Http/Controllers/Entries/MoveEntryToSectionController.php @@ -4,9 +4,9 @@ namespace CraftCms\Cms\Http\Controllers\Entries; -use craft\elements\Entry; use craft\helpers\Cp; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Entry\Entries; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Section\Data\Section; @@ -127,7 +127,7 @@ public function move(): Response ->drafts(null) ->site('*') ->unique() - ->all(); + ->get(); abort_if(empty($entries), 400, 'Cannot find the entries to move to the new section.'); diff --git a/src/Http/Controllers/Entries/StoreEntryController.php b/src/Http/Controllers/Entries/StoreEntryController.php index 668223be1fd..fb468082d47 100644 --- a/src/Http/Controllers/Entries/StoreEntryController.php +++ b/src/Http/Controllers/Entries/StoreEntryController.php @@ -6,11 +6,11 @@ use Craft; use craft\base\Element; -use craft\elements\Entry; use craft\errors\InvalidElementException; use craft\errors\UnsupportedSiteException; use craft\helpers\Cp; use craft\helpers\DateTimeHelper; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Entry\Entries; use CraftCms\Cms\Http\EnforcesPermissions; use CraftCms\Cms\Http\RespondsWithFlash; diff --git a/src/Section/Sections.php b/src/Section/Sections.php index a155808c8b0..77d7158a830 100644 --- a/src/Section/Sections.php +++ b/src/Section/Sections.php @@ -4,16 +4,16 @@ namespace CraftCms\Cms\Section; +use Craft; use craft\base\Element; use craft\base\MemoizableArray; -use craft\elements\Entry; use craft\errors\SectionNotFoundException; use craft\helpers\AdminTable; -use craft\helpers\Db as DbHelper; use craft\helpers\Queue; use craft\queue\jobs\ApplyNewPropagationMethod; use craft\queue\jobs\ResaveElements; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Element\Enums\PropagationMethod; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\ProjectConfig\Events\ConfigEvent; @@ -710,7 +710,7 @@ public function handleChangedSection(ConfigEvent $event): void $this->refreshSections(); if ($wasTrashed) { - /** @var Entry[] $entries */ + /** @var \craft\elements\ElementCollection $entries */ $entries = Entry::find() ->sectionId($sectionModel->id) ->drafts(null) @@ -719,16 +719,17 @@ public function handleChangedSection(ConfigEvent $event): void ->trashed() ->site('*') ->unique() - ->andWhere(['entries.deletedWithSection' => true]) - ->all(); + ->where('entries.deletedWithSection', true) + ->get(); + /** @var Entry[][] $entriesByType */ - $entriesByType = Collection::make($entries)->groupBy('typeId')->all(); + $entriesByType = $entries->groupBy('typeId')->all(); foreach ($entriesByType as $typeEntries) { try { array_walk($typeEntries, function (Entry $entry) { $entry->deletedWithSection = false; }); - \Craft::$app->getElements()->restoreElements($typeEntries); + Craft::$app->getElements()->restoreElements($typeEntries); } catch (InvalidConfigException) { // the entry type probably wasn't restored } @@ -748,7 +749,7 @@ public function handleChangedSection(ConfigEvent $event): void } // Invalidate entry caches - \Craft::$app->getElements()->invalidateCachesForElementType(Entry::class); + Craft::$app->getElements()->invalidateCachesForElementType(Entry::class); } public function refreshSections(): void @@ -768,20 +769,18 @@ public function refreshSections(): void private function populateNewStructure(SectionModel $sectionModel): void { // Add all of the entries to the structure - $query = Entry::find() + Entry::find() ->sectionId($sectionModel->id) ->drafts(null) ->draftOf(false) ->site('*') ->unique() ->status(null) - ->orderBy(['id' => SORT_ASC]) - ->withStructure(false); - - foreach (DbHelper::each($query) as $entry) { - /** @var Entry $entry */ - Structures::appendToRoot($sectionModel->structureId, $entry, Mode::Insert); - } + ->withStructure(false) + ->orderBy('id') + ->each(function (Entry $entry) use ($sectionModel) { + Structures::appendToRoot($sectionModel->structureId, $entry, Mode::Insert); + }, 100); } /** @@ -837,19 +836,20 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null /** @var Entry|null $entry */ $entry = $baseEntryQuery ->typeId($entryTypeIds) - ->one(); + ->first(); // if we didn't find any, look for any entry in this section // regardless of type ID, and potentially even soft-deleted if ($entry === null) { + /** @var Entry|null $entry */ $entry = $baseEntryQuery ->typeId(null) ->trashed(null) - ->one(); + ->first(); if ($entry !== null) { if (isset($entry->dateDeleted)) { - \Craft::$app->getElements()->restoreElement($entry); + Craft::$app->getElements()->restoreElement($entry); } $entry->setTypeId($entryTypeIds[0]); @@ -859,11 +859,12 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null // if we still don't have any, // try without the typeId with trashed where they were deleted with entry type if ($entry === null) { + /** @var ?Entry $entry */ $entry = $baseEntryQuery ->typeId(null) ->trashed(null) - ->where(['entries.deletedWithEntryType' => true]) - ->one(); + ->where('entries.deletedWithEntryType', true) + ->first(); if ($entry !== null) { $entry->setTypeId($entryTypeIds[0]); @@ -892,7 +893,7 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null if ( $entry->hasErrors() || - ! \Craft::$app->getElements()->saveElement($entry, false) + ! Craft::$app->getElements()->saveElement($entry, false) ) { throw new Exception("Couldn’t save single entry for section $section->name due to validation errors: ".implode(', ', $entry->getFirstErrors())); @@ -901,7 +902,7 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null // Delete any other entries in the section // --------------------------------------------------------------------- - $elementsService = \Craft::$app->getElements(); + $elementsService = Craft::$app->getElements(); $otherEntriesQuery = Entry::find() ->sectionId($section->id) ->drafts(null) @@ -911,12 +912,12 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null ->id(['not', $entry->id]) ->status(null); - foreach (DbHelper::each($otherEntriesQuery) as $entryToDelete) { + $otherEntriesQuery->each(function (Entry $entryToDelete) use ($entry, $elementsService) { /** @var Entry $entryToDelete */ if (! $entryToDelete->getIsDraft() || $entry->canonicalId != $entry->id) { $elementsService->deleteElement($entryToDelete, true); } - } + }, 100); return $entry; } @@ -1045,7 +1046,7 @@ public function handleDeletedSection(ConfigEvent $event): void } // Invalidate entry caches - \Craft::$app->getElements()->invalidateCachesForElementType(Entry::class); + Craft::$app->getElements()->invalidateCachesForElementType(Entry::class); } /** diff --git a/src/Structure/Commands/RepairCommand.php b/src/Structure/Commands/RepairCommand.php index 159086a9de8..fe59dbe775f 100644 --- a/src/Structure/Commands/RepairCommand.php +++ b/src/Structure/Commands/RepairCommand.php @@ -5,20 +5,26 @@ namespace CraftCms\Cms\Structure\Commands; use craft\base\ElementInterface; -use craft\elements\db\ElementQuery; use craft\helpers\ElementHelper; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Structure\Enums\Mode; use CraftCms\Cms\Structure\Models\StructureElement; use CraftCms\Cms\Support\Facades\Structures; use Illuminate\Console\Command; +use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Throwable; +use Tpetry\QueryExpressions\Language\CaseGroup; +use Tpetry\QueryExpressions\Language\CaseRule; +use Tpetry\QueryExpressions\Operator\Comparison\NotIsNull; +use Tpetry\QueryExpressions\Value\Value; use yii\console\ExitCode; -use yii\db\Expression; abstract class RepairCommand extends Command { - protected function repairStructure(int $structureId, ElementQuery $query): int + protected function repairStructure(int $structureId, ElementQuery|Collection $query): int { $structure = Structures::getStructureById($structureId); @@ -28,38 +34,41 @@ protected function repairStructure(int $structureId, ElementQuery $query): int return self::FAILURE; } - // Get all the elements that match the query, including ones that may not be part of the structure - $elements = $query - ->site('*') - ->unique() - ->drafts(null) - ->provisionalDrafts(null) - ->status(null) - ->withStructure(false) - ->addSelect([ - 'structureelements.root', - 'structureelements.lft', - 'structureelements.rgt', - 'structureelements.level', - ]) - ->leftJoin('{{%structureelements}} structureelements', [ - 'and', - '[[structureelements.elementId]] = [[elements.id]]', - ['structureelements.structureId' => $structureId], - ]) - // Only include unpublished and provisional drafts - ->andWhere([ - 'or', - ['elements.draftId' => null], - ['elements.canonicalId' => null], - ['and', ['drafts.provisional' => true], ['not', ['structureelements.lft' => null]]], - ]) - ->orderBy([ - new Expression('CASE WHEN [[structureelements.lft]] IS NOT NULL THEN 0 ELSE 1 END ASC'), - 'structureelements.lft' => SORT_ASC, - 'elements.dateCreated' => SORT_ASC, - ]) - ->all(); + if (! $query instanceof Collection) { + // Get all the elements that match the query, including ones that may not be part of the structure + $elements = $query + ->site('*') + ->unique() + ->drafts(null) + ->provisionalDrafts(null) + ->status(null) + ->withStructure(false) + ->addSelect([ + 'structureelements.root', + 'structureelements.lft', + 'structureelements.rgt', + 'structureelements.level', + ]) + ->leftJoin('structureelements', function (JoinClause $join) use ($structureId) { + $join->on('structureelements.elementId', '=', 'elements.id') + ->where('structureelements.structureId', $structureId); + }) + // Only include unpublished and provisional drafts + ->where(function (Builder $query) { + $query->whereNull('elements.draftId') + ->orWhereNull('elements.canonicalId') + ->orWhere(function (Builder $query) { + $query->where('drafts.provisional', true) + ->whereNotNull('structureelements.lft'); + }); + }) + ->orderBy(new CaseGroup(when: [ + new CaseRule(result: '0', condition: new NotIsNull('structureelements.lft')), + ], else: new Value(1))) + ->orderBy('structureelements.lft') + ->orderBy('elements.dateCreated') + ->get(); + } /** @var class-string $elementType */ $elementType = $query->elementType; @@ -205,7 +214,7 @@ protected function repairStructure(int $structureId, ElementQuery $query): int $this->components->twoColumnDetail( "Finished processing $displayName", - $this->option('dry-run') ? ' (dry run)' : '' + $this->option('dry-run') ? ' (dry run)' : '', ); return ExitCode::OK; diff --git a/src/Structure/Commands/RepairSectionStructureCommand.php b/src/Structure/Commands/RepairSectionStructureCommand.php index 2b679d58b45..d96026b15de 100644 --- a/src/Structure/Commands/RepairSectionStructureCommand.php +++ b/src/Structure/Commands/RepairSectionStructureCommand.php @@ -4,8 +4,8 @@ namespace CraftCms\Cms\Structure\Commands; -use craft\elements\Entry; use CraftCms\Cms\Console\CraftCommand; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Section\Data\Section; use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Section\Sections; diff --git a/src/Structure/Structures.php b/src/Structure/Structures.php index 58da8a48acf..7a389cc0f2a 100644 --- a/src/Structure/Structures.php +++ b/src/Structure/Structures.php @@ -4,8 +4,10 @@ namespace CraftCms\Cms\Structure; +use Craft; use craft\base\Element; use craft\base\ElementInterface; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Structure\Data\Structure; use CraftCms\Cms\Structure\Enums\Action; @@ -109,7 +111,12 @@ public function fillGapsInElements(array &$elements): void ->status(null); if ($prevElement) { - $ancestorQuery->andWhere(['>', 'structureelements.lft', $prevElement->lft]); + if ($ancestorQuery instanceof ElementQuery) { + $ancestorQuery->where('structureelements.lft', '>', $prevElement->lft); + } else { + // @TODO: Remove when all ElementQueries are ported + $ancestorQuery->andWhere(['>', 'structureelements.lft', $prevElement->lft]); + } } /** @var T $ancestor */ @@ -474,7 +481,7 @@ private function doIt( // Invalidate all caches for the element type // (see https://github.com/craftcms/cms/issues/14846) - \Craft::$app->getElements()->invalidateCachesForElementType($element::class); + Craft::$app->getElements()->invalidateCachesForElementType($element::class); if (Event::hasListeners($afterEvent)) { Event::dispatch(new $afterEvent( diff --git a/yii2-adapter/legacy/base/Element.php b/yii2-adapter/legacy/base/Element.php index 99a0f28d251..81134a6a729 100644 --- a/yii2-adapter/legacy/base/Element.php +++ b/yii2-adapter/legacy/base/Element.php @@ -930,7 +930,7 @@ public static function statuses(): array * @inheritdoc * @return ElementQueryInterface */ - public static function find(): ElementQueryInterface + public static function find(): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery { return new ElementQuery(static::class); } diff --git a/yii2-adapter/legacy/base/ElementInterface.php b/yii2-adapter/legacy/base/ElementInterface.php index 3f8d4d8e92b..a011df962d9 100644 --- a/yii2-adapter/legacy/base/ElementInterface.php +++ b/yii2-adapter/legacy/base/ElementInterface.php @@ -16,6 +16,7 @@ use craft\errors\InvalidFieldException; use craft\models\FieldLayout; use CraftCms\Cms\Component\Contracts\ComponentInterface; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Element\Enums\AttributeStatus; use CraftCms\Cms\Site\Data\Site; use GraphQL\Type\Definition\Type; @@ -188,7 +189,7 @@ public static function hasStatuses(): bool; * * @return ElementQueryInterface The newly created [[ElementQueryInterface]] instance. */ - public static function find(): ElementQueryInterface; + public static function find(): ElementQueryInterface|ElementQuery; /** * Returns a single element instance by a primary key or a set of element criteria parameters. diff --git a/yii2-adapter/legacy/base/Field.php b/yii2-adapter/legacy/base/Field.php index 52f9d0d5204..8fb8611e145 100644 --- a/yii2-adapter/legacy/base/Field.php +++ b/yii2-adapter/legacy/base/Field.php @@ -7,6 +7,7 @@ namespace craft\base; +use Craft; use Illuminate\Database\Query\Builder; /** @@ -22,13 +23,13 @@ public static function modifyQuery(Builder $query, array $instances, mixed $valu $params = []; - $condition = self::queryCondition($instances, $value, $params); + $condition = static::queryCondition($instances, $value, $params); if ($condition === null || $condition === false) { return $query; } - $db = \Craft::$app->getDb(); + $db = Craft::$app->getDb(); $sql = $db->getQueryBuilder()->buildCondition($condition, $params); // Yii uses named parameters, Laravel uses positional diff --git a/yii2-adapter/legacy/console/controllers/ElementsController.php b/yii2-adapter/legacy/console/controllers/ElementsController.php index 345b61a7d95..dce3deb1781 100644 --- a/yii2-adapter/legacy/console/controllers/ElementsController.php +++ b/yii2-adapter/legacy/console/controllers/ElementsController.php @@ -10,10 +10,10 @@ use Craft; use craft\base\ElementInterface; use craft\console\Controller; -use craft\elements\Entry; use craft\helpers\Component; use craft\helpers\Console; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Section\Enums\SectionType; use CraftCms\Cms\Support\Facades\Sections; use Illuminate\Support\Facades\DB; diff --git a/yii2-adapter/legacy/console/controllers/EntrifyController.php b/yii2-adapter/legacy/console/controllers/EntrifyController.php index d0cfc8d5e67..8ff278e02ec 100644 --- a/yii2-adapter/legacy/console/controllers/EntrifyController.php +++ b/yii2-adapter/legacy/console/controllers/EntrifyController.php @@ -11,7 +11,6 @@ use craft\base\Event; use craft\console\Controller; use craft\elements\Category; -use craft\elements\Entry; use craft\elements\GlobalSet; use craft\elements\Tag; use craft\elements\User; @@ -24,6 +23,7 @@ use craft\services\Entries as EntriesService; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Element\ElementSources; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Field\BaseRelationField; diff --git a/yii2-adapter/legacy/console/controllers/UpdateStatusesController.php b/yii2-adapter/legacy/console/controllers/UpdateStatusesController.php index 06362d5665b..f971ae0f464 100644 --- a/yii2-adapter/legacy/console/controllers/UpdateStatusesController.php +++ b/yii2-adapter/legacy/console/controllers/UpdateStatusesController.php @@ -9,12 +9,13 @@ use Craft; use craft\console\Controller; -use craft\elements\Entry; use craft\events\MultiElementActionEvent; use craft\helpers\Console; use craft\helpers\DateTimeHelper; use craft\helpers\Db; use craft\services\Elements; +use CraftCms\Cms\Element\Elements\Entry; +use Illuminate\Database\Query\Builder; use yii\console\ExitCode; /** @@ -41,21 +42,20 @@ public function actionIndex(): int $elementsService = Craft::$app->getElements(); $conditions = [ - Entry::STATUS_LIVE => [ - 'and', - ['<=', 'entries.postDate', $now], - [ - 'or', - ['entries.expiryDate' => null], - ['>', 'entries.expiryDate', $now], - ], - ], - Entry::STATUS_PENDING => ['>', 'entries.postDate', $now], - Entry::STATUS_EXPIRED => [ - 'and', - ['not', ['entries.expiryDate' => null]], - ['<=', 'entries.expiryDate', $now], - ], + Entry::STATUS_LIVE => function(Builder $query) use ($now) { + $query->where('entries.postDate', '<=', $now) + ->where(function(Builder $query) use ($now) { + $query->whereNull('expiryDate') + ->orWhere('expiryDate', '>', $now); + }); + }, + Entry::STATUS_PENDING => function(Builder $query) use ($now) { + $query->where('entries.postDate', '>', $now); + }, + Entry::STATUS_EXPIRED => function(Builder $query) use ($now) { + $query->whereNotNull('entries.expiryDate') + ->where('entries.expiryDate', '<=', $now); + }, ]; foreach ($conditions as $status => $condition) { @@ -63,11 +63,11 @@ public function actionIndex(): int ->site('*') ->unique() ->status(null) - ->andWhere(['not', ['status' => $status]]) - ->andWhere($condition); + ->where('status', '!=', $status) + ->where($condition); $this->do("Updating $status entries", function() use ($elementsService, $query) { - $count = (int)$query->count(); + $count = $query->count(); $beforeCallback = function(MultiElementActionEvent $e) use ($query, $count) { if ($e->query === $query) { diff --git a/yii2-adapter/legacy/console/controllers/utils/PruneOrphanedEntriesController.php b/yii2-adapter/legacy/console/controllers/utils/PruneOrphanedEntriesController.php index 5e3ab348387..7d2d1a62fdd 100644 --- a/yii2-adapter/legacy/console/controllers/utils/PruneOrphanedEntriesController.php +++ b/yii2-adapter/legacy/console/controllers/utils/PruneOrphanedEntriesController.php @@ -9,11 +9,11 @@ use Craft; use craft\console\Controller; -use craft\db\Query; -use craft\db\Table; -use craft\elements\Entry; use craft\helpers\Console; +use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Support\Facades\Sites; +use Illuminate\Support\Facades\DB; use yii\console\ExitCode; /** @@ -33,7 +33,7 @@ public function actionIndex(): int { if (!Sites::isMultiSite()) { $this->stdout("This command should only be run for multi-site installs.\n", Console::FG_YELLOW); - return ExitCode::OK; + //return ExitCode::OK; } $elementsService = Craft::$app->getElements(); @@ -45,19 +45,15 @@ public function actionIndex(): int foreach ($sites as $site) { $this->stdout(sprintf('Finding orphaned entries for site "%s" ... ', $site->getName())); - $esSubQuery = (new Query()) - ->from(['es' => Table::ELEMENTS_SITES]) - ->where([ - 'and', - '[[es.elementId]] = [[entries.primaryOwnerId]]', - ['es.siteId' => $site->id], - ]); + $esSubQuery = DB::table(Table::ELEMENTS_SITES, 'es') + ->whereColumn('es.elementId', 'entries.primaryOwnerId') + ->where('es.siteId', $site->id); $entries = Entry::find() ->status(null) ->siteId($site->id) - ->where(['not', ['entries.primaryOwnerId' => null]]) - ->andWhere(['not exists', $esSubQuery]) + ->whereNotNull('entries.primaryOwnerId') + ->whereNotExists($esSubQuery) ->all(); if (empty($entries)) { diff --git a/yii2-adapter/legacy/controllers/MatrixController.php b/yii2-adapter/legacy/controllers/MatrixController.php index 84e884fbd9a..74516faf3ef 100644 --- a/yii2-adapter/legacy/controllers/MatrixController.php +++ b/yii2-adapter/legacy/controllers/MatrixController.php @@ -11,11 +11,11 @@ use craft\base\Element; use craft\elements\db\EntryQuery; use craft\elements\ElementCollection; -use craft\elements\Entry; use craft\errors\InvalidElementException; use craft\helpers\ElementHelper; use craft\web\Controller; use CraftCms\Cms\Element\Drafts; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Field\Matrix; use CraftCms\Cms\Support\Facades\EntryTypes; use CraftCms\Cms\Support\Facades\Sites; @@ -122,6 +122,7 @@ public function actionCreateEntry(): Response // duplicate an existing entry? $sourceId = $this->request->getBodyParam('duplicate'); if ($sourceId) { + /** @var ?Entry $source */ $source = Entry::find() ->id($sourceId) ->siteId($siteId) diff --git a/yii2-adapter/legacy/db/QueryParam.php b/yii2-adapter/legacy/db/QueryParam.php index 808facce668..25688bb9c9c 100644 --- a/yii2-adapter/legacy/db/QueryParam.php +++ b/yii2-adapter/legacy/db/QueryParam.php @@ -7,6 +7,7 @@ namespace craft\db; +/** @phpstan-ignore-next-line */ if (false) { /** * Class QueryParam diff --git a/yii2-adapter/legacy/elements/Entry.php b/yii2-adapter/legacy/elements/Entry.php index 23c0b876982..a98adf3a2c1 100644 --- a/yii2-adapter/legacy/elements/Entry.php +++ b/yii2-adapter/legacy/elements/Entry.php @@ -237,7 +237,7 @@ public static function statuses(): array * @inheritdoc * @return EntryQuery The newly created [[EntryQuery]] instance. */ - public static function find(): EntryQuery + public static function find(): EntryQuery|\CraftCms\Cms\Database\Queries\ElementQuery { return new EntryQuery(static::class); } diff --git a/yii2-adapter/legacy/elements/NestedElementManager.php b/yii2-adapter/legacy/elements/NestedElementManager.php index e6888f8c536..47d6d4618ba 100644 --- a/yii2-adapter/legacy/elements/NestedElementManager.php +++ b/yii2-adapter/legacy/elements/NestedElementManager.php @@ -75,7 +75,7 @@ class NestedElementManager extends Component * Constructor * * @param class-string $elementType The nested element type. - * @param Closure(ElementInterface $owner): ElementQueryInterface $queryFactory A factory method which returns a + * @param Closure(ElementInterface $owner): (ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery) $queryFactory A factory method which returns a * query for fetching nested elements * @param array $config name-value pairs that will be used to initialize the object properties. */ diff --git a/yii2-adapter/legacy/elements/db/ElementQuery.php b/yii2-adapter/legacy/elements/db/ElementQuery.php index 0957a5fd95a..a61c626c592 100644 --- a/yii2-adapter/legacy/elements/db/ElementQuery.php +++ b/yii2-adapter/legacy/elements/db/ElementQuery.php @@ -2853,19 +2853,17 @@ private function _applyCustomFieldParams(): void continue; } - if ($condition !== null) { - $conditions[] = $condition; - - // if we have a generated field with the same handle, we need to add it into the condition - if (isset($generatedFieldsByHandle[$handle])) { - $generatedFieldsConditions = $this->_conditionsForGeneratedFields( - $generatedFieldsByHandle, - $fieldAttributes, - $fieldsByHandle, - false - ); - $conditions = array_merge($conditions, $generatedFieldsConditions); - } + $conditions[] = $condition; + + // if we have a generated field with the same handle, we need to add it into the condition + if (isset($generatedFieldsByHandle[$handle])) { + $generatedFieldsConditions = $this->_conditionsForGeneratedFields( + $generatedFieldsByHandle, + $fieldAttributes, + $fieldsByHandle, + false + ); + $conditions = array_merge($conditions, $generatedFieldsConditions); } } diff --git a/yii2-adapter/legacy/fields/conditions/FieldConditionRuleTrait.php b/yii2-adapter/legacy/fields/conditions/FieldConditionRuleTrait.php index 06c2e6fac00..631b3087653 100644 --- a/yii2-adapter/legacy/fields/conditions/FieldConditionRuleTrait.php +++ b/yii2-adapter/legacy/fields/conditions/FieldConditionRuleTrait.php @@ -221,7 +221,14 @@ public function modifyQuery(QueryInterface $query): void if ($value !== null) { $instances = $this->fieldInstances(); $firstInstance = $instances[0]; + $params = []; + + if (!method_exists($firstInstance, 'queryCondition')) { + return; + } + + /** @phpstan-ignore-next-line */ $condition = $firstInstance::queryCondition($instances, $value, $params); if ($condition === false) { diff --git a/yii2-adapter/legacy/fields/conditions/RelationalFieldConditionRule.php b/yii2-adapter/legacy/fields/conditions/RelationalFieldConditionRule.php index a7cc55d02cd..2ea01622cba 100644 --- a/yii2-adapter/legacy/fields/conditions/RelationalFieldConditionRule.php +++ b/yii2-adapter/legacy/fields/conditions/RelationalFieldConditionRule.php @@ -176,11 +176,18 @@ public function modifyQuery(ElementQueryInterface $query): void if ($this->operator === self::OPERATOR_RELATED_TO) { $this->traitModifyQuery($query); } else { + // @TODO: Fix when ElementQuerInterface is replaced with new ElementQuery + if (!method_exists($field, 'existsQueryCondition')) { + return; + } + // Add the condition manually so we can ignore the related elements’ statuses and the field’s target site // so conditions reflect what authors see in the UI $query->andWhere( $this->operator === self::OPERATOR_NOT_EMPTY + /** @phpstan-ignore-next-line */ ? $field::existsQueryCondition($field, false, false) + /** @phpstan-ignore-next-line */ : ['not', $field::existsQueryCondition($field, false, false)] ); } diff --git a/yii2-adapter/legacy/services/Elements.php b/yii2-adapter/legacy/services/Elements.php index 2c62ca643b7..eb581cf11ed 100644 --- a/yii2-adapter/legacy/services/Elements.php +++ b/yii2-adapter/legacy/services/Elements.php @@ -1593,7 +1593,7 @@ public function updateCanonicalElement(ElementInterface $element, array $newAttr /** * Resaves all elements that match a given element query. * - * @param ElementQueryInterface $query The element query to fetch elements with + * @param ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery $query The element query to fetch elements with * @param bool $continueOnError Whether to continue going if an error occurs * @param bool $skipRevisions Whether elements that are (or belong to) a revision should be skipped * @param bool|null $updateSearchIndex Whether to update the element search index for the element @@ -1604,7 +1604,7 @@ public function updateCanonicalElement(ElementInterface $element, array $newAttr * @since 3.2.0 */ public function resaveElements( - ElementQueryInterface $query, + ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery $query, bool $continueOnError = false, bool $skipRevisions = true, ?bool $updateSearchIndex = null, diff --git a/yii2-adapter/legacy/services/Entries.php b/yii2-adapter/legacy/services/Entries.php index d48774dcadf..a4bc84ed512 100644 --- a/yii2-adapter/legacy/services/Entries.php +++ b/yii2-adapter/legacy/services/Entries.php @@ -855,9 +855,16 @@ public function moveEntryToSection(Entry $entry, Section|\CraftCms\Cms\Section\D $section = self::sectionDataFromSection($section); } + $entry = self::newEntryFromEntry($entry); + return EntriesFacade::moveEntryToSection($entry, $section); } + private static function newEntryFromEntry(Entry $entry): \CraftCms\Cms\Element\Elements\Entry + { + return new \CraftCms\Cms\Element\Elements\Entry($entry->toArray()); + } + private static function sectionFromSectionData(\CraftCms\Cms\Section\Data\Section $section): Section { $yiiSection = new Section(Utils::getPublicProperties($section)); @@ -881,7 +888,7 @@ private static function sectionDataFromSection(Section $section): \CraftCms\Cms\ return \CraftCms\Cms\Section\Data\Section::from($data); } - private static function sectionSiteSettingsFromSiteSettingsData(\CraftCms\Cms\Section\Data\SectionSiteSettings $siteSettings): Section_SiteSettings + private static function sectionSiteSettingsFromSiteSettingsData(SectionSiteSettings $siteSettings): Section_SiteSettings { return new Section_SiteSettings(Utils::getPublicProperties($siteSettings)); } diff --git a/yii2-adapter/src/Console/RepairCategoryGroupStructureCommand.php b/yii2-adapter/src/Console/RepairCategoryGroupStructureCommand.php index fae6047bc8f..b6d8417e2e6 100644 --- a/yii2-adapter/src/Console/RepairCategoryGroupStructureCommand.php +++ b/yii2-adapter/src/Console/RepairCategoryGroupStructureCommand.php @@ -4,10 +4,12 @@ namespace CraftCms\Yii2Adapter\Console; +use Craft; use craft\elements\Category; use CraftCms\Cms\Console\CraftCommand; use CraftCms\Cms\Structure\Commands\RepairCommand; use Illuminate\Contracts\Console\PromptsForMissingInput; +use yii\db\Expression; final class RepairCategoryGroupStructureCommand extends RepairCommand implements PromptsForMissingInput { @@ -21,7 +23,7 @@ final class RepairCategoryGroupStructureCommand extends RepairCommand implements public function handle(): int { - $group = \Craft::$app->getCategories()->getGroupByHandle($handle = $this->argument('handle')); + $group = Craft::$app->getCategories()->getGroupByHandle($handle = $this->argument('handle')); if (!$group) { $this->components->error("Invalid category group handle: $handle"); @@ -29,6 +31,38 @@ public function handle(): int return self::FAILURE; } - return $this->repairStructure($group->structureId, Category::find()->group($group)); + $elements = Category::find() + ->group($group) + ->site('*') + ->unique() + ->drafts(null) + ->provisionalDrafts(null) + ->status(null) + ->withStructure(false) + ->addSelect([ + 'structureelements.root', + 'structureelements.lft', + 'structureelements.rgt', + 'structureelements.level', + ]) + ->leftJoin('{{%structureelements}} structureelements', [ + 'and', + '[[structureelements.elementId]] = [[elements.id]]', + ['structureelements.structureId' => $group->structureId], + ]) + ->andWhere([ + 'or', + ['elements.draftId' => null], + ['elements.canonicalId' => null], + ['and', ['drafts.provisional' => true], ['not', ['structureelements.lft' => null]]], + ]) + ->orderBy([ + new Expression('CASE WHEN [[structureelements.lft]] IS NOT NULL THEN 0 ELSE 1 END ASC'), + 'structureelements.lft' => SORT_ASC, + 'elements.dateCreated' => SORT_ASC, + ]) + ->collect(); + + return $this->repairStructure($group->structureId, $elements); } } From 4ce5426e9689b54f9d81e2b97593d6cbe7204154 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 15:19:24 +0100 Subject: [PATCH 45/52] Test fixes --- .../Queries/Concerns/QueriesEntryTypes.php | 2 +- src/Database/Queries/ElementQuery.php | 51 +++++++++++++++---- .../Exceptions/QueryAbortedException.php | 5 +- src/Section/Sections.php | 2 + .../Queries/Concerns/CachesQueriesTest.php | 2 +- .../Concerns/CollectsCacheTagsTest.php | 2 +- .../Concerns/QueriesCustomFieldsTest.php | 4 +- .../Queries/Concerns/QueriesEagerlyTest.php | 2 +- .../Concerns/QueriesNestedElementsTest.php | 2 +- .../Concerns/QueriesRelatedElementsTest.php | 2 +- tests/Database/Queries/ElementQueryTest.php | 2 +- tests/Database/Queries/EntryQueryTest.php | 21 ++------ tests/Support/QueryTest.php | 8 ++- tests/TestCase.php | 7 +-- yii2-adapter/legacy/base/Element.php | 16 +++--- yii2-adapter/legacy/base/ElementInterface.php | 14 ++--- yii2-adapter/legacy/services/Elements.php | 2 +- 17 files changed, 84 insertions(+), 60 deletions(-) diff --git a/src/Database/Queries/Concerns/QueriesEntryTypes.php b/src/Database/Queries/Concerns/QueriesEntryTypes.php index 1c1cf115d6b..5de48fda71f 100644 --- a/src/Database/Queries/Concerns/QueriesEntryTypes.php +++ b/src/Database/Queries/Concerns/QueriesEntryTypes.php @@ -53,7 +53,7 @@ protected function initQueriesEntryTypes(): void return; } - $entryQuery->subQuery->whereIn('entries.typeId', $this->typeId); + $entryQuery->subQuery->whereIn('entries.typeId', $entryQuery->typeId); }); } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index 23c8bc6e0d3..cf6184bd110 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -11,6 +11,7 @@ use craft\elements\ElementCollection; use craft\helpers\ElementHelper; use CraftCms\Cms\Database\Queries\Exceptions\ElementNotFoundException; +use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Typecast; @@ -31,6 +32,7 @@ use ReflectionException; use ReflectionMethod; use ReflectionProperty; +use RuntimeException; use Tpetry\QueryExpressions\Function\Conditional\Coalesce; use Tpetry\QueryExpressions\Language\Alias; use Twig\Markup; @@ -448,7 +450,11 @@ public function getModels(array|string $columns = ['*']): array return $result; } - $this->applyBeforeQueryCallbacks(); + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return []; + } if ((int) $this->queryCacheDuration >= 0) { $result = DependencyCache::remember( @@ -499,7 +505,11 @@ public function pluck($column, $key = null): Collection|array return collect($result)->pluck($column, $key); } - $this->applyBeforeQueryCallbacks(); + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return $this->asArray ? [] : new Collection; + } $column = $this->columnMap[$column] ?? $column; @@ -523,7 +533,11 @@ public function count($columns = '*'): int return count($result); } - $this->applyBeforeQueryCallbacks(); + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return 0; + } $eagerLoadedCount = $this->eagerLoad(count: true); @@ -593,7 +607,11 @@ public function applyAfterQueryCallbacks(mixed $result): mixed */ public function cursor(): LazyCollection { - $this->applyBeforeQueryCallbacks(); + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return new LazyCollection; + } return $this->query->cursor()->map(function ($record) { $model = $this->createElement((array) $record); @@ -799,7 +817,11 @@ public function __call($method, $parameters): mixed } if (in_array(strtolower($method), $this->passthru)) { - $this->applyBeforeQueryCallbacks(); + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return null; + } if ((int) $this->queryCacheDuration >= 0) { return DependencyCache::remember( @@ -928,11 +950,6 @@ public function applyBeforeQueryCallbacks(): void protected function elementQueryBeforeQuery(): void { - // Give other classes a chance to make changes up front - /*if (!$this->beforePrepare()) { - throw new QueryAbortedException(); - }*/ - $this->applySelectParams(); // If an element table was never joined in, explicitly filter based on the element type @@ -1071,4 +1088,18 @@ private function resolveColumnMapping(string $key): string|array return $this->columnMap[$key]; } + + /** + * Throw an exception if the query doesn't have an orderBy clause. + * + * @return void + * + * @throws \RuntimeException + */ + protected function enforceOrderBy() + { + if (empty($this->query->orders) && empty($this->query->unionOrders)) { + throw new RuntimeException('You must specify an orderBy clause when using this function.'); + } + } } diff --git a/src/Database/Queries/Exceptions/QueryAbortedException.php b/src/Database/Queries/Exceptions/QueryAbortedException.php index 1978248192d..3cce34721a7 100644 --- a/src/Database/Queries/Exceptions/QueryAbortedException.php +++ b/src/Database/Queries/Exceptions/QueryAbortedException.php @@ -4,6 +4,5 @@ namespace CraftCms\Cms\Database\Queries\Exceptions; -use Exception; - -final class QueryAbortedException extends Exception {} +/** @TODO Replace legacy aborted with this one */ +final class QueryAbortedException extends \craft\db\QueryAbortedException {} diff --git a/src/Section/Sections.php b/src/Section/Sections.php index 77d7158a830..bdcaf65c5e7 100644 --- a/src/Section/Sections.php +++ b/src/Section/Sections.php @@ -835,6 +835,7 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null // If there are any existing entries, find the first one with a valid typeId /** @var Entry|null $entry */ $entry = $baseEntryQuery + ->clone() ->typeId($entryTypeIds) ->first(); @@ -843,6 +844,7 @@ private function ensureSingleEntry(Section $section, ?array $siteSettings = null if ($entry === null) { /** @var Entry|null $entry */ $entry = $baseEntryQuery + ->clone() ->typeId(null) ->trashed(null) ->first(); diff --git a/tests/Database/Queries/Concerns/CachesQueriesTest.php b/tests/Database/Queries/Concerns/CachesQueriesTest.php index bd951bb3de6..ca2c0b3b171 100644 --- a/tests/Database/Queries/Concerns/CachesQueriesTest.php +++ b/tests/Database/Queries/Concerns/CachesQueriesTest.php @@ -19,7 +19,7 @@ expect(entryQuery()->count())->toBe(2); expect(entryQuery()->all()->count())->toBe(2); - Craft::$app->getElements()->invalidateCachesForElementType(\craft\elements\Entry::class); + Craft::$app->getElements()->invalidateCachesForElementType(\CraftCms\Cms\Element\Elements\Entry::class); expect(entryQuery()->cache()->count())->toBe(2); expect(entryQuery()->cache()->all()->count())->toBe(2); diff --git a/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php index 5c359fe5812..4382c607f39 100644 --- a/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php +++ b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php @@ -1,7 +1,7 @@ first(); $entry->title = 'Test entry'; $entry->setFieldValue('textField', 'Foo'); diff --git a/tests/Database/Queries/Concerns/QueriesEagerlyTest.php b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php index cf1fd4ed3dd..8fa467e9e19 100644 --- a/tests/Database/Queries/Concerns/QueriesEagerlyTest.php +++ b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php @@ -2,9 +2,9 @@ use craft\behaviors\CustomFieldBehavior; use craft\elements\ElementCollection; -use craft\elements\Entry; use craft\fieldlayoutelements\CustomField; use CraftCms\Cms\Database\Queries\ElementQuery; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Element\Models\EntryType; use CraftCms\Cms\Entry\Models\Entry as EntryModel; use CraftCms\Cms\Field\Entries; diff --git a/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php b/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php index 0aa0560709c..f36ef8cc77c 100644 --- a/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php +++ b/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php @@ -50,6 +50,6 @@ /** @var \CraftCms\DependencyAwareCache\Dependency\TagDependency $dependency */ $dependency = Craft::$app->getElements()->stopCollectingCacheInfo()[0]; - expect($dependency->tags)->toContain('element::craft\elements\Entry::field:'.$field->id); + expect($dependency->tags)->toContain('element::CraftCms\Cms\Element\Elements\Entry::field:'.$field->id); expect($dependency->tags)->toContain('element::'.$entry->id); }); diff --git a/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php b/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php index d3c0a4aa3c8..045a89d6c6f 100644 --- a/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php +++ b/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php @@ -1,8 +1,8 @@ expectException(QueryAbortedException::class); - - entryQuery()->editable()->count(); -}); - -test('editable/savable throws when having no access', function (string $method) { +test('editable/savable returns 0 when having no access', function (string $method) { Edition::set(Edition::Pro); actingAs(User::first()); @@ -29,20 +22,12 @@ actingAs(User::factory()->create()); // Access to nothing - $this->expectException(QueryAbortedException::class); - - entryQuery()->$method()->count(); + expect(entryQuery()->$method()->count())->toBe(0); })->with([ 'editable', 'savable', ]); -test('savable requires authentication', function () { - $this->expectException(QueryAbortedException::class); - - entryQuery()->savable()->count(); -}); - test('savable', function () { actingAs(User::first()); diff --git a/tests/Support/QueryTest.php b/tests/Support/QueryTest.php index 66f4b01f833..ffb1ed6105d 100644 --- a/tests/Support/QueryTest.php +++ b/tests/Support/QueryTest.php @@ -96,7 +96,13 @@ $query = DB::table(Table::SESSIONS)->whereDateParam('dateCreated', $param); - expect($query->pluck('token')->all())->toEqual($expected); + expect( + $query + ->pluck('token') + // Trim because on pgsql these are fixed length + ->map(fn ($token) => trim((string) $token)) + ->all() + )->toEqual($expected); })->with([ ['today', ['test-today']], ['tomorrow', ['test-tomorrow']], diff --git a/tests/TestCase.php b/tests/TestCase.php index 0b556e2710e..2281c55c9fa 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -23,13 +23,14 @@ use Illuminate\Support\Facades\Schema; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as Orchestra; +use Override; class TestCase extends Orchestra { use LazilyRefreshDatabase; use WithWorkbench; - #[\Override] + #[Override] protected function setUp(): void { parent::setUp(); @@ -51,7 +52,7 @@ protected function setUp(): void $this->withoutVite(); } - #[\Override] + #[Override] protected function tearDown(): void { app(ProjectConfig::class)->reset(); @@ -105,7 +106,7 @@ protected function migrateDatabases() } } - #[\Override] + #[Override] protected function getEnvironmentSetUp($app) { File::cleanDirectory(config_path('craft/project')); diff --git a/yii2-adapter/legacy/base/Element.php b/yii2-adapter/legacy/base/Element.php index 81134a6a729..fa155668a78 100644 --- a/yii2-adapter/legacy/base/Element.php +++ b/yii2-adapter/legacy/base/Element.php @@ -1395,7 +1395,7 @@ public static function indexHtml( return Craft::$app->getView()->renderTemplate($template, $variables); } - private static function elementQueryWithAllDescendants(ElementQueryInterface $elementQuery): ElementQueryInterface + private static function elementQueryWithAllDescendants(ElementQueryInterface $elementQuery): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery { if (is_array($elementQuery->where)) { foreach ($elementQuery->where as $key => $condition) { @@ -4480,7 +4480,7 @@ public function getRootOwner(): ElementInterface * @inheritdoc * @since 3.5.0 */ - public function getLocalized(): ElementQueryInterface|ElementCollection + public function getLocalized(): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery|ElementCollection { // Eager-loaded? if (($localized = $this->getEagerLoadedElements('localized')) !== null) { @@ -4707,7 +4707,7 @@ private function _checkForNewParent(): bool /** * @inheritdoc */ - public function getAncestors(?int $dist = null): ElementQueryInterface|ElementCollection + public function getAncestors(?int $dist = null): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery|ElementCollection { // Eager-loaded? if (($ancestors = $this->getEagerLoadedElements('ancestors')) !== null) { @@ -4726,7 +4726,7 @@ public function getAncestors(?int $dist = null): ElementQueryInterface|ElementCo * @return ElementQueryInterface * @since 5.6.8 */ - protected function ancestors(): ElementQueryInterface + protected function ancestors(): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery { return static::find() ->structureId($this->structureId) @@ -4737,7 +4737,7 @@ protected function ancestors(): ElementQueryInterface /** * @inheritdoc */ - public function getDescendants(?int $dist = null): ElementQueryInterface|ElementCollection + public function getDescendants(?int $dist = null): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery|ElementCollection { // Eager-loaded? if (($descendants = $this->getEagerLoadedElements('descendants')) !== null) { @@ -4756,7 +4756,7 @@ public function getDescendants(?int $dist = null): ElementQueryInterface|Element * @return ElementQueryInterface * @since 5.6.8 */ - protected function descendants(): ElementQueryInterface + protected function descendants(): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery { return static::find() ->structureId($this->structureId) @@ -4767,7 +4767,7 @@ protected function descendants(): ElementQueryInterface /** * @inheritdoc */ - public function getChildren(): ElementQueryInterface|ElementCollection + public function getChildren(): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery|ElementCollection { // Eager-loaded? if (($children = $this->getEagerLoadedElements('children')) !== null) { @@ -4780,7 +4780,7 @@ public function getChildren(): ElementQueryInterface|ElementCollection /** * @inheritdoc */ - public function getSiblings(): ElementQueryInterface|ElementCollection + public function getSiblings(): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery|ElementCollection { return static::find() ->structureId($this->structureId) diff --git a/yii2-adapter/legacy/base/ElementInterface.php b/yii2-adapter/legacy/base/ElementInterface.php index a011df962d9..3f3348e4dbe 100644 --- a/yii2-adapter/legacy/base/ElementInterface.php +++ b/yii2-adapter/legacy/base/ElementInterface.php @@ -166,7 +166,7 @@ public static function hasStatuses(): bool; * ```php * class Product extends Element * { - * public static function find(): ElementQueryInterface + * public static function find(): ElementQueryInterface|ElementQuery * { * // use ProductQuery instead of the default ElementQuery * return new ProductQuery(get_called_class()); @@ -180,7 +180,7 @@ public static function hasStatuses(): bool; * ```php * class Customer extends ActiveRecord * { - * public static function find(): ElementQueryInterface + * public static function find(): ElementQueryInterface|ElementQuery * { * return parent::find()->limit(50); * } @@ -1130,7 +1130,7 @@ public function getRootOwner(): self; * * @return ElementQueryInterface|ElementCollection */ - public function getLocalized(): ElementQueryInterface|ElementCollection; + public function getLocalized(): ElementQueryInterface|ElementQuery|ElementCollection; /** * Returns the next element relative to this one, from a given set of criteria. @@ -1191,7 +1191,7 @@ public function setParent(?self $parent): void; * @param int|null $dist * @return ElementQueryInterface|ElementCollection */ - public function getAncestors(?int $dist = null): ElementQueryInterface|ElementCollection; + public function getAncestors(?int $dist = null): ElementQueryInterface|ElementQuery|ElementCollection; /** * Returns the element’s descendants. @@ -1199,21 +1199,21 @@ public function getAncestors(?int $dist = null): ElementQueryInterface|ElementCo * @param int|null $dist * @return ElementQueryInterface|ElementCollection */ - public function getDescendants(?int $dist = null): ElementQueryInterface|ElementCollection; + public function getDescendants(?int $dist = null): ElementQueryInterface|ElementQuery|ElementCollection; /** * Returns the element’s children. * * @return ElementQueryInterface|ElementCollection */ - public function getChildren(): ElementQueryInterface|ElementCollection; + public function getChildren(): ElementQueryInterface|ElementQuery|ElementCollection; /** * Returns all of the element’s siblings. * * @return ElementQueryInterface|ElementCollection */ - public function getSiblings(): ElementQueryInterface|ElementCollection; + public function getSiblings(): ElementQueryInterface|ElementQuery|ElementCollection; /** * Returns the element’s previous sibling. diff --git a/yii2-adapter/legacy/services/Elements.php b/yii2-adapter/legacy/services/Elements.php index eb581cf11ed..c46b1ee0daa 100644 --- a/yii2-adapter/legacy/services/Elements.php +++ b/yii2-adapter/legacy/services/Elements.php @@ -575,7 +575,7 @@ public function createElement(mixed $config): ElementInterface * @throws InvalidArgumentException if $elementType is not a valid element * @since 3.5.0 */ - public function createElementQuery(string $elementType): ElementQueryInterface + public function createElementQuery(string $elementType): ElementQueryInterface|\CraftCms\Cms\Database\Queries\ElementQuery { if (!is_subclass_of($elementType, ElementInterface::class)) { throw new InvalidArgumentException("$elementType is not a valid element."); From 50592a590892e74301ea35eb1c6269571eb5b437 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 15:26:03 +0100 Subject: [PATCH 46/52] Fix typehint --- src/Database/Queries/ElementQuery.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index cf6184bd110..a96803df249 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -897,7 +897,7 @@ protected static function registerMixin(object $mixin, bool $replace = true): vo } } - public function clone(): self + public function clone(): static { return clone $this; } From e83a3b74b30c1985297e2fbe873f9cd63bc59953 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 15:36:15 +0100 Subject: [PATCH 47/52] Fix some legacy tests --- .../Queries/Concerns/QueriesNestedElements.php | 12 ++++++------ .../legacy/elements/NestedElementManager.php | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Database/Queries/Concerns/QueriesNestedElements.php b/src/Database/Queries/Concerns/QueriesNestedElements.php index 592620db183..5426634c9ec 100644 --- a/src/Database/Queries/Concerns/QueriesNestedElements.php +++ b/src/Database/Queries/Concerns/QueriesNestedElements.php @@ -116,12 +116,12 @@ function (JoinClause $join) { $allowOwnerRevisions = $elementQuery->allowOwnerRevisions ?? ($elementQuery->id || $elementQuery->primaryOwnerId || $elementQuery->ownerId); if (! $allowOwnerDrafts || ! $allowOwnerRevisions) { - $this->subQuery->join( + $elementQuery->subQuery->join( new Alias(Table::ELEMENTS, 'owners'), fn (JoinClause $join) => $join->when( $elementQuery->ownerId, fn (JoinClause $join) => $join->on('owners.id', '=', 'elements_owners.ownerId'), - fn (JoinClause $join) => $join->on('owners.id', '=', $this->getPrimaryOwnerIdColumn()), + fn (JoinClause $join) => $join->on('owners.id', '=', $elementQuery->getPrimaryOwnerIdColumn()), ) ); @@ -134,7 +134,7 @@ function (JoinClause $join) { } } - $this->defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; + $elementQuery->defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; }); } @@ -381,19 +381,19 @@ protected function cacheTags(): array $tags = []; if ($this->fieldId) { - foreach ($this->fieldId as $fieldId) { + foreach (Arr::wrap($this->fieldId) as $fieldId) { $tags[] = "field:$fieldId"; } } if ($this->primaryOwnerId) { - foreach ($this->primaryOwnerId as $ownerId) { + foreach (Arr::wrap($this->primaryOwnerId) as $ownerId) { $tags[] = "element::$ownerId"; } } if ($this->ownerId) { - foreach ($this->ownerId as $ownerId) { + foreach (Arr::wrap($this->ownerId) as $ownerId) { $tags[] = "element::$ownerId"; } } diff --git a/yii2-adapter/legacy/elements/NestedElementManager.php b/yii2-adapter/legacy/elements/NestedElementManager.php index 47d6d4618ba..36f27eaa4a0 100644 --- a/yii2-adapter/legacy/elements/NestedElementManager.php +++ b/yii2-adapter/legacy/elements/NestedElementManager.php @@ -20,6 +20,7 @@ use craft\events\DuplicateNestedElementsEvent; use craft\helpers\Cp; use craft\helpers\ElementHelper; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\Enums\PropagationMethod; @@ -174,12 +175,12 @@ public function getIsTranslatable(?ElementInterface $owner = null): bool return $this->propagationMethod !== PropagationMethod::All; } - private function nestedElementQuery(ElementInterface $owner): ElementQueryInterface + private function nestedElementQuery(ElementInterface $owner): ElementQueryInterface|ElementQuery { return call_user_func($this->queryFactory, $owner); } - private function getValue(ElementInterface $owner, bool $fetchAll = false): ElementQueryInterface|ElementCollection + private function getValue(ElementInterface $owner, bool $fetchAll = false): ElementQueryInterface|ElementQuery|ElementCollection { if (isset($this->valueGetter)) { return call_user_func($this->valueGetter, $owner, $fetchAll); @@ -211,7 +212,7 @@ private function getValue(ElementInterface $owner, bool $fetchAll = false): Elem return $query; } - private function setValue(ElementInterface $owner, ElementQueryInterface|ElementCollection $value): void + private function setValue(ElementInterface $owner, ElementQueryInterface|ElementQuery|ElementCollection $value): void { if ($this->valueSetter === false) { return; From c7255c0abce9e54d0f6632cb37bc640527855f81 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 15:42:24 +0100 Subject: [PATCH 48/52] Old element type for now (ResaveElements Job is not ported yet) --- src/Section/Sections.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Section/Sections.php b/src/Section/Sections.php index bdcaf65c5e7..f18fa5e95c5 100644 --- a/src/Section/Sections.php +++ b/src/Section/Sections.php @@ -684,7 +684,7 @@ public function handleChangedSection(ConfigEvent $event): void 'description' => I18N::prep('Resaving {name} entries', [ 'name' => $sectionModel->name, ]), - 'elementType' => Entry::class, + 'elementType' => \craft\elements\Entry::class, 'criteria' => [ 'sectionId' => $sectionModel->id, 'siteId' => array_values($siteIdMap), From abe5b9e4878e0055d41ce717923923222c1c1421 Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 19 Nov 2025 15:52:48 +0100 Subject: [PATCH 49/52] Fix calls to getCachedResult --- yii2-adapter/legacy/elements/NestedElementManager.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/yii2-adapter/legacy/elements/NestedElementManager.php b/yii2-adapter/legacy/elements/NestedElementManager.php index 36f27eaa4a0..a75db6125e8 100644 --- a/yii2-adapter/legacy/elements/NestedElementManager.php +++ b/yii2-adapter/legacy/elements/NestedElementManager.php @@ -200,7 +200,11 @@ private function getValue(ElementInterface $owner, bool $fetchAll = false): Elem $query = $this->nestedElementQuery($owner); } - if ($fetchAll && $query->getCachedResult() === null) { + $result = method_exists($query, 'getCachedResult') + ? $query->getCachedResult() + : $query->getResultOverride(); + + if ($fetchAll && $result === null) { $query ->drafts(null) ->canonicalsOnly() @@ -772,7 +776,9 @@ private function saveNestedElements(ElementInterface $owner): void $elements = $value->all(); $saveAll = true; } else { - $elements = $value->getCachedResult(); + $elements = method_exists($value, 'getCachedResult') + ? $value->getCachedResult() + : $value->getResultOverride(); if ($elements !== null) { $saveAll = !empty($owner->newSiteIds); } else { From 550037d6c4b00ffc25351310f6cb5ff577a11d77 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 20 Nov 2025 09:56:49 +0100 Subject: [PATCH 50/52] Add macros for deprecated/renamed functions --- .../Queries/Concerns/OverridesResults.php | 2 +- src/Database/Queries/ElementQuery.php | 12 ------ src/Field/BaseRelationField.php | 4 +- .../legacy/controllers/ElementsController.php | 2 +- yii2-adapter/src/Yii2ServiceProvider.php | 37 +++++++++++++++++-- .../unit/elements/ElementCollectionTest.php | 30 +++++++-------- 6 files changed, 52 insertions(+), 35 deletions(-) diff --git a/src/Database/Queries/Concerns/OverridesResults.php b/src/Database/Queries/Concerns/OverridesResults.php index 3fff67dba30..9876a1328f5 100644 --- a/src/Database/Queries/Concerns/OverridesResults.php +++ b/src/Database/Queries/Concerns/OverridesResults.php @@ -70,7 +70,7 @@ public function setResultOverride(array $elements): void * @see getResultOverride() * @see setResultOverride() */ - public function clearOverride(): void + public function clearResultOverride(): void { $this->override = $this->overrideCriteria = null; } diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php index a96803df249..025d9a3e365 100644 --- a/src/Database/Queries/ElementQuery.php +++ b/src/Database/Queries/ElementQuery.php @@ -480,18 +480,6 @@ public function all(array|string $columns = ['*']): ElementCollection|array return $this->get($columns); } - /** - * Execute the query as a "select" statement. - * - * @return \craft\elements\ElementCollection - */ - public function collect(array|string $columns = ['*']): ElementCollection - { - $this->asArray = false; - - return $this->get($columns); - } - public function one(array|string $columns = ['*']): ?ElementInterface { return $this->first($columns); diff --git a/src/Field/BaseRelationField.php b/src/Field/BaseRelationField.php index 569344bf2e4..49771c9a79e 100644 --- a/src/Field/BaseRelationField.php +++ b/src/Field/BaseRelationField.php @@ -982,7 +982,7 @@ public function getPreviewHtml(mixed $value, ElementInterface $element): string { /** @var ElementQueryInterface|ElementCollection $value */ if ($value instanceof ElementQueryInterface) { - $value = $this->_all($value, $element)->collect(); + $value = $this->_all($value, $element)->get(); } else { // todo: come up with a way to get the normalized field value ignoring the eager-loaded value $rawValue = $element->getBehavior('customFields')->{$this->handle} ?? null; @@ -1200,7 +1200,7 @@ public function getRelationTargetIds(ElementInterface $element): array // just running $this->_all()->ids() will cause the query to get adjusted // see https://github.com/craftcms/cms/issues/14674 for details $targetIds = $this->_all($value, $element) - ->collect() + ->get() ->map(fn (ElementInterface $element) => $element->id) ->all(); } diff --git a/yii2-adapter/legacy/controllers/ElementsController.php b/yii2-adapter/legacy/controllers/ElementsController.php index 23b9b7618f0..78e6b240ac0 100644 --- a/yii2-adapter/legacy/controllers/ElementsController.php +++ b/yii2-adapter/legacy/controllers/ElementsController.php @@ -791,7 +791,7 @@ private function _contextMenuItems( ->status(null) ->orderBy(['dateUpdated' => SORT_DESC]) ->with(['draftCreator']) - ->collect() + ->get() ->filter(fn(ElementInterface $draft) => $elementsService->canView($draft, $user)) ->all(); } else { diff --git a/yii2-adapter/src/Yii2ServiceProvider.php b/yii2-adapter/src/Yii2ServiceProvider.php index c54e8291eca..ef596fdad20 100644 --- a/yii2-adapter/src/Yii2ServiceProvider.php +++ b/yii2-adapter/src/Yii2ServiceProvider.php @@ -66,6 +66,7 @@ use craft\services\Utilities; use craft\utilities\AssetIndexes; use craft\utilities\ClearCaches; +use craft\web\Application; use craft\web\twig\GlobalsExtension; use craft\web\twig\variables\Cp as CpVariable; use craft\web\UrlManager; @@ -73,6 +74,7 @@ use CraftCms\Aliases\Aliases; use CraftCms\Cms\Cms; use CraftCms\Cms\Config\BaseConfig; +use CraftCms\Cms\Database\Queries\ElementQuery; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Edition\Events\EditionChanged; use CraftCms\Cms\Field\Events\RegisterFieldTypes; @@ -207,6 +209,33 @@ private function registerMacros(): void $this->dispatchComponentEvent($name, $event); }); + + ElementQuery::macro('getCachedResult', function() { + Deprecator::log('ElementQuery-getCachedResult', 'Calling ->getCachedResult on an ElementQuery is deprecated. Use ->getResultOverride() instead.'); + + /** @var ElementQuery $this */ + return $this->getResultOverride(); + }); + + ElementQuery::macro('setCachedResult', function(array $elements) { + Deprecator::log('ElementQuery-setCachedResult', 'Calling ->setCachedResult on an ElementQuery is deprecated. Use ->setResultOverride() instead.'); + + /** @var ElementQuery $this */ + $this->setResultOverride($elements); + }); + + ElementQuery::macro('clearCachedResult', function() { + Deprecator::log('ElementQuery-clearCachedResult', 'Calling ->clearCachedResult on an ElementQuery is deprecated. Use ->clearResultOverride() instead.'); + + /** @var ElementQuery $this */ + $this->clearResultOverride(); + }); + + ElementQuery::macro('collect', function() { + Deprecator::log('ElementQuery-collect', 'Calling ->collect on an ElementQuery is deprecated. ElementQuery now returns a collection by default.'); + + return $this->get(); + }); } protected function registerLegacyApp(): void @@ -246,7 +275,7 @@ protected function registerLegacyApp(): void $app->setTimeZone(app()->getTimezone()); $app->language = app()->getLocale(); - \Craft::$app = $app; + Craft::$app = $app; $this->bootEvents(); self::bootYiiEvents(); @@ -468,11 +497,11 @@ private function bootEvents(): void $craft = app('Craft'); // Fire an 'afterEditionChange' event - if (!$craft->hasEventHandlers(\craft\web\Application::EVENT_AFTER_EDITION_CHANGE)) { + if (!$craft->hasEventHandlers(Application::EVENT_AFTER_EDITION_CHANGE)) { return; } - $craft->trigger(\craft\web\Application::EVENT_AFTER_EDITION_CHANGE, new EditionChangeEvent([ + $craft->trigger(Application::EVENT_AFTER_EDITION_CHANGE, new EditionChangeEvent([ 'oldEdition' => $event->oldEdition->value, 'newEdition' => $event->newEdition->value, ])); @@ -1152,7 +1181,7 @@ private function ensureNewMigrationTable(): void Artisan::call('craft:migrate:migration-table', [ '--force' => true, ]); - } catch (\PDOException) { + } catch (PDOException) { // No database connection } } diff --git a/yii2-adapter/tests/unit/elements/ElementCollectionTest.php b/yii2-adapter/tests/unit/elements/ElementCollectionTest.php index c4089cbbfac..59ce01bc396 100644 --- a/yii2-adapter/tests/unit/elements/ElementCollectionTest.php +++ b/yii2-adapter/tests/unit/elements/ElementCollectionTest.php @@ -33,7 +33,7 @@ public function _fixtures(): array public function testFind(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); $first = $collection->first(); self::assertInstanceOf(Entry::class, $first); @@ -47,7 +47,7 @@ public function testFind(): void public function testContains(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); self::assertTrue($collection->contains('title', 'Theories of life')); self::assertTrue($collection->contains(fn(Entry $entry) => $entry->title === 'Theories of life')); @@ -63,7 +63,7 @@ public function testContains(): void public function testIds(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); $ids = $collection->map(fn(Entry $entry) => $entry->id)->all(); self::assertSame($ids, $collection->ids()->all()); @@ -72,7 +72,7 @@ public function testIds(): void public function testMerge(): void { /** @var ElementCollection $collection */ - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); $first = $collection->first(); self::assertInstanceOf(Entry::class, $first); @@ -86,7 +86,7 @@ public function testMerge(): void public function testMap(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); $mapped = $collection->map(fn(Entry $entry) => new Entry()); self::assertInstanceOf(ElementCollection::class, $mapped); @@ -96,7 +96,7 @@ public function testMap(): void public function testMapWithKeys(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); $mapped = $collection->mapWithKeys(fn(Entry $entry, int|string $key) => [$entry->id => new Entry()]); self::assertInstanceOf(ElementCollection::class, $mapped); @@ -107,7 +107,7 @@ public function testMapWithKeys(): void public function testFresh(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); $collection->each(function(Entry $entry) { $entry->title .= 'edit'; @@ -120,10 +120,10 @@ public function testFresh(): void public function testDiff(): void { - $collection1 = Entry::find()->limit(4)->collect(); + $collection1 = Entry::find()->limit(4)->get(); self::assertInstanceOf(ElementCollection::class, $collection1); self::assertSame(4, $collection1->count()); - $collection2 = Entry::find()->offset(3)->collect(); + $collection2 = Entry::find()->offset(3)->get(); self::assertInstanceOf(ElementCollection::class, $collection2); self::assertTrue($collection2->isNotEmpty()); $diff = $collection1->diff($collection2->all()); @@ -132,10 +132,10 @@ public function testDiff(): void public function testIntersect(): void { - $collection1 = Entry::find()->limit(4)->collect(); + $collection1 = Entry::find()->limit(4)->get(); self::assertInstanceOf(ElementCollection::class, $collection1); self::assertSame(4, $collection1->count()); - $collection2 = Entry::find()->offset(3)->collect(); + $collection2 = Entry::find()->offset(3)->get(); self::assertInstanceOf(ElementCollection::class, $collection2); self::assertTrue($collection2->isNotEmpty()); $intersect = $collection1->intersect($collection2->all()); @@ -144,7 +144,7 @@ public function testIntersect(): void public function testUnique(): void { - $collection = Entry::find()->limit(4)->collect(); + $collection = Entry::find()->limit(4)->get(); self::assertInstanceOf(ElementCollection::class, $collection); $count = $collection->count(); $collection->push(...$collection->all()); @@ -155,7 +155,7 @@ public function testUnique(): void public function testOnly(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); self::assertNotEquals(1, $collection->count()); $first = $collection->first(); @@ -166,7 +166,7 @@ public function testOnly(): void public function testExcept(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); $count = $collection->count(); $first = $collection->first(); @@ -177,7 +177,7 @@ public function testExcept(): void public function testBaseMethods(): void { - $collection = Entry::find()->collect(); + $collection = Entry::find()->get(); self::assertInstanceOf(ElementCollection::class, $collection); self::assertSame(Collection::class, get_class($collection->countBy(fn(Entry $entry) => $entry->sectionId))); self::assertSame(Collection::class, get_class($collection->collapse())); From 5fd967c00d79774f1766f9626b6415f2ee8ed3de Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 20 Nov 2025 10:00:57 +0100 Subject: [PATCH 51/52] Legacy can use collect() for now --- .../legacy/controllers/ElementsController.php | 2 +- .../unit/elements/ElementCollectionTest.php | 30 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/yii2-adapter/legacy/controllers/ElementsController.php b/yii2-adapter/legacy/controllers/ElementsController.php index 78e6b240ac0..23b9b7618f0 100644 --- a/yii2-adapter/legacy/controllers/ElementsController.php +++ b/yii2-adapter/legacy/controllers/ElementsController.php @@ -791,7 +791,7 @@ private function _contextMenuItems( ->status(null) ->orderBy(['dateUpdated' => SORT_DESC]) ->with(['draftCreator']) - ->get() + ->collect() ->filter(fn(ElementInterface $draft) => $elementsService->canView($draft, $user)) ->all(); } else { diff --git a/yii2-adapter/tests/unit/elements/ElementCollectionTest.php b/yii2-adapter/tests/unit/elements/ElementCollectionTest.php index 59ce01bc396..c4089cbbfac 100644 --- a/yii2-adapter/tests/unit/elements/ElementCollectionTest.php +++ b/yii2-adapter/tests/unit/elements/ElementCollectionTest.php @@ -33,7 +33,7 @@ public function _fixtures(): array public function testFind(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $first = $collection->first(); self::assertInstanceOf(Entry::class, $first); @@ -47,7 +47,7 @@ public function testFind(): void public function testContains(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); self::assertTrue($collection->contains('title', 'Theories of life')); self::assertTrue($collection->contains(fn(Entry $entry) => $entry->title === 'Theories of life')); @@ -63,7 +63,7 @@ public function testContains(): void public function testIds(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $ids = $collection->map(fn(Entry $entry) => $entry->id)->all(); self::assertSame($ids, $collection->ids()->all()); @@ -72,7 +72,7 @@ public function testIds(): void public function testMerge(): void { /** @var ElementCollection $collection */ - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $first = $collection->first(); self::assertInstanceOf(Entry::class, $first); @@ -86,7 +86,7 @@ public function testMerge(): void public function testMap(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $mapped = $collection->map(fn(Entry $entry) => new Entry()); self::assertInstanceOf(ElementCollection::class, $mapped); @@ -96,7 +96,7 @@ public function testMap(): void public function testMapWithKeys(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $mapped = $collection->mapWithKeys(fn(Entry $entry, int|string $key) => [$entry->id => new Entry()]); self::assertInstanceOf(ElementCollection::class, $mapped); @@ -107,7 +107,7 @@ public function testMapWithKeys(): void public function testFresh(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $collection->each(function(Entry $entry) { $entry->title .= 'edit'; @@ -120,10 +120,10 @@ public function testFresh(): void public function testDiff(): void { - $collection1 = Entry::find()->limit(4)->get(); + $collection1 = Entry::find()->limit(4)->collect(); self::assertInstanceOf(ElementCollection::class, $collection1); self::assertSame(4, $collection1->count()); - $collection2 = Entry::find()->offset(3)->get(); + $collection2 = Entry::find()->offset(3)->collect(); self::assertInstanceOf(ElementCollection::class, $collection2); self::assertTrue($collection2->isNotEmpty()); $diff = $collection1->diff($collection2->all()); @@ -132,10 +132,10 @@ public function testDiff(): void public function testIntersect(): void { - $collection1 = Entry::find()->limit(4)->get(); + $collection1 = Entry::find()->limit(4)->collect(); self::assertInstanceOf(ElementCollection::class, $collection1); self::assertSame(4, $collection1->count()); - $collection2 = Entry::find()->offset(3)->get(); + $collection2 = Entry::find()->offset(3)->collect(); self::assertInstanceOf(ElementCollection::class, $collection2); self::assertTrue($collection2->isNotEmpty()); $intersect = $collection1->intersect($collection2->all()); @@ -144,7 +144,7 @@ public function testIntersect(): void public function testUnique(): void { - $collection = Entry::find()->limit(4)->get(); + $collection = Entry::find()->limit(4)->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $count = $collection->count(); $collection->push(...$collection->all()); @@ -155,7 +155,7 @@ public function testUnique(): void public function testOnly(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); self::assertNotEquals(1, $collection->count()); $first = $collection->first(); @@ -166,7 +166,7 @@ public function testOnly(): void public function testExcept(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); $count = $collection->count(); $first = $collection->first(); @@ -177,7 +177,7 @@ public function testExcept(): void public function testBaseMethods(): void { - $collection = Entry::find()->get(); + $collection = Entry::find()->collect(); self::assertInstanceOf(ElementCollection::class, $collection); self::assertSame(Collection::class, get_class($collection->countBy(fn(Entry $entry) => $entry->sectionId))); self::assertSame(Collection::class, get_class($collection->collapse())); From 245ccdd0de30ec6dcfe35e7346c8060aaaae8f08 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 20 Nov 2025 10:17:27 +0100 Subject: [PATCH 52/52] Move Entry specific concerns to Entry folder --- .../Queries/Concerns/{ => Entry}/QueriesAuthors.php | 2 +- .../Queries/Concerns/{ => Entry}/QueriesEntryDates.php | 2 +- .../Queries/Concerns/{ => Entry}/QueriesEntryTypes.php | 2 +- .../Queries/Concerns/{ => Entry}/QueriesRef.php | 2 +- .../Queries/Concerns/{ => Entry}/QueriesSections.php | 2 +- src/Database/Queries/EntryQuery.php | 10 +++++----- 6 files changed, 10 insertions(+), 10 deletions(-) rename src/Database/Queries/Concerns/{ => Entry}/QueriesAuthors.php (99%) rename src/Database/Queries/Concerns/{ => Entry}/QueriesEntryDates.php (99%) rename src/Database/Queries/Concerns/{ => Entry}/QueriesEntryTypes.php (98%) rename src/Database/Queries/Concerns/{ => Entry}/QueriesRef.php (97%) rename src/Database/Queries/Concerns/{ => Entry}/QueriesSections.php (99%) diff --git a/src/Database/Queries/Concerns/QueriesAuthors.php b/src/Database/Queries/Concerns/Entry/QueriesAuthors.php similarity index 99% rename from src/Database/Queries/Concerns/QueriesAuthors.php rename to src/Database/Queries/Concerns/Entry/QueriesAuthors.php index 17db7fcb781..9d98eb3d499 100644 --- a/src/Database/Queries/Concerns/QueriesAuthors.php +++ b/src/Database/Queries/Concerns/Entry/QueriesAuthors.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Cms\Database\Queries\Concerns; +namespace CraftCms\Cms\Database\Queries\Concerns\Entry; use craft\models\UserGroup; use CraftCms\Cms\Database\Queries\EntryQuery; diff --git a/src/Database/Queries/Concerns/QueriesEntryDates.php b/src/Database/Queries/Concerns/Entry/QueriesEntryDates.php similarity index 99% rename from src/Database/Queries/Concerns/QueriesEntryDates.php rename to src/Database/Queries/Concerns/Entry/QueriesEntryDates.php index 84a0b78c784..6632c8cec79 100644 --- a/src/Database/Queries/Concerns/QueriesEntryDates.php +++ b/src/Database/Queries/Concerns/Entry/QueriesEntryDates.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Cms\Database\Queries\Concerns; +namespace CraftCms\Cms\Database\Queries\Concerns\Entry; use CraftCms\Cms\Database\Queries\EntryQuery; diff --git a/src/Database/Queries/Concerns/QueriesEntryTypes.php b/src/Database/Queries/Concerns/Entry/QueriesEntryTypes.php similarity index 98% rename from src/Database/Queries/Concerns/QueriesEntryTypes.php rename to src/Database/Queries/Concerns/Entry/QueriesEntryTypes.php index 5de48fda71f..2198a1282ba 100644 --- a/src/Database/Queries/Concerns/QueriesEntryTypes.php +++ b/src/Database/Queries/Concerns/Entry/QueriesEntryTypes.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Cms\Database\Queries\Concerns; +namespace CraftCms\Cms\Database\Queries\Concerns\Entry; use craft\helpers\Db as DbHelper; use CraftCms\Cms\Database\Queries\EntryQuery; diff --git a/src/Database/Queries/Concerns/QueriesRef.php b/src/Database/Queries/Concerns/Entry/QueriesRef.php similarity index 97% rename from src/Database/Queries/Concerns/QueriesRef.php rename to src/Database/Queries/Concerns/Entry/QueriesRef.php index f560e507e89..769aed15d42 100644 --- a/src/Database/Queries/Concerns/QueriesRef.php +++ b/src/Database/Queries/Concerns/Entry/QueriesRef.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Cms\Database\Queries\Concerns; +namespace CraftCms\Cms\Database\Queries\Concerns\Entry; use CraftCms\Cms\Database\Queries\EntryQuery; use CraftCms\Cms\Database\Table; diff --git a/src/Database/Queries/Concerns/QueriesSections.php b/src/Database/Queries/Concerns/Entry/QueriesSections.php similarity index 99% rename from src/Database/Queries/Concerns/QueriesSections.php rename to src/Database/Queries/Concerns/Entry/QueriesSections.php index a94eb5dcaf8..d53b9831c97 100644 --- a/src/Database/Queries/Concerns/QueriesSections.php +++ b/src/Database/Queries/Concerns/Entry/QueriesSections.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Cms\Database\Queries\Concerns; +namespace CraftCms\Cms\Database\Queries\Concerns\Entry; use craft\helpers\Db as DbHelper; use CraftCms\Cms\Database\Queries\EntryQuery; diff --git a/src/Database/Queries/EntryQuery.php b/src/Database/Queries/EntryQuery.php index 273510e4d32..863c4ca7781 100644 --- a/src/Database/Queries/EntryQuery.php +++ b/src/Database/Queries/EntryQuery.php @@ -6,12 +6,12 @@ use Closure; use CraftCms\Cms\Cms; -use CraftCms\Cms\Database\Queries\Concerns\QueriesAuthors; -use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryDates; -use CraftCms\Cms\Database\Queries\Concerns\QueriesEntryTypes; +use CraftCms\Cms\Database\Queries\Concerns\Entry\QueriesAuthors; +use CraftCms\Cms\Database\Queries\Concerns\Entry\QueriesEntryDates; +use CraftCms\Cms\Database\Queries\Concerns\Entry\QueriesEntryTypes; +use CraftCms\Cms\Database\Queries\Concerns\Entry\QueriesRef; +use CraftCms\Cms\Database\Queries\Concerns\Entry\QueriesSections; use CraftCms\Cms\Database\Queries\Concerns\QueriesNestedElements; -use CraftCms\Cms\Database\Queries\Concerns\QueriesRef; -use CraftCms\Cms\Database\Queries\Concerns\QueriesSections; use CraftCms\Cms\Database\Queries\Exceptions\QueryAbortedException; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Elements\Entry;