diff --git a/.formatter.exs b/.formatter.exs index 117ac344..967c099c 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -170,6 +170,7 @@ spark_locals_without_parens = [ require_atomic?: 1, require_attributes: 1, require_primary_key?: 1, + require_reference?: 1, required?: 1, resource: 1, resource: 2, diff --git a/documentation/dsls/DSL:-Ash.Domain.md b/documentation/dsls/DSL:-Ash.Domain.md index 0b84c1b4..c814fc83 100644 --- a/documentation/dsls/DSL:-Ash.Domain.md +++ b/documentation/dsls/DSL:-Ash.Domain.md @@ -122,9 +122,10 @@ define :get_user_by_id, User, action: :get_by_id, args: [:id], get?: true | [`action`](#resources-resource-define-action){: #resources-resource-define-action } | `atom` | | The name of the action that will be called. Defaults to the same name as the function. | | [`args`](#resources-resource-define-args){: #resources-resource-define-args } | `list(atom \| {:optional, atom})` | | Map specific arguments to named inputs. Can provide any argument/attributes that the action allows. | | [`not_found_error?`](#resources-resource-define-not_found_error?){: #resources-resource-define-not_found_error? } | `boolean` | `true` | If the action or interface is configured with `get?: true`, this determines whether or not an error is raised or `nil` is returned. | -| [`get?`](#resources-resource-define-get?){: #resources-resource-define-get? } | `boolean` | | Expects to only receive a single result from a read action, and returns a single result instead of a list. Ignored for other action types. | -| [`get_by`](#resources-resource-define-get_by){: #resources-resource-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true automatically. Ignored for non-read actions. | -| [`get_by_identity`](#resources-resource-define-get_by_identity){: #resources-resource-define-get_by_identity } | `atom` | | Only relevant for read actions. Takes an identity, and gets its field list, performing the same logic as `get_by` once it has the list of fields. | +| [`require_reference?`](#resources-resource-define-require_reference?){: #resources-resource-define-require_reference? } | `boolean` | `true` | For update and destroy actions, require a resource or identifier to be passed in as the first argument. Not relevant for other action types. | +| [`get?`](#resources-resource-define-get?){: #resources-resource-define-get? } | `boolean` | | Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically. | +| [`get_by`](#resources-resource-define-get_by){: #resources-resource-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument. | +| [`get_by_identity`](#resources-resource-define-get_by_identity){: #resources-resource-define-get_by_identity } | `atom` | | Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument. | diff --git a/documentation/dsls/DSL:-Ash.Resource.md b/documentation/dsls/DSL:-Ash.Resource.md index d0b1af93..84c2bd79 100644 --- a/documentation/dsls/DSL:-Ash.Resource.md +++ b/documentation/dsls/DSL:-Ash.Resource.md @@ -2032,9 +2032,10 @@ define :get_user_by_id, action: :get_by_id, args: [:id], get?: true | [`action`](#code_interface-define-action){: #code_interface-define-action } | `atom` | | The name of the action that will be called. Defaults to the same name as the function. | | [`args`](#code_interface-define-args){: #code_interface-define-args } | `list(atom \| {:optional, atom})` | | Map specific arguments to named inputs. Can provide any argument/attributes that the action allows. | | [`not_found_error?`](#code_interface-define-not_found_error?){: #code_interface-define-not_found_error? } | `boolean` | `true` | If the action or interface is configured with `get?: true`, this determines whether or not an error is raised or `nil` is returned. | -| [`get?`](#code_interface-define-get?){: #code_interface-define-get? } | `boolean` | | Expects to only receive a single result from a read action, and returns a single result instead of a list. Ignored for other action types. | -| [`get_by`](#code_interface-define-get_by){: #code_interface-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true automatically. Ignored for non-read actions. | -| [`get_by_identity`](#code_interface-define-get_by_identity){: #code_interface-define-get_by_identity } | `atom` | | Only relevant for read actions. Takes an identity, and gets its field list, performing the same logic as `get_by` once it has the list of fields. | +| [`require_reference?`](#code_interface-define-require_reference?){: #code_interface-define-require_reference? } | `boolean` | `true` | For update and destroy actions, require a resource or identifier to be passed in as the first argument. Not relevant for other action types. | +| [`get?`](#code_interface-define-get?){: #code_interface-define-get? } | `boolean` | | Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically. | +| [`get_by`](#code_interface-define-get_by){: #code_interface-define-get_by } | `atom \| list(atom)` | | Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument. | +| [`get_by_identity`](#code_interface-define-get_by_identity){: #code_interface-define-get_by_identity } | `atom` | | Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument. | diff --git a/lib/ash.ex b/lib/ash.ex index 592347f1..dc9af96a 100644 --- a/lib/ash.ex +++ b/lib/ash.ex @@ -462,6 +462,11 @@ defmodule Ash do doc: "A select statement to apply to records. Ignored if `return_records?` is not true." ], + filter: [ + type: :any, + doc: + "A filter to apply to records. This is also applied to a stream of inputs." + ], strategy: [ type: {:wrap_list, {:one_of, [:atomic, :atomic_batches, :stream]}}, default: [:atomic], @@ -524,6 +529,11 @@ defmodule Ash do doc: "The strategy or strategies to enable. :stream is used in all cases if the data layer does not support atomics." ], + filter: [ + type: :any, + doc: + "A filter to apply to records. This is also applied to a stream of inputs." + ], skip_unknown_inputs: [ type: {:list, {:or, [:atom, :string]}}, doc: diff --git a/lib/ash/actions/destroy/bulk.ex b/lib/ash/actions/destroy/bulk.ex index 855b7416..14ba90cf 100644 --- a/lib/ash/actions/destroy/bulk.ex +++ b/lib/ash/actions/destroy/bulk.ex @@ -120,6 +120,9 @@ defmodule Ash.Actions.Destroy.Bulk do |> Keyword.put(:domain, domain) |> Keyword.take(Ash.stream_opt_keys()) + query = + Ash.Query.do_filter(query, opts[:filter]) + run( domain, Ash.stream!( @@ -661,6 +664,7 @@ defmodule Ash.Actions.Destroy.Bulk do tracer: opts[:tracer], atomic_changeset: atomic_changeset, return_errors?: opts[:return_errors?], + filter: opts[:filter], return_notifications?: opts[:return_notifications?], notify?: opts[:notify?], return_records?: opts[:return_records?], @@ -833,6 +837,7 @@ defmodule Ash.Actions.Destroy.Bulk do resource |> Ash.Changeset.new() + |> Ash.Changeset.filter(opts[:filter]) |> Map.put(:domain, domain) |> Ash.Actions.Helpers.add_context(opts) |> Ash.Changeset.set_context(opts[:context] || %{}) diff --git a/lib/ash/actions/update/bulk.ex b/lib/ash/actions/update/bulk.ex index 67a5ad8e..67063a5c 100644 --- a/lib/ash/actions/update/bulk.ex +++ b/lib/ash/actions/update/bulk.ex @@ -85,6 +85,9 @@ defmodule Ash.Actions.Update.Bulk do } _ -> + query = + Ash.Query.do_filter(query, opts[:filter]) + run( domain, Ash.stream!( @@ -109,6 +112,9 @@ defmodule Ash.Actions.Update.Bulk do } atomic_changeset -> + atomic_changeset = + Ash.Changeset.filter(atomic_changeset, opts[:filter]) + {atomic_changeset, opts} = Ash.Actions.Helpers.set_context_and_get_opts(domain, atomic_changeset, opts) @@ -655,6 +661,7 @@ defmodule Ash.Actions.Update.Bulk do tracer: opts[:tracer], atomic_changeset: atomic_changeset, return_errors?: opts[:return_errors?], + filter: opts[:filter], return_notifications?: opts[:return_notifications?], notify?: opts[:notify?], return_records?: opts[:return_records?], @@ -899,6 +906,7 @@ defmodule Ash.Actions.Update.Bulk do resource |> Ash.Changeset.new() |> Map.put(:domain, domain) + |> Ash.Changeset.filter(opts[:filter]) |> Ash.Actions.Helpers.add_context(opts) |> Ash.Changeset.set_context(opts[:context] || %{}) |> Ash.Changeset.prepare_changeset_for_action(action, opts) diff --git a/lib/ash/actions/update/update.ex b/lib/ash/actions/update/update.ex index b4ae563e..fa3a32ac 100644 --- a/lib/ash/actions/update/update.ex +++ b/lib/ash/actions/update/update.ex @@ -86,6 +86,7 @@ defmodule Ash.Actions.Update do |> Ash.Changeset.set_context(%{data_layer: %{use_atomic_update_data?: true}}) |> Map.put(:load, changeset.load) |> Map.put(:select, changeset.select) + |> Map.put(:filter, changeset.filter) |> Ash.Changeset.set_context(changeset.context) {atomic_changeset, opts} = diff --git a/lib/ash/changeset/changeset.ex b/lib/ash/changeset/changeset.ex index c379331b..ddc373d3 100644 --- a/lib/ash/changeset/changeset.ex +++ b/lib/ash/changeset/changeset.ex @@ -4899,6 +4899,10 @@ defmodule Ash.Changeset do Used by optimistic locking. See `Ash.Resource.Change.Builtins.optimistic_lock/1` for more. """ @spec filter(t(), Ash.Expr.t()) :: t() + def filter(changeset, expr) when expr in [nil, %{}, []] do + changeset + end + def filter(changeset, expr) do if Ash.DataLayer.data_layer_can?(changeset.resource, :changeset_filter) do %{ diff --git a/lib/ash/code_interface.ex b/lib/ash/code_interface.ex index 4784ed67..09ac5ce3 100644 --- a/lib/ash/code_interface.ex +++ b/lib/ash/code_interface.ex @@ -411,7 +411,7 @@ defmodule Ash.CodeInterface do filter_keys = cond do - action.type != :read -> + action.type not in [:read, :update, :destroy] -> [] interface.get_by_identity -> @@ -736,64 +736,93 @@ defmodule Ash.CodeInterface do :update -> subject = quote do: changeset - subject_args = quote do: [record] + + subject_args = + if interface.require_reference? do + quote do: [record] + else + [] + end resolve_subject = - quote do - {changeset_opts, opts} = - Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context]) + if Enum.empty?(filter_keys) do + quote do + {changeset_opts, opts} = + Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context]) - changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain)) + changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain)) - changeset = - record - |> case do - %Ash.Changeset{resource: unquote(resource)} -> - Ash.Changeset.for_update( - record, - unquote(action.name), - params, - changeset_opts - ) + changeset = + record + |> case do + %Ash.Changeset{resource: unquote(resource)} -> + {filters, params} = Map.split(params, unquote(filter_keys)) - %Ash.Changeset{resource: other_resource} -> - raise ArgumentError, - "Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." + record + |> Ash.Changeset.filter(filters) + |> Ash.Changeset.for_update( + unquote(action.name), + params, + changeset_opts + ) - %other_resource{} when other_resource != unquote(resource) -> - raise ArgumentError, - "Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." + %Ash.Changeset{resource: other_resource} -> + raise ArgumentError, + "Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." - %struct{} = record when struct == unquote(resource) -> - Ash.Changeset.for_update( - record, - unquote(action.name), - params, - changeset_opts - ) + %other_resource{} when other_resource != unquote(resource) -> + raise ArgumentError, + "Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." - %Ash.Query{} = query -> - {:atomic, :query, query} + %struct{} = record when struct == unquote(resource) -> + {filters, params} = Map.split(params, unquote(filter_keys)) - value when is_function(value) -> - {:atomic, :stream, value} + record + |> Ash.Changeset.new() + |> Ash.Changeset.filter(filters) + |> Ash.Changeset.for_update( + unquote(action.name), + params, + changeset_opts + ) - %Stream{} = value -> - {:atomic, :stream, value} + %Ash.Query{} = query -> + {:atomic, :query, query} - [{_key, _val} | _] = id -> - {:atomic, :id, id} + value when is_function(value) -> + {:atomic, :stream, value} - list when is_list(list) -> - {:atomic, :stream, list} + %Stream{} = value -> + {:atomic, :stream, value} - other -> - {:atomic, :id, other} - end + [{_key, _val} | _] = id -> + {:atomic, :id, id} + + list when is_list(list) -> + {:atomic, :stream, list} + + other -> + {:atomic, :id, other} + end + end + else + quote do + filters = Map.take(params, unquote(filter_keys)) + + {changeset_opts, opts} = + Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context]) + + changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain)) + + changeset = + {:atomic, :query, Ash.Query.do_filter(unquote(resource), filters)} + end end act = quote do + {filters, params} = Map.split(params, unquote(filter_keys)) + case changeset do {:atomic, method, id} -> bulk_opts = @@ -811,12 +840,20 @@ defmodule Ash.CodeInterface do end end) + bulk_opts = + if method == :stream do + Keyword.put(bulk_opts, :filter, filters) + else + bulk_opts + end + case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do {:ok, query} -> query |> Ash.bulk_update(unquote(action.name), params, bulk_opts) |> case do - %Ash.BulkResult{} = result when method == :stream -> + %Ash.BulkResult{} = result + when method == :stream and unquote(Enum.empty?(filter_keys)) -> result %Ash.BulkResult{status: :success, records: [record]} = result -> @@ -848,6 +885,8 @@ defmodule Ash.CodeInterface do quote do case changeset do {:atomic, method, id} -> + {filters, params} = Map.split(params, unquote(filter_keys)) + bulk_opts = opts |> Keyword.delete(:bulk_options) @@ -863,12 +902,20 @@ defmodule Ash.CodeInterface do end end) + bulk_opts = + if method == :stream do + Keyword.put(bulk_opts, :filter, filters) + else + bulk_opts + end + case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do {:ok, query} -> query |> Ash.bulk_update!(unquote(action.name), params, bulk_opts) |> case do - %Ash.BulkResult{} = result when method == :stream -> + %Ash.BulkResult{} = result + when method == :stream and unquote(Enum.empty?(filter_keys)) -> result %Ash.BulkResult{status: :success, records: [record]} = result -> @@ -896,66 +943,95 @@ defmodule Ash.CodeInterface do :destroy -> subject = quote do: changeset - subject_args = quote do: [record] + + subject_args = + if interface.require_reference? do + quote do: [record] + else + [] + end resolve_subject = - quote do - {changeset_opts, opts} = - Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context]) + if interface.require_reference? do + quote do + {changeset_opts, opts} = + Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context]) - changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain)) + changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain)) - changeset = - record - |> case do - %Ash.Changeset{resource: unquote(resource)} -> - Ash.Changeset.for_destroy( - record, - unquote(action.name), - params, - changeset_opts - ) + changeset = + record + |> case do + %Ash.Changeset{resource: unquote(resource)} -> + {filters, params} = Map.split(params, unquote(filter_keys)) - %Ash.Changeset{resource: other_resource} -> - raise ArgumentError, - "Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." + record + |> Ash.Changeset.filter(filters) + |> Ash.Changeset.for_destroy( + unquote(action.name), + params, + changeset_opts + ) - %other_resource{} when other_resource != unquote(resource) -> - raise ArgumentError, - "Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." + %Ash.Changeset{resource: other_resource} -> + raise ArgumentError, + "Changeset #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." - %struct{} = record when struct == unquote(resource) -> - Ash.Changeset.for_destroy( - record, - unquote(action.name), - params, - changeset_opts - ) + %other_resource{} when other_resource != unquote(resource) -> + raise ArgumentError, + "Record #{inspect(record)} does not match expected resource #{inspect(unquote(resource))}." - %Ash.Query{} = query -> - {:atomic, :query, query} + %struct{} = record when struct == unquote(resource) -> + {filters, params} = Map.split(params, unquote(filter_keys)) - value when is_function(value) -> - {:atomic, :stream, value} + record + |> Ash.Changeset.new() + |> Ash.Changeset.filter(filters) + |> Ash.Changeset.for_destroy( + unquote(action.name), + params, + changeset_opts + ) - %Stream{} = value -> - {:atomic, :stream, value} + %Ash.Query{} = query -> + {:atomic, :query, query} - [{_key, _val} | _] = id -> - {:atomic, :id, id} + value when is_function(value) -> + {:atomic, :stream, value} - list when is_list(list) -> - {:atomic, :stream, list} + %Stream{} = value -> + {:atomic, :stream, value} - other -> - {:atomic, :id, other} - end + [{_key, _val} | _] = id -> + {:atomic, :id, id} + + list when is_list(list) -> + {:atomic, :stream, list} + + other -> + {:atomic, :id, other} + end + end + else + quote do + filters = Map.take(params, unquote(filter_keys)) + + {changeset_opts, opts} = + Keyword.split(opts, [:actor, :tenant, :authorize?, :tracer, :context]) + + changeset_opts = Keyword.put(changeset_opts, :domain, unquote(domain)) + + changeset = + {:atomic, :query, Ash.Query.do_filter(unquote(resource), filters)} + end end act = quote do case changeset do {:atomic, method, id} -> + {filters, params} = Map.split(params, unquote(filter_keys)) + bulk_opts = opts |> Keyword.drop([:bulk_options, :return_destroyed?]) @@ -971,12 +1047,20 @@ defmodule Ash.CodeInterface do end end) + bulk_opts = + if method == :stream do + Keyword.put(bulk_opts, :filter, filters) + else + bulk_opts + end + case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do {:ok, query} -> query |> Ash.bulk_destroy(unquote(action.name), params, bulk_opts) |> case do - %Ash.BulkResult{} = result when method == :stream -> + %Ash.BulkResult{} = result + when method == :stream and unquote(Enum.empty?(filter_keys)) -> result %Ash.BulkResult{status: :success, records: [record]} = result -> @@ -1013,6 +1097,8 @@ defmodule Ash.CodeInterface do quote do case changeset do {:atomic, method, id} -> + {filters, params} = Map.split(params, unquote(filter_keys)) + bulk_opts = opts |> Keyword.drop([:bulk_options, :return_destroyed?]) @@ -1028,12 +1114,20 @@ defmodule Ash.CodeInterface do end end) + bulk_opts = + if method == :stream do + Keyword.put(bulk_opts, :filter, filters) + else + bulk_opts + end + case Ash.CodeInterface.bulk_query(unquote(resource), method, id) do {:ok, query} -> query |> Ash.bulk_destroy!(unquote(action.name), params, bulk_opts) |> case do - %Ash.BulkResult{} = result when method == :stream -> + %Ash.BulkResult{} = result + when method == :stream and unquote(Enum.empty?(filter_keys)) -> result %Ash.BulkResult{status: :success, records: [record]} = result -> diff --git a/lib/ash/resource/change/builtins.ex b/lib/ash/resource/change/builtins.ex index 0cb825ee..3a40be6e 100644 --- a/lib/ash/resource/change/builtins.ex +++ b/lib/ash/resource/change/builtins.ex @@ -62,6 +62,16 @@ defmodule Ash.Resource.Change.Builtins do @relate_actor_opts end + @doc """ + Applies a filter to the changeset. Has no effect for create actions. + + This ensures that only things matching the provided filter are updated or destroyed. + """ + @spec filter(expr :: Ash.Expr.t()) :: Ash.Resource.Change.ref() + def filter(filter) do + {Ash.Resource.Change.Filter, filter: filter} + end + @spec relate_actor(relationship :: atom, opts :: Keyword.t()) :: Ash.Resource.Change.ref() def relate_actor(relationship, opts \\ []) do opts = diff --git a/lib/ash/resource/change/filter.ex b/lib/ash/resource/change/filter.ex new file mode 100644 index 00000000..4c35511d --- /dev/null +++ b/lib/ash/resource/change/filter.ex @@ -0,0 +1,14 @@ +defmodule Ash.Resource.Change.Filter do + @moduledoc false + use Ash.Resource.Change + + @impl true + def change(changeset, opts, _) do + Ash.Changeset.filter(changeset, opts[:filter]) + end + + @impl true + def atomic(changeset, opts, context) do + {:ok, change(changeset, opts, context)} + end +end diff --git a/lib/ash/resource/interface.ex b/lib/ash/resource/interface.ex index 57950130..d104469d 100644 --- a/lib/ash/resource/interface.ex +++ b/lib/ash/resource/interface.ex @@ -2,18 +2,42 @@ defmodule Ash.Resource.Interface do @moduledoc """ Represents a function in a resource's code interface """ - defstruct [:name, :action, :args, :get?, :get_by, :get_by_identity, :not_found_error?] + defstruct [ + :name, + :action, + :args, + :get?, + :get_by, + :get_by_identity, + :not_found_error?, + require_reference?: true + ] @type t :: %__MODULE__{} def transform(definition) do + {:ok, + definition + |> set_get?() + |> set_require_reference?()} + end + + defp set_get?(definition) do if definition.get_by || definition.get_by_identity do - {:ok, %{definition | get?: true}} + %{definition | get?: true} else - {:ok, definition} + definition end end + defp set_require_reference?(%{get?: true} = definition) do + %{definition | require_reference?: false} + end + + defp set_require_reference?(definition) do + definition + end + def interface_options(:calculate) do [ actor: [ @@ -125,22 +149,28 @@ defmodule Ash.Resource.Interface do doc: "If the action or interface is configured with `get?: true`, this determines whether or not an error is raised or `nil` is returned." ], + require_reference?: [ + type: :boolean, + default: true, + doc: + "For update and destroy actions, require a resource or identifier to be passed in as the first argument. Not relevant for other action types." + ], get?: [ type: :boolean, doc: """ - Expects to only receive a single result from a read action, and returns a single result instead of a list. Ignored for other action types. + Expects to only receive a single result from a read action or a bulk update/destroy, and returns a single result instead of a list. Sets `require_reference?` to false automatically. """ ], get_by: [ type: {:wrap_list, :atom}, doc: """ - Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true automatically. Ignored for non-read actions. + Takes a list of fields and adds those fields as arguments, which will then be used to filter. Sets `get?` to true and `require_reference?` to false automatically. Adds filters for read, update and destroy actions, replacing the `record` first argument. """ ], get_by_identity: [ type: :atom, doc: """ - Only relevant for read actions. Takes an identity, and gets its field list, performing the same logic as `get_by` once it has the list of fields. + Takes an identity, gets its field list, and performs the same logic as `get_by` with those fields. Adds filters for read, update and destroy actions, replacing the `record` first argument. """ ] ]