diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c2e9bb..4696f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### New features + +- `udf` command to manage Cosmos DB for NoSQL user-defined functions on the current container: `list`, `show`, `exists` (returns a boolean usable in `if`/`while` conditions), `create` (from a JavaScript file or piped body, with `--force` to replace), and `delete`. ([#103](https://github.com/Azure/CosmosDBShell/issues/103)) +- `trigger` command to manage Cosmos DB for NoSQL triggers on the current container: `list`, `show`, `exists` (returns a boolean usable in `if`/`while` conditions), `create` (from a JavaScript file or piped body, with `--type` for pre/post, `--operation` for the operation, and `--force` to replace), and `delete`. ([#103](https://github.com/Azure/CosmosDBShell/issues/103)) + ## 1.1.4-preview — 2026-05-21 First release on the 1.1 line. A pretty packed cycle. The headline change is **ARM-based control plane for database and container management**, but there’s also a fully reworked CLI, two new item commands, a much friendlier shell experience for newcomers, and a long list of paper-cut fixes. diff --git a/CosmosDBShell.Tests/CommandTests/TriggerCommandTests.cs b/CosmosDBShell.Tests/CommandTests/TriggerCommandTests.cs new file mode 100644 index 0000000..ee6ee02 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/TriggerCommandTests.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.Core; +using Microsoft.Azure.Cosmos.Scripts; + +/// +/// Unit tests for the pure helpers on : subcommand +/// normalization and trigger type/operation parsing. +/// +public class TriggerCommandTests +{ + [Theory] + [InlineData("LIST", "list")] + [InlineData(" Show ", "show")] + [InlineData("Create", "create")] + [InlineData(null, "")] + [InlineData("", "")] + public void NormalizeSubcommand_TrimsAndLowercases(string? input, string expected) + { + Assert.Equal(expected, TriggerCommand.NormalizeSubcommand(input)); + } + + [Theory] + [InlineData("pre", TriggerType.Pre)] + [InlineData("PRE", TriggerType.Pre)] + [InlineData(" post ", TriggerType.Post)] + public void ParseTriggerType_ValidValues_AreParsed(string input, TriggerType expected) + { + Assert.Equal(expected, TriggerCommand.ParseTriggerType(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("middle")] + public void ParseTriggerType_InvalidValues_Throw(string? input) + { + Assert.Throws(() => TriggerCommand.ParseTriggerType(input)); + } + + [Theory] + [InlineData(null, TriggerOperation.All)] + [InlineData("", TriggerOperation.All)] + [InlineData("all", TriggerOperation.All)] + [InlineData("Create", TriggerOperation.Create)] + [InlineData("replace", TriggerOperation.Replace)] + [InlineData("DELETE", TriggerOperation.Delete)] + [InlineData(" update ", TriggerOperation.Update)] + public void ParseTriggerOperation_ValidValues_AreParsed(string? input, TriggerOperation expected) + { + Assert.Equal(expected, TriggerCommand.ParseTriggerOperation(input)); + } + + [Theory] + [InlineData("insert")] + [InlineData("bogus")] + public void ParseTriggerOperation_InvalidValues_Throw(string input) + { + Assert.Throws(() => TriggerCommand.ParseTriggerOperation(input)); + } +} diff --git a/CosmosDBShell.Tests/CommandTests/UdfCommandTests.cs b/CosmosDBShell.Tests/CommandTests/UdfCommandTests.cs new file mode 100644 index 0000000..0a854fb --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/UdfCommandTests.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using Azure.Data.Cosmos.Shell.Commands; + +/// +/// Unit tests for the pure helpers on . +/// +public class UdfCommandTests +{ + [Theory] + [InlineData("LIST", "list")] + [InlineData(" Show ", "show")] + [InlineData("Create", "create")] + [InlineData(null, "")] + [InlineData("", "")] + public void NormalizeSubcommand_TrimsAndLowercases(string? input, string expected) + { + Assert.Equal(expected, UdfCommand.NormalizeSubcommand(input)); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs new file mode 100644 index 0000000..c3acd33 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs @@ -0,0 +1,360 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Commands; + +using System.Net; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.Util; +using global::Azure.Data.Cosmos.Shell.Core; +using global::Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos.Scripts; +using Spectre.Console; + +[CosmosCommand("trigger")] +[CosmosExample("trigger list", Description = "List the triggers in the current container")] +[CosmosExample("trigger show myTrigger", Description = "Display the body of a trigger")] +[CosmosExample("trigger create myTrigger ./myTrigger.js --type pre --operation create", Description = "Create a pre-trigger for create operations")] +[CosmosExample("trigger create myTrigger ./myTrigger.js --type post --operation all --force", Description = "Create or replace a post-trigger for all operations")] +[CosmosExample("trigger delete myTrigger", Description = "Delete a trigger")] +#pragma warning disable SA1118 // Parameter should not span multiple lines +[McpAnnotation( + Title = "Triggers", + Description = @" +Manages JavaScript triggers on the current Cosmos DB container through subcommands: +- 'list' returns the trigger ids in the container with their type and operation. +- 'show ' returns the body of a trigger. +- 'create ' creates a trigger from a JavaScript file. --type selects pre or post, --operation selects all/create/replace/delete/update, and --force replaces an existing one. +- 'delete ' removes a trigger. +This command is restricted in MCP. Run it manually in the shell. +", + Restricted = true, + Destructive = true, + OpenWorld = true)] +#pragma warning restore SA1118 // Parameter should not span multiple lines +internal class TriggerCommand : CosmosCommand +{ + [CosmosParameter("subcommand", RequiredErrorKey = "command-trigger-error-missing_subcommand")] + public string Subcommand { get; init; } = string.Empty; + + [CosmosParameter("name", IsRequired = false)] + public string? Name { get; init; } + + [CosmosParameter("value", IsRequired = false)] + public string? Value { get; init; } + + [CosmosOption("type", "t")] + public string? Type { get; init; } + + [CosmosOption("operation", "op")] + public string? Operation { get; init; } + + [CosmosOption("force", "f")] + public bool? Force { get; init; } + + [CosmosOption("database", "db")] + public string? Database { get; init; } + + [CosmosOption("container", "con")] + public string? Container { get; init; } + + /// + /// Normalizes a subcommand token to its canonical lower-case form. + /// + internal static string NormalizeSubcommand(string? value) => (value ?? string.Empty).Trim().ToLowerInvariant(); + + /// + /// Parses the --type option into a . Accepts + /// 'pre' and 'post' (case-insensitive). + /// + internal static TriggerType ParseTriggerType(string? value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "pre" => TriggerType.Pre, + "post" => TriggerType.Post, + _ => throw new CommandException( + "trigger", + MessageService.GetArgsString("command-trigger-error-invalid_type", "type", value ?? string.Empty)), + }; + } + + /// + /// Parses the --operation option into a . + /// Defaults to when no value is supplied. + /// + internal static TriggerOperation ParseTriggerOperation(string? value) + { + return (value ?? string.Empty).Trim().ToLowerInvariant() switch + { + "" or "all" => TriggerOperation.All, + "create" => TriggerOperation.Create, + "replace" => TriggerOperation.Replace, + "delete" => TriggerOperation.Delete, + "update" => TriggerOperation.Update, + _ => throw new CommandException( + "trigger", + MessageService.GetArgsString("command-trigger-error-invalid_operation", "operation", value ?? string.Empty)), + }; + } + + public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + var subcommand = NormalizeSubcommand(this.Subcommand); + if (subcommand.Length == 0) + { + throw new CommandException("trigger", MessageService.GetString("command-trigger-error-missing_subcommand")); + } + + if (shell.State is not ConnectedState connectedState) + { + throw new NotConnectedException("trigger"); + } + + var (_, _, container) = await ResolveContainerAsync( + connectedState.Client, + shell.State, + this.Database, + this.Container, + "trigger", + token); + + return subcommand switch + { + "list" or "ls" => await this.ListAsync(container, commandState, token), + "show" or "cat" => await this.ShowAsync(container, commandState, token), + "exists" => await this.ExistsAsync(container, commandState, token), + "create" or "set" => await this.CreateAsync(container, commandState, token), + "delete" or "rm" => await this.DeleteAsync(container, commandState, token), + _ => throw new CommandException( + "trigger", + MessageService.GetArgsString("command-trigger-error-invalid_subcommand", "subcommand", subcommand)), + }; + } + + private static CommandException NotFound(string name, Exception inner) => + new("trigger", MessageService.GetArgsString("command-trigger-error-not_found", "name", name), inner); + + private async Task ListAsync(Container container, CommandState commandState, CancellationToken token) + { + var items = new List(); + var rows = new List<(string Id, string Type, string Operation, int BodyLength)>(); + using var iterator = container.Scripts.GetTriggerQueryIterator(); + while (iterator.HasMoreResults) + { + foreach (var properties in await iterator.ReadNextAsync(token)) + { + items.Add(new + { + id = properties.Id, + triggerType = properties.TriggerType.ToString(), + triggerOperation = properties.TriggerOperation.ToString(), + etag = properties.ETag, + bodyLength = properties.Body?.Length ?? 0, + }); + rows.Add(( + properties.Id, + properties.TriggerType.ToString(), + properties.TriggerOperation.ToString(), + properties.Body?.Length ?? 0)); + } + } + + if (rows.Count == 0) + { + AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-trigger-list-empty"))); + } + else + { + AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-title"))); + var table = new Table(); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-id")))); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-type")))); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-operation")))); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-size"))).RightAligned()); + + foreach (var row in rows) + { + table.AddRow( + Theme.FormatTableValue(row.Id), + Theme.FormatTableValue(row.Type), + Theme.FormatTableValue(row.Operation), + Theme.FormatTableValue(row.BodyLength.ToString())); + } + + AnsiConsole.Write(table); + } + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(items)); + commandState.IsPrinted = true; + return commandState; + } + + private async Task ShowAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + try + { + var response = await container.Scripts.ReadTriggerAsync(name, cancellationToken: token); + commandState.Result = new ShellText(response.Resource.Body); + return commandState; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + } + + private async Task ExistsAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + bool exists; + try + { + await container.Scripts.ReadTriggerAsync(name, cancellationToken: token); + exists = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + exists = false; + } + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + exists ? "command-trigger-exists-yes" : "command-trigger-exists-no", + "name", + name)); + + commandState.Result = new ShellBool(exists); + commandState.IsPrinted = true; + return commandState; + } + + private async Task CreateAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + bool force = this.Force == true; + + if (string.IsNullOrWhiteSpace(this.Type)) + { + throw new CommandException("trigger", MessageService.GetString("command-trigger-error-missing_type")); + } + + var triggerType = ParseTriggerType(this.Type); + var triggerOperation = ParseTriggerOperation(this.Operation); + + var body = this.TryReadExplicitBody(commandState) + ?? throw new CommandException("trigger", MessageService.GetString("command-trigger-error-missing_file")); + + var properties = new TriggerProperties + { + Id = name, + Body = body, + TriggerType = triggerType, + TriggerOperation = triggerOperation, + }; + + TriggerResponse response; + bool replaced; + if (force) + { + try + { + response = await container.Scripts.ReplaceTriggerAsync(properties, cancellationToken: token); + replaced = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + response = await container.Scripts.CreateTriggerAsync(properties, cancellationToken: token); + replaced = false; + } + } + else + { + try + { + response = await container.Scripts.CreateTriggerAsync(properties, cancellationToken: token); + replaced = false; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + throw new CommandException( + "trigger", + MessageService.GetArgsString("command-trigger-error-already_exists", "name", name), + ex); + } + } + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + replaced ? "command-trigger-replaced" : "command-trigger-created", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new + { + id = name, + triggerType = triggerType.ToString(), + triggerOperation = triggerOperation.ToString(), + })); + commandState.IsPrinted = true; + return commandState; + } + + private async Task DeleteAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + try + { + var response = await container.Scripts.DeleteTriggerAsync(name, cancellationToken: token); + ShellInterpreter.WriteLine(MessageService.GetArgsString( + "command-trigger-deleted", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, deleted = true })); + commandState.IsPrinted = true; + return commandState; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + } + + private string RequireName() + { + if (string.IsNullOrWhiteSpace(this.Name)) + { + throw new CommandException("trigger", MessageService.GetString("command-trigger-error-missing_name")); + } + + return this.Name; + } + + private string? TryReadExplicitBody(CommandState commandState) + { + if (!string.IsNullOrWhiteSpace(this.Value)) + { + if (!File.Exists(this.Value)) + { + throw new CommandException( + "trigger", + MessageService.GetArgsString("command-trigger-error-file_not_found", "file", this.Value)); + } + + return File.ReadAllText(this.Value); + } + + var piped = commandState.Result?.ConvertShellObject(DataType.Text) as string; + return string.IsNullOrEmpty(piped) ? null : piped; + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs new file mode 100644 index 0000000..dd99e4d --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs @@ -0,0 +1,292 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Commands; + +using System.Net; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Mcp; +using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.Util; +using global::Azure.Data.Cosmos.Shell.Core; +using global::Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos.Scripts; +using Spectre.Console; + +[CosmosCommand("udf")] +[CosmosExample("udf list", Description = "List the user-defined functions in the current container")] +[CosmosExample("udf show myFunc", Description = "Display the body of a user-defined function")] +[CosmosExample("udf create myFunc ./myFunc.js", Description = "Create a user-defined function from a JavaScript file")] +[CosmosExample("udf create myFunc ./myFunc.js --force", Description = "Create or replace a user-defined function")] +[CosmosExample("udf delete myFunc", Description = "Delete a user-defined function")] +#pragma warning disable SA1118 // Parameter should not span multiple lines +[McpAnnotation( + Title = "User-Defined Functions", + Description = @" +Manages JavaScript user-defined functions (UDFs) on the current Cosmos DB container through subcommands: +- 'list' returns the user-defined function ids in the container. +- 'show ' returns the body of a user-defined function. +- 'create ' creates a user-defined function from a JavaScript file. Pass --force to replace an existing one. +- 'delete ' removes a user-defined function. +This command is restricted in MCP. Run it manually in the shell. +", + Restricted = true, + Destructive = true, + OpenWorld = true)] +#pragma warning restore SA1118 // Parameter should not span multiple lines +internal class UdfCommand : CosmosCommand +{ + [CosmosParameter("subcommand", RequiredErrorKey = "command-udf-error-missing_subcommand")] + public string Subcommand { get; init; } = string.Empty; + + [CosmosParameter("name", IsRequired = false)] + public string? Name { get; init; } + + [CosmosParameter("value", IsRequired = false)] + public string? Value { get; init; } + + [CosmosOption("force", "f")] + public bool? Force { get; init; } + + [CosmosOption("database", "db")] + public string? Database { get; init; } + + [CosmosOption("container", "con")] + public string? Container { get; init; } + + /// + /// Normalizes a subcommand token to its canonical lower-case form. + /// + internal static string NormalizeSubcommand(string? value) => (value ?? string.Empty).Trim().ToLowerInvariant(); + + public async override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + var subcommand = NormalizeSubcommand(this.Subcommand); + if (subcommand.Length == 0) + { + throw new CommandException("udf", MessageService.GetString("command-udf-error-missing_subcommand")); + } + + if (shell.State is not ConnectedState connectedState) + { + throw new NotConnectedException("udf"); + } + + var (_, _, container) = await ResolveContainerAsync( + connectedState.Client, + shell.State, + this.Database, + this.Container, + "udf", + token); + + return subcommand switch + { + "list" or "ls" => await this.ListAsync(container, commandState, token), + "show" or "cat" => await this.ShowAsync(container, commandState, token), + "exists" => await this.ExistsAsync(container, commandState, token), + "create" or "set" => await this.CreateAsync(container, commandState, token), + "delete" or "rm" => await this.DeleteAsync(container, commandState, token), + _ => throw new CommandException( + "udf", + MessageService.GetArgsString("command-udf-error-invalid_subcommand", "subcommand", subcommand)), + }; + } + + private static CommandException NotFound(string name, Exception inner) => + new("udf", MessageService.GetArgsString("command-udf-error-not_found", "name", name), inner); + + private async Task ListAsync(Container container, CommandState commandState, CancellationToken token) + { + var items = new List(); + var rows = new List<(string Id, int BodyLength)>(); + using var iterator = container.Scripts.GetUserDefinedFunctionQueryIterator(); + while (iterator.HasMoreResults) + { + foreach (var properties in await iterator.ReadNextAsync(token)) + { + items.Add(new + { + id = properties.Id, + etag = properties.ETag, + bodyLength = properties.Body?.Length ?? 0, + }); + rows.Add(( + properties.Id, + properties.Body?.Length ?? 0)); + } + } + + if (rows.Count == 0) + { + AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-udf-list-empty"))); + } + else + { + AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-title"))); + var table = new Table(); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-column-id")))); + table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-column-size"))).RightAligned()); + + foreach (var row in rows) + { + table.AddRow( + Theme.FormatTableValue(row.Id), + Theme.FormatTableValue(row.BodyLength.ToString())); + } + + AnsiConsole.Write(table); + } + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(items)); + commandState.IsPrinted = true; + return commandState; + } + + private async Task ShowAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + try + { + var response = await container.Scripts.ReadUserDefinedFunctionAsync(name, cancellationToken: token); + commandState.Result = new ShellText(response.Resource.Body); + return commandState; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + } + + private async Task ExistsAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + bool exists; + try + { + await container.Scripts.ReadUserDefinedFunctionAsync(name, cancellationToken: token); + exists = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + exists = false; + } + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + exists ? "command-udf-exists-yes" : "command-udf-exists-no", + "name", + name)); + + commandState.Result = new ShellBool(exists); + commandState.IsPrinted = true; + return commandState; + } + + private async Task CreateAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + bool force = this.Force == true; + + var body = this.TryReadExplicitBody(commandState) + ?? throw new CommandException("udf", MessageService.GetString("command-udf-error-missing_file")); + + var properties = new UserDefinedFunctionProperties { Id = name, Body = body }; + + UserDefinedFunctionResponse response; + bool replaced; + if (force) + { + try + { + response = await container.Scripts.ReplaceUserDefinedFunctionAsync(properties, cancellationToken: token); + replaced = true; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + response = await container.Scripts.CreateUserDefinedFunctionAsync(properties, cancellationToken: token); + replaced = false; + } + } + else + { + try + { + response = await container.Scripts.CreateUserDefinedFunctionAsync(properties, cancellationToken: token); + replaced = false; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict) + { + throw new CommandException( + "udf", + MessageService.GetArgsString("command-udf-error-already_exists", "name", name), + ex); + } + } + + ShellInterpreter.WriteLine(MessageService.GetArgsString( + replaced ? "command-udf-replaced" : "command-udf-created", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name })); + commandState.IsPrinted = true; + return commandState; + } + + private async Task DeleteAsync(Container container, CommandState commandState, CancellationToken token) + { + var name = this.RequireName(); + + try + { + var response = await container.Scripts.DeleteUserDefinedFunctionAsync(name, cancellationToken: token); + ShellInterpreter.WriteLine(MessageService.GetArgsString( + "command-udf-deleted", + "name", + name, + "charge", + response.RequestCharge.ToString("F2"))); + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { id = name, deleted = true })); + commandState.IsPrinted = true; + return commandState; + } + catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) + { + throw NotFound(name, ex); + } + } + + private string RequireName() + { + if (string.IsNullOrWhiteSpace(this.Name)) + { + throw new CommandException("udf", MessageService.GetString("command-udf-error-missing_name")); + } + + return this.Name; + } + + private string? TryReadExplicitBody(CommandState commandState) + { + if (!string.IsNullOrWhiteSpace(this.Value)) + { + if (!File.Exists(this.Value)) + { + throw new CommandException( + "udf", + MessageService.GetArgsString("command-udf-error-file_not_found", "file", this.Value)); + } + + return File.ReadAllText(this.Value); + } + + var piped = commandState.Result?.ConvertShellObject(DataType.Text) as string; + return string.IsNullOrEmpty(piped) ? null : piped; + } +} diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index f1bc7e5..1533b40 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -336,6 +336,61 @@ command-index-error-show_no_args = 'index show' does not take any arguments. Use command-index-error-invalid_policy = Invalid indexing policy JSON. Please provide a valid Cosmos DB indexing policy. command-index-error-no_policy = The container has no indexing policy configured. +command-udf-description = Manages user-defined functions on a container via list, show, exists, create, and delete subcommands. +command-udf-description-subcommand = The action to perform: list, show, exists, create, or delete. +command-udf-description-name = The user-defined function id. +command-udf-description-value = The JavaScript file to read for create. +command-udf-description-force = Replace the user-defined function if it already exists. +command-udf-description-database = The database containing the container. +command-udf-description-container = The container that owns the user-defined functions. +command-udf-created = Created user-defined function '{ $name }' (RU charge: { $charge }). +command-udf-replaced = Replaced user-defined function '{ $name }' (RU charge: { $charge }). +command-udf-deleted = Deleted user-defined function '{ $name }' (RU charge: { $charge }). +command-udf-list-empty = No user-defined functions found. +command-udf-list-title = User-defined functions +command-udf-list-column-id = Id +command-udf-list-column-size = Size (chars) +command-udf-exists-yes = User-defined function '{ $name }' exists. +command-udf-exists-no = User-defined function '{ $name }' does not exist. +command-udf-error-missing_subcommand = Missing subcommand. Use one of: list, show, exists, create, delete. +command-udf-error-invalid_subcommand = Unknown subcommand '{ $subcommand }'. Use one of: list, show, exists, create, delete. +command-udf-error-missing_name = Missing user-defined function name. Specify the id, for example: udf show myFunc. +command-udf-error-missing_file = No source provided. Specify a JavaScript file or pipe the body in, for example: udf create myFunc ./myFunc.js. +command-udf-error-file_not_found = File not found: '{ $file }'. +command-udf-error-already_exists = User-defined function '{ $name }' already exists. Use --force to replace it. +command-udf-error-not_found = User-defined function '{ $name }' was not found. + +command-trigger-description = Manages triggers on a container via list, show, exists, create, and delete subcommands. +command-trigger-description-subcommand = The action to perform: list, show, exists, create, or delete. +command-trigger-description-name = The trigger id. +command-trigger-description-value = The JavaScript file to read for create. +command-trigger-description-type = The trigger type for create: pre or post. +command-trigger-description-operation = The operation the trigger fires on: all, create, replace, delete, or update. Defaults to all. +command-trigger-description-force = Replace the trigger if it already exists. +command-trigger-description-database = The database containing the container. +command-trigger-description-container = The container that owns the triggers. +command-trigger-created = Created trigger '{ $name }' (RU charge: { $charge }). +command-trigger-replaced = Replaced trigger '{ $name }' (RU charge: { $charge }). +command-trigger-deleted = Deleted trigger '{ $name }' (RU charge: { $charge }). +command-trigger-list-empty = No triggers found. +command-trigger-list-title = Triggers +command-trigger-list-column-id = Id +command-trigger-list-column-type = Type +command-trigger-list-column-operation = Operation +command-trigger-list-column-size = Size (chars) +command-trigger-exists-yes = Trigger '{ $name }' exists. +command-trigger-exists-no = Trigger '{ $name }' does not exist. +command-trigger-error-missing_subcommand = Missing subcommand. Use one of: list, show, exists, create, delete. +command-trigger-error-invalid_subcommand = Unknown subcommand '{ $subcommand }'. Use one of: list, show, exists, create, delete. +command-trigger-error-missing_name = Missing trigger name. Specify the id, for example: trigger show myTrigger. +command-trigger-error-missing_file = No source provided. Specify a JavaScript file or pipe the body in, for example: trigger create myTrigger ./myTrigger.js --type pre. +command-trigger-error-file_not_found = File not found: '{ $file }'. +command-trigger-error-already_exists = Trigger '{ $name }' already exists. Use --force to replace it. +command-trigger-error-not_found = Trigger '{ $name }' was not found. +command-trigger-error-missing_type = A trigger type is required. Use --type pre or --type post. +command-trigger-error-invalid_type = Invalid trigger type '{ $type }'. Use pre or post. +command-trigger-error-invalid_operation = Invalid trigger operation '{ $operation }'. Use all, create, replace, delete, or update. + command-ls-description = List resources in the current context. command-ls-description-filter = The filter pattern. command-ls-description-max = Maximum number of items returned when listing container items. Defaults to 100 if omitted. Use 0 or a negative value for no limit. diff --git a/docs/commands.md b/docs/commands.md index 1ea5dca..e62177e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -571,6 +571,95 @@ index set --mode=consistent --automatic=true index set '{"indexingMode":"consistent","automatic":true,"includedPaths":[{"path":"/*"}],"excludedPaths":[]}' ``` +### udf + +Manage JavaScript user-defined functions (UDFs) on a container through subcommands. + +```text +Usage: udf subcommand [name] [value] [-force] [-database ] [-container ] + +Arguments: + subcommand list, show, exists, create, or delete + [name] The user-defined function id + [value] A JavaScript file (for create) + +Options: + -force, -f Replace the user-defined function if it already exists (create) + -database, -db + Override database name (Optional) + -container, -con + Override container name (Optional) +``` + +#### Subcommands + +|Subcommand|Behavior| +|-|-| +|`list`|Returns the user-defined function ids in the current container.| +|`show `|Returns the body of a user-defined function.| +|`exists `|Returns a boolean indicating whether a user-defined function exists. The boolean result can be used directly in `if` and `while` conditions.| +|`create `|Creates a user-defined function from a JavaScript file. The body can also be piped in. Pass `--force` to replace an existing one.| +|`delete `|Deletes a user-defined function.| + +#### Examples + +```bash +udf list +udf show myFunc +udf exists myFunc +udf create myFunc ./myFunc.js +udf create myFunc ./myFunc.js --force +udf delete myFunc +``` + +User-defined functions are a Cosmos DB for NoSQL feature invoked from within queries. The `udf` command operates on the current container, the same scope as `index`. + +### trigger + +Manage JavaScript triggers on a container through subcommands. + +```text +Usage: trigger subcommand [name] [value] [-type ] [-operation ] [-force] [-database ] [-container ] + +Arguments: + subcommand list, show, exists, create, or delete + [name] The trigger id + [value] A JavaScript file (for create) + +Options: + -type, -t Trigger type for create: pre or post (required for create) + -operation, -op + Operation the trigger fires on: all, create, replace, delete, or update (default: all) + -force, -f Replace the trigger if it already exists (create) + -database, -db + Override database name (Optional) + -container, -con + Override container name (Optional) +``` + +#### Subcommands + +|Subcommand|Behavior| +|-|-| +|`list`|Returns the trigger ids in the current container with their type and operation.| +|`show `|Returns the body of a trigger.| +|`exists `|Returns a boolean indicating whether a trigger exists. The boolean result can be used directly in `if` and `while` conditions.| +|`create `|Creates a trigger from a JavaScript file. The body can also be piped in. `--type` selects `pre` or `post`, `--operation` selects the operation (defaults to `all`), and `--force` replaces an existing one.| +|`delete `|Deletes a trigger.| + +#### Examples + +```bash +trigger list +trigger show myTrigger +trigger exists myTrigger +trigger create myTrigger ./myTrigger.js --type pre --operation create +trigger create myTrigger ./myTrigger.js --type post --operation all --force +trigger delete myTrigger +``` + +Triggers are a Cosmos DB for NoSQL feature. Pre-triggers and post-triggers are invoked when item operations opt in to them. The `trigger` command operates on the current container, the same scope as `index`. + ## Utilities ### az