diff --git a/database/Factories/DraftFactory.php b/database/Factories/DraftFactory.php new file mode 100644 index 00000000000..cdfa05a8123 --- /dev/null +++ b/database/Factories/DraftFactory.php @@ -0,0 +1,43 @@ + Element::factory(), + 'creatorId' => User::factory(), + 'provisional' => fake()->boolean(), + 'name' => fake()->words(asText: true), + 'trackChanges' => fake()->boolean(), + 'dateLastMerged' => now(), + '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/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 e98a5dfdef5..8d9396ea711 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 [ @@ -27,4 +28,63 @@ public function definition(): array 'dateUpdated' => $created, ]; } + + #[Override] + public function configure(): self + { + $this->afterCreating(function (Entry $entry) { + $entry->element->update([ + 'dateCreated' => $entry->postDate, + 'dateUpdated' => $entry->postDate, + ]); + }); + + 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), + ]); + } + + 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/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/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/DatabaseServiceProvider.php b/src/Database/DatabaseServiceProvider.php index 8f7a13fcb24..5235acfd57b 100644 --- a/src/Database/DatabaseServiceProvider.php +++ b/src/Database/DatabaseServiceProvider.php @@ -6,12 +6,13 @@ 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\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; @@ -58,6 +59,17 @@ public function boot(Repository $config, Connection $db, \Illuminate\Cache\Repos public function registerQueryBuilderMacros(): void { + 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()); Builder::macro('uidById', fn (int $id): ?string => $this->where('id', $id)->value('uid') ?: null); diff --git a/src/Database/ElementRelationParamFilter.php b/src/Database/ElementRelationParamFilter.php new file mode 100644 index 00000000000..6361c161374 --- /dev/null +++ b/src/Database/ElementRelationParamFilter.php @@ -0,0 +1,470 @@ +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; + } + + 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); + } + }); + } + + /** + * 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/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/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..2829331cccc --- /dev/null +++ b/src/Database/Expressions/JsonExtract.php @@ -0,0 +1,44 @@ +stringize($grammar, $this->expression); + $path = $this->formatPath($grammar); + + return match ($this->identify($grammar)) { + '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/Expressions/OrderByPlaceholderExpression.php b/src/Database/Expressions/OrderByPlaceholderExpression.php new file mode 100644 index 00000000000..691ba665729 --- /dev/null +++ b/src/Database/Expressions/OrderByPlaceholderExpression.php @@ -0,0 +1,16 @@ +query->toRawSql(); + /** @var \Illuminate\Database\Connection $connection */ + $connection = $elementQuery->query->getConnection(); + $config = Json::encode($connection->getConfig()); + + return md5($sql.$method.$config.Json::encode($parameters)); + } + + 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/Concerns/CollectsCacheTags.php b/src/Database/Queries/Concerns/CollectsCacheTags.php new file mode 100644 index 00000000000..109dfd089d9 --- /dev/null +++ b/src/Database/Queries/Concerns/CollectsCacheTags.php @@ -0,0 +1,114 @@ +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/Entry/QueriesAuthors.php b/src/Database/Queries/Concerns/Entry/QueriesAuthors.php new file mode 100644 index 00000000000..9d98eb3d499 --- /dev/null +++ b/src/Database/Queries/Concerns/Entry/QueriesAuthors.php @@ -0,0 +1,255 @@ +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(); + * ``` + * + * @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(); + * ``` + * + * + * @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(); + * ``` + * + * + * @uses $authorGroupId + */ + public function authorGroupId(mixed $value): static + { + $this->authorGroupId = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/Entry/QueriesEntryDates.php b/src/Database/Queries/Concerns/Entry/QueriesEntryDates.php new file mode 100644 index 00000000000..6632c8cec79 --- /dev/null +++ b/src/Database/Queries/Concerns/Entry/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/Entry/QueriesEntryTypes.php b/src/Database/Queries/Concerns/Entry/QueriesEntryTypes.php new file mode 100644 index 00000000000..2198a1282ba --- /dev/null +++ b/src/Database/Queries/Concerns/Entry/QueriesEntryTypes.php @@ -0,0 +1,164 @@ +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', $entryQuery->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(); + * ``` + * + * + * @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(); + * ``` + * + * + * @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/Concerns/Entry/QueriesRef.php b/src/Database/Queries/Concerns/Entry/QueriesRef.php new file mode 100644 index 00000000000..769aed15d42 --- /dev/null +++ b/src/Database/Queries/Concerns/Entry/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): static + { + $this->ref = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/Entry/QueriesSections.php b/src/Database/Queries/Concerns/Entry/QueriesSections.php new file mode 100644 index 00000000000..d53b9831c97 --- /dev/null +++ b/src/Database/Queries/Concerns/Entry/QueriesSections.php @@ -0,0 +1,197 @@ +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($entryQuery); + $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(); + * ``` + * + * + * @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; + } + + /** + * 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(); + * ``` + * + * + * @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 (! $entryQuery->sectionId) { + return; + } + + $entryQuery->subQuery->whereIn('entries.sectionId', $entryQuery->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(EntryQuery $entryQuery): void + { + $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/Concerns/FormatsResults.php b/src/Database/Queries/Concerns/FormatsResults.php new file mode 100644 index 00000000000..79f3ccbe42f --- /dev/null +++ b/src/Database/Queries/Concerns/FormatsResults.php @@ -0,0 +1,282 @@ + 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 + */ + 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) + */ + 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) + */ + public function fixedOrder(bool $value = true): static + { + $this->fixedOrder = $value; + + return $this; + } + + public function ids(): array + { + return $this->pluck('elements.id')->all(); + } + + protected function initFormatsResults(): void + { + $this->query->orderBy(new OrderByPlaceholderExpression); + } + + protected function applyOrderByParams(ElementQuery $elementQuery): void + { + $this->orderBySearchResults($elementQuery); + $this->applyDefaultOrder($elementQuery); + + if ($elementQuery->inReverse) { + $orders = $elementQuery->query->orders; + + $elementQuery->query->reorder(); + + 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']); + + continue; + } + + $elementQuery->query->orderBy($order['column'], $order['direction'] === 'asc' ? 'desc' : 'asc'); + } + } + + $this->parseOrderColumnMappings($elementQuery); + } + + private function applyDefaultOrder(ElementQuery $elementQuery): void + { + $orders = $elementQuery->query->orders; + + if (is_null($orders)) { + return; + } + + $elementQuery->query->orders = array_filter( + array: $orders, + callback: fn ($order) => ! $order['column'] instanceof OrderByPlaceholderExpression, + ); + + // Order by was set + if (count($elementQuery->query->orders) > 0) { + return; + } + + if ($elementQuery->fixedOrder) { + throw_if(empty($elementQuery->id), QueryAbortedException::class); + + if (! is_array($ids = $elementQuery->id)) { + $ids = is_string($ids) ? str($ids)->explode(',')->all() : [$ids]; + } + + $elementQuery->query->orderBy(new FixedOrderExpression('elements.id', $ids)); + + return; + } + + if ($elementQuery->revisions) { + $elementQuery->query->orderByDesc('num'); + + return; + } + + if ($elementQuery->shouldJoinStructureData()) { + $elementQuery->query->orderBy('structureelements.lft'); + } + + 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), + }); + } + } + + private function parseOrderColumnMappings(ElementQuery $elementQuery): void + { + $orders = $elementQuery->query->orders; + + if (is_null($orders)) { + return; + } + + $elementQuery->query->orders = array_map(function ($order) use ($elementQuery) { + if (! is_string($order['column'])) { + return $order; + } + + $order['column'] = $elementQuery->columnMap[$order['column']] ?? $order['column']; + + return $order; + }, $orders); + } + + private function orderBySearchResults(ElementQuery $elementQuery): void + { + $elementQuery->query->orders = array_filter( + $elementQuery->query->orders ?? [], + fn (array $order) => $order['column'] !== 'score', + ); + + if (! $elementQuery->searchResults) { + return; + } + + $keys = array_keys($elementQuery->searchResults); + + $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->query->orderBy(new CaseGroup($rules, new Value($i + 1))); + } +} diff --git a/src/Database/Queries/Concerns/HydratesElements.php b/src/Database/Queries/Concerns/HydratesElements.php new file mode 100644 index 00000000000..d5e8ec9d2d9 --- /dev/null +++ b/src/Database/Queries/Concerns/HydratesElements.php @@ -0,0 +1,207 @@ + + */ + public function hydrate(array $items): ElementCollection + { + $items = array_map(fn (stdClass $row) => (array) $row, $items); + + $elements = collect($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)) + ->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; + })->all(); + + if ($this->withProvisionalDrafts) { + ElementHelper::swapInProvisionalDrafts($elements); + } + + if (Event::hasListeners(ElementsHydrated::class)) { + Event::dispatch($event = new ElementsHydrated($elements, $items)); + + return new ElementCollection($event->elements); + } + + return new ElementCollection($elements); + } + + protected function createElement(array $row): ElementInterface + { + // Do we have a placeholder for this element? + if ( + ! $this->ignorePlaceholders && + isset($row['id'], $row['siteId']) && + ! is_null($element = Craft::$app->getElements()->getPlaceholderElement($row['id'], $row['siteId'])) + ) { + return $element; + } + + /** @var class-string $class */ + $class = $this->elementType; + + // Instantiate the element + 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 (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']])) { + continue; + } + + $row['generatedFieldValues'][$field['uid']] = $content[$field['uid']]; + + if (! empty($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/OverridesResults.php b/src/Database/Queries/Concerns/OverridesResults.php new file mode 100644 index 00000000000..9876a1328f5 --- /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 clearResultOverride(): void + { + $this->override = $this->overrideCriteria = null; + } +} diff --git a/src/Database/Queries/Concerns/QueriesCustomFields.php b/src/Database/Queries/Concerns/QueriesCustomFields.php new file mode 100644 index 00000000000..feaea76f301 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesCustomFields.php @@ -0,0 +1,277 @@ + Column alias => cast type + * + * @see prepare() + * @see _applyOrderByParams() + */ + 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 = []; + $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(); + $this->addGeneratedFieldsToColumnMap(); + + $this->applyCustomFieldParams($elementQuery); + $this->applyGeneratedFieldParams($elementQuery); + }); + } + + /** + * Sets whether custom fields should be factored into the query. + */ + 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 Collection + */ + protected function fieldLayouts(): Collection + { + return Fields::getLayoutsByType($this->elementType); + } + + /** + * Returns the field layouts that could be associated with the resulting elements. + * + * @return Collection + */ + public function getFieldLayouts(): Collection + { + return $this->fieldLayouts(); + } + + /** + * Include custom fields in the column map + */ + private function addCustomFieldsToColumnMap(): void + { + foreach ($this->customFields ?? [] as $field) { + $dbTypes = $field::dbType(); + + 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); + + $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 + /** @var \Illuminate\Database\Connection $connection */ + $connection = $this->query->getConnection(); + if ($connection->getDriverName() === 'mysql' && Query::parseColumnType($dbType) === Query::TYPE_TEXT) { + $this->columnsToCast[$alias] = 'CHAR(255)'; + } + } + } + } + + /** + * 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']), + ); + } + } + + 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(ElementQuery $elementQuery): void + { + if (empty($elementQuery->customFields)) { + return; + } + + $fieldsByHandle = $this->fieldsByHandle($elementQuery); + + 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 (($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."); + } + + $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); + } + }); + } + } + + private function applyGeneratedFieldParams(ElementQuery $elementQuery): void + { + if (empty($elementQuery->generatedFields)) { + return; + } + + $fieldsByHandle = $this->fieldsByHandle($elementQuery); + + $generatedFieldColumns = []; + + 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 new file mode 100644 index 00000000000..f71b6115c8b --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesDraftsAndRevisions.php @@ -0,0 +1,611 @@ +beforeQuery(function (ElementQuery $elementQuery) { + $this->applyDraftParams($elementQuery); + $this->applyRevisionParams($elementQuery); + }); + } + + private function applyDraftParams(ElementQuery $elementQuery): void + { + if ($elementQuery->drafts === false) { + $elementQuery->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.draftId'))); + + return; + } + + $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); + + $elementQuery->query->addSelect([ + 'elements.draftId as draftId', + 'drafts.creatorId as draftCreatorId', + 'drafts.provisional as isProvisionalDraft', + 'drafts.name as draftName', + 'drafts.notes as draftNotes', + ]); + + if ($elementQuery->draftId) { + $elementQuery->subQuery->where('elements.draftId', $elementQuery->draftId); + } + + if ($elementQuery->draftOf === '*') { + $elementQuery->subQuery->whereNotNull('elements.canonicalId'); + } elseif (isset($elementQuery->draftOf)) { + if ($elementQuery->draftOf === false) { + $elementQuery->subQuery->whereNull('elements.canonicalId'); + } else { + $elementQuery->subQuery->whereIn('elements.canonicalId', Arr::wrap($elementQuery->draftOf)); + } + } + + if ($elementQuery->draftCreator) { + $elementQuery->subQuery->where('drafts.creatorId', $elementQuery->draftCreator); + } + + if (isset($elementQuery->provisionalDrafts)) { + $elementQuery->subQuery->where(function (Builder $q) use ($elementQuery) { + $q->whereNull('elements.draftId') + ->orWhere('drafts.provisional', $elementQuery->provisionalDrafts); + }); + } + + if ($elementQuery->canonicalsOnly) { + $elementQuery->subQuery->where(function (Builder $query) use ($elementQuery) { + $query->whereNull('elements.draftId') + ->orWhere(function (Builder $q) use ($elementQuery) { + $q + ->whereNull('elements.canonicalId') + ->when( + $elementQuery->savedDraftsOnly, + fn (Builder $q) => $q->where('drafts.saved', true) + ); + }); + }); + } elseif ($elementQuery->savedDraftsOnly) { + $elementQuery->subQuery->where(function (Builder $query) { + $query->whereNull('elements.draftId') + ->orWhereNotNull('elements.canonicalId') + ->orWhere('drafts.saved', true); + }); + } + } + + private function applyRevisionParams(ElementQuery $elementQuery): void + { + if ($elementQuery->revisions === false) { + $elementQuery->subQuery->where($this->placeholderCondition(fn (Builder $q) => $q->whereNull('elements.revisionId'))); + + return; + } + + $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); + + $elementQuery->query->addSelect([ + 'elements.revisionId as revisionId', + 'revisions.creatorId as revisionCreatorId', + 'revisions.num as revisionNum', + 'revisions.notes as revisionNotes', + ]); + + if ($elementQuery->revisionId) { + $elementQuery->subQuery->where('elements.revisionId', $elementQuery->revisionId); + } + + if ($elementQuery->revisionOf) { + $elementQuery->subQuery->where('elements.canonicalId', $elementQuery->revisionOf); + } + + if ($elementQuery->revisionCreator) { + $elementQuery->subQuery->where('revisions.creatorId', $elementQuery->revisionCreator); + } + } + + /** + * 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(); + * ``` + */ + public function drafts(?bool $value = true): static + { + $this->drafts = $value; + + return $this; + } + + /** + * 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 + { + $this->withProvisionalDrafts = $value; + + return $this; + } + + /** + * 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(); + * ``` + */ + public function draftId(?int $value = null): static + { + $this->draftId = $value; + + if ($value !== null && $this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + + /** + * 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(); + * ``` + */ + public function draftOf(mixed $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'); + } + + /** + * 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(Auth::user()) + * ->all(); + * ``` + */ + public function draftCreator(mixed $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; + } + + /** + * 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(Auth::user()) + * ->all(); + * ``` + */ + public function provisionalDrafts(?bool $value = true): static + { + $this->provisionalDrafts = $value; + + if ($value === true && $this->drafts === false) { + $this->drafts = true; + } + + return $this; + } + + /** + * 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. + */ + public function canonicalsOnly(bool $value = true): static + { + $this->canonicalsOnly = $value; + + return $this; + } + + /** + * 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(); + * ``` + */ + public function savedDraftsOnly(bool $value = true): static + { + $this->savedDraftsOnly = $value; + + return $this; + } + + /** + * 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(); + * ``` + */ + public function revisions(?bool $value = true): static + { + $this->revisions = $value; + + return $this; + } + + /** + * 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(); + * ``` + */ + public function revisionId(?int $value = null): static + { + $this->revisionId = $value; + + if ($value !== null && $this->revisions === false) { + $this->revisions = true; + } + + return $this; + } + + /** + * 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(); + * ``` + */ + public function revisionOf(mixed $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; + } + + /** + * 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(Auth::user()) + * ->all(); + * ``` + */ + public function revisionCreator(mixed $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..c002bb10456 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesEagerly.php @@ -0,0 +1,238 @@ +afterQuery(function (mixed $result) { + if (! $result instanceof Collection) { + return $result; + } + + if (! $this->with) { + return $result; + } + + $elementsService = Craft::$app->getElements(); + $elementsService->eagerLoadElements($this->elementType, $result->all(), $this->with); + + return $result; + }); + } + + /** + * 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. + * + * --- + * + * ```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 + { + 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; + } + + /** + * 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 + { + if (! is_null($value)) { + return $this; + } + + return $this->with($value); + } + + /** + * 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 + { + $this->eagerly = $value !== false; + $this->eagerLoadAlias = is_string($value) ? $value : null; + + return $this; + } + + /** + * 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->eagerLoadSourceElement = $sourceElement; + + return $this; + } + + /** + * Returns whether the query results were already eager loaded by the query's source element. + */ + 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((string) $planHandle, ':')) { + $planHandle = explode(':', (string) $planHandle, 2)[1]; + } + + return $this->eagerLoadSourceElement->hasEagerLoadedElements($planHandle); + } + + /** + * Returns whether the query result count was already eager loaded by the query's source element. + */ + 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((string) $planHandle, ':')) { + $planHandle = explode(':', (string) $planHandle, 2)[1]; + } + + return $this->eagerLoadSourceElement->getEagerLoadedElementCount($planHandle) !== null; + } + + protected 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/QueriesFields.php b/src/Database/Queries/Concerns/QueriesFields.php new file mode 100644 index 00000000000..c403f672074 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesFields.php @@ -0,0 +1,488 @@ +beforeQuery(function (ElementQuery $elementQuery) { + if ($elementQuery->id) { + $elementQuery->subQuery->whereNumericParam('elements.id', $elementQuery->id); + } + + if ($elementQuery->uid) { + $elementQuery->subQuery->whereParam('elements.uid', $elementQuery->uid); + } + + if ($elementQuery->siteSettingsId) { + $elementQuery->subQuery->whereNumericParam('elements_sites.id', $elementQuery->siteSettingsId); + } + + match ($elementQuery->trashed) { + true => $elementQuery->subQuery->whereNotNull('elements.dateDeleted'), + false => $elementQuery->subQuery->whereNull('elements.dateDeleted'), + default => null, + }; + + if ($elementQuery->dateCreated) { + $elementQuery->subQuery->whereDateParam('elements.dateCreated', $elementQuery->dateCreated); + } + + if ($elementQuery->dateUpdated) { + $elementQuery->subQuery->whereDateParam('elements.dateUpdated', $elementQuery->dateUpdated); + } + + if (isset($elementQuery->title) && $elementQuery->title !== '' && $elementQuery->elementType::hasTitles()) { + if (is_string($elementQuery->title)) { + $elementQuery->title = Query::escapeCommas($elementQuery->title); + } + + $elementQuery->subQuery->whereParam('elements_sites.title', $elementQuery->title, caseInsensitive: true); + } + + if ($elementQuery->slug) { + $elementQuery->subQuery->whereParam('elements_sites.slug', $elementQuery->slug); + } + + if ($elementQuery->uri) { + $elementQuery->subQuery->whereParam('elements_sites.uri', $elementQuery->uri, caseInsensitive: true); + } + + if ($elementQuery->inBulkOp) { + $elementQuery->subQuery + ->join(new Alias(Table::ELEMENTS_BULKOPS, 'elements_bulkops'), 'elements_bulkops.elementId', 'elements.id') + ->where('elements_bulkops.key', $elementQuery->inBulkOp); + } + }); + } + + /** + * 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(); + * ``` + * + * --- + * + * ::: tip + * This can be combined with [[fixedOrder()]] if you want the results to be returned in a specific order. + * ::: + */ + public function id(mixed $value): static + { + $this->id = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch the {element} by its UID + * ${element-var} = {php-method} + * ->uid('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx') + * ->one(); + * ``` + */ + public function uid(mixed $value): static + { + $this->uid = $value; + + return $this; + } + + /** + * 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(); + * ``` + */ + public function siteSettingsId(mixed $value): static + { + $this->siteSettingsId = $value; + + return $this; + } + + /** + * Narrows the query results to only {elements} that have been soft-deleted. + * + * --- + * + * ```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) + */ + public function trashed(?bool $value = true): static + { + $this->trashed = $value; + + return $this; + } + + /** + * 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); + * + * ${elements-var} = {php-method} + * ->dateCreated(['and', ">= {$start}", "< {$end}"]) + * ->all(); + * ``` + */ + public function dateCreated(mixed $value): static + { + $this->dateCreated = $value; + + return $this; + } + + /** + * 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 %} + * + * {% 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(); + * ``` + */ + public function dateUpdated(mixed $value): static + { + $this->dateUpdated = $value; + + return $this; + } + + /** + * Narrows the query results based on the {elements}’ titles. + * + * Possible values include: + * + * | 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(); + * ``` + */ + public function title(mixed $value): static + { + $this->title = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```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(); + * ``` + */ + public function slug(mixed $value): static + { + $this->slug = $value; + + return $this; + } + + /** + * Narrows the query results based on the {elements}’ URIs. + * + * 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() %} + * + * {# 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(); + * ``` + */ + public function uri(mixed $value): static + { + $this->uri = $value; + + return $this; + } + + /** + * Narrows the query results to only {elements} that were involved in a bulk element operation. + * + * @param string|null $value The property value + */ + public function inBulkOp(?string $value): static + { + $this->inBulkOp = $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..5426634c9ec --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesNestedElements.php @@ -0,0 +1,491 @@ +beforeQuery(function (ElementQuery $elementQuery) { + /** @var \CraftCms\Cms\Database\Queries\EntryQuery $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 as ownerId', + 'elements_owners.sortOrder as 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) { + $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', '=', $elementQuery->getPrimaryOwnerIdColumn()), + ) + ); + + if (! $allowOwnerDrafts) { + $elementQuery->subQuery->whereNull('owners.draftId'); + } + + if (! $allowOwnerRevisions) { + $elementQuery->subQuery->whereNull('owners.revisionId'); + } + } + + $elementQuery->defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; + }); + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch {elements} in the Foo field + * ${elements-var} = {php-method} + * ->field('foo') + * ->all(); + * ``` + */ + public function field(mixed $value): static + { + if (Query::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; + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch {elements} in the field with an ID of 1 + * ${elements-var} = {php-method} + * ->fieldId(1) + * ->all(); + * ``` + */ + public function fieldId(mixed $value): static + { + $this->fieldId = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```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 + { + $this->primaryOwnerId = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch {elements} created for this entry + * ${elements-var} = {php-method} + * ->primaryOwner($myEntry) + * ->all(); + * ``` + */ + public function primaryOwner(ElementInterface $primaryOwner): static + { + $this->primaryOwnerId = [$primaryOwner->id]; + $this->siteId = $primaryOwner->siteId; + + return $this; + } + + /** + * Narrows the query results based on the 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} + * .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; + + return $this; + } + + /** + * Sets the [[ownerId()]] and [[siteId()]] parameters based on a given element. + * + * --- + * + * ```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; + + return $this; + } + + /** + * Narrows the query results based on whether the {elements}’ owners are drafts. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `true` | which can belong to a draft. + * | `false` | which cannot belong to a draft. + */ + public function allowOwnerDrafts(?bool $value = true): static + { + $this->allowOwnerDrafts = $value; + + return $this; + } + + /** + * Narrows the query results based on whether the {elements}’ owners are revisions. + * + * Possible values include: + * + * | Value | Fetches {elements}… + * | - | - + * | `true` | which can belong to a revision. + * | `false` | which cannot belong to a revision. + */ + public function allowOwnerRevisions(?bool $value = true): static + { + $this->allowOwnerRevisions = $value; + + return $this; + } + + /** + * {@inheritdoc} + */ + protected function cacheTags(): array + { + $tags = []; + + if ($this->fieldId) { + foreach (Arr::wrap($this->fieldId) as $fieldId) { + $tags[] = "field:$fieldId"; + } + } + + if ($this->primaryOwnerId) { + foreach (Arr::wrap($this->primaryOwnerId) as $ownerId) { + $tags[] = "element::$ownerId"; + } + } + + if ($this->ownerId) { + foreach (Arr::wrap($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 = Fields::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 \CraftCms\Cms\Database\Queries\EntryQuery $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 \CraftCms\Cms\Database\Queries\EntryQuery $query */ + if ($query->fieldId === false) { + return; + } + + if (empty($query->fieldId)) { + $query->fieldId = is_array($query->fieldId) ? [] : null; + + return; + } + + if (is_numeric($query->fieldId)) { + $query->fieldId = [$query->fieldId]; + + return; + } + + if (! is_array($query->fieldId) || ! Arr::isNumeric($query->fieldId)) { + $query->fieldId = 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/Concerns/QueriesPlaceholderElements.php b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php new file mode 100644 index 00000000000..f764ad9988d --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesPlaceholderElements.php @@ -0,0 +1,86 @@ +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)->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..b62502bd571 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesRelatedElements.php @@ -0,0 +1,239 @@ +applyRelatedToParam(); + $this->applyNotRelatedToParam(); + } + + private function applyRelatedToParam(): void + { + $this->beforeQuery(function (ElementQuery $elementQuery) { + if (! $elementQuery->relatedTo) { + return; + } + + 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 + ); + }); + } + + private function applyNotRelatedToParam(): void + { + $this->beforeQuery(function (ElementQuery $elementQuery) { + if (! $elementQuery->notRelatedTo) { + return; + } + + $notRelatedToParam = $elementQuery->notRelatedTo; + + $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 + ); + }); + }); + } + + /** + * 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. + * + * --- + * + * ```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 + { + $this->notRelatedTo = $value; + + return $this; + } + + /** + * 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. + * + * --- + * + * ```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 + { + $relatedTo = $this->_andRelatedToCriteria($value, $this->notRelatedTo); + + if ($relatedTo === false) { + return $this; + } + + return $this->notRelatedTo($relatedTo); + } + + /** + * 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. + * + * --- + * + * ```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 + { + $this->relatedTo = $value; + + return $this; + } + + /** + * 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. + * + * --- + * + * ```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 + { + $relatedTo = $this->_andRelatedToCriteria($value, $this->relatedTo); + + if ($relatedTo === false) { + return $this; + } + + return $this->relatedTo($relatedTo); + } + + 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 = ElementRelationParamFilter::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 RuntimeException('It’s not possible to combine “or” and “and” relatedTo conditions.'); + } + + $relatedTo[0] = $criteriaCount > 0 ? 'and' : 'or'; + $relatedTo[] = ElementRelationParamFilter::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..0c018aff286 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesSites.php @@ -0,0 +1,289 @@ +beforeQuery(function (ElementQuery $elementQuery) { + // Make sure the siteId param is set + try { + if (! $elementQuery->elementType::isLocalized()) { + // The criteria *must* be set to the primary site ID + $elementQuery->siteId = Sites::getPrimarySite()->id; + } else { + $elementQuery->siteId = $this->normalizeSiteId($elementQuery); + } + } catch (SiteNotFoundException $e) { + // Fail silently if Craft isn't installed yet or is in the middle of updating + if (Info::isInstalled() && ! Updates::isCraftUpdatePending()) { + throw $e; + } + + throw new QueryAbortedException($e->getMessage(), 0, $e); + } + + if (Sites::isMultiSite(false, true)) { + $elementQuery->subQuery->whereIn('elements_sites.siteId', Arr::wrap($elementQuery->siteId)); + } + }); + } + + /** + * Determines which site(s) the {elements} should be queried in. + * + * The current site will be used by default. + * + * 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 = Sites::getAllSiteIds(); + } elseif ($value instanceof Site || $value instanceof SiteModel) { + $this->siteId = $value->id; + } elseif (is_string($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); + } + + $this->siteId = []; + + foreach (Sites::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; + } + + /** + * 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. + * + * --- + * + * ```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 + { + if (is_array($value) && strtolower((string) reset($value)) === 'not') { + array_shift($value); + + $this->siteId = []; + + foreach (Sites::getAllSites() as $site) { + if (! in_array($site->id, $value)) { + $this->siteId[] = $site->id; + } + } + + return $this; + } + + $this->siteId = $value; + + return $this; + } + + /** + * 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`. + * + * ::: 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. + * ::: + * + * --- + * + * ```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): static + { + if (is_string($value)) { + $sites = Sites::getSitesByLanguage($value); + + if ($sites->isEmpty()) { + throw new InvalidArgumentException("Invalid language: $value"); + } + + $this->siteId = $sites->pluck('id')->all(); + + return $this; + } + + if ($not = (strtolower((string) reset($value)) === 'not')) { + array_shift($value); + } + + $this->siteId = []; + + foreach (Sites::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(ElementQuery $query): mixed + { + if (! $query->siteId) { + // Default to the current site + return Sites::getCurrentSite()->id; + } + + if ($query->siteId === '*') { + return Sites::getAllSiteIds()->all(); + } + + if ($query->siteId instanceof Collection) { + $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) + ->filter(fn ($siteId) => Sites::getSiteById($siteId, true) !== null) + ->all(); + + if (empty($siteIds)) { + throw new QueryAbortedException; + } + + 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 new file mode 100644 index 00000000000..33d21f59348 --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesStatuses.php @@ -0,0 +1,176 @@ +beforeQuery(function (ElementQuery $elementQuery) { + if ($elementQuery->archived) { + $elementQuery->subQuery->where('elements.archived', true); + + return; + } + + $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($elementQuery->status) || ! in_array($elementQuery->elementType::STATUS_ARCHIVED, $elementQuery->status)) { + $elementQuery->subQuery->where('elements.archived', false); + } + }); + } + + public function archived(bool $value = true): static + { + $this->archived = $value; + + return $this; + } + + /** + * 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 + */ + 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(ElementQuery $elementQuery): void + { + if (! $elementQuery->status || ! $elementQuery->elementType::hasStatuses()) { + return; + } + + // Normalize the status param + if (! is_array($elementQuery->status)) { + $elementQuery->status = str($elementQuery->status)->explode(',')->all(); + } + + $statuses = array_merge($elementQuery->status); + $firstVal = strtolower((string) reset($statuses)); + $glue = 'or'; + + if (in_array($firstVal, ['not', 'or'])) { + $glue = $firstVal; + array_shift($statuses); + } + + if (! $statuses) { + return; + } + + if ($negate = ($glue === 'not')) { + $glue = 'and'; + } + + $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))), + $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))), + }; + } + }); + } + + /** + * 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..eb93e4fdd8f --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesStructures.php @@ -0,0 +1,787 @@ +beforeQuery(function (ElementQuery $elementQuery) { + $this->applyStructureParams($elementQuery); + }); + + $this->afterQuery(function (mixed $result) { + if (! $result instanceof Collection) { + return $result; + } + + if ($this->structureId) { + return $result->map(function ($element) { + $element->structureId = $this->structureId; + + return $element; + }); + } + + return $result; + }); + } + + /** + * Explicitly determines whether the query should join in the structure data. + */ + public function withStructure(bool $value = true): static + { + $this->withStructure = $value; + + return $this; + } + + /** + * Determines which structure data should be joined into the query. + * + * @internal + */ + public function structureId(?int $value = null): static + { + $this->structureId = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch {elements} positioned at level 3 or above + * ${elements-var} = {php-method} + * ->level('>= 3') + * ->all(); + * ``` + */ + public function level($value = null): static + { + $this->level = $value; + + return $this; + } + + /** + * Narrows the query results based on whether the {elements} have any descendants in their structure. + * + * (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 + { + $this->hasDescendants = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch {elements} that have no descendants + * ${elements-var} = {php-method} + * ->leaves() + * ->all(); + * ``` + */ + public function leaves(bool $value = true): static + { + $this->leaves = $value; + + return $this; + } + + /** + * Narrows the query results to only {elements} that are ancestors of another {element} in its structure. + * + * 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 + { + $this->ancestorOf = $value; + + return $this; + } + + /** + * Narrows the query results to only {elements} that are up to a certain distance away from the {element} specified by [[ancestorOf()]]. + * + * --- + * + * ```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 + { + $this->ancestorDist = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```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 + { + $this->descendantOf = $value; + + return $this; + } + + /** + * Narrows the query results to only {elements} that are up to a certain distance away from the {element} specified by [[descendantOf()]]. + * + * --- + * + * ```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 + { + $this->descendantDist = $value; + + return $this; + } + + /** + * 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. + * + * --- + * + * ```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 + { + $this->siblingOf = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch the previous {element} + * ${element-var} = {php-method} + * ->prevSiblingOf(${myElement}) + * ->one(); + * ``` + */ + public function prevSiblingOf(ElementInterface|int|null $value): static + { + $this->prevSiblingOf = $value; + + return $this; + } + + /** + * Narrows the query results to only the {element} that comes immediately after another {element} in its structure. + * + * 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 + { + $this->nextSiblingOf = $value; + + return $this; + } + + /** + * 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. + * + * --- + * + * ```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 + { + $this->positionedBefore = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```php + * // Fetch {elements} after this one + * ${elements-var} = {php-method} + * ->positionedAfter(${myElement}) + * ->all(); + * ``` + */ + 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(ElementQuery $elementQuery): void + { + if (! $elementQuery->shouldJoinStructureData()) { + $structureParams = [ + 'hasDescendants', + 'ancestorOf', + 'descendantOf', + 'siblingOf', + 'prevSiblingOf', + 'nextSiblingOf', + 'positionedBefore', + 'positionedAfter', + 'level', + ]; + + foreach ($structureParams as $param) { + if ($elementQuery->$param !== null) { + throw new QueryAbortedException("Unable to apply the '$param' param because 'structureId' isn't set"); + } + } + + return; + } + + $elementQuery->query->addSelect([ + 'structureelements.root as root', + 'structureelements.lft as lft', + 'structureelements.rgt as rgt', + 'structureelements.level as level', + ]); + + if ($elementQuery->structureId) { + $elementQuery->query->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join + ->on('structureelements.elementId', '=', 'subquery.elementsId') + ->where('structureelements.structureId', $elementQuery->structureId)); + + $elementQuery->subQuery->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join + ->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 + ->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 + // performant one (dateDeleted). + ->when( + DB::getDriverName() === 'mysql', + fn (Builder $query) => $query->useIndex('primary'), + ) + ->whereColumn('id', 'structureelements.structureId') + ->whereNull('dateDeleted'); + + $elementQuery->subQuery + ->addSelect('structureelements.structureId as structureId') + ->leftJoin(new Alias(Table::STRUCTUREELEMENTS, 'structureelements'), fn (JoinClause $join) => $join + ->on('structureelements.elementId', '=', 'elements.id') + ->whereExists($existsQuery) + ); + } + + 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 ($elementQuery->ancestorOf) { + $ancestorOf = $elementQuery->normalizeStructureParamValue('ancestorOf'); + + $elementQuery->subQuery + ->where('structureelements.lft', '<', $ancestorOf->lft) + ->where('structureelements.rgt', '>', $ancestorOf->rgt) + ->where('structureelements.root', $ancestorOf->root) + ->when( + $elementQuery->ancestorDist, + fn (Builder $q) => $q->where('structureelements.level', '>=', $ancestorOf->level - $elementQuery->ancestorDist) + ); + } + + if ($elementQuery->descendantOf) { + $descendantOf = $elementQuery->normalizeStructureParamValue('descendantOf'); + + $elementQuery->subQuery + ->where('structureelements.lft', '>', $descendantOf->lft) + ->where('structureelements.rgt', '<', $descendantOf->rgt) + ->where('structureelements.root', $descendantOf->root) + ->when( + $elementQuery->descendantDist, + fn (Builder $q) => $q->where('structureelements.level', '<=', $descendantOf->level + $elementQuery->descendantDist) + ); + } + + foreach (['siblingOf', 'prevSiblingOf', 'nextSiblingOf'] as $param) { + if (! $elementQuery->$param) { + continue; + } + + $siblingOf = $elementQuery->normalizeStructureParamValue($param); + + $elementQuery->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; + } + + $elementQuery->subQuery + ->where('structureelements.lft', '>', $parent->lft) + ->where('structureelements.rgt', '>', $parent->rgt); + } + + switch ($param) { + case 'prevSiblingOf': + $elementQuery->orderByDesc('structureelements.lft'); + $elementQuery->subQuery + ->where('structureelements.lft', '<', $siblingOf->lft) + ->orderByDesc('structureelements.lft') + ->limit(1); + break; + case 'nextSiblingOf': + $elementQuery->orderBy('structureelements.lft'); + $elementQuery->subQuery + ->where('structureelements.lft', '>', $siblingOf->lft) + ->orderBy('structureelements.lft') + ->limit(1); + break; + } + } + + if ($elementQuery->positionedBefore) { + $positionedBefore = $elementQuery->normalizeStructureParamValue('positionedBefore'); + + $elementQuery->subQuery + ->where('structureelements.lft', '<', $positionedBefore->lft) + ->where('structureelements.root', $positionedBefore->root); + } + + if ($elementQuery->positionedAfter) { + $positionedAfter = $elementQuery->normalizeStructureParamValue('positionedAfter'); + + $elementQuery->subQuery + ->where('structureelements.lft', '>', $positionedAfter->rgt) + ->where('structureelements.root', $positionedAfter->root); + } + + if (isset($elementQuery->level)) { + $allowNull = is_array($elementQuery->level) && in_array(null, $elementQuery->level, true); + + $elementQuery->subQuery->when( + 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'); + }), + default: fn (Builder $q) => $q->whereNumericParam('structureelements.level', $elementQuery->level), + ); + } + + if ($elementQuery->leaves) { + $elementQuery->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. + * @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..c6b05a1ee8b --- /dev/null +++ b/src/Database/Queries/Concerns/QueriesUniqueElements.php @@ -0,0 +1,146 @@ +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); + } + + /** + * Determines whether only elements with unique IDs should be returned by the query. + * + * 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 + { + $this->unique = $value; + + return $this; + } + + /** + * 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() %} + * ``` + * + * ```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 + { + $this->preferSites = $value; + + return $this; + } +} diff --git a/src/Database/Queries/Concerns/SearchesElements.php b/src/Database/Queries/Concerns/SearchesElements.php new file mode 100644 index 00000000000..edb0e18b5f7 --- /dev/null +++ b/src/Database/Queries/Concerns/SearchesElements.php @@ -0,0 +1,150 @@ +|null + * + * @see applySearchParam() + * @see applyOrderByParams() + * @see hydrate() + */ + private ?array $searchResults = null; + + protected function initSearchesElements(): void + { + $this->beforeQuery(function (ElementQuery $elementQuery) { + $this->applySearchParam($elementQuery); + }); + } + + /** + * Applies the 'search' param to the query being prepared. + * + * @throws QueryAbortedException + */ + private function applySearchParam(ElementQuery $elementQuery): void + { + $elementQuery->searchResults = null; + + if (! $elementQuery->search) { + return; + } + + $searchService = Craft::$app->getSearch(); + + $scoreOrder = Arr::first($elementQuery->query->orders ?? [], fn ($order) => $order['column'] === 'score'); + + if ($scoreOrder || $searchService->shouldCallSearchElements($elementQuery)) { + // Get the scored results up front + $searchResults = $searchService->searchElements($elementQuery); + + if ($scoreOrder['direction'] === 'asc') { + $searchResults = array_reverse($searchResults, true); + } + + if (($elementQuery->query->orders[0]['column'] ?? null) === 'score') { + // Only use the portion we're actually querying for + 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($elementQuery->query->getLimit()) && $elementQuery->query->getLimit() !== 0) { + $searchResults = array_slice($searchResults, 0, $elementQuery->query->getLimit(), true); + $elementQuery->subQuery->limit = null; + $elementQuery->subQuery->unionLimit = null; + } + } + + if (empty($searchResults)) { + throw new QueryAbortedException; + } + + $elementQuery->searchResults = $searchResults; + + $elementIdsBySiteId = []; + foreach (array_keys($searchResults) as $key) { + [$elementId, $siteId] = explode('-', (string) $key, 2); + $elementIdsBySiteId[$siteId][] = $elementId; + } + + $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) + ->whereIn('elements.id', $elementIds); + }); + } + }); + + return; + } + + // Just filter the main query by the search query + $searchQuery = $searchService->createDbQuery($elementQuery->search, $elementQuery); + + if ($searchQuery === false) { + throw new QueryAbortedException; + } + + $elementQuery->subQuery->whereIn('elements.id', $searchQuery->select('elementId')->all()); + } + + /** + * 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(); + * ``` + */ + public function search(mixed $value): static + { + $this->search = $value; + + return $this; + } +} diff --git a/src/Database/Queries/ElementQuery.php b/src/Database/Queries/ElementQuery.php new file mode 100644 index 00000000000..025d9a3e365 --- /dev/null +++ b/src/Database/Queries/ElementQuery.php @@ -0,0 +1,1093 @@ + */ + use BuildsQueries { + BuildsQueries::sole as baseSole; + BuildsQueries::first as baseFirst; + } + + use Concerns\CachesQueries; + use Concerns\CollectsCacheTags; + use Concerns\FormatsResults; + + /** @use Concerns\HydratesElements */ + use Concerns\HydratesElements; + + use Concerns\OverridesResults; + 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\SearchesElements; + use ForwardsCalls; + + /** + * The base query builder instance. + */ + protected Builder $query; + + /** + * The subquery that the main query will select from. + */ + protected Builder $subQuery; + + /** + * 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', + 'orders', + ]; + + /** + * 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', + '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 before retrieving data from the database. + */ + protected array $beforeQueryCallbacks = []; + + /** + * 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 = ['**']; + + // 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 + */ + public function __construct( + /** @var class-string */ + public string $elementType = Element::class, + protected array $config = [], + ) { + Typecast::properties(static::class, $config); + + foreach ($config as $key => $value) { + $this->{$key} = $value; + } + + $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', + '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) + $this->columnMap = [ + 'id' => 'elements.id', + 'enabled' => 'elements.enabled', + 'dateCreated' => 'elements.dateCreated', + 'dateUpdated' => 'elements.dateUpdated', + 'uid' => 'elements.uid', + ]; + + if ($this->elementType::hasTitles()) { + $this->columnMap['title'] = 'elements_sites.title'; + } + + $this->initTraits(); + } + + protected function initTraits(): void + { + $class = static::class; + + $uses = class_uses_recursive($class); + + $conventionalInitMethods = array_map(static fn ($trait) => 'init'.class_basename($trait), $uses); + + foreach (new ReflectionClass($class)->getMethods() as $method) { + if (in_array($method->getName(), $conventionalInitMethods)) { + $this->{$method->getName()}(); + } + } + } + + /** + * 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. + * + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \craft\elements\ElementCollection : TElement|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->id($id)->first($columns); + } + + /** + * Find multiple elements by their primary keys. + * + * @param \Illuminate\Contracts\Support\Arrayable|array $ids + * @return \craft\elements\ElementCollection|array + */ + public function findMany(mixed $ids, array|string $columns = ['*']): ElementCollection|array + { + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return new ElementCollection; + } + + 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) ? \craft\elements\ElementCollection : TElement) + * + * @throws ElementNotFoundException + */ + public function findOrFail(mixed $id, array|string $columns = ['*']): ElementInterface|ElementCollection + { + $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->pluck('id')->all()) + ); + } + + 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 (\Closure(): TValue)|list|string $columns + * @param (\Closure(): TValue)|null $callback + * @return ( + * $id is (\Illuminate\Contracts\Support\Arrayable|array) + * ? \craft\elements\ElementCollection + * : TElement|TValue + * ) + */ + public function findOr(mixed $id, array|string|Closure $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. + * + * @return TElement + * + * @throws ElementNotFoundException + */ + public function firstOrFail(array|string $columns = ['*']): ElementInterface + { + 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(array|string|Closure $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. + * + * @return TElement + * + * @throws ElementNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function sole(array|string $columns = ['*']): ElementInterface + { + try { + return $this->baseSole($columns); + } catch (RecordsNotFoundException) { + throw (new ElementNotFoundException)->setElement($this->elementType); + } + } + + 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. + * + * @return \craft\elements\ElementCollection|array + */ + public function get(array|string $columns = ['*']): ElementCollection|array + { + $models = $this->getModels($columns); + + return $this->applyAfterQueryCallbacks(new ElementCollection($models)) + ->when($this->asArray, fn (ElementCollection $collection) => $collection->all()); + } + + /** + * Get the hydrated elements + * + * @return 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; + } + + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return []; + } + + 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(); + } + + /** + * Execute the query as a "select" statement. + * + * @return \craft\elements\ElementCollection|array + */ + public function all(array|string $columns = ['*']): ElementCollection|array + { + return $this->get($columns); + } + + public function one(array|string $columns = ['*']): ?ElementInterface + { + return $this->first($columns); + } + + 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); + } + + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return $this->asArray ? [] : new Collection; + } + + $column = $this->columnMap[$column] ?? $column; + + 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 + { + if (! $this->getOffset() && ! $this->getLimit() && ! is_null($result = $this->getResultOverride())) { + return count($result); + } + + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return 0; + } + + $eagerLoadedCount = $this->eagerLoad(count: true); + + if ($eagerLoadedCount !== null) { + return $eagerLoadedCount; + } + + 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); + } + + 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, + '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. + */ + public function afterQuery(Closure $callback): self + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + */ + 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 + { + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return new LazyCollection; + } + + return $this->query->cursor()->map(function ($record) { + $model = $this->createElement((array) $record); + + return $this->applyAfterQueryCallbacks(new ElementCollection([$model]))->first(); + })->reject(fn ($model) => is_null($model)); + } + + /** + * Get the underlying query builder instance. + */ + public function getQuery(): Builder + { + return $this->query; + } + + /** + * Get the underlying subquery builder instance. + */ + 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. + */ + 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. + */ + 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. + */ + public function getMacro(string $name): Closure + { + return Arr::get($this->localMacros, $name); + } + + /** + * Checks if a macro is registered. + */ + public function hasMacro(string $name): bool + { + return isset($this->localMacros[$name]); + } + + /** + * Get the given global macro by name. + */ + public static function getGlobalMacro(string $name): Closure + { + return Arr::get(static::$macros, $name); + } + + /** + * Checks if a global macro is registered. + * + * @param string $name + */ + public static function hasGlobalMacro($name): bool + { + return isset(static::$macros[$name]); + } + + /** + * Dynamically access builder proxies. + * + * @param string $key + * + * @throws \Exception + */ + 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}; + } + + throw new Exception("Property [{$key}] does not exist on the Element query instance."); + } + + 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); + + 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. + * + * @param string $method + * @param array $parameters + */ + public function __call($method, $parameters): mixed + { + if ($method === 'macro') { + $this->localMacros[$parameters[0]] = $parameters[1]; + + return null; + } + + if (array_key_exists($method, $this->customFieldValues)) { + $this->customFieldValues[$method] = $parameters[0]; + + return $this; + } + + 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)) { + try { + $this->applyBeforeQueryCallbacks(); + } catch (QueryAbortedException) { + return null; + } + + 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); + } + + if (in_array(strtolower($method), ['orderby', 'orderbydesc', 'select', 'reorder'])) { + $this->forwardCallTo($this->query, $method, $parameters); + + return $this; + } + + $this->forwardCallTo($this->subQuery, $method, $parameters); + + return $this; + } + + /** + * Dynamically handle calls into the query instance. + * + * @param string $method + * @param array $parameters + * + * @throws \BadMethodCallException + */ + public static function __callStatic($method, $parameters): mixed + { + if ($method === 'macro') { + static::$macros[$parameters[0]] = $parameters[1]; + + return null; + } + + if ($method === 'mixin') { + static::registerMixin($parameters[0], $parameters[1] ?? true); + + return null; + } + + 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. + */ + protected static function registerMixin(object $mixin, bool $replace = true): void + { + $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(): static + { + return clone $this; + } + + /** + * Register a closure to be invoked on a clone. + */ + public function onClone(Closure $callback): self + { + $this->onCloneCallbacks[] = $callback; + + return $this; + } + + /** + * Force a clone of the underlying query builders when cloning. + */ + 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 before the query is executed. + */ + public function beforeQuery(Closure $callback): self + { + $this->beforeQueryCallbacks[] = $callback; + + return $this; + } + + public function applyBeforeQueryCallbacks(): void + { + foreach ($this->beforeQueryCallbacks as $i => $callback) { + $callback($this); + + unset($this->beforeQueryCallbacks[$i]); + } + + $this->beforeQueryCallbacks = []; + + $this->elementQueryBeforeQuery(); + } + + protected function elementQueryBeforeQuery(): void + { + $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->applyOrderByParams($this); + $this->applyUniqueParams($this); + + $this->query->fromSub($this->subQuery, 'subquery'); + } + + /** + * 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}}` + */ + public 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]; + } + + /** + * 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/EntryQuery.php b/src/Database/Queries/EntryQuery.php new file mode 100644 index 00000000000..863c4ca7781 --- /dev/null +++ b/src/Database/Queries/EntryQuery.php @@ -0,0 +1,352 @@ + + */ +final class EntryQuery extends ElementQuery +{ + use QueriesAuthors; + use QueriesEntryDates; + use QueriesEntryTypes; + use QueriesNestedElements { + cacheTags as nestedTraitCacheTags; + fieldLayouts as nestedTraitFieldLayouts; + } + use QueriesRef; + use QueriesSections; + + /** + * {@inheritdoc} + */ + protected array $defaultOrderBy = [ + 'entries.postDate' => SORT_DESC, + 'elements.id' => SORT_DESC, + ]; + + 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 + if (! isset($config['status'])) { + $config['status'] = [ + Entry::STATUS_LIVE, + ]; + } + + parent::__construct(Entry::class, $config); + + $this->joinElementTable(Table::ENTRIES); + + $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) { + $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'); + }); + } + + 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), + }; + } + + /** + * Sets the [[$editable]] property. + * + * @param bool|null $value The property value (defaults to true) + * + * @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); + } + + /** + * @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) { + $partialAccessSections = []; + + 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) { + $partialAccessSections[] = $section->id; + + $query->orWhere(function (Builder $query) use ($excludePeerDrafts, $user, $excludePeerEntries, $section) { + $query->where('entries.sectionId', $section->id); + + if ($excludePeerEntries) { + $query->whereExists( + 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); + } + + // They don't have access to anything + if (empty($partialAccessSections) && $value) { + throw new QueryAbortedException; + } + }, boolean: $value ? 'and' : 'and not'); + } + + /** + * {@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); + + $fieldLayouts = []; + + if ($this->typeId) { + foreach ($this->typeId as $entryTypeId) { + $entryType = EntryTypes::getEntryTypeById($entryTypeId); + if ($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(); + } + } + } + + return collect($fieldLayouts); + } + + return $this->nestedTraitFieldLayouts(); + } +} 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 @@ + The populated elements + */ + public array $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 @@ + + */ + private string $element; + + /** + * The affected element IDs. + * + * @var array + */ + private 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|int|string $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..3cce34721a7 --- /dev/null +++ b/src/Database/Queries/Exceptions/QueryAbortedException.php @@ -0,0 +1,8 @@ +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/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 @@ + 'bool', + 'trackChanges' => 'bool', + 'dateLastMerged' => 'datetime', + '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> + */ + 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 80f136bcd4b..7f379bc5df1 100644 --- a/src/Element/Models/Element.php +++ b/src/Element/Models/Element.php @@ -9,8 +9,9 @@ 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\Pivot; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; final class Element extends BaseModel @@ -20,20 +21,55 @@ final class Element extends BaseModel use SoftDeletes; /** - * @return BelongsToMany + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\CraftCms\Cms\Site\Models\Site, $this, ElementSiteSettings> */ 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'); + } + + /** + * @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/ElementSiteSettings.php b/src/Element/Models/ElementSiteSettings.php new file mode 100644 index 00000000000..5e1bfa259e3 --- /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/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/src/Entry/Commands/MergeEntryTypesCommand.php b/src/Entry/Commands/MergeEntryTypesCommand.php index 2e9538c038c..5a1a9b0939f 100644 --- a/src/Entry/Commands/MergeEntryTypesCommand.php +++ b/src/Entry/Commands/MergeEntryTypesCommand.php @@ -5,11 +5,11 @@ namespace CraftCms\Cms\Entry\Commands; use craft\base\FieldLayoutElement; -use craft\elements\Entry; use craft\models\FieldLayoutTab; use CraftCms\Aliases\Aliases; use CraftCms\Cms\Console\CraftCommand; use CraftCms\Cms\Database\Migrator; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Entry\Data\EntryType; use CraftCms\Cms\Entry\EntryTypes; use CraftCms\Cms\Field\Contracts\ElementContainerFieldInterface; diff --git a/src/Entry/Entries.php b/src/Entry/Entries.php index c8330c554f1..9dcb20d51da 100644 --- a/src/Entry/Entries.php +++ b/src/Entry/Entries.php @@ -4,12 +4,12 @@ namespace CraftCms\Cms\Entry; +use Craft; use craft\base\Element; -use craft\elements\Entry; use craft\errors\InvalidElementException; use craft\errors\UnsupportedSiteException; -use craft\helpers\Db as DbHelper; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Elements\Entry; use CraftCms\Cms\Entry\Events\EntryMovedToSection; use CraftCms\Cms\Entry\Events\MovingEntryToSection; use CraftCms\Cms\Section\Data\Section; @@ -62,7 +62,7 @@ public function getEntryById(int $entryId, array|int|string|null $siteId = null, ->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 ef33d547892..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; @@ -180,7 +180,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', @@ -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/Entry/Models/Entry.php b/src/Entry/Models/Entry.php index a5d81535fa3..dfee6d3a2a3 100644 --- a/src/Entry/Models/Entry.php +++ b/src/Entry/Models/Entry.php @@ -4,13 +4,17 @@ namespace CraftCms\Cms\Entry\Models; +use CraftCms\Cms\Database\Queries\EntryQuery; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Models\Element; 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 { @@ -74,4 +78,18 @@ 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; + } } 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 77ef759aa2b..006f5a947fd 100644 --- a/src/Field/BaseOptionsField.php +++ b/src/Field/BaseOptionsField.php @@ -4,14 +4,12 @@ namespace CraftCms\Cms\Field; -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; @@ -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..49771c9a79e 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,9 @@ 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; use CraftCms\Cms\Field\Contracts\CrossSiteCopyableFieldInterface; @@ -46,11 +44,13 @@ 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 Override; +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; @@ -110,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, @@ -120,7 +120,7 @@ public static function phpType(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function dbType(): array|string|null { return Schema::TYPE_JSON; @@ -129,8 +129,8 @@ public static function dbType(): array|string|null /** * {@inheritdoc} */ - #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): array|false + #[Override] + 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; } /** @@ -421,7 +408,7 @@ public function __construct(array $config = []) parent::__construct($config); } - #[\Override] + #[Override] public static function getRules(): array { return array_merge(parent::getRules(), [ @@ -548,7 +535,7 @@ public function getSettingsHtml(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getElementValidationRules(): array { $rules = [ @@ -672,11 +659,11 @@ private static function _validateRelatedElement(ElementInterface $source, Elemen /** * {@inheritdoc} */ - #[\Override] + #[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(); } @@ -686,14 +673,14 @@ 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, // 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 && @@ -706,20 +693,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 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+, @@ -739,46 +726,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($q->orderBy ?? []) === 1 && + ($q->orderBy[0]['column'] ?? null) instanceof OrderByPlaceholderExpression + ) { + $q->orderBy("$relationsAlias.sortOrder"); } - }, - ])); + } + }); } else { $query->id(false); } @@ -795,7 +778,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element): mixed return $query; } - private function fetchRelationsFromDbTable(?Elementinterface $element): bool + protected function fetchRelationsFromDbTable(?Elementinterface $element): bool { if ($this->layoutElement?->uid === null) { return false; @@ -833,7 +816,7 @@ private function fetchRelationsFromDbTable(?Elementinterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function serializeValue(mixed $value, ?ElementInterface $element): mixed { if ($this->maintainHierarchy) { @@ -862,7 +845,7 @@ public function getElementConditionRuleType(): array|string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function modifyElementIndexQuery(ElementQueryInterface $query): void { $criteria = [ @@ -885,7 +868,7 @@ public function modifyElementIndexQuery(ElementQueryInterface $query): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getIsTranslatable(?ElementInterface $element): bool { return $this->localizeRelations; @@ -894,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); @@ -903,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); @@ -972,7 +955,7 @@ private function normalizeValueForInput( /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function searchKeywords(mixed $value, ElementInterface $element): string { /** @var ElementQuery|ElementCollection $value */ @@ -994,12 +977,12 @@ protected function searchKeywords(mixed $value, ElementInterface $element): stri /** * {@inheritdoc} */ - #[\Override] + #[Override] 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; @@ -1015,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()); @@ -1083,7 +1066,7 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false ]) ->where(fn (Builder $query) => $query ->where('sourceSiteId', $sourceSiteId) - ->orWhereNull('sourceSiteId') + ->orWhereNull('sourceSiteId'), ) ->orderBy('sortOrder') ->get() @@ -1119,7 +1102,7 @@ public function getEagerLoadingMap(array $sourceElements): array|null|false /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getContentGqlMutationArgumentType(): array { return [ @@ -1159,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 @@ -1208,15 +1191,16 @@ public function getRelationTargetIds(ElementInterface $element): array ) { $targetIds = $value->id ?: []; } elseif ( - isset($value->where['elements.id']) && - Arr::isNumeric($value->where['elements.id']) + $value instanceof \CraftCms\Cms\Database\Queries\ElementQuery && + ($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 $targetIds = $this->_all($value, $element) - ->collect() + ->get() ->map(fn (ElementInterface $element) => $element->id) ->all(); } @@ -1252,7 +1236,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 @@ -1433,7 +1417,7 @@ public function getViewModeFieldHtml(): ?string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function useFieldset(): bool { return true; @@ -1755,7 +1739,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|ElementQueryInterface $query, ?ElementInterface $element = null): \CraftCms\Cms\Database\Queries\ElementQuery { $clone = (clone $query) ->drafts(null) @@ -1764,6 +1748,7 @@ private function _all(ElementQueryInterface $query, ?ElementInterface $element = ->limit(null) ->unique() ->eagerly(false); + if ($element !== null) { $clone->preferSites([$this->targetSiteId($element)]); } diff --git a/src/Field/Contracts/FieldInterface.php b/src/Field/Contracts/FieldInterface.php index 619a6d38eb9..8775d685ae2 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 54ccc8fcae1..e958ca0ad18 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,11 +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; @@ -426,17 +432,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 +447,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 +461,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 +476,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 +1016,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 +1028,7 @@ public function getValueSql(?string $key = null): ?string return $this->_valueSql[$cacheKey] ?: null; } - private function _valueSql(?string $key): ?string + private function _valueSql(?string $key): ?Expression { $dbType = $this->dbTypeForValueSql(); @@ -1039,22 +1040,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 +1081,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 +1101,7 @@ private function _valueSql(?string $key): ?string } } - $sql = "CAST($sql AS $castType)"; + $sql = new Cast($sql, $castType); } return $sql; diff --git a/src/Field/Fields.php b/src/Field/Fields.php index e19392b81b9..1e27a23bac2 100644 --- a/src/Field/Fields.php +++ b/src/Field/Fields.php @@ -750,7 +750,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/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 ab3c0571c46..22ade6ffc4e 100644 --- a/src/Field/Matrix.php +++ b/src/Field/Matrix.php @@ -10,19 +10,12 @@ use craft\base\GqlInlineFragmentFieldInterface; use craft\base\GqlInlineFragmentInterface; 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; 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; @@ -39,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; @@ -59,15 +54,16 @@ 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; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; +use Override; use Tpetry\QueryExpressions\Language\Alias; use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; -use yii\db\Expression; use function CraftCms\Cms\t; @@ -94,7 +90,7 @@ final class Matrix extends Field implements EagerLoadingFieldInterface, ElementC /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function displayName(): string { return t('Matrix'); @@ -103,7 +99,7 @@ public static function displayName(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function icon(): string { return 'binary'; @@ -112,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. @@ -124,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); @@ -133,7 +129,7 @@ public static function phpType(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public static function dbType(): array|string|null { return null; @@ -142,30 +138,27 @@ public static function dbType(): array|string|null /** * {@inheritdoc} */ - #[\Override] - public static function queryCondition(array $instances, mixed $value, array &$params): array + #[Override] + 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 +169,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); } /** @@ -359,7 +352,7 @@ public function getSettings(): array return $settings; } - #[\Override] + #[Override] public static function getRules(): array { return array_merge(parent::getRules(), [ @@ -697,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); @@ -706,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); @@ -723,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(); } @@ -743,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); @@ -780,7 +768,7 @@ private function createEntryQuery(?ElementInterface $owner): EntryQuery /** * {@inheritdoc} */ - #[\Override] + #[Override] public function serializeValue(mixed $value, ?ElementInterface $element): array { /** @var EntryQuery|ElementCollection $value */ @@ -806,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 */ @@ -832,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() @@ -849,7 +837,7 @@ public function getElementConditionRuleType(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getIsTranslatable(?ElementInterface $element): bool { return $this->entryManager()->getIsTranslatable($element); @@ -858,7 +846,7 @@ public function getIsTranslatable(?ElementInterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] protected function actionMenuItems(): array { $items = match ($this->viewMode) { @@ -992,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); }); @@ -1010,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); @@ -1075,7 +1063,7 @@ private function cardViewActionMenuItems(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getTranslationDescription(?ElementInterface $element): ?string { return $this->entryManager()->getTranslationDescription($element); @@ -1086,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); @@ -1095,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); @@ -1127,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) @@ -1136,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(); @@ -1323,7 +1311,7 @@ private function defaultCreateButtonLabel(): string /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getElementValidationRules(): array { return [ @@ -1338,7 +1326,7 @@ public function getElementValidationRules(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function isValueEmpty(mixed $value, ElementInterface $element): bool { /** @var EntryQuery|ElementCollection $value */ @@ -1353,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) @@ -1386,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) { @@ -1429,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); @@ -1483,7 +1471,7 @@ public function getEagerLoadingMap(array $sourceElements): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function canMergeFrom(FieldInterface $outgoingField, ?string &$reason): bool { if (! $outgoingField instanceof self) { @@ -1508,7 +1496,7 @@ public function canMergeFrom(FieldInterface $outgoingField, ?string &$reason): b /** * {@inheritdoc} */ - #[\Override] + #[Override] public function afterMergeFrom(FieldInterface $outgoingField): void { DB::table(Table::ENTRIES) @@ -1524,7 +1512,7 @@ public function afterMergeFrom(FieldInterface $outgoingField): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getContentGqlType(): array { $typeArray = EntryTypeGenerator::generateTypes($this); @@ -1549,7 +1537,7 @@ public function getContentGqlType(): array /** * {@inheritdoc} */ - #[\Override] + #[Override] public function getContentGqlMutationArgumentType(): Type|array { return MatrixInputType::getType($this); @@ -1579,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 @@ -1634,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); @@ -1671,7 +1659,7 @@ public function afterSaveEntries(BulkElementsEvent $event): void /** * {@inheritdoc} */ - #[\Override] + #[Override] public function beforeElementDelete(ElementInterface $element): bool { if (! parent::beforeElementDelete($element)) { @@ -1687,7 +1675,7 @@ public function beforeElementDelete(ElementInterface $element): bool /** * {@inheritdoc} */ - #[\Override] + #[Override] public function beforeElementDeleteForSite(ElementInterface $element): bool { $elementsService = Craft::$app->getElements(); @@ -1709,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 @@ -1757,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/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/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..f18fa5e95c5 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; @@ -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), @@ -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); } /** @@ -836,20 +835,23 @@ 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) - ->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 + ->clone() ->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 +861,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 +895,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 +904,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 +914,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 +1048,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/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 @@ +belongsTo(SiteGroup::class, 'groupId'); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\CraftCms\Cms\Element\Models\Element, $this, ElementSiteSettings> + */ + 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/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/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/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/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/src/Support/Query.php b/src/Support/Query.php new file mode 100644 index 00000000000..0ab9b077dcd --- /dev/null +++ b/src/Support/Query.php @@ -0,0 +1,667 @@ +=', '<', '>', '=']; + + /** + * 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 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 $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 + * @param self::TYPE_* $columnType The database column type the param is targeting + */ + public static function whereParam( + Builder $query, + string|Expression $column, + mixed $param, + string $defaultOperator = '=', + bool $caseInsensitive = false, + ?string $columnType = null, + string $boolean = 'and', + ): Builder { + $parsed = QueryParam::parse($param); + + if (empty($parsed->values)) { + return $query; + } + + $parsedColumnType = $columnType + ? self::parseColumnType($columnType) + : null; + + /** @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) { + $caseInsensitive = false; + } + + $caseColumn = $caseInsensitive + ? new Lower($column) + : $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)) + ) { + $method = $operator === '!=' ? 'whereNot' : 'where'; + + $query->$method(function (Builder $query) use ($column) { + $query->whereNull($column)->orWhere($column, ''); + }, boolean: $boolean); + + continue; + } + + $method = $operator === '!=' ? 'whereNotNull' : 'whereNull'; + + $query->$method($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); + } + }, boolean: $boolean); + + return $query; + } + + /** + * 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 Builder $query The query builder to apply the param to. + * @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 `=`) + * @param string|null $columnType The database column type the param is targeting + */ + public static function whereNumericParam( + Builder $query, + 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, $boolean); + } + + /** + * 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|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|Expression $column, + mixed $value, + string $defaultOperator = '=', + string $boolean = 'and', + ): 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 = Date::parse($val); + + $normalizedValues[] = $operator.$val; + } + + return self::whereParam($query, $column, $normalizedValues, $defaultOperator, false, self::TYPE_DATETIME, $boolean); + } + + /** + * 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|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 + * (can be `not`, `!=`, `<=`, `>=`, `<`, `>`, or `=`) + */ + public static function whereMoneyParam( + Builder $query, + string|Expression $column, + string $currency, + mixed $value, + string $defaultOperator = '=', + string $boolean = 'and', + ): 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, $boolean); + } + + /** + * 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|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|Expression $column, + mixed $value, + ?bool $defaultValue = null, + string $columnType = self::TYPE_BOOLEAN, + string $boolean = 'and', + ): 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), + ); + }, boolean: $boolean); + } + + /** + * 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 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. + * + * @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 === '=' => '!=', + default => throw new InvalidArgumentException("Invalid operator: $op"), + }; + } + + /** + * Returns whether the given column type is numeric. + */ + public static function isNumericColumnType(string $columnType): bool + { + return in_array(self::parseColumnType($columnType), self::NUMERIC_COLUMN_TYPES, true); + } + + /** + * Returns whether the given column type is textual. + */ + public static function isTextualColumnType(string $columnType): bool + { + 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('/(? 'boolean', ]; - private ?Collection $userGroups = null; + private ?Collection $userGroupData = null; public function isAdmin(): bool { - return $this->admin; + return (bool) $this->admin; } /** @@ -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/CachesQueriesTest.php b/tests/Database/Queries/Concerns/CachesQueriesTest.php new file mode 100644 index 00000000000..ca2c0b3b171 --- /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(\CraftCms\Cms\Element\Elements\Entry::class); + + expect(entryQuery()->cache()->count())->toBe(2); + expect(entryQuery()->cache()->all()->count())->toBe(2); + expect(entryQuery()->cache()->pluck('id')->count())->toBe(2); +}); diff --git a/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php new file mode 100644 index 00000000000..4382c607f39 --- /dev/null +++ b/tests/Database/Queries/Concerns/CollectsCacheTagsTest.php @@ -0,0 +1,60 @@ +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('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(); + + 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..968de7192da --- /dev/null +++ b/tests/Database/Queries/Concerns/FormatsResultsTest.php @@ -0,0 +1,95 @@ +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]); +}); + +test('it applies a default order when no orderBy is specified', function () { + $query = entryQuery(); + $query->applyBeforeQueryCallbacks(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'entries.postDate') + ->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', 'entries.postDate') + ->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->withStructure(); + $query->applyBeforeQueryCallbacks(); + + expect( + collect($query->getQuery()->orders) + ->where('column', 'structureelements.lft') + ->where('direction', 'asc') + ->first() + )->not()->toBeNull(); +}); 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); +}); diff --git a/tests/Database/Queries/Concerns/QueriesAuthorsTest.php b/tests/Database/Queries/Concerns/QueriesAuthorsTest.php new file mode 100644 index 00000000000..787545fae6d --- /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($author1->id)->count())->toBe(2); + + Edition::set(Edition::Pro); + + 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 () { + $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); +}); diff --git a/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php b/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php new file mode 100644 index 00000000000..8b7fee8862f --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesCustomFieldsTest.php @@ -0,0 +1,69 @@ +create([ + 'handle' => 'textField', + 'type' => PlainText::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, + ], + ], + ], + ], + ], + ]); + + $entryModel = EntryModel::factory()->create(); + $entryModel->element->update([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + + $entryModel->entryType->update([ + 'fieldLayoutId' => $fieldLayout->id, + ]); + + CustomFieldBehavior::$fieldHandles[$field->handle] = true; + + Fields::refreshFields(); + + /** @var \CraftCms\Cms\Element\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 new file mode 100644 index 00000000000..5f72c35e331 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesDraftsAndRevisionsTest.php @@ -0,0 +1,104 @@ +create(); + + $entry = EntryModel::factory()->create(); + $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(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); +}); + +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); +}); + +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); +}); diff --git a/tests/Database/Queries/Concerns/QueriesEagerlyTest.php b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php new file mode 100644 index 00000000000..8fa467e9e19 --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesEagerlyTest.php @@ -0,0 +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/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); +}); 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); +}); diff --git a/tests/Database/Queries/Concerns/QueriesFieldsTest.php b/tests/Database/Queries/Concerns/QueriesFieldsTest.php new file mode 100644 index 00000000000..c20da1fb64f --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesFieldsTest.php @@ -0,0 +1,108 @@ +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('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([ + $column => today()->subDay(), + ]); + + // Today + EntryModel::factory()->create()->element->update([ + $column => today(), + ]); + + // 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], +]); + +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); +}); diff --git a/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php b/tests/Database/Queries/Concerns/QueriesNestedElementsTest.php new file mode 100644 index 00000000000..f36ef8cc77c --- /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::CraftCms\Cms\Element\Elements\Entry::field:'.$field->id); + expect($dependency->tags)->toContain('element::'.$entry->id); +}); 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'); +}); 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/Concerns/QueriesRelatedElementsTest.php b/tests/Database/Queries/Concerns/QueriesRelatedElementsTest.php new file mode 100644 index 00000000000..045a89d6c6f --- /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); +}); 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); +}); 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); +}); diff --git a/tests/Database/Queries/Concerns/QueriesStatusesTest.php b/tests/Database/Queries/Concerns/QueriesStatusesTest.php new file mode 100644 index 00000000000..255ea038fed --- /dev/null +++ b/tests/Database/Queries/Concerns/QueriesStatusesTest.php @@ -0,0 +1,43 @@ +enabled()->create(); + + Entry::factory()->disabled()->create(); + + $element3 = Entry::factory()->enabled()->create(); + // Disabled in site + $element3->element->siteSettings->first()->update(['enabled' => false]); + + expect(entryQuery()->count())->toBe(1); + expect(entryQuery()->firstOrFail()->id)->toBe($element1->id); +}); + +it('can query archived and statuses', function () { + $element1 = Entry::factory()->create(); + $element2 = Entry::factory()->archived()->create(); + + expect(entryQuery()->count())->toBe(1); + expect(entryQuery()->first()->id)->toBe($element1->id); + + expect(entryQuery()->archived()->count())->toBe(1); + expect(entryQuery()->archived()->first()->id)->toBe($element2->id); + + expect(entryQuery()->status([ + Element::STATUS_ENABLED, + Element::STATUS_ARCHIVED, + ])->count())->toBe(2); + + expect(entryQuery()->status([ + Element::STATUS_ARCHIVED, + ])->count())->toBe(1); + + // Does not fail but doesn't apply parameters + expect(entryQuery()->status(['not'])->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/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); +}); 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); +}); diff --git a/tests/Database/Queries/Concerns/SearchesElementsTest.php b/tests/Database/Queries/Concerns/SearchesElementsTest.php new file mode 100644 index 00000000000..e86a593c2ba --- /dev/null +++ b/tests/Database/Queries/Concerns/SearchesElementsTest.php @@ -0,0 +1,62 @@ +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); +}); + +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', + 'slug' => '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(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); +}); diff --git a/tests/Database/Queries/ElementQueryTest.php b/tests/Database/Queries/ElementQueryTest.php new file mode 100644 index 00000000000..5fc439d0ae4 --- /dev/null +++ b/tests/Database/Queries/ElementQueryTest.php @@ -0,0 +1,44 @@ +all())->toBeEmpty(); + + $elements = EntryModel::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); + + $this->expectException(MultipleRecordsFoundException::class); + entryQuery()->sole(); + + $this->expectException(ModelNotFoundException::class); + 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(); + + expect(entryQuery()->count())->toBe(2); + expect(entryQuery()->trashed(true)->count())->toBe(2); + expect(entryQuery()->trashed(null)->count())->toBe(4); +}); diff --git a/tests/Database/Queries/EntryQueryTest.php b/tests/Database/Queries/EntryQueryTest.php new file mode 100644 index 00000000000..99941f7e245 --- /dev/null +++ b/tests/Database/Queries/EntryQueryTest.php @@ -0,0 +1,87 @@ +create(); + + Sections::refreshSections(); + + expect(entryQuery()->$method()->count())->toBe(1); + + actingAs(User::factory()->create()); + + // Access to nothing + expect(entryQuery()->$method()->count())->toBe(0); +})->with([ + 'editable', + 'savable', +]); + +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); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 0f2986144ed..6ff37fe0aed 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(array $config = []): EntryQuery +{ + return new EntryQuery($config); +} diff --git a/tests/Support/QueryTest.php b/tests/Support/QueryTest.php new file mode 100644 index 00000000000..ffb1ed6105d --- /dev/null +++ b/tests/Support/QueryTest.php @@ -0,0 +1,153 @@ +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') + // Trim because on pgsql these are fixed length + ->map(fn ($token) => trim((string) $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], +]); diff --git a/tests/TestCase.php b/tests/TestCase.php index e61adb55d3b..2e09bc893e5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -112,6 +112,7 @@ protected function getEnvironmentSetUp($app) $app->make(\Illuminate\Contracts\Config\Repository::class)->set('inertia.testing.page_paths', [__DIR__.'/../resources/js/pages']); 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; diff --git a/yii2-adapter/legacy/base/Element.php b/yii2-adapter/legacy/base/Element.php index 99a0f28d251..fa155668a78 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); } @@ -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 3f8d4d8e92b..3f3348e4dbe 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; @@ -165,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()); @@ -179,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); * } @@ -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. @@ -1129,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. @@ -1190,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. @@ -1198,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/base/Field.php b/yii2-adapter/legacy/base/Field.php index fbfd36fb3ef..8fb8611e145 100644 --- a/yii2-adapter/legacy/base/Field.php +++ b/yii2-adapter/legacy/base/Field.php @@ -7,13 +7,34 @@ namespace craft\base; -use CraftCms\Cms\Field\Contracts\FieldInterface; +use Craft; +use Illuminate\Database\Query\Builder; -/** @phpstan-ignore-next-line */ -if (false) { - abstract class Field extends SavableComponent implements FieldInterface, Iconic, Actionable +/** + * @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 { + if (!method_exists(static::class, 'queryCondition')) { + return $query; + } + + $params = []; + + $condition = static::queryCondition($instances, $value, $params); + + if ($condition === null || $condition === false) { + return $query; + } + + $db = Craft::$app->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/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 a05a742ccdf..25688bb9c9c 100644 --- a/yii2-adapter/legacy/db/QueryParam.php +++ b/yii2-adapter/legacy/db/QueryParam.php @@ -7,121 +7,18 @@ 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); - } - +/** @phpstan-ignore-next-line */ +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/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 7c02e555754..4f721b15ebe 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; @@ -75,7 +76,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. */ @@ -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); @@ -199,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() @@ -211,7 +216,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; @@ -771,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 { diff --git a/yii2-adapter/legacy/elements/db/ElementQuery.php b/yii2-adapter/legacy/elements/db/ElementQuery.php index a869d6fe63b..a61c626c592 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,15 +29,17 @@ 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\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 CraftCms\Cms\Updates\Updates; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Schema as SchemaFacade; use ReflectionClass; @@ -1609,7 +1610,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(Updates::class)->isCraftUpdatePending()) { + if (Info::isInstalled() && !Updates::isCraftUpdatePending()) { /** @noinspection PhpUnhandledExceptionInspection */ throw $e; } @@ -2835,26 +2836,34 @@ 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) { - $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/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/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/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/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/helpers/Db.php b/yii2-adapter/legacy/helpers/Db.php index 555b79c4a56..5dacbf86c2b 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; @@ -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); } /** diff --git a/yii2-adapter/legacy/services/Elements.php b/yii2-adapter/legacy/services/Elements.php index bc724f2908f..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."); @@ -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, @@ -3344,14 +3344,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(); 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/legacy/services/Search.php b/yii2-adapter/legacy/services/Search.php index 05d74786a9b..259731834b8 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\Database\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\Database\Queries\ElementQuery $elementQuery): array { $searchQuery = $this->normalizeSearchQuery($elementQuery->search); @@ -428,8 +428,18 @@ public function searchElements(ElementQuery $elementQuery): array 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(); @@ -468,7 +478,7 @@ public function searchElements(ElementQuery $elementQuery): array * @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); @@ -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)) { 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); } } 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 } }