From 9ffab37e0d4f4d3483b1eda51c6fc014091b9cbc Mon Sep 17 00:00:00 2001 From: Moritz Friedrich Date: Tue, 30 Nov 2021 18:37:12 +0100 Subject: [PATCH] Cleaned up, added proper deprecations --- src/Classes/Bulk.php | 220 ++-- src/Compatibility/HasAttributes.php | 4 +- src/Concerns/AppliesScopes.php | 178 +-- src/Concerns/BuildsFluentQueries.php | 1523 +++++++++++----------- src/Concerns/ExecutesQueries.php | 573 ++++---- src/Concerns/HasGlobalScopes.php | 52 +- src/Connection.php | 8 + src/Index.php | 216 ++-- src/Model.php | 1797 +++++++++++++------------- src/Query.php | 117 +- 10 files changed, 2373 insertions(+), 2315 deletions(-) diff --git a/src/Classes/Bulk.php b/src/Classes/Bulk.php index ad45e39..37e68c3 100755 --- a/src/Classes/Bulk.php +++ b/src/Classes/Bulk.php @@ -1,7 +1,10 @@ autocommitAfter = (int)$autocommitAfter; } - /** - * Set the index name - * - * @param string|null $index - * - * @return $this - */ - public function index(?string $index = null): self - { - $this->index = $index; - - return $this; - } - - /** - * Set the type name - * - * @param string|null $type - * - * @return $this - */ - public function type(?string $type = null): self - { - $this->type = $type; - - return $this; - } - /** * Filter by _id * @@ -113,52 +88,6 @@ public function _id(?string $_id = null): self return $this; } - /** - * Just an alias for _id() method - * - * @param string|null $_id - * - * @return $this - */ - public function id(?string $_id = null): self - { - return $this->_id($_id); - } - - /** - * Add pending document for insert - * - * @param array $data - * - * @return bool - */ - public function insert(array $data = []): bool - { - return $this->action('index', $data); - } - - /** - * Add pending document for update - * - * @param array $data - * - * @return bool - */ - public function update(array $data = []): bool - { - return $this->action('update', $data); - } - - /** - * Add pending document for deletion - * - * @return bool - */ - public function delete(): bool - { - return $this->action('delete'); - } - /** * Add pending document abstract action * @@ -169,7 +98,7 @@ public function delete(): bool */ public function action(string $actionType, array $data = []): bool { - $this->body["body"][] = [ + $this->body['body'][] = [ $actionType => [ '_index' => $this->getIndex(), '_type' => $this->getType(), @@ -178,8 +107,8 @@ public function action(string $actionType, array $data = []): bool ]; if ( ! empty($data)) { - $this->body["body"][] = $actionType === "update" - ? ["doc" => $data] + $this->body['body'][] = $actionType === 'update' + ? ['doc' => $data] : $data; } @@ -207,17 +136,6 @@ public function body(): array return $this->body; } - /** - * Reset names - * - * @return void - */ - public function reset(): void - { - $this->index(); - $this->type(); - } - /** * Commit all pending operations * @@ -241,6 +159,91 @@ public function commit(): ?array return $result; } + /** + * Add pending document for deletion + * + * @return bool + */ + public function delete(): bool + { + return $this->action('delete'); + } + + /** + * Just an alias for _id() method + * + * @param string|null $_id + * + * @return $this + */ + public function id(?string $_id = null): self + { + return $this->_id($_id); + } + + /** + * Set the index name + * + * @param string|null $index + * + * @return $this + */ + public function index(?string $index = null): self + { + $this->index = $index; + + return $this; + } + + /** + * Add pending document for insert + * + * @param array $data + * + * @return bool + */ + public function insert(array $data = []): bool + { + return $this->action('index', $data); + } + + /** + * Reset names + * + * @return void + */ + public function reset(): void + { + $this->index(); + $this->type(); + } + + /** + * Set the type name + * + * @param string|null $type + * + * @return $this + */ + public function type(?string $type = null): self + { + $this->type = $type; + + return $this; + } + + /** + * Add pending document for update + * + * @param array $data + * + * @return bool + */ + public function update(array $data = []): bool + { + return $this->action('update', $data); + } + /** * Get the index name * @@ -255,9 +258,10 @@ protected function getIndex(): ?string * Get the type name * * @return string|null - * @deprecated Mapping types are deprecated as of Elasticsearch 6.0.0 + * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html */ + #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')] protected function getType(): ?string { return $this->type ?: $this->query->getType(); diff --git a/src/Compatibility/HasAttributes.php b/src/Compatibility/HasAttributes.php index 1fd7dca..dc90699 100644 --- a/src/Compatibility/HasAttributes.php +++ b/src/Compatibility/HasAttributes.php @@ -31,6 +31,7 @@ use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; use InvalidArgumentException; +use JetBrains\PhpStorm\Deprecated; use JsonException; use LogicException; use Matchory\Elasticsearch\Compatibility\Exceptions\InvalidCastException; @@ -176,12 +177,13 @@ trait HasAttributes protected $classCastCache = []; /** - * The attributes that should be mutated to dates. + * The attributes that should be mutated to date objects. * * @deprecated Use the "casts" property * * @var array */ + #[Deprecated(replacement: '%class%->casts')] protected $dates = []; /** diff --git a/src/Concerns/AppliesScopes.php b/src/Concerns/AppliesScopes.php index e7b37a1..fe2dd1f 100644 --- a/src/Concerns/AppliesScopes.php +++ b/src/Concerns/AppliesScopes.php @@ -20,13 +20,6 @@ trait AppliesScopes { - /** - * Holds all scopes applied to the query. - * - * @var array - */ - protected $scopes; - /** * Holds all scopes removed from the query. * @@ -35,62 +28,60 @@ trait AppliesScopes protected $removedScopes; /** - * Register a new global scope. - * - * @param string $identifier - * @param ScopeInterface|Closure $scope + * Holds all scopes applied to the query. * - * @return $this + * @var array */ - public function withGlobalScope(string $identifier, $scope): self - { - $this->scopes[$identifier] = $scope; - - if (method_exists($scope, 'extend')) { - $scope->extend($this); - } - - return $this; - } + protected $scopes; /** - * Remove a registered global scope. - * - * @param ScopeInterface|string $scope + * Apply the scopes to the Elasticsearch query instance and return it. * * @return $this */ - public function withoutGlobalScope($scope): self + public function applyScopes(): self { - if ( ! is_string($scope)) { - $scope = get_class($scope); + if ( ! $this->scopes) { + return $this; } - unset($this->scopes[$scope]); + $query = clone $this; - $this->removedScopes[] = $scope; + foreach ($this->scopes as $identifier => $scope) { + if ( ! isset($query->scopes[$identifier])) { + continue; + } - return $this; + $query->callScope(function (Query $query) use ($scope) { + // If the scope is a Closure we will just go ahead and call the + // scope with the builder instance. + if ($scope instanceof Closure) { + $scope($query); + } + + // If the scope is a scope object, we will call the apply method + // on this scope passing in the query and the model instance. + // After we run all of these scopes we will return the query + // instance to the outside caller. + if ($scope instanceof ScopeInterface) { + $scope->apply($query, $this->getModel()); + } + }); + } + + return $query; } /** - * Remove all or passed registered global scopes. + * Determine if the given model has a scope. * - * @param ScopeInterface[]|null $scopes + * @param string $scope * - * @return $this + * @return bool */ - public function withoutGlobalScopes(array $scopes = null): self + public function hasNamedScope(string $scope): bool { - if ( ! is_array($scopes)) { - $scopes = array_keys($this->scopes); - } - - foreach ($scopes as $scope) { - $this->withoutGlobalScope($scope); - } - - return $this; + return $this->getModel()->hasNamedScope($scope); } /** @@ -103,18 +94,6 @@ public function removedScopes(): array return $this->removedScopes; } - /** - * Determine if the given model has a scope. - * - * @param string $scope - * - * @return bool - */ - public function hasNamedScope(string $scope): bool - { - return $this->getModel()->hasNamedScope($scope); - } - /** * Call the given local model scopes. * @@ -136,9 +115,9 @@ public function scopes($scopes): self } // Next we'll pass the scope callback to the callScope method which - // will take care of grouping the "wheres" properly so the logical + // will take care of grouping the conditions properly so the logical // order doesn't get messed up when adding scopes. - // Then we'll return back out the query. + // Then we'll return out the query. $query = $query->callNamedScope( $scope, (array)$parameters @@ -149,56 +128,60 @@ public function scopes($scopes): self } /** - * Apply the scopes to the Elasticsearch query instance and return it. + * Register a new global scope. + * + * @param string $identifier + * @param ScopeInterface|Closure $scope * * @return $this */ - public function applyScopes(): self + public function withGlobalScope(string $identifier, $scope): self { - if ( ! $this->scopes) { - return $this; + $this->scopes[$identifier] = $scope; + + if (method_exists($scope, 'extend')) { + $scope->extend($this); } - $query = clone $this; + return $this; + } - foreach ($this->scopes as $identifier => $scope) { - if ( ! isset($query->scopes[$identifier])) { - continue; - } + /** + * Remove a registered global scope. + * + * @param ScopeInterface|string $scope + * + * @return $this + */ + public function withoutGlobalScope($scope): self + { + if ( ! is_string($scope)) { + $scope = get_class($scope); + } - $query->callScope(function (Query $query) use ($scope) { - // If the scope is a Closure we will just go ahead and call the - // scope with the builder instance. - if ($scope instanceof Closure) { - $scope($query); - } + unset($this->scopes[$scope]); - // If the scope is a scope object, we will call the apply method - // on this scope passing in the query and the model instance. - // After we run all of these scopes we will return back the - // query instance to the outside caller. - if ($scope instanceof ScopeInterface) { - $scope->apply($query, $this->getModel()); - } - }); - } + $this->removedScopes[] = $scope; - return $query; + return $this; } /** - * Apply the given scope on the current builder instance. + * Remove all or passed registered global scopes. * - * @param callable $scope - * @param array $parameters + * @param ScopeInterface[]|null $scopes * * @return $this */ - protected function callScope(callable $scope, array $parameters = []): self + public function withoutGlobalScopes(array $scopes = null): self { - array_unshift($parameters, $this); + if ( ! is_array($scopes)) { + $scopes = array_keys($this->scopes); + } - $scope(...array_values($parameters)) ?? $this; + foreach ($scopes as $scope) { + $this->withoutGlobalScope($scope); + } return $this; } @@ -222,4 +205,21 @@ protected function callNamedScope( ); }, $parameters); } + + /** + * Apply the given scope on the current builder instance. + * + * @param callable $scope + * @param array $parameters + * + * @return $this + */ + protected function callScope(callable $scope, array $parameters = []): self + { + array_unshift($parameters, $this); + + $scope(...array_values($parameters)) ?? $this; + + return $this; + } } diff --git a/src/Concerns/BuildsFluentQueries.php b/src/Concerns/BuildsFluentQueries.php index c711128..3adf6a3 100644 --- a/src/Concerns/BuildsFluentQueries.php +++ b/src/Concerns/BuildsFluentQueries.php @@ -5,6 +5,7 @@ namespace Matchory\Elasticsearch\Concerns; use InvalidArgumentException; +use JetBrains\PhpStorm\Deprecated; use Matchory\Elasticsearch\Classes\Search; use Matchory\Elasticsearch\Model; use Matchory\Elasticsearch\Query; @@ -29,18 +30,18 @@ trait BuildsFluentQueries { /** - * Ignored HTTP errors + * Query body * * @var array */ - public $ignores = []; + public $body = []; /** - * Query body + * Ignored HTTP errors * * @var array */ - public $body = []; + public $ignores = []; /** * Query bool must @@ -57,45 +58,32 @@ trait BuildsFluentQueries public $must_not = []; /** - * Index name - * ========== - * Name of the index to query. To search all data streams and indices in a - * cluster, omit this parameter or use _all or *. - * An index can be thought of as an optimized collection of documents and - * each document is a collection of fields, which are the key-value pairs - * that contain your data. By default, Elasticsearch indexes all data in - * every field and each indexed field has a dedicated, optimized data - * structure. For example, text fields are stored in inverted indices, and - * numeric and geo fields are stored in BKD trees. The ability to use the - * per-field data structures to assemble and return search results is what - * makes Elasticsearch so fast. + * Result aggregations * - * @var string|null - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/search-search.html - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/documents-indices.html + * @var array */ - protected $index; + protected $aggregations = []; /** - * Mapping type - * ============ - * Each document indexed is associated with a `_type` and an `_id`. - * The `_type` field is indexed in order to make searching by type name fast - * The value of the `_type` field is accessible in queries, aggregations, - * scripts, and when sorting. - * Note that mapping types are deprecated as of 6.0.0: - * Indices created in Elasticsearch 7.0.0 or later no longer accept a - * `_default_` mapping. Indices created in 6.x will continue to function as - * before in Elasticsearch 6.x. Types are deprecated in APIs in 7.0, with - * breaking changes to the index creation, put mapping, get mapping, put - * template, get template and get field mappings APIs. + * Query bool filter * - * @var string|null - * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/mapping-type-field.html + * @var array */ - protected $type; + protected $filter = []; + + /** + * Starting document offset + * ======================== + * Starting document offset. Defaults to `0`. + * + * By default, you cannot page through more than 10,000 hits using the + * `from` and `size` parameters. To page through more hits, use the + * `search_after` parameter. + * + * @var int + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/search-search.html#search-type + */ + protected $from = Query::DEFAULT_OFFSET; /** * Unique document ID @@ -120,6 +108,42 @@ trait BuildsFluentQueries */ protected $id; + /** + * Index name + * ========== + * Name of the index to query. To search all data streams and indices in a + * cluster, omit this parameter or use _all or *. + * An index can be thought of as an optimized collection of documents and + * each document is a collection of fields, which are the key-value pairs + * that contain your data. By default, Elasticsearch indexes all data in + * every field and each indexed field has a dedicated, optimized data + * structure. For example, text fields are stored in inverted indices, and + * numeric and geo fields are stored in BKD trees. The ability to use the + * per-field data structures to assemble and return search results is what + * makes Elasticsearch so fast. + * + * @var string|null + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/search-search.html + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/documents-indices.html + */ + protected $index; + + /** + * Filter operators + * + * @var array + */ + protected $operators = [ + Query::OPERATOR_EQUAL, + Query::OPERATOR_NOT_EQUAL, + Query::OPERATOR_GREATER_THAN, + Query::OPERATOR_GREATER_THAN_OR_EQUAL, + Query::OPERATOR_LOWER_THAN, + Query::OPERATOR_LOWER_THAN_OR_EQUAL, + Query::OPERATOR_LIKE, + Query::OPERATOR_EXISTS, + ]; + /** * Scroll * ====== @@ -161,50 +185,6 @@ trait BuildsFluentQueries */ protected $scrollId = null; - /** - * Result aggregations - * - * @var array - */ - protected $aggregations = []; - - /** - * Filter operators - * - * @var array - */ - protected $operators = [ - Query::OPERATOR_EQUAL, - Query::OPERATOR_NOT_EQUAL, - Query::OPERATOR_GREATER_THAN, - Query::OPERATOR_GREATER_THAN_OR_EQUAL, - Query::OPERATOR_LOWER_THAN, - Query::OPERATOR_LOWER_THAN_OR_EQUAL, - Query::OPERATOR_LIKE, - Query::OPERATOR_EXISTS, - ]; - - /** - * Query bool filter - * - * @var array - */ - protected $filter = []; - - /** - * Query returned fields list - * - * @var array|null - */ - protected $source; - - /** - * Query sort fields - * - * @var array - */ - protected $sort = []; - /** * Search Type * =========== @@ -280,93 +260,84 @@ trait BuildsFluentQueries protected $size = Query::DEFAULT_LIMIT; /** - * Starting document offset - * ======================== - * Starting document offset. Defaults to `0`. - * - * By default, you cannot page through more than 10,000 hits using the - * `from` and `size` parameters. To page through more hits, use the - * `search_after` parameter. + * Query sort fields * - * @var int - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/search-search.html#search-type + * @var array */ - protected $from = Query::DEFAULT_OFFSET; + protected $sort = []; /** - * Sets the name of the index to use for the query. - * - * @param string|null $index + * Query returned fields list * - * @return $this + * @var array|null */ - public function index(?string $index = null): self - { - $this->index = $index; - - return $this; - } + protected $source; /** - * Sets the document mapping type to restrict the query to. - * - * @param string $type Name of the document mapping type + * Mapping type + * ============ + * Each document indexed is associated with a `_type` and an `_id`. + * The `_type` field is indexed in order to make searching by type name fast + * The value of the `_type` field is accessible in queries, aggregations, + * scripts, and when sorting. + * Note that mapping types are deprecated as of 6.0.0: + * Indices created in Elasticsearch 7.0.0 or later no longer accept a + * `_default_` mapping. Indices created in 6.x will continue to function as + * before in Elasticsearch 6.x. Types are deprecated in APIs in 7.0, with + * breaking changes to the index creation, put mapping, get mapping, put + * template, get template and get field mappings APIs. * - * @return $this - * @deprecated Mapping types are deprecated as of Elasticsearch 6.0.0 + * @var string|null + * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/mapping-type-field.html */ - public function type(string $type): self - { - $this->type = $type; + #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')] + protected $type; - return $this; + /** + * Retrieves the ID the query is restricted to. + * + * @return string|null + */ + public function getId(): ?string + { + return $this->id; } /** - * Enables the scroll API. The argument may be used to set the duration to - * keep the scroll ID alive for. Defaults to 5 minutes. - * - * @param string $keepAlive + * Retrieves all ignored fields * - * @return $this + * @return array */ - public function scroll(string $keepAlive = '5m'): self + public function getIgnores(): array { - $this->scroll = $keepAlive; - - return $this; + return $this->ignores; } /** - * Sets the query scroll ID. - * - * @param string|null $scroll + * Retrieves the name of the index used for the query. * - * @return $this + * @return string|null */ - public function scrollId(?string $scroll): self + public function getIndex(): ?string { - $this->scrollId = $scroll; - - return $this; + return $this->index; } /** - * Sets the query search type. - * - * @param string $type - * - * @psalm-param 'query_then_fetch'|'dfs_query_then_fetch' $type + * Get the query scroll * - * @return $this - * @see https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search-request-search-type.html + * @return string|null */ - public function searchType(string $type): self + public function getScroll(): ?string { - $this->searchType = $type; + return $this->scroll; + } - return $this; + public function getScrollId(): ?string + { + return $this->scrollId; } /** @@ -382,94 +353,29 @@ public function getSearchType(): ?string } /** - * Avoids throwing an error on unsuccessful responses from the Elasticsearch - * server, as returned by the Elasticsearch client. - * - * @param mixed ...$args + * Retrieves the document mapping type the query is restricted to. * - * @return $this + * @return string|null + * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html */ - public function ignore(...$args): self + #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')] + public function getType(): ?string { - $this->ignores = array_merge( - $this->ignores, - $this->flattenArgs($args) - ); - - $this->ignores = array_unique($this->ignores); - - return $this; - } - - /** - * Set the sorting field - * - * @param string|int $field - * @param string $direction - * - * @return $this - */ - public function orderBy($field, string $direction = 'asc'): self - { - $this->sort[] = [$field => $direction]; - - return $this; - } - - /** - * Set the query fields to return - * - * @param mixed ...$args - * - * @return $this - */ - public function select(...$args): self - { - $fields = $this->flattenArgs($args); - - $this->source[Query::SOURCE_INCLUDES] = array_unique(array_merge( - $this->source[Query::SOURCE_INCLUDES] ?? [], - $fields - )); - - $this->source[Query::SOURCE_EXCLUDES] = array_values(array_filter( - $this->source[Query::SOURCE_EXCLUDES] ?? [], function ($field) { - return ! in_array( - $field, - $this->source[Query::SOURCE_INCLUDES] ?? [], - false - ); - })); - - return $this; + return $this->type; } /** - * Set the ignored fields to not be returned - * - * @param mixed ...$args + * @param string|null $id ID to filter by * * @return $this + * @deprecated Use id() instead + * @see Query::id() */ - public function unselect(...$args): self + #[Deprecated(replacement: '%class%->id(%parameter0%)')] + public function _id(?string $id = null): self { - $fields = $this->flattenArgs($args); - - $this->source[Query::SOURCE_EXCLUDES] = array_unique(array_merge( - $this->source[Query::SOURCE_EXCLUDES] ?? [], - $fields - )); - - $this->source[Query::SOURCE_INCLUDES] = array_values(array_filter( - $this->source[Query::SOURCE_INCLUDES] ?? [], function ($field) { - return ! in_array( - $field, - $this->source[Query::SOURCE_EXCLUDES] ?? [], - false - ); - })); - - return $this; + return $this->id($id); } /** @@ -522,15 +428,45 @@ public function aggregate( } /** - * @param string|null $id ID to filter by + * Sets the query body + * + * @param array $body * * @return $this - * @deprecated Use id() instead - * @see Query::id() */ - public function _id(?string $id = null): self + public function body(array $body = []): self { - return $this->id($id); + $this->body = $body; + + return $this; + } + + /** + * Add a condition to find documents which are some distance away from the + * given geo point. + * + * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.4/query-dsl-geo-distance-query.html + * + * @param string|callable $name A name of the field. + * @param mixed $value A starting geo point which can be + * represented by a string 'lat,lon', an + * object like `{'lat': lat, 'lon': lon}` + * or an array like `[lon,lat]`. + * @param string $distance A distance from the starting geo point. + * It can be for example '20km'. + * + * @return $this + */ + public function distance($name, $value, string $distance): self + { + if (is_callable($name)) { + return tap($this, $name); + } + + return $this->filter('geo_distance', [ + $name => $value, + 'distance' => $distance, + ]); } public function filter(string $type, array $parameters): self @@ -543,144 +479,204 @@ public function filter(string $type, array $parameters): self } /** - * Shorthand to add a "term" filter. + * Set the query where clause and retrieve the first matching document. * - * @param string $field Name of the field to add a filter for - * @param string|array|callable $value Filter value. Either a string value, - * an array of Elasticsearch parameters, - * or a callable that returns either of - * the previous. + * @param string|callable $name + * @param string|int|null $operator + * @param mixed|null $value * - * @return $this + * @return Model|null + * @throws InvalidArgumentException */ - public function termFilter(string $field, $value): self - { - return $this->filter('term', [ - $field => value($value, $this, $field), - ]); + public function firstWhere( + $name, + $operator = Query::OPERATOR_EQUAL, + $value = null + ): ?Model { + return $this + ->where($name, $operator, $value) + ->first(); } /** - * Shorthand to add a "terms" filter. + * Set the collapse field * - * @param string $field Name of the field to add a filter for - * @param array|callable $value Filter value. Either a string value, an - * array of Elasticsearch parameters, or a - * callable that returns either of the previous - * @param float|null $boost Floating point number used to decrease or - * increase the relevance scores of a query. - * Defaults to 1.0. You can use the boost - * parameter to adjust relevance scores for - * searches containing two or more queries. - * Boost values are relative to the default - * value of 1.0. A boost value between 0 and - * 1.0 decreases the relevance score. A value - * greater than 1.0 increases the relevance - * score. + * @param string $field * * @return $this */ - public function termsFilter( - string $field, - $value, - ?float $boost = null - ): self { - $value = value($value, $this, $field); - - if ($boost === null) { - return $this->filter('terms', [ - $field => $value, - ]); - } + public function groupBy(string $field): self + { + $this->body['collapse'] = [ + 'field' => $field, + ]; - return $this->filter('terms', [ - $field => $value, - 'boost' => $boost, - ]); + return $this; } /** - * Shorthand to add a "range" filter. + * Get highlight result * - * @param string $field Name of the field to add a filter - * for - * @param string|array|callable $operator Range comparison operator as a - * string, an array of custom range - * comparison parameters or a - * callable that returns either of - * the previous. - * @param string|array|callable $value Filter value. Either a string - * value, an array of Elasticsearch - * parameters, or a callable that - * returns either of the previous. - * Only used if a string operator has - * been passed as the second argument + * @param mixed ...$args * * @return $this - * @example $query->rangeFilter('year', ['gte' => '2006']) - * @example $query->rangeFilter('year', ['gte' => '2006', 'lt' => '2021']) - * @example $query->rangeFilter('year', fn($q, $field) => 'lt', '2021') - * @example $query->rangeFilter('year', fn($q, $field) => ['lt' => '2021']) - * - * @example $query->rangeFilter('year', 'gt', '2006') */ - public function rangeFilter(string $field, $operator, $value = null): self + public function highlight(...$args): self { - $operator = value($operator, $this, $field); + $fields = $this->flattenArgs($args); + $new_fields = []; - if (is_string($operator) && $value) { - return $this->filter('range', [ - $field => [ - $operator => value($value, $this, $field), - ], - ]); + foreach ($fields as $field) { + $new_fields[$field] = new stdClass(); } - return $this->filter('range', [ - [ - $field => $operator, - ], - ]); + $this->body['highlight'] = [ + 'fields' => $new_fields, + ]; + + return $this; } /** - * Shorthand to add a "match" filter. + * Adds a term filter for the `_id` field. * - * @param string $field Name of the field to add a filter for - * @param string|array|callable $value Filter value. Either a string value, - * an array of Elasticsearch parameters, - * or a callable that returns either of - * the previous. + * @param string|null $id * * @return $this */ - public function matchFilter(string $field, $value): self + public function id(?string $id = null): self { - return $this->filter('match', [ - $field => value($value, $this, $field), - ]); + $this->id = $id; + $this->filter[] = [ + 'term' => [ + Query::FIELD_ID => $id, + ], + ]; + + return $this; } /** - * Shorthand to add a "prefix" filter. + * Avoids throwing an error on unsuccessful responses from the Elasticsearch + * server, as returned by the Elasticsearch client. * - * @param string $field Name of the field to add a filter for - * @param string|array|callable $value Filter value. Either a string value, - * an array of Elasticsearch parameters, - * or a callable that returns either of - * the previous. + * @param mixed ...$args * * @return $this */ - public function prefixFilter(string $field, $value): self + public function ignore(...$args): self { - return $this->filter('prefix', [ + $this->ignores = array_merge( + $this->ignores, + $this->flattenArgs($args) + ); + + $this->ignores = array_unique($this->ignores); + + return $this; + } + + /** + * Sets the name of the index to use for the query. + * + * @param string|null $index + * + * @return $this + */ + public function index(?string $index = null): self + { + $this->index = $index; + + return $this; + } + + /** + * Shorthand to add a "match" filter. + * + * @param string $field Name of the field to add a filter for + * @param string|array|callable $value Filter value. Either a string value, + * an array of Elasticsearch parameters, + * or a callable that returns either of + * the previous. + * + * @return $this + */ + public function matchFilter(string $field, $value): self + { + return $this->filter('match', [ $field => value($value, $this, $field), ]); } /** - * Shorthand to add a "wildcard" filter. + * Adds a must condition to the query. + * + * @param string $type Query type + * @param array $parameters Parameters to the query + * + * @return $this + */ + public function must(string $type, array $parameters): self + { + $this->must[] = [ + $type => $parameters, + ]; + + return $this; + } + + /** + * Adds a must_not condition to the query. + * + * @param string $type Query type + * @param array $parameters Parameters to the query + * + * @return $this + */ + public function mustNot(string $type, array $parameters): self + { + $this->must_not[] = [ + $type => $parameters, + ]; + + return $this; + } + + /** + * @param string $path + * + * @return $this + */ + public function nested(string $path): self + { + $this->body = [ + 'query' => [ + 'nested' => [ + 'path' => $path, + ], + ], + ]; + + return $this; + } + + /** + * Set the sorting field + * + * @param string|int $field + * @param string $direction + * + * @return $this + */ + public function orderBy($field, string $direction = 'asc'): self + { + $this->sort[] = [$field => $direction]; + + return $this; + } + + /** + * Shorthand to add a "prefix" filter. * * @param string $field Name of the field to add a filter for * @param string|array|callable $value Filter value. Either a string value, @@ -690,13 +686,57 @@ public function prefixFilter(string $field, $value): self * * @return $this */ - public function wildcardFilter(string $field, $value): self + public function prefixFilter(string $field, $value): self { - return $this->filter('wildcard', [ + return $this->filter('prefix', [ $field => value($value, $this, $field), ]); } + /** + * Shorthand to add a "range" filter. + * + * @param string $field Name of the field to add a filter + * for + * @param string|array|callable $operator Range comparison operator as a + * string, an array of custom range + * comparison parameters or a + * callable that returns either of + * the previous. + * @param string|array|callable $value Filter value. Either a string + * value, an array of Elasticsearch + * parameters, or a callable that + * returns either of the previous. + * Only used if a string operator has + * been passed as the second argument + * + * @return $this + * @example $query->rangeFilter('year', ['gte' => '2006']) + * @example $query->rangeFilter('year', ['gte' => '2006', 'lt' => '2021']) + * @example $query->rangeFilter('year', fn($q, $field) => 'lt', '2021') + * @example $query->rangeFilter('year', fn($q, $field) => ['lt' => '2021']) + * + * @example $query->rangeFilter('year', 'gt', '2006') + */ + public function rangeFilter(string $field, $operator, $value = null): self + { + $operator = value($operator, $this, $field); + + if (is_string($operator) && $value) { + return $this->filter('range', [ + $field => [ + $operator => value($value, $this, $field), + ], + ]); + } + + return $this->filter('range', [ + [ + $field => $operator, + ], + ]); + } + /** * Shorthand to add a "regexp" filter. * @@ -799,593 +839,510 @@ public function regexpFilter( } /** - * Add a condition to find documents which are some distance away from the - * given geo point. + * Enables the scroll API. The argument may be used to set the duration to + * keep the scroll ID alive for. Defaults to 5 minutes. * - * @see https://www.elastic.co/guide/en/elasticsearch/reference/2.4/query-dsl-geo-distance-query.html + * @param string $keepAlive * - * @param string|callable $name A name of the field. - * @param mixed $value A starting geo point which can be - * represented by a string 'lat,lon', an - * object like `{'lat': lat, 'lon': lon}` - * or an array like `[lon,lat]`. - * @param string $distance A distance from the starting geo point. - * It can be for example '20km'. + * @return $this + */ + public function scroll(string $keepAlive = '5m'): self + { + $this->scroll = $keepAlive; + + return $this; + } + + /** + * Sets the query scroll ID. + * + * @param string|null $scroll * * @return $this */ - public function distance($name, $value, string $distance): self + public function scrollId(?string $scroll): self { - if (is_callable($name)) { - return tap($this, $name); - } + $this->scrollId = $scroll; - return $this->filter('geo_distance', [ - $name => $value, - 'distance' => $distance, - ]); + return $this; } /** - * Adds a filter to the query + * Search the entire document fields * - * @param string|callable $name - * @param string $operator - * @param mixed|null $value + * @param string|null $queryString + * @param callable|array|null $settings + * @param int|null $boost * * @return $this - * @throws InvalidArgumentException */ - public function where( - $name, - $operator = Query::OPERATOR_EQUAL, - $value = null + public function search( + ?string $queryString = null, + $settings = null, + ?int $boost = null ): self { - if (is_callable($name)) { - $name($this); - - return $this; - } + if ($queryString) { + $search = new Search( + $this, + $queryString, + $settings + ); - if ( ! $this->isOperator((string)$operator)) { - $value = $operator; - $operator = Query::OPERATOR_EQUAL; + $search->boost($boost ?? 1); + $search->build(); } - switch ((string)$operator) { - case 'eq': - case Query::OPERATOR_EQUAL: - if ($name === Query::FIELD_ID) { - return $this->id((string)$value); - } + return $this; + } - return $this->termFilter($name, $value); + /** + * Sets the query search type. + * + * @param string $type + * + * @psalm-param 'query_then_fetch'|'dfs_query_then_fetch' $type + * + * @return $this + * @see https://www.elastic.co/guide/en/elasticsearch/reference/6.8/search-request-search-type.html + */ + public function searchType(string $type): self + { + $this->searchType = $type; - case 'gt': - case Query::OPERATOR_GREATER_THAN: - return $this->rangeFilter( - $name, - 'gt', - $value - ); - - case 'gte': - case Query::OPERATOR_GREATER_THAN_OR_EQUAL: - return $this->rangeFilter( - $name, - 'gte', - $value - ); - - case 'lt': - case Query::OPERATOR_LOWER_THAN: - return $this->rangeFilter( - $name, - 'lt', - $value - ); - - case 'lte': - case Query::OPERATOR_LOWER_THAN_OR_EQUAL: - return $this->rangeFilter( - $name, - 'lte', - $value - ); - - case Query::OPERATOR_LIKE: - return $this->must('match', [ - $name => $value, - ]); - - case Query::OPERATOR_EXISTS: - return $this->whereExists($name, (bool)$value); - - default: - throw new InvalidArgumentException( - "Unknown operator '{$operator}'" - ); - } - } - - /** - * Set the query where clause and retrieve the first matching document. - * - * @param string|callable $name - * @param string $operator - * @param mixed|null $value - * - * @return Model|null - * @throws InvalidArgumentException - */ - public function firstWhere( - $name, - $operator = Query::OPERATOR_EQUAL, - $value = null - ): ?Model { - return $this - ->where($name, $operator, $value) - ->first(); - } + return $this; + } /** - * Set the query inverse where clause + * Set the query fields to return * - * @param string|callable $name - * @param string $operator - * @param null $value + * @param mixed ...$args * * @return $this */ - public function whereNot( - $name, - $operator = Query::OPERATOR_EQUAL, - $value = null - ): self { - if (is_callable($name)) { - return tap($this, $name); - } - - if ( ! $this->isOperator($operator)) { - $value = $operator; - $operator = Query::OPERATOR_EQUAL; - } - - switch ($operator) { - case 'eq': - case Query::OPERATOR_EQUAL: - return $this->mustNot('term', [ - $name => $value, - ]); - - case 'gt': - case Query::OPERATOR_GREATER_THAN: - return $this->mustNot('range', [ - $name => ['gt' => $value], - ]); - - case 'gte': - case Query::OPERATOR_GREATER_THAN_OR_EQUAL: - return $this->mustNot('range', [ - $name => ['gte' => $value], - ]); - - case 'lt': - case Query::OPERATOR_LOWER_THAN: - return $this->mustNot('range', [ - $name => ['lt' => $value], - ]); - - case 'lte': - case Query::OPERATOR_LOWER_THAN_OR_EQUAL: - return $this->mustNot('range', [ - $name => ['lte' => $value], - ]); + public function select(...$args): self + { + $fields = $this->flattenArgs($args); - case Query::OPERATOR_LIKE: - return $this->mustNot('match', [ - $name => $value, - ]); + $this->source[Query::SOURCE_INCLUDES] = array_unique(array_merge( + $this->source[Query::SOURCE_INCLUDES] ?? [], + $fields + )); - case Query::OPERATOR_EXISTS: - $this->whereExists($name, ! $value); - } + $this->source[Query::SOURCE_EXCLUDES] = array_values(array_filter( + $this->source[Query::SOURCE_EXCLUDES] ?? [], function ($field) { + return ! in_array( + $field, + $this->source[Query::SOURCE_INCLUDES] ?? [], + false + ); + })); return $this; } /** - * Set the query where between clause - * - * @param string $name - * @param mixed $firstValue - * @param mixed $lastValue - * - * @return $this - */ - public function whereBetween( - string $name, - $firstValue, - $lastValue = null - ): self { - if (is_array($firstValue) && count($firstValue) === 2) { - [$firstValue, $lastValue] = $firstValue; - } - - return $this->filter('range', [ - $name => [ - 'gte' => $firstValue, - 'lte' => $lastValue, - ], - ]); - } - - /** - * Set the query where not between clause + * Set the query offset * - * @param string $name - * @param mixed $firstValue - * @param mixed|null $lastValue + * @param int $from * * @return $this */ - public function whereNotBetween( - string $name, - $firstValue, - $lastValue = null - ): self { - if (is_array($firstValue) && count($firstValue) === 2) { - [$firstValue, $lastValue] = $firstValue; - } + public function skip(int $from = 0): self + { + $this->from = $from; - return $this->mustNot('range', [ - $name => [ - 'gte' => $firstValue, - 'lte' => $lastValue, - ], - ]); + return $this; } /** - * Set the query where in clause + * Sets the number of hits to return from the result. * - * @param string|callable $name - * @param array $value + * @param int $size * * @return $this */ - public function whereIn($name, $value = []): self + public function take(int $size = Query::DEFAULT_LIMIT): self { - if (is_callable($name)) { - return tap($this, $name); - } + $this->size = $size; - return $this->termsFilter($name, $value); + return $this; } /** - * Set the query where not in clause + * Shorthand to add a "term" filter. * - * @param string|callable $name - * @param array $value + * @param string $field Name of the field to add a filter for + * @param string|array|callable $value Filter value. Either a string value, + * an array of Elasticsearch parameters, + * or a callable that returns either of + * the previous. * * @return $this */ - public function whereNotIn($name, $value = []): self + public function termFilter(string $field, $value): self { - if (is_callable($name)) { - return tap($this, $name); - } - - return $this->mustNot('terms', [ - $name => $value, + return $this->filter('term', [ + $field => value($value, $this, $field), ]); } /** - * Set the query where exists clause + * Shorthand to add a "terms" filter. * - * @param string $name - * @param bool $exists + * @param string $field Name of the field to add a filter for + * @param array|callable $value Filter value. Either a string value, an + * array of Elasticsearch parameters, or a + * callable that returns either of the previous + * @param float|null $boost Floating point number used to decrease or + * increase the relevance scores of a query. + * Defaults to 1.0. You can use the boost + * parameter to adjust relevance scores for + * searches containing two or more queries. + * Boost values are relative to the default + * value of 1.0. A boost value between 0 and + * 1.0 decreases the relevance score. A value + * greater than 1.0 increases the relevance + * score. * * @return $this */ - public function whereExists(string $name, bool $exists = true): self - { - if ($exists) { - return $this->must('exists', [ - 'field' => $name, + public function termsFilter( + string $field, + $value, + ?float $boost = null + ): self { + $value = value($value, $this, $field); + + if ($boost === null) { + return $this->filter('terms', [ + $field => $value, ]); } - return $this->mustNot('exists', [ - 'field' => $name, + return $this->filter('terms', [ + $field => $value, + 'boost' => $boost, ]); } /** - * Adds a must condition to the query. + * Sets the document mapping type to restrict the query to. * - * @param string $type Query type - * @param array $parameters Parameters to the query + * @param string $type Name of the document mapping type * * @return $this + * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html */ - public function must(string $type, array $parameters): self + #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')] + public function type(string $type): self { - $this->must[] = [ - $type => $parameters, - ]; + $this->type = $type; return $this; } /** - * Adds a must_not condition to the query. + * Set the ignored fields to not be returned * - * @param string $type Query type - * @param array $parameters Parameters to the query + * @param mixed ...$args * * @return $this */ - public function mustNot(string $type, array $parameters): self + public function unselect(...$args): self { - $this->must_not[] = [ - $type => $parameters, - ]; + $fields = $this->flattenArgs($args); - return $this; - } + $this->source[Query::SOURCE_EXCLUDES] = array_unique(array_merge( + $this->source[Query::SOURCE_EXCLUDES] ?? [], + $fields + )); - /** - * Search the entire document fields - * - * @param string|null $queryString - * @param callable|null $settings - * @param int|null $boost - * - * @return $this - * @noinspection PhpParamsInspection - */ - public function search( - ?string $queryString = null, - $settings = null, - ?int $boost = null - ): self { - if ($queryString) { - $search = new Search( - $this, - $queryString, - $settings + $this->source[Query::SOURCE_INCLUDES] = array_values(array_filter( + $this->source[Query::SOURCE_INCLUDES] ?? [], function ($field) { + return ! in_array( + $field, + $this->source[Query::SOURCE_EXCLUDES] ?? [], + false ); - - $search->boost($boost ?? 1); - $search->build(); - } - - return $this; - } - - /** - * @param string $path - * - * @return $this - */ - public function nested(string $path): self - { - $this->body = [ - 'query' => [ - 'nested' => [ - 'path' => $path, - ], - ], - ]; + })); return $this; } /** - * Get highlight result + * Adds a filter to the query * - * @param mixed ...$args + * @param string|callable $name + * @param string|int|null $operator + * @param mixed|null $value * * @return $this + * @throws InvalidArgumentException */ - public function highlight(...$args): self - { - $fields = $this->flattenArgs($args); - $new_fields = []; + public function where( + $name, + $operator = Query::OPERATOR_EQUAL, + $value = null + ): self { + if (is_callable($name)) { + $name($this); - foreach ($fields as $field) { - $new_fields[$field] = new stdClass(); + return $this; } - $this->body['highlight'] = [ - 'fields' => $new_fields, - ]; + if ( ! $this->isOperator((string)$operator)) { + $value = $operator; + $operator = Query::OPERATOR_EQUAL; + } - return $this; - } + switch ((string)$operator) { + case 'eq': + case Query::OPERATOR_EQUAL: + if ($name === Query::FIELD_ID) { + return $this->id((string)$value); + } - /** - * Sets the query body - * - * @param array $body - * - * @return $this - */ - public function body(array $body = []): self - { - $this->body = $body; + return $this->termFilter($name, $value); - return $this; - } + case 'gt': + case Query::OPERATOR_GREATER_THAN: + return $this->rangeFilter( + $name, + 'gt', + $value + ); - /** - * Set the collapse field - * - * @param string $field - * - * @return $this - */ - public function groupBy(string $field): self - { - $this->body['collapse'] = [ - 'field' => $field, - ]; + case 'gte': + case Query::OPERATOR_GREATER_THAN_OR_EQUAL: + return $this->rangeFilter( + $name, + 'gte', + $value + ); - return $this; - } + case 'lt': + case Query::OPERATOR_LOWER_THAN: + return $this->rangeFilter( + $name, + 'lt', + $value + ); - /** - * Retrieves the ID the query is restricted to. - * - * @return string|null - */ - public function getId(): ?string - { - return $this->id; - } + case 'lte': + case Query::OPERATOR_LOWER_THAN_OR_EQUAL: + return $this->rangeFilter( + $name, + 'lte', + $value + ); - /** - * Retrieves all ignored fields - * - * @return array - */ - public function getIgnores(): array - { - return $this->ignores; - } + case Query::OPERATOR_LIKE: + return $this->must('match', [ + $name => $value, + ]); - /** - * Retrieves the name of the index used for the query. - * - * @return string|null - */ - public function getIndex(): ?string - { - return $this->index; + case Query::OPERATOR_EXISTS: + return $this->whereExists($name, (bool)$value); + + default: + throw new InvalidArgumentException( + "Unknown operator '{$operator}'" + ); + } } /** - * Get the query scroll + * Set the query where between clause * - * @return string|null + * @param string $name + * @param mixed $firstValue + * @param mixed $lastValue + * + * @return $this */ - public function getScroll(): ?string - { - return $this->scroll; - } + public function whereBetween( + string $name, + $firstValue, + $lastValue = null + ): self { + if (is_array($firstValue) && count($firstValue) === 2) { + [$firstValue, $lastValue] = $firstValue; + } - public function getScrollId(): ?string - { - return $this->scrollId; + return $this->filter('range', [ + $name => [ + 'gte' => $firstValue, + 'lte' => $lastValue, + ], + ]); } /** - * Retrieves the document mapping type the query is restricted to. + * Set the query where exists clause * - * @return string|null - * @deprecated Mapping types are deprecated as of Elasticsearch 6.0.0 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html + * @param string $name + * @param bool $exists + * + * @return $this */ - public function getType(): ?string + public function whereExists(string $name, bool $exists = true): self { - return $this->type; + if ($exists) { + return $this->must('exists', [ + 'field' => $name, + ]); + } + + return $this->mustNot('exists', [ + 'field' => $name, + ]); } /** - * Adds a term filter for the `_id` field. + * Set the query where in clause * - * @param string|null $id + * @param string|callable $name + * @param mixed|null $value * * @return $this */ - public function id(?string $id = null): self + public function whereIn($name, $value = []): self { - $this->id = $id; - $this->filter[] = [ - 'term' => [ - Query::FIELD_ID => $id, - ], - ]; + if (is_callable($name)) { + return tap($this, $name); + } - return $this; + return $this->termsFilter($name, $value); } /** - * Set the query offset + * Set the query inverse where clause * - * @param int $from + * @param string|callable $name + * @param string $operator + * @param null $value * * @return $this */ - public function skip(int $from = 0): self - { - $this->from = $from; + public function whereNot( + $name, + string $operator = Query::OPERATOR_EQUAL, + $value = null + ): self { + if (is_callable($name)) { + return tap($this, $name); + } + + if ( ! $this->isOperator($operator)) { + $value = $operator; + $operator = Query::OPERATOR_EQUAL; + } + + switch ($operator) { + case 'eq': + case Query::OPERATOR_EQUAL: + return $this->mustNot('term', [ + $name => $value, + ]); + + case 'gt': + case Query::OPERATOR_GREATER_THAN: + return $this->mustNot('range', [ + $name => ['gt' => $value], + ]); + + case 'gte': + case Query::OPERATOR_GREATER_THAN_OR_EQUAL: + return $this->mustNot('range', [ + $name => ['gte' => $value], + ]); + + case 'lt': + case Query::OPERATOR_LOWER_THAN: + return $this->mustNot('range', [ + $name => ['lt' => $value], + ]); + + case 'lte': + case Query::OPERATOR_LOWER_THAN_OR_EQUAL: + return $this->mustNot('range', [ + $name => ['lte' => $value], + ]); + + case Query::OPERATOR_LIKE: + return $this->mustNot('match', [ + $name => $value, + ]); + + case Query::OPERATOR_EXISTS: + $this->whereExists($name, ! $value); + } return $this; } /** - * Sets the number of hits to return from the result. + * Set the query where not between clause * - * @param int $size + * @param string $name + * @param mixed $firstValue + * @param mixed|null $lastValue * * @return $this */ - public function take(int $size = Query::DEFAULT_LIMIT): self - { - $this->size = $size; + public function whereNotBetween( + string $name, + $firstValue, + $lastValue = null + ): self { + if (is_array($firstValue) && count($firstValue) === 2) { + [$firstValue, $lastValue] = $firstValue; + } - return $this; + return $this->mustNot('range', [ + $name => [ + 'gte' => $firstValue, + 'lte' => $lastValue, + ], + ]); } /** - * Get the query limit + * Set the query where not in clause * - * @return int - * @deprecated Use getSize() instead - */ - protected function getTake(): int - { - return $this->getSize(); - } - - /** - * Retrieves the number of hits to limit the query to. + * @param string|callable $name + * @param mixed|array $value * - * @return int + * @return $this */ - protected function getSize(): int + public function whereNotIn($name, $value = []): self { - return $this->size; - } + if (is_callable($name)) { + return tap($this, $name); + } - /** - * Get the query offset - * - * @return int - */ - protected function getSkip(): int - { - return $this->from; + return $this->mustNot('terms', [ + $name => $value, + ]); } /** - * check if it's a valid operator + * Shorthand to add a "wildcard" filter. * - * @param string $string + * @param string $field Name of the field to add a filter for + * @param string|array|callable $value Filter value. Either a string value, + * an array of Elasticsearch parameters, + * or a callable that returns either of + * the previous. * - * @return bool + * @return $this */ - protected function isOperator(string $string): bool + public function wildcardFilter(string $field, $value): self { - return in_array( - $string, - $this->operators, - true - ); + return $this->filter('wildcard', [ + $field => value($value, $this, $field), + ]); } /** @@ -1461,6 +1418,70 @@ protected function getBody(): array return $body; } + /** + * Retrieves the number of hits to limit the query to. + * + * @return int + */ + protected function getSize(): int + { + return $this->size; + } + + /** + * Get the query offset + * + * @return int + */ + protected function getSkip(): int + { + return $this->from; + } + + /** + * Get the query limit + * + * @return int + * @deprecated Use getSize() instead + */ + #[Deprecated(replacement: '%class%->getSize()')] + protected function getTake(): int + { + return $this->getSize(); + } + + /** + * check if it's a valid operator + * + * @param string $string + * + * @return bool + */ + protected function isOperator(string $string): bool + { + return in_array( + $string, + $this->operators, + true + ); + } + + private function flattenArgs(array $args): array + { + $flattened = []; + + foreach ($args as $arg) { + if (is_array($arg)) { + /** @noinspection SlowArrayOperationsInLoopInspection */ + $flattened = array_merge($flattened, $arg); + } else { + $flattened[] = $arg; + } + } + + return $flattened; + } + private function resolveRegexpFlags(int $flags): ?string { $stringFlags = []; @@ -1491,20 +1512,4 @@ private function resolveRegexpFlags(int $flags): ?string return implode('|', $stringFlags); } - - private function flattenArgs(array $args): array - { - $flattened = []; - - foreach ($args as $arg) { - if (is_array($arg)) { - /** @noinspection SlowArrayOperationsInLoopInspection */ - $flattened = array_merge($flattened, $arg); - } else { - $flattened[] = $arg; - } - } - - return $flattened; - } } diff --git a/src/Concerns/ExecutesQueries.php b/src/Concerns/ExecutesQueries.php index 70ef7f5..f2bfc33 100644 --- a/src/Concerns/ExecutesQueries.php +++ b/src/Concerns/ExecutesQueries.php @@ -6,6 +6,7 @@ use DateTime; use Illuminate\Support\Facades\Request; +use JetBrains\PhpStorm\Deprecated; use JsonException; use Matchory\Elasticsearch\Classes\Bulk; use Matchory\Elasticsearch\Collection; @@ -38,31 +39,35 @@ trait ExecutesQueries protected $cacheKey; /** - * The number of seconds to cache the query. + * A cache prefix. * - * @var DateTime|int|null + * @var string */ - protected $cacheTtl; + protected $cachePrefix = Query::DEFAULT_CACHE_PREFIX; /** - * A cache prefix. + * The number of seconds to cache the query. * - * @var string + * @var DateTime|int|null */ - protected $cachePrefix = Query::DEFAULT_CACHE_PREFIX; + protected $cacheTtl; /** - * Set the cache prefix. + * Get the collection of results * - * @param string $prefix + * @param string|null $scrollId * - * @return $this + * @return Collection */ - public function cachePrefix(string $prefix): self + public function get(?string $scrollId = null): Collection { - $this->cachePrefix = $prefix; + $result = $this->getResult($scrollId); - return $this; + if ( ! $result) { + return new Collection([]); + } + + return $this->transformIntoCollection($result); } /** @@ -78,134 +83,137 @@ public function getCacheKey(): string } /** - * Generate the unique cache key for the query. + * Insert multiple documents at once. * - * @return string + * @param array|callable $data Dictionary of [id => data] pairs + * + * @return object */ - public function generateCacheKey(): string + public function bulk($data): object { - try { - return md5($this->toJson()); - } catch (JsonException $e) { - return md5(serialize($this)); + if (is_callable($data)) { + /** @var Query $this */ + $bulk = new Bulk($this); + + $data($bulk); + + $params = $bulk->body(); + } else { + $params = []; + + foreach ($data as $key => $value) { + $params['body'][] = [ + + 'index' => [ + '_index' => $this->getIndex(), + '_type' => $this->getType(), + '_id' => $key, + ], + + ]; + + $params['body'][] = $value; + } } + + return (object)$this->getConnection()->getClient()->bulk( + $params + ); } /** - * Indicate that the query results should be cached. + * Set the cache prefix. * - * @param DateTime|int $ttl Cache TTL in seconds. - * @param string|null $key Cache key to use. Will be generated - * automatically if omitted. + * @param string $prefix * * @return $this */ - public function remember($ttl, ?string $key = null): self + public function cachePrefix(string $prefix): self { - $this->cacheTtl = $ttl; - $this->cacheKey = $key; + $this->cachePrefix = $prefix; return $this; } /** - * Indicate that the query results should be cached forever. + * Clear scroll query id * - * @param string|null $key + * @param string|null $scrollId * - * @return $this + * @return Collection */ - public function rememberForever(?string $key = null): self + public function clear(?string $scrollId = null): Collection { - return $this->remember(-1, $key); + $scrollId = $scrollId ?? $this->getScrollId(); + + return new Collection( + $this->getConnection()->getClient()->clearScroll([ + 'scroll_id' => $scrollId, + 'client' => ['ignore' => $this->getIgnores()], + ]) + ); } /** - * Get the collection of results - * - * @param string|null $scrollId + * Get the count of result * - * @return Collection + * @return int */ - public function get(?string $scrollId = null): Collection + public function count(): int { - $result = $this->getResult($scrollId); + $query = $this->toArray(); - if ( ! $result) { - return new Collection([]); - } + // Remove unsupported count query keys + unset( + $query[Query::PARAM_SIZE], + $query[Query::PARAM_FROM], + $query['body']['_source'], + $query['body']['sort'] + ); - return $this->transformIntoCollection($result); + return (int)$this + ->getConnection() + ->getClient() + ->count($query)['count']; } /** - * Paginate collection of results + * Increment a document field * - * @param int $perPage - * @param string $pageName - * @param int|null $page + * @param string $field + * @param int $count * - * @return Pagination + * @return object */ - public function paginate( - int $perPage = 10, - string $pageName = 'page', - ?int $page = null - ): Pagination { - // Check if the request from PHP CLI - if (PHP_SAPI === 'cli') { - $this->take($perPage); - - $page = $page ?: 1; - - $this->skip(($page * $perPage) - $perPage); - - $collection = $this->get(); - - return new Pagination( - $collection, - $collection->getTotal() ?? 0, - $perPage, - $page - ); - } - - $this->take($perPage); - - $page = $page ?: Request::get($pageName, 1); - - $this->skip(($page * $perPage) - $perPage); - - $collection = $this->get(); - - return new Pagination( - $collection, - $collection->getTotal() ?? 0, - $perPage, - $page, - [ - 'path' => Request::url(), - 'query' => Request::query(), - ] - ); + public function decrement(string $field, int $count = 1): object + { + return $this->script("ctx._source.{$field} -= params.count", [ + 'count' => $count, + ]); } /** - * Clear scroll query id + * Delete a document * - * @param string|null $scrollId + * @param string|null $id * - * @return Collection + * @return object */ - public function clear(?string $scrollId = null): Collection + public function delete(?string $id = null): object { - $scrollId = $scrollId ?? $this->getScrollId(); + if ($id) { + $this->id($id); + } - return new Collection( - $this->getConnection()->getClient()->clearScroll([ - 'scroll_id' => $scrollId, - 'client' => ['ignore' => $this->getIgnores()], - ]) + $parameters = [ + 'id' => $this->getId(), + 'client' => ['ignore' => $this->getIgnores()], + ]; + + $parameters = $this->addBaseParams($parameters); + + return (object)$this->getConnection()->getClient()->delete( + $parameters ); } @@ -275,6 +283,35 @@ public function firstOrFail(?string $scrollId = null): Model ); } + /** + * Generate the unique cache key for the query. + * + * @return string + */ + public function generateCacheKey(): string + { + try { + return md5($this->toJson()); + } catch (JsonException $e) { + return md5(serialize($this)); + } + } + + /** + * Increment a document field + * + * @param string $field + * @param int $count + * + * @return object + */ + public function increment(string $field, int $count = 1): object + { + return $this->script("ctx._source.{$field} += params.count", [ + 'count' => $count, + ]); + } + /** * Insert a document * @@ -308,90 +345,140 @@ public function insert(array $attributes, ?string $id = null): object } /** - * Update a document + * Paginate collection of results * - * @param array $attributes - * @param string|null $id + * @param int $perPage + * @param string $pageName + * @param int|null $page * - * @return object + * @return Pagination */ - public function update(array $attributes, $id = null): object - { - if ($id) { - $this->id($id); - } + public function paginate( + int $perPage = 10, + string $pageName = 'page', + ?int $page = null + ): Pagination { + // Check if the request from PHP CLI + if (PHP_SAPI === 'cli') { + $this->take($perPage); - unset( - $attributes[self::FIELD_HIGHLIGHT], - $attributes[self::FIELD_INDEX], - $attributes[self::FIELD_SCORE], - $attributes[self::FIELD_TYPE], - $attributes[self::FIELD_ID], - ); + $page = $page ?: 1; - $parameters = [ - 'id' => $this->getId(), - 'body' => [ - 'doc' => $attributes, - ], - 'client' => [ - 'ignore' => $this->getIgnores(), - ], - ]; + $this->skip(($page * $perPage) - $perPage); - $parameters = $this->addBaseParams($parameters); + $collection = $this->get(); - return (object)$this->getConnection()->getClient()->update( - $parameters + return new Pagination( + $collection, + $collection->getTotal() ?? 0, + $perPage, + $page + ); + } + + $this->take($perPage); + + $page = $page ?: Request::get($pageName, 1); + + $this->skip(($page * $perPage) - $perPage); + + $collection = $this->get(); + + return new Pagination( + $collection, + $collection->getTotal() ?? 0, + $perPage, + $page, + [ + 'path' => Request::url(), + 'query' => Request::query(), + ] ); } /** - * Delete a document + * Get non-cached results * - * @param string|null $id + * @param string|null $scrollId * - * @return object + * @return array + * @throws InvalidArgumentException */ - public function delete(?string $id = null): object + public function performSearch(?string $scrollId = null): ?array { - if ($id) { - $this->id($id); + $scrollId = $scrollId ?? $this->getScrollId(); + + if ($scrollId) { + $result = $this + ->getConnection() + ->getClient() + ->scroll([ + Query::PARAM_SCROLL => $this->getScroll(), + Query::PARAM_SCROLL_ID => $scrollId, + ]); + } else { + $query = $this->buildQuery(); + $result = $this + ->getConnection() + ->getClient() + ->search($query); } - $parameters = [ - 'id' => $this->getId(), - 'client' => ['ignore' => $this->getIgnores()], - ]; + // We attempt to cache the results if we have a cache instance, and the + // TTl is truthy. This allows to use values such as `-1` to flush it. + if ($this->cacheTtl && ($cache = $this->getCache())) { + $cache->set( + $this->getCacheKey(), + $result, + $this->cacheTtl instanceof DateTime + ? $this->cacheTtl->getTimestamp() + : $this->cacheTtl + ); + } - $parameters = $this->addBaseParams($parameters); + return $result; + } - return (object)$this->getConnection()->getClient()->delete( - $parameters - ); + /** + * Keeping around for backwards compatibility + * + * @return array + * @deprecated Use toArray() instead + * @see Query::toArray() + */ + #[Deprecated(replacement: '%class%->toArray()')] + public function query(): array + { + return $this->toArray(); } /** - * Get the count of result + * Indicate that the query results should be cached. * - * @return int + * @param DateTime|int $ttl Cache TTL in seconds. + * @param string|null $key Cache key to use. Will be generated + * automatically if omitted. + * + * @return $this */ - public function count(): int + public function remember($ttl, ?string $key = null): self { - $query = $this->toArray(); + $this->cacheTtl = $ttl; + $this->cacheKey = $key; - // Remove unsupported count query keys - unset( - $query[Query::PARAM_SIZE], - $query[Query::PARAM_FROM], - $query['body']['_source'], - $query['body']['sort'] - ); + return $this; + } - return (int)$this - ->getConnection() - ->getClient() - ->count($query)['count']; + /** + * Indicate that the query results should be cached forever. + * + * @param string|null $key + * + * @return $this + */ + public function rememberForever(?string $key = null): self + { + return $this->remember(-1, $key); } /** @@ -423,127 +510,74 @@ public function script($script, array $params = []): object } /** - * Increment a document field - * - * @param string $field - * @param int $count - * - * @return object - */ - public function increment(string $field, int $count = 1): object - { - return $this->script("ctx._source.{$field} += params.count", [ - 'count' => $count, - ]); - } - - /** - * Increment a document field - * - * @param string $field - * @param int $count - * - * @return object - */ - public function decrement(string $field, int $count = 1): object - { - return $this->script("ctx._source.{$field} -= params.count", [ - 'count' => $count, - ]); - } - - /** - * Insert multiple documents at once. + * Update a document * - * @param array|callable $data Dictionary of [id => data] pairs + * @param array $attributes + * @param string|int|null $id * * @return object */ - public function bulk($data): object + public function update(array $attributes, $id = null): object { - if (is_callable($data)) { - /** @var Query $this */ - $bulk = new Bulk($this); - - $data($bulk); - - $params = $bulk->body(); - } else { - $params = []; - - foreach ($data as $key => $value) { - $params['body'][] = [ + if ($id) { + $this->id($id); + } - 'index' => [ - '_index' => $this->getIndex(), - '_type' => $this->getType(), - '_id' => $key, - ], + unset( + $attributes[self::FIELD_HIGHLIGHT], + $attributes[self::FIELD_INDEX], + $attributes[self::FIELD_SCORE], + $attributes[self::FIELD_TYPE], + $attributes[self::FIELD_ID], + ); - ]; + $parameters = [ + 'id' => $this->getId(), + 'body' => [ + 'doc' => $attributes, + ], + 'client' => [ + 'ignore' => $this->getIgnores(), + ], + ]; - $params['body'][] = $value; - } - } + $parameters = $this->addBaseParams($parameters); - return (object)$this->getConnection()->getClient()->bulk( - $params + return (object)$this->getConnection()->getClient()->update( + $parameters ); } /** - * Get non-cached results + * Processes a result and turns it into a model instance. * - * @param string|null $scrollId + * @param array $document Raw document to create a model + * instance from * - * @return array - * @throws InvalidArgumentException + * @return Model Model instance representing the source document */ - public function performSearch(?string $scrollId = null): ?array + protected function createModelInstance(array $document): Model { - $scrollId = $scrollId ?? $this->getScrollId(); - - if ($scrollId) { - $result = $this - ->getConnection() - ->getClient() - ->scroll([ - Query::PARAM_SCROLL => $this->getScroll(), - Query::PARAM_SCROLL_ID => $scrollId, - ]); - } else { - $query = $this->buildQuery(); - $result = $this - ->getConnection() - ->getClient() - ->search($query); - } - - // We attempt to cache the results if we have a cache instance, and the - // TTl is truthy. This allows to use values such as `-1` to flush it. - if ($this->cacheTtl && ($cache = $this->getCache())) { - $cache->set( - $this->getCacheKey(), - $result, - $this->cacheTtl instanceof DateTime - ? $this->cacheTtl->getTimestamp() - : $this->cacheTtl - ); - } + $data = $document[Query::FIELD_SOURCE] ?? []; + $metadata = array_diff_key($document, array_flip([ + Query::FIELD_SOURCE, + ])); - return $result; + return $this->getModel()->newInstance( + $data, + $metadata, + true, + $document[Query::FIELD_INDEX] ?? null, + $document[Query::FIELD_TYPE] ?? null, + ); } /** - * Keeping around for backwards compatibility - * - * @return array - * @deprecated Use toArray() instead - * @see Query::toArray() + * @return CacheInterface|null */ - public function query(): array + protected function getCache(): ?CacheInterface { - return $this->toArray(); + return $this->getConnection()->getCache(); } /** @@ -552,6 +586,7 @@ public function query(): array * @param string|null $scrollId * * @return array|null + * @throws InvalidArgumentException */ protected function getResult(?string $scrollId = null): ?array { @@ -562,7 +597,7 @@ protected function getResult(?string $scrollId = null): ?array if ($cache = $this->getCache()) { try { return $cache->get($this->getCacheKey()); - } catch (Throwable | InvalidArgumentException $exception) { + } catch (Throwable|InvalidArgumentException $exception) { // If the cache didn't like our cache key (which should be // impossible), we regard it as a cache failure and perform a // normal search instead. @@ -572,14 +607,6 @@ protected function getResult(?string $scrollId = null): ?array return $this->performSearch($scrollId); } - /** - * @return CacheInterface|null - */ - protected function getCache(): ?CacheInterface - { - return $this->getConnection()->getCache(); - } - /** * Retrieves all documents from a response. * @@ -623,30 +650,6 @@ protected function transformIntoModel(array $response = []): ?Model ); } - /** - * Processes a result and turns it into a model instance. - * - * @param array $document Raw document to create a model - * instance from - * - * @return Model Model instance representing the source document - */ - protected function createModelInstance(array $document): Model - { - $data = $document[Query::FIELD_SOURCE] ?? []; - $metadata = array_diff_key($document, array_flip([ - Query::FIELD_SOURCE, - ])); - - return $this->getModel()->newInstance( - $data, - $metadata, - true, - $document[Query::FIELD_INDEX] ?? null, - $document[Query::FIELD_TYPE] ?? null, - ); - } - /** * Adds the base parameters required for all queries. * diff --git a/src/Concerns/HasGlobalScopes.php b/src/Concerns/HasGlobalScopes.php index a8cc6bc..94064ff 100644 --- a/src/Concerns/HasGlobalScopes.php +++ b/src/Concerns/HasGlobalScopes.php @@ -30,6 +30,32 @@ trait HasGlobalScopes */ protected static $globalScopes = []; + /** + * Get a global scope registered with the model. + * + * @param ScopeInterface|string $scope + * + * @return ScopeInterface|Closure|null + */ + public static function getGlobalScope($scope) + { + if (is_string($scope)) { + return static::$globalScopes[static::class][$scope] ?? null; + } + + return static::$globalScopes[static::class][get_class($scope)] ?? null; + } + + /** + * Get the global scopes for this class instance. + * + * @return array + */ + public function getGlobalScopes(): array + { + return static::$globalScopes[static::class] ?? []; + } + /** * Register a new global scope on the model. * @@ -72,30 +98,4 @@ public static function hasGlobalScope($scope): bool { return (bool)static::getGlobalScope($scope); } - - /** - * Get a global scope registered with the model. - * - * @param ScopeInterface|string $scope - * - * @return ScopeInterface|Closure|null - */ - public static function getGlobalScope($scope) - { - if (is_string($scope)) { - return static::$globalScopes[static::class][$scope] ?? null; - } - - return static::$globalScopes[static::class][get_class($scope)] ?? null; - } - - /** - * Get the global scopes for this class instance. - * - * @return array - */ - public function getGlobalScopes(): array - { - return static::$globalScopes[static::class] ?? []; - } } diff --git a/src/Connection.php b/src/Connection.php index 577964c..763511b 100755 --- a/src/Connection.php +++ b/src/Connection.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Traits\ForwardsCalls; use InvalidArgumentException; +use JetBrains\PhpStorm\Deprecated; use Matchory\Elasticsearch\Interfaces\ClientFactoryInterface; use Matchory\Elasticsearch\Interfaces\ConnectionInterface; use Matchory\Elasticsearch\Interfaces\ConnectionResolverInterface as Resolver; @@ -38,6 +39,7 @@ class Connection implements ConnectionInterface * @deprecated * @todo remove in next major version */ + #[Deprecated] private static $resolver; /** @@ -47,6 +49,7 @@ class Connection implements ConnectionInterface * @deprecated * @todo remove in next major version */ + #[Deprecated] protected $clients = []; /** @@ -101,6 +104,7 @@ public function __construct( * @todo remove in next major version * @noinspection PhpDeprecationInspection */ + #[Deprecated] public static function setConnectionResolver(Resolver $resolver): void { static::$resolver = $resolver; @@ -117,6 +121,7 @@ public static function setConnectionResolver(Resolver $resolver): void * will be removed in the next major version. * @see ConnectionManager */ + #[Deprecated(reason: 'Use the connection manager to create connections instead.')] public static function configureLogging( ClientBuilder $clientBuilder, array $config @@ -154,6 +159,7 @@ public static function configureLogging( * will be removed in the next major version. * @see ConnectionManager */ + #[Deprecated(reason: 'Use the connection manager to create connections instead.')] public static function create($config): Query { $app = App::getFacadeApplication(); @@ -181,6 +187,7 @@ public static function create($config): Query * will be removed in the next major version. * @see ConnectionManager */ + #[Deprecated(reason: 'Use the connection manager to create connections instead.')] public function connection(string $name): Query { return $this->newQuery($name); @@ -198,6 +205,7 @@ public function connection(string $name): Query * @see ConnectionManager * @noinspection PhpDeprecationInspection */ + #[Deprecated(reason: 'Use the connection manager to create connections instead.')] public function isLoaded(string $name): bool { return (bool)static::$resolver->connection($name); diff --git a/src/Index.php b/src/Index.php index bd55ac7..5df5bbb 100755 --- a/src/Index.php +++ b/src/Index.php @@ -6,6 +6,7 @@ use ArrayObject; use Elasticsearch\Client; +use JetBrains\PhpStorm\Deprecated; use Matchory\Elasticsearch\Interfaces\ConnectionInterface; use TypeError; @@ -39,6 +40,15 @@ class Index private const PARAM_SETTINGS_NUMBER_OF_SHARDS = 'number_of_shards'; + /** + * Index create callback + * + * @var callable|null + * @deprecated Will be made private in the next major release. + */ + #[Deprecated()] + public $callback; + /** * Native elasticsearch client instance * @@ -47,6 +57,7 @@ class Index * method accessor instead. * @see Index::getConnection() */ + #[Deprecated(replacement: '%class%::getConnection()')] public $connection; /** @@ -57,35 +68,29 @@ class Index * method accessor instead. * @see Index::ignore() */ + #[Deprecated(replacement: '%class%::ignore()')] public $ignores = []; /** - * Index name + * Mappings the index shall be configured with. * - * @var string + * @var array * @deprecated Will be made private in the next major release. Use the * method accessor instead. - * @see Index::getName() - */ - public $name; - - /** - * Index create callback - * - * @var callable|null - * @deprecated Will be made private in the next major release. + * @see Index::mapping() */ - public $callback; + #[Deprecated(replacement: '%class%::mapping()')] + public $mappings = []; /** - * The number of shards the index shall be configured with. + * Index name * - * @var int + * @var string * @deprecated Will be made private in the next major release. Use the * method accessor instead. - * @see Index::shards() + * @see Index::getName() */ - public $shards = 5; + public $name; /** * The number of replicas the index shall be configured with. @@ -95,17 +100,19 @@ class Index * method accessor instead. * @see Index::replicas() */ + #[Deprecated(replacement: '%class%::replicas()')] public $replicas = 0; /** - * Mappings the index shall be configured with. + * The number of shards the index shall be configured with. * - * @var array + * @var int * @deprecated Will be made private in the next major release. Use the * method accessor instead. - * @see Index::mapping() + * @see Index::shards() */ - public $mappings = []; + #[Deprecated(replacement: '%class%::shards()')] + public $shards = 5; /** * Aliases the index shall be configured with. @@ -129,52 +136,47 @@ public function __construct(string $name, ?callable $callback = null) } /** - * Retrieves the name of the new index. + * Retrieves the Elasticsearch client instance. * - * @return string + * @return Client + * @internal */ - public function getName(): string + public function getClient(): Client { - return $this->name; + return $this->getConnection()->getClient(); } /** - * The number of primary shards that an index should have. Defaults to `1`. - * This setting can only be set at index creation time. It cannot be changed - * on a closed index. - * - * The number of shards are limited to 1024 per index. This limitation is a - * safety limit to prevent accidental creation of indices that can - * destabilize a cluster due to resource allocation. The limit can be - * modified by specifying - * `export ES_JAVA_OPTS="-Des.index.max_number_of_shards=128"` system - * property on every node that is part of the cluster. - * - * @param int $shards Number of shards to configure. + * Retrieves the active connection. * - * @return $this - * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-shards + * @return ConnectionInterface + * @internal */ - public function shards(int $shards): self + public function getConnection(): ConnectionInterface { - $this->shards = $shards; - - return $this; + return $this->connection; } /** - * The number of replicas each primary shard has. Defaults to `1`. + * Sets the active connection on the index. * - * @param int $replicas Number of replicas to configure. + * @param ConnectionInterface $connection * - * @return $this - * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-replicas + * @internal */ - public function replicas(int $replicas): self + public function setConnection(ConnectionInterface $connection): void { - $this->replicas = $replicas; + $this->connection = $connection; + } - return $this; + /** + * Retrieves the name of the new index. + * + * @return string + */ + public function getName(): string + { + return $this->name; } /** @@ -218,36 +220,6 @@ public function alias(string $alias, $options = null): self return $this; } - /** - * Configures the client to ignore bad HTTP requests. - * - * @param int ...$statusCodes HTTP Status codes to ignore. - * - * @return $this - */ - public function ignore(int ...$statusCodes): self - { - $this->ignores = array_unique($statusCodes); - - return $this; - } - - /** - * Checks whether an index exists. - * - * @return bool - */ - public function exists(): bool - { - return $this - ->getConnection() - ->getClient() - ->indices() - ->exists([ - 'index' => $this->name, - ]); - } - /** * Creates a new index * @@ -318,51 +290,99 @@ public function drop(): array } /** - * Sets the fields mappings. + * Checks whether an index exists. * - * @param array $mappings + * @return bool + */ + public function exists(): bool + { + return $this + ->getConnection() + ->getClient() + ->indices() + ->exists([ + 'index' => $this->name, + ]); + } + + /** + * Alias to the {@see Index::ignores()} method. + * + * @param int ...$statusCodes * * @return $this */ - public function mapping(array $mappings = []): self + #[Deprecated(replacement: '%class%->ignores(%parametersList%)')] + public function ignore(int ...$statusCodes): self { - $this->mappings = $mappings; + return $this->ignores(...$statusCodes); + } + + /** + * Configures the client to ignore bad HTTP requests. + * + * @param int ...$statusCodes HTTP Status codes to ignore. + * + * @return $this + */ + public function ignores(int ...$statusCodes): self + { + $this->ignores = array_unique($statusCodes); return $this; } /** - * Retrieves the Elasticsearch client instance. + * Sets the fields mappings. * - * @return Client - * @internal + * @param array $mappings + * + * @return $this */ - public function getClient(): Client + public function mapping(array $mappings = []): self { - return $this->getConnection()->getClient(); + $this->mappings = $mappings; + + return $this; } /** - * Retrieves the active connection. + * The number of replicas each primary shard has. Defaults to `1`. * - * @return ConnectionInterface - * @internal + * @param int $replicas Number of replicas to configure. + * + * @return $this + * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-replicas */ - public function getConnection(): ConnectionInterface + public function replicas(int $replicas): self { - return $this->connection; + $this->replicas = $replicas; + + return $this; } /** - * Sets the active connection on the index. + * The number of primary shards that an index should have. Defaults to `1`. + * This setting can only be set at index creation time. It cannot be changed + * on a closed index. * - * @param ConnectionInterface $connection + * The number of shards are limited to 1024 per index. This limitation is a + * safety limit to prevent accidental creation of indices that can + * destabilize a cluster due to resource allocation. The limit can be + * modified by specifying + * `export ES_JAVA_OPTS="-Des.index.max_number_of_shards=128"` system + * property on every node that is part of the cluster. * - * @internal + * @param int $shards Number of shards to configure. + * + * @return $this + * @see https://www.elastic.co/guide/en/elasticsearch/reference/master/index-modules.html#index-number-of-shards */ - public function setConnection(ConnectionInterface $connection): void + public function shards(int $shards): self { - $this->connection = $connection; + $this->shards = $shards; + + return $this; } } diff --git a/src/Model.php b/src/Model.php index 6779d83..67af7cc 100755 --- a/src/Model.php +++ b/src/Model.php @@ -20,6 +20,8 @@ use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\Str; use Illuminate\Support\Traits\ForwardsCalls; +use InvalidArgumentException; +use JetBrains\PhpStorm\Deprecated; use JsonException; use JsonSerializable; use Matchory\Elasticsearch\Compatibility\Exceptions\InvalidCastException; @@ -32,12 +34,15 @@ use Matchory\Elasticsearch\Interfaces\ConnectionInterface as Connection; use Matchory\Elasticsearch\Interfaces\ConnectionResolverInterface as Resolver; +use ReturnTypeWillChange; + use function array_key_exists; use function array_merge; use function array_unique; use function class_basename; use function class_uses_recursive; use function count; +use function dd; use function forward_static_call; use function func_get_args; use function get_class; @@ -88,11 +93,11 @@ class Model implements Arrayable, protected static $booted = []; /** - * The array of trait initializers that will be called on each new instance. + * The event dispatcher instance. * - * @var array + * @var Dispatcher */ - protected static $traitInitializers = []; + protected static $dispatcher; /** * The connection resolver instance. @@ -102,11 +107,11 @@ class Model implements Arrayable, protected static $resolver; /** - * The event dispatcher instance. + * The array of trait initializers that will be called on each new instance. * - * @var Dispatcher + * @var array */ - protected static $dispatcher; + protected static $traitInitializers = []; /** * Indicates if the model was inserted during the current request lifecycle. @@ -116,18 +121,18 @@ class Model implements Arrayable, public $wasRecentlyCreated = false; /** - * Metadata received from Elasticsearch as part of the response + * Model connection name. If `null` it will use the default connection. * - * @var array + * @var string|null */ - protected $resultMetadata = []; + protected $connectionName = null; /** - * Model connection name. If `null` it will use the default connection. + * Indicates whether the model exists in the Elasticsearch index. * - * @var string|null + * @var bool */ - protected $connectionName = null; + protected $exists = false; /** * Index name @@ -137,11 +142,11 @@ class Model implements Arrayable, protected $index = null; /** - * Document mapping type + * Metadata received from Elasticsearch as part of the response * - * @var string|null + * @var array */ - protected $type = null; + protected $resultMetadata = []; /** * Model selectable fields @@ -151,18 +156,18 @@ class Model implements Arrayable, protected $selectable = []; /** - * Model unselectable fields + * Document mapping type * - * @var string[] + * @var string|null */ - protected $unselectable = []; + protected $type = null; /** - * Indicates whether the model exists in the Elasticsearch index. + * Model unselectable fields * - * @var bool + * @var string[] */ - protected $exists = false; + protected $unselectable = []; /** * Create a new Elasticsearch model instance. @@ -171,7 +176,7 @@ class Model implements Arrayable, * models; additionally, it should actually rather be an assertion, as this * specific error should pop up in development. * Therefore, we've decided to inherit this from Eloquent, which simply does - * not add the throws annotation to their constructor. + * not add the `throws` annotation to their constructor. * * @param array $attributes * @param bool $exists @@ -200,156 +205,110 @@ final public function __construct( } /** - * Retrieves all model documents. - * - * @param string|null $scrollId - * - * @return Collection - */ - public static function all(?string $scrollId = null): Collection - { - return static::query()->get($scrollId); - } - - /** - * Retrieves a model by key. + * Get an attribute from the model. * * @param string $key * - * @return static|null - * @psalm-suppress MismatchingDocblockReturnType + * @return mixed + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException + * @noinspection PhpDeprecationInspection */ - public static function find(string $key): ?self + public function getAttribute(string $key) { - return static - ::query() - ->id($key) - ->take(1) - ->first(); - } + if ( ! $key) { + return null; + } - /** - * Retrieves a model by key or fails. - * - * @param string $key - * - * @return static - * @throws DocumentNotFoundException - * @psalm-suppress MismatchingDocblockReturnType - */ - public static function findOrFail(string $key): self - { - $result = static::find($key); + // If the attribute exists in the metadata array, we will get the value + // from there. + if (array_key_exists($key, $this->resultMetadata)) { + return $this->getResultMetadataValue($key); + } - if (is_null($result)) { - throw (new DocumentNotFoundException())->setModel( - static::class, - $key - ); + if ($key === '_index') { + return $this->getIndex(); } - return $result; + if ($key === '_type') { + return $this->getType(); + } + + if ($key === '_score') { + return $this->getScore(); + } + + // If the attribute exists in the attribute array or has a "get" mutator + // we will get the attribute's value. + if ( + array_key_exists($key, $this->attributes) || + array_key_exists($key, $this->casts) || + $this->hasGetMutator($key) || + $this->isClassCastable($key) + ) { + return $this->getAttributeValue($key); + } + + return null; } /** - * Save a new model and return the instance. - * - * @param array $attributes - * @param string|null $id + * Get all of the current attributes on the model. * - * @return static - * @psalm-suppress LessSpecificReturnStatement + * @return array */ - public static function create(array $attributes, ?string $id = null): self + public function getAttributes(): array { - $metadata = []; - if ( ! is_null($id)) { - $metadata['_id'] = $id; - } - - return tap( - (new static())->newInstance($attributes, $metadata), - static function ($instance) { - $instance->save(); - } - ); + return $this->attributes; } /** - * Destroy the models for the given IDs. - * - * @param BaseCollection|array|int|string $ids + * Get the casts array. * - * @return int + * @return array */ - public static function destroy($ids): int + public function getCasts(): array { - if ($ids instanceof BaseCollection) { - $ids = $ids->all(); - } - - $ids = is_array($ids) ? $ids : func_get_args(); - - if (count($ids) === 0) { - return 0; - } - - // We will actually pull the models from the index and call delete on - // each of them individually so that their events get fired properly - // with a correct set of attributes in case the developers wants to - // check these. - $count = 0; - $query = (new static()) - ->newQuery() - ->whereIn(self::FIELD_ID, $ids) - ->get(); - - foreach ($query as $model) { - if ($model->delete()) { - $count++; - } - } - - return $count; + return $this->casts; } /** - * Handle dynamic static method calls into the method. - * - * @param string $method - * @param array $parameters + * Get current connection name * - * @return mixed + * @return string + * @deprecated Use getConnectionName instead. This method will be changed in + * the next major version to return the connection instance + * instead. + * @see Model::getConnectionName() */ - public static function __callStatic(string $method, array $parameters) + #[Deprecated(replacement: '%class%->getConnectionName()')] + public function getConnection(): ?string { - return (new static())->$method(...$parameters); + return $this->getConnectionName(); } /** - * Begin querying the model. + * Get current connection name * - * @return Query + * @return string */ - public static function query(): Query + public function getConnectionName(): ?string { - return (new static())->newQuery(); + return $this->connectionName ?: null; } /** - * Resolve a connection instance. + * Set current connection name * - * @param string|null $connection + * @param string|null $connectionName * - * @return Connection - * @internal This method is used by the package during initialization to get - * the models to resolve the Elasticsearch connection. You won't - * need it during normal operation. It may change at any time. + * @return void */ - public static function resolveConnection( - ?string $connection = null - ): Connection { - return static::$resolver->connection($connection); + public function setConnectionName(?string $connectionName): void + { + $this->connectionName = $connectionName; } /** @@ -366,207 +325,161 @@ public static function getConnectionResolver(): Resolver } /** - * Set the connection resolver instance. - * - * @param Resolver $resolver + * Get the format for database stored dates. * - * @return void - * @internal This method is used by the package during initialization to get - * the models to resolve the Elasticsearch connection. You won't - * need it during normal operation. It may change at any time. + * @return string */ - public static function setConnectionResolver(Resolver $resolver): void + public function getDateFormat(): string { - static::$resolver = $resolver; + return $this->dateFormat ?: DATE_ATOM; } /** - * Unset the connection resolver for models. + * Retrieves the result highlights. * - * @return void - * @internal This method is used by the package during initialization to get - * the models to resolve the Elasticsearch connection. You won't - * need it during normal operation. It may change at any time. + * @return array|null + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException + * @internal */ - public static function unsetConnectionResolver(): void + public function getHighlight(): ?array { - static::$resolver = null; + return $this->getResultMetadataValue('highlight'); } /** - * Clear the list of booted models so they will be re-booted. + * Get field highlights * - * @return void - */ - public static function clearBootedModels(): void - { - static::$booted = []; - static::$globalScopes = []; - } + * @param string|null $field + * + * @return mixed + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException + */ + public function getHighlights(?string $field = null) + { + $highlights = $this->getAttribute('highlight'); + + if ($field && array_key_exists($field, $highlights)) { + return $highlights[$field]; + } + + return $highlights; + } /** - * Perform any actions required after the model boots. + * Retrieves the model key * - * @return void + * @return string|null + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException */ - protected static function booted(): void + public function getId(): ?string { - // + $id = $this->getAttribute(self::FIELD_ID); + + return $id ? (string)$id : null; } /** - * Perform any actions required before the model boots. + * Get index name * - * @return void + * @return string|null */ - protected static function booting(): void + public function getIndex(): ?string { - // + return $this->index; } /** - * Bootstrap the model and its traits. + * Set index name + * + * @param string|null $index * * @return void */ - protected static function boot(): void + public function setIndex(?string $index): void { - static::bootTraits(); + $this->index = $index; } /** - * Boot all of the bootable traits on the model. + * Get the value of the model's primary key. * - * @return void + * @return string|null + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException */ - protected static function bootTraits(): void + public function getKey(): ?string { - $class = static::class; - - $booted = []; - - static::$traitInitializers[$class] = []; - - foreach (class_uses_recursive($class) as $trait) { - $method = 'boot' . class_basename($trait); - - if ( - method_exists($class, $method) && - ! in_array($method, $booted, true) - ) { - forward_static_call([$class, $method]); - - $booted[] = $method; - } - - if (method_exists( - $class, - $method = 'initialize' . class_basename($trait) - )) { - static::$traitInitializers[$class][] = $method; - - static::$traitInitializers[$class] = array_unique( - static::$traitInitializers[$class] - ); - } - } + return $this->getAttribute(self::FIELD_ID); } /** - * Handle dynamic method calls into the model. - * - * @param string $method - * @param array $parameters - * - * @return mixed - * @throws BadMethodCallException + * @inheritDoc */ - public function __call(string $method, array $parameters) + public function getQueueableConnection(): ?string { - return $this->forwardCallTo( - $this->newQuery(), - $method, - $parameters - ); + return $this->getConnectionName(); } /** - * Fill the model with an array of attributes. - * - * @param array $attributes - * - * @return static - * + * @inheritDoc + * @return string|null * @throws DecryptException - * @throws EncryptException * @throws InvalidCastException * @throws InvalidFormatException - * @throws JsonEncodingException * @throws JsonException - * @throws MassAssignmentException */ - public function fill(array $attributes): self + public function getQueueableId(): ?string { - $totallyGuarded = $this->totallyGuarded(); - - foreach ($this->fillableFromArray($attributes) as $key => $value) { - // The developers may choose to place some attributes in the "fillable" array - // which means only those attributes may be set through mass assignment to - // the model, and all others will just get ignored for security reasons. - if ($this->isFillable($key)) { - $this->setAttribute($key, $value); - } elseif ($totallyGuarded) { - throw new MassAssignmentException(sprintf( - 'Add [%s] to fillable property to allow mass assignment on [%s].', - $key, get_class($this) - )); - } - } + return $this->getKey(); + } - return $this; + /** + * @inheritDoc + */ + public function getQueueableRelations(): array + { + // Elasticsearch does not implement the concept of relations + return []; } /** - * Fill the model with an array of attributes. Force mass assignment. - * - * @param array $attributes + * Retrieves result metadata retrieved from the query * - * @return static - * @throws DecryptException - * @throws EncryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonEncodingException - * @throws JsonException - * @throws MassAssignmentException + * @return array */ - public function forceFill(array $attributes): self + public function getResultMetadata(): array { - return static::unguarded(function () use ($attributes) { - return $this->fill($attributes); - }); + return $this->resultMetadata; } /** - * Determine if the given attribute exists. + * Sets the result metadata retrieved from the query. This is mainly useful + * during model hydration. * - * @param mixed $offset + * @param array $resultMetadata * - * @return bool - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException + * @internal */ - public function offsetExists($offset): bool + public function setResultMetadata(array $resultMetadata): void { - return ! is_null($this->getAttribute($offset)); + $this->resultMetadata = $resultMetadata; } /** - * Get the value for a given offset. + * Retrieves result metadata retrieved from the query * - * @param mixed $offset + * @param string $key * * @return mixed * @throws DecryptException @@ -574,371 +487,584 @@ public function offsetExists($offset): bool * @throws InvalidFormatException * @throws JsonException */ - public function offsetGet($offset) + public function getResultMetadataValue(string $key) { - return $this->getAttribute($offset); + return array_key_exists($key, $this->resultMetadata) + ? $this->transformModelValue($key, $this->resultMetadata[$key]) + : null; } /** - * Set the value for a given offset. - * - * @param mixed $offset - * @param mixed $value - * - * @return void + * @inheritDoc + * @return float|mixed|string|null * @throws DecryptException - * @throws EncryptException * @throws InvalidCastException * @throws InvalidFormatException - * @throws JsonEncodingException * @throws JsonException */ - public function offsetSet($offset, $value): void + public function getRouteKey() { - $this->setAttribute($offset, $value); + return $this->getAttribute($this->getRouteKeyName()); } /** - * Unset the value for a given offset. + * @inheritDoc + */ + public function getRouteKeyName(): string + { + return self::FIELD_ID; + } + + /** + * Retrieve the child model for a bound value. + * Elasticsearch does not support relations, so any resolution request will + * be proxied to the usual route binding resolution method. * - * @param mixed $offset + * @param string $childType + * @param mixed $value + * @param string|null $field * - * @return void + * @return Model|null + * @throws InvalidArgumentException + * @psalm-suppress ImplementedReturnTypeMismatch */ - public function offsetUnset($offset): void - { - unset($this->attributes[$offset]); + final public function resolveChildRouteBinding( + $childType, + $value, + $field = null + ): ?self { + return $this->resolveRouteBinding($value, $field); } /** - * Magic getter for model properties + * Resolves a route binding to a model instance. Note that the interface + * specifies Eloquent models in its documentation comment, + * a rather short-sighted decision. + * Route bindings using Elasticsearch models should work fine regardless. * - * @param string $name + * @param mixed $value + * @param string|null $field * - * @return mixed|null + * @return Model|null + * @throws InvalidArgumentException + * @psalm-suppress ImplementedReturnTypeMismatch + */ + public function resolveRouteBinding($value, $field = null): ?self + { + return $this + ->newQuery() + ->firstWhere( + $field ?? $this->getRouteKeyName(), + $value + ); + } + + /** + * Retrieves the result score. + * + * @return float|null * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException + * @internal */ - public function __get(string $name) + public function getScore(): ?float { - return $this->getAttribute($name); + return $this->getResultMetadataValue('_score'); } /** - * Handle model properties setter + * Get selectable fields * - * @param string $name - * @param $value + * @return array + */ + public function getSelectable(): array + { + return $this->selectable ?: []; + } + + /** + * Retrieves the document mapping type. + * + * @return string|null + * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html + */ + #[Deprecated('Mapping types are deprecated as of Elasticsearch 7.0.0')] + public function getType(): ?string + { + return $this->type; + } + + /** + * Sets the document mapping type. + * + * @param string|null $type * * @return void - * @throws DecryptException - * @throws EncryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonEncodingException - * @throws JsonException + * @deprecated Mapping types are deprecated as of Elasticsearch 7.0.0 + * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html */ - public function __set(string $name, $value): void + #[Deprecated(reason: 'Mapping types are deprecated as of Elasticsearch 7.0.0')] + public function setType(?string $type): void { - $this->setAttribute($name, $value); + $this->type = $type; } /** - * Determine if an attribute exists on the model. + * Get selectable fields + * + * @return array + */ + public function getUnSelectable(): array + { + return $this->unselectable ?: []; + } + + /** + * Set a given attribute on the model. * * @param string $key + * @param mixed $value * - * @return bool + * @return mixed * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException + * @throws EncryptException + * @throws JsonEncodingException */ - public function __isset(string $key): bool + public function setAttribute(string $key, $value) { - if ($key === self::FIELD_ID) { - return isset($this->_id); + // First we will check for the presence of a mutator for the set + // operation which simply lets the developers tweak the attribute as it + // is set on the model, such as "json_encoding" an listing of data + // for storage. + if ($this->hasSetMutator($key)) { + return $this->setMutatedAttributeValue($key, $value); } - return $this->offsetExists($key); + if ($value && $this->isDateAttribute($key)) { + $value = $this->fromDateTime($value); + } + + // If an attribute is listed as a "date", we'll convert it from a + // DateTime instance into a form proper for storage on the index. + // We will auto set the values. + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + + return $this; + } + + if ( ! is_null($value) && $this->isJsonCastable($key)) { + $value = $this->castAttributeAsJson($key, $value); + } + + // If this attribute contains a JSON ->, we'll set the proper value in + // the attribute's underlying array. This takes care of properly nesting + // an attribute in the array's value in the case of deeply nested items. + if (Str::contains($key, '->')) { + return $this->fillJsonAttribute($key, $value); + } + + if ( ! is_null($value) && $this->isEncryptedCastable($key)) { + $value = $this->castAttributeAsEncryptedString($key, $value); + } + + $this->attributes[$key] = $value; + + return $this; } /** - * Unset an attribute on the model. + * Set current connection name * - * @param string $key + * @param string $connectionName * * @return void + * @deprecated Use setConnectionName instead. This method will be removed in + * the next major version. + * @see Model::setConnectionName() */ - public function __unset(string $key) + #[Deprecated(replacement: '%class%->setConnectionName(%parameter0%)')] + public function setConnection(string $connectionName): void { - $this->offsetUnset($key); + $this->setConnectionName($connectionName); } /** - * Get model as array + * Set the connection resolver instance. * - * @return array - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException + * @param Resolver $resolver + * + * @return void + * @internal This method is used by the package during initialization to get + * the models to resolve the Elasticsearch connection. You won't + * need it during normal operation. It may change at any time. */ - public function toArray(): array + public static function setConnectionResolver(Resolver $resolver): void { - return $this->attributesToArray(); + static::$resolver = $resolver; } /** - * Convert the model to a JSON string. + * Set the date format used by the model. * - * @param int $options + * @param string $format * - * @return string - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException + * @return static */ - public function toJson($options = 0): string + public function setDateFormat(string $format): self { - return json_encode( - $this->jsonSerialize(), - JSON_THROW_ON_ERROR | $options - ); + $this->dateFormat = $format; + + return $this; } /** - * @inheritDoc - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException + * Handle dynamic static method calls into the method. + * + * @param string $method + * @param array $parameters + * + * @return mixed */ - public function jsonSerialize(): array + public static function __callStatic(string $method, array $parameters) { - return $this->toArray(); + return (new static())->$method(...$parameters); } /** - * Get current connection name + * Retrieves all model documents. * - * @return string - * @deprecated Use getConnectionName instead. This method will be changed in - * the next major version to return the connection instance - * instead. - * @see Model::getConnectionName() + * @param string|null $scrollId + * + * @return Collection */ - public function getConnection(): ?string + public static function all(?string $scrollId = null): Collection { - return $this->getConnectionName(); + return static::query()->get($scrollId); + } + + /** + * Clear the list of booted models so they will be re-booted. + * + * @return void + */ + public static function clearBootedModels(): void + { + static::$booted = []; + static::$globalScopes = []; } - /** - * Get current connection name - * - * @return string - */ - public function getConnectionName(): ?string - { - return $this->connectionName ?: null; + /** + * Save a new model and return the instance. + * + * @param array $attributes + * @param string|null $id + * + * @return static + * @psalm-suppress LessSpecificReturnStatement + */ + public static function create(array $attributes, ?string $id = null): self + { + $metadata = []; + if ( ! is_null($id)) { + $metadata['_id'] = $id; + } + + return tap( + (new static())->newInstance($attributes, $metadata), + static function ($instance) { + $instance->save(); + } + ); + } + + /** + * Destroy the models for the given IDs. + * + * @param BaseCollection|array|int|string $ids + * + * @return int + */ + public static function destroy($ids): int + { + if ($ids instanceof BaseCollection) { + $ids = $ids->all(); + } + + $ids = is_array($ids) ? $ids : func_get_args(); + + if (count($ids) === 0) { + return 0; + } + + // We will actually pull the models from the index and call delete on + // each of them individually so that their events get fired properly + // with a correct set of attributes in case the developers wants to + // check these. + $count = 0; + $query = (new static()) + ->newQuery() + ->whereIn(self::FIELD_ID, $ids) + ->get(); + + foreach ($query as $model) { + if ($model->delete()) { + $count++; + } + } + + return $count; } /** - * Set current connection name + * Retrieves a model by key. * - * @param string|null $connectionName + * @param string $key * - * @return void + * @return static|null + * @psalm-suppress MismatchingDocblockReturnType */ - public function setConnectionName(?string $connectionName): void + public static function find(string $key): ?self { - $this->connectionName = $connectionName; + return static + ::query() + ->id($key) + ->take(1) + ->first(); } /** - * Set current connection name + * Retrieves a model by key or fails. * - * @param string $connectionName + * @param string $key * - * @return void - * @deprecated Use setConnectionName instead. This method will be removed in - * the next major version. - * @see Model::setConnectionName() + * @return static + * @throws DocumentNotFoundException + * @psalm-suppress MismatchingDocblockReturnType */ - public function setConnection(string $connectionName): void + public static function findOrFail(string $key): self { - $this->setConnectionName($connectionName); + $result = static::find($key); + + if (is_null($result)) { + throw (new DocumentNotFoundException())->setModel( + static::class, + $key + ); + } + + return $result; } /** - * Get index name + * Begin querying the model. * - * @return string|null + * @return Query */ - public function getIndex(): ?string + public static function query(): Query { - return $this->index; + return (new static())->newQuery(); } /** - * Set index name + * Resolve a connection instance. * - * @param string|null $index + * @param string|null $connection * - * @return void + * @return Connection + * @internal This method is used by the package during initialization to get + * the models to resolve the Elasticsearch connection. You won't + * need it during normal operation. It may change at any time. */ - public function setIndex(?string $index): void - { - $this->index = $index; + public static function resolveConnection( + ?string $connection = null + ): Connection { + return static::$resolver->connection($connection); } /** - * Get selectable fields + * Unset the connection resolver for models. * - * @return array + * @return void + * @internal This method is used by the package during initialization to get + * the models to resolve the Elasticsearch connection. You won't + * need it during normal operation. It may change at any time. */ - public function getSelectable(): array + public static function unsetConnectionResolver(): void { - return $this->selectable ?: []; + static::$resolver = null; } /** - * Get selectable fields + * Bootstrap the model and its traits. * - * @return array + * @return void */ - public function getUnSelectable(): array + protected static function boot(): void { - return $this->unselectable ?: []; + static::bootTraits(); } /** - * Retrieves the document mapping type. + * Boot all bootable traits on the model. * - * @return string|null - * @deprecated Mapping types are deprecated as of Elasticsearch 6.0.0 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html + * @return void */ - public function getType(): ?string + protected static function bootTraits(): void { - return $this->type; + $class = static::class; + $booted = []; + + static::$traitInitializers[$class] = []; + + foreach (class_uses_recursive($class) as $trait) { + $method = 'boot' . class_basename($trait); + + if ( + method_exists($class, $method) && + ! in_array($method, $booted, true) + ) { + forward_static_call([$class, $method]); + + $booted[] = $method; + } + + if (method_exists( + $class, + $method = 'initialize' . class_basename($trait) + )) { + /** @noinspection UnsupportedStringOffsetOperationsInspection */ + static::$traitInitializers[$class][] = $method; + + static::$traitInitializers[$class] = array_unique( + static::$traitInitializers[$class] + ); + } + } } /** - * Sets the document mapping type. - * - * @param string|null $type + * Perform any actions required after the model boots. * * @return void - * @deprecated Mapping types are deprecated as of Elasticsearch 6.0.0 - * @see https://www.elastic.co/guide/en/elasticsearch/reference/7.10/removal-of-types.html */ - public function setType(?string $type): void + protected static function booted(): void { - $this->type = $type; + // } /** - * Create a new instance of the given model. - * This method just provides a convenient way for us to generate fresh - * model instances of this current model. It is particularly useful during - * the hydration of new objects via the Query instance. - * - * @param array $attributes Model attributes - * @param array $metadata Query result metadata - * @param bool $exists Whether the document exists - * @param string|null $index Name of the index the document lives in - * @param string|null $type (Deprecated) Mapping type of the document + * Perform any actions required before the model boots. * - * @return static - * @noinspection PhpDeprecationInspection + * @return void */ - public function newInstance( - array $attributes = [], - array $metadata = [], - bool $exists = false, - ?string $index = null, - ?string $type = null - ): self { - $model = new static([], $exists); - - $model->setRawAttributes($attributes, true); - $model->setConnectionName($this->getConnectionName()); - $model->setResultMetadata($metadata); - $model->setIndex($index ?? $this->getIndex()); - $model->setType($type ?? $this->getType()); - $model->mergeCasts($this->casts); - - $model->fireModelEvent('retrieved', false); - - return $model; + protected static function booting(): void + { + // } /** - * Creates a new collection instance. + * Handle dynamic method calls into the model. * - * @param static[] $models + * @param string $method + * @param array $parameters * - * @return Collection + * @return mixed + * @throws BadMethodCallException */ - public function newCollection(array $models = []): Collection + public function __call(string $method, array $parameters) { - return new Collection($models); + return $this->forwardCallTo( + $this->newQuery(), + $method, + $parameters + ); } /** - * Retrieves the result score. + * Magic getter for model properties * - * @return float|null + * @param string $name + * + * @return mixed|null * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException - * @internal */ - public function getScore(): ?float + public function __get(string $name) { - return $this->getResultMetadataValue('_score'); + return $this->getAttribute($name); } /** - * Retrieves the result highlights. + * Handle model properties setter * - * @return array|null + * @param string $name + * @param $value + * + * @return void * @throws DecryptException + * @throws EncryptException * @throws InvalidCastException * @throws InvalidFormatException + * @throws JsonEncodingException * @throws JsonException - * @internal */ - public function getHighlight(): ?array + public function __set(string $name, $value): void { - return $this->getResultMetadataValue('highlight'); + $this->setAttribute($name, $value); } /** - * Get field highlights + * Determine if an attribute exists on the model. * - * @param string|null $field + * @param string $key * - * @return mixed + * @return bool * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException */ - public function getHighlights($field = null) + public function __isset(string $key): bool { - $highlights = $this->getAttribute('highlight'); - - if ($field && array_key_exists($field, $highlights)) { - return $highlights[$field]; + if ($key === self::FIELD_ID) { + return isset($this->_id); } - return $highlights; + return $this->offsetExists($key); + } + + /** + * Unset an attribute on the model. + * + * @param string $key + * + * @return void + */ + public function __unset(string $key) + { + $this->offsetUnset($key); + } + + /** + * Apply the given named scope if possible. + * + * @param string $scope + * @param array $parameters + * + * @return mixed + */ + public function callNamedScope(string $scope, array $parameters = []) + { + return $this->{'scope' . ucfirst($scope)}(...$parameters); } /** @@ -974,56 +1100,55 @@ public function delete(): void } /** - * Save the model to the index. + * Check model is exists + * + * @return bool + */ + public function exists(): bool + { + return $this->exists; + } + + /** + * Fill the model with an array of attributes. + * + * @param array $attributes * * @return static + * * @throws DecryptException * @throws EncryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonEncodingException * @throws JsonException + * @throws MassAssignmentException */ - public function save(): self + public function fill(array $attributes): self { - $this->mergeAttributesFromClassCasts(); - - $query = $this->newQuery(); - - // If the "saving" event returns false we'll bail out of the save and - // return false, indicating that the save failed. This provides a chance - // for any listeners to cancel save operations if validations fail - // or whatever. - if ($this->fireModelEvent('saving') === false) { - return $this; - } - - // If the model already exists in the index we can just update our - // record that is already in this index using the current ID to only - // update this model. Otherwise, we'll just insert it. - if ($this->exists) { - $saved = ! $this->isDirty() || $this->performUpdate($query); - } - - // If the model is brand new, we'll insert it into our index and set the - // ID attribute on the model to the value of the newly inserted ID. - else { - $saved = $this->performInsert($query); - } + $totallyGuarded = $this->totallyGuarded(); - // If the model is successfully saved, we need to do a few more things - // once that is done. We will call the "saved" method here to run any - // actions we need to happen after a model gets successfully saved - // right here. - if ($saved) { - $this->finishSave(); + foreach ($this->fillableFromArray($attributes) as $key => $value) { + // The developers may choose to place some attributes in the "fillable" array + // which means only those attributes may be set through mass assignment to + // the model, and all others will just get ignored for security reasons. + if ($this->isFillable($key)) { + $this->setAttribute($key, $value); + } elseif ($totallyGuarded) { + throw new MassAssignmentException(sprintf( + 'Add [%s] to fillable property to allow mass assignment on [%s].', + $key, get_class($this) + )); + } } return $this; } /** - * Save the model to the index without raising any events. + * Fill the model with an array of attributes. Force mass assignment. + * + * @param array $attributes * * @return static * @throws DecryptException @@ -1032,155 +1157,125 @@ public function save(): self * @throws InvalidFormatException * @throws JsonEncodingException * @throws JsonException + * @throws MassAssignmentException */ - public function saveQuietly(): self + public function forceFill(array $attributes): self { - return static::withoutEvents(function () { - return $this->save(); + return static::unguarded(function () use ($attributes) { + return $this->fill($attributes); }); } /** - * Check model is exists + * Determine if the model has a given scope. + * + * @param string $scope * * @return bool */ - public function exists(): bool + public function hasNamedScope(string $scope): bool { - return $this->exists; + return method_exists( + $this, + 'scope' . ucfirst($scope) + ); } /** - * Retrieves the model key + * Determine if two models have the same ID and belong to the same table. * - * @return string|null - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException - */ - public function getId(): ?string - { - $id = $this->getAttribute(self::FIELD_ID); - - return $id ? (string)$id : null; - } - - /** - * Get the value of the model's primary key. + * @param static|null $model * - * @return string|null + * @return bool * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException + * @noinspection PhpDeprecationInspection */ - public function getKey(): ?string - { - return $this->getAttribute(self::FIELD_ID); - } - - /** - * @inheritDoc - */ - public function getQueueableConnection(): ?string + public function is(?self $model): bool { - return $this->getConnectionName(); + return ! is_null($model) && + $this->getId() === $model->getId() && + $this->getType() === $model->getType() && + $this->getIndex() === $model->getIndex() && + $this->getConnectionName() === $model->getConnectionName(); } /** - * @inheritDoc - * @return string|null + * Determine if two models are not the same. + * + * @param static|null $model + * + * @return bool * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException */ - public function getQueueableId(): ?string - { - return $this->getKey(); - } - - /** - * @inheritDoc - */ - public function getQueueableRelations(): array + public function isNot(?self $model): bool { - // Elasticsearch does not implement the concept of relations - return []; + return ! $this->is($model); } /** * @inheritDoc - * @return float|mixed|string|null * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException */ - public function getRouteKey() - { - return $this->getAttribute($this->getRouteKeyName()); - } - - /** - * @inheritDoc - */ - public function getRouteKeyName(): string + public function jsonSerialize(): array { - return self::FIELD_ID; + return $this->toArray(); } /** - * Retrieve the child model for a bound value. - * Elasticsearch does not support relations, so any resolution request will - * be proxied to the usual route binding resolution method. + * Creates a new collection instance. * - * @param string $childType - * @param mixed $value - * @param string|null $field + * @param static[] $models * - * @return Model|null - * @psalm-suppress ImplementedReturnTypeMismatch + * @return Collection */ - final public function resolveChildRouteBinding( - $childType, - $value, - $field = null - ): ?self { - return $this->resolveRouteBinding($value, $field); + public function newCollection(array $models = []): Collection + { + return new Collection($models); } /** - * Resolves a route binding to a model instance. Note that the interface - * specifies Eloquent models in its documentation comment, a rather short - * sighted decision. - * Route bindings using Elasticsearch models should work fine regardless. + * Create a new instance of the given model. + * This method just provides a convenient way for us to generate fresh + * model instances of this current model. It is particularly useful during + * the hydration of new objects via the Query instance. * - * @param mixed $value - * @param string|null $field + * @param array $attributes Model attributes + * @param array $metadata Query result metadata + * @param bool $exists Whether the document exists + * @param string|null $index Name of the index the document lives in + * @param string|null $type (Deprecated) Mapping type of the document * - * @return Model|null - * @psalm-suppress ImplementedReturnTypeMismatch + * @return static + * @noinspection PhpDeprecationInspection */ - public function resolveRouteBinding($value, $field = null): ?self - { - return $this - ->newQuery() - ->firstWhere( - $field ?? $this->getRouteKeyName(), - $value - ); - } + public function newInstance( + array $attributes = [], + array $metadata = [], + bool $exists = false, + ?string $index = null, + ?string $type = null + ): self { + $model = new static([], $exists); - /** - * Determine if the model uses timestamps. - * - * @return bool - */ - final public function usesTimestamps(): bool - { - return false; + $model->setRawAttributes($attributes, true); + $model->setConnectionName($this->getConnectionName()); + $model->setResultMetadata($metadata); + $model->setIndex($index ?? $this->getIndex()); + $model->setType($type ?? $this->getType()); + $model->mergeCasts($this->casts); + + $model->fireModelEvent('retrieved', false); + + return $model; } /** @@ -1215,47 +1310,83 @@ public function newQuery(): Query } /** - * Register the global scopes for this builder instance. + * Determine if the given attribute exists. + * + * @param mixed $offset + * + * @return bool + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException + */ + public function offsetExists($offset): bool + { + return ! is_null($this->getAttribute($offset)); + } + + /** + * Get the value for a given offset. + * + * @param mixed $offset + * + * @return mixed + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException + */ + #[ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->getAttribute($offset); + } + + /** + * Set the value for a given offset. * - * @param Query $query + * @param mixed $offset + * @param mixed $value * - * @return Query + * @return void + * @throws DecryptException + * @throws EncryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonEncodingException + * @throws JsonException */ - public function registerGlobalScopes(Query $query): Query + public function offsetSet($offset, $value): void { - foreach ($this->getGlobalScopes() as $identifier => $scope) { - $query->withGlobalScope($identifier, $scope); - } - - return $query; + $this->setAttribute($offset, $value); } /** - * Determine if the model has a given scope. + * Unset the value for a given offset. * - * @param string $scope + * @param mixed $offset * - * @return bool + * @return void */ - public function hasNamedScope(string $scope): bool + public function offsetUnset($offset): void { - return method_exists( - $this, - 'scope' . ucfirst($scope) - ); + unset($this->attributes[$offset]); } /** - * Apply the given named scope if possible. + * Register the global scopes for this builder instance. * - * @param string $scope - * @param array $parameters + * @param Query $query * - * @return mixed + * @return Query */ - public function callNamedScope(string $scope, array $parameters = []) + public function registerGlobalScopes(Query $query): Query { - return $this->{'scope' . ucfirst($scope)}(...$parameters); + foreach ($this->getGlobalScopes() as $identifier => $scope) { + $query->withGlobalScope($identifier, $scope); + } + + return $query; } /** @@ -1285,263 +1416,210 @@ public function replicate(array $except = null): self } /** - * Determine if two models have the same ID and belong to the same table. - * - * @param static|null $model - * - * @return bool - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException - * @noinspection PhpDeprecationInspection - */ - public function is(?self $model): bool - { - return ! is_null($model) && - $this->getId() === $model->getId() && - $this->getType() === $model->getType() && - $this->getIndex() === $model->getIndex() && - $this->getConnectionName() === $model->getConnectionName(); - } - - /** - * Determine if two models are not the same. - * - * @param static|null $model - * - * @return bool - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException - */ - public function isNot(?self $model): bool - { - return ! $this->is($model); - } - - /** - * Get an attribute from the model. - * - * @param string $key + * Save the model to the index. * - * @return mixed + * @return static * @throws DecryptException + * @throws EncryptException * @throws InvalidCastException * @throws InvalidFormatException + * @throws JsonEncodingException * @throws JsonException - * @noinspection PhpDeprecationInspection */ - public function getAttribute(string $key) + public function save(): self { - if ( ! $key) { - return null; - } + $this->mergeAttributesFromClassCasts(); - // If the attribute exists in the metadata array, we will get the value - // from there. - if (array_key_exists($key, $this->resultMetadata)) { - return $this->getResultMetadataValue($key); - } + $query = $this->newQuery(); - if ($key === '_index') { - return $this->getIndex(); + // If the "saving" event returns false we'll bail out of the save and + // return false, indicating that the save failed. This provides a chance + // for any listeners to cancel save operations if validations fail + // or whatever. + if ($this->fireModelEvent('saving') === false) { + return $this; } - if ($key === '_type') { - return $this->getType(); + // If the model already exists in the index we can just update our + // record that is already in this index using the current ID to only + // update this model. Otherwise, we'll just insert it. + if ($this->exists) { + $saved = ! $this->isDirty() || $this->performUpdate($query); } - if ($key === '_score') { - return $this->getScore(); + // If the model is brand new, we'll insert it into our index and set the + // ID attribute on the model to the value of the newly inserted ID. + else { + $saved = $this->performInsert($query); } - // If the attribute exists in the attribute array or has a "get" mutator - // we will get the attribute's value. - if ( - array_key_exists($key, $this->attributes) || - array_key_exists($key, $this->casts) || - $this->hasGetMutator($key) || - $this->isClassCastable($key) - ) { - return $this->getAttributeValue($key); + // If the model is successfully saved, we need to do a few more things + // once that is done. We will call the "saved" method here to run any + // actions we need to happen after a model gets successfully saved + // right here. + if ($saved) { + $this->finishSave(); } - return null; + return $this; } /** - * Get all of the current attributes on the model. + * Save the model to the index without raising any events. * - * @return array + * @return static + * @throws DecryptException + * @throws EncryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonEncodingException + * @throws JsonException */ - public function getAttributes(): array + public function saveQuietly(): self { - return $this->attributes; + return static::withoutEvents(function () { + return $this->save(); + }); } /** - * Set a given attribute on the model. - * - * @param string $key - * @param mixed $value + * Get model as array * - * @return mixed + * @return array * @throws DecryptException * @throws InvalidCastException * @throws InvalidFormatException * @throws JsonException - * @throws EncryptException - * @throws JsonEncodingException */ - public function setAttribute(string $key, $value) + public function toArray(): array { - // First we will check for the presence of a mutator for the set - // operation which simply lets the developers tweak the attribute as it - // is set on the model, such as "json_encoding" an listing of data - // for storage. - if ($this->hasSetMutator($key)) { - return $this->setMutatedAttributeValue($key, $value); - } - - if ($value && $this->isDateAttribute($key)) { - $value = $this->fromDateTime($value); - } - - // If an attribute is listed as a "date", we'll convert it from a - // DateTime instance into a form proper for storage on the index. - // We will auto set the values. - if ($this->isClassCastable($key)) { - $this->setClassCastableAttribute($key, $value); - - return $this; - } - - if ( ! is_null($value) && $this->isJsonCastable($key)) { - $value = $this->castAttributeAsJson($key, $value); - } - - // If this attribute contains a JSON ->, we'll set the proper value in - // the attribute's underlying array. This takes care of properly nesting - // an attribute in the array's value in the case of deeply nested items. - if (Str::contains($key, '->')) { - return $this->fillJsonAttribute($key, $value); - } - - if ( ! is_null($value) && $this->isEncryptedCastable($key)) { - $value = $this->castAttributeAsEncryptedString($key, $value); - } - - $this->attributes[$key] = $value; - - return $this; + return $this->attributesToArray(); } /** - * Get the casts array. + * Convert the model to a JSON string. * - * @return array + * @param int $options + * + * @return string + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException */ - public function getCasts(): array + public function toJson($options = 0): string { - return $this->casts; + return json_encode( + $this->jsonSerialize(), + JSON_THROW_ON_ERROR | $options + ); } /** - * Get the format for database stored dates. + * Determine if the model uses timestamps. * - * @return string + * @return bool */ - public function getDateFormat(): string + final public function usesTimestamps(): bool { - return $this->dateFormat ?: DATE_ATOM; + return false; } /** - * Set the date format used by the model. - * - * @param string $format + * Check if the model needs to be booted and if so, do it. * - * @return static + * @return void */ - public function setDateFormat(string $format): self + protected function bootIfNotBooted(): void { - $this->dateFormat = $format; + if ( ! isset(static::$booted[static::class])) { + static::$booted[static::class] = true; + + $this->fireModelEvent('booting', false); + + static::booting(); + static::boot(); + static::booted(); - return $this; + $this->fireModelEvent('booted', false); + } } /** - * Retrieves result metadata retrieved from the query + * Perform any actions that are necessary after the model is saved. * - * @return array + * @return void */ - public function getResultMetadata(): array + protected function finishSave(): void { - return $this->resultMetadata; + $this->fireModelEvent('saved', false); + + $this->syncOriginal(); } /** - * Sets the result metadata retrieved from the query. This is mainly useful - * during model hydration. - * - * @param array $resultMetadata + * Get the primary key value for a save query. * - * @internal + * @return string + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException */ - public function setResultMetadata(array $resultMetadata): void + protected function getKeyForSaveQuery(): ?string { - $this->resultMetadata = $resultMetadata; + return $this->original[self::FIELD_ID] ?? $this->getKey(); } /** - * Retrieves result metadata retrieved from the query - * - * @param string $key + * Initialize any initializable traits on the model. * - * @return mixed - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException + * @return void */ - public function getResultMetadataValue(string $key) + protected function initializeTraits(): void { - return array_key_exists($key, $this->resultMetadata) - ? $this->transformModelValue($key, $this->resultMetadata[$key]) - : null; + foreach (static::$traitInitializers[static::class] as $method) { + $this->{$method}(); + } } /** - * Transform a raw model value using mutators, casts, etc. + * Insert the given attributes and set the ID on the model. * - * @param string $key - * @param mixed $value + * @param Query $query + * @param array $attributes * - * @return mixed + * @return void + * @throws DecryptException + * @throws EncryptException * @throws InvalidCastException - * @throws JsonException * @throws InvalidFormatException - * @throws DecryptException + * @throws JsonEncodingException + * @throws JsonException */ - protected function transformModelValue(string $key, $value) + protected function insertAndSetId(Query $query, array $attributes): void { - // If the attribute has a get mutator, we will call that, then return - // what it returns as the value, which is useful for transforming values - // on retrieval from the model to a form that is more useful for usage. - if ($this->hasGetMutator($key)) { - return $this->mutateAttribute($key, $value); + $result = $query->insert($attributes); + + if (isset($result->_index)) { + $this->setIndex($result->_index); } - // If the attribute exists within the cast array, we will convert it to - // an appropriate native PHP type dependent upon the associated value - // given with the key in the pair. Dayle made this comment line up. - if ($this->hasCast($key)) { - return $this->castAttribute($key, $value); + if (isset($result->_type)) { + $this->setAttribute('_type', $result->_type); } - return $value; + $this->setAttribute(self::FIELD_ID, $result->_id); + } + + /** + * Get a new query builder instance for the connection. + */ + protected function newQueryBuilder(): Query + { + return static + ::resolveConnection($this->getConnectionName()) + ->newQuery(); } /** @@ -1560,18 +1638,6 @@ protected function performDeleteOnModel(): void $this->exists = false; } - /** - * Perform any actions that are necessary after the model is saved. - * - * @return void - */ - protected function finishSave(): void - { - $this->fireModelEvent('saved', false); - - $this->syncOriginal(); - } - /** * Perform a model insert operation. * @@ -1617,35 +1683,6 @@ protected function performInsert(Query $query): bool return true; } - /** - * Insert the given attributes and set the ID on the model. - * - * @param Query $query - * @param array $attributes - * - * @return void - * @throws DecryptException - * @throws EncryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonEncodingException - * @throws JsonException - */ - protected function insertAndSetId(Query $query, array $attributes): void - { - $result = $query->insert($attributes); - - if (isset($result->_index)) { - $this->setIndex($result->_index); - } - - if (isset($result->_type)) { - $this->setAttribute('_type', $result->_type); - } - - $this->setAttribute(self::FIELD_ID, $result->_id); - } - /** * Perform a model update operation. * @@ -1687,80 +1724,6 @@ protected function performUpdate(Query $query): bool return true; } - /** - * Set the keys for a save update query. - * - * @param Query $query - * - * @return Query - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException - */ - protected function setKeysForSaveQuery(Query $query): Query - { - $query->id($this->getKeyForSaveQuery()); - - return $query; - } - - /** - * Get the primary key value for a save query. - * - * @return string - * @throws DecryptException - * @throws InvalidCastException - * @throws InvalidFormatException - * @throws JsonException - */ - protected function getKeyForSaveQuery(): ?string - { - return $this->original[self::FIELD_ID] ?? $this->getKey(); - } - - /** - * Initialize any initializable traits on the model. - * - * @return void - */ - protected function initializeTraits(): void - { - foreach (static::$traitInitializers[static::class] as $method) { - $this->{$method}(); - } - } - - /** - * Check if the model needs to be booted and if so, do it. - * - * @return void - */ - protected function bootIfNotBooted(): void - { - if ( ! isset(static::$booted[static::class])) { - static::$booted[static::class] = true; - - $this->fireModelEvent('booting', false); - - static::booting(); - static::boot(); - static::booted(); - - $this->fireModelEvent('booted', false); - } - } - - /** - * Get a new query builder instance for the connection. - */ - protected function newQueryBuilder(): Query - { - return static - ::resolveConnection($this->getConnectionName()) - ->newQuery(); - } - /** * Set attributes casting * @@ -1770,6 +1733,7 @@ protected function newQueryBuilder(): Query * @return mixed * @deprecated This method will be removed in the next major version. */ + #[Deprecated('This method will be removed in the next major version.')] protected function setAttributeType(string $name, $value) { $castTypes = [ @@ -1798,4 +1762,53 @@ protected function setAttributeType(string $name, $value) return $value; } + + /** + * Set the keys for a save update query. + * + * @param Query $query + * + * @return Query + * @throws DecryptException + * @throws InvalidCastException + * @throws InvalidFormatException + * @throws JsonException + */ + protected function setKeysForSaveQuery(Query $query): Query + { + $query->id($this->getKeyForSaveQuery()); + + return $query; + } + + /** + * Transform a raw model value using mutators, casts, etc. + * + * @param string $key + * @param mixed $value + * + * @return mixed + * @throws InvalidCastException + * @throws JsonException + * @throws InvalidFormatException + * @throws DecryptException + */ + protected function transformModelValue(string $key, $value) + { + // If the attribute has a get mutator, we will call that, then return + // what it returns as the value, which is useful for transforming values + // on retrieval from the model to a form that is more useful for usage. + if ($this->hasGetMutator($key)) { + return $this->mutateAttribute($key, $value); + } + + // If the attribute exists within the cast array, we will convert it to + // an appropriate native PHP type dependent upon the associated value + // given with the key in the pair. Dayle made this comment line up. + if ($this->hasCast($key)) { + return $this->castAttribute($key, $value); + } + + return $value; + } } diff --git a/src/Query.php b/src/Query.php index 9b83ecf..58b4628 100755 --- a/src/Query.php +++ b/src/Query.php @@ -11,6 +11,7 @@ use Illuminate\Contracts\Support\Jsonable; use Illuminate\Support\Traits\ForwardsCalls; use IteratorAggregate; +use JetBrains\PhpStorm\Deprecated; use JsonException; use JsonSerializable; use Matchory\Elasticsearch\Concerns\AppliesScopes; @@ -43,8 +44,6 @@ class Query implements Arrayable, JsonSerializable, Jsonable, IteratorAggregate use ManagesIndices; use ExplainsQueries; - public const FIELD_AGGS = 'aggs'; - public const DEFAULT_CACHE_PREFIX = 'es'; public const DEFAULT_LIMIT = 10; @@ -55,6 +54,8 @@ class Query implements Arrayable, JsonSerializable, Jsonable, IteratorAggregate public const EXISTS = self::OPERATOR_EXISTS; + public const FIELD_AGGS = 'aggs'; + protected const FIELD_HIGHLIGHT = '_highlight'; protected const FIELD_HITS = 'hits'; @@ -93,16 +94,6 @@ class Query implements Arrayable, JsonSerializable, Jsonable, IteratorAggregate public const OPERATOR_GREATER_THAN = '>'; - public const REGEXP_FLAG_ALL = 1; - - public const REGEXP_FLAG_COMPLEMENT = 2; - - public const REGEXP_FLAG_INTERVAL = 4; - - public const REGEXP_FLAG_INTERSECTION = 8; - - public const REGEXP_FLAG_ANYSTRING = 16; - public const OPERATOR_GREATER_THAN_OR_EQUAL = '>='; public const OPERATOR_LIKE = 'like'; @@ -133,6 +124,16 @@ class Query implements Arrayable, JsonSerializable, Jsonable, IteratorAggregate public const PARAM_TYPE = 'type'; + public const REGEXP_FLAG_ALL = 1; + + public const REGEXP_FLAG_ANYSTRING = 16; + + public const REGEXP_FLAG_COMPLEMENT = 2; + + public const REGEXP_FLAG_INTERSECTION = 8; + + public const REGEXP_FLAG_INTERVAL = 4; + public const SOURCE_EXCLUDES = 'excludes'; public const SOURCE_INCLUDES = 'includes'; @@ -148,6 +149,7 @@ class Query implements Arrayable, JsonSerializable, Jsonable, IteratorAggregate * @see ConnectionInterface::getClient() * @see Query::getConnection() */ + #[Deprecated(replacement: '%class%->getConnection()->getClient()')] public $client = null; /** @@ -157,6 +159,7 @@ class Query implements Arrayable, JsonSerializable, Jsonable, IteratorAggregate * @deprecated Use getModel() instead * @see Query::getModel() */ + #[Deprecated(replacement: '%class%->getModel()')] public $model; /** @@ -185,20 +188,6 @@ public function __construct(ConnectionInterface $connection) $this->setModel(new Model()); } - /** - * Retrieves the underlying Elasticsearch client instance. This can be used - * to work with the Elasticsearch library directly. You should check out its - * documentation for more information. - * - * @return Client Elasticsearch Client instance. - * @see https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/overview.html - * @see Client - */ - public function raw(): Client - { - return $this->getConnection()->getClient(); - } - /** * Retrieves the underlying Elasticsearch connection. * @@ -212,28 +201,14 @@ public function getConnection(): ConnectionInterface } /** - * Converts the fluent query into an Elasticsearch query array that can be - * converted into JSON. - * - * @inheritDoc - */ - final public function toArray(): array - { - return $this->buildQuery(); - } - - /** - * Converts the query to a JSON string. + * Proxies to the collection iterator, allowing to iterate the query builder + * directly as though it were a result collection. * * @inheritDoc - * @throws JsonException */ - public function toJson($options = 0): string + final public function getIterator(): ArrayIterator { - return json_encode( - $this->jsonSerialize(), - JSON_THROW_ON_ERROR | $options - ); + return $this->get()->getIterator(); } /** @@ -297,14 +272,42 @@ public function jsonSerialize(): array } /** - * Proxies to the collection iterator, allowing to iterate the query builder - * directly as though it were a result collection. + * Retrieves the underlying Elasticsearch client instance. This can be used + * to work with the Elasticsearch library directly. You should check out its + * documentation for more information. + * + * @return Client Elasticsearch Client instance. + * @see https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/overview.html + * @see Client + */ + public function raw(): Client + { + return $this->getConnection()->getClient(); + } + + /** + * Converts the fluent query into an Elasticsearch query array that can be + * converted into JSON. * * @inheritDoc */ - final public function getIterator(): ArrayIterator + final public function toArray(): array { - return $this->get()->getIterator(); + return $this->buildQuery(); + } + + /** + * Converts the query to a JSON string. + * + * @inheritDoc + * @throws JsonException + */ + public function toJson($options = 0): string + { + return json_encode( + $this->jsonSerialize(), + JSON_THROW_ON_ERROR | $options + ); } /** @@ -317,30 +320,30 @@ protected function buildQuery(): array $query = $this->applyScopes(); $params = [ - self::PARAM_BODY => $this->getBody(), - self::PARAM_FROM => $this->getSkip(), - self::PARAM_SIZE => $this->getSize(), + self::PARAM_BODY => $query->getBody(), + self::PARAM_FROM => $query->getSkip(), + self::PARAM_SIZE => $query->getSize(), ]; - if (count($this->getIgnores())) { + if (count($query->getIgnores())) { $params[self::PARAM_CLIENT] = [ - self::PARAM_CLIENT_IGNORE => $this->ignores, + self::PARAM_CLIENT_IGNORE => $query->ignores, ]; } - if ($searchType = $this->getSearchType()) { + if ($searchType = $query->getSearchType()) { $params[self::PARAM_SEARCH_TYPE] = $searchType; } - if ($scroll = $this->getScroll()) { + if ($scroll = $query->getScroll()) { $params[self::PARAM_SCROLL] = $scroll; } - if ($index = $this->getIndex()) { + if ($index = $query->getIndex()) { $params[self::PARAM_INDEX] = $index; } - if ($type = $this->getType()) { + if ($type = $query->getType()) { $params[self::PARAM_TYPE] = $type; }