diff --git a/docs/reference/esql.md b/docs/reference/esql.md index a4cd11655..0b730daac 100644 --- a/docs/reference/esql.md +++ b/docs/reference/esql.md @@ -14,6 +14,7 @@ There are two ways to use ES|QL in the PHP client: * Use the Elasticsearch [ES|QL API](https://www.elastic.co/docs/api/doc/elasticsearch/group/endpoint-esql) directly: This is the most flexible approach, but it’s also the most complex because you must handle results in their raw form. You can choose the precise format of results, such as JSON, CSV, or text. * Use ES|QL `mapTo($class)` helper. This mapper takes care of parsing the raw response and converting into an array of objects. If you don’t specify the class using the `$class` parameter, the mapper uses [stdClass](https://www.php.net/manual/en/class.stdclass.php). +ES|QL queries can be given directly as regular strings or heredoc strings, or for a more convenient option you can use the [ES|QL query builder](#esql-query-builder) helper, which can define ES|QL queries using PHP code. ## How to use the ES|QL API [esql-how-to] @@ -206,3 +207,71 @@ $result = $client->esql()->query([ $books = $result->mapTo(Book::class); // Array of Book ``` +## Using the ES|QL query builder [esql-query-builder] + +::::{warning} +This functionality is in technical preview and may be changed or removed in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features. +:::: + +The ES|QL query builder allows you to construct ES|QL queries using PHP syntax. Consider the following example: + +```php +use Elastic\Elasticsearch\Helper\Esql\Query; + +$query = Query::from("employees") + ->sort("emp_no") + ->keep("first_name", "last_name", "height") + ->eval(height_feet: "height * 3.281", height_cm: "height * 100") + ->limit(3); +``` + +Casting this query object to a string returns the raw ES|QL query, which you can send to the Elasticsearch ES|QL API: + +```php +$result = $client->esql()->query([ + 'body' => ['query' => (string)$query] +]); +``` + +### Creating an ES|QL query + +To construct an ES|QL query object you typically use `Query::from()`, although there are more [source commands](https://www.elastic.co/docs/reference/query-languages/esql/commands/source-commands) available. Here are some examples: + +```php +// FROM employees +$query = Query::from("employees"); + +// FROM +$query = Query::from(""); + +// FROM employees-00001, other-employees-* +$query = Query::from("employees-00001", "other-employees-*"); + +// FROM cluster_one:employees-00001, cluster_two:other-employees-* +$query = Query::from("cluster_one:employees-00001", "cluster_two:other-employees-*"); + +// FROM employees METADATA _id +$query = Query::from("employees")->metadata("_id"); +``` + +Note how in the last example the optional `METADATA` clause of the `FROM` command is added as a chained method. + +### Adding processing commands + +Once you have a query object, you can add one or more processing commands to it by chaining them. The following example shows how to create a query that uses the `WHERE` and `LIMIT` processing commands to filter the results: + +```php +$query = Query::from("employees") + ->where("still_hired == true") + ->limit(10); +``` + +The ES|QL documentation includes the complete list of available [processing commands](https://www.elastic.co/docs/reference/query-languages/esql/commands/processing-commands). + +### Using Code completion + +You can rely on autocompletion as an aid in constructing ES|QL queries with the query builder. Note that this requires an IDE that is configured with a PHP language server. + +![Autocompleting Query::](images/autocompleting-query.png) + +![Autocompleting from()](images/autocompleting-from.png) diff --git a/docs/reference/images/autocompleting-from.png b/docs/reference/images/autocompleting-from.png new file mode 100644 index 000000000..10e83dead Binary files /dev/null and b/docs/reference/images/autocompleting-from.png differ diff --git a/docs/reference/images/autocompleting-query.png b/docs/reference/images/autocompleting-query.png new file mode 100644 index 000000000..3a7393b68 Binary files /dev/null and b/docs/reference/images/autocompleting-query.png differ diff --git a/src/Helper/Esql/Branch.php b/src/Helper/Esql/Branch.php new file mode 100644 index 000000000..5a4586acd --- /dev/null +++ b/src/Helper/Esql/Branch.php @@ -0,0 +1,33 @@ +value = $value; + } + + /** + * Continuation of the `CHANGE_POINT` command. + * + * @param string $key The column with the key to order the values by. If not + * specified, `@timestamp` is used. + */ + public function on(string $key): ChangePointCommand + { + $this->key = $key; + return $this; + } + + /** + * Continuation of the `CHANGE_POINT` command. + * + * @param string $type_name The name of the output column with the change + * point type. If not specified, `type` is used. + * @param string $pvalue_name The name of the output column with the p-value + * that indicates how extreme the change point is. + * If not specified, `pvalue` is used. + */ + public function as(string $type_name, string $pvalue_name): ChangePointCommand + { + $this->type_name = $type_name; + $this->pvalue_name = $pvalue_name; + return $this; + } + + protected function renderInternal(): string + { + $key = $this->key ? " ON " . $this->formatId($this->key) : ""; + $names = ($this->type_name && $this->pvalue_name) ? + " AS " . $this->formatId($this->type_name) . ", " . + $this->formatId($this->pvalue_name) + : ""; + return "CHANGE_POINT " . $this->value . $key . $names; + } +} diff --git a/src/Helper/Esql/CompletionCommand.php b/src/Helper/Esql/CompletionCommand.php new file mode 100644 index 000000000..f4ab1d588 --- /dev/null +++ b/src/Helper/Esql/CompletionCommand.php @@ -0,0 +1,74 @@ +isNamedArgumentList($prompt)) { + $this->named_prompt = $prompt; + } + else { + $this->prompt = $prompt[0]; + } + } + + /** + * Continuation of the `COMPLETION` command. + * + * @param string $inference_id The ID of the inference endpoint to use for + * the task. The inference endpoint must be + * configured with the `completion` task type. + */ + public function with(string $inference_id): CompletionCommand + { + $this->inference_id = $inference_id; + return $this; + } + + protected function renderInternal(): string + { + if (!$this->inference_id) { + throw new RuntimeException("The completion command requires an inference ID"); + } + $with = ["inference_id" => $this->inference_id]; + if ($this->named_prompt) { + return "COMPLETION " . + $this->formatId(array_keys($this->named_prompt)[0]) . " = " . + $this->formatId(array_values($this->named_prompt)[0]) . + " WITH " . json_encode($with); + } + else { + return "COMPLETION " . $this->formatId($this->prompt) . + " WITH " . json_encode($with); + } + } +} diff --git a/src/Helper/Esql/DissectCommand.php b/src/Helper/Esql/DissectCommand.php new file mode 100644 index 000000000..f664b55ad --- /dev/null +++ b/src/Helper/Esql/DissectCommand.php @@ -0,0 +1,52 @@ +input = $input; + $this->pattern = $pattern; + } + + /** + * Continuation of the `DISSECT` command. + * + * @param string $separator A string used as the separator between appended + * values, when using the append modifier. + */ + public function append_separator(string $separator): DissectCommand + { + $this->separator = $separator; + return $this; + } + + protected function renderInternal(): string + { + $sep = $this->separator ? " APPEND_SEPARATOR=" . json_encode($this->separator) : ""; + return "DISSECT " . $this->formatId($this->input) . " " . json_encode($this->pattern) . $sep; + } +} diff --git a/src/Helper/Esql/DropCommand.php b/src/Helper/Esql/DropCommand.php new file mode 100644 index 000000000..353d85e9e --- /dev/null +++ b/src/Helper/Esql/DropCommand.php @@ -0,0 +1,38 @@ +columns = $columns; + } + + protected function renderInternal(): string + { + return "DROP " . implode( + ", ", array_map(array($this, "formatId"), $this->columns) + ); + } +} diff --git a/src/Helper/Esql/EnrichCommand.php b/src/Helper/Esql/EnrichCommand.php new file mode 100644 index 000000000..1c5c8fc75 --- /dev/null +++ b/src/Helper/Esql/EnrichCommand.php @@ -0,0 +1,91 @@ +policy = $policy; + } + + /** + * Continuation of the `ENRICH` command. + * + * @param string $match_field The match field. `ENRICH` uses its value to + * look for records in the enrich index. If not + * specified, the match will be performed on the + * column with the same name as the `match_field` + * defined in the enrich policy. + */ + public function on(string $match_field): EnrichCommand + { + $this->match_field = $match_field; + return $this; + } + + /** + * Continuation of the `ENRICH` command. + * + * @param string ...$fields The enrich fields from the enrich index that + * are added to the result as new columns, given + * as positional or named arguments. If a column + * with the same name as the enrich field already + * exists, the existing column will be replaced by + * the new column. If not specified, each of the + * enrich fields defined in the policy is added. A + * column with the same name as the enrich field + * will be dropped unless the enrich field is + * renamed. + */ + public function with(string ...$fields): EnrichCommand + { + if ($this->isNamedArgumentList($fields)) { + $this->named_fields = $fields; + } + else { + $this->fields = $fields; + } + return $this; + } + + protected function renderInternal(): string + { + $on = ($this->match_field != "") ? " ON " . $this->formatId($this->match_field) : ""; + $with = ""; + $items = []; + if (sizeof($this->named_fields)) { + $with = " WITH " . $this->formatKeyValues($this->named_fields); + } + else if (sizeof($this->fields)) { + $with = implode( + ", ", + array_map(fn($value): string => $this->formatId($value), $this->fields) + ); + } + return "ENRICH " . $this->policy . $on . $with; + } +} diff --git a/src/Helper/Esql/EsqlBase.php b/src/Helper/Esql/EsqlBase.php new file mode 100644 index 000000000..04347815e --- /dev/null +++ b/src/Helper/Esql/EsqlBase.php @@ -0,0 +1,640 @@ +previous_command) { + return $this->previous_command->isForked(); + } + return false; + } + + public function __construct(?EsqlBase $previous_command) + { + $this->previous_command = $previous_command; + } + + /** + * Render the ES|QL command to a string. + */ + public function render(): string + { + $query = ""; + if ($this->previous_command) { + $query .= $this->previous_command->render() . "\n| "; + } + $query .= $this->renderInternal(); + return $query; + } + + /** + * Abstract method implemented by all the command subclasses. + */ + protected abstract function renderInternal(): string; + + public function __toString(): string + { + return $this->render() . "\n"; + } + + /** + * `CHANGE_POINT` detects spikes, dips, and change points in a metric. + * + * @param string $value The column with the metric in which you want to + * detect a change point. + * + * Examples: + * + * $query = Query::row(key: range(1, 25)) + * ->mvExpand("key") + * ->eval(value: "CASE(key < 13, 0, 42)") + * ->changePoint("value") + * ->on("key") + * ->where("type IS NOT NULL"); + */ + public function changePoint(string $value): ChangePointCommand + { + return new ChangePointCommand($this, $value); + } + + /** + * The `COMPLETION` command allows you to send prompts and context to a Large + * Language Model (LLM) directly within your ES|QL queries, to perform text + * generation tasks. + * + * @param string ...$prompt The input text or expression used to prompt the + * LLM. This can be a string literal or a reference + * to a column containing text. If the prompt is + * given as a positional argument, the results will + * be stored in a column named `completion`. If + * given as a named argument, the given name will + * be used. If the specified column already exists, + * it will be overwritten with the new results. + * + * Examples: + * + * $query1 = Query::row(question: "What is Elasticsearch?") + * ->completion("question")->with("test_completion_model") + * ->keep("question", "completion"); + * $query2 = Query::row(question: "What is Elasticsearch?") + * ->completion(answer: "question")->with("test_completion_model") + * ->keep("question", "answer"); + * $query3 = Query::from("movies") + * ->sort("rating DESC") + * ->limit(10) + * ->eval(prompt: "CONCAT(\n" . + * " \"Summarize this movie using the following information: \\n\",\n" . + * " \"Title: \", title, \"\\n\",\n" . + * " \"Synopsis: \", synopsis, \"\\n\",\n" . + * " \"Actors: \", MV_CONCAT(actors, \", \"), \"\\n\",\n" . + * " )" + * ) + * ->completion(summary: "prompt")->with("test_completion_model") + * ->keep("title", "summary", "rating"); + */ + public function completion(string ...$prompt): CompletionCommand + { + return new CompletionCommand($this, $prompt); + } + + /** + * `DISSECT` enables you to extract structured data out of a string. + * + * @param string $input The column that contains the string you want to + * structure. If the column has multiple values, + * `DISSECT` will process each value. + * @param string $pattern A dissect pattern. If a field name conflicts with + * an existing column, the existing column is + * dropped. If a field name is used more than once, + * only the rightmost duplicate creates a column. + * + * Examples: + * + * $query = Query::row(a: "2023-01-23T12:15:00.000Z - some text - 127.0.0.1") + * ->dissect("a", "%{date} - %{msg} - %{ip}") + * ->keep("date", "msg", "ip"); + */ + public function dissect(string $input, string $pattern): DissectCommand + { + return new DissectCommand($this, $input, $pattern); + } + + /** + * The `DROP` processing command removes one or more columns. + * + * @param string ...$columns The columns to drop, given as positional + * arguments. Supports wildcards. + * + * Examples: + * + * $query1 = Query::from("employees")->drop("height"); + * $query2 = Query::from("employees")->drop("height*"); + */ + public function drop(string ...$columns): DropCommand + { + return new DropCommand($this, $columns); + } + + /** + * `ENRICH` enables you to add data from existing indices as new columns using an + * enrich policy. + * + * @param string $policy The name of the enrich policy. You need to create + * and execute the enrich policy first. + * + * Examples: + * + * $query1 = Query::row(language_code: "1") + * ->enrich("languages_policy"); + * $query2 = Query::row(language_code: "1") + * ->enrich("languages_policy")->on("a"); + * $query3 = Query::row(language_code: "1") + * ->enrich("languages_policy")->on("a")->with(name: "language_name"); + */ + public function enrich(string $policy): EnrichCommand + { + return new EnrichCommand($this, $policy); + } + + /** + * The `EVAL` processing command enables you to append new columns with calculated + * values. + * + * @param string ...$columns The values for the columns, given as positional + * or named arguments. Can be literals, expressions, + * or functions. Can use columns defined left of + * this one. When given as named arguments, the + * names are used as column names in the results. + * If a column with the same name already exists, + * the existing column is dropped. If a column name + * is used more than once, only the rightmost + * duplicate creates a column. + * + * Examples: + * + * $query1 = Query::from("employees") + * ->sort("emp_no") + * ->keep("first_name", "last_name", "height") + * ->eval(height_feet: "height * 3.281", height_cm: "height * 100"); + * $query2 = Query::from("employees") + * ->sort("emp_no") + * ->keep("first_name", "last_name", "height") + * ->eval("height * 3.281"); + * $query3 = Query::from("employees") + * ->eval("height * 3.281") + * ->stats(avg_height_feet: "AVG(`height * 3.281`)"); + */ + public function eval(string ...$columns): EvalCommand + { + return new EvalCommand($this, $columns); + } + + /** + * The `FORK` processing command creates multiple execution branches to operate on the + * same input data and combines the results in a single output table. + * + * @param EsqlBase $fork1 Up to 8 execution branches, created with the + * `Query.branch()` method. + * + * Examples: + * + * $query = Query::from("employees") + * ->fork( + * Query::branch()->where("emp_no == 10001"), + * Query::branch()->where("emp_no == 10002"), + * ) + * ->keep("emp_no", "_fork") + * ->sort("emp_no"); + */ + public function fork( + EsqlBase $fork1, + ?EsqlBase $fork2 = null, + ?EsqlBase $fork3 = null, + ?EsqlBase $fork4 = null, + ?EsqlBase $fork5 = null, + ?EsqlBase $fork6 = null, + ?EsqlBase $fork7 = null, + ?EsqlBase $fork8 = null, + ): ForkCommand + { + if ($this->isForked()) { + throw new RuntimeException("a query can only have one fork"); + } + return new ForkCommand($this, $fork1, $fork2, $fork3, $fork4, $fork5, $fork6, $fork7, $fork8); + } + + /** + * The `FUSE` processing command merges rows from multiple result sets and assigns + * new relevance scores. + * + * @param string $method Defaults to `RRF`. Can be one of `RRF` (for Reciprocal Rank Fusion) + * or `LINEAR` (for linear combination of scores). Designates which + * method to use to assign new relevance scores. + * + * Examples: + * + */ + public function fuse(string $method = ""): FuseCommand + { + return new FuseCommand($this, $method); + } + + /** + * `GROK` enables you to extract structured data out of a string. + * + * @param string $input The column that contains the string you want to + * structure. If the column has multiple values, `GROK` + * will process each value. + * @param string $pattern A grok pattern. If a field name conflicts with an + * existing column, the existing column is discarded. + * If a field name is used more than once, a + * multi-valued column will be created with one value + * per each occurrence of the field name. + * + * Examples: + * + * $query1 = Query::row(a: "2023-01-23T12:15:00.000Z 127.0.0.1 some.email@foo.com 42") + * ->grok( + * "a", + * "%{TIMESTAMP_ISO8601:date} %{IP:ip} %{EMAILADDRESS:email} %{NUMBER:num}", + * ) + * ->keep("date", "ip", "email", "num"); + * $query2 = Query::row(a: "2023-01-23T12:15:00.000Z 127.0.0.1 some.email@foo.com 42") + * ->grok( + * "a", + * "%{TIMESTAMP_ISO8601:date} %{IP:ip} %{EMAILADDRESS:email} %{NUMBER:num:int}", + * ) + * ->keep("date", "ip", "email", "num") + * ->eval(date: "TO_DATETIME(date)"); + * $query3 = Query::from("addresses") + * ->keep("city.name", "zip_code") + * ->grok("zip_code", "%{WORD:zip_parts} %{WORD:zip_parts}"); + */ + public function grok(string $input, string $pattern): GrokCommand + { + return new GrokCommand($this, $input, $pattern); + } + + /** + * The `INLINE STATS` processing command groups rows according to a common value + * and calculates one or more aggregated values over the grouped rows. + * + * The command is identical to ``STATS`` except that it preserves all the columns from + * the input table. + * + * @param string ...$expressions A list of boolean expressions, given as + * positional or named arguments. These + * expressions are combined with an `AND` + * logical operator. + * + * Examples: + */ + public function inlineStats(string ...$expressions): InlineStatsCommand + { + return new InlineStatsCommand($this, $expressions); + } + + /** + * The `KEEP` processing command enables you to specify what columns are returned + * and the order in which they are returned. + * + * @param string ...$columns The columns to keep, given as positional + * arguments. Supports wildcards. + * + * Examples: + * + * $query1 = Query::from("employees") + * ->keep("emp_no", "first_name", "last_name", "height"); + * $query2 = Query::from("employees") + * ->keep("h*"); + * $query3 = Query::from("employees") + * ->keep("h*", "first_name"); + */ + public function keep(string ...$columns): KeepCommand + { + return new KeepCommand($this, $columns); + } + + /** + * The `LIMIT` processing command enables you to limit the number of rows that are + * returned. + * + * @param int $max_number_of_rows The maximum number of rows to return. + * + * Examples: + * + * $query1 = Query::from("index") + * ->where("field == \"value\"") + * ->limit(1000); + * $query2 = Query::from("index") + * ->stats("AVG(field1)")->by("field2") + * ->limit(20000); + */ + public function limit(int $max_number_of_rows): LimitCommand + { + return new LimitCommand($this, $max_number_of_rows); + } + + /** + * `LOOKUP JOIN` enables you to add data from another index, AKA a 'lookup' index, + * to your ES|QL query results, simplifying data enrichment and analysis workflows. + * + * @param string $lookup_index The name of the lookup index. This must be a + * specific index name - wildcards, aliases, and + * remote cluster references are not supported. + * Indices used for lookups must be configured + * with the lookup index mode. + * + * Examples: + * + * $query1 = Query::from("firewall_logs") + * ->lookupJoin("threat_list")->on("source.IP") + * ->where("threat_level IS NOT NULL"); + * $query2 = Query::from("system_metrics") + * ->lookupJoin("host_inventory")->on("host.name") + * ->lookupJoin("ownerships")->on("host.name"); + * $query3 = Query::from("app_logs") + * ->lookupJoin("service_owners")->on("service_id"); + * $query4 = Query::from("employees") + * ->eval(language_code: "languages") + * ->where("emp_no >= 10091", "emp_no < 10094") + * ->lookupJoin("languages_lookup")->on("language_code"); + */ + public function lookupJoin(string $lookup_index): LookupJoinCommand + { + return new LookupJoinCommand($this, $lookup_index); + } + + /** + * The `MV_EXPAND` processing command expands multivalued columns into one row per + * value, duplicating other columns. + * + * @param string $column The multivalued column to expand. + * + * Examples: + * + * $query = Query::row(a: [1, 2, 3], b: "b", j: ["a", "b"]) + * ->mvExpand("a"); + */ + public function mvExpand(string $column): MvExpandCommand + { + return new MvExpandCommand($this, $column); + } + + /** + * The `RENAME` processing command renames one or more columns. + * + * @param string ...$columns The old and new column name pairs, given as + * named arguments. If a name conflicts with an + * existing column name, the existing column is + * dropped. If multiple columns are renamed to the + * same name, all but the rightmost column with + * the same new name are dropped. + * + * Examples: + * + * $query = Query::from("employees") + * ->keep("first_name", "last_name", "still_hired") + * ->rename(still_hired: "employed"); + */ + public function rename(string ...$columns): RenameCommand + { + return new RenameCommand($this, $columns); + } + + /** + * The `RERANK` command uses an inference model to compute a new relevance score + * for an initial set of documents, directly within your ES|QL queries. + * + * @param string ...$query The query text used to rerank the documents. + * This is typically the same query used in the + * initial search. If given as a named argument, The + * argument name is used for the column name. If the + * query is given as a positional argument, the + * results will be stored in a column named `_score`. + * If the specified column already exists, it will + * be overwritten with the new results. + * + * Examples: + * + * $query1 = Query::from("books")->metadata("_score") + * ->where("MATCH(description, \"hobbit\")") + * ->sort("_score DESC") + * ->limit(100) + * ->rerank("hobbit")->on("description")->with("test_reranker") + * ->limit(3) + * ->keep("title", "_score"); + * $query2 = Query::from("books")->metadata("_score") + * ->where("MATCH(description, \"hobbit\") OR MATCH(author, \"Tolkien\")") + * ->sort("_score DESC") + * ->limit(100) + * ->rerank(rerank_score: "hobbit")->on("description", "author")->with("test_reranker") + * ->sort("rerank_score") + * ->limit(3) + * ->keep("title", "_score", "rerank_score"); + * $query3 = Query::from("books")->metadata("_score") + * ->where("MATCH(description, \"hobbit\") OR MATCH(author, \"Tolkien\")") + * ->sort("_score DESC") + * ->limit(100) + * ->rerank(rerank_score: "hobbit")->on("description", "author")->with("test_reranker") + * ->eval(original_score: "_score", _score: "rerank_score + original_score") + * ->sort("_score") + * ->limit(3) + * ->keep("title", "original_score", "rerank_score", "_score"); + */ + public function rerank(string ...$query): RerankCommand + { + return new RerankCommand($this, $query); + } + + /** + * The `SAMPLE` command samples a fraction of the table rows. + * + * @param float $probability The probability that a row is included in the + * sample. The value must be between 0 and 1, + * exclusive. + * + * Examples: + * + * $query = Query::from("employees") + * ->keep("emp_no") + * ->sample(0.05); + */ + public function sample(float $probability): SampleCommand + { + return new SampleCommand($this, $probability); + } + + /** + * The `SORT` processing command sorts a table on one or more columns. + * + * @param string ...$columns: The columns to sort on. + * + * Examples: + * + * $query1 = Query::from("employees") + * ->keep("first_name", "last_name", "height") + * ->sort("height"); + * $query2 = Query::from("employees") + * ->keep("first_name", "last_name", "height") + * ->sort("height DESC"); + * $query3 = Query::from("employees") + * ->keep("first_name", "last_name", "height") + * ->sort("height DESC", "first_name ASC"); + * $query4 = Query::from("employees") + * ->keep("first_name", "last_name", "height") + * ->sort("first_name ASC NULLS FIRST"); + */ + public function sort(string ...$columns): SortCommand + { + return new SortCommand($this, $columns); + } + + /** + * The `STATS` processing command groups rows according to a common value and + * calculates one or more aggregated values over the grouped rows. + * + * @param string ...$expressions A list of expressions, given as positional + * or named arguments. The argument names are + * used for the returned aggregated values. + * + * Examples: + * + * $query1 = Query::from("employees") + * ->stats(count: "COUNT(emp_no)")->by("languages") + * ->sort("languages"); + * $query2 = Query::from("employees") + * ->stats(avg_lang: "AVG(languages)"); + * $query3 = Query::from("employees") + * ->stats( + * avg_lang: "AVG(languages)", + * max_lang: "MAX(languages)", + * ); + * $query4 = Query::from("employees") + * ->stats( + * avg50s: "AVG(salary) WHERE birth_date < \"1960-01-01\"", + * avg60s: "AVG(salary) WHERE birth_date >= \"1960-01-01\"" + * )->by("gender") + * ->sort("gender"); + * $query5 = Query::from("employees") + * ->eval(Ks: "salary / 1000") + * ->stats( + * under_40K: "COUNT(*) WHERE Ks < 40", + * inbetween: "COUNT(*) WHERE (40 <= Ks) AND (Ks < 60)", + * over_60K: "COUNT(*) WHERE 60 <= Ks", + * total: "COUNT(*)", + * ); + * $query6 = Query::row(i: 1, a: ["a", "b"]) + * ->stats("MIN(i)")->by("a") + * ->sort("a ASC"); + * $query7 = Query::from("employees") + * ->eval(hired: "DATE_FORMAT(\"yyyy\", hire_date)") + * ->stats(avg_salary: "AVG(salary)")->by("hired", "languages.long") + * ->eval(avg_salary: "ROUND(avg_salary)") + * ->sort("hired", "languages.long"); + */ + public function stats(string ...$expressions): StatsCommand + { + return new StatsCommand($this, $expressions); + } + + /** + * The `WHERE` processing command produces a table that contains all the rows + * from the input table for which the provided condition evaluates to `true`. + * + * @param string ...$expressions A list of boolean expressions, given as + * positional or named arguments. These + * expressions are combined with an `AND` + * logical operator. + * + * Examples: + * + * $query1 = Query::from("employees") + * ->keep("first_name", "last_name", "still_hired") + * ->where("still_hired == true"); + * $query2 = Query::from("sample_data") + * ->where("@timestamp > NOW() - 1 hour"); + * $query3 = Query::from("employees") + * ->keep("first_name", "last_name", "height") + * ->where("LENGTH(first_name) < 4"); + */ + public function where(string ...$expressions): WhereCommand + { + return new WhereCommand($this, $expressions); + } +} diff --git a/src/Helper/Esql/EvalCommand.php b/src/Helper/Esql/EvalCommand.php new file mode 100644 index 000000000..c2736eb62 --- /dev/null +++ b/src/Helper/Esql/EvalCommand.php @@ -0,0 +1,51 @@ +isNamedArgumentList($columns)) { + $this->named_columns = $columns; + } + else { + $this->columns = $columns; + } + } + + protected function renderInternal(): string + { + if (sizeof($this->named_columns)) { + $eval = $this->formatKeyValues($this->named_columns); + } + else { + $eval = implode( + ", ", + array_map(fn($value): string => $this->formatId($value), $this->columns) + ); + } + return "EVAL " . $eval; + } +} diff --git a/src/Helper/Esql/ForkCommand.php b/src/Helper/Esql/ForkCommand.php new file mode 100644 index 000000000..0c50e3b7b --- /dev/null +++ b/src/Helper/Esql/ForkCommand.php @@ -0,0 +1,58 @@ +branches = [$fork1, $fork2, $fork3, $fork4, $fork5, $fork6, $fork7, $fork8]; + } + + protected function renderInternal(): string + { + $cmds = ""; + foreach ($this->branches as $branch) { + if ($branch) { + $cmd = str_replace("\n", " ", substr($branch->render(), 3)); + if ($cmds == "") { + $cmds = "( " . $cmd . " )"; + } + else { + $cmds .= "\n ( " . $cmd . " )"; + } + } + } + return "FORK " . $cmds; + } +} diff --git a/src/Helper/Esql/FromCommand.php b/src/Helper/Esql/FromCommand.php new file mode 100644 index 000000000..334e7652b --- /dev/null +++ b/src/Helper/Esql/FromCommand.php @@ -0,0 +1,49 @@ +indices = $indices; + } + + /** + * Continuation of the `FROM` or `TS` source commands. + * + * *param string ...$metadata_fields Metadata fields to retrieve, given as + * positional arguments. + */ + public function metadata(string ...$metadata_fields): FromCommand + { + $this->metadata_fields = $metadata_fields; + return $this; + } + + protected function renderInternal(): string + { + $s = $this::name . " " . implode(", ", $this->indices); + if (sizeof($this->metadata_fields)) { + $s .= " METADATA " . implode( + ", ", array_map(array($this, "formatId"), $this->metadata_fields) + ); + } + return $s; + } +} diff --git a/src/Helper/Esql/FuseCommand.php b/src/Helper/Esql/FuseCommand.php new file mode 100644 index 000000000..b5bebf8fb --- /dev/null +++ b/src/Helper/Esql/FuseCommand.php @@ -0,0 +1,56 @@ +method = $method; + } + + public function by(string ...$columns): FuseCommand + { + $this->columns = $columns; + return $this; + } + + public function with(mixed ...$options): FuseCommand + { + $this->options = $options; + return $this; + } + + protected function renderInternal(): string + { + $method = ($this->method != "") ? " " . strtoupper($this->method) : ""; + $by = sizeof($this->columns) ? " " . implode(" ", array_map(function($column) { + return "BY " . $column; + }, $this->columns)) : ""; + $with = sizeof($this->options) ? " WITH " . json_encode($this->options) : ""; + return "FUSE" . $method . $by . $with; + } + +} diff --git a/src/Helper/Esql/GrokCommand.php b/src/Helper/Esql/GrokCommand.php new file mode 100644 index 000000000..b4a0e87cb --- /dev/null +++ b/src/Helper/Esql/GrokCommand.php @@ -0,0 +1,38 @@ +input = $input; + $this->pattern = $pattern; + } + + protected function renderInternal(): string + { + return "GROK " . $this->formatId($this->input) . " " . json_encode($this->pattern); + } +} diff --git a/src/Helper/Esql/InlineStatsCommand.php b/src/Helper/Esql/InlineStatsCommand.php new file mode 100644 index 000000000..ce41b4d55 --- /dev/null +++ b/src/Helper/Esql/InlineStatsCommand.php @@ -0,0 +1,25 @@ +columns = $columns; + } + + protected function renderInternal(): string + { + return "KEEP " . implode( + ", ", array_map(array($this, "formatId"), $this->columns) + ); + } +} diff --git a/src/Helper/Esql/LimitCommand.php b/src/Helper/Esql/LimitCommand.php new file mode 100644 index 000000000..8248544a8 --- /dev/null +++ b/src/Helper/Esql/LimitCommand.php @@ -0,0 +1,36 @@ +max_number_of_rows = $max_number_of_rows; + } + + protected function renderInternal(): string + { + return "LIMIT " . json_encode($this->max_number_of_rows); + } +} diff --git a/src/Helper/Esql/LookupJoinCommand.php b/src/Helper/Esql/LookupJoinCommand.php new file mode 100644 index 000000000..64d22a30c --- /dev/null +++ b/src/Helper/Esql/LookupJoinCommand.php @@ -0,0 +1,58 @@ +lookup_index = $lookup_index; + } + + /** + * Continuation of the `LOOKUP_JOIN` command. + * + * @param string $field The field to join on. This field must exist in both + * your current query results and in the lookup index. + * If the field contains multi-valued entries, those + * entries will not match anything (the added fields + * will contain null for those rows). + */ + public function on(string $field): LookupJoinCommand + { + $this->field = $field; + return $this; + } + + protected function renderInternal(): string + { + if (!$this->field) { + throw new RuntimeException ("Joins require a field to join on."); + } + return "LOOKUP JOIN " . $this->lookup_index . + " ON " . $this->formatId($this->field); + } +} diff --git a/src/Helper/Esql/MvExpandCommand.php b/src/Helper/Esql/MvExpandCommand.php new file mode 100644 index 000000000..e8d98d4b7 --- /dev/null +++ b/src/Helper/Esql/MvExpandCommand.php @@ -0,0 +1,36 @@ +column = $column; + } + + protected function renderInternal(): string + { + return "MV_EXPAND " . $this->formatId($this->column); + } +} diff --git a/src/Helper/Esql/Query.php b/src/Helper/Esql/Query.php new file mode 100644 index 000000000..fb04bf315 --- /dev/null +++ b/src/Helper/Esql/Query.php @@ -0,0 +1,102 @@ +"); + * $query3 = Query::from("employees-00001", "other-employees-*"); + * $query4 = Query::from("cluster_one:employees-00001", "cluster_two:other-employees-*"); + * $query5 = Query::from("employees")->metadata("_id"); + */ + public static function from(string ...$indices): FromCommand + { + return new FromCommand($indices); + } + + /** + * The ``ROW`` source command produces a row with one or more columns with + * values that you specify. This can be useful for testing. + * + * @param string ...$params the column values to produce, given as keyword + * arguments. + * + * Examples: + * + * $query1 = Query::row(a: 1, b: "two", c: null); + * $query2 = Query::row(a: [2, 1]); + */ + public static function row(mixed ...$params): RowCommand + { + return new RowCommand($params); + } + + /** + * The `SHOW` source command returns information about the deployment and + * its capabilities. + * + * @param string $item Can only be `INFO`. + * + * Examples: + * + * $query = Query::show("INFO"); + */ + public static function show(string $item): ShowCommand + { + return new ShowCommand($item); + } + + /** + * The `TS` source command is similar to ``FROM``, but for time series indices. + * + * @param string $indices A list of indices, data streams or aliases. Supports + * wildcards and date math. + * + * Examples: + * + * $query = Query::ts("metrics") + * ->where("@timestamp >= now() - 1 day") + * ->stats("SUM(AVG_OVER_TIME(memory_usage))").by("host", "TBUCKET(1 hour)") + */ + public static function ts(string ...$indices): TSCommand + { + return new TSCommand($indices); + } + + /** + * This method can only be used inside a `FORK` command to create each branch. + * + * Examples: + * + * $query = Query::from("employees") + * ->fork( + * Query::branch()->where("emp_no == 10001"), + * Query::branch()->where("emp_no == 10002"), + * ) + */ + public static function branch(): Branch + { + return new Branch(); + } +} diff --git a/src/Helper/Esql/RenameCommand.php b/src/Helper/Esql/RenameCommand.php new file mode 100644 index 000000000..949b4da4c --- /dev/null +++ b/src/Helper/Esql/RenameCommand.php @@ -0,0 +1,41 @@ +isNamedArgumentList($columns)) { + throw new RuntimeException("Only named arguments are valid"); + } + parent::__construct($previous_command); + $this->named_columns = $columns; + } + + protected function renderInternal(): string + { + return "RENAME " . $this->formatKeyValues($this->named_columns, joinText: "AS"); + } +} diff --git a/src/Helper/Esql/RerankCommand.php b/src/Helper/Esql/RerankCommand.php new file mode 100644 index 000000000..c8288846a --- /dev/null +++ b/src/Helper/Esql/RerankCommand.php @@ -0,0 +1,96 @@ +isNamedArgumentList($query)) { + $this->named_query = $query; + } + else { + $this->query = $query[0]; + } + } + + /** + * Continuation of the `RERANK` command. + * + * @param string ...$fields One or more fields to use for reranking. These + * fields should contain the text that the + * reranking model will evaluate. + */ + public function on(string ...$fields): RerankCommand + { + $this->fields = $fields; + return $this; + } + + /** + * Continuation of the `RERANK` command. + * + * @param string $inference_id The ID of the inference endpoint to use for + * the task. The inference endpoint must be + * configured with the `rerank` task type. + */ + public function with(string $inference_id): RerankCommand + { + $this->inference_id = $inference_id; + return $this; + } + + protected function renderInternal(): string + { + if (sizeof($this->fields) == 0) { + throw new RuntimeException( + "The rerank command requires one or more fields to rerank on" + ); + } + if ($this->inference_id == "") { + throw new RuntimeException( + "The rerank command requires an inference ID" + ); + } + $with = ["inference_id" => $this->inference_id]; + if (sizeof($this->named_query)) { + $column = array_keys($this->named_query)[0]; + $value = array_values($this->named_query)[0]; + $query = $this->formatId($column) . " = " . json_encode($value); + } + else { + $query = json_encode($this->query); + } + return "RERANK " . $query . + " ON " . implode(", ", array_map(array($this, "formatId"), $this->fields)) . + " WITH " . json_encode($with); + } +} diff --git a/src/Helper/Esql/RowCommand.php b/src/Helper/Esql/RowCommand.php new file mode 100644 index 000000000..778e96fbe --- /dev/null +++ b/src/Helper/Esql/RowCommand.php @@ -0,0 +1,35 @@ +params = $params; + } + + protected function renderInternal(): string + { + return "ROW " . $this->formatKeyValues($this->params, jsonEncode: true); + } +} diff --git a/src/Helper/Esql/SampleCommand.php b/src/Helper/Esql/SampleCommand.php new file mode 100644 index 000000000..115f47875 --- /dev/null +++ b/src/Helper/Esql/SampleCommand.php @@ -0,0 +1,36 @@ +probability = $probability; + } + + protected function renderInternal(): string + { + return "SAMPLE " . json_encode($this->probability); + } +} diff --git a/src/Helper/Esql/ShowCommand.php b/src/Helper/Esql/ShowCommand.php new file mode 100644 index 000000000..aff14b0db --- /dev/null +++ b/src/Helper/Esql/ShowCommand.php @@ -0,0 +1,35 @@ +item = $item; + } + + protected function renderInternal(): string + { + return "SHOW " . $this->formatId($this->item); + } +} diff --git a/src/Helper/Esql/SortCommand.php b/src/Helper/Esql/SortCommand.php new file mode 100644 index 000000000..c9d0eb91c --- /dev/null +++ b/src/Helper/Esql/SortCommand.php @@ -0,0 +1,42 @@ +columns = $columns; + } + + protected function renderInternal(): string + { + $sorts = []; + foreach ($this->columns as $column) { + array_push($sorts, implode( + " ", array_map(array($this, "formatId"), explode(" ", $column)) + )); + } + return "SORT " . implode(", ", $sorts); + } +} diff --git a/src/Helper/Esql/StatsCommand.php b/src/Helper/Esql/StatsCommand.php new file mode 100644 index 000000000..042fb5179 --- /dev/null +++ b/src/Helper/Esql/StatsCommand.php @@ -0,0 +1,73 @@ +isNamedArgumentList($expressions)) { + $this->named_expressions = $expressions; + } + else { + $this->expressions = $expressions; + } + } + + /** + * Continuation of the `STATS` command. + * + * @param string ...$grouping_expressions Expressions that output the values + * to group by. If their names + * coincides with one of the computed + * columns, that column will be + * ignored. + */ + public function by(string ...$grouping_expressions): StatsCommand + { + $this->grouping_expressions = $grouping_expressions; + return $this; + } + + protected function renderInternal(): string + { + $indent = str_repeat(" ", strlen($this::name) + 3); + if (sizeof($this->named_expressions)) { + $expr = $this->formatKeyValues($this->named_expressions, implodeText: ",\n" . $indent); + } + else { + $expr = implode( + ", ", + array_map(fn($value): string => $this->formatId($value), $this->expressions) + ); + } + $by = ""; + if (sizeof($this->grouping_expressions)) { + $by = "\n" . $indent . "BY " . implode(", ", $this->grouping_expressions); + } + return $this::name . " " . $expr . $by; + } +} diff --git a/src/Helper/Esql/TSCommand.php b/src/Helper/Esql/TSCommand.php new file mode 100644 index 000000000..73ec6cc73 --- /dev/null +++ b/src/Helper/Esql/TSCommand.php @@ -0,0 +1,19 @@ +expressions = $expressions; + } + + protected function renderInternal(): string + { + return "WHERE " . implode(" AND ", $this->expressions); + } +} diff --git a/tests/Helper/EsqlTest.php b/tests/Helper/EsqlTest.php new file mode 100644 index 000000000..9b7f3ee39 --- /dev/null +++ b/tests/Helper/EsqlTest.php @@ -0,0 +1,807 @@ +assertEquals( + "FROM employees\n", + (string) $query + ); + + $query = Query::from(""); + $this->assertEquals( + "FROM \n", + (string) $query + ); + + $query = Query::from("employees-00001", "other-employees-*"); + $this->assertEquals( + "FROM employees-00001, other-employees-*\n", + (string) $query + ); + + $query = Query::from("cluster_one:employees-00001", "cluster_two:other-employees-*"); + $this->assertEquals( + "FROM cluster_one:employees-00001, cluster_two:other-employees-*\n", + (string) $query + ); + + $query = Query::from("employees")->metadata("_id"); + $this->assertEquals( + "FROM employees METADATA _id\n", + (string) $query + ); + } + + public function testRow(): void + { + $query = Query::row(a: 1, b: "two", c: null); + $this->assertEquals( + "ROW a = 1, b = \"two\", c = null\n", + (string) $query + ); + + $query = Query::row(a: [2, 1]); + $this->assertEquals( + "ROW a = [2,1]\n", + (string) $query + ); + } + + public function testShow(): void + { + $query = Query::show("INFO"); + $this->assertEquals( + "SHOW INFO\n", + (string) $query + ); + } + + public function testTS(): void + { + $query = Query::ts("metrics") + ->where("@timestamp >= now() - 1 day") + ->stats("SUM(AVG_OVER_TIME(memory_usage))")->by("host", "TBUCKET(1 hour)"); + $this->assertEquals(<<= now() - 1 day + | STATS SUM(AVG_OVER_TIME(memory_usage)) + BY host, TBUCKET(1 hour)\n + ESQL, + (string) $query + ); + + } + + public function testChangePoint(): void + { + $query = Query::row(key: range(1, 25)) + ->mvExpand("key") + ->eval(value: "CASE(key < 13, 0, 42)") + ->changePoint("value") + ->on("key") + ->where("type IS NOT NULL"); + $this->assertEquals(<<completion("question")->with("test_completion_model") + ->keep("question", "completion"); + $this->assertEquals(<<completion(answer: "question")->with("test_completion_model") + ->keep("question", "answer"); + $this->assertEquals(<<sort("rating DESC") + ->limit(10) + ->eval(prompt: "CONCAT(\n" . + " \"Summarize this movie using the following information: \\n\",\n" . + " \"Title: \", title, \"\\n\",\n" . + " \"Synopsis: \", synopsis, \"\\n\",\n" . + " \"Actors: \", MV_CONCAT(actors, \", \"), \"\\n\",\n" . + " )" + ) + ->completion(summary: "prompt")->with("test_completion_model") + ->keep("title", "summary", "rating"); + $this->assertEquals(<<dissect("a", "%{date} - %{msg} - %{ip}") + ->keep("date", "msg", "ip"); + $this->assertEquals(<<drop("height"); + $this->assertEquals("FROM employees\n| DROP height\n", (string) $query); + $query = Query::from("employees")->drop("height*"); + $this->assertEquals("FROM employees\n| DROP height*\n", (string) $query); + } + + public function testEnrich(): void + { + $query = Query::row(language_code: "1")->enrich("languages_policy"); + $this->assertEquals(<<enrich("languages_policy")->on("a"); + $this->assertEquals(<<enrich("languages_policy")->on("a")->with(name: "language_name"); + $this->assertEquals(<<sort("emp_no") + ->keep("first_name", "last_name", "height") + ->eval(height_feet: "height * 3.281", height_cm: "height * 100"); + $this->assertEquals(<<sort("emp_no") + ->keep("first_name", "last_name", "height") + ->eval("height * 3.281"); + $this->assertEquals(<<eval("height * 3.281") + ->stats(avg_height_feet: "AVG(`height * 3.281`)"); + $this->assertEquals(<<fork( + Query::branch()->where("emp_no == 10001"), + Query::branch()->where("emp_no == 10002"), + ) + ->keep("emp_no", "_fork") + ->sort("emp_no"); + $this->assertEquals(<<metadata("_id", "_index", "_score") + ->fork( + Query::branch()->where('title:"Shakespeare"')->sort("_score DESC"), + Query::branch()->where('semantic_title:"Shakespeare"')->sort("_score DESC") + ) + ->fuse(); + $this->assertEquals(<<metadata("_id", "_index", "_score") + ->fork( + Query::branch()->where('title:"Shakespeare"')->sort("_score DESC"), + Query::branch()->where('semantic_title:"Shakespeare"')->sort("_score DESC") + ) + ->fuse("linear"); + $this->assertEquals(<<metadata("_id", "_index", "_score") + ->fork( + Query::branch()->where('title:"Shakespeare"')->sort("_score DESC"), + Query::branch()->where('semantic_title:"Shakespeare"')->sort("_score DESC") + ) + ->fuse("linear")->by("title", "description"); + $this->assertEquals(<<metadata("_id", "_index", "_score") + ->fork( + Query::branch()->where('title:"Shakespeare"')->sort("_score DESC"), + Query::branch()->where('semantic_title:"Shakespeare"')->sort("_score DESC") + ) + ->fuse("linear")->with(normalizer: "minmax"); + $this->assertEquals(<<grok( + "a", + "%{TIMESTAMP_ISO8601:date} %{IP:ip} %{EMAILADDRESS:email} %{NUMBER:num}", + ) + ->keep("date", "ip", "email", "num"); + $this->assertEquals(<<grok( + "a", + "%{TIMESTAMP_ISO8601:date} %{IP:ip} %{EMAILADDRESS:email} %{NUMBER:num:int}", + ) + ->keep("date", "ip", "email", "num") + ->eval(date: "TO_DATETIME(date)"); + $this->assertEquals(<<keep("city.name", "zip_code") + ->grok("zip_code", "%{WORD:zip_parts} %{WORD:zip_parts}"); + $this->assertEquals(<<keep("emp_no", "languages", "salary") + ->inlineStats(max_salary: "MAX(salary)")->by("languages"); + $this->assertEquals(<<keep("emp_no", "languages", "salary") + ->inlineStats(max_salary: "MAX(salary)"); + $this->assertEquals(<<where("still_hired") + ->keep("emp_no", "languages", "salary", "hire_date") + ->eval(tenure: "DATE_DIFF(\"year\", hire_date, \"2025-09-18T00:00:00\")") + ->drop("hire_date") + ->inlineStats( + avg_salary: "AVG(salary)", + count: "COUNT(*)", + )->by("languages", "tenure"); + $this->assertEquals(<<keep("emp_no", "salary") + ->inlineStats( + avg_lt_50: "ROUND(AVG(salary)) WHERE salary < 50000", + avg_lt_60: "ROUND(AVG(salary)) WHERE salary >= 50000 AND salary < 60000", + avg_gt_60: "ROUND(AVG(salary)) WHERE salary >= 60000", + ); + $this->assertEquals(<<= 50000 AND salary < 60000, + avg_gt_60 = ROUND(AVG(salary)) WHERE salary >= 60000\n + ESQL, + (string) $query + ); + } + + public function testKeep(): void + { + $query = Query::from("employees") + ->keep("emp_no", "first_name", "last_name", "height"); + $this->assertEquals(<<keep("h*"); + $this->assertEquals(<<keep("h*", "first_name"); + $this->assertEquals(<<where("field == \"value\"") + ->limit(1000); + $this->assertEquals(<<stats("AVG(field1)")->by("field2") + ->limit(20000); + $this->assertEquals(<<lookupJoin("threat_list")->on("source.IP") + ->where("threat_level IS NOT NULL"); + $this->assertEquals(<<lookupJoin("host_inventory")->on("host.name") + ->lookupJoin("ownerships")->on("host.name"); + $this->assertEquals(<<lookupJoin("service_owners")->on("service_id"); + $this->assertEquals(<<eval(language_code: "languages") + ->where("emp_no >= 10091", "emp_no < 10094") + ->lookupJoin("languages_lookup")->on("language_code"); + $this->assertEquals(<<= 10091 AND emp_no < 10094 + | LOOKUP JOIN languages_lookup ON language_code\n + ESQL, + (string) $query + ); + } + + public function testMvExpand(): void + { + $query = Query::row(a: [1, 2, 3], b: "b", j: ["a", "b"]) + ->mvExpand("a"); + $this->assertEquals(<<keep("first_name", "last_name", "still_hired") + ->rename(still_hired: "employed"); + $this->assertEquals(<<metadata("_score") + ->where("MATCH(description, \"hobbit\")") + ->sort("_score DESC") + ->limit(100) + ->rerank("hobbit")->on("description")->with("test_reranker") + ->limit(3) + ->keep("title", "_score"); + $this->assertEquals(<<metadata("_score") + ->where("MATCH(description, \"hobbit\") OR MATCH(author, \"Tolkien\")") + ->sort("_score DESC") + ->limit(100) + ->rerank(rerank_score: "hobbit")->on("description", "author")->with("test_reranker") + ->sort("rerank_score") + ->limit(3) + ->keep("title", "_score", "rerank_score"); + $this->assertEquals(<<metadata("_score") + ->where("MATCH(description, \"hobbit\") OR MATCH(author, \"Tolkien\")") + ->sort("_score DESC") + ->limit(100) + ->rerank(rerank_score: "hobbit")->on("description", "author")->with("test_reranker") + ->eval(original_score: "_score", _score: "rerank_score + original_score") + ->sort("_score") + ->limit(3) + ->keep("title", "original_score", "rerank_score", "_score"); + $this->assertEquals(<<keep("emp_no") + ->sample(0.05); + $this->assertEquals(<<keep("first_name", "last_name", "height") + ->sort("height"); + $this->assertEquals(<<keep("first_name", "last_name", "height") + ->sort("height DESC"); + $this->assertEquals(<<keep("first_name", "last_name", "height") + ->sort("height DESC", "first_name ASC"); + $this->assertEquals(<<keep("first_name", "last_name", "height") + ->sort("first_name ASC NULLS FIRST"); + $this->assertEquals(<<stats(count: "COUNT(emp_no)")->by("languages") + ->sort("languages"); + $this->assertEquals(<<stats(avg_lang: "AVG(languages)"); + $this->assertEquals(<<stats( + avg_lang: "AVG(languages)", + max_lang: "MAX(languages)", + ); + $this->assertEquals(<<stats( + avg50s: "AVG(salary) WHERE birth_date < \"1960-01-01\"", + avg60s: "AVG(salary) WHERE birth_date >= \"1960-01-01\"" + )->by("gender") + ->sort("gender"); + $this->assertEquals(<<= "1960-01-01" + BY gender + | SORT gender\n + ESQL, + (string) $query + ); + $query = Query::from("employees") + ->eval(Ks: "salary / 1000") + ->stats( + under_40K: "COUNT(*) WHERE Ks < 40", + inbetween: "COUNT(*) WHERE (40 <= Ks) AND (Ks < 60)", + over_60K: "COUNT(*) WHERE 60 <= Ks", + total: "COUNT(*)", + ); + $this->assertEquals(<<stats("MIN(i)")->by("a") + ->sort("a ASC"); + $this->assertEquals(<<eval(hired: "DATE_FORMAT(\"yyyy\", hire_date)") + ->stats(avg_salary: "AVG(salary)")->by("hired", "languages.long") + ->eval(avg_salary: "ROUND(avg_salary)") + ->sort("hired", "languages.long"); + $this->assertEquals(<<keep("first_name", "last_name", "still_hired") + ->where("still_hired == true"); + $this->assertEquals(<<where("@timestamp > NOW() - 1 hour"); + $this->assertEquals(<< NOW() - 1 hour\n + ESQL, + (string) $query + ); + $query = Query::from("employees") + ->keep("first_name", "last_name", "height") + ->where("LENGTH(first_name) < 4"); + $this->assertEquals(<<