diff --git a/.changeset/first-version.md b/.changeset/first-version.md index fe387c4..f78b71b 100644 --- a/.changeset/first-version.md +++ b/.changeset/first-version.md @@ -10,6 +10,7 @@ First version of `meili`, the Meilisearch CLI built for coding agents. - Tasks: `meili get tasks`, narrowed by `--ids`, `--statuses`, `--types`, `--index-ids`, `--batch-ids`, `--canceled-by` and the date filters of the route, paging with `--limit` and `--from` and following the `next` cursor with `--all`; `meili cancel tasks` and `meili delete tasks --yes`, which take the same filters and answer a task. - Search: `meili search --index --q `, on `POST /indexes/{index_uid}/search`, with every non-experimental search parameter of the route as a flag, from `--filter` and `--sort` to `--hybrid` and `--vector`; `--data` or `--file` carries a whole search body, which a flag of the same field overrides. `meili multi-search --queries ''`, on `POST /multi-search`, runs several queries in one request, each naming its index, answered side by side or merged into one ranked list with `--federation`. - Similar documents: `meili similar --index --id --embedder `, on `POST /indexes/{index_uid}/similar`, returns the documents of the index closest to one of them, narrowed by `--filter`, `--limit` and `--offset`; `--data` or `--file` carries a whole body, which a flag of the same field overrides. +- Facet values: `meili facet-search --index --facet-name `, on `POST /indexes/{index_uid}/facet-search`, searches the values of one filterable attribute with `--facet-query` and counts them on the documents `--q` and `--filter` keep; `--data` or `--file` carries a whole body, which a flag of the same field overrides. - Settings: `meili get settings --index `, which returns every setting with its current value or its default; `meili update settings --index `, with one flag per setting, from `--searchable-attributes` and `--stop-words` to `--typo-tolerance` and `--embedders`, where a value of `null` resets that one setting and `--data` or `--file` carries a whole settings body; `meili delete settings --index --yes`, which resets them all. - `meili schema` prints the index of the command tree as JSON, one entry per command with its routes, and `meili schema --command "get indexes"` prints one command in full, with its key actions and whether it returns a task. - Connection through `--project-url` and `--project-api-key`, or `MEILI_PROJECT_URL` and `MEILI_PROJECT_API_KEY`; `MEILI_MANAGEMENT_TOKEN` and `MEILI_MANAGEMENT_URL` for the Management API. diff --git a/docs/contract/commands.md b/docs/contract/commands.md index 54dce1c..4c7abe8 100644 --- a/docs/contract/commands.md +++ b/docs/contract/commands.md @@ -28,7 +28,7 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i 7. **Values.** A scalar is passed as it is. A boolean is a bare flag, `--retrieve-vectors`, unless the route also reads `false` from it, as the settings do: it then takes its value, `--facet-search false`, because a bare flag says neither `false` nor `null`. An array of scalars is comma-separated, `--fields id,title`, as the API's own GET syntax. An object or a nested array is JSON text, `--typo-tolerance '{"enabled":false}'`. The value `null` sends a JSON null, `--expires-at null`, `--stop-words null`. 8. **Body input.** On every route with a body, `--file ` or `--data ''` replaces the body fields; `--file -` reads stdin. Path and query flags stay valid next to them. A body-field flag given together with `--file` or `--data` overrides that field, as a flag overrides its environment variable. When a route's body is not an object of named fields but the value itself, the array of identifiers of `delete documents --ids` or the documents of `add documents`, the body is that value, and its shape selects the route among the ones the command covers: `delete documents --data '[1,2]'` deletes by identifiers, `--data '{"filter":…}'` deletes by filter. Documents are sent as JSON, the only content type the document declares. 9. **Routes that are not implemented.** The `GET` twin of a `POST` route that takes the same parameters in a body (search, similar, the list of documents); the unit route of a resource whose list filters by identifiers (documents, tasks, batches); the settings sub-routes, since `update settings` sends any subset and `null` resets one setting; `GET /api/v2/meilisearch-versions`. Each is recorded without command in the exceptions. -10. **Experimental routes** are out of the contract until the feature is stable. Implementing one is a contract change: an exception row with the reason. `compact` is the one exception today. A body field or a query parameter that an experimental feature gates is out of the contract the same way: the command declares no flag for it, and `--data` or `--file` still carries it to the API. `useNetwork` on `get documents`, and `media`, `personalize`, `useNetwork` and `inlineRule` on `search`. These stay out of the exceptions table, which lists routes. +10. **Experimental routes** are out of the contract until the feature is stable. Implementing one is a contract change: an exception row with the reason. `compact` is the one exception today. A body field or a query parameter that an experimental feature gates is out of the contract the same way: the command declares no flag for it, and `--data` or `--file` still carries it to the API. `useNetwork` on `get documents`, `media`, `personalize`, `useNetwork` and `inlineRule` on `search`, and `media` and `useNetwork` on `facet-search`. These stay out of the exceptions table, which lists routes. 11. **Destructive commands.** A command of the verb `delete` takes `--yes` and refuses to run without it: `cli_usage`, exit 2, before any network call. `--yes` is a flag to write, never a question the CLI asks, see `output.md`. | Routes | Command | @@ -47,6 +47,7 @@ Every API command declares its routes, `static routes = ["GET /indexes", "GET /i | `POST …/documents/delete-batch`, `POST …/documents/delete`, `DELETE …/documents` | `meili delete documents --index (--ids \| --filter \| --all) --yes` | | `POST /indexes/{index_uid}/search` | `meili search --index --q ` | | `POST /indexes/{index_uid}/similar` | `meili similar --index --id --embedder ` | +| `POST /indexes/{index_uid}/facet-search` | `meili facet-search --index --facet-name ` | | `POST /multi-search` | `meili multi-search --queries [--federation ]` | | `GET /indexes/{index_uid}/settings` | `meili get settings --index ` | | `PATCH /indexes/{index_uid}/settings` | `meili update settings --index -- ` | diff --git a/oclif.manifest.json b/oclif.manifest.json index d807140..634b011 100644 --- a/oclif.manifest.json +++ b/oclif.manifest.json @@ -3018,6 +3018,310 @@ "tasks.js" ] }, + "facet-search": { + "aliases": [], + "args": {}, + "description": "Search the values of one facet attribute of an index, counted on the documents --q and --filter keep.", + "examples": [ + "<%= config.bin %> <%= command.id %> --index movies --facet-name genres --facet-query ac", + "<%= config.bin %> <%= command.id %> --index movies --facet-name genres --facet-query ac --output json", + "<%= config.bin %> <%= command.id %> --index movies --facet-name genres --q horror --filter 'rating > 4'", + "<%= config.bin %> <%= command.id %> --index movies --data '{\"facetName\":\"genres\",\"facetQuery\":\"ac\"}'" + ], + "flags": { + "output": { + "char": "o", + "description": "json (default): one document, the API body as it is, indented in a terminal, compact when piped. ndjson: one item per line, list commands only, streams with --all.", + "name": "output", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "options": [ + "json", + "ndjson" + ], + "type": "option" + }, + "debug": { + "description": "Print every HTTP request and response on stderr, secrets redacted.", + "name": "debug", + "allowNo": false, + "type": "boolean" + }, + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "name": "project-url", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "name": "project-api-key", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "name": "project", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "index": { + "description": "The index to search the facet values in.", + "name": "index", + "required": true, + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "facet-name": { + "description": "The facet attribute to search in. It must be one of the filterable attributes of the index.", + "name": "facet-name", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "facet-query": { + "description": "The terms the facet values are matched against, the first word only. Omit it to return every value, up to 100.", + "name": "facet-query", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "q": { + "description": "The search terms that narrow the documents the facet values are computed on, not the values themselves.", + "name": "q", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "filter": { + "description": "Filter expression, for example `rating > 4`. Its array-of-arrays form goes through --data.", + "name": "filter", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "matching-strategy": { + "description": "How the terms of --q are relaxed when there are not enough results.", + "name": "matching-strategy", + "hasDynamicHelp": false, + "multiple": false, + "options": [ + "last", + "all", + "frequency" + ], + "type": "option" + }, + "attributes-to-search-on": { + "description": "Restrict the search of --q to these searchable attributes, comma-separated.", + "name": "attributes-to-search-on", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "ranking-score-threshold": { + "description": "Count only the documents whose ranking score reaches this value, between 0.0 and 1.0.", + "name": "ranking-score-threshold", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "locales": { + "description": "ISO-639 locales of the query, comma-separated, instead of auto-detection.", + "name": "locales", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "hybrid": { + "description": "Hybrid search as JSON, for example `{\"embedder\":\"default\",\"semanticRatio\":0.5}`.", + "name": "hybrid", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "vector": { + "description": "Query vector for a user-provided embedder, comma-separated numbers.", + "name": "vector", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "exhaustive-facet-count": { + "description": "Count the facet values exhaustively instead of estimating the counts.", + "name": "exhaustive-facet-count", + "allowNo": false, + "type": "boolean" + }, + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "exclusive": [ + "data" + ], + "name": "file", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "name": "data", + "hasDynamicHelp": false, + "helpValue": "", + "multiple": false, + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "facet-search", + "pluginAlias": "@meilisearch/cli", + "pluginName": "@meilisearch/cli", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "api": "meilisearch", + "routes": [ + "POST /indexes/{index_uid}/facet-search" + ], + "keyActions": [ + "search" + ], + "connectionFlags": { + "project-url": { + "description": "Project URL, the Meilisearch instance to talk to.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project-api-key": { + "description": "Project API key. `-` reads it from stdin.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "project": { + "description": "A project of the config file. Cannot be combined with --project-url. Required when the file has several projects.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "waitFlags": { + "wait": { + "description": "Poll the task until it reaches a final status, then print the final task.", + "allowNo": false, + "type": "boolean" + }, + "timeout": { + "description": "Maximum wait time, for example 30s, 2m. Default 60s. Only with --wait.", + "helpValue": "", + "dependsOn": [ + "wait" + ], + "input": [], + "multiple": false, + "type": "option" + } + }, + "paginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "offset": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "fromPaginationFlags": { + "limit": { + "description": "Passed to the API.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "from": { + "description": "Passed to the API: the uid the page starts at, going down.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + }, + "all": { + "description": "Fetch every page. Streams with ndjson, buffers into one array with json.", + "allowNo": false, + "type": "boolean" + } + }, + "bodyInputFlags": { + "file": { + "description": "Read the body from a file. `-` reads from stdin.", + "helpValue": "", + "exclusive": [ + "data" + ], + "input": [], + "multiple": false, + "type": "option" + }, + "data": { + "description": "Inline JSON body.", + "helpValue": "", + "input": [], + "multiple": false, + "type": "option" + } + }, + "confirmFlags": { + "yes": { + "required": true, + "description": "Confirm the deletion. Nothing is sent to the API without it.", + "allowNo": false, + "type": "boolean" + } + }, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "facet-search.js" + ] + }, "get:documents": { "aliases": [], "args": {}, diff --git a/src/commands/facet-search.ts b/src/commands/facet-search.ts new file mode 100644 index 0000000..6518838 --- /dev/null +++ b/src/commands/facet-search.ts @@ -0,0 +1,109 @@ +import { Flags } from "@oclif/core"; +import type { HybridSearch, SearchForFacetValuesParams } from "meilisearch"; +import { type Api, BaseCommand } from "../lib/base-command.js"; +import { readJsonObjectBody } from "../lib/input.js"; +import { asJson, asNumber } from "../lib/values.js"; + +export default class FacetSearch extends BaseCommand { + static api: Api = "meilisearch"; + + static routes = ["POST /indexes/{index_uid}/facet-search"]; + + static keyActions = ["search"]; + + static override description = + "Search the values of one facet attribute of an index, counted on the documents --q and --filter keep."; + + static override examples = [ + "<%= config.bin %> <%= command.id %> --index movies --facet-name genres --facet-query ac", + "<%= config.bin %> <%= command.id %> --index movies --facet-name genres --facet-query ac --output json", + "<%= config.bin %> <%= command.id %> --index movies --facet-name genres --q horror --filter 'rating > 4'", + '<%= config.bin %> <%= command.id %> --index movies --data \'{"facetName":"genres","facetQuery":"ac"}\'', + ]; + + static override flags = { + ...BaseCommand.connectionFlags, + index: Flags.string({ + required: true, + description: "The index to search the facet values in.", + helpValue: "", + }), + "facet-name": Flags.string({ + description: + "The facet attribute to search in. It must be one of the filterable attributes of the index.", + helpValue: "", + }), + "facet-query": Flags.string({ + description: + "The terms the facet values are matched against, the first word only. Omit it to return every value, up to 100.", + helpValue: "", + }), + q: Flags.string({ + description: + "The search terms that narrow the documents the facet values are computed on, not the values themselves.", + helpValue: "", + }), + filter: Flags.string({ + description: + "Filter expression, for example `rating > 4`. Its array-of-arrays form goes through --data.", + helpValue: "", + }), + "matching-strategy": Flags.option({ + options: ["last", "all", "frequency"] as const, + description: "How the terms of --q are relaxed when there are not enough results.", + })(), + "attributes-to-search-on": Flags.string({ + description: "Restrict the search of --q to these searchable attributes, comma-separated.", + helpValue: "", + }), + "ranking-score-threshold": Flags.string({ + description: + "Count only the documents whose ranking score reaches this value, between 0.0 and 1.0.", + helpValue: "", + }), + locales: Flags.string({ + description: "ISO-639 locales of the query, comma-separated, instead of auto-detection.", + helpValue: "", + }), + hybrid: Flags.string({ + description: + 'Hybrid search as JSON, for example `{"embedder":"default","semanticRatio":0.5}`.', + helpValue: "", + }), + vector: Flags.string({ + description: "Query vector for a user-provided embedder, comma-separated numbers.", + helpValue: "", + }), + "exhaustive-facet-count": Flags.boolean({ + description: "Count the facet values exhaustively instead of estimating the counts.", + }), + ...BaseCommand.bodyInputFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(FacetSearch); + const fromFlags: Partial = { + facetName: flags["facet-name"], + facetQuery: flags["facet-query"], + q: flags.q, + filter: flags.filter, + matchingStrategy: flags["matching-strategy"], + attributesToSearchOn: flags["attributes-to-search-on"]?.split(","), + rankingScoreThreshold: asNumber(flags["ranking-score-threshold"]) as number | undefined, + locales: flags.locales?.split(","), + hybrid: asJson(flags.hybrid) as HybridSearch | undefined, + vector: flags.vector?.split(",").map(asNumber) as number[] | undefined, + exhaustiveFacetCount: flags["exhaustive-facet-count"] ? true : undefined, + }; + + // The body of --file or --data first, then the flags, which override a field of it (rule 8). + // The undefined entries are dropped rather than spread: an absent flag would otherwise + // erase the field the body carries, since JSON.stringify leaves an undefined value out. + const params = { + ...readJsonObjectBody(flags), + ...Object.fromEntries(Object.entries(fromFlags).filter(([, value]) => value !== undefined)), + } as SearchForFacetValuesParams; + + await this.output(await this.meilisearch.index(flags.index).searchForFacetValues(params)); + } +} diff --git a/test/commands/__snapshots__/facet-search.test.ts.snap b/test/commands/__snapshots__/facet-search.test.ts.snap new file mode 100644 index 0000000..044cd93 --- /dev/null +++ b/test/commands/__snapshots__/facet-search.test.ts.snap @@ -0,0 +1,101 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`meili facet-search > passes the API error of an attribute that is not filterable through and exits 1 1`] = ` +{ + "exitCode": 1, + "stderr": "{"error":{"code":"invalid_facet_search_facet_name","type":"invalid_request","message":"Attribute \`title\` is not facet-searchable. Available facet-searchable attributes patterns are: \`genres\`.","link":"https://docs.meilisearch.com/errors#invalid_facet_search_facet_name"}} +", + "stdout": "", +} +`; + +exports[`meili facet-search > prints its help without any network call 1`] = ` +"Search the values of one facet attribute of an index, counted on the documents --q and --filter keep. + +USAGE + $ meili facet-search --index [-o ] [--debug] + [--project-url ] [--project-api-key ] [--project ] + [--facet-name ] [--facet-query ] [--q ] [--filter + ] [--matching-strategy last|all|frequency] + [--attributes-to-search-on ] [--ranking-score-threshold ] + [--locales ] [--hybrid ] [--vector ] + [--exhaustive-facet-count] [--file | --data ] + +FLAGS + -o, --output= json (default): one document, the API + body as it is, indented in a terminal, + compact when piped. ndjson: one item + per line, list commands only, streams + with --all. + --attributes-to-search-on= Restrict the search of --q to these + searchable attributes, + comma-separated. + --data= Inline JSON body. + --debug Print every HTTP request and response + on stderr, secrets redacted. + --exhaustive-facet-count Count the facet values exhaustively + instead of estimating the counts. + --facet-name= The facet attribute to search in. It + must be one of the filterable + attributes of the index. + --facet-query= The terms the facet values are matched + against, the first word only. Omit it + to return every value, up to 100. + --file= Read the body from a file. \`-\` reads + from stdin. + --filter= Filter expression, for example \`rating + > 4\`. Its array-of-arrays form goes + through --data. + --hybrid= Hybrid search as JSON, for example + \`{"embedder":"default","semanticRatio" + :0.5}\`. + --index= (required) The index to search the + facet values in. + --locales= ISO-639 locales of the query, + comma-separated, instead of + auto-detection. + --matching-strategy=