From dac6587e58640e6b46b72bdccd1d89d5b6515202 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 29 Apr 2026 07:28:50 +0930 Subject: [PATCH 1/4] minimal base place and place extension --- lib/diffo/provider/components/base_place.ex | 189 ++++++++++++++++++ lib/diffo/provider/components/place.ex | 124 +----------- .../provider/components/place/extension.ex | 17 ++ .../components/place/extension/info.ex | 16 ++ 4 files changed, 223 insertions(+), 123 deletions(-) create mode 100644 lib/diffo/provider/components/base_place.ex create mode 100644 lib/diffo/provider/components/place/extension.ex create mode 100644 lib/diffo/provider/components/place/extension/info.ex diff --git a/lib/diffo/provider/components/base_place.ex b/lib/diffo/provider/components/base_place.ex new file mode 100644 index 0000000..0d93633 --- /dev/null +++ b/lib/diffo/provider/components/base_place.ex @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.BasePlace do + @moduledoc """ + Ash Resource Fragment which is the point of extension for your TMF Place. + + `BasePlace` is the foundation for domain-specific Place kinds. + Include it as a fragment on an `Ash.Resource` to get common Place attributes, Neo4j graph + wiring, and the `Diffo.Provider.Place.Extension` DSL. + + `Diffo.Provider.Place` uses `BasePlace` directly as the out-of-the-box TMF Place resource. + Domain-specific resources extend it for richer domain identity. + + ## Attributes + + - `id` — string primary key (required, no default — set by domain). + - `href` — optional URI for the place. + - `name` — the place name. + - `type` — TMF `@type`. Defaults to `:PlaceRef`. One of `:PlaceRef`, `:GeographicSite`, + `:GeographicLocation`, `:GeographicAddress`. When `referred_type` is present, `type` must + be `:PlaceRef`. + - `referred_type` — TMF `@referredType`. One of `:GeographicSite`, `:GeographicLocation`, + `:GeographicAddress`. When present, indicates this is a reference to a place of that kind; + `type` must be `:PlaceRef`. + + ## Usage + + defmodule MyApp.GeographicSite do + use Ash.Resource, fragments: [BasePlace], domain: MyApp.Domain + + resource do + description "A Geographic Site" + plural_name :geographic_sites + end + + jason do + pick [:id, :href, :name, :referred_type, :type] + compact true + rename referred_type: "@referredType", type: "@type" + end + + outstanding do + expect [:id, :name, :referred_type, :type] + end + + actions do + create :build do + accept [:id, :href, :name] + change set_attribute(:type, :GeographicSite) + end + end + end + + ## TMF type and referred_type + + The `type` and `referred_type` attributes map to the TMF `@type` and `@referredType` JSON + fields via the jason layer. When `referred_type` is present, `type` must be `:PlaceRef`; + otherwise `type` must not be `:PlaceRef`. + """ + use Spark.Dsl.Fragment, + of: Ash.Resource, + otp_app: :diffo, + domain: Diffo.Provider, + data_layer: AshNeo4j.DataLayer, + extensions: [ + AshOutstanding.Resource, + AshJason.Resource, + Diffo.Provider.Place.Extension + ] + + neo4j do + relate [ + {:place_refs, :RELATES, :incoming, :PlaceRef} + ] + end + + attributes do + attribute :id, :string do + description "the unique id of the place" + primary_key? true + allow_nil? false + public? true + source :key + end + + attribute :href, :string do + description "the href of the place" + allow_nil? true + public? true + end + + attribute :name, :string do + description "the name of the place" + allow_nil? true + public? true + constraints match: ~r/^[a-zA-Z0-9\s._-]+$/ + end + + attribute :type, :atom do + description "the type of the place" + allow_nil? false + public? true + default :PlaceRef + constraints one_of: [:PlaceRef, :GeographicSite, :GeographicLocation, :GeographicAddress] + end + + attribute :referred_type, :atom do + description "the referred type of the place" + allow_nil? true + public? true + constraints one_of: [:GeographicSite, :GeographicLocation, :GeographicAddress] + end + + create_timestamp :created_at + + update_timestamp :updated_at + end + + relationships do + has_many :place_refs, Diffo.Provider.PlaceRef do + description "the place refs relating this place to instances" + destination_attribute :place_id + public? true + end + end + + actions do + defaults [:read, :destroy] + + create :create do + description "creates a place" + accept [:id, :href, :name, :type, :referred_type] + upsert? true + end + + update :update do + description "updates the place" + accept [:href, :name, :type, :referred_type] + end + + read :list do + description "lists all places" + end + + read :find_by_id do + description "finds place by id" + get? false + + argument :query, :ci_string do + description "Return only places with id's including the given value." + end + + filter expr(contains(id, ^arg(:query))) + end + + read :find_by_name do + description "finds place by name" + get? false + + argument :query, :ci_string do + description "Return only places with names including the given value." + end + + filter expr(contains(name, ^arg(:query))) + end + end + + validations do + validate {Diffo.Validations.HrefEndsWithId, id: :id, href: :href} do + where [present(:id), present(:href)] + end + + validate attribute_equals(:type, :PlaceRef) do + where present(:referred_type) + message "when referred_type is present, type must be PlaceRef" + end + + validate attribute_does_not_equal(:type, :PlaceRef) do + where absent(:referred_type) + message "when referred_type is absent, type must be not be PlaceRef" + end + end + + preparations do + prepare build(sort: [id: :asc, name: :asc]) + end +end diff --git a/lib/diffo/provider/components/place.ex b/lib/diffo/provider/components/place.ex index 5210a37..d0b0234 100644 --- a/lib/diffo/provider/components/place.ex +++ b/lib/diffo/provider/components/place.ex @@ -6,23 +6,13 @@ defmodule Diffo.Provider.Place do @moduledoc """ Ash Resource for a TMF Place """ - use Ash.Resource, - otp_app: :diffo, - domain: Diffo.Provider, - data_layer: AshNeo4j.DataLayer, - extensions: [AshOutstanding.Resource, AshJason.Resource] + use Ash.Resource, fragments: [Diffo.Provider.BasePlace], domain: Diffo.Provider resource do description "An Ash Resource for a TMF Place" plural_name :places end - neo4j do - relate [ - {:place_refs, :RELATES, :incoming, :PlaceRef} - ] - end - jason do pick [:id, :href, :name, :referred_type, :type] compact true @@ -32,116 +22,4 @@ defmodule Diffo.Provider.Place do outstanding do expect [:id, :name, :referred_type, :type] end - - actions do - defaults [:read, :destroy] - - create :create do - description "creates a place" - accept [:id, :href, :name, :type, :referred_type] - upsert? true - end - - read :find_by_id do - description "finds place by id" - get? false - - argument :query, :ci_string do - description "Return only places with id's including the given value." - end - - filter expr(contains(id, ^arg(:query))) - end - - read :find_by_name do - description "finds place by name" - get? false - - argument :query, :ci_string do - description "Return only places with names including the given value." - end - - filter expr(contains(name, ^arg(:query))) - end - - read :list do - description "lists all places" - end - - update :update do - description "updates the place" - accept [:href, :name, :type, :referred_type] - end - end - - attributes do - attribute :id, :string do - description "the unique id of the place" - primary_key? true - allow_nil? false - public? true - source :key - end - - attribute :href, :string do - description "the href of the place" - allow_nil? true - public? true - end - - attribute :name, :string do - description "the name of the place" - allow_nil? true - public? true - constraints match: ~r/^[a-zA-Z0-9\s._-]+$/ - end - - attribute :type, :atom do - description "the type of the place" - allow_nil? false - public? true - default :PlaceRef - constraints one_of: [:PlaceRef, :GeographicSite, :GeographicLocation, :GeographicAddress] - end - - attribute :referred_type, :atom do - description "the type of the place" - allow_nil? true - public? true - constraints one_of: [:GeographicSite, :GeographicLocation, :GeographicAddress] - end - - create_timestamp :created_at - - update_timestamp :updated_at - end - - relationships do - has_many :place_refs, Diffo.Provider.PlaceRef do - description "the place refs relating this place to instances" - destination_attribute :place_id - public? true - end - end - - validations do - validate {Diffo.Validations.HrefEndsWithId, id: :id, href: :href} do - where [present(:id), present(:href)] - end - - validate attribute_equals(:type, :PlaceRef) do - where present(:referred_type) - message "when referred_type is present, type must be PlaceRef" - end - - validate attribute_does_not_equal(:type, :PlaceRef) do - where absent(:referred_type) - message "when referred_type is absent, type must be not be PlaceRef" - end - end - - preparations do - prepare build(sort: [id: :asc, name: :asc]) - end - end diff --git a/lib/diffo/provider/components/place/extension.ex b/lib/diffo/provider/components/place/extension.ex new file mode 100644 index 0000000..12235f8 --- /dev/null +++ b/lib/diffo/provider/components/place/extension.ex @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension do + @moduledoc """ + DSL Extension customising a Place. + + Provides compile-time declaration blocks for domain-specific Place kinds + built on `Diffo.Provider.BasePlace`. All declarations are introspectable via + `Diffo.Provider.Place.Extension.Info`. + + See the [DSL cheat sheet](DSL-Diffo.Provider.Place.Extension.html) for the full DSL reference. + See `Diffo.Provider.BasePlace` for full usage documentation. + """ + use Spark.Dsl.Extension, sections: [] +end diff --git a/lib/diffo/provider/components/place/extension/info.ex b/lib/diffo/provider/components/place/extension/info.ex new file mode 100644 index 0000000..01bf39c --- /dev/null +++ b/lib/diffo/provider/components/place/extension/info.ex @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.Info do + use Spark.InfoGenerator, + extension: Diffo.Provider.Place.Extension, + sections: [] + + @doc "Returns true if the module is a BasePlace-derived resource" + @spec place?(module()) :: boolean() + def place?(module) do + Code.ensure_loaded?(module) and + Diffo.Provider.Place.Extension in Ash.Resource.Info.extensions(module) + end +end From 30f3d092961bce2a9ba6b75ed65fcb91c919be47 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 29 Apr 2026 08:15:12 +0930 Subject: [PATCH 2/4] place extension --- .../provider/components/place/extension.ex | 96 ++++++++++++++++++- .../components/place/extension/info.ex | 2 +- .../place/extension/instance_role.ex | 14 +++ .../components/place/extension/party_role.ex | 14 +++ .../components/place/extension/place_role.ex | 14 +++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 lib/diffo/provider/components/place/extension/instance_role.ex create mode 100644 lib/diffo/provider/components/place/extension/party_role.ex create mode 100644 lib/diffo/provider/components/place/extension/place_role.ex diff --git a/lib/diffo/provider/components/place/extension.ex b/lib/diffo/provider/components/place/extension.ex index 12235f8..605b4ba 100644 --- a/lib/diffo/provider/components/place/extension.ex +++ b/lib/diffo/provider/components/place/extension.ex @@ -13,5 +13,99 @@ defmodule Diffo.Provider.Place.Extension do See the [DSL cheat sheet](DSL-Diffo.Provider.Place.Extension.html) for the full DSL reference. See `Diffo.Provider.BasePlace` for full usage documentation. """ - use Spark.Dsl.Extension, sections: [] + @instance_role %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Place kind plays with respect to Instances", + target: Diffo.Provider.Place.Extension.InstanceRole, + args: [:role, :instance_type], + schema: [ + role: [ + type: :atom, + required: true, + doc: "The role name, an atom" + ], + instance_type: [ + type: :any, + doc: "The module of the related Instance resource" + ] + ] + } + + @party_role %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Place kind plays with respect to Parties", + target: Diffo.Provider.Place.Extension.PartyRole, + args: [:role, :party_type], + schema: [ + role: [ + type: :atom, + required: true, + doc: "The role name, an atom" + ], + party_type: [ + type: :any, + doc: "The module of the related Party resource" + ] + ] + } + + @place_role %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Place kind plays with respect to other Places", + target: Diffo.Provider.Place.Extension.PlaceRole, + args: [:role, :place_type], + schema: [ + role: [ + type: :atom, + required: true, + doc: "The role name, an atom" + ], + place_type: [ + type: :any, + doc: "The module of the related Place resource" + ] + ] + } + + @instances %Spark.Dsl.Section{ + name: :instances, + describe: "Declares the roles this Place kind plays with respect to Instances", + examples: [ + """ + instances do + role :site_for, MyApp.AccessService + end + """ + ], + entities: [@instance_role] + } + + @parties %Spark.Dsl.Section{ + name: :parties, + describe: "Declares the roles this Place kind plays with respect to Parties", + examples: [ + """ + parties do + role :home_of, MyApp.Organization + end + """ + ], + entities: [@party_role] + } + + @places %Spark.Dsl.Section{ + name: :places, + describe: "Declares the roles this Place kind plays with respect to other Places", + examples: [ + """ + places do + role :within, MyApp.GeographicSite + end + """ + ], + entities: [@place_role] + } + + use Spark.Dsl.Extension, + sections: [@instances, @parties, @places] end diff --git a/lib/diffo/provider/components/place/extension/info.ex b/lib/diffo/provider/components/place/extension/info.ex index 01bf39c..4023595 100644 --- a/lib/diffo/provider/components/place/extension/info.ex +++ b/lib/diffo/provider/components/place/extension/info.ex @@ -5,7 +5,7 @@ defmodule Diffo.Provider.Place.Extension.Info do use Spark.InfoGenerator, extension: Diffo.Provider.Place.Extension, - sections: [] + sections: [:instances, :parties, :places] @doc "Returns true if the module is a BasePlace-derived resource" @spec place?(module()) :: boolean() diff --git a/lib/diffo/provider/components/place/extension/instance_role.ex b/lib/diffo/provider/components/place/extension/instance_role.ex new file mode 100644 index 0000000..b806e1e --- /dev/null +++ b/lib/diffo/provider/components/place/extension/instance_role.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.InstanceRole do + @moduledoc """ + InstanceRole - DSL entity declaring a role this Place kind plays with respect to Instances + """ + defstruct [:role, :instance_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/components/place/extension/party_role.ex b/lib/diffo/provider/components/place/extension/party_role.ex new file mode 100644 index 0000000..266d16c --- /dev/null +++ b/lib/diffo/provider/components/place/extension/party_role.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.PartyRole do + @moduledoc """ + PartyRole - DSL entity declaring a role this Place kind plays with respect to Parties + """ + defstruct [:role, :party_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/components/place/extension/place_role.ex b/lib/diffo/provider/components/place/extension/place_role.ex new file mode 100644 index 0000000..ffecfab --- /dev/null +++ b/lib/diffo/provider/components/place/extension/place_role.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.PlaceRole do + @moduledoc """ + PlaceRole - DSL entity declaring a role this Place kind plays with respect to other Places + """ + defstruct [:role, :place_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end From 115fbfdaab37f8476ada0cd09acc0d8e4b15151d Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 29 Apr 2026 08:44:49 +0930 Subject: [PATCH 3/4] places do in instance and party extensions --- .../DSL-Diffo.Provider.Instance.Extension.md | 100 +++++++++++++++++- .../DSL-Diffo.Provider.Party.Extension.md | 49 +++++++++ .../use_diffo_provider_extension.livemd | 100 ++++++++++++++---- .../provider/components/base_instance.ex | 15 +++ lib/diffo/provider/components/base_place.ex | 2 + .../provider/components/instance/extension.ex | 72 ++++++++++++- .../extension/persisters/persist_places.ex | 21 ++++ .../instance/extension/place_declaration.ex | 15 +++ .../transformers/transform_behaviour.ex | 4 + .../provider/components/instance/info.ex | 12 +++ .../provider/components/party/extension.ex | 33 +++++- .../components/party/extension/info.ex | 2 +- .../components/party/extension/place_role.ex | 14 +++ test/instance_extension/transformer_test.exs | 46 ++++++++ test/support/resource/shelf.ex | 5 + 15 files changed, 463 insertions(+), 27 deletions(-) create mode 100644 lib/diffo/provider/components/instance/extension/persisters/persist_places.ex create mode 100644 lib/diffo/provider/components/instance/extension/place_declaration.ex create mode 100644 lib/diffo/provider/components/party/extension/place_role.ex diff --git a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md index 6b5c7d3..2da6fc3 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md @@ -22,6 +22,8 @@ module at compile time via persisters and are introspectable at runtime via payload and an enabled/disabled default. - `parties do` — the party roles that instances of this kind relate to, with multiplicity, reference, and calculation options. +- `places do` — the place roles that instances of this kind relate to, mirroring `parties do` + in structure and options. ## behaviour @@ -39,7 +41,7 @@ See `Diffo.Provider.BaseInstance` for full usage documentation including generat ## structure -Defines the structural shape of the Instance — its specification, characteristics, features, and parties +Defines the structural shape of the Instance — its specification, characteristics, features, parties, and places ### Nested DSLs * [specification](#structure-specification) @@ -51,6 +53,9 @@ Defines the structural shape of the Instance — its specification, characterist * [parties](#structure-parties) * party * parties + * [places](#structure-places) + * place + * places ### Examples @@ -69,6 +74,10 @@ structure do parties do party :provider, MyApp.Provider end + + places do + place :installation_site, MyApp.GeographicSite + end end ``` @@ -326,6 +335,95 @@ Declares a plural party role on this Instance Target: `Diffo.Provider.Instance.Extension.PartyDeclaration` +### structure.places +List of Instance Place roles + +### Nested DSLs + * [place](#structure-places-place) + * [places](#structure-places-places) + + +### Examples +``` +places do + place :installation_site, MyApp.GeographicSite + places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] + place :billing_address, MyApp.GeographicAddress, reference: true +end + +``` + + + + +### structure.places.place +```elixir +place role, place_type +``` + + +Declares a singular place role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#structure-places-place-role){: #structure-places-place-role .spark-required} | `atom` | | The role name, an atom | +| [`place_type`](#structure-places-place-place_type){: #structure-places-place-place_type } | `any` | | The module of the Place kind. A BasePlace-derived resource. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`reference`](#structure-places-place-reference){: #structure-places-place-reference } | `boolean` | `false` | If true, no direct PlaceRef edge is created; the place is reachable by graph traversal. | +| [`calculate`](#structure-places-place-calculate){: #structure-places-place-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the place at build time. | + + + + + +### Introspection + +Target: `Diffo.Provider.Instance.Extension.PlaceDeclaration` + +### structure.places.places +```elixir +places role, place_type +``` + + +Declares a plural place role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#structure-places-places-role){: #structure-places-places-role .spark-required} | `atom` | | The role name, an atom | +| [`place_type`](#structure-places-places-place_type){: #structure-places-places-place_type } | `any` | | The module of the Place kind. A BasePlace-derived resource. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`reference`](#structure-places-places-reference){: #structure-places-places-reference } | `boolean` | `false` | If true, no direct PlaceRef edge is created; the place is reachable by graph traversal. | +| [`calculate`](#structure-places-places-calculate){: #structure-places-places-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the place at build time. | +| [`constraints`](#structure-places-places-constraints){: #structure-places-places-constraints } | `keyword` | | Multiplicity constraints on the number of places in this role, e.g. [min: 1, max: 3] | + + + + + +### Introspection + +Target: `Diffo.Provider.Instance.Extension.PlaceDeclaration` + + diff --git a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md index 92e0d82..643ce53 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md @@ -110,5 +110,54 @@ Target: `Diffo.Provider.Party.Extension.PartyRole` +## places +Declares the roles this Party kind plays with respect to Places + +### Nested DSLs + * [role](#places-role) + + +### Examples +``` +places do + role :headquartered_at, MyApp.GeographicSite +end + +``` + + + + +### places.role +```elixir +role role, place_type +``` + + +Declares a role this Party kind plays with respect to Places + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#places-role-role){: #places-role-role .spark-required} | `atom` | | The role name, an atom | +| [`place_type`](#places-role-place_type){: #places-role-place_type } | `any` | | The module of the related Place resource | + + + + + + +### Introspection + +Target: `Diffo.Provider.Party.Extension.PlaceRole` + + + + diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index 587fbfd..78f7394 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -35,6 +35,7 @@ In this 'Diffo Provider Instance Extension' livebook you will learn about: * Using the Assigner * Composing a Resource from partially assigned Resources * Declaring domain Parties using the Party Extension +* Declaring domain Places using the Place Extension ### Installing Neo4j and Configuring Bolty @@ -146,7 +147,7 @@ Diffo also has an inbuilt Spark DSL extension [Diffo.Provider.Instance.Extension The extension has two top-level sections: -**`structure do`** — describes the static shape of the Instance kind: its TMF Specification, Characteristics, Features, and Party roles. All declarations are baked into the module at compile time and introspectable at runtime via generated functions (`specification/0`, `characteristics/0`, `features/0`, `parties/0`) and `Diffo.Provider.Instance.Info`. +**`structure do`** — describes the static shape of the Instance kind: its TMF Specification, Characteristics, Features, Party roles, and Place roles. All declarations are baked into the module at compile time and introspectable at runtime via generated functions (`specification/0`, `characteristics/0`, `features/0`, `parties/0`, `places/0`) and `Diffo.Provider.Instance.Info`. **`behaviour do`** — declares which Ash actions should be wired for instance lifecycle management. Declaring `create :name` injects `:specified_by`, `:features`, and `:characteristics` arguments onto that action, and the `BuildBefore`/`BuildAfter` changes registered on `BaseInstance` automatically handle specification upsert, feature and characteristic creation, party validation, and graph relationship wiring for every create action. You write the action body for your domain-specific accepts and arguments; the structural wiring is handled for you. @@ -208,6 +209,10 @@ defmodule Diffo.Compute.Cluster do party :operator, Tenant party :manager, Engineer end + + places do + place :data_centre, Diffo.Compute.DataCentre + end end behaviour do @@ -507,6 +512,60 @@ defmodule Diffo.Compute.Engineer do end ``` +## Place Extension + +`Diffo.Provider.BasePlace` is an Ash Resource Fragment for domain-specific Place kinds, mirroring `BaseInstance` and `BaseParty`. It provides common Place attributes — `id`, `href`, `name`, `type`, `referred_type` — and the `Diffo.Provider.Place.Extension` DSL, which lets a Place kind declare the roles it plays with respect to Instances, Parties, and other Places. + +`type` defaults to `:PlaceRef` and is typically set in the `build` action to the concrete place type (`:GeographicSite`, `:GeographicLocation`, or `:GeographicAddress`). When `referred_type` is present, `type` must be `:PlaceRef` — meaning this Place is a reference rather than a physical location. + +The `Diffo.Provider.Place.Extension` DSL cheat sheet is at [DSL-Diffo.Provider.Place.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Place.Extension.html). + +### Defining Place kinds + +We'll add a `DataCentre` Place kind to our Compute domain. Clusters are hosted at a data centre; the `instances do` block records that relationship from the DataCentre's perspective. + +```elixir +defmodule Diffo.Compute.DataCentre do + @moduledoc """ + DataCentre in the Compute domain + """ + + alias Diffo.Provider.BasePlace + alias Diffo.Compute + + use Ash.Resource, + fragments: [BasePlace], + domain: Compute + + resource do + description "A Compute Data Centre" + plural_name :data_centres + end + + jason do + pick [:id, :href, :name, :type] + compact true + rename type: "@type" + end + + outstanding do + expect [:id, :name, :type] + end + + actions do + create :build do + accept [:id, :href, :name] + change set_attribute(:type, :GeographicSite) + end + end + + instances do + role :data_centre, Diffo.Compute.Cluster + role :data_centre, Diffo.Compute.GPU + end +end +``` + ### Compute Domain With all resources defined we can now declare the `Diffo.Compute` domain, which exposes a typed API for each resource: @@ -525,6 +584,7 @@ defmodule Diffo.Compute do alias Diffo.Compute.Cluster alias Diffo.Compute.Tenant alias Diffo.Compute.Engineer + alias Diffo.Compute.DataCentre resources do resource GPU do @@ -561,6 +621,11 @@ defmodule Diffo.Compute do define :get_engineer_by_id, action: :read, get_by: :id define :list_engineers, action: :read end + + resource DataCentre do + define :create_data_centre, action: :build + define :get_data_centre_by_id, action: :read, get_by: :id + end end end ``` @@ -591,27 +656,18 @@ alias Diffo.Provider.Instance.Party ### Creating a Cluster -We'll use a helper module to set up the data centre place: +First we create the data centre — our `DataCentre` resource uses `BasePlace`, so it is managed via the Compute domain API like any other domain resource: ```elixir -defmodule Diffo.Compute.Test do - alias Diffo.Provider - alias Diffo.Provider.Instance.Place - - def create_data_centre_place do - dc = - Provider.create_place!(%{ - id: "NXTM2", - name: :dataCentreId, - href: "place/compute/NXTM2", - referred_type: :GeographicSite - }) - - %Place{id: dc.id, role: :dataCentre} - end -end +alias Diffo.Provider.Instance.Place + +{:ok, dc} = Compute.create_data_centre(%{id: "NXTM2", name: "NextDC M2"}) +``` -places = [Diffo.Compute.Test.create_data_centre_place()] +Now build the cluster, passing the data centre as a place and our party members by id and role: + +```elixir +places = [%Place{id: dc.id, role: :data_centre}] parties = [ %Party{id: tenant.id, role: :operator}, %Party{id: engineer.id, role: :manager} @@ -682,10 +738,10 @@ What happens when I request a specific assignment from an instance to which the ### What Next? -In this tutorial you've used Diffo's Provider Instance Extension to define a Compute domain with a composite Cluster resource comprised of assigned GPU cores, and the Provider Party Extension to define Tenant and Engineer party kinds that operate and manage those resources. +In this tutorial you've used Diffo's Provider Instance Extension to define a Compute domain with a composite Cluster resource comprised of assigned GPU cores, the Provider Party Extension to define Tenant and Engineer party kinds that operate and manage those resources, and the Provider Place Extension to declare where instances and parties exist geographically. -`BaseParty` follows the same pattern as `BaseInstance` — domain-specific party resources use it as a fragment and write their own `build` action for domain-specific attributes. No manual wiring is needed. +`BaseParty` and `BasePlace` follow the same pattern as `BaseInstance` — domain-specific resources use them as fragments and write their own actions for domain-specific attributes. No manual wiring is needed. -A `BasePlace` extension for domain-specific Place kinds (such as a DataCentre with its own attributes) follows the same pattern and will be added in a future release. +Domain-specific Place kinds (such as a DataCentre with its own attributes) use `BasePlace` as a fragment and declare their roles via `instances do`, `parties do`, and `places do` sections on `Diffo.Provider.Place.Extension`. Party kinds similarly declare their place roles via `places do` on `Diffo.Provider.Party.Extension`. If you find Diffo useful please visit and star on [github](https://github.com/diffo-dev/diffo/). Feel free to join discussions and raise issues to discuss PR's. diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index d927cb4..c8468b3 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -41,6 +41,15 @@ defmodule Diffo.Provider.BaseInstance do - `reference: true` — no direct `PartyRef` edge; party is reachable by graph traversal - `calculate:` — names an Ash calculation on this resource that produces the party at build time + `places do` — declares the Place roles this Instance kind relates to. Mirrors `parties do` + in structure: + + places do + place :installation_site, MyApp.GeographicSite + places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] + place :billing_address, MyApp.GeographicAddress, reference: true + end + All declarations are introspectable at runtime via `Diffo.Provider.Instance.Info` and at compile time via `Diffo.Provider.Instance.Extension.Info`. @@ -62,10 +71,12 @@ defmodule Diffo.Provider.BaseInstance do - `characteristics/0` — list of `Characteristic` structs - `features/0` — list of `Feature` structs - `parties/0` — list of `PartyDeclaration` structs + - `places/0` — list of `PlaceDeclaration` structs - `characteristic/1` — returns the named `Characteristic` or `nil` - `feature/1` — returns the named `Feature` or `nil` - `feature_characteristic/2` — returns the named characteristic within a feature, or `nil` - `party/1` — returns the `PartyDeclaration` for the given role, or `nil` + - `place/1` — returns the `PlaceDeclaration` for the given role, or `nil` - `build_before/1` — called automatically before every create action; upserts the specification and creates features, characteristics, and parties, setting their ids as action arguments @@ -96,6 +107,10 @@ defmodule Diffo.Provider.BaseInstance do party :operator, MyApp.Organization parties :installer, MyApp.Engineer end + + places do + place :site, MyApp.GeographicSite + end end behaviour do diff --git a/lib/diffo/provider/components/base_place.ex b/lib/diffo/provider/components/base_place.ex index 0d93633..0e478ad 100644 --- a/lib/diffo/provider/components/base_place.ex +++ b/lib/diffo/provider/components/base_place.ex @@ -74,6 +74,8 @@ defmodule Diffo.Provider.BasePlace do relate [ {:place_refs, :RELATES, :incoming, :PlaceRef} ] + + label :Place end attributes do diff --git a/lib/diffo/provider/components/instance/extension.ex b/lib/diffo/provider/components/instance/extension.ex index 222acc1..1376116 100644 --- a/lib/diffo/provider/components/instance/extension.ex +++ b/lib/diffo/provider/components/instance/extension.ex @@ -23,6 +23,8 @@ defmodule Diffo.Provider.Instance.Extension do payload and an enabled/disabled default. - `parties do` — the party roles that instances of this kind relate to, with multiplicity, reference, and calculation options. + - `places do` — the place roles that instances of this kind relate to, mirroring `parties do` + in structure and options. ## behaviour @@ -222,9 +224,70 @@ defmodule Diffo.Provider.Instance.Extension do entities: [@party_entity, @parties_entity] } + @place_schema [ + role: [ + doc: "The role name, an atom", + type: :atom, + required: true + ], + place_type: [ + doc: "The module of the Place kind. A BasePlace-derived resource.", + type: :any + ], + reference: [ + doc: "If true, no direct PlaceRef edge is created; the place is reachable by graph traversal.", + type: :boolean, + default: false + ], + calculate: [ + doc: "Name of an Ash calculation on this resource that produces the place at build time.", + type: :atom + ] + ] + + @place_entity %Spark.Dsl.Entity{ + name: :place, + describe: "Declares a singular place role on this Instance", + target: Diffo.Provider.Instance.Extension.PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: false], + schema: @place_schema + } + + @places_entity %Spark.Dsl.Entity{ + name: :places, + describe: "Declares a plural place role on this Instance", + target: Diffo.Provider.Instance.Extension.PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: true], + schema: + @place_schema ++ + [ + constraints: [ + doc: "Multiplicity constraints on the number of places in this role, e.g. [min: 1, max: 3]", + type: :keyword_list + ] + ] + } + + @places %Spark.Dsl.Section{ + name: :places, + describe: "List of Instance Place roles", + examples: [ + """ + places do + place :installation_site, MyApp.GeographicSite + places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] + place :billing_address, MyApp.GeographicAddress, reference: true + end + """ + ], + entities: [@place_entity, @places_entity] + } + @structure %Spark.Dsl.Section{ name: :structure, - describe: "Defines the structural shape of the Instance — its specification, characteristics, features, and parties", + describe: "Defines the structural shape of the Instance — its specification, characteristics, features, parties, and places", examples: [ """ structure do @@ -241,10 +304,14 @@ defmodule Diffo.Provider.Instance.Extension do parties do party :provider, MyApp.Provider end + + places do + place :installation_site, MyApp.GeographicSite + end end """ ], - sections: [@specification, @characteristics, @features, @parties] + sections: [@specification, @characteristics, @features, @parties, @places] } # ── behaviour ────────────────────────────────────────────────────────────── @@ -314,6 +381,7 @@ defmodule Diffo.Provider.Instance.Extension do Diffo.Provider.Instance.Extension.Persisters.PersistCharacteristics, Diffo.Provider.Instance.Extension.Persisters.PersistFeatures, Diffo.Provider.Instance.Extension.Persisters.PersistParties, + Diffo.Provider.Instance.Extension.Persisters.PersistPlaces, Diffo.Provider.Instance.Extension.Transformers.TransformBehaviour ], verifiers: [ diff --git a/lib/diffo/provider/components/instance/extension/persisters/persist_places.ex b/lib/diffo/provider/components/instance/extension/persisters/persist_places.ex new file mode 100644 index 0000000..ec90dd6 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/persisters/persist_places.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.Persisters.PersistPlaces do + @moduledoc "Persists place declarations and bakes places/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:structure, :places]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :places, declarations) + + {:ok, Transformer.eval(dsl_state, [], quote do + @doc false + def places, do: unquote(escaped) + end)} + end +end diff --git a/lib/diffo/provider/components/instance/extension/place_declaration.ex b/lib/diffo/provider/components/instance/extension/place_declaration.ex new file mode 100644 index 0000000..1d53d72 --- /dev/null +++ b/lib/diffo/provider/components/instance/extension/place_declaration.ex @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Instance.Extension.PlaceDeclaration do + @moduledoc """ + PlaceDeclaration - DSL entity declaring a place role on an Instance + """ + defstruct [:role, :place_type, :multiple, :reference, :calculate, :constraints, + __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex index 5aea342..aac21c2 100644 --- a/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex +++ b/lib/diffo/provider/components/instance/extension/transformers/transform_behaviour.ex @@ -62,6 +62,9 @@ defmodule Diffo.Provider.Instance.Extension.Transformers.TransformBehaviour do @doc false def party(role), do: Enum.find(parties(), &(&1.role == role)) + + @doc false + def place(role), do: Enum.find(places(), &(&1.role == role)) end)} end @@ -100,5 +103,6 @@ defmodule Diffo.Provider.Instance.Extension.Transformers.TransformBehaviour do def after?(Diffo.Provider.Instance.Extension.Persisters.PersistCharacteristics), do: true def after?(Diffo.Provider.Instance.Extension.Persisters.PersistFeatures), do: true def after?(Diffo.Provider.Instance.Extension.Persisters.PersistParties), do: true + def after?(Diffo.Provider.Instance.Extension.Persisters.PersistPlaces), do: true def after?(_), do: false end diff --git a/lib/diffo/provider/components/instance/info.ex b/lib/diffo/provider/components/instance/info.ex index ae17ac9..31c9c45 100644 --- a/lib/diffo/provider/components/instance/info.ex +++ b/lib/diffo/provider/components/instance/info.ex @@ -57,4 +57,16 @@ defmodule Diffo.Provider.Instance.Info do def party(resource, role) do Enum.find(parties(resource), &(&1.role == role)) end + + @doc "Returns the list of place role declarations for the resource" + @spec places(Ash.Resource.t()) :: list() | [] + def places(resource) do + Extension.get_persisted(resource, :places, []) + end + + @doc "Returns the place declaration for the given role, or nil" + @spec place(Ash.Resource.t(), atom()) :: struct() | nil + def place(resource, role) do + Enum.find(places(resource), &(&1.role == role)) + end end diff --git a/lib/diffo/provider/components/party/extension.ex b/lib/diffo/provider/components/party/extension.ex index 865738f..3e1a58d 100644 --- a/lib/diffo/provider/components/party/extension.ex +++ b/lib/diffo/provider/components/party/extension.ex @@ -74,6 +74,37 @@ defmodule Diffo.Provider.Party.Extension do entities: [@party_role] } + @place_role %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party kind plays with respect to Places", + target: Diffo.Provider.Party.Extension.PlaceRole, + args: [:role, :place_type], + schema: [ + role: [ + type: :atom, + required: true, + doc: "The role name, an atom" + ], + place_type: [ + type: :any, + doc: "The module of the related Place resource" + ] + ] + } + + @places %Spark.Dsl.Section{ + name: :places, + describe: "Declares the roles this Party kind plays with respect to Places", + examples: [ + """ + places do + role :headquartered_at, MyApp.GeographicSite + end + """ + ], + entities: [@place_role] + } + use Spark.Dsl.Extension, - sections: [@instances, @parties] + sections: [@instances, @parties, @places] end diff --git a/lib/diffo/provider/components/party/extension/info.ex b/lib/diffo/provider/components/party/extension/info.ex index 8c29009..2ca0532 100644 --- a/lib/diffo/provider/components/party/extension/info.ex +++ b/lib/diffo/provider/components/party/extension/info.ex @@ -5,7 +5,7 @@ defmodule Diffo.Provider.Party.Extension.Info do use Spark.InfoGenerator, extension: Diffo.Provider.Party.Extension, - sections: [:instances, :parties] + sections: [:instances, :parties, :places] @doc "Returns true if the module is a BaseParty-derived resource" @spec party?(module()) :: boolean() diff --git a/lib/diffo/provider/components/party/extension/place_role.ex b/lib/diffo/provider/components/party/extension/place_role.ex new file mode 100644 index 0000000..8a2c0c7 --- /dev/null +++ b/lib/diffo/provider/components/party/extension/place_role.ex @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.PlaceRole do + @moduledoc """ + PlaceRole - DSL entity declaring a role this Party kind plays with respect to Places + """ + defstruct [:role, :place_type, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/test/instance_extension/transformer_test.exs b/test/instance_extension/transformer_test.exs index 2d9363a..a32ca0e 100644 --- a/test/instance_extension/transformer_test.exs +++ b/test/instance_extension/transformer_test.exs @@ -11,6 +11,7 @@ defmodule Diffo.InstanceExtension.TransformerTest do alias Diffo.Provider.Instance.Characteristic alias Diffo.Provider.Instance.Feature alias Diffo.Provider.Instance.Info + alias Diffo.Provider.Instance.Extension.PlaceDeclaration describe "PersistSpecification" do test "bakes specification/0 onto the resource" do @@ -162,6 +163,41 @@ defmodule Diffo.InstanceExtension.TransformerTest do end end + describe "PersistPlaces" do + test "bakes places/0 onto the resource" do + places = Shelf.places() + assert is_list(places) + assert length(places) == 2 + roles = Enum.map(places, & &1.role) + assert :installation_site in roles + assert :billing_address in roles + end + + test "each place is a PlaceDeclaration struct" do + [first | _] = Shelf.places() + assert is_struct(first, PlaceDeclaration) + end + + test "reference place has reference flag set" do + billing = Enum.find(Shelf.places(), &(&1.role == :billing_address)) + assert billing.reference == true + end + + test "places are also accessible via Info" do + assert length(Info.places(Shelf)) == 2 + assert Info.places(Card) == [] + end + + test "Info.place/2 returns the named place declaration by role" do + p = Info.place(Shelf, :installation_site) + assert p.role == :installation_site + end + + test "Info.place/2 returns nil for unknown role" do + assert Info.place(Shelf, :nonexistent) == nil + end + end + describe "TransformBehaviour" do setup do Code.ensure_loaded!(Shelf) @@ -241,5 +277,15 @@ defmodule Diffo.InstanceExtension.TransformerTest do test "party/1 returns nil for unknown role" do assert Shelf.party(:nonexistent) == nil end + + test "place/1 returns the named place declaration by role" do + p = Shelf.place(:installation_site) + assert p.role == :installation_site + assert p.multiple == false + end + + test "place/1 returns nil for unknown role" do + assert Shelf.place(:nonexistent) == nil + end end end diff --git a/test/support/resource/shelf.ex b/test/support/resource/shelf.ex index b9022b3..898f10e 100644 --- a/test/support/resource/shelf.ex +++ b/test/support/resource/shelf.ex @@ -59,6 +59,11 @@ defmodule Diffo.Test.Shelf do party :manager, Diffo.Test.Organization, calculate: :manager_calc parties :installer, Diffo.Test.Person, constraints: [min: 1, max: 3] end + + places do + place :installation_site, Diffo.Provider.Place + place :billing_address, Diffo.Provider.Place, reference: true + end end behaviour do From 2308991564ded525ceda023fef8229fc468f07e0 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Wed, 29 Apr 2026 10:04:12 +0930 Subject: [PATCH 4/4] =?UTF-8?q?Place=20DSL=20=E2=80=94=20BasePlace=20fragm?= =?UTF-8?q?ent,=20Place/Party/Instance=20Extension=20sections,=20persister?= =?UTF-8?q?s,=20verifiers,=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts BasePlace as a Spark.Dsl.Fragment following the BaseParty pattern. Adds instances/parties/places DSL sections to Place.Extension and Party.Extension, with persisters baking role declarations onto resources at compile time and a VerifyRoles verifier checking for duplicates and correct base types across all sections. Adds places do to Instance.Extension structure do, with PersistPlaces and place/1 generated via TransformBehaviour. Four test fixtures illustrate the simple and complex patterns for both Party and Place. Moduledocs across BaseInstance, BaseParty, and BasePlace now document the domain-specific attributes contract explicitly. Livebook updated with a Place Extension section and simplified cluster creation using the domain API. --- lib/diffo/provider/components/base_party.ex | 26 +++ lib/diffo/provider/components/base_place.ex | 26 +++ .../provider/components/party/extension.ex | 10 +- .../extension/persisters/persist_instances.ex | 21 ++ .../extension/persisters/persist_parties.ex | 21 ++ .../extension/persisters/persist_places.ex | 21 ++ .../party/extension/verifiers/verify_roles.ex | 79 +++++++ .../provider/components/place/extension.ex | 10 +- .../extension/persisters/persist_instances.ex | 21 ++ .../extension/persisters/persist_parties.ex | 21 ++ .../extension/persisters/persist_places.ex | 21 ++ .../place/extension/verifiers/verify_roles.ex | 79 +++++++ test/instance_extension/party_test.exs | 50 ++++- test/instance_extension/place_test.exs | 133 +++++++++++ test/party_extension/transformer_test.exs | 87 ++++++++ test/party_extension/verifier_test.exs | 207 ++++++++++++++++++ test/place_extension/transformer_test.exs | 65 ++++++ test/place_extension/verifier_test.exs | 207 ++++++++++++++++++ test/support/nbn.ex | 20 +- test/support/resource/carrier.ex | 62 ++++++ test/support/resource/exchange_building.ex | 64 ++++++ test/support/resource/geographic_site.ex | 52 +++++ test/support/resource/organization.ex | 8 + test/support/resource/person.ex | 8 + 24 files changed, 1310 insertions(+), 9 deletions(-) create mode 100644 lib/diffo/provider/components/party/extension/persisters/persist_instances.ex create mode 100644 lib/diffo/provider/components/party/extension/persisters/persist_parties.ex create mode 100644 lib/diffo/provider/components/party/extension/persisters/persist_places.ex create mode 100644 lib/diffo/provider/components/party/extension/verifiers/verify_roles.ex create mode 100644 lib/diffo/provider/components/place/extension/persisters/persist_instances.ex create mode 100644 lib/diffo/provider/components/place/extension/persisters/persist_parties.ex create mode 100644 lib/diffo/provider/components/place/extension/persisters/persist_places.ex create mode 100644 lib/diffo/provider/components/place/extension/verifiers/verify_roles.ex create mode 100644 test/instance_extension/place_test.exs create mode 100644 test/party_extension/transformer_test.exs create mode 100644 test/party_extension/verifier_test.exs create mode 100644 test/place_extension/transformer_test.exs create mode 100644 test/place_extension/verifier_test.exs create mode 100644 test/support/resource/carrier.ex create mode 100644 test/support/resource/exchange_building.ex create mode 100644 test/support/resource/geographic_site.ex diff --git a/lib/diffo/provider/components/base_party.ex b/lib/diffo/provider/components/base_party.ex index 3498f19..5840ecc 100644 --- a/lib/diffo/provider/components/base_party.ex +++ b/lib/diffo/provider/components/base_party.ex @@ -76,6 +76,32 @@ defmodule Diffo.Provider.BaseParty do end end + ## Domain-specific attributes + + Add Ash `attribute` declarations directly to your derived resource for any fields beyond the + base set. Those attributes can only be set via actions you declare on the derived resource — + the base `create` action provided by `BaseParty` only accepts the base fields (`id`, `href`, + `name`, `type`, `referred_type`). Use your domain API to call the derived resource's action: + + defmodule MyApp.Carrier do + use Ash.Resource, fragments: [BaseParty], domain: MyApp.Domain + + attributes do + attribute :abn, :string, public?: true + attribute :carrier_code, :string, public?: true + end + + actions do + create :build do + accept [:id, :href, :name, :abn, :carrier_code] + change set_attribute(:type, :Organization) + end + end + end + + # Use the domain API — Provider.create_party!/1 does not know about :abn + MyApp.Domain.create_carrier!(%{name: "Acme", abn: "51824753556", carrier_code: "ACM"}) + ## TMF type and referred_type The `type` and `referred_type` attributes map to the TMF `@type` and `@referredType` JSON diff --git a/lib/diffo/provider/components/base_place.ex b/lib/diffo/provider/components/base_place.ex index 0e478ad..f29005c 100644 --- a/lib/diffo/provider/components/base_place.ex +++ b/lib/diffo/provider/components/base_place.ex @@ -53,6 +53,32 @@ defmodule Diffo.Provider.BasePlace do end end + ## Domain-specific attributes + + Add Ash `attribute` declarations directly to your derived resource for any fields beyond the + base set. Those attributes can only be set via actions you declare on the derived resource — + the base `create` action provided by `BasePlace` only accepts the base fields (`id`, `href`, + `name`, `type`, `referred_type`). Use your domain API to call the derived resource's action: + + defmodule MyApp.DataCentre do + use Ash.Resource, fragments: [BasePlace], domain: MyApp.Domain + + attributes do + attribute :tier, :integer, public?: true + attribute :power_capacity_kw, :integer, public?: true + end + + actions do + create :build do + accept [:id, :href, :name, :tier, :power_capacity_kw] + change set_attribute(:type, :GeographicSite) + end + end + end + + # Use the domain API — Provider.create_place!/1 does not know about :tier + MyApp.Domain.create_data_centre!(%{name: "M2", tier: 3, power_capacity_kw: 40_000}) + ## TMF type and referred_type The `type` and `referred_type` attributes map to the TMF `@type` and `@referredType` JSON diff --git a/lib/diffo/provider/components/party/extension.ex b/lib/diffo/provider/components/party/extension.ex index 3e1a58d..a205402 100644 --- a/lib/diffo/provider/components/party/extension.ex +++ b/lib/diffo/provider/components/party/extension.ex @@ -106,5 +106,13 @@ defmodule Diffo.Provider.Party.Extension do } use Spark.Dsl.Extension, - sections: [@instances, @parties, @places] + sections: [@instances, @parties, @places], + persisters: [ + Diffo.Provider.Party.Extension.Persisters.PersistInstances, + Diffo.Provider.Party.Extension.Persisters.PersistParties, + Diffo.Provider.Party.Extension.Persisters.PersistPlaces + ], + verifiers: [ + Diffo.Provider.Party.Extension.Verifiers.VerifyRoles + ] end diff --git a/lib/diffo/provider/components/party/extension/persisters/persist_instances.ex b/lib/diffo/provider/components/party/extension/persisters/persist_instances.ex new file mode 100644 index 0000000..49823d1 --- /dev/null +++ b/lib/diffo/provider/components/party/extension/persisters/persist_instances.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.Persisters.PersistInstances do + @moduledoc "Persists instance role declarations and bakes instances/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:instances]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :instances, declarations) + + {:ok, Transformer.eval(dsl_state, [], quote do + @doc false + def instances, do: unquote(escaped) + end)} + end +end diff --git a/lib/diffo/provider/components/party/extension/persisters/persist_parties.ex b/lib/diffo/provider/components/party/extension/persisters/persist_parties.ex new file mode 100644 index 0000000..f6e6590 --- /dev/null +++ b/lib/diffo/provider/components/party/extension/persisters/persist_parties.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.Persisters.PersistParties do + @moduledoc "Persists party role declarations and bakes parties/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:parties]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :parties, declarations) + + {:ok, Transformer.eval(dsl_state, [], quote do + @doc false + def parties, do: unquote(escaped) + end)} + end +end diff --git a/lib/diffo/provider/components/party/extension/persisters/persist_places.ex b/lib/diffo/provider/components/party/extension/persisters/persist_places.ex new file mode 100644 index 0000000..453b1de --- /dev/null +++ b/lib/diffo/provider/components/party/extension/persisters/persist_places.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.Persisters.PersistPlaces do + @moduledoc "Persists place role declarations and bakes places/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:places]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :places, declarations) + + {:ok, Transformer.eval(dsl_state, [], quote do + @doc false + def places, do: unquote(escaped) + end)} + end +end diff --git a/lib/diffo/provider/components/party/extension/verifiers/verify_roles.ex b/lib/diffo/provider/components/party/extension/verifiers/verify_roles.ex new file mode 100644 index 0000000..d2bdba5 --- /dev/null +++ b/lib/diffo/provider/components/party/extension/verifiers/verify_roles.ex @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Party.Extension.Verifiers.VerifyRoles do + @moduledoc "Verifies role declarations across instances, parties, and places sections" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo + alias Diffo.Provider.Party.Extension.Info, as: PartyInfo + alias Diffo.Provider.Place.Extension.Info, as: PlaceInfo + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + + errors = + check_section(dsl_state, [:instances], :party_type, &InstanceInfo.instance?/1, + "instances", "instance_type", "BaseInstance", resource) ++ + check_section(dsl_state, [:parties], :party_type, &PartyInfo.party?/1, + "parties", "party_type", "BaseParty", resource) ++ + check_section(dsl_state, [:places], :place_type, &PlaceInfo.place?/1, + "places", "place_type", "BasePlace", resource) + + case errors do + [] -> :ok + _ -> {:error, errors} + end + end + + defp check_section(dsl_state, path, type_field, type_check?, section, field, base, resource) do + entities = Verifier.get_entities(dsl_state, path) + duplicate_errors(entities, section, resource) ++ + type_errors(entities, type_field, type_check?, section, field, base, resource) + end + + defp duplicate_errors(entities, section, resource) do + entities + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, list} -> length(list) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [String.to_atom(section)], + message: "#{section}: role #{inspect(role)} is declared more than once" + ) + end) + end + + defp type_errors(entities, type_field, type_check?, section, field, base, resource) do + Enum.reduce(entities, [], fn entity, acc -> + mod = Map.get(entity, type_field) + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [DslError.exception( + module: resource, + path: [String.to_atom(section)], + message: "#{section}: #{field} #{inspect(mod)} does not exist" + ) | acc] + + !type_check?.(mod) -> + [DslError.exception( + module: resource, + path: [String.to_atom(section)], + message: "#{section}: #{field} #{inspect(mod)} does not extend #{base}" + ) | acc] + + true -> + acc + end + end) + end +end diff --git a/lib/diffo/provider/components/place/extension.ex b/lib/diffo/provider/components/place/extension.ex index 605b4ba..74df77a 100644 --- a/lib/diffo/provider/components/place/extension.ex +++ b/lib/diffo/provider/components/place/extension.ex @@ -107,5 +107,13 @@ defmodule Diffo.Provider.Place.Extension do } use Spark.Dsl.Extension, - sections: [@instances, @parties, @places] + sections: [@instances, @parties, @places], + persisters: [ + Diffo.Provider.Place.Extension.Persisters.PersistInstances, + Diffo.Provider.Place.Extension.Persisters.PersistParties, + Diffo.Provider.Place.Extension.Persisters.PersistPlaces + ], + verifiers: [ + Diffo.Provider.Place.Extension.Verifiers.VerifyRoles + ] end diff --git a/lib/diffo/provider/components/place/extension/persisters/persist_instances.ex b/lib/diffo/provider/components/place/extension/persisters/persist_instances.ex new file mode 100644 index 0000000..d64d3f3 --- /dev/null +++ b/lib/diffo/provider/components/place/extension/persisters/persist_instances.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.Persisters.PersistInstances do + @moduledoc "Persists instance role declarations and bakes instances/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:instances]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :instances, declarations) + + {:ok, Transformer.eval(dsl_state, [], quote do + @doc false + def instances, do: unquote(escaped) + end)} + end +end diff --git a/lib/diffo/provider/components/place/extension/persisters/persist_parties.ex b/lib/diffo/provider/components/place/extension/persisters/persist_parties.ex new file mode 100644 index 0000000..6612423 --- /dev/null +++ b/lib/diffo/provider/components/place/extension/persisters/persist_parties.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.Persisters.PersistParties do + @moduledoc "Persists party role declarations and bakes parties/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:parties]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :parties, declarations) + + {:ok, Transformer.eval(dsl_state, [], quote do + @doc false + def parties, do: unquote(escaped) + end)} + end +end diff --git a/lib/diffo/provider/components/place/extension/persisters/persist_places.ex b/lib/diffo/provider/components/place/extension/persisters/persist_places.ex new file mode 100644 index 0000000..3fa789d --- /dev/null +++ b/lib/diffo/provider/components/place/extension/persisters/persist_places.ex @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.Persisters.PersistPlaces do + @moduledoc "Persists place role declarations and bakes places/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:places]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :places, declarations) + + {:ok, Transformer.eval(dsl_state, [], quote do + @doc false + def places, do: unquote(escaped) + end)} + end +end diff --git a/lib/diffo/provider/components/place/extension/verifiers/verify_roles.ex b/lib/diffo/provider/components/place/extension/verifiers/verify_roles.ex new file mode 100644 index 0000000..70991df --- /dev/null +++ b/lib/diffo/provider/components/place/extension/verifiers/verify_roles.ex @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Place.Extension.Verifiers.VerifyRoles do + @moduledoc "Verifies role declarations across instances, parties, and places sections" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo + alias Diffo.Provider.Party.Extension.Info, as: PartyInfo + alias Diffo.Provider.Place.Extension.Info, as: PlaceInfo + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + + errors = + check_section(dsl_state, [:instances], :instance_type, &InstanceInfo.instance?/1, + "instances", "instance_type", "BaseInstance", resource) ++ + check_section(dsl_state, [:parties], :party_type, &PartyInfo.party?/1, + "parties", "party_type", "BaseParty", resource) ++ + check_section(dsl_state, [:places], :place_type, &PlaceInfo.place?/1, + "places", "place_type", "BasePlace", resource) + + case errors do + [] -> :ok + _ -> {:error, errors} + end + end + + defp check_section(dsl_state, path, type_field, type_check?, section, field, base, resource) do + entities = Verifier.get_entities(dsl_state, path) + duplicate_errors(entities, section, resource) ++ + type_errors(entities, type_field, type_check?, section, field, base, resource) + end + + defp duplicate_errors(entities, section, resource) do + entities + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, list} -> length(list) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [String.to_atom(section)], + message: "#{section}: role #{inspect(role)} is declared more than once" + ) + end) + end + + defp type_errors(entities, type_field, type_check?, section, field, base, resource) do + Enum.reduce(entities, [], fn entity, acc -> + mod = Map.get(entity, type_field) + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [DslError.exception( + module: resource, + path: [String.to_atom(section)], + message: "#{section}: #{field} #{inspect(mod)} does not exist" + ) | acc] + + !type_check?.(mod) -> + [DslError.exception( + module: resource, + path: [String.to_atom(section)], + message: "#{section}: #{field} #{inspect(mod)} does not extend #{base}" + ) | acc] + + true -> + acc + end + end) + end +end diff --git a/test/instance_extension/party_test.exs b/test/instance_extension/party_test.exs index 04c1a71..96482dc 100644 --- a/test/instance_extension/party_test.exs +++ b/test/instance_extension/party_test.exs @@ -10,6 +10,7 @@ defmodule Diffo.InstanceExtension.PartyTest do alias Diffo.Provider.Party.Extension.Info, as: PartyInfo alias Diffo.Test.Organization alias Diffo.Test.Person + alias Diffo.Test.Carrier alias Diffo.Test.Shelf alias Diffo.Test.Nbn alias Diffo.Test.Servo @@ -33,8 +34,10 @@ defmodule Diffo.InstanceExtension.PartyTest do assert hd(roles).party_type == Diffo.Provider.Instance end - test "no party roles declared" do - assert PartyInfo.parties(Organization) == [] + test "party roles are declared" do + roles = PartyInfo.parties(Organization) + assert length(roles) == 1 + assert hd(roles).role == :employer end end @@ -46,8 +49,10 @@ defmodule Diffo.InstanceExtension.PartyTest do assert hd(roles).party_type == Diffo.Test.Person end - test "no instance roles declared" do - assert PartyInfo.instances(Person) == [] + test "instance roles are declared" do + roles = PartyInfo.instances(Person) + assert length(roles) == 1 + assert hd(roles).role == :overseer end end @@ -153,8 +158,8 @@ defmodule Diffo.InstanceExtension.PartyTest do end end - describe "BaseParty — Organization CRUD" do - test "create and read organization" do + describe "BaseParty — simple pattern (Organization)" do + test "create and read using only base attributes" do {:ok, org} = Nbn.create_organization(%{name: "Acme Corp"}) assert org.name == "Acme Corp" assert org.type == :Organization @@ -164,6 +169,39 @@ defmodule Diffo.InstanceExtension.PartyTest do end end + describe "BaseParty — complex pattern (Carrier)" do + test "domain-specific attributes are accepted and persisted" do + {:ok, carrier} = Nbn.create_carrier(%{ + name: "Acme Wholesale", + abn: "51824753556", + trading_name: "Acme" + }) + + assert carrier.name == "Acme Wholesale" + assert carrier.type == :Organization + assert carrier.abn == "51824753556" + assert carrier.trading_name == "Acme" + end + + test "domain-specific attributes are readable after creation" do + {:ok, carrier} = Nbn.create_carrier(%{ + name: "Acme Wholesale", + abn: "51824753556", + trading_name: "Acme" + }) + + {:ok, loaded} = Nbn.get_carrier_by_id(carrier.id) + assert loaded.abn == "51824753556" + assert loaded.trading_name == "Acme" + end + + test "domain-specific attributes are nil when not provided" do + {:ok, carrier} = Nbn.create_carrier(%{name: "Bare Carrier"}) + assert carrier.abn == nil + assert carrier.trading_name == nil + end + end + describe "BaseParty — Person CRUD" do test "create and read person" do {:ok, person} = Nbn.create_person(%{name: "Alice"}) diff --git a/test/instance_extension/place_test.exs b/test/instance_extension/place_test.exs new file mode 100644 index 0000000..b6f7fe7 --- /dev/null +++ b/test/instance_extension/place_test.exs @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.InstanceExtension.PlaceTest do + @moduledoc false + use ExUnit.Case + + alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo + alias Diffo.Provider.Place.Extension.Info, as: PlaceInfo + alias Diffo.Test.Organization + alias Diffo.Test.GeographicSite + alias Diffo.Test.ExchangeBuilding + alias Diffo.Test.Shelf + alias Diffo.Test.Nbn + + setup_all do + AshNeo4j.BoltyHelper.start() + end + + setup do + on_exit(fn -> + AshNeo4j.Neo4jHelper.delete_all() + end) + end + + describe "Place DSL — GeographicSite" do + test "instance roles are declared" do + roles = PlaceInfo.instances(GeographicSite) + assert length(roles) == 1 + assert hd(roles).role == :installed_at + assert hd(roles).instance_type == Diffo.Provider.Instance + end + + test "party roles are declared" do + roles = PlaceInfo.parties(GeographicSite) + assert length(roles) == 1 + assert hd(roles).role == :managed_by + assert hd(roles).party_type == Organization + end + + test "place roles are declared" do + roles = PlaceInfo.places(GeographicSite) + assert length(roles) == 1 + assert hd(roles).role == :contained_in + assert hd(roles).place_type == Diffo.Provider.Place + end + end + + describe "Instance DSL — Shelf places" do + test "place declarations are accessible via info" do + places = InstanceInfo.structure_places(Shelf) + roles = Enum.map(places, & &1.role) + assert :installation_site in roles + assert :billing_address in roles + end + + test "place types are correct" do + places = InstanceInfo.structure_places(Shelf) + installation_site = Enum.find(places, &(&1.role == :installation_site)) + assert installation_site.place_type == Diffo.Provider.Place + end + + test "singular place defaults to multiple: false" do + places = InstanceInfo.structure_places(Shelf) + installation_site = Enum.find(places, &(&1.role == :installation_site)) + assert installation_site.multiple == false + end + + test "reference: true is declared" do + places = InstanceInfo.structure_places(Shelf) + billing = Enum.find(places, &(&1.role == :billing_address)) + assert billing.reference == true + assert billing.multiple == false + end + + test "reference defaults to false" do + places = InstanceInfo.structure_places(Shelf) + installation_site = Enum.find(places, &(&1.role == :installation_site)) + assert installation_site.reference == false + end + end + + describe "BasePlace — simple pattern (GeographicSite)" do + test "create and read using only base attributes" do + {:ok, site} = Nbn.create_geographic_site(%{id: "SITE-01", name: "Data Centre 1"}) + assert site.name == "Data Centre 1" + assert site.type == :GeographicSite + + {:ok, loaded} = Nbn.get_geographic_site_by_id("SITE-01") + assert loaded.name == "Data Centre 1" + end + end + + describe "BasePlace — complex pattern (ExchangeBuilding)" do + test "domain-specific attributes are accepted and persisted" do + {:ok, building} = Nbn.create_exchange_building(%{ + id: "EX-MEL-001", + name: "Melbourne Central Exchange", + nli: "MEXMELB0001", + access_type: :unmanned + }) + + assert building.name == "Melbourne Central Exchange" + assert building.type == :GeographicSite + assert building.nli == "MEXMELB0001" + assert building.access_type == :unmanned + end + + test "domain-specific attributes are readable after creation" do + {:ok, building} = Nbn.create_exchange_building(%{ + id: "EX-MEL-002", + name: "South Yarra Exchange", + nli: "MEXMELB0002", + access_type: :attended + }) + + {:ok, loaded} = Nbn.get_exchange_building_by_id("EX-MEL-002") + assert loaded.nli == "MEXMELB0002" + assert loaded.access_type == :attended + end + + test "domain-specific attributes are nil when not provided" do + {:ok, building} = Nbn.create_exchange_building(%{ + id: "EX-MEL-003", + name: "Bare Exchange" + }) + + assert building.nli == nil + assert building.access_type == nil + end + end +end diff --git a/test/party_extension/transformer_test.exs b/test/party_extension/transformer_test.exs new file mode 100644 index 0000000..e3ed62b --- /dev/null +++ b/test/party_extension/transformer_test.exs @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.PartyExtension.TransformerTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Diffo.Test.Organization + alias Diffo.Test.Person + alias Diffo.Provider.Party.Extension.InstanceRole + alias Diffo.Provider.Party.Extension.PartyRole + alias Diffo.Provider.Party.Extension.PlaceRole + alias Diffo.Provider.Party.Extension.Info + + describe "PersistInstances" do + test "bakes instances/0 onto the resource" do + roles = Organization.instances() + assert is_list(roles) + assert length(roles) == 1 + assert hd(roles).role == :facilitator + end + + test "each instance role is an InstanceRole struct" do + assert is_struct(hd(Organization.instances()), InstanceRole) + end + + test "instances are also accessible via Info" do + assert length(Info.instances(Organization)) == 1 + assert length(Info.instances(Person)) == 1 + end + + test "Person instances/0 bakes correctly" do + roles = Person.instances() + assert length(roles) == 1 + assert hd(roles).role == :overseer + end + end + + describe "PersistParties" do + test "bakes parties/0 onto the resource" do + roles = Organization.parties() + assert is_list(roles) + assert length(roles) == 1 + assert hd(roles).role == :employer + end + + test "each party role is a PartyRole struct" do + assert is_struct(hd(Organization.parties()), PartyRole) + end + + test "parties are also accessible via Info" do + assert length(Info.parties(Organization)) == 1 + assert length(Info.parties(Person)) == 1 + end + + test "Person parties/0 bakes correctly" do + roles = Person.parties() + assert length(roles) == 1 + assert hd(roles).role == :manager + end + end + + describe "PersistPlaces" do + test "bakes places/0 onto the resource" do + roles = Organization.places() + assert is_list(roles) + assert length(roles) == 1 + assert hd(roles).role == :headquarters + end + + test "each place role is a PlaceRole struct" do + assert is_struct(hd(Organization.places()), PlaceRole) + end + + test "places are also accessible via Info" do + assert length(Info.places(Organization)) == 1 + assert length(Info.places(Person)) == 1 + end + + test "Person places/0 bakes correctly" do + roles = Person.places() + assert length(roles) == 1 + assert hd(roles).role == :residence + end + end +end diff --git a/test/party_extension/verifier_test.exs b/test/party_extension/verifier_test.exs new file mode 100644 index 0000000..0e4f65e --- /dev/null +++ b/test/party_extension/verifier_test.exs @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.PartyExtension.VerifierTest do + @moduledoc false + use ExUnit.Case, async: false + alias Diffo.Test.Util + + describe "instances verifier" do + test "duplicate instance role warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: role :operator is declared more than once", + fn -> + defmodule DuplicateInstanceRole do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with duplicate instance role" + end + + instances do + role :operator, Diffo.Provider.Instance + role :operator, Diffo.Provider.Instance + end + end + end + ) + end + + test "non-existent instance_type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type NonExistent.InstanceModule does not exist", + fn -> + defmodule InvalidInstanceType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with non-existent instance type" + end + + instances do + role :operator, NonExistent.InstanceModule + end + end + end + ) + end + + test "instance_type not extending BaseInstance warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type Diffo.Test.Organization does not extend BaseInstance", + fn -> + defmodule WrongInstanceType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with party as instance type" + end + + instances do + role :operator, Diffo.Test.Organization + end + end + end + ) + end + end + + describe "parties verifier" do + test "duplicate party role warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: role :employer is declared more than once", + fn -> + defmodule DuplicatePartyRole do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with duplicate party role" + end + + parties do + role :employer, Diffo.Test.Organization + role :employer, Diffo.Test.Organization + end + end + end + ) + end + + test "non-existent party_type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type NonExistent.PartyModule does not exist", + fn -> + defmodule InvalidPartyRoleType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with non-existent party type" + end + + parties do + role :employer, NonExistent.PartyModule + end + end + end + ) + end + + test "party_type not extending BaseParty warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type Diffo.Provider.Instance does not extend BaseParty", + fn -> + defmodule WrongPartyRoleType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with instance as party type" + end + + parties do + role :employer, Diffo.Provider.Instance + end + end + end + ) + end + end + + describe "places verifier" do + test "duplicate place role warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: role :headquarters is declared more than once", + fn -> + defmodule DuplicatePlaceRole do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with duplicate place role" + end + + places do + role :headquarters, Diffo.Provider.Place + role :headquarters, Diffo.Provider.Place + end + end + end + ) + end + + test "non-existent place_type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type NonExistent.PlaceModule does not exist", + fn -> + defmodule InvalidPlaceRoleType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with non-existent place type" + end + + places do + role :headquarters, NonExistent.PlaceModule + end + end + end + ) + end + + test "place_type not extending BasePlace warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type Diffo.Test.Organization does not extend BasePlace", + fn -> + defmodule WrongPlaceRoleType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "resource with party as place type" + end + + places do + role :headquarters, Diffo.Test.Organization + end + end + end + ) + end + end +end diff --git a/test/place_extension/transformer_test.exs b/test/place_extension/transformer_test.exs new file mode 100644 index 0000000..0b5cd76 --- /dev/null +++ b/test/place_extension/transformer_test.exs @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.PlaceExtension.TransformerTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Diffo.Test.GeographicSite + alias Diffo.Provider.Place.Extension.InstanceRole + alias Diffo.Provider.Place.Extension.PartyRole + alias Diffo.Provider.Place.Extension.PlaceRole + alias Diffo.Provider.Place.Extension.Info + + describe "PersistInstances" do + test "bakes instances/0 onto the resource" do + roles = GeographicSite.instances() + assert is_list(roles) + assert length(roles) == 1 + assert hd(roles).role == :installed_at + end + + test "each instance role is an InstanceRole struct" do + assert is_struct(hd(GeographicSite.instances()), InstanceRole) + end + + test "instances are also accessible via Info" do + assert length(Info.instances(GeographicSite)) == 1 + end + end + + describe "PersistParties" do + test "bakes parties/0 onto the resource" do + roles = GeographicSite.parties() + assert is_list(roles) + assert length(roles) == 1 + assert hd(roles).role == :managed_by + end + + test "each party role is a PartyRole struct" do + assert is_struct(hd(GeographicSite.parties()), PartyRole) + end + + test "parties are also accessible via Info" do + assert length(Info.parties(GeographicSite)) == 1 + end + end + + describe "PersistPlaces" do + test "bakes places/0 onto the resource" do + roles = GeographicSite.places() + assert is_list(roles) + assert length(roles) == 1 + assert hd(roles).role == :contained_in + end + + test "each place role is a PlaceRole struct" do + assert is_struct(hd(GeographicSite.places()), PlaceRole) + end + + test "places are also accessible via Info" do + assert length(Info.places(GeographicSite)) == 1 + end + end +end diff --git a/test/place_extension/verifier_test.exs b/test/place_extension/verifier_test.exs new file mode 100644 index 0000000..f9b5b2f --- /dev/null +++ b/test/place_extension/verifier_test.exs @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.PlaceExtension.VerifierTest do + @moduledoc false + use ExUnit.Case, async: false + alias Diffo.Test.Util + + describe "instances verifier" do + test "duplicate instance role warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: role :site_for is declared more than once", + fn -> + defmodule DuplicatePlaceInstanceRole do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with duplicate instance role" + end + + instances do + role :site_for, Diffo.Provider.Instance + role :site_for, Diffo.Provider.Instance + end + end + end + ) + end + + test "non-existent instance_type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type NonExistent.InstanceModule does not exist", + fn -> + defmodule InvalidPlaceInstanceType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with non-existent instance type" + end + + instances do + role :site_for, NonExistent.InstanceModule + end + end + end + ) + end + + test "instance_type not extending BaseInstance warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type Diffo.Test.Organization does not extend BaseInstance", + fn -> + defmodule WrongPlaceInstanceType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with party as instance type" + end + + instances do + role :site_for, Diffo.Test.Organization + end + end + end + ) + end + end + + describe "parties verifier" do + test "duplicate party role warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: role :managed_by is declared more than once", + fn -> + defmodule DuplicatePlacePartyRole do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with duplicate party role" + end + + parties do + role :managed_by, Diffo.Test.Organization + role :managed_by, Diffo.Test.Organization + end + end + end + ) + end + + test "non-existent party_type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type NonExistent.PartyModule does not exist", + fn -> + defmodule InvalidPlacePartyType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with non-existent party type" + end + + parties do + role :managed_by, NonExistent.PartyModule + end + end + end + ) + end + + test "party_type not extending BaseParty warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type Diffo.Provider.Instance does not extend BaseParty", + fn -> + defmodule WrongPlacePartyType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with instance as party type" + end + + parties do + role :managed_by, Diffo.Provider.Instance + end + end + end + ) + end + end + + describe "places verifier" do + test "duplicate place role warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: role :contained_in is declared more than once", + fn -> + defmodule DuplicatePlacePlaceRole do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with duplicate place role" + end + + places do + role :contained_in, Diffo.Provider.Place + role :contained_in, Diffo.Provider.Place + end + end + end + ) + end + + test "non-existent place_type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type NonExistent.PlaceModule does not exist", + fn -> + defmodule InvalidPlacePlaceType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with non-existent place type" + end + + places do + role :contained_in, NonExistent.PlaceModule + end + end + end + ) + end + + test "place_type not extending BasePlace warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type Diffo.Test.Organization does not extend BasePlace", + fn -> + defmodule WrongPlacePlaceType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with party as place type" + end + + places do + role :contained_in, Diffo.Test.Organization + end + end + end + ) + end + end +end diff --git a/test/support/nbn.ex b/test/support/nbn.ex index c55292d..0c99bdc 100644 --- a/test/support/nbn.ex +++ b/test/support/nbn.ex @@ -14,9 +14,12 @@ defmodule Diffo.Test.Nbn do alias Diffo.Test.Organization alias Diffo.Test.Person + alias Diffo.Test.Carrier + alias Diffo.Test.GeographicSite + alias Diffo.Test.ExchangeBuilding domain do - description "NBN party domain" + description "NBN party and place domain" end resources do @@ -31,5 +34,20 @@ defmodule Diffo.Test.Nbn do define :get_person_by_id, action: :read, get_by: :id define :list_persons, action: :list end + + resource Carrier do + define :create_carrier, action: :build + define :get_carrier_by_id, action: :read, get_by: :id + end + + resource GeographicSite do + define :create_geographic_site, action: :build + define :get_geographic_site_by_id, action: :read, get_by: :id + end + + resource ExchangeBuilding do + define :create_exchange_building, action: :build + define :get_exchange_building_by_id, action: :read, get_by: :id + end end end diff --git a/test/support/resource/carrier.ex b/test/support/resource/carrier.ex new file mode 100644 index 0000000..1f8c31d --- /dev/null +++ b/test/support/resource/carrier.ex @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Carrier do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + Carrier - a telecommunications carrier with domain-specific attributes, + demonstrating the complex BaseParty pattern. + """ + + alias Diffo.Provider.BaseParty + alias Diffo.Test.Nbn + + use Ash.Resource, + fragments: [BaseParty], + domain: Nbn + + resource do + description "A Telecommunications Carrier" + plural_name :carriers + end + + attributes do + attribute :abn, :string do + description "Australian Business Number" + allow_nil? true + public? true + end + + attribute :trading_name, :string do + description "Trading name, distinct from legal name" + allow_nil? true + public? true + end + end + + jason do + pick [:id, :name, :type, :abn, :trading_name] + compact true + end + + outstanding do + expect [:id, :name, :type] + end + + actions do + create :build do + accept [:id, :href, :name, :abn, :trading_name] + change set_attribute(:type, :Organization) + end + end + + instances do + role :provider, Diffo.Provider.Instance + end + + places do + role :exchange, Diffo.Provider.Place + end +end diff --git a/test/support/resource/exchange_building.ex b/test/support/resource/exchange_building.ex new file mode 100644 index 0000000..d6c3edc --- /dev/null +++ b/test/support/resource/exchange_building.ex @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.ExchangeBuilding do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + ExchangeBuilding - an NBN exchange building with domain-specific attributes, + demonstrating the complex BasePlace pattern. + """ + + alias Diffo.Provider.BasePlace + alias Diffo.Test.Nbn + + use Ash.Resource, + fragments: [BasePlace], + domain: Nbn + + resource do + description "An NBN Exchange Building" + plural_name :exchange_buildings + end + + attributes do + attribute :nli, :string do + description "Network Location Identifier" + allow_nil? true + public? true + end + + attribute :access_type, :atom do + description "Access type for the exchange building" + allow_nil? true + public? true + constraints one_of: [:attended, :unmanned, :restricted] + end + end + + jason do + pick [:id, :href, :name, :type, :nli, :access_type] + compact true + rename type: "@type" + end + + outstanding do + expect [:id, :name, :type] + end + + actions do + create :build do + accept [:id, :href, :name, :nli, :access_type] + change set_attribute(:type, :GeographicSite) + end + end + + parties do + role :operator, Diffo.Test.Carrier + end + + instances do + role :host, Diffo.Provider.Instance + end +end diff --git a/test/support/resource/geographic_site.ex b/test/support/resource/geographic_site.ex new file mode 100644 index 0000000..81ce42c --- /dev/null +++ b/test/support/resource/geographic_site.ex @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.GeographicSite do + @moduledoc """ + Diffo - TMF Service and Resource Management with a difference + + GeographicSite - test fixture for Place Extension DSL + """ + + alias Diffo.Provider.BasePlace + alias Diffo.Test.Nbn + + use Ash.Resource, + fragments: [BasePlace], + domain: Nbn + + resource do + description "A Geographic Site" + plural_name :geographic_sites + end + + jason do + pick [:id, :href, :name, :type] + compact true + rename type: "@type" + end + + outstanding do + expect [:id, :name, :type] + end + + actions do + create :build do + accept [:id, :href, :name] + change set_attribute(:type, :GeographicSite) + end + end + + instances do + role :installed_at, Diffo.Provider.Instance + end + + parties do + role :managed_by, Diffo.Test.Organization + end + + places do + role :contained_in, Diffo.Provider.Place + end +end diff --git a/test/support/resource/organization.ex b/test/support/resource/organization.ex index f50930c..0a7c7bc 100644 --- a/test/support/resource/organization.ex +++ b/test/support/resource/organization.ex @@ -40,4 +40,12 @@ defmodule Diffo.Test.Organization do instances do role :facilitator, Diffo.Provider.Instance end + + parties do + role :employer, Diffo.Test.Person + end + + places do + role :headquarters, Diffo.Provider.Place + end end diff --git a/test/support/resource/person.ex b/test/support/resource/person.ex index 55aeb0b..e260004 100644 --- a/test/support/resource/person.ex +++ b/test/support/resource/person.ex @@ -37,7 +37,15 @@ defmodule Diffo.Test.Person do end end + instances do + role :overseer, Diffo.Provider.Instance + end + parties do role :manager, Diffo.Test.Person end + + places do + role :residence, Diffo.Provider.Place + end end