diff --git a/.formatter.exs b/.formatter.exs index 319f323..f8106c0 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -2,7 +2,16 @@ # # SPDX-License-Identifier: MIT -spark_locals_without_parens = [expose?: 1, name: 1] +spark_locals_without_parens = [ + action: 3, + action: 4, + argument_names: 1, + expose?: 1, + field_names: 1, + name: 1, + namespace: 1, + namespace: 2 +] [ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], diff --git a/config/test.exs b/config/test.exs index 502872e..8f4cab7 100644 --- a/config/test.exs +++ b/config/test.exs @@ -7,4 +7,4 @@ import Config config :ash, policies: [show_policy_breakdowns?: true] config :ash_lua, - ash_domains: [AshLua.Test.Posts] + ash_domains: [AshLua.Test.Posts, AshLua.Test.Surface] diff --git a/documentation/dsls/DSL-AshLua.Domain.md b/documentation/dsls/DSL-AshLua.Domain.md index 8d87b4f..a531cee 100644 --- a/documentation/dsls/DSL-AshLua.Domain.md +++ b/documentation/dsls/DSL-AshLua.Domain.md @@ -10,6 +10,9 @@ Extension that exposes an Ash domain's resources to Lua scripts evaluated throug Domain-level configuration for AshLua. +### Nested DSLs + * [namespace](#lua-namespace) + * action ### Examples @@ -31,6 +34,87 @@ end +### lua.namespace +```elixir +namespace name +``` + + +Defines a public Lua namespace for actions. + +### Nested DSLs + * [action](#lua-namespace-action) + + +### Examples +``` +namespace "pages" do + action :list, MyApp.StorefrontPage, :list_for_storefront +end + +``` + +``` +namespace "storefronts.pages" do + action :list, MyApp.StorefrontPage, :list_for_storefront +end + +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#lua-namespace-name){: #lua-namespace-name .spark-required} | `String.t \| list(String.t)` | | The public Lua namespace. Dotted strings are split into nested Lua tables, so "storefronts.pages" exposes `storefronts.pages.*`. | + + + +### lua.namespace.action +```elixir +action name, resource, action +``` + + +Expose an Ash action at a public Lua function name inside a namespace. + + + +### Examples +``` +namespace "pages" do + action :list, MyApp.StorefrontPage, :list_for_storefront +end + +``` + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#lua-namespace-action-name){: #lua-namespace-action-name .spark-required} | `atom` | | The Lua function name inside the namespace. | +| [`resource`](#lua-namespace-action-resource){: #lua-namespace-action-resource .spark-required} | `module` | | The Ash resource that owns the action. | +| [`action`](#lua-namespace-action-action){: #lua-namespace-action-action .spark-required} | `atom` | | The internal Ash action to call. | + + + + + + +### Introspection + +Target: `AshLua.Domain.Action` + + + + +### Introspection + +Target: `AshLua.Domain.Namespace` + diff --git a/documentation/dsls/DSL-AshLua.Resource.md b/documentation/dsls/DSL-AshLua.Resource.md index 88bf0a9..a029260 100644 --- a/documentation/dsls/DSL-AshLua.Resource.md +++ b/documentation/dsls/DSL-AshLua.Resource.md @@ -32,6 +32,8 @@ end |------|------|---------|------| | [`name`](#lua-name){: #lua-name } | `String.t` | | The Lua key (under the domain table) to expose this resource as. Defaults to snake_case of the resource module's last segment. | | [`expose?`](#lua-expose?){: #lua-expose? } | `boolean` | `true` | Whether to expose this resource and its public actions to Lua. | +| [`field_names`](#lua-field_names){: #lua-field_names } | `keyword` | `[]` | A keyword list mapping internal Ash field names to exact Lua-facing field names. | +| [`argument_names`](#lua-argument_names){: #lua-argument_names } | `keyword` | `[]` | A keyword list mapping internal Ash argument names to exact Lua-facing argument names per action. | diff --git a/documentation/tutorials/getting-started-with-ash-lua.md b/documentation/tutorials/getting-started-with-ash-lua.md index 5224ee0..ef0a2a2 100644 --- a/documentation/tutorials/getting-started-with-ash-lua.md +++ b/documentation/tutorials/getting-started-with-ash-lua.md @@ -94,12 +94,14 @@ callable as `accounts.user.(input)` from Lua. ```elixir {[user_id], _lua} = AshLua.eval!( - """ - local user, err = accounts.user.create({ - name = "Zach", - email = "z@example.com", - fields = { "id" } - }) + """ + local user, err = accounts.user.create({ + input = { + name = "Zach", + email = "z@example.com" + }, + fields = { "id" } + }) assert(err == nil) return user.id """, @@ -123,7 +125,7 @@ two values: a result and an error. A successful call returns `(result, nil)`; a failed call returns `(nil, err_table)`. ```lua -local user, err = accounts.user.create({ name = "Zach" }) +local user, err = accounts.user.create({ input = { name = "Zach" } }) if err then -- err is a table: { message = "...", errors = { { code = "...", fields = {...}, ... }, ... } } @@ -136,7 +138,7 @@ end If you'd rather have errors raise, wrap the call in Lua's built-in `assert`: ```lua -local user = assert(accounts.user.create({ name = "Zach" })) +local user = assert(accounts.user.create({ input = { name = "Zach" } })) ``` `assert` returns the first value when the second is `nil`, and raises with the @@ -212,19 +214,21 @@ Supported operations: `"count"`, `"exists"`, and `{ "sum" | "avg" | "min" | ## 7. Mutations -Create / update / delete behave like read, except update and delete take the -primary key inline in the input: +Create / update / delete behave like read, except action fields and arguments go +under the `input` key. Update and delete take the primary key there too: ```lua local post = assert(posts.post.create({ - title = "Hello", body = "World", fields = { "id", "title" } + input = { title = "Hello", body = "World" }, + fields = { "id", "title" } })) local updated = assert(posts.post.update({ - id = post.id, title = "Hello again", fields = { "title" } + input = { id = post.id, title = "Hello again" }, + fields = { "title" } })) -assert(posts.post.destroy({ id = post.id })) +assert(posts.post.destroy({ input = { id = post.id } })) ``` Generic actions (defined with `action :name, type do ... end`) take their diff --git a/lib/ash_lua.ex b/lib/ash_lua.ex index dce2326..d2f52a7 100644 --- a/lib/ash_lua.ex +++ b/lib/ash_lua.ex @@ -7,9 +7,10 @@ defmodule AshLua do AshLua exposes Ash actions to Lua scripts evaluated through the [`lua`](https://hex.pm/packages/lua) Elixir package, ensuring a consistent actor / tenant / context are propagated into every Ash call. - The Lua surface is derived from `Ash.Info.Manifest.generate/1` — every public action becomes a - callable at `..` (names overridable via the `AshLua.Domain` and - `AshLua.Resource` DSL extensions). + The Lua surface is resolved from `Ash.Info.Manifest.generate/1` plus AshLua DSL. Domains with no + explicit `lua do namespace ... end` config keep the legacy + `..` callable shape; domains with explicit namespaces expose only their + configured public action paths. ## Example @@ -30,7 +31,7 @@ defmodule AshLua do end AshLua.eval!(\""" - local user, err = accounts.user.create({ name = "Zach" }) + local user, err = accounts.user.create({ input = { name = "Zach" } }) assert(err == nil) return user.id \""", otp_app: :my_app, actor: current_user) @@ -38,7 +39,7 @@ defmodule AshLua do Action callables always return `(result, nil)` on success and `(nil, err_table)` on failure. Wrap a call in Lua's built-in `assert()` for raise semantics: - local user = assert(accounts.user.create({ name = "Zach" })) + local user = assert(accounts.user.create({ input = { name = "Zach" } })) ## Actor / tenant / context diff --git a/lib/ash_lua/docs.ex b/lib/ash_lua/docs.ex index ae329b4..2541dff 100644 --- a/lib/ash_lua/docs.ex +++ b/lib/ash_lua/docs.ex @@ -158,12 +158,12 @@ defmodule AshLua.Docs do Wrap a block of operations in a transaction with `utils.transaction.transact`: - ```lua - utils.transaction.transact({ "posts.post", "posts.comment" }, function() - local post = assert(posts.post.create({ title = "Hello" })) - assert(posts.comment.create({ post_id = post.id, body = "First" })) - return post.id - end) + ```lua + utils.transaction.transact({ "posts.post", "posts.comment" }, function() + local post = assert(posts.post.create({ input = { title = "Hello" } })) + assert(posts.comment.create({ input = { post_id = post.id, body = "First" } })) + return post.id + end) ``` The first argument is the list of record-type paths that the transaction @@ -188,11 +188,11 @@ defmodule AshLua.Docs do Example with `assert`-driven rollback: - ```lua - local _, err = utils.transaction.transact({ "posts.post" }, function() - assert(posts.post.create({ title = "Will Roll Back" })) - assert(posts.post.create({ })) -- missing required `title`, raises - end) + ```lua + local _, err = utils.transaction.transact({ "posts.post" }, function() + assert(posts.post.create({ input = { title = "Will Roll Back" } })) + assert(posts.post.create({ })) -- missing required `title`, raises + end) if err then -- Neither post exists; the whole transaction was rolled back. @@ -201,10 +201,10 @@ defmodule AshLua.Docs do Example with explicit rollback: - ```lua - local _, err = utils.transaction.transact({ "posts.post" }, function() - local p = assert(posts.post.create({ title = "Tentative" })) - if not some_business_check(p) then + ```lua + local _, err = utils.transaction.transact({ "posts.post" }, function() + local p = assert(posts.post.create({ input = { title = "Tentative" } })) + if not some_business_check(p) then utils.transaction.rollback("post failed business check") end return p.id @@ -252,7 +252,7 @@ defmodule AshLua.Docs do error is `nil`; on failure the result is `nil` and the error is a table. ```lua - local user, err = accounts.user.create({ name = "" }) + local user, err = accounts.user.create({ input = { name = "" } }) if err then -- handle the failure else @@ -264,7 +264,7 @@ defmodule AshLua.Docs do `assert/1`: ```lua - local user = assert(accounts.user.create({ name = "Zach" })) + local user = assert(accounts.user.create({ input = { name = "Zach" } })) ``` `assert` returns the first value when the second is `nil`, and raises @@ -330,7 +330,7 @@ defmodule AshLua.Docs do manifest = ensure_manifest(manifest_or_opts) manifest.entrypoints - |> Enum.flat_map(&entrypoint_path/1) + |> Enum.map(&AshLua.Surface.path_string/1) |> Enum.sort() end @@ -412,8 +412,8 @@ defmodule AshLua.Docs do manifest = ensure_manifest(manifest_or_opts) case find_callable(manifest, path) do - {:ok, {entrypoint, domain_name, resource_name}} -> - {:ok, render_callable(manifest, entrypoint, domain_name, resource_name)} + {:ok, surface_action} -> + {:ok, render_callable(manifest, surface_action)} :error -> {:error, :not_found} @@ -632,10 +632,11 @@ defmodule AshLua.Docs do defp reserved_input_keys do """ - ## Reserved input keys + ## Reserved input keys - * `fields` — which fields to return; selection tree (list of names and - nested tables). Default: primary key only. + * `input` — action input values live inside this table. + * `fields` — which fields to return; selection tree (list of names and + nested tables). Default: primary key only. * `filter` — narrow the result set by field values (list operations only). Shape is per-record-type; see each record type's page for the fields you can filter on. @@ -647,25 +648,22 @@ defmodule AshLua.Docs do * `operation` — summarize the result set instead of returning records: `"count"`, `"exists"`, or `{ "sum" | "avg" | "min" | "max" | "count" | "list" | "first", "" }` (list operations only). + + Record type identifiers are documentation identifiers, not necessarily + callable Lua namespaces. A flattened callable such as `surface.page_list` + can still return a record documented as `surface.page`. """ |> String.trim_trailing() end - defp ensure_manifest(%Manifest{} = m), do: m + defp ensure_manifest(%Manifest{} = m), do: AshLua.Surface.for_manifest(m) defp ensure_manifest(opts) when is_list(opts) do - {:ok, manifest} = Manifest.generate(opts) + otp_app = Keyword.fetch!(opts, :otp_app) + {:ok, manifest} = AshLua.Surface.for_otp_app(otp_app, Keyword.delete(opts, :otp_app)) manifest end - defp entrypoint_path(%Manifest.Entrypoint{resource: resource, action: action}) do - if AshLua.Resource.Info.expose?(resource) do - [resource_path(resource) <> "." <> Atom.to_string(action.name)] - else - [] - end - end - defp record_type_identifier(%Manifest.Resource{module: module}) do if AshLua.Resource.Info.expose?(module) do [resource_path(module)] @@ -680,24 +678,7 @@ defmodule AshLua.Docs do end defp find_callable(manifest, path) do - Enum.reduce_while(manifest.entrypoints, :error, fn entrypoint, _acc -> - if AshLua.Resource.Info.expose?(entrypoint.resource) do - domain = Ash.Resource.Info.domain(entrypoint.resource) - domain_name = AshLua.Domain.Info.name(domain) - resource_name = AshLua.Resource.Info.name(entrypoint.resource) - - candidate = - domain_name <> "." <> resource_name <> "." <> Atom.to_string(entrypoint.action.name) - - if candidate == path do - {:halt, {:ok, {entrypoint, domain_name, resource_name}}} - else - {:cont, :error} - end - else - {:cont, :error} - end - end) + AshLua.Surface.find_entrypoint(manifest, path) end defp find_resource_by_path(manifest, path) do @@ -711,17 +692,15 @@ defmodule AshLua.Docs do end) end - defp render_callable(manifest, entrypoint, domain_name, resource_name) do + defp render_callable(manifest, entrypoint) do resource_module = entrypoint.resource action = entrypoint.action resource = Manifest.get_resource!(Manifest.resource_lookup(manifest), resource_module) type_lookup = Manifest.type_lookup(manifest) resource_lookup = Manifest.resource_lookup(manifest) - path = domain_name <> "." <> resource_name <> "." <> Atom.to_string(action.name) - [ - "# `#{path}`", + "# `#{AshLua.Surface.path_string(entrypoint)}`", "**Operation:** `#{operation_kind(action)}`", action_description(action), input_section(action, resource, resource_lookup, type_lookup), @@ -744,13 +723,16 @@ defmodule AshLua.Docs do defp input_section(action, resource, resource_lookup, type_lookup) do input_rows = - (action.inputs || []) - |> Enum.map(&input_row(&1, resource_lookup, type_lookup)) + Enum.map( + action.inputs, + &input_row(&1, action, resource, resource_lookup, type_lookup) + ) pk_rows = pk_rows(action, resource) reserved_rows = reserved_rows(action) + action_input_rows = input_rows ++ pk_rows - rows = input_rows ++ pk_rows ++ reserved_rows + rows = input_container_rows(action_input_rows) ++ action_input_rows ++ reserved_rows if rows == [] do "## Input\n\n_None._" @@ -764,8 +746,28 @@ defmodule AshLua.Docs do end end - defp input_row(%Manifest.Argument{} = input, resource_lookup, type_lookup) do + defp input_container_rows(action_input_rows) do + if action_input_rows != [] do + required = + if Enum.any?(action_input_rows, &String.contains?(&1, "| yes |")), do: "yes", else: "no" + + [ + "| `input` | table | #{required} | action input values |" + ] + else + [] + end + end + + defp input_row( + %Manifest.Argument{} = input, + action, + resource, + resource_lookup, + type_lookup + ) do required = if not input.allow_nil? and not input.has_default?, do: "yes", else: "no" + name = input_name(input, action, resource) notes = [ @@ -776,9 +778,20 @@ defmodule AshLua.Docs do |> Enum.reject(&(is_nil(&1) or &1 == "")) |> Enum.join("; ") - "| `#{input.name}` | #{type_link(input.type, resource_lookup, type_lookup)} | #{required} | #{notes} |" + "| `input.#{name}` | #{type_link(input.type, resource_lookup, type_lookup)} | #{required} | #{notes} |" end + defp input_name( + %Manifest.Argument{name: name}, + %Manifest.Action{name: action_name, type: type}, + resource + ) + when type in [:read, :create, :update, :destroy, :action] do + AshLua.FieldNames.to_lua_input_name(resource.module, action_name, type, name) + end + + defp input_name(%Manifest.Argument{name: name}, _action, _resource), do: name + defp pk_rows(%Manifest.Action{type: type}, resource) when type in [:update, :delete, :destroy] do Enum.map(resource.primary_key, fn pk -> @@ -788,7 +801,9 @@ defmodule AshLua.Docs do _ -> "_unknown_" end - "| `#{pk}` | #{type_text} | yes | identifies the record |" + lua_name = AshLua.FieldNames.to_lua_field_name(resource.module, pk) + + "| `input.#{lua_name}` | #{type_text} | yes | identifies the record |" end) end @@ -904,7 +919,11 @@ defmodule AshLua.Docs do d -> "\n\n" <> d end - primary_key_line = "**Primary key:** #{Enum.map_join(resource.primary_key, ", ", &"`#{&1}`")}" + primary_key_line = + "**Primary key:** " <> + Enum.map_join(resource.primary_key, ", ", fn field -> + "`#{AshLua.FieldNames.to_lua_field_name(resource.module, field)}`" + end) fields_section = case Manifest.Resource.all_fields(resource) do @@ -914,7 +933,7 @@ defmodule AshLua.Docs do fields -> rows = Enum.map_join(fields, "\n", fn %Manifest.Field{} = f -> - row_field(f, resource_lookup, type_lookup) + row_field(resource, f, resource_lookup, type_lookup) end) "\n\n## Fields\n\n| Name | Type | Notes |\n|------|------|-------|\n" <> rows @@ -929,7 +948,8 @@ defmodule AshLua.Docs do rows = Enum.map_join(rels, "\n", fn r -> dest = record_link(r.destination, resource_lookup) - "| `#{r.name}` | #{r.cardinality} | #{dest} |" + name = AshLua.FieldNames.to_lua_field_name(resource.module, r.name) + "| `#{name}` | #{r.cardinality} | #{dest} |" end) "\n\n## Related records\n\n| Name | Cardinality | Type |\n|------|-------------|------|\n" <> @@ -963,7 +983,7 @@ defmodule AshLua.Docs do fields -> body = Enum.map_join(fields, "\n\n", fn field -> - field_filter_block(field, resource_lookup, type_lookup, op_display_map) + field_filter_block(resource, field, resource_lookup, type_lookup, op_display_map) end) "\n\n## Filterable fields\n\nThe `filter` reserved input on list operations accepts these per-field forms. See the **Filters** topic for the overall expression shape, including boolean combinators.\n\n" <> @@ -971,12 +991,19 @@ defmodule AshLua.Docs do end end - defp field_filter_block(%Manifest.Field{} = field, resource_lookup, type_lookup, op_display_map) do + defp field_filter_block( + %Manifest.Resource{} = resource, + %Manifest.Field{} = field, + resource_lookup, + type_lookup, + op_display_map + ) do operators = field.filter_operators || [] functions = field.filter_functions || [] custom_exprs = field.filter_custom_expressions || [] - header = "### `#{field.name}` (#{type_link(field.type, resource_lookup, type_lookup)})" + header = + "### `#{AshLua.FieldNames.to_lua_field_name(resource.module, field.name)}` (#{type_link(field.type, resource_lookup, type_lookup)})" lines = Enum.map( @@ -1072,7 +1099,7 @@ defmodule AshLua.Docs do resource |> Manifest.Resource.all_fields() |> Enum.filter(& &1.sortable?) - |> Enum.map(& &1.name) + |> Enum.map(&AshLua.FieldNames.to_lua_field_name(resource.module, &1.name)) case sortable do [] -> @@ -1107,7 +1134,7 @@ defmodule AshLua.Docs do fields -> rows = Enum.map_join(fields, "\n", fn %Manifest.Field{} = f -> - row_field(f, resource_lookup, type_lookup) + row_field(resource, f, resource_lookup, type_lookup) end) "\n\n## Fields\n\n| Name | Type | Notes |\n|------|------|-------|\n" <> rows @@ -1123,7 +1150,12 @@ defmodule AshLua.Docs do header <> "\n\n" <> body end - defp row_field(%Manifest.Field{} = f, resource_lookup, type_lookup) do + defp row_field( + %Manifest.Resource{} = resource, + %Manifest.Field{} = f, + resource_lookup, + type_lookup + ) do notes = [ f.description, @@ -1137,7 +1169,8 @@ defmodule AshLua.Docs do |> Enum.reject(&(is_nil(&1) or &1 == "")) |> Enum.join("; ") - "| `#{f.name}` | #{type_link(f.type, resource_lookup, type_lookup)} | #{notes} |" + name = AshLua.FieldNames.to_lua_field_name(resource.module, f.name) + "| `#{name}` | #{type_link(f.type, resource_lookup, type_lookup)} | #{notes} |" end defp kind_note(%Manifest.Field{kind: :calculation}), do: "computed" @@ -1273,13 +1306,7 @@ defmodule AshLua.Docs do defp collect_search_candidates(manifest) do callables = manifest.entrypoints - |> Enum.flat_map(fn entrypoint -> - if AshLua.Resource.Info.expose?(entrypoint.resource) do - [callable_candidate(entrypoint)] - else - [] - end - end) + |> Enum.map(&callable_candidate/1) record_types = manifest.resources @@ -1294,21 +1321,21 @@ defmodule AshLua.Docs do named_types = Enum.map(manifest.types, &named_type_candidate/1) topics = Enum.map(@topic_ids, &topic_candidate/1) - callables ++ record_types ++ named_types ++ topics + record_types ++ callables ++ named_types ++ topics end defp callable_candidate(%Manifest.Entrypoint{} = entrypoint) do %{ - id: resource_path(entrypoint.resource) <> "." <> Atom.to_string(entrypoint.action.name), + id: AshLua.Surface.path_string(entrypoint), kind: "operation", summary: callable_summary(entrypoint) } end - defp callable_summary(%Manifest.Entrypoint{action: action, resource: resource}) do + defp callable_summary(%Manifest.Entrypoint{action: action} = entrypoint) do case action.description do - nil -> "#{operation_kind(action)} operation on `#{resource_path(resource)}`" - "" -> "#{operation_kind(action)} operation on `#{resource_path(resource)}`" + nil -> "#{operation_kind(action)} operation `#{AshLua.Surface.path_string(entrypoint)}`" + "" -> "#{operation_kind(action)} operation `#{AshLua.Surface.path_string(entrypoint)}`" desc -> desc end end @@ -1357,10 +1384,13 @@ defmodule AshLua.Docs do defp score_candidate(%{id: id, summary: summary}, needle) do id_l = String.downcase(id) sum_l = summary |> to_string() |> String.downcase() + segments = String.split(id_l, ".") cond do id_l == needle -> 1000 - String.starts_with?(id_l, needle) -> 800 + String.starts_with?(id_l, needle <> ".") -> 800 + needle in segments -> 700 + String.starts_with?(id_l, needle) -> 600 String.contains?(id_l, needle) -> 500 String.contains?(sum_l, needle) -> 100 true -> 0 diff --git a/lib/ash_lua/domain.ex b/lib/ash_lua/domain.ex index d73112d..42d37f5 100644 --- a/lib/ash_lua/domain.ex +++ b/lib/ash_lua/domain.ex @@ -3,6 +3,67 @@ # SPDX-License-Identifier: MIT defmodule AshLua.Domain do + @action %Spark.Dsl.Entity{ + name: :action, + target: AshLua.Domain.Action, + args: [:name, :resource, :action], + describe: "Expose an Ash action at a public Lua function name inside a namespace.", + examples: [ + """ + namespace "pages" do + action :list, MyApp.StorefrontPage, :list_for_storefront + end + """ + ], + schema: [ + name: [ + type: :atom, + required: true, + doc: "The Lua function name inside the namespace." + ], + resource: [ + type: {:spark, Ash.Resource}, + required: true, + doc: "The Ash resource that owns the action." + ], + action: [ + type: :atom, + required: true, + doc: "The internal Ash action to call." + ] + ] + } + + @namespace %Spark.Dsl.Entity{ + name: :namespace, + target: AshLua.Domain.Namespace, + args: [:name], + describe: "Defines a public Lua namespace for actions.", + examples: [ + """ + namespace "pages" do + action :list, MyApp.StorefrontPage, :list_for_storefront + end + """, + """ + namespace "storefronts.pages" do + action :list, MyApp.StorefrontPage, :list_for_storefront + end + """ + ], + schema: [ + name: [ + type: {:or, [:string, {:list, :string}]}, + required: true, + doc: + "The public Lua namespace. Dotted strings are split into nested Lua tables, so \"storefronts.pages\" exposes `storefronts.pages.*`." + ] + ], + entities: [ + actions: [@action] + ] + } + @lua %Spark.Dsl.Section{ name: :lua, describe: """ @@ -21,12 +82,15 @@ defmodule AshLua.Domain do doc: "The Lua table name to expose this domain under. Defaults to snake_case of the domain module's last segment." ] - ] + ], + entities: [@namespace] } @moduledoc """ Extension that exposes an Ash domain's resources to Lua scripts evaluated through `AshLua.eval!/2`. """ - use Spark.Dsl.Extension, sections: [@lua] + use Spark.Dsl.Extension, + sections: [@lua], + verifiers: [AshLua.Domain.Verifiers.VerifySurface] end diff --git a/lib/ash_lua/domain/action.ex b/lib/ash_lua/domain/action.ex new file mode 100644 index 0000000..f88001e --- /dev/null +++ b/lib/ash_lua/domain/action.ex @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Domain.Action do + @moduledoc """ + Internal struct backing one explicit Lua surface action declared in a domain. + """ + + @type t :: %__MODULE__{ + name: atom(), + resource: module(), + action: atom(), + __spark_metadata__: term() + } + + defstruct [:name, :resource, :action, __spark_metadata__: nil] +end diff --git a/lib/ash_lua/domain/info.ex b/lib/ash_lua/domain/info.ex index bf6dccf..4d39120 100644 --- a/lib/ash_lua/domain/info.ex +++ b/lib/ash_lua/domain/info.ex @@ -5,6 +5,7 @@ defmodule AshLua.Domain.Info do @moduledoc "Introspection helpers for `AshLua.Domain`." + alias AshLua.Domain.Namespace alias Spark.Dsl.Extension @doc """ @@ -20,6 +21,15 @@ defmodule AshLua.Domain.Info do end end + @doc "Explicit public Lua namespaces configured on the domain." + @spec namespaces(Ash.Domain.t() | Spark.Dsl.t()) :: [Namespace.t()] + def namespaces(domain) do + domain + |> Extension.get_entities([:lua]) + |> List.wrap() + |> Enum.filter(&match?(%Namespace{}, &1)) + end + defp default_name(domain) when is_atom(domain) do domain |> Module.split() diff --git a/lib/ash_lua/domain/namespace.ex b/lib/ash_lua/domain/namespace.ex new file mode 100644 index 0000000..8f57a94 --- /dev/null +++ b/lib/ash_lua/domain/namespace.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Domain.Namespace do + @moduledoc """ + Internal struct backing one explicit Lua namespace declared in a domain. + """ + + @type t :: %__MODULE__{ + name: String.t() | [String.t()], + actions: [AshLua.Domain.Action.t()], + __spark_metadata__: term() + } + + defstruct [:name, actions: [], __spark_metadata__: nil] +end diff --git a/lib/ash_lua/domain/verifiers/verify_surface.ex b/lib/ash_lua/domain/verifiers/verify_surface.ex new file mode 100644 index 0000000..e29b757 --- /dev/null +++ b/lib/ash_lua/domain/verifiers/verify_surface.ex @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Domain.Verifiers.VerifySurface do + @moduledoc false + + use Spark.Dsl.Verifier + + alias AshLua.Domain.{Action, Namespace} + alias Spark.Dsl.Verifier + + @impl true + def verify(dsl) do + domain = Verifier.get_persisted(dsl, :module) + + namespaces = + dsl + |> Verifier.get_entities([:lua]) + |> List.wrap() + |> Enum.filter(&match?(%Namespace{}, &1)) + + errors = + [] + |> validate_actions(domain, namespaces) + |> validate_duplicate_paths(namespaces) + + case Enum.reverse(errors) do + [] -> :ok + errors -> {:error, dsl_error(errors)} + end + end + + defp validate_actions(errors, domain, namespaces) do + Enum.reduce(namespaces, errors, fn %Namespace{} = namespace, acc -> + Enum.reduce(namespace.actions, acc, fn %Action{} = action, acc -> + acc + |> validate_resource_domain(domain, namespace, action) + |> validate_action_exists(namespace, action) + end) + end) + end + + defp validate_resource_domain(errors, domain, namespace, %Action{} = action) do + case safe_resource_domain(action.resource) do + {:ok, ^domain} -> + errors + + {:ok, other_domain} -> + [ + "namespace #{inspect(namespace.name)} action #{inspect(action.name)} references #{inspect(action.resource)}, which belongs to #{inspect(other_domain)} instead of #{inspect(domain)}" + | errors + ] + + {:error, reason} -> + [ + "namespace #{inspect(namespace.name)} action #{inspect(action.name)} references #{inspect(action.resource)}, but its domain could not be read: #{inspect(reason)}" + | errors + ] + end + end + + defp validate_action_exists(errors, namespace, %Action{} = action) do + case safe_action(action.resource, action.action) do + {:ok, %{public?: true}} -> + errors + + {:ok, %{public?: false}} -> + [ + "namespace #{inspect(namespace.name)} action #{inspect(action.name)} references private action #{inspect(action.action)} on #{inspect(action.resource)}" + | errors + ] + + :error -> + [ + "namespace #{inspect(namespace.name)} action #{inspect(action.name)} references missing action #{inspect(action.action)} on #{inspect(action.resource)}" + | errors + ] + end + end + + defp validate_duplicate_paths(errors, namespaces) do + namespaces + |> Enum.flat_map(fn %Namespace{} = namespace -> + namespace_segments = namespace_segments(namespace.name) + + Enum.map(namespace.actions, fn %Action{} = action -> + path = namespace_segments ++ [Atom.to_string(action.name)] + {Enum.join(path, "."), action} + end) + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Enum.reduce(errors, fn + {_path, [_one]}, acc -> + acc + + {path, actions}, acc -> + details = + actions + |> Enum.map_join(", ", &"#{inspect(&1.resource)}.#{&1.action}") + + ["duplicate Lua surface path #{inspect(path)} configured for #{details}" | acc] + end) + end + + defp safe_resource_domain(resource) do + case Code.ensure_compiled(resource) do + {:module, _} -> {:ok, Ash.Resource.Info.domain(resource)} + {:error, reason} -> {:error, reason} + end + end + + defp safe_action(resource, action_name) do + case Code.ensure_compiled(resource) do + {:module, _} -> + case Ash.Resource.Info.action(resource, action_name) do + nil -> :error + action -> {:ok, action} + end + + {:error, _reason} -> + :error + end + end + + defp namespace_segments(name) when is_binary(name) do + name + |> String.split(".", trim: true) + |> Enum.reject(&(&1 == "")) + end + + defp namespace_segments(names) when is_list(names), do: Enum.map(names, &to_string/1) + + defp dsl_error(errors) do + Spark.Error.DslError.exception( + message: """ + Invalid AshLua domain surface configuration: + + #{Enum.map_join(errors, "\n", &("- " <> &1))} + """ + ) + end +end diff --git a/lib/ash_lua/encoder.ex b/lib/ash_lua/encoder.ex index d51942d..d0928f8 100644 --- a/lib/ash_lua/encoder.ex +++ b/lib/ash_lua/encoder.ex @@ -231,7 +231,7 @@ defmodule AshLua.Encoder do Enum.map(list, &encode_with_template(&1, sub)) end - def encode_with_template(record, {:resource, entries}) when is_map(record) do + def encode_with_template(record, {:resource, _resource, entries}) when is_map(record) do Map.new(entries, fn entry -> encode_resource_entry(record, entry) end) end @@ -264,19 +264,22 @@ defmodule AshLua.Encoder do def encode_with_template(value, _template), do: encode_result(value) - defp encode_resource_entry(record, {:attr, name, sub}) do - {Atom.to_string(name), encode_with_template(Map.get(record, name), sub)} + defp encode_resource_entry(record, {:attr, resource, name, sub}) do + {AshLua.FieldNames.to_lua_field_name(resource, name), + record |> Map.get(name) |> encode_with_template(sub)} end - defp encode_resource_entry(record, {:calc, name, sub}) do - {Atom.to_string(name), encode_with_template(unwrap_loaded(Map.get(record, name)), sub)} + defp encode_resource_entry(record, {:calc, resource, name, sub}) do + {AshLua.FieldNames.to_lua_field_name(resource, name), + encode_with_template(unwrap_loaded(Map.get(record, name)), sub)} end - defp encode_resource_entry(record, {:agg, name}) do - {Atom.to_string(name), encode_result(unwrap_loaded(Map.get(record, name)))} + defp encode_resource_entry(record, {:agg, resource, name}) do + {AshLua.FieldNames.to_lua_field_name(resource, name), + encode_result(unwrap_loaded(Map.get(record, name)))} end - defp encode_resource_entry(record, {:rel_one, name, sub}) do + defp encode_resource_entry(record, {:rel_one, resource, name, sub}) do value = Map.get(record, name) encoded = @@ -287,10 +290,10 @@ defmodule AshLua.Encoder do record_or_struct -> encode_with_template(record_or_struct, sub) end - {Atom.to_string(name), encoded} + {AshLua.FieldNames.to_lua_field_name(resource, name), encoded} end - defp encode_resource_entry(record, {:rel_many, name, sub}) do + defp encode_resource_entry(record, {:rel_many, resource, name, sub}) do value = Map.get(record, name) encoded = @@ -301,7 +304,7 @@ defmodule AshLua.Encoder do _ -> [] end - {Atom.to_string(name), encoded} + {AshLua.FieldNames.to_lua_field_name(resource, name), encoded} end defp unwrap_loaded(%Ash.NotLoaded{}), do: nil diff --git a/lib/ash_lua/eval_actions/info.ex b/lib/ash_lua/eval_actions/info.ex index e1ea76f..2004f9c 100644 --- a/lib/ash_lua/eval_actions/info.ex +++ b/lib/ash_lua/eval_actions/info.ex @@ -48,6 +48,14 @@ defmodule AshLua.EvalActions.Info do """ @spec action_entrypoints(Ash.Resource.t() | Spark.Dsl.t()) :: [{module(), atom()}] def action_entrypoints(resource) do + {:ok, manifest} = AshLua.Surface.for_eval_resource(resource) + + AshLua.Surface.action_entrypoints(manifest) + end + + @doc false + @spec exposed_action_entrypoints(Ash.Resource.t() | Spark.Dsl.t()) :: [{module(), atom()}] + def exposed_action_entrypoints(resource) do resource |> exposes() |> Enum.flat_map(&expand_expose/1) diff --git a/lib/ash_lua/eval_actions/run/docs.ex b/lib/ash_lua/eval_actions/run/docs.ex index 49e26ff..0fcd0c2 100644 --- a/lib/ash_lua/eval_actions/run/docs.ex +++ b/lib/ash_lua/eval_actions/run/docs.ex @@ -23,13 +23,9 @@ defmodule AshLua.EvalActions.Run.Docs do use Ash.Resource.Actions.Implementation - alias AshLua.EvalActions.Info - @impl true def run(input, _opts, _context) do resource = input.resource - otp_app = Info.otp_app(resource) - entrypoints = Info.action_entrypoints(resource) name = Map.get(input.arguments, :name) search = Map.get(input.arguments, :search) @@ -41,8 +37,7 @@ defmodule AshLua.EvalActions.Run.Docs do message: "`name` and `search` are mutually exclusive — pass at most one" )} else - with {:ok, manifest} <- - Ash.Info.Manifest.generate(otp_app: otp_app, action_entrypoints: entrypoints) do + with {:ok, manifest} <- AshLua.Surface.for_eval_resource(resource) do dispatch(manifest, name, search) end end diff --git a/lib/ash_lua/eval_actions/run/eval.ex b/lib/ash_lua/eval_actions/run/eval.ex index 11a733a..34ed8e2 100644 --- a/lib/ash_lua/eval_actions/run/eval.ex +++ b/lib/ash_lua/eval_actions/run/eval.ex @@ -17,17 +17,12 @@ defmodule AshLua.EvalActions.Run.Eval do use Ash.Resource.Actions.Implementation - alias AshLua.EvalActions.Info - @impl true def run(input, _opts, context) do script = input.arguments.script resource = input.resource - otp_app = Info.otp_app(resource) - entrypoints = Info.action_entrypoints(resource) - with {:ok, manifest} <- - Ash.Info.Manifest.generate(otp_app: otp_app, action_entrypoints: entrypoints) do + with {:ok, manifest} <- AshLua.Surface.for_eval_resource(resource) do do_run(script, manifest, context) end end diff --git a/lib/ash_lua/field_names.ex b/lib/ash_lua/field_names.ex new file mode 100644 index 0000000..9222679 --- /dev/null +++ b/lib/ash_lua/field_names.ex @@ -0,0 +1,279 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.FieldNames do + @moduledoc """ + Exact resource field name mappings for the Lua boundary. + """ + + @doc "Returns the Lua-facing name for an internal resource field." + @spec to_lua_field_name(module(), atom() | String.t()) :: String.t() + def to_lua_field_name(resource, field_name) do + field_atom = field_atom(field_name) + + case field_atom && Keyword.get(field_names(resource), field_atom) do + nil -> to_string(field_name) + mapped -> mapped + end + end + + @doc "Returns the internal field atom for a Lua-facing field name when known." + @spec to_internal_field_name(module(), atom() | String.t()) :: atom() | String.t() + def to_internal_field_name(_resource, field_name) when is_atom(field_name), do: field_name + + def to_internal_field_name(resource, field_name) when is_binary(field_name) do + case Map.fetch(reverse_field_names(resource), field_name) do + {:ok, internal} -> internal + :error -> field_atom(field_name) || field_name + end + end + + @doc "Returns the Lua-facing name for an internal action argument." + @spec to_lua_argument_name(module(), atom(), atom() | String.t()) :: String.t() + def to_lua_argument_name(resource, action_name, argument_name) do + argument_atom = field_atom(argument_name) + + case argument_atom && Keyword.get(argument_names(resource, action_name), argument_atom) do + nil -> to_string(argument_name) + mapped -> mapped + end + end + + @doc "Returns the internal action argument atom for a Lua-facing argument name when known." + @spec to_internal_argument_name(module(), atom(), atom() | String.t()) :: atom() | String.t() + def to_internal_argument_name(_resource, _action_name, argument_name) + when is_atom(argument_name), + do: argument_name + + def to_internal_argument_name(resource, action_name, argument_name) + when is_binary(argument_name) do + case Map.fetch(reverse_argument_names(resource, action_name), argument_name) do + {:ok, internal} -> internal + :error -> field_atom(argument_name) || argument_name + end + end + + @doc "Returns the Lua-facing input name for an action field or argument." + @spec to_lua_input_name(module(), atom(), atom(), atom() | String.t()) :: String.t() + def to_lua_input_name(resource, action_name, action_type, input_name) do + argument_name = to_lua_argument_name(resource, action_name, input_name) + + cond do + argument_name != to_string(input_name) -> + argument_name + + action_type in [:read, :create, :update, :destroy] -> + to_lua_field_name(resource, input_name) + + true -> + argument_name + end + end + + @doc "Rewrites resource action input keys from Lua names to internal Ash names." + @spec to_internal_input(module(), map(), map()) :: map() + def to_internal_input(resource, %{name: action_name, type: action_type}, input) + when is_map(input) do + Map.new(input, fn + {key, value} when key in ["filter", :filter] -> + {key, to_internal_filter(resource, value)} + + {key, value} when key in ["sort", :sort] -> + {key, to_internal_sort(resource, value)} + + {key, value} -> + {to_internal_input_key(resource, action_name, action_type, key), value} + end) + end + + def to_internal_input(_resource, _action, input), do: input + + @doc "Rewrites only action input keys, without treating reserved call controls specially." + @spec to_internal_action_input(module(), map(), map()) :: map() + def to_internal_action_input(resource, %{name: action_name, type: action_type}, input) + when is_map(input) do + Map.new(input, fn {key, value} -> + {to_internal_action_input_key(resource, action_name, action_type, key), value} + end) + end + + def to_internal_action_input(_resource, _action, input), do: input + + @doc "Rewrites encoded Ash error fields to Lua-facing input names." + @spec to_lua_error(map(), module(), map()) :: map() + def to_lua_error(error_map, resource, %{name: action_name, type: action_type}) + when is_map(error_map) do + Map.update(error_map, "errors", [], fn errors -> + Enum.map(errors, &to_lua_error_leaf(&1, resource, action_name, action_type)) + end) + end + + def to_lua_error(error_map, _resource, _action), do: error_map + + defp to_internal_input_key(_resource, _action_name, _action_type, key) + when key in [ + "fields", + :fields, + "filter", + :filter, + "sort", + :sort, + "limit", + :limit, + "offset", + :offset, + "page", + :page, + "operation", + :operation + ], + do: key + + defp to_internal_input_key(resource, action_name, action_type, key) do + to_internal_action_input_key(resource, action_name, action_type, key) + end + + defp to_internal_action_input_key(resource, action_name, action_type, key) do + argument_name = to_internal_argument_name(resource, action_name, key) + + cond do + argument_name != key and argument_name != to_string(key) -> + Atom.to_string(argument_name) + + action_type in [:read, :create, :update, :destroy] -> + to_internal_field_key(resource, key) + + true -> + key + end + end + + defp to_lua_error_leaf(leaf, resource, action_name, action_type) when is_map(leaf) do + Map.update(leaf, "fields", [], fn fields -> + fields + |> List.wrap() + |> Enum.map(&to_lua_input_name(resource, action_name, action_type, &1)) + end) + end + + defp to_lua_error_leaf(leaf, _resource, _action_name, _action_type), do: leaf + + @doc "Rewrites field names in an Ash filter input tree." + @spec to_internal_filter(module(), term()) :: term() + def to_internal_filter(resource, filter) when is_map(filter) do + Map.new(filter, fn {key, value} -> + case key_string(key) do + "and" -> + {key, map_filter_list(resource, value)} + + "or" -> + {key, map_filter_list(resource, value)} + + "not" -> + {key, to_internal_filter(resource, value)} + + _ -> + {to_internal_field_key(resource, key), filter_value(value)} + end + end) + end + + def to_internal_filter(resource, filters) when is_list(filters) do + Enum.map(filters, &to_internal_filter(resource, &1)) + end + + def to_internal_filter(_resource, filter), do: filter + + @doc "Rewrites field names in a sort input." + @spec to_internal_sort(module(), term()) :: term() + def to_internal_sort(resource, sort) when is_binary(sort) do + {prefix, name} = + case sort do + "-" <> rest -> {"-", rest} + other -> {"", other} + end + + prefix <> to_internal_field_string(resource, name) + end + + def to_internal_sort(resource, sort) when is_atom(sort) do + sort + |> Atom.to_string() + |> then(&to_internal_sort(resource, &1)) + end + + def to_internal_sort(resource, sort) when is_list(sort) do + Enum.map(sort, &to_internal_sort(resource, &1)) + end + + def to_internal_sort(_resource, sort), do: sort + + @doc ~S(Rewrites field names in read operation descriptors like `{ "sum", "total" }`.) + @spec to_internal_operation(module(), term()) :: term() + def to_internal_operation(resource, [op, field]) when is_binary(field) or is_atom(field) do + [op, to_internal_field_string(resource, field)] + end + + def to_internal_operation(_resource, operation), do: operation + + @doc "Returns the internal field name as a string for Ash string-keyed inputs." + @spec to_internal_field_string(module(), atom() | String.t()) :: String.t() + def to_internal_field_string(resource, field_name) do + resource + |> to_internal_field_name(field_name) + |> to_string() + end + + defp to_internal_field_key(resource, field_name) do + resource + |> to_internal_field_name(field_name) + |> to_string() + end + + defp field_names(resource) do + AshLua.Resource.Info.field_names(resource) + end + + defp argument_names(resource, action_name) do + resource + |> AshLua.Resource.Info.argument_names() + |> Keyword.get(action_name, []) + end + + defp reverse_field_names(resource) do + Map.new(field_names(resource), fn {internal, external} -> + {external, internal} + end) + end + + defp reverse_argument_names(resource, action_name) do + Map.new(argument_names(resource, action_name), fn {internal, external} -> + {external, internal} + end) + end + + defp field_atom(name) when is_atom(name), do: name + + defp field_atom(name) when is_binary(name) do + String.to_existing_atom(name) + rescue + ArgumentError -> nil + end + + defp field_atom(_name), do: nil + + defp key_string(key) when is_atom(key), do: Atom.to_string(key) + defp key_string(key) when is_binary(key), do: key + defp key_string(key), do: to_string(key) + + defp map_filter_list(resource, filters) when is_list(filters) do + Enum.map(filters, &to_internal_filter(resource, &1)) + end + + defp map_filter_list(resource, filter), do: to_internal_filter(resource, filter) + + defp filter_value(value) when is_map(value), do: value + defp filter_value(value) when is_list(value), do: Enum.map(value, &filter_value/1) + defp filter_value(value), do: value +end diff --git a/lib/ash_lua/fields.ex b/lib/ash_lua/fields.ex index f5cedb7..ee71509 100644 --- a/lib/ash_lua/fields.ex +++ b/lib/ash_lua/fields.ex @@ -117,15 +117,7 @@ defmodule AshLua.Fields do def parse(_), do: {:error, err("`fields` must be a list", "invalid_fields")} - defp parse_item(name) when is_binary(name) do - case to_field_atom(name) do - nil -> - {:error, err("unknown field `#{name}`", "unknown_field", [name], %{"name" => name})} - - atom -> - {:ok, [{:simple, atom}]} - end - end + defp parse_item(name) when is_binary(name), do: {:ok, [{:simple, name}]} defp parse_item(name) when is_atom(name), do: {:ok, [{:simple, name}]} @@ -156,17 +148,17 @@ defmodule AshLua.Fields do end defp parse_entry(key, value) do - case to_field_atom(key) do - nil -> - s = name_str(key) - {:error, err("unknown field `#{s}`", "unknown_field", [s], %{"name" => s})} - - name -> - case parse_value(value) do - {:with_args, args, sub} -> {:ok, {:with_args, name, args, sub}} - {:nested, sub} -> {:ok, {:nested, name, sub}} - {:error, _} = err -> err - end + if is_binary(key) or is_atom(key) do + name = key + + case parse_value(value) do + {:with_args, args, sub} -> {:ok, {:with_args, name, args, sub}} + {:nested, sub} -> {:ok, {:nested, name, sub}} + {:error, _} = err -> err + end + else + s = name_str(key) + {:error, err("unknown field `#{s}`", "unknown_field", [s], %{"name" => s})} end end @@ -307,9 +299,9 @@ defmodule AshLua.Fields do defp select_for_resource(%Manifest.Resource{} = resource, :default, _ctx) do pk_template = resource.primary_key - |> Enum.map(fn name -> {:attr, name, :passthrough} end) + |> Enum.map(fn name -> {:attr, resource.module, name, :passthrough} end) - {resource.primary_key, [], {:resource, pk_template}} + {resource.primary_key, [], {:resource, resource.module, pk_template}} end defp select_for_resource(%Manifest.Resource{} = resource, ast, ctx) when is_list(ast) do @@ -319,13 +311,16 @@ defmodule AshLua.Fields do {sel ++ sub_sel, ld ++ sub_ld, [entry | tmpl]} end) - {Enum.uniq(select), load, {:resource, Enum.reverse(template)}} + {Enum.uniq(select), load, {:resource, resource.module, Enum.reverse(template)}} end defp select_resource_field(resource, {:simple, name}, ctx) do case classify_resource_field(resource, name) do {:attribute, field} -> - {[name], [], {:attr, name, primitive_or_sub(field.type, :default, ctx)}} + internal = field.name + + {[internal], [], + {:attr, resource.module, internal, primitive_or_sub(field.type, :default, ctx)}} {:calculation, field} -> if has_required_args?(field) do @@ -341,11 +336,15 @@ defmodule AshLua.Fields do )} ) else - {[], [name], {:calc, name, primitive_or_sub(field.type, :default, ctx)}} + internal = field.name + + {[], [internal], + {:calc, resource.module, internal, primitive_or_sub(field.type, :default, ctx)}} end - {:aggregate, _field} -> - {[], [name], {:agg, name}} + {:aggregate, field} -> + internal = field.name + {[], [internal], {:agg, resource.module, internal}} {:relationship, rel} -> # Default sub-selection: pk-only on destination @@ -356,7 +355,7 @@ defmodule AshLua.Fields do load_entry = if sub_load == [], do: rel.name, else: {rel.name, sub_load} kind = rel_kind(rel.cardinality) - {[], [load_entry], {kind, rel.name, sub_template}} + {[], [load_entry], {kind, resource.module, rel.name, sub_template}} :unknown -> s = name_str(name) @@ -368,6 +367,7 @@ defmodule AshLua.Fields do defp select_resource_field(resource, {:nested, name, sub_ast}, ctx) do case classify_resource_field(resource, name) do {:attribute, field} -> + internal = field.name {sub_sel, sub_ld, sub_tmpl} = select_for_type(field.type, sub_ast, ctx) if sub_sel != [] or sub_ld != [] do @@ -384,7 +384,7 @@ defmodule AshLua.Fields do ) end - {[name], [], {:attr, name, sub_tmpl}} + {[internal], [], {:attr, resource.module, internal, sub_tmpl}} {:calculation, field} -> if has_required_args?(field) do @@ -417,7 +417,8 @@ defmodule AshLua.Fields do ) end - {[], [name], {:calc, name, sub_tmpl}} + internal = field.name + {[], [internal], {:calc, resource.module, internal, sub_tmpl}} {:relationship, rel} -> dest_resource = Manifest.get_resource!(ctx.lookup, rel.destination) @@ -425,7 +426,7 @@ defmodule AshLua.Fields do load_entry = if sub_load == [], do: rel.name, else: {rel.name, sub_load} kind = rel_kind(rel.cardinality) - {[], [load_entry], {kind, rel.name, sub_tmpl}} + {[], [load_entry], {kind, resource.module, rel.name, sub_tmpl}} {:aggregate, _field} -> s = name_str(name) @@ -450,6 +451,7 @@ defmodule AshLua.Fields do defp select_resource_field(resource, {:with_args, name, args, sub_ast}, ctx) do case classify_resource_field(resource, name) do {:calculation, field} -> + internal = field.name {sub_sel, sub_ld, sub_tmpl} = select_for_type(field.type, sub_ast, ctx) if sub_sel != [] or sub_ld != [] do @@ -467,8 +469,8 @@ defmodule AshLua.Fields do end # Ash supports `{calc_name, %{arg => value}}` in load. - load_entry = {name, atomize_arg_keys(args, field)} - {[], [load_entry], {:calc, name, sub_tmpl}} + load_entry = {internal, atomize_arg_keys(args, field)} + {[], [load_entry], {:calc, resource.module, internal, sub_tmpl}} _ -> s = name_str(name) @@ -486,21 +488,27 @@ defmodule AshLua.Fields do end defp classify_resource_field(%Manifest.Resource{} = resource, name) do - case Manifest.Resource.get_field(resource, name) do - %Manifest.Field{kind: :attribute} = f -> - {:attribute, f} + internal_name = AshLua.FieldNames.to_internal_field_name(resource.module, name) - %Manifest.Field{kind: :calculation} = f -> - {:calculation, f} + if is_atom(internal_name) do + case Manifest.Resource.get_field(resource, internal_name) do + %Manifest.Field{kind: :attribute} = f -> + {:attribute, f} - %Manifest.Field{kind: :aggregate} = f -> - {:aggregate, f} + %Manifest.Field{kind: :calculation} = f -> + {:calculation, f} - nil -> - case Manifest.Resource.get_relationship(resource, name) do - %Manifest.Relationship{} = r -> {:relationship, r} - nil -> :unknown - end + %Manifest.Field{kind: :aggregate} = f -> + {:aggregate, f} + + nil -> + case Manifest.Resource.get_relationship(resource, internal_name) do + %Manifest.Relationship{} = r -> {:relationship, r} + nil -> :unknown + end + end + else + :unknown end end @@ -591,8 +599,8 @@ defmodule AshLua.Fields do )} ) - %{type: t} -> - {:typed_map_field, name, primitive_or_sub(t, :default, ctx)} + %{name: internal, type: t} -> + {:typed_map_field, internal, primitive_or_sub(t, :default, ctx)} end {:nested, name, sub_ast} -> @@ -610,8 +618,8 @@ defmodule AshLua.Fields do )} ) - %{type: t} -> - {:typed_map_field, name, primitive_or_sub(t, sub_ast, ctx)} + %{name: internal, type: t} -> + {:typed_map_field, internal, primitive_or_sub(t, sub_ast, ctx)} end {:with_args, name, _, _} -> @@ -633,7 +641,10 @@ defmodule AshLua.Fields do {[], [], {:typed_map, entries}} end - defp find_field(fields, name), do: Enum.find(fields, &(&1.name == name)) + defp find_field(fields, name) do + atom_name = to_field_atom(name) + Enum.find(fields, &(&1.name == atom_name)) + end defp select_for_tuple(%Manifest.Type{element_types: ets}, :default, ctx) do entries = @@ -655,7 +666,7 @@ defmodule AshLua.Fields do entries = Enum.map(ast, fn {:simple, name} -> - case Enum.find(indexed, &(&1.name == name)) do + case find_tuple_field(indexed, name) do nil -> s = name_str(name) @@ -669,12 +680,12 @@ defmodule AshLua.Fields do )} ) - %{type: t, index: idx} -> - {:tuple_field, name, idx, primitive_or_sub(t, :default, ctx)} + %{name: internal, type: t, index: idx} -> + {:tuple_field, internal, idx, primitive_or_sub(t, :default, ctx)} end {:nested, name, sub_ast} -> - case Enum.find(indexed, &(&1.name == name)) do + case find_tuple_field(indexed, name) do nil -> s = name_str(name) @@ -688,8 +699,8 @@ defmodule AshLua.Fields do )} ) - %{type: t, index: idx} -> - {:tuple_field, name, idx, primitive_or_sub(t, sub_ast, ctx)} + %{name: internal, type: t, index: idx} -> + {:tuple_field, internal, idx, primitive_or_sub(t, sub_ast, ctx)} end {:with_args, name, _, _} -> @@ -709,6 +720,11 @@ defmodule AshLua.Fields do {[], [], {:tuple, entries}} end + defp find_tuple_field(fields, name) do + atom_name = to_field_atom(name) + Enum.find(fields, &(&1.name == atom_name)) + end + defp select_for_union(%Manifest.Type{members: members}, :default, ctx) do entries = Enum.map(members, fn %{name: name, type: type} -> @@ -732,7 +748,7 @@ defmodule AshLua.Fields do ) {:nested, name, sub_ast} -> - {name, sub_ast} + {to_field_atom(name) || name, sub_ast} {:with_args, name, _, _} -> s = name_str(name) diff --git a/lib/ash_lua/resource.ex b/lib/ash_lua/resource.ex index fbbf57f..fcb7565 100644 --- a/lib/ash_lua/resource.ex +++ b/lib/ash_lua/resource.ex @@ -26,6 +26,17 @@ defmodule AshLua.Resource do type: :boolean, default: true, doc: "Whether to expose this resource and its public actions to Lua." + ], + field_names: [ + type: :keyword_list, + default: [], + doc: "A keyword list mapping internal Ash field names to exact Lua-facing field names." + ], + argument_names: [ + type: :keyword_list, + default: [], + doc: + "A keyword list mapping internal Ash argument names to exact Lua-facing argument names per action." ] ] } @@ -36,5 +47,7 @@ defmodule AshLua.Resource do Used in conjunction with `AshLua.Domain` on the resource's owning domain. """ - use Spark.Dsl.Extension, sections: [@lua] + use Spark.Dsl.Extension, + sections: [@lua], + verifiers: [AshLua.Resource.Verifiers.VerifyNames] end diff --git a/lib/ash_lua/resource/info.ex b/lib/ash_lua/resource/info.ex index 823cd3c..64eb797 100644 --- a/lib/ash_lua/resource/info.ex +++ b/lib/ash_lua/resource/info.ex @@ -26,6 +26,18 @@ defmodule AshLua.Resource.Info do Extension.get_opt(resource, [:lua], :expose?, true) end + @doc "Lua-facing field name mappings configured for this resource." + @spec field_names(Ash.Resource.t() | Spark.Dsl.t()) :: keyword(String.t()) + def field_names(resource) do + Extension.get_opt(resource, [:lua], :field_names, []) + end + + @doc "Lua-facing argument name mappings configured for this resource." + @spec argument_names(Ash.Resource.t() | Spark.Dsl.t()) :: keyword(keyword(String.t())) + def argument_names(resource) do + Extension.get_opt(resource, [:lua], :argument_names, []) + end + defp default_name(resource) when is_atom(resource) do resource |> Module.split() diff --git a/lib/ash_lua/resource/verifiers/verify_names.ex b/lib/ash_lua/resource/verifiers/verify_names.ex new file mode 100644 index 0000000..b9487d7 --- /dev/null +++ b/lib/ash_lua/resource/verifiers/verify_names.ex @@ -0,0 +1,195 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Resource.Verifiers.VerifyNames do + @moduledoc false + + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + + @reserved_input_keys ~w(fields filter sort limit offset page operation) + + @impl true + def verify(dsl) do + resource = Verifier.get_persisted(dsl, :module) + field_names = Verifier.get_option(dsl, [:lua], :field_names) || [] + argument_names = Verifier.get_option(dsl, [:lua], :argument_names) || [] + + errors = + [] + |> validate_field_names(resource, field_names) + |> validate_argument_names(resource, argument_names, field_names) + + case Enum.reverse(errors) do + [] -> :ok + errors -> {:error, dsl_error(errors)} + end + end + + defp validate_field_names(errors, _resource, []), do: errors + + defp validate_field_names(errors, resource, mappings) do + public_fields = public_field_names(resource) + + errors = + Enum.reduce(mappings, errors, fn {internal, lua_name}, acc -> + acc + |> validate_known(:field, internal, public_fields, resource) + |> validate_string(:field, internal, lua_name) + |> validate_not_reserved(:field, internal, lua_name) + end) + + lua_names = + Enum.map(public_fields, fn field -> + Keyword.get(mappings, field, Atom.to_string(field)) + end) + + validate_unique_names(errors, :field, lua_names, resource) + end + + defp validate_argument_names(errors, _resource, [], _field_names), do: errors + + defp validate_argument_names(errors, resource, mappings, field_names) do + Enum.reduce(mappings, errors, fn {action_name, action_mappings}, acc -> + case Ash.Resource.Info.action(resource, action_name) do + nil -> + [ + "argument_names references missing action #{inspect(action_name)} on #{inspect(resource)}" + | acc + ] + + action -> + validate_action_argument_names( + acc, + resource, + action, + List.wrap(action_mappings), + field_names + ) + end + end) + end + + defp validate_action_argument_names(errors, resource, action, mappings, field_names) do + public_arguments = + action + |> Map.get(:arguments, []) + |> Enum.filter(& &1.public?) + |> Enum.map(& &1.name) + + errors = + Enum.reduce(mappings, errors, fn {internal, lua_name}, acc -> + acc + |> validate_known({:argument, action.name}, internal, public_arguments, resource) + |> validate_string({:argument, action.name}, internal, lua_name) + |> validate_not_reserved({:argument, action.name}, internal, lua_name) + end) + + argument_lua_names = + Enum.map(public_arguments, fn argument -> + Keyword.get(mappings, argument, Atom.to_string(argument)) + end) + + errors = validate_unique_names(errors, {:argument, action.name}, argument_lua_names, resource) + + if action.type == :action do + errors + else + field_lua_names = + resource + |> action_field_inputs(action) + |> Enum.map(fn field -> Keyword.get(field_names, field, Atom.to_string(field)) end) + + validate_unique_names( + errors, + {:action_input, action.name}, + argument_lua_names ++ field_lua_names, + resource + ) + end + end + + defp action_field_inputs(resource, action) do + fields = MapSet.new(public_field_names(resource)) + inputs = Ash.Resource.Info.action_inputs(resource, action.name) + + inputs + |> Enum.filter(&MapSet.member?(fields, &1)) + end + + defp public_field_names(resource) do + [ + Ash.Resource.Info.public_attributes(resource), + Ash.Resource.Info.public_relationships(resource), + Ash.Resource.Info.public_calculations(resource), + Ash.Resource.Info.public_aggregates(resource) + ] + |> List.flatten() + |> Enum.map(& &1.name) + end + + defp validate_known(errors, kind, internal, known, resource) do + if internal in known do + errors + else + ["#{kind_label(kind)} #{inspect(internal)} does not exist on #{inspect(resource)}" | errors] + end + end + + defp validate_string(errors, kind, internal, lua_name) when is_binary(lua_name) do + _ = kind + _ = internal + errors + end + + defp validate_string(errors, kind, internal, lua_name) do + [ + "#{kind_label(kind)} #{inspect(internal)} maps to #{inspect(lua_name)}, but Lua-facing names must be strings" + | errors + ] + end + + defp validate_not_reserved(errors, kind, internal, lua_name) when is_binary(lua_name) do + if lua_name in @reserved_input_keys do + [ + "#{kind_label(kind)} #{inspect(internal)} maps to reserved Lua input key #{inspect(lua_name)}" + | errors + ] + else + errors + end + end + + defp validate_not_reserved(errors, _kind, _internal, _lua_name), do: errors + + defp validate_unique_names(errors, kind, lua_names, resource) do + lua_names + |> Enum.group_by(& &1) + |> Enum.reduce(errors, fn + {_name, [_one]}, acc -> + acc + + {name, duplicates}, acc -> + [ + "#{kind_label(kind)} names on #{inspect(resource)} collide on Lua-facing name #{inspect(name)} (#{length(duplicates)} entries)" + | acc + ] + end) + end + + defp kind_label(:field), do: "field" + defp kind_label({:argument, action}), do: "argument for action #{inspect(action)}" + defp kind_label({:action_input, action}), do: "input for action #{inspect(action)}" + + defp dsl_error(errors) do + Spark.Error.DslError.exception( + message: """ + Invalid AshLua resource naming configuration: + + #{Enum.map_join(errors, "\n", &("- " <> &1))} + """ + ) + end +end diff --git a/lib/ash_lua/runtime.ex b/lib/ash_lua/runtime.ex index 7623846..1ed5e45 100644 --- a/lib/ash_lua/runtime.ex +++ b/lib/ash_lua/runtime.ex @@ -9,7 +9,7 @@ defmodule AshLua.Runtime do ## Lua surface - local user, err = accounts.user.create({ name = "Zach" }) + local user, err = accounts.user.create({ input = { name = "Zach" } }) assert(accounts.todo.complete({ id = todo.id })) -- raises on error Action callables always return `(result, nil)` on success and `(nil, err_table)` on failure; @@ -23,6 +23,7 @@ defmodule AshLua.Runtime do alias AshLua.Encoder @private_key :ash_lua + @reserved_call_input_keys ~w(fields filter sort limit offset page operation) @doc """ Builds a `%Lua{}` VM with Ash bindings installed. @@ -41,11 +42,11 @@ defmodule AshLua.Runtime do manifest = case Keyword.fetch(opts, :manifest) do {:ok, %Manifest{} = m} -> - m + AshLua.Surface.for_manifest(m) :error -> otp_app = Keyword.fetch!(opts, :otp_app) - {:ok, m} = Manifest.generate(otp_app: otp_app) + {:ok, m} = AshLua.Surface.for_otp_app(otp_app) m end @@ -61,7 +62,7 @@ defmodule AshLua.Runtime do lua |> Lua.put_private(@private_key, private) |> install_print_capture() - |> install_entrypoints(manifest) + |> install_surface(manifest) |> install_utils(manifest) end @@ -126,36 +127,36 @@ defmodule AshLua.Runtime do Lua.eval!(lua, script, script_opts) end - defp install_entrypoints(lua, %Manifest{} = manifest) do - manifest.entrypoints - |> Enum.group_by(& &1.resource) - |> Enum.reduce(lua, fn {resource, eps_for_resource}, lua -> - install_resource(lua, resource, eps_for_resource, manifest) + defp install_surface(lua, %Manifest{entrypoints: entrypoints} = manifest) do + lua = + entrypoints + |> Enum.flat_map(&path_prefixes(parent_path(AshLua.Surface.path(&1)))) + |> Enum.uniq() + |> Enum.sort_by(&length/1) + |> Enum.reduce(lua, fn path, lua -> + Lua.set!(lua, path, %{}) + end) + + Enum.reduce(entrypoints, lua, fn entrypoint, lua -> + callback = + build_action_callback( + entrypoint.resource, + entrypoint.action, + manifest + ) + + Lua.set!(lua, AshLua.Surface.path(entrypoint), callback) end) end - defp install_resource(lua, resource, entrypoints, manifest) do - if AshLua.Resource.Info.expose?(resource) do - domain = Ash.Resource.Info.domain(resource) - domain_name = AshLua.Domain.Info.name(domain) - resource_name = AshLua.Resource.Info.name(resource) - - # Pre-seed `[domain, resource]` as an empty table so each deep - # `Lua.set!/3` for an action can walk through it. Without this, - # the second resource under a shared domain trips `invalid_index` - # because luerl halts traversal at the first existing prefix - # (`[domain]`) and then can't materialize the missing parent. - lua = Lua.set!(lua, [domain_name, resource_name], %{}) - - Enum.reduce(entrypoints, lua, fn entrypoint, lua -> - action_name = Atom.to_string(entrypoint.action.name) - path = [domain_name, resource_name, action_name] - callback = build_action_callback(resource, entrypoint.action, manifest) - Lua.set!(lua, path, callback) - end) - else - lua - end + defp parent_path([_action]), do: [] + defp parent_path(path), do: Enum.drop(path, -1) + + defp path_prefixes([]), do: [] + + defp path_prefixes(path) do + 1..length(path) + |> Enum.map(&Enum.take(path, &1)) end defp install_utils(lua, %Manifest{} = manifest) do @@ -527,35 +528,113 @@ defmodule AshLua.Runtime do defp build_action_callback(resource, action, manifest) do fn args, state -> - input = decode_call_args(state, args) - ash_opts = build_ash_opts(state) - {fields_input, input} = Map.pop(input, "fields") - {operation, input} = Map.pop(input, "operation") + call_input = decode_call_args(state, args) + + case split_call_input(call_input) do + {:ok, action_input, controls} -> + ash_opts = build_ash_opts(state) + {fields_input, controls} = Map.pop(controls, "fields") + {operation, controls} = Map.pop(controls, "operation") + + controls = AshLua.FieldNames.to_internal_input(resource, action, controls) + + action_input = + AshLua.FieldNames.to_internal_action_input(resource, action, action_input) + + input = Map.merge(action_input, controls) + operation = AshLua.FieldNames.to_internal_operation(resource, operation) + + cond do + is_nil(operation) -> + regular_call(resource, action, input, ash_opts, fields_input, manifest, state) + + action.type == :read -> + operation_call(resource, action, input, ash_opts, operation, state) + + true -> + t = Atom.to_string(action.type) + + encode_error_response( + state, + %AshLua.Errors.FieldsError{ + message: "`operation` is only supported on list operations (this is `#{t}`)", + short_message: "operation only on list operations", + code: "operation_only_on_list_operations", + fields: [], + vars: %{"action_type" => t} + } + ) + end - cond do - is_nil(operation) -> - regular_call(resource, action, input, ash_opts, fields_input, manifest, state) + {:error, error} -> + encode_error_response(state, error) + end + end + end - action.type == :read -> - operation_call(resource, action, input, ash_opts, operation, state) + defp split_call_input(input) do + {action_input, controls} = Map.pop(input, "input") - true -> - t = Atom.to_string(action.type) + case unexpected_nested_input_keys(controls) do + [] -> + normalize_action_input(action_input, controls) - encode_error_response( - state, - %AshLua.Errors.FieldsError{ - message: "`operation` is only supported on list operations (this is `#{t}`)", - short_message: "operation only on list operations", - code: "operation_only_on_list_operations", - fields: [], - vars: %{"action_type" => t} - } - ) - end + keys -> + {:error, nested_input_keys_error(keys)} end end + defp normalize_action_input(nil, controls), do: {:ok, %{}, controls} + + defp normalize_action_input(action_input, controls) when is_map(action_input) do + {:ok, action_input, controls} + end + + defp normalize_action_input(_action_input, _controls) do + {:error, + %AshLua.Errors.FieldsError{ + message: "`input` must be a table of action input values", + short_message: "invalid input", + code: "invalid_input_shape", + fields: ["input"], + vars: %{} + }} + end + + defp unexpected_nested_input_keys(input) do + input + |> Map.keys() + |> Enum.reject(&reserved_call_input_key?/1) + |> Enum.map(&to_string/1) + |> Enum.sort() + end + + defp reserved_call_input_key?(key) when is_atom(key) do + key + |> Atom.to_string() + |> reserved_call_input_key?() + end + + defp reserved_call_input_key?(key) when is_binary(key) do + key in @reserved_call_input_keys + end + + defp reserved_call_input_key?(_key), do: false + + defp nested_input_keys_error(keys) do + joined = Enum.map_join(keys, ", ", &"`#{&1}`") + + %AshLua.Errors.FieldsError{ + message: + "Lua action inputs must be passed under `input`; found top-level key(s): " <> + joined, + short_message: "invalid input shape", + code: "invalid_input_shape", + fields: keys, + vars: %{"keys" => keys} + } + end + defp regular_call(resource, action, input, ash_opts, fields_input, manifest, state) do case AshLua.Fields.for_action(manifest, resource, action, fields_input) do {:ok, {select, load, template}} -> @@ -568,11 +647,11 @@ defmodule AshLua.Runtime do {[true, nil], state} {:error, error} -> - encode_error_response(state, error) + encode_action_error_response(state, resource, action, error) end {:error, reason} -> - encode_error_response(state, reason) + encode_action_error_response(state, resource, action, reason) end end @@ -583,10 +662,10 @@ defmodule AshLua.Runtime do {[encoded, nil], state} {:operation_error, reason} -> - encode_error_response(state, reason) + encode_action_error_response(state, resource, action, reason) {:error, error} -> - encode_error_response(state, error) + encode_action_error_response(state, resource, action, error) end end @@ -595,6 +674,16 @@ defmodule AshLua.Runtime do {[nil, encoded], state} end + defp encode_action_error_response(state, resource, action, error) do + encoded_error = + error + |> Encoder.encode_error() + |> AshLua.FieldNames.to_lua_error(resource, action) + + {encoded, state} = Lua.encode!(state, encoded_error) + {[nil, encoded], state} + end + defp run_read_operation(resource, action, input, opts, operation) do {_page_opt, input} = pop_page_opt(input) {filter, input} = Map.pop(input, "filter") diff --git a/lib/ash_lua/surface.ex b/lib/ash_lua/surface.ex new file mode 100644 index 0000000..64c882d --- /dev/null +++ b/lib/ash_lua/surface.ex @@ -0,0 +1,251 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Surface do + @moduledoc """ + Annotates `%Ash.Info.Manifest{}` entrypoints with AshLua public surface data. + + The manifest remains the API specification. AshLua stores extension-specific + callable path metadata under each entrypoint's `config[:ash_lua]`. + """ + + alias Ash.Info.Manifest + alias AshLua.Domain.{Action, Namespace} + + @config_key :ash_lua + + @type surface_config :: %{ + required(:domain) => module(), + required(:lua_action) => String.t(), + required(:path) => [String.t()], + required(:path_string) => String.t(), + required(:path_source) => :explicit | :derived + } + + @doc "Generates a manifest for an OTP app with AshLua surface metadata attached." + @spec for_otp_app(atom(), keyword()) :: {:ok, Manifest.t()} + def for_otp_app(otp_app, opts \\ []) do + {filter, opts} = Keyword.pop(opts, :action_entrypoints) + action_entrypoints = action_entrypoints_for_otp_app(otp_app, filter) + + opts + |> Keyword.put(:otp_app, otp_app) + |> Keyword.put(:action_entrypoints, action_entrypoints) + |> Manifest.generate() + end + + @doc "Generates the scoped Lua manifest for an `AshLua.EvalActions` resource." + @spec for_eval_resource(module()) :: {:ok, Manifest.t()} + def for_eval_resource(resource) do + otp_app = AshLua.EvalActions.Info.otp_app(resource) + + for_otp_app(otp_app, + action_entrypoints: AshLua.EvalActions.Info.exposed_action_entrypoints(resource) + ) + end + + @doc """ + Attaches AshLua surface metadata to an existing manifest. + + Domains with explicit `lua do namespace ... end` actions expose only those + configured actions. Domains without explicit surface actions retain the legacy + derived shape: `..`. + """ + @spec for_manifest(Manifest.t()) :: Manifest.t() + def for_manifest(%Manifest{entrypoints: []} = manifest), do: manifest + + def for_manifest(%Manifest{} = manifest) do + filter = + Enum.map(manifest.entrypoints, fn %Manifest.Entrypoint{} = entrypoint -> + {entrypoint.resource, entrypoint.action.name} + end) + + entries_by_key = + Enum.group_by(manifest.entrypoints, fn %Manifest.Entrypoint{} = entrypoint -> + {entrypoint.resource, entrypoint.action.name} + end) + + entrypoints = + manifest + |> otp_app_from_manifest() + |> action_entrypoints_for_otp_app(filter) + |> Enum.flat_map(fn %{resource: resource, action: action, config: config} -> + case Map.get(entries_by_key, {resource, action}) do + nil -> + [] + + [entrypoint | _] -> + [%{entrypoint | config: merge_config(entrypoint.config, config)}] + end + end) + + %{manifest | entrypoints: entrypoints} + end + + @doc "Deprecated compatibility alias for `for_manifest/1`." + @spec from_manifest(Manifest.t()) :: Manifest.t() + def from_manifest(%Manifest{} = manifest), do: for_manifest(manifest) + + @doc "Returns the annotated entrypoint matching a dotted Lua callable path." + @spec find_entrypoint(Manifest.t(), String.t()) :: {:ok, Manifest.Entrypoint.t()} | :error + def find_entrypoint(%Manifest{} = manifest, path) when is_binary(path) do + case Enum.find(manifest.entrypoints, &(path_string(&1) == path)) do + nil -> :error + entrypoint -> {:ok, entrypoint} + end + end + + @doc "Compatibility alias for `find_entrypoint/2`." + @spec find_action(Manifest.t(), String.t()) :: {:ok, Manifest.Entrypoint.t()} | :error + def find_action(%Manifest{} = manifest, path), do: find_entrypoint(manifest, path) + + @doc "Returns the resource/action tuples represented by the annotated manifest." + @spec action_entrypoints(Manifest.t()) :: [{module(), atom()}] + def action_entrypoints(%Manifest{entrypoints: entrypoints}) do + entrypoints + |> Enum.map(&{&1.resource, &1.action.name}) + |> Enum.uniq() + end + + @doc "Returns the Lua path segments for an annotated entrypoint." + @spec path(Manifest.Entrypoint.t()) :: [String.t()] + def path(%Manifest.Entrypoint{} = entrypoint), do: config!(entrypoint).path + + @doc "Returns the dotted Lua path for an annotated entrypoint." + @spec path_string(Manifest.Entrypoint.t()) :: String.t() + def path_string(%Manifest.Entrypoint{} = entrypoint), do: config!(entrypoint).path_string + + @doc "Returns the AshLua config for an annotated entrypoint." + @spec config(Manifest.Entrypoint.t()) :: surface_config() | nil + def config(%Manifest.Entrypoint{config: config}) do + Map.get(config || %{}, @config_key) + end + + defp config!(%Manifest.Entrypoint{} = entrypoint) do + config(entrypoint) || raise ArgumentError, "manifest entrypoint is missing AshLua metadata" + end + + defp action_entrypoints_for_otp_app(otp_app, filter) do + filter = filter_set(filter) + + otp_app + |> Ash.Info.domains() + |> Enum.flat_map(&action_entrypoints_for_domain(&1, filter)) + |> Enum.sort_by(fn %{config: %{@config_key => %{path_string: path}}} -> path end) + end + + defp action_entrypoints_for_domain(domain, filter) do + case AshLua.Domain.Info.namespaces(domain) do + [] -> derived_action_entrypoints(domain, filter) + namespaces -> explicit_action_entrypoints(domain, namespaces, filter) + end + end + + defp explicit_action_entrypoints(domain, namespaces, filter) do + Enum.flat_map(namespaces, fn %Namespace{name: namespace, actions: actions} -> + namespace_segments = namespace_segments(namespace) + + actions + |> Enum.filter(&included?(&1.resource, &1.action, filter)) + |> Enum.map(fn %Action{} = action -> + path = namespace_segments ++ [Atom.to_string(action.name)] + + action_entrypoint(action.resource, action.action, %{ + domain: domain, + lua_action: Atom.to_string(action.name), + path: path, + path_string: Enum.join(path, "."), + path_source: :explicit + }) + end) + end) + end + + defp derived_action_entrypoints(domain, filter) do + domain + |> Ash.Domain.Info.resources() + |> Enum.flat_map(fn resource -> + if AshLua.Resource.Info.expose?(resource) do + resource + |> Ash.Resource.Info.actions() + |> Enum.filter(&included?(resource, &1.name, filter)) + |> Enum.map(fn action -> + path = legacy_path(resource, action.name) + + action_entrypoint(resource, action.name, %{ + domain: domain, + lua_action: Atom.to_string(action.name), + path: path, + path_string: Enum.join(path, "."), + path_source: :derived + }) + end) + else + [] + end + end) + end + + defp action_entrypoint(resource, action, config) do + %{ + resource: resource, + action: action, + config: %{@config_key => config} + } + end + + defp merge_config(old, new) do + Map.merge(old || %{}, new || %{}, fn + @config_key, old_config, new_config -> Map.merge(old_config || %{}, new_config || %{}) + _key, _old_value, new_value -> new_value + end) + end + + defp filter_set(nil), do: nil + + defp filter_set(entries) when is_list(entries) do + entries + |> Enum.map(fn + {resource, action} -> {resource, action} + %{resource: resource, action: action} -> {resource, action} + end) + |> MapSet.new() + end + + defp included?(_resource, _action, nil), do: true + + defp included?(resource, action, %MapSet{} = filter) do + MapSet.member?(filter, {resource, action}) + end + + defp otp_app_from_manifest(%Manifest{ + entrypoints: [%Manifest.Entrypoint{resource: resource} | _] + }) do + resource + |> Ash.Resource.Info.domain() + |> Spark.otp_app() + end + + defp otp_app_from_manifest(%Manifest{}) do + raise ArgumentError, "cannot infer otp_app from an empty manifest" + end + + defp legacy_path(resource, action_name) do + domain = Ash.Resource.Info.domain(resource) + + [ + AshLua.Domain.Info.name(domain), + AshLua.Resource.Info.name(resource), + Atom.to_string(action_name) + ] + end + + defp namespace_segments(name) when is_binary(name) do + name + |> String.split(".", trim: true) + |> Enum.reject(&(&1 == "")) + end + + defp namespace_segments(names) when is_list(names), do: Enum.map(names, &to_string/1) +end diff --git a/test/ash_lua/docs_test.exs b/test/ash_lua/docs_test.exs index fde7f36..6577bee 100644 --- a/test/ash_lua/docs_test.exs +++ b/test/ash_lua/docs_test.exs @@ -129,7 +129,8 @@ defmodule AshLua.DocsTest do assert md =~ "# `posts.post.create`" assert md =~ "**Operation:** `create`" - assert md =~ "| `title` |" + assert md =~ "| `input` | table | yes |" + assert md =~ "| `input.title` |" assert md =~ "| `fields` |" assert md =~ "default = primary key only" @@ -158,17 +159,17 @@ defmodule AshLua.DocsTest do {:ok, delete_md} = AshLua.Docs.callable_doc(opts(), "posts.post.destroy") assert update_md =~ "**Operation:** `update`" - assert update_md =~ "| `id` |" + assert update_md =~ "| `input.id` |" assert update_md =~ "identifies the record" assert delete_md =~ "**Operation:** `delete`" - assert delete_md =~ "| `id` |" + assert delete_md =~ "| `input.id` |" end test "generic (call) renders its inputs and return type" do {:ok, md} = AshLua.Docs.callable_doc(opts(), "posts.post.word_count") assert md =~ "**Operation:** `call`" - assert md =~ "| `text` | `string` | yes" + assert md =~ "| `input.text` | `string` | yes" assert md =~ "`integer`" refute md =~ "selection tree" end diff --git a/test/ash_lua/eval_actions_test.exs b/test/ash_lua/eval_actions_test.exs index cf14951..1582ec0 100644 --- a/test/ash_lua/eval_actions_test.exs +++ b/test/ash_lua/eval_actions_test.exs @@ -27,7 +27,7 @@ defmodule AshLua.EvalActionsTest do input = Ash.ActionInput.for_action(MCPActions, :eval, %{ script: """ - return posts.post.create({ body = "missing title" }) + return posts.post.create({ input = { body = "missing title" } }) """ }) @@ -140,7 +140,7 @@ defmodule AshLua.EvalActionsTest do input = Ash.ActionInput.for_action(MCPActions, :eval, %{ script: """ - local _, err = posts.comment.create({ body = "x" }) + local _, err = posts.comment.create({ input = { body = "x" } }) return err == nil """ }) diff --git a/test/ash_lua/fields_test.exs b/test/ash_lua/fields_test.exs index 3e2fafd..0352571 100644 --- a/test/ash_lua/fields_test.exs +++ b/test/ash_lua/fields_test.exs @@ -442,7 +442,7 @@ defmodule AshLua.FieldsTest do {[nil, err], _lua} = AshLua.eval!( """ - return posts.post.create({ title = "x", operation = "count" }) + return posts.post.create({ input = { title = "x" }, operation = "count" }) """, otp_app: :ash_lua ) @@ -464,7 +464,7 @@ defmodule AshLua.FieldsTest do AshLua.eval!( """ local p = assert(posts.post.create({ - title = "Created", body = "B", author_id = "#{user.id}", + input = { title = "Created", body = "B", author_id = "#{user.id}" }, fields = { "title", { author = { "name" } } } })) return p @@ -485,7 +485,7 @@ defmodule AshLua.FieldsTest do AshLua.eval!( """ local p = assert(posts.post.update({ - id = "#{post.id}", title = "New", + input = { id = "#{post.id}", title = "New" }, fields = { "title", "title_downcase" } })) return p @@ -505,7 +505,7 @@ defmodule AshLua.FieldsTest do AshLua.eval!( """ local p = assert(posts.post.destroy({ - id = "#{post.id}", + input = { id = "#{post.id}" }, fields = { "title" } })) return p @@ -521,7 +521,9 @@ defmodule AshLua.FieldsTest do {[count], _lua} = AshLua.eval!( """ - local n = assert(posts.post.word_count({ text = "one two three" })) + local n = assert(posts.post.word_count({ + input = { text = "one two three" } + })) return n """, otp_app: :ash_lua diff --git a/test/ash_lua/surface_test.exs b/test/ash_lua/surface_test.exs new file mode 100644 index 0000000..b98f184 --- /dev/null +++ b/test/ash_lua/surface_test.exs @@ -0,0 +1,242 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.SurfaceTest do + use ExUnit.Case, async: false + + alias AshLua.Test.Surface.MCPActions + alias AshLua.Test.Surface.Page + + defp opts, do: [otp_app: :ash_lua] + + test "surface metadata is stored on manifest entrypoints" do + assert {:ok, %Ash.Info.Manifest{} = manifest} = AshLua.Surface.for_otp_app(:ash_lua) + assert {:ok, entrypoint} = AshLua.Surface.find_entrypoint(manifest, "surface.page_list") + + assert entrypoint.config.ash_lua.path == ["surface", "page_list"] + assert entrypoint.config.ash_lua.path_string == "surface.page_list" + assert entrypoint.config.ash_lua.path_source == :explicit + end + + test "domain lua namespace exposes public action paths" do + title = unique_title("Landing") + {:ok, _page} = Ash.create(Page, %{title: title}, action: :create) + + script = """ + local rows = assert(surface.page_list({ + fields = { "id", "headline", "featured" }, + filter = { headline = "#{title}", featured = false }, + sort = "headline" + })) + + return rows[1] + """ + + {[record], _lua} = AshLua.eval!(script, opts()) + + record = Map.new(record) + + assert record["headline"] == title + assert record["featured"] == false + assert is_binary(record["id"]) + refute Map.has_key?(record, "title") + refute Map.has_key?(record, "featured?") + end + + test "domain explicit surface does not expose legacy resource/action paths" do + callables = AshLua.Docs.list_callables(opts()) + + assert "surface.page_list" in callables + assert "surface.page_create" in callables + assert "surface.page_rename" in callables + assert "surface.page_summarize" in callables + refute "pages.list" in callables + refute "surface.page.list_for_storefront" in callables + refute "surface.page.rename" in callables + end + + test "docs render public surface paths" do + {:ok, md} = AshLua.Docs.callable_doc(opts(), "surface.page_list") + + assert md =~ "# `surface.page_list`" + assert md =~ "**Operation:** `list`" + assert md =~ "A list of [`surface.page`](#surface-page) records." + refute md =~ "list_for_storefront" + end + + test "field_names rewrite input fields and output keys" do + title = unique_title("Before") + renamed = unique_title("After") + {:ok, page} = Ash.create(Page, %{title: title}, action: :create) + + script = """ + local page = assert(surface.page_rename({ + input = { + id = "#{page.id}", + headline = "#{renamed}", + featured = true + }, + fields = { "id", "headline", "featured" } + })) + + return page + """ + + {[record], _lua} = AshLua.eval!(script, opts()) + + record = Map.new(record) + + assert record["id"] == page.id + assert record["headline"] == renamed + assert record["featured"] == true + refute Map.has_key?(record, "title") + refute Map.has_key?(record, "featured?") + end + + test "argument_names rewrite generic action inputs" do + script = """ + return assert(surface.page_summarize({ + input = { headlineText = "About" } + })) + """ + + {[result, nil], _lua} = AshLua.eval!(script, opts()) + + assert result == "summary: About" + end + + test "explicit surface action inputs must be nested under input" do + script = """ + local _result, err = surface.page_summarize({ headlineText = "About" }) + + return err.errors[1].code, err.errors[1].fields[1] + """ + + {[code, field], _lua} = AshLua.eval!(script, opts()) + + assert code == "invalid_input_shape" + assert field == "headlineText" + end + + test "docs use field_names in resource-facing rows" do + {:ok, callable_md} = AshLua.Docs.callable_doc(opts(), "surface.page_rename") + {:ok, type_md} = AshLua.Docs.type_doc(opts(), "surface.page") + + assert callable_md =~ "| `input` | table | yes |" + assert callable_md =~ "| `input.headline` |" + assert callable_md =~ "| `input.featured` |" + refute callable_md =~ "| `title` |" + refute callable_md =~ "| `featured?` |" + + assert type_md =~ "| `headline` | `string`" + assert type_md =~ "| `featured` | `boolean`" + assert type_md =~ "### `headline` (`string`)" + assert type_md =~ "- `headline`" + refute type_md =~ "| `title` |" + refute type_md =~ "| `featured?` |" + + {:ok, generic_md} = AshLua.Docs.callable_doc(opts(), "surface.page_summarize") + assert generic_md =~ "| `input.headlineText` | `string` | yes" + refute generic_md =~ "| `title_text` |" + end + + test "Ash errors rewrite field names back to Lua names" do + script = """ + local _page, err = surface.page_create({ + fields = { "headline" } + }) + + return err.errors[1].fields[1] + """ + + {[field], _lua} = AshLua.eval!(script, opts()) + + assert field == "headline" + end + + test "eval_actions scopes to the resolved public surface" do + title = unique_title("Eval") + {:ok, _page} = Ash.create(Page, %{title: title}, action: :create) + + input = + Ash.ActionInput.for_action(MCPActions, :eval, %{ + script: """ + local rows = assert(surface.page_list({ + fields = { "headline" }, + filter = { headline = "#{title}" } + })) + + return rows[1].headline + """ + }) + + assert {:ok, %{result: ^title, error: nil}} = Ash.run_action(input) + end + + test "eval_actions map field_names through returned records" do + title = unique_title("Eval Record") + {:ok, _page} = Ash.create(Page, %{title: title}, action: :create) + + input = + Ash.ActionInput.for_action(MCPActions, :eval, %{ + script: """ + local rows = assert(surface.page_list({ + fields = { "headline", "featured" }, + filter = { headline = "#{title}", featured = false }, + sort = "headline" + })) + + return rows + """ + }) + + assert {:ok, %{result: [record], error: nil}} = Ash.run_action(input) + + assert record["headline"] == title + assert record["featured"] == false + refute Map.has_key?(record, "title") + refute Map.has_key?(record, "featured?") + end + + test "eval_actions docs index uses public paths" do + input = Ash.ActionInput.for_action(MCPActions, :docs, %{}) + + assert {:ok, md} = Ash.run_action(input) + assert md =~ "- `surface.page_list`" + assert md =~ "- `surface.page_rename`" + assert md =~ "- `surface.page_summarize`" + assert md =~ "- `surface.page`" + assert md =~ ~s(name = "full") + refute md =~ "surface.page.list_for_storefront" + end + + test "eval_actions docs resolve field_names on focused pages" do + callable_input = Ash.ActionInput.for_action(MCPActions, :docs, %{name: "surface.page_rename"}) + type_input = Ash.ActionInput.for_action(MCPActions, :docs, %{name: "surface.page"}) + + assert {:ok, callable_md} = Ash.run_action(callable_input) + assert callable_md =~ "# `surface.page_rename`" + assert callable_md =~ "| `input` | table | yes |" + assert callable_md =~ "| `input.headline` |" + assert callable_md =~ "| `input.featured` |" + refute callable_md =~ "| `title` |" + + assert {:ok, type_md} = Ash.run_action(type_input) + assert type_md =~ "# Record type `surface.page`" + assert type_md =~ "| `headline` | `string`" + assert type_md =~ "| `featured` | `boolean`" + refute type_md =~ "| `featured?` |" + + generic_input = + Ash.ActionInput.for_action(MCPActions, :docs, %{name: "surface.page_summarize"}) + + assert {:ok, generic_md} = Ash.run_action(generic_input) + assert generic_md =~ "| `input.headlineText` | `string` | yes" + refute generic_md =~ "| `title_text` |" + end + + defp unique_title(prefix) do + "#{prefix} #{System.unique_integer([:positive])}" + end +end diff --git a/test/ash_lua/transactions_test.exs b/test/ash_lua/transactions_test.exs index 426a7c0..74d7ffe 100644 --- a/test/ash_lua/transactions_test.exs +++ b/test/ash_lua/transactions_test.exs @@ -21,7 +21,10 @@ defmodule AshLua.TransactionsTest do AshLua.eval!( """ return utils.transaction.transact({ "posts.mnesia_note" }, function() - local n = assert(posts.mnesia_note.create({ body = "alpha", fields = { "id", "body" } })) + local n = assert(posts.mnesia_note.create({ + input = { body = "alpha" }, + fields = { "id", "body" } + })) return n.body end) """, @@ -38,7 +41,7 @@ defmodule AshLua.TransactionsTest do AshLua.eval!( """ return utils.transaction.transact({ "posts.mnesia_note" }, function() - assert(posts.mnesia_note.create({ body = "first" })) + assert(posts.mnesia_note.create({ input = { body = "first" } })) -- intentionally pass an empty body to trigger an action-level error assert(posts.mnesia_note.create({})) return "should not reach" @@ -66,7 +69,7 @@ defmodule AshLua.TransactionsTest do AshLua.eval!( """ return utils.transaction.transact({ "posts.mnesia_note" }, function() - assert(posts.mnesia_note.create({ body = "first" })) + assert(posts.mnesia_note.create({ input = { body = "first" } })) return posts.mnesia_note.create({}) -- returns (nil, err) instead of asserting end) """, @@ -139,7 +142,7 @@ defmodule AshLua.TransactionsTest do AshLua.eval!( """ return utils.transaction.transact({ "posts.mnesia_note" }, function() - assert(posts.mnesia_note.create({ body = "will roll back" })) + assert(posts.mnesia_note.create({ input = { body = "will roll back" } })) utils.transaction.rollback("business rule failed") return "should not reach" end) @@ -179,7 +182,7 @@ defmodule AshLua.TransactionsTest do AshLua.eval!( ~S""" return utils.transaction.transact({ "posts.mnesia_note" }, function() - assert(posts.mnesia_note.create({ body = "before error" })) + assert(posts.mnesia_note.create({ input = { body = "before error" } })) error({ code = "custom_thing", message = "i raised this directly" }) end) """, @@ -202,7 +205,7 @@ defmodule AshLua.TransactionsTest do AshLua.eval!( ~S""" return utils.transaction.transact({ "posts.mnesia_note" }, function() - assert(posts.mnesia_note.create({ body = "before error" })) + assert(posts.mnesia_note.create({ input = { body = "before error" } })) error("boom") end) """, @@ -223,7 +226,10 @@ defmodule AshLua.TransactionsTest do """ local prefix = "from-outer-" return utils.transaction.transact({ "posts.mnesia_note" }, function() - local n = assert(posts.mnesia_note.create({ body = prefix .. "scope", fields = { "body" } })) + local n = assert(posts.mnesia_note.create({ + input = { body = prefix .. "scope" }, + fields = { "body" } + })) return n.body end) """, diff --git a/test/ash_lua/verifiers_test.exs b/test/ash_lua/verifiers_test.exs new file mode 100644 index 0000000..a1eee69 --- /dev/null +++ b/test/ash_lua/verifiers_test.exs @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.VerifiersTest do + use ExUnit.Case, async: true + + alias AshLua.Domain.{Action, Namespace} + alias AshLua.Test.Surface.Page + + test "resource verifier rejects field_names for missing fields" do + dsl = put_lua_opt(Page.spark_dsl_config(), :field_names, missing: "missing") + + assert {:error, error} = AshLua.Resource.Verifiers.VerifyNames.verify(dsl) + assert Exception.message(error) =~ "field :missing does not exist" + end + + test "resource verifier rejects argument_names for missing arguments" do + dsl = put_lua_opt(Page.spark_dsl_config(), :argument_names, summarize: [missing: "missing"]) + + assert {:error, error} = AshLua.Resource.Verifiers.VerifyNames.verify(dsl) + assert Exception.message(error) =~ "argument for action :summarize :missing does not exist" + end + + test "domain verifier rejects duplicate public paths and missing actions" do + namespace = %Namespace{ + name: "pages", + actions: [ + %Action{name: :list, resource: Page, action: :list_for_storefront}, + %Action{name: :list, resource: Page, action: :missing} + ] + } + + dsl = + put_in(AshLua.Test.Surface.spark_dsl_config(), [Access.key([:lua]), :entities], [namespace]) + + assert {:error, error} = AshLua.Domain.Verifiers.VerifySurface.verify(dsl) + + message = Exception.message(error) + assert message =~ "duplicate Lua surface path" + assert message =~ "missing action :missing" + end + + defp put_lua_opt(dsl, key, value) do + put_in(dsl, [Access.key([:lua]), :opts, key], value) + end +end diff --git a/test/ash_lua_test.exs b/test/ash_lua_test.exs index edb37d1..61f1078 100644 --- a/test/ash_lua_test.exs +++ b/test/ash_lua_test.exs @@ -13,7 +13,7 @@ defmodule AshLuaTest do AshLua.eval!( """ local post, err = posts.post.create({ - title = "Hello", body = "World", + input = { title = "Hello", body = "World" }, fields = { "id" } }) assert(err == nil) @@ -30,7 +30,7 @@ defmodule AshLuaTest do {[nil, err], _lua} = AshLua.eval!( """ - local post, err = posts.post.create({ body = "no title" }) + local post, err = posts.post.create({ input = { body = "no title" } }) return post, err """, otp_app: :ash_lua @@ -53,7 +53,7 @@ defmodule AshLuaTest do AshLua.eval!( """ local post = assert(posts.post.create({ - title = "Assert works", body = "yes", + input = { title = "Assert works", body = "yes" }, fields = { "title" } })) return post.title @@ -66,7 +66,7 @@ defmodule AshLuaTest do assert_raise Lua.RuntimeException, fn -> AshLua.eval!( """ - assert(posts.post.create({ body = "no title" })) + assert(posts.post.create({ input = { body = "no title" } })) """, otp_app: :ash_lua ) @@ -129,7 +129,7 @@ defmodule AshLuaTest do AshLua.eval!( """ local p = assert(posts.post.update({ - id = "#{post.id}", title = "Updated", + input = { id = "#{post.id}", title = "Updated" }, fields = { "title" } })) return p.title @@ -142,7 +142,7 @@ defmodule AshLuaTest do {[true_value], _lua} = AshLua.eval!( """ - local _, err = posts.post.destroy({ id = "#{post.id}" }) + local _, err = posts.post.destroy({ input = { id = "#{post.id}" } }) return err == nil """, otp_app: :ash_lua @@ -159,7 +159,7 @@ defmodule AshLuaTest do AshLua.eval!( """ local p = assert(posts.post.publish({ - id = "#{post.id}", + input = { id = "#{post.id}" }, fields = { "published" } })) return p.published @@ -174,7 +174,9 @@ defmodule AshLuaTest do {[count], _lua} = AshLua.eval!( """ - local n = assert(posts.post.word_count({ text = "one two three four" })) + local n = assert(posts.post.word_count({ + input = { text = "one two three four" } + })) return n """, otp_app: :ash_lua diff --git a/test/support/fixtures/surface.ex b/test/support/fixtures/surface.ex new file mode 100644 index 0000000..369391d --- /dev/null +++ b/test/support/fixtures/surface.ex @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Test.Surface do + @moduledoc false + use Ash.Domain, + otp_app: :ash_lua, + extensions: [AshLua.Domain] + + lua do + namespace "surface" do + action :page_create, AshLua.Test.Surface.Page, :create + action :page_list, AshLua.Test.Surface.Page, :list_for_storefront + action :page_rename, AshLua.Test.Surface.Page, :rename + action :page_summarize, AshLua.Test.Surface.Page, :summarize + end + end + + resources do + resource AshLua.Test.Surface.Page + resource AshLua.Test.Surface.MCPActions + end +end diff --git a/test/support/fixtures/surface_mcp_actions.ex b/test/support/fixtures/surface_mcp_actions.ex new file mode 100644 index 0000000..35a19ee --- /dev/null +++ b/test/support/fixtures/surface_mcp_actions.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Test.Surface.MCPActions do + @moduledoc false + use Ash.Resource, + domain: AshLua.Test.Surface, + extensions: [AshLua.EvalActions] + + eval_actions do + resource AshLua.Test.Surface.Page, actions: [:list_for_storefront, :rename, :summarize] + end +end diff --git a/test/support/fixtures/surface_page.ex b/test/support/fixtures/surface_page.ex new file mode 100644 index 0000000..7004994 --- /dev/null +++ b/test/support/fixtures/surface_page.ex @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 ash_lua contributors +# +# SPDX-License-Identifier: MIT + +defmodule AshLua.Test.Surface.Page do + @moduledoc false + use Ash.Resource, + domain: AshLua.Test.Surface, + data_layer: Ash.DataLayer.Ets, + extensions: [AshLua.Resource] + + ets do + private? true + end + + lua do + field_names title: "headline", featured?: "featured" + argument_names summarize: [title_text: "headlineText"] + end + + actions do + create :create do + primary? true + accept [:title, :featured?] + end + + read :list_for_storefront do + primary? true + end + + update :rename do + accept [:title, :featured?] + end + + action :summarize, :string do + argument :title_text, :string do + allow_nil? false + end + + run fn input, _context -> + {:ok, "summary: " <> input.arguments.title_text} + end + end + end + + attributes do + uuid_primary_key :id + + attribute :title, :string do + allow_nil? false + public? true + end + + attribute :featured?, :boolean do + default false + public? true + end + end +end