diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..25ffd94 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,188 @@ + + +# AGENTS.md — Diffo + +AI agent guidance for the Diffo source repository. + +## What this project is + +Diffo is a Telecommunications Management Forum (TMF) Service and Resource Manager, built +on [Ash Framework](https://www.ash-hq.org/) + [AshNeo4j](https://github.com/diffo-dev/ash_neo4j) + [Neo4j](https://github.com/neo4j/neo4j). It models TMF 638/639 Service and Resource inventory and provides a Spark DSL for defining domain-specific instance, party, and place kinds. + +## Before making changes + +1. Read `usage-rules.md` — Diffo-specific DSL rules. +2. Read `CLAUDE.md` — dependency usage rules (Ash, Elixir, OTP, AshNeo4j, Spark). +3. Consult the skill at `.claude/skills/diffo-framework/` for Ash ecosystem patterns. + +## Project structure + +``` +lib/diffo/provider/ + extension.ex # Unified Spark DSL extension (provider do) + extension/ + info.ex # Runtime introspection via Spark.InfoGenerator + characteristic.ex # Characteristic build helpers + feature.ex # Feature build helpers + pool.ex # Pool struct + create_pools/2 + update_pools/3 + instance_role.ex # InstanceRole struct + party_declaration.ex # PartyDeclaration struct + place_declaration.ex # PlaceDeclaration struct + party_role.ex # PartyRole struct (Party/Place kinds) + place_role.ex # PlaceRole struct (Party/Place kinds) + persisters/ # Spark transformers — bake DSL state into module + transformers/ # TransformBehaviour — action argument injection + verifiers/ # Compile-time DSL correctness checks + assigner/ + assigner.ex # Diffo.Provider.Assigner — assign/3 (pools do) and assign/4 + assignable_characteristic.ex # AssignableCharacteristic — pool bounds + algorithm + assigned_to_relationship.ex # AssignedToRelationship — assignedTo edges (pool/thing/assigned) + base_instance.ex # Ash Fragment for Instance resources + base_party.ex # Ash Fragment for Party resources + base_place.ex # Ash Fragment for Place resources + components/ + base_characteristic.ex # Ash Fragment for typed characteristic resources + base_relationship.ex # Ash Fragment for shared Relationship structure + calculations/ + characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields + assigned_values.ex # Calculation: returns list of assigned integers for a pool+thing + instance/extension.ex # Thin marker (sections: []) — kind identification + party/extension.ex # Thin marker + place/extension.ex # Thin marker + +test/provider/extension/ # All provider extension tests + instance_transformer_test.exs + party_transformer_test.exs + place_transformer_test.exs + instance_verifier_test.exs + party_verifier_test.exs + place_verifier_test.exs + party_test.exs # Integration: parties enforcement + place_test.exs # Integration: places enforcement + specification_test.exs # Integration: spec roundtrip + characteristic_test.exs # Integration: characteristic creation + feature_test.exs # Integration: feature creation + assigner_test.exs # Integration: resource assignment + +test/support/ + resources/ # Test domain resources (Shelf, Card, Organization, etc.) + domains/ # Test domains (Servo, Nbn) +``` + +## The unified `provider do` DSL + +All DSL declarations use a single `provider do` section — there is no `structure do`, +top-level `behaviour do`, or bare `instances/parties/places do`. + +### Instance resources (`BaseInstance`) + +```elixir +provider do + specification do + id "da9b207a-..." # stable UUID4 — never change after first commit + name "myService" # camelCase + type :serviceSpecification + major_version 1 + description "..." + category "..." + end + + characteristics do + characteristic :slot_value, MyApp.SlotCharacteristic + characteristic :ports, {:array, MyApp.PortCharacteristic} + end + + pools do + pool :cores, :core # assignable pool; thing name is :core + pool :vlans, :vlan + end + + features do + feature :advanced_routing, is_enabled?: false do + characteristic :policy, MyApp.RoutingPolicy + end + end + + parties do + party :provider, MyApp.RSP # singular, direct edge + parties :engineers, MyApp.Engineer, constraints: [min: 1, max: 5] + party_ref :owner, MyApp.Organization # no direct edge + party :operator, MyApp.RSP, calculate: :derive_operator + end + + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + behaviour do + actions do + create :build # injects :specified_by, :features, :characteristics + end + end +end +``` + +### Party and Place resources (`BaseParty` / `BasePlace`) + +```elixir +provider do + instances do + role :provider, MyApp.BroadbandService + instance_ref :manages, MyApp.InternalService # no direct edge + end + parties do + role :employer, MyApp.Organization + end + places do + role :headquarters, MyApp.GeographicSite + end +end +``` + +## Running tests + +Integration tests require a running Neo4j instance. + +```sh +mix test # full suite +mix test test/provider/extension/ # extension tests only +mix test path/to/test.exs:LINE # single test +mix test --max-failures 5 # stop early +``` + +## Module naming and Neo4j labels + +AshNeo4j derives a node label from the **last segment** of the module name. Two resources +whose names end in the same word get the same label, which causes read collisions. + +**Rule:** suffix every resource module with its kind so the last segment is unique: +- Instance resources: `MyApp.Instance.WidgetInstance` (not `MyApp.Instance.Widget`) +- Characteristic resources: `MyApp.Characteristic.WidgetCharacteristic` (not `MyApp.Characteristic.Widget`) +- Party/Place resources: follow the same convention if ambiguity is possible. + +E.g. `Diffo.Test.Instance.CardInstance` → label `:CardInstance`, +and `Diffo.Test.Characteristic.CardCharacteristic` → label `:CardCharacteristic` — no collision. + +## Common agent mistakes + +- Using old `structure do` / top-level `instances do` — use `provider do` only. +- Using `party :role, Type, reference: true` — use `party_ref :role, Type`. +- Using a plain `Ash.TypedStruct` as a `characteristic` DSL target — use a `BaseCharacteristic`-derived resource instead; the TypedStruct belongs in `.Value`. +- Using `characteristic :name, Diffo.Provider.AssignableCharacteristic` for pools — use `pools do / pool :name, :thing / end` instead. +- Using the removed `AssignableValue` TypedStruct — it no longer exists; use `pools do`. +- Calling `Assigner.assign/4` when a `pools do` declaration exists — prefer `Assigner.assign/3` which looks up the thing automatically. +- Forgetting to call `Pool.update_pools/3` in `:define` actions when the resource has `pools do` — pool bounds (`first`, `last`, `algorithm`) are set here. +- Using `characteristic :pool_name, Diffo.Provider.AssignedToRelationship` — `AssignedToRelationship` is not a characteristic; use `pools do / pool :name, :thing / end` instead. +- Querying `Diffo.Provider.Relationship` for assignment records — assignment relationships are on `Diffo.Provider.AssignedToRelationship`; access them via `instance.assignments`. +- Filtering `instance.forward_relationships` for `type == :assignedTo` — those records no longer exist there; use `instance.assignments` directly. +- Calling `build_before/1` or `build_after/2` in actions — these run automatically. +- Declaring `:specified_by`, `:features`, `:characteristics` as action arguments. +- Editing `documentation/dsls/DSL-Diffo.Provider.Extension.md` — it is Spark-generated; + run `mix spark.cheat_sheets` to regenerate it. +- Editing content between `` markers in `CLAUDE.md` — that is + auto-generated by `mix usage_rules.sync`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 613e20a..e78c41f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,31 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline +## [v0.3.0](https://github.com/diffo-dev/diffo/compare/v0.2.2...v0.3.0) (2026-05-17) + +### Breaking Changes + +* `Diffo.Provider.Relationship` no longer stores assignment records. Assignment relationships are now on `Diffo.Provider.AssignedToRelationship`. Any existing graph data with `type: :assignedTo` on `Relationship` nodes will need to be migrated. +* `instance.forward_relationships` no longer contains assignment records — use `instance.assignments` instead. +* `Diffo.Provider.create_assignment_relationship` removed — use `Diffo.Provider.create_assigned_to_relationship`. + +### Notable Changes + +* `Diffo.Provider.BaseRelationship` — new Ash Resource Fragment providing common attributes and behaviour for all relationship types +* `Diffo.Provider.AssignedToRelationship` — new dedicated resource for pool assignment relationships, split out from `Diffo.Provider.Relationship` +* `Diffo.Provider.Relationship` — now TMF-only; `pool`, `thing`, `assigned` attributes and `:create_assignment` action removed +* `instance.assignments` — new `has_many` on `BaseInstance` for pool assignment relationships; included in JSON encoding and default loads +* `Diffo.Provider.BaseCharacteristic` — new Ash Resource Fragment for typed characteristic resources; `ShelfCharacteristic`, `CardCharacteristic` etc. now extend this rather than using plain `Ash.TypedStruct` +* `pools do` DSL — new section on Instance resources replacing the old `characteristic :name, AssignableValue` pattern; generates `pools/0` and `pool/1` introspection functions +* Module naming convention — Instance resources must be suffixed `…Instance`, Characteristic resources `…Characteristic` to avoid Neo4j label collisions (documented in `usage-rules.md` and `AGENTS.md`) +* `Diffo.Provider.Extension` — unified Spark DSL extension consolidating the prior per-kind extensions + +### What's Changed + +* provider extension consolidation by @matt-beanland in https://github.com/diffo-dev/diffo/pull/130 +* base characteristic by @matt-beanland in https://github.com/diffo-dev/diffo/pull/133 +* assigner refactor — BaseRelationship, AssignedToRelationship, pools DSL, resource naming by @matt-beanland in https://github.com/diffo-dev/diffo/pull/135 + ## [v0.2.2](https://github.com/diffo-dev/diffo/compare/v0.2.1...v0.2.2) (2026-05-08) ## Notable Changes diff --git a/README.md b/README.md index 95dd7da..43b5a92 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Alternatively, add `diffo` to your list of dependencies in `mix.exs` manually: ```elixir def deps do [ - {:diffo, "~> 0.2.1"} + {:diffo, "~> 0.3.0"} ] end ``` diff --git a/diffo.livemd b/diffo.livemd index 5b3fb54..9746a19 100644 --- a/diffo.livemd +++ b/diffo.livemd @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:diffo, "~> 0.2.2"} + {:diffo, "~> 0.3.0"} ], consolidate_protocols: false ) diff --git a/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md new file mode 100644 index 0000000..091aea2 --- /dev/null +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -0,0 +1,812 @@ + +# Diffo.Provider.Extension + +Unified DSL extension for all Diffo provider resource kinds. + +Provides a single `provider do` section for Instance, Party, and Place kinds. +The sections within `provider do` are self-similar across kinds — each kind uses +the sections relevant to it, and verifiers enforce correct usage. + +## Instance + + provider do + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + end + + characteristics do + characteristic :circuit, Diffo.Access.Circuit + end + + features do + feature :dynamic_line_management do + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end + end + + pools do + pool :ports, :port + end + + parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] + end + + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + behaviour do + actions do + create :build + end + end + end + +## Party + + provider do + instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService + end + parties do + role :employer, MyApp.Person + end + places do + role :headquarters, MyApp.GeographicSite + end + end + +## Place + + provider do + instances do + role :site_for, MyApp.AccessService + end + parties do + role :managed_by, MyApp.Organization + end + places do + role :within, MyApp.GeographicSite + end + end + +See `Diffo.Provider.Extension.Info` for runtime introspection. +See `Diffo.Provider.BaseInstance`, `Diffo.Provider.BaseParty`, `Diffo.Provider.BasePlace` +for full usage documentation. + + +## provider +Provider DSL — structure, roles, and behaviour for this resource kind + +### Nested DSLs + * [specification](#provider-specification) + * [characteristics](#provider-characteristics) + * characteristic + * [features](#provider-features) + * feature + * characteristic + * [pools](#provider-pools) + * pool + * [parties](#provider-parties) + * party + * parties + * party_ref + * role + * [places](#provider-places) + * place + * places + * place_ref + * role + * [instances](#provider-instances) + * role + * instance_ref + * [behaviour](#provider-behaviour) + * actions + * create + * update + + + + +### provider.specification +Defines the Instance Specification + + + +### Examples +``` +specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + major_version 1 + description "An access network service" + category "Network Service" +end + +``` + + + + +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`id`](#provider-specification-id){: #provider-specification-id .spark-required} | `String.t` | | The id of the specification, a uuid4 the same in all environments, unique for name and major_version. | +| [`name`](#provider-specification-name){: #provider-specification-name .spark-required} | `String.t` | | The name of the specification. | +| [`type`](#provider-specification-type){: #provider-specification-type } | `atom` | `:serviceSpecification` | The type of the specification. | +| [`major_version`](#provider-specification-major_version){: #provider-specification-major_version } | `integer` | `1` | The major version of the specification. | +| [`minor_version`](#provider-specification-minor_version){: #provider-specification-minor_version } | `integer` | | The minor version of the specification. | +| [`patch_version`](#provider-specification-patch_version){: #provider-specification-patch_version } | `integer` | | The patch version of the specification. | +| [`tmf_version`](#provider-specification-tmf_version){: #provider-specification-tmf_version } | `integer` | | The TMF API version of the specification, e.g. 4. | +| [`description`](#provider-specification-description){: #provider-specification-description } | `String.t` | | A generic description of the specified service or resource. | +| [`category`](#provider-specification-category){: #provider-specification-category } | `String.t` | | The category the specified service or resource belongs to. | + + + + +### provider.characteristics +List of Instance Characteristics + +### Nested DSLs + * [characteristic](#provider-characteristics-characteristic) + + +### Examples +``` +characteristics do + characteristic :circuit, Diffo.Access.Circuit + characteristic :line, Diffo.Access.Line +end + +``` + + + + +### provider.characteristics.characteristic +```elixir +characteristic name, value_type +``` + + +Adds a Characteristic + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-characteristics-characteristic-name){: #provider-characteristics-characteristic-name .spark-required} | `atom` | | The name of the characteristic. | +| [`value_type`](#provider-characteristics-characteristic-value_type){: #provider-characteristics-characteristic-value_type } | `any` | | The type of the characteristic value — a module or `{:array, module}` for an array. | + + + + + + + + +### provider.features +Configuration for Instance Features + +### Nested DSLs + * [feature](#provider-features-feature) + * characteristic + + +### Examples +``` +features do + feature :dynamic_line_management do + is_enabled? true + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end +end + +``` + + + + +### provider.features.feature +```elixir +feature name +``` + + +Adds a Feature + +### Nested DSLs + * [characteristic](#provider-features-feature-characteristic) + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-features-feature-name){: #provider-features-feature-name .spark-required} | `atom` | | The name of the feature. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`is_enabled?`](#provider-features-feature-is_enabled?){: #provider-features-feature-is_enabled? } | `boolean` | | Whether the feature is enabled by default, defaults true. | + + +### provider.features.feature.characteristic +```elixir +characteristic name, value_type +``` + + +Adds a Characteristic + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-features-feature-characteristic-name){: #provider-features-feature-characteristic-name .spark-required} | `atom` | | The name of the characteristic. | +| [`value_type`](#provider-features-feature-characteristic-value_type){: #provider-features-feature-characteristic-value_type } | `any` | | The type of the characteristic value — a module or `{:array, module}` for an array. | + + + + + + + + + + + + +### provider.pools +Assignable pools on this Instance — each pool maps to an AssignableCharacteristic + +### Nested DSLs + * [pool](#provider-pools-pool) + + +### Examples +``` +pools do + pool :ports, :port +end + +``` + + + + +### provider.pools.pool +```elixir +pool name, thing +``` + + +Declares an assignable pool — a named range of values for auto-assignment + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-pools-pool-name){: #provider-pools-pool-name .spark-required} | `atom` | | The pool name (matches the AssignableCharacteristic name). | +| [`thing`](#provider-pools-pool-thing){: #provider-pools-pool-thing .spark-required} | `atom` | | The name of the thing being assigned within the pool (e.g. :port). | + + + + + + + + +### provider.parties +Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds + +### Nested DSLs + * [party](#provider-parties-party) + * [parties](#provider-parties-parties) + * [party_ref](#provider-parties-party_ref) + * [role](#provider-parties-role) + + +### Examples +``` +# Instance +parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] +end + +# Party or Place +parties do + role :employer, MyApp.Person +end + +``` + + + + +### provider.parties.party +```elixir +party role, party_type +``` + + +Declares a singular party role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-party-role){: #provider-parties-party-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-party-party_type){: #provider-parties-party-party_type } | `any` | | The module of the Party kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-parties-party-calculate){: #provider-parties-party-calculate } | `atom` | | Ash calculation on this resource that produces the party. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyDeclaration` + +### provider.parties.parties +```elixir +parties role, party_type +``` + + +Declares a plural party role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-parties-role){: #provider-parties-parties-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-parties-party_type){: #provider-parties-parties-party_type } | `any` | | The module of the Party kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-parties-parties-calculate){: #provider-parties-parties-calculate } | `atom` | | Ash calculation on this resource that produces the party. | +| [`constraints`](#provider-parties-parties-constraints){: #provider-parties-parties-constraints } | `keyword` | | Multiplicity constraints, e.g. [min: 1, max: 3]. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyDeclaration` + +### provider.parties.party_ref +```elixir +party_ref role, party_type +``` + + +Declares a singular reference party role — no direct PartyRef edge, reachable by graph traversal + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-party_ref-role){: #provider-parties-party_ref-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-party_ref-party_type){: #provider-parties-party_ref-party_type } | `any` | | The module of the Party kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-parties-party_ref-calculate){: #provider-parties-party_ref-calculate } | `atom` | | Ash calculation on this resource that produces the party. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyDeclaration` + +### provider.parties.role +```elixir +role role, party_type +``` + + +Declares a role this Party or Place kind plays with respect to other Parties + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-parties-role-role){: #provider-parties-role-role .spark-required} | `atom` | | The role name. | +| [`party_type`](#provider-parties-role-party_type){: #provider-parties-role-party_type } | `any` | | The module of the related Party kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PartyRole` + + +### provider.places +Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds + +### Nested DSLs + * [place](#provider-places-place) + * [places](#provider-places-places) + * [place_ref](#provider-places-place_ref) + * [role](#provider-places-role) + + +### Examples +``` +# Instance +places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress +end + +# Party or Place +places do + role :headquarters, MyApp.GeographicSite +end + +``` + + + + +### provider.places.place +```elixir +place role, place_type +``` + + +Declares a singular place role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-place-role){: #provider-places-place-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-place-place_type){: #provider-places-place-place_type } | `any` | | The module of the Place kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-places-place-calculate){: #provider-places-place-calculate } | `atom` | | Ash calculation on this resource that produces the place. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceDeclaration` + +### provider.places.places +```elixir +places role, place_type +``` + + +Declares a plural place role on this Instance + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-places-role){: #provider-places-places-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-places-place_type){: #provider-places-places-place_type } | `any` | | The module of the Place kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-places-places-calculate){: #provider-places-places-calculate } | `atom` | | Ash calculation on this resource that produces the place. | +| [`constraints`](#provider-places-places-constraints){: #provider-places-places-constraints } | `keyword` | | Multiplicity constraints, e.g. [min: 1, max: 3]. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceDeclaration` + +### provider.places.place_ref +```elixir +place_ref role, place_type +``` + + +Declares a singular reference place role — no direct PlaceRef edge, reachable by graph traversal + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-place_ref-role){: #provider-places-place_ref-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-place_ref-place_type){: #provider-places-place_ref-place_type } | `any` | | The module of the Place kind. | +### Options + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`calculate`](#provider-places-place_ref-calculate){: #provider-places-place_ref-calculate } | `atom` | | Ash calculation on this resource that produces the place. | + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceDeclaration` + +### provider.places.role +```elixir +role role, place_type +``` + + +Declares a role this Party or Place kind plays with respect to Places + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-places-role-role){: #provider-places-role-role .spark-required} | `atom` | | The role name. | +| [`place_type`](#provider-places-role-place_type){: #provider-places-role-place_type } | `any` | | The module of the related Place kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.PlaceRole` + + +### provider.instances +Declares the roles this Party or Place kind plays with respect to Instances + +### Nested DSLs + * [role](#provider-instances-role) + * [instance_ref](#provider-instances-instance_ref) + + +### Examples +``` +instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService +end + +``` + + + + +### provider.instances.role +```elixir +role role, instance_type +``` + + +Declares a role this Party or Place kind plays with respect to Instances + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-instances-role-role){: #provider-instances-role-role .spark-required} | `atom` | | The role name. | +| [`instance_type`](#provider-instances-role-instance_type){: #provider-instances-role-instance_type } | `any` | | The module of the related Instance kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InstanceRole` + +### provider.instances.instance_ref +```elixir +instance_ref role, instance_type +``` + + +Declares a reference instance role — no direct edge created, reachable by graph traversal + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`role`](#provider-instances-instance_ref-role){: #provider-instances-instance_ref-role .spark-required} | `atom` | | The role name. | +| [`instance_type`](#provider-instances-instance_ref-instance_type){: #provider-instances-instance_ref-instance_type } | `any` | | The module of the related Instance kind. | + + + + + + +### Introspection + +Target: `Diffo.Provider.Extension.InstanceRole` + + +### provider.behaviour +Defines the behavioural wiring for the Instance — actions, and in future triggers + +### Nested DSLs + * [actions](#provider-behaviour-actions) + * create + * update + + +### Examples +``` +behaviour do + actions do + create :build + end +end + +``` + + + +### provider.behaviour.actions +Declares which actions to wire for instance behaviour + +### Nested DSLs + * [create](#provider-behaviour-actions-create) + * [update](#provider-behaviour-actions-update) + + +### Examples +``` +actions do + create :build + update :define +end + +``` + + + + +### provider.behaviour.actions.create +```elixir +create name +``` + + +Marks a create action for instance build wiring + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-behaviour-actions-create-name){: #provider-behaviour-actions-create-name .spark-required} | `atom` | | The name of the create action to wire. | + + + + + + + +### provider.behaviour.actions.update +```elixir +update name +``` + + +Marks an update action for instance behaviour wiring + + + + + +### Arguments + +| Name | Type | Default | Docs | +|------|------|---------|------| +| [`name`](#provider-behaviour-actions-update-name){: #provider-behaviour-actions-update-name .spark-required} | `atom` | | The name of the update action to wire. | + + + + + + + + + + + + + + + + diff --git a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md deleted file mode 100644 index 24cc338..0000000 --- a/documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md +++ /dev/null @@ -1,530 +0,0 @@ - -# Diffo.Provider.Instance.Extension - -DSL Extension customising an Instance. - -Provides two top-level sections: - -## structure - -Describes the static shape of the Instance kind — what it is, what values it carries, -and what parties it relates to. All structure declarations are baked into the resource -module at compile time via persisters and are introspectable at runtime via -`Diffo.Provider.Instance.Info` or directly as generated functions on the resource module. - -- `specification do` — the TMF Specification (id, name, type, version, description, category). - The id is a stable UUID4 that is the same across all environments for this Instance kind. -- `characteristics do` — typed value slots carried by instances of this kind, each backed - by an `Ash.TypedStruct`. -- `features do` — optional capabilities of this kind, each with its own typed characteristic - 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 - -Declares which Ash actions should be wired for instance build lifecycle management. -Currently supports `create` declarations; future sections will cover triggers and other -lifecycle concerns. - -Declaring `create :name` in `behaviour do actions do` causes the `TransformBehaviour` -transformer to inject `:specified_by`, `:features`, and `:characteristics` arguments onto -the named Ash create action. These arguments carry the UUIDs of the TMF entities created -by `build_before/1` and consumed by the Ash relationship management in the action. - -See the [DSL cheat sheet](DSL-Diffo.Provider.Instance.Extension.html) for the full DSL reference. -See `Diffo.Provider.BaseInstance` for full usage documentation including generated functions. - - -## structure -Defines the structural shape of the Instance — its specification, characteristics, features, parties, and places - -### Nested DSLs - * [specification](#structure-specification) - * [characteristics](#structure-characteristics) - * characteristic - * [features](#structure-features) - * feature - * characteristic - * [parties](#structure-parties) - * party - * parties - * [places](#structure-places) - * place - * places - - -### Examples -``` -structure do - specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - end - - characteristics do - characteristic :circuit, Diffo.Access.Circuit - end - - parties do - party :provider, MyApp.Provider - end - - places do - place :installation_site, MyApp.GeographicSite - end -end - -``` - - - -### structure.specification -Defines the Instance Specification - - - -### Examples -``` -specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - major_version 1 - description "An access network service connecting a subscriber premises to an access NNI via DSL" - category "Network Service" -end - -``` - - - - -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`id`](#structure-specification-id){: #structure-specification-id .spark-required} | `String.t` | | The id of the specification, a uuid4 the same in all environments, unique for name and major_version. | -| [`name`](#structure-specification-name){: #structure-specification-name .spark-required} | `String.t` | | The name of the specification, unique to a service but common for all versions. | -| [`type`](#structure-specification-type){: #structure-specification-type } | `atom` | `:serviceSpecification` | The type of the specification. | -| [`major_version`](#structure-specification-major_version){: #structure-specification-major_version } | `integer` | `1` | The major_version of the specification. | -| [`minor_version`](#structure-specification-minor_version){: #structure-specification-minor_version } | `integer` | | The minor_version of the specification. | -| [`patch_version`](#structure-specification-patch_version){: #structure-specification-patch_version } | `integer` | | The patch_version of the specification. | -| [`tmf_version`](#structure-specification-tmf_version){: #structure-specification-tmf_version } | `integer` | | The TMF API version of the specification, e.g. 4. | -| [`description`](#structure-specification-description){: #structure-specification-description } | `String.t` | | A generic description of the specified service or resource. | -| [`category`](#structure-specification-category){: #structure-specification-category } | `String.t` | | The category the specified service or resource belongs to. | - - - - -### structure.characteristics -List of Instance Characteristics - -### Nested DSLs - * [characteristic](#structure-characteristics-characteristic) - - -### Examples -``` -characteristics do - characteristic :dslam, Diffo.Access.Dslam - characteristic :aggregate_interface, Diffo.Access.AggregateInterface - characteristic :circuit, Diffo.Access.Circuit - characteristic :line, Diffo.Access.Line -end - -``` - - - - -### structure.characteristics.characteristic -```elixir -characteristic name, value_type -``` - - -Adds a Characteristic - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#structure-characteristics-characteristic-name){: #structure-characteristics-characteristic-name .spark-required} | `atom` | | The name of the characteristic, an atom | -| [`value_type`](#structure-characteristics-characteristic-value_type){: #structure-characteristics-characteristic-value_type } | `any` | | The type of the characteristic's value. An atom module name such as an Ash.TypedStruct for a scalar value, or `{:array, module}` for an array of values of that type. | - - - - - - - - -### structure.features -Configuration for Instance Features - -### Nested DSLs - * [feature](#structure-features-feature) - * characteristic - - -### Examples -``` -features do - feature :dynamic_line_management do - is_enabled? true - characteristics do - characteristic :constraints, Diffo.Access.Constraints - end - end -end - -``` - - - - -### structure.features.feature -```elixir -feature name -``` - - -Adds a Feature - -### Nested DSLs - * [characteristic](#structure-features-feature-characteristic) - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#structure-features-feature-name){: #structure-features-feature-name .spark-required} | `atom` | | The name of the feature, an atom | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`is_enabled?`](#structure-features-feature-is_enabled?){: #structure-features-feature-is_enabled? } | `boolean` | | Whether the feature is enabled by default, defaults true | - - -### structure.features.feature.characteristic -```elixir -characteristic name, value_type -``` - - -Adds a Characteristic - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#structure-features-feature-characteristic-name){: #structure-features-feature-characteristic-name .spark-required} | `atom` | | The name of the characteristic, an atom | -| [`value_type`](#structure-features-feature-characteristic-value_type){: #structure-features-feature-characteristic-value_type } | `any` | | The type of the characteristic's value. An atom module name such as an Ash.TypedStruct for a scalar value, or `{:array, module}` for an array of values of that type. | - - - - - - - - - - - - -### structure.parties -List of Instance Party roles - -### Nested DSLs - * [party](#structure-parties-party) - * [parties](#structure-parties-parties) - - -### Examples -``` -parties do - party :provider, MyApp.Provider, calculate: :provider_calculation - parties :technician, MyApp.Technician, constraints: [min: 1, max: 3] - party :owner, MyApp.InfrastructureCo, reference: true -end - -``` - - - - -### structure.parties.party -```elixir -party role, party_type -``` - - -Declares a singular party role on this Instance - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#structure-parties-party-role){: #structure-parties-party-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#structure-parties-party-party_type){: #structure-parties-party-party_type } | `any` | | The module of the Party kind. An atom module name such as a BaseParty-derived resource. | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#structure-parties-party-reference){: #structure-parties-party-reference } | `boolean` | `false` | If true, no direct PartyRef edge is created; the party is reachable by graph traversal. | -| [`calculate`](#structure-parties-party-calculate){: #structure-parties-party-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the party at build time. | - - - - - -### Introspection - -Target: `Diffo.Provider.Instance.Extension.PartyDeclaration` - -### structure.parties.parties -```elixir -parties role, party_type -``` - - -Declares a plural party role on this Instance - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#structure-parties-parties-role){: #structure-parties-parties-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#structure-parties-parties-party_type){: #structure-parties-parties-party_type } | `any` | | The module of the Party kind. An atom module name such as a BaseParty-derived resource. | -### Options - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`reference`](#structure-parties-parties-reference){: #structure-parties-parties-reference } | `boolean` | `false` | If true, no direct PartyRef edge is created; the party is reachable by graph traversal. | -| [`calculate`](#structure-parties-parties-calculate){: #structure-parties-parties-calculate } | `atom` | | Name of an Ash calculation on this resource that produces the party at build time. | -| [`constraints`](#structure-parties-parties-constraints){: #structure-parties-parties-constraints } | `keyword` | | Multiplicity constraints on the number of parties in this role, e.g. [min: 1, max: 3] | - - - - - -### Introspection - -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` - - - - - - -## behaviour -Defines the behavioural wiring for the Instance — actions, and in future triggers and tasks - -### Nested DSLs - * [actions](#behaviour-actions) - * create - * update - - -### Examples -``` -behaviour do - actions do - create :build - update :define - end -end - -``` - - - -### behaviour.actions -Declares which actions to wire for instance behaviour - -### Nested DSLs - * [create](#behaviour-actions-create) - * [update](#behaviour-actions-update) - - -### Examples -``` -actions do - create :build - update :define -end - -``` - - - - -### behaviour.actions.create -```elixir -create name -``` - - -Marks a create action for instance build wiring, injecting :specified_by, :features, and :characteristics arguments - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#behaviour-actions-create-name){: #behaviour-actions-create-name .spark-required} | `atom` | | The name of the create action to wire | - - - - - - - -### behaviour.actions.update -```elixir -update name -``` - - -Marks an update action for instance behaviour wiring - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`name`](#behaviour-actions-update-name){: #behaviour-actions-update-name .spark-required} | `atom` | | The name of the update action to wire | - - - - - - - - - - - - - - diff --git a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md deleted file mode 100644 index 643ce53..0000000 --- a/documentation/dsls/DSL-Diffo.Provider.Party.Extension.md +++ /dev/null @@ -1,163 +0,0 @@ - -# Diffo.Provider.Party.Extension - -DSL Extension customising a Party. - -Provides compile-time declaration blocks for domain-specific Party kinds -built on `Diffo.Provider.BaseParty`. All declarations are introspectable via -`Diffo.Provider.Party.Extension.Info`. - -See the [DSL cheat sheet](DSL-Diffo.Provider.Party.Extension.html) for the full DSL reference. - - -## instances -Declares the roles this Party kind plays with respect to Instances - -### Nested DSLs - * [role](#instances-role) - - -### Examples -``` -instances do - role :facilitates, MyApp.AccessService -end - -``` - - - - -### instances.role -```elixir -role role, party_type -``` - - -Declares a role this Party kind plays - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#instances-role-role){: #instances-role-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#instances-role-party_type){: #instances-role-party_type } | `any` | | The module of the related resource | - - - - - - -### Introspection - -Target: `Diffo.Provider.Party.Extension.InstanceRole` - - - - -## parties -Declares the roles this Party kind plays with respect to other Parties - -### Nested DSLs - * [role](#parties-role) - - -### Examples -``` -parties do - role :managed_by, MyApp.Person -end - -``` - - - - -### parties.role -```elixir -role role, party_type -``` - - -Declares a role this Party kind plays with respect to other Parties - - - - - -### Arguments - -| Name | Type | Default | Docs | -|------|------|---------|------| -| [`role`](#parties-role-role){: #parties-role-role .spark-required} | `atom` | | The role name, an atom | -| [`party_type`](#parties-role-party_type){: #parties-role-party_type } | `any` | | The module of the related Party kind | - - - - - - -### Introspection - -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 f44f4e5..5c9514f 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -4,12 +4,12 @@ SPDX-FileCopyrightText: 2025 diffo contributors -# Using the Diffo Provider Instance Extension +# Using the Diffo Provider Extension ```elixir Mix.install( [ - {:diffo, "~> 0.2.1"} + {:diffo, "~> 0.3.0"} ], config: [ diffo: [ash_domains: [Diffo.Provider]] @@ -27,19 +27,19 @@ If you are not already familiar with Ash then please explore [Ash Get Started](h First ensure you've explored the Diffo Livebook for an introduction to Diffo: [![Run in Livebook](https://livebook.dev/badge/v1/blue.svg)](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2Fdiffo%2Ddev%2Fdiffo%2Fblob%2Fdev%2Fdiffo.livemd) -In this 'Diffo Provider Instance Extension' livebook you will learn about: +In this livebook you will learn about: * TMF Services and Resources * Building your own Domain -* Declaring a Composite Resource using the Instance Extension -* Using the Assigner +* Declaring Instance resources with the unified `provider do` DSL +* Using the Assigner for partial resource allocation and assignment * Composing a Resource from partially assigned Resources -* Declaring domain Parties using the Party Extension -* Declaring domain Places using the Place Extension +* Declaring Party kinds with `provider do` +* Declaring Place kinds with `provider do` ### Installing Neo4j and Configuring Bolty -Diffo uses the [Ash Neo4j DataLayer](https://github.com/diffo-dev/ash_neo4j), which requires Neo4j to be installed +Diffo uses the [Ash Neo4j DataLayer](https://github.com/diffo-dev/ash_neo4j), which requires Neo4j to be installed. While [Neo4j community edition](https://github.com/neo4j/neo4j) is open source and you can build from source it is likely that you'll use an installation. @@ -110,7 +110,7 @@ When a Provider creates a pool of resources this is known as 'allocation'. For i When a Consumer is leased a resource this is assignment. -Assigment is effectively a request for a relationship from a Provider Resource 'back up' to a Consumer Service or Resource. There are different variants on this: +Assignment is effectively a request for a relationship from a Provider Resource 'back up' to a Consumer Service or Resource. There are different variants on this: * Specific Resource assignment - the specific resource requested by the Consumer is assigned * 'To specification' Resource assignment - an entire resource is assigned by the Provider, allocation may be 'just in time' @@ -121,41 +121,39 @@ In all cases the assignment is only successful if the Provider allows the reques Partial resource assignment uses a relationship characteristic to indicate which part of the resource is optionally requested and ultimately assigned. -## Instance Extension +## Provider Extension -Diffo.Provider.Instance models either a Service or Resource. It actually uses the Diffo.Provider.BaseInstance [Spark.Dsl.Fragment](https://hexdocs.pm/spark/Spark.Dsl.Fragment.html). There is no need to evaluate the Diffo.Provider.Instance below, it is already defined. +`Diffo.Provider.BaseInstance` is an Ash Resource Fragment for domain-specific Instance kinds +(services and resources). It provides a rich set of base attributes — `id`, `href`, `name`, +`type`, `state` and more — plus the unified `Diffo.Provider.Extension` DSL. -```elixir -defmodule Diffo.Provider.Instance do - @moduledoc """ - Ash Resource for a TMF Service or Resource Instance - """ - alias Diffo.Provider.BaseInstance +The extension provides a single `provider do` section containing everything needed to +describe and wire an Instance kind. 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.Extension.Info`. - use Ash.Resource, - fragments: [BaseInstance], - domain: Diffo.Provider +The `provider do` section contains: - resource do - description "An Ash Resource for a TMF Service or Resource Instance" - plural_name :instances - end -end -``` +**`specification do`** — the TMF Specification (id, name, type, version, description, category). +The id is a stable UUID4, the same in every environment for this Instance kind. -Diffo also has an inbuilt Spark DSL extension [Diffo.Provider.Instance.Extension](https://hexdocs.pm/diffo/Diffo.Provider.Instance.Extension.html) which provides DSL and functions for use in building and operating domain specific services and resources. +**`characteristics do`** — typed value slots backed by `Diffo.Provider.BaseCharacteristic`-derived resources. -The extension has two top-level sections: +**`features do`** — optional capabilities with their own typed characteristic payload. -**`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`. +**`pools do`** — assignable pools for partial resource allocation. Each `pool :name, :thing` declaration creates an `AssignableCharacteristic` node during `build` and generates `pools/0` / `pool/1` on the module. Pool bounds (`first`, `last`, `algorithm`, `assignable_type`) are set in a `:define` action via `Pool.update_pools/3`. Assignment actions use `Assigner.assign/3` — the thing name is looked up from the pool declaration. -**`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. +**`parties do`** — party roles: `party` (singular), `parties` (plural), `party_ref` (reference, no direct edge). -Feature and Instance Characteristics can have payloads defined by [Ash.TypedStruct](https://hexdocs.pm/ash/Ash.TypedStruct.html). TypedStruct are DSL specified types which are effectively lightweight embedded resources. We've extended both [AshJason](https://hexdocs.pm/ash_jason/) and [AshOutstanding](https://hexdocs.pm/ash_outstanding/) to support Ash.TypedStruct. +**`places do`** — place roles: `place` (singular), `places` (plural), `place_ref` (reference). -For partial resource allocation and assignment we've created Diffo.Provider.Assigner. It is used by the host resource, which declares a characteristic with an Diffo.Provider.AssignableValue TypedStruct. Allocation is managed within the Provider domain using this characteristic. Assignment to Services or Resources is via 'reverse' type: "assignedTo" relationships enriched by relationship characteristics. +**`behaviour do`** — declares which Ash create actions to wire for build lifecycle management. +Declaring `create :name` injects `:specified_by`, `:features`, and `:characteristics` +arguments automatically onto that action. -We can still use the Diffo.Provider API's noting that they will return Diffo.Provider.Instance rather than our specific domain resource, but we'll use our own domain API linked to specific actions. +Each characteristic is a dedicated Ash resource using the `Diffo.Provider.BaseCharacteristic` fragment. It carries direct typed attributes and a `:value` calculation that builds a companion `.Value` TypedStruct for ordered JSON encoding. The TypedStruct uses [AshJason.TypedStruct](https://hexdocs.pm/ash_jason/) to control field order in the JSON output. + +For partial resource allocation and assignment we've created `Diffo.Provider.Assigner`. The host resource declares a `pools do` section listing each assignable pool by name and the kind of thing being assigned. Pool bounds (first/last value, algorithm) are set via a `:define` action. Each assignment is stored as a `Diffo.Provider.AssignedToRelationship` node (Neo4j label `:AssignmentRelationship`) carrying `pool`, `thing`, and the `assigned` value. These are distinct from regular TMF `Diffo.Provider.Relationship` nodes and are accessible on an instance via `instance.assignments`. Let's imagine a Compute domain which operates GPU and NPU resources. We want to expose a Cluster composite resource which can be dynamically composed of a number of GPU and NPU cores. @@ -167,7 +165,74 @@ We'll define all the resources first, then declare the `Diffo.Compute` domain on ### Declaring a Composite Resource -We will start by declaring the Cluster Resource. It is going to be a composite resource, where it can be assigned individual GPU and NPU cores via resource relationships. It is an Ash.Resource incorporating the Diffo.Provider.BaseInstance fragment. +We will start by declaring the Cluster Resource. It is going to be a composite resource, where it can be assigned individual GPU and NPU cores via resource relationships. It is an Ash.Resource incorporating the `Diffo.Provider.BaseInstance` fragment. + +First we define the `ClusterCharacteristic` typed resource and its companion `Value` TypedStruct: + +```elixir +defmodule Diffo.Compute.ClusterCharacteristic do + @moduledoc "Typed characteristic carrying cluster composition fields." + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: Diffo.Compute + + resource do + plural_name :cluster_characteristics + end + + attributes do + attribute :gpu_cores, :integer, public?: true, default: 0, constraints: [min: 0] + attribute :npu_cores, :integer, public?: true, default: 0, constraints: [min: 0] + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + actions do + create :create do + accept [:name, :gpu_cores, :npu_cores] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:gpu_cores, :npu_cores] + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end + +defmodule Diffo.Compute.ClusterCharacteristic.Value do + @moduledoc "Value struct for ClusterCharacteristic — controls JSON field order." + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + typed_struct do + field :gpu_cores, :integer + field :npu_cores, :integer + end + + jason do + pick [:gpu_cores, :npu_cores] + compact true + end +end +``` + +Now the Cluster resource itself. It declares `ClusterCharacteristic` as the `:cluster` characteristic — updates to it are made directly on the characteristic resource, so no `update :define` is needed here: ```elixir defmodule Diffo.Compute.Cluster do @@ -177,9 +242,8 @@ defmodule Diffo.Compute.Cluster do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship - alias Diffo.Provider.Instance.Characteristic alias Diffo.Compute - alias Diffo.Compute.ClusterValue + alias Diffo.Compute.ClusterCharacteristic alias Diffo.Compute.Tenant alias Diffo.Compute.Engineer @@ -192,7 +256,7 @@ defmodule Diffo.Compute.Cluster do plural_name :Clusters end - structure do + provider do specification do id "4bcfc4c9-e776-4878-a658-e8d81857bed7" name "cluster" @@ -202,7 +266,7 @@ defmodule Diffo.Compute.Cluster do end characteristics do - characteristic :cluster, ClusterValue + characteristic :cluster, ClusterCharacteristic end parties do @@ -213,11 +277,11 @@ defmodule Diffo.Compute.Cluster do places do place :data_centre, Diffo.Compute.DataCentre end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end @@ -234,17 +298,6 @@ defmodule Diffo.Compute.Cluster do upsert? false end - update :define do - description "defines the cluster" - argument :characteristic_value_updates, {:array, :term} - - change after_action(fn changeset, result, _context -> - with {:ok, result} <- Characteristic.update_values(result, changeset), - {:ok, cluster} <- Compute.get_cluster_by_id(result.id), - do: {:ok, cluster} - end) - end - update :relate do description "relates the cluster with other instances" argument :relationships, {:array, :struct} @@ -259,49 +312,78 @@ defmodule Diffo.Compute.Cluster do end ``` -And of course we'll need a ClusterValue TypedStruct for the Cluster Resource's cluster characteristic: +### Using the Assigner + +We'll now define a GPU Resource which uses the `Diffo.Provider.Assigner` functionality. + +First define the `GpuCharacteristic` typed resource and its `Value` TypedStruct: ```elixir -defmodule Diffo.Compute.ClusterValue do - @moduledoc """ - AshTyped Struct for Cluster Characteristic Value - """ - use Ash.TypedStruct, extensions: [AshJason.TypedStruct, AshOutstanding.TypedStruct] +defmodule Diffo.Compute.GpuCharacteristic do + @moduledoc "Typed characteristic carrying GPU identity fields." + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: Diffo.Compute - jason do - pick [:name, :gpu_cores, :npu_cores] - compact true + resource do + plural_name :gpu_characteristics end - outstanding do - expect [:gpu_cores] + attributes do + attribute :family, :atom, public?: true, description: "the GPU family name" + attribute :model, :string, public?: true, description: "the GPU model name" + attribute :technology, :atom, public?: true, description: "the GPU technology" end - typed_struct do - field :name, :string, description: "the cluster name" + calculations do + calculate :value, Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + actions do + create :create do + accept [:name, :family, :model, :technology] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end - field :gpu_cores, :integer, - default: 0, - constraints: [min: 0], - description: "the number of GPU cores in the cluster" + update :update do + accept [:family, :model, :technology] + end + end - field :npu_cores, :integer, - default: 0, - constraints: [min: 0], - description: "the number of NPU cores in the cluster" + preparations do + prepare build(load: [:value]) end - defimpl String.Chars do - def to_string(struct) do - inspect(struct) - end + jason do + pick [:name, :value] + compact true end end -``` -### Using the Assigner +defmodule Diffo.Compute.GpuCharacteristic.Value do + @moduledoc "Value struct for GpuCharacteristic — controls JSON field order." + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] -We'll now define a GPU Resource which uses the Diffo.Provider.Assigner functionality. + typed_struct do + field :family, :atom + field :model, :string + field :technology, :atom + end + + jason do + pick [:family, :model, :technology] + compact true + end +end +``` + +The GPU resource declares `GpuCharacteristic` for the typed `:gpu` slot and uses `pools do` to declare the `:cores` assignable pool. The `update :define` action updates both the typed characteristic and the pool bounds. The `update :assign_core` action uses `Assigner.assign/3` — the thing name (`:core`) is looked up from the pool declaration automatically: ```elixir defmodule Diffo.Compute.GPU do @@ -311,12 +393,12 @@ defmodule Diffo.Compute.GPU do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship - alias Diffo.Provider.Instance.Characteristic + alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Pool alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableValue alias Diffo.Compute - alias Diffo.Compute.GPUValue + alias Diffo.Compute.GpuCharacteristic use Ash.Resource, fragments: [BaseInstance], @@ -327,7 +409,7 @@ defmodule Diffo.Compute.GPU do plural_name :gpus end - structure do + provider do specification do id "ad50073f-17e0-45cb-b9b1-aa4296876156" name "gpu" @@ -337,14 +419,17 @@ defmodule Diffo.Compute.GPU do end characteristics do - characteristic :gpu, GPUValue - characteristic :cores, AssignableValue + characteristic :gpu, GpuCharacteristic + end + + pools do + pool :cores, :core end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end @@ -362,11 +447,13 @@ defmodule Diffo.Compute.GPU do end update :define do - description "defines the GPU" + description "sets GPU identity and allocates the cores pool" argument :characteristic_value_updates, {:array, :term} change after_action(fn changeset, result, _context -> - with {:ok, result} <- Characteristic.update_values(result, changeset), + with {:ok, result} <- + Characteristic.update_all(result, changeset, characteristics()), + {:ok, result} <- Pool.update_pools(result, changeset, pools()), {:ok, result} <- Compute.get_gpu_by_id(result.id), do: {:ok, result} end) @@ -384,11 +471,11 @@ defmodule Diffo.Compute.GPU do end update :assign_core do - description "relates the GPU with an instance by assigning a core" + description "assigns a core from this GPU to another instance" argument :assignment, :struct, constraints: [instance_of: Assignment] change after_action(fn changeset, result, _context -> - with {:ok, result} <- Assigner.assign(result, changeset, :cores, :core), + with {:ok, result} <- Assigner.assign(result, changeset, :cores), {:ok, result} <- Compute.get_gpu_by_id(result.id), do: {:ok, result} end) @@ -397,49 +484,13 @@ defmodule Diffo.Compute.GPU do end ``` -And we must define the GPUValue TypedStruct, used in the GPU's gpu characteristic: - -```elixir -defmodule Diffo.Compute.GPUValue do - @moduledoc """ - AshTyped Struct for GPU Characteristic Value - """ - use Ash.TypedStruct, extensions: [AshJason.TypedStruct, AshOutstanding.TypedStruct] - - jason do - pick [:name, :family, :model, :technology] - compact true - end - - outstanding do - expect [:name] - end - - typed_struct do - field :name, :string, description: "the GPU name" - - field :family, :atom, description: "the GPU family name" - - field :model, :string, description: "the GPU model name" - - field :technology, :atom, description: "the GPU technology" - end - - defimpl String.Chars do - def to_string(struct) do - inspect(struct) - end - end -end -``` - ## Party Extension -`Diffo.Provider.BaseParty` is an Ash Resource Fragment for domain-specific Party kinds, mirroring `BaseInstance`. It provides common Party attributes — `id`, `href`, `name`, `type`, `referred_type` — and the `Diffo.Provider.Party.Extension` DSL, which lets a Party kind declare the roles it plays with respect to Instances and other Parties. +`Diffo.Provider.BaseParty` is an Ash Resource Fragment for domain-specific Party kinds, mirroring `BaseInstance`. It provides common Party attributes — `id`, `href`, `name`, `type`, `referred_type` — and the unified `Diffo.Provider.Extension` DSL. Within `provider do`, a Party kind uses `instances do`, `parties do`, and `places do` sections to declare the roles it plays. `type` defaults to `:PartyRef` and can be set to `:Individual`, `:Organization`, or `:Entity`. Domain party kinds typically set `type` in their `build` action. The `id` defaults to a generated uuid but can be set to any meaningful string (such as an ABN or a data centre identifier). -The `Diffo.Provider.Party.Extension` DSL cheat sheet is at [DSL-Diffo.Provider.Party.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Party.Extension.html). +The `Diffo.Provider.Extension` DSL cheat sheet is at [DSL-Diffo.Provider.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Extension.html). ### Defining Party kinds @@ -470,9 +521,11 @@ defmodule Diffo.Compute.Tenant do end end - instances do - role :operator, Diffo.Compute.Cluster - role :operator, Diffo.Compute.GPU + provider do + instances do + role :operator, Diffo.Compute.Cluster + role :operator, Diffo.Compute.GPU + end end end ``` @@ -502,24 +555,23 @@ defmodule Diffo.Compute.Engineer do end end - instances do - role :manager, Diffo.Compute.Cluster - end - - parties do - role :employer, Diffo.Compute.Tenant + provider do + instances do + role :manager, Diffo.Compute.Cluster + end + parties do + role :employer, Diffo.Compute.Tenant + end end 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. +`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 unified `Diffo.Provider.Extension` DSL. Within `provider do`, a Place kind uses `instances do`, `parties do`, and `places do` sections to declare the roles it plays. `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. @@ -559,9 +611,11 @@ defmodule Diffo.Compute.DataCentre do end end - instances do - role :data_centre, Diffo.Compute.Cluster - role :data_centre, Diffo.Compute.GPU + provider do + instances do + role :data_centre, Diffo.Compute.Cluster + role :data_centre, Diffo.Compute.GPU + end end end ``` @@ -580,8 +634,10 @@ defmodule Diffo.Compute do validate_config_inclusion?: false alias Diffo.Compute.GPU + alias Diffo.Compute.GpuCharacteristic #alias Diffo.Compute.NPU alias Diffo.Compute.Cluster + alias Diffo.Compute.ClusterCharacteristic alias Diffo.Compute.Tenant alias Diffo.Compute.Engineer alias Diffo.Compute.DataCentre @@ -595,6 +651,10 @@ defmodule Diffo.Compute do define :assign_gpu_core, action: :assign_core end + resource GpuCharacteristic do + define :update_gpu_characteristic, action: :update + end + #resource NPU do #define :get_npu_by_id, action: :read, get_by: :id #define :build_npu, action: :build @@ -606,10 +666,13 @@ defmodule Diffo.Compute do resource Cluster do define :get_cluster_by_id, action: :read, get_by: :id define :build_cluster, action: :build - define :define_cluster, action: :define define :relate_cluster, action: :relate end + resource ClusterCharacteristic do + define :update_cluster_characteristic, action: :update + end + resource Tenant do define :create_tenant, action: :build define :get_tenant_by_id, action: :read, get_by: :id @@ -688,19 +751,19 @@ gpu_1 = Compute.build_gpu!(%{name: "GPU 1"}) gpu_2 = Compute.build_gpu!(%{name: "GPU 2"}) ``` -We need to define each GPU instance, in this case defining the gpu Characteristic AssignableValue performs the allocation - in this case setting how many GPU cores are available. +We define each GPU: setting its typed `:gpu` characteristic fields and allocating the `:cores` pool bounds. Both are passed via `characteristic_value_updates` to the `:define` action — `Characteristic.update_all` handles the typed `:gpu` update and `Pool.update_pools` handles the `:cores` pool bounds: ```elixir -updates = [ +gpu_attrs = [ gpu: [family: :nvidia, model: "GeForce RTX5090", technology: :blackwell], - cores: [first: 1, last: 680, free: 680, assignable_type: "tensor"] + cores: [first: 1, last: 680, assignable_type: "tensor"] ] -gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: updates}) -gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: updates}) +gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: gpu_attrs}) +gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: gpu_attrs}) ``` -The GPU's core characteristic is an AssignableValue, now we've allocated it we can use it to keep track of how many cores are free (unassigned). We can render one as json: +The `:cores` pool is backed by an `AssignableCharacteristic` node that records the range bounds and algorithm. Free cores are computed at assignment time from the count of existing `AssignmentRelationship` records — there is no stored `free` counter. We can render one as json: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts @@ -720,16 +783,16 @@ gpu_1 = Compute.assign_gpu_core!(gpu_1, assignment) gpu_2 = Compute.assign_gpu_core!(gpu_2, assignment) ``` -Now our cluster should have a core from each gpu. Check in the neo4j browser for the type: :assignedTo Relationship from the gpu_1 and gpu_2 to the clusters. There should be four, each with a Relationship Characteristic of core, with a value of the assigned core, e.g. 1, 2. +Now our cluster should have a core from each GPU. Check in the Neo4j browser for `:AssignmentRelationship` nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each carries `pool: :cores`, `thing: :core`, and the `assigned` integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_2). -Also the gpu will show each assignedTo relationship, since these are forward relationships. These should also show the relationship characteristic: +The GPU's `assignments` hold each assignment, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts ``` -Make sure you have a look at it in the neo4j browser. There should be Relationship nodes with a role of :assignedTo from each GPU resource instance to the cluster_1 resource instance. Each Relationship should be defined by a Characteristic with the assigned core number. -There is no central assignment table, rather the relationships ARE the assignments. +Make sure you have a look at it in the neo4j browser. There should be `:AssignmentRelationship` nodes from each GPU resource instance to the `cluster_1` resource instance, each carrying the assigned core number. +There is no central assignment table — the `AssignedToRelationship` nodes ARE the assignments. They are separate from the regular `:Relationship` nodes used for TMF service/resource relationships, and are accessible in Elixir via `instance.assignments`. As an exercise, clone the GPU resource to create an NPU resource and assign some NPU cores from it to your cluster. Check that the assigned NPU cores are unique. @@ -738,10 +801,16 @@ 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, 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. +In this tutorial you've used Diffo's unified `provider do` extension to define a Compute domain with: + +- A composite Cluster resource that receives GPU cores via `Diffo.Provider.Assigner` +- A GPU resource using `pools do` to declare the `:cores` assignable pool — `pool :cores, :core` replaces the old `characteristic :cores, AssignableValue` pattern +- Assignments stored on `Diffo.Provider.AssignedToRelationship` nodes (Neo4j label `:AssignmentRelationship`, distinct from TMF `:Relationship` nodes); accessible via `instance.assignments` +- `Tenant` and `Engineer` Party kinds declared with `provider do` that express which instances they operate and manage +- A `DataCentre` Place kind that declares the instances located at it -`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. +`BaseParty` and `BasePlace` follow the same `provider do` pattern as `BaseInstance` — domain-specific resources use them as fragments, write their own actions for domain-specific attributes, and declare their roles via the unified DSL sections. -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`. +The full DSL reference is at [DSL-Diffo.Provider.Extension](https://hexdocs.pm/diffo/DSL-Diffo.Provider.Extension.html). 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/documentation/how_to/use_diffo_provider_versioning.livemd b/documentation/how_to/use_diffo_provider_versioning.livemd index b25e679..80e509a 100644 --- a/documentation/how_to/use_diffo_provider_versioning.livemd +++ b/documentation/how_to/use_diffo_provider_versioning.livemd @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:diffo, "~> 0.2.1"} + {:diffo, "~> 0.3.0"} ], config: [ diffo: [ash_domains: [Diffo.Provider]] diff --git a/documentation/how_to/use_diffo_type.livemd b/documentation/how_to/use_diffo_type.livemd index 2be0455..222b421 100644 --- a/documentation/how_to/use_diffo_type.livemd +++ b/documentation/how_to/use_diffo_type.livemd @@ -9,7 +9,7 @@ SPDX-License-Identifier: MIT ```elixir Mix.install( [ - {:diffo, "~> 0.2.1"} + {:diffo, "~> 0.3.0"} ], consolidate_protocols: false ) diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index 74987a4..65d16a6 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -77,6 +77,19 @@ defmodule Diffo.Provider do define :delete_relationship, action: :destroy end + resource Diffo.Provider.AssignedToRelationship do + define :create_assigned_to_relationship, action: :create_assignment + define :get_assigned_to_relationship_by_id, action: :read, get_by: :id + define :delete_assigned_to_relationship, action: :destroy + end + + resource Diffo.Provider.AssignableCharacteristic do + define :create_assignable_characteristic, action: :create + define :get_assignable_characteristic_by_id, action: :read, get_by: :id + define :update_assignable_characteristic, action: :update + define :delete_assignable_characteristic, action: :destroy + end + resource Diffo.Provider.Characteristic do define :create_characteristic, action: :create define :get_characteristic_by_id, action: :read, get_by: :id diff --git a/lib/diffo/provider/assigner/assignable_characteristic.ex b/lib/diffo/provider/assigner/assignable_characteristic.ex new file mode 100644 index 0000000..3e4b568 --- /dev/null +++ b/lib/diffo/provider/assigner/assignable_characteristic.ex @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.AssignableCharacteristic do + @moduledoc """ + Typed characteristic carrying pool bounds and assignment algorithm. + + Replaces the `AssignableValue` TypedStruct. Stored as a proper Neo4j node + via `BaseCharacteristic`, with direct attributes rather than a wrapped + `Diffo.Type.Value` dynamic. The `free` count is not stored here — it is + derived from the count of `assignedTo` Relationship records (Phase 4). + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: Diffo.Provider + + resource do + description "Typed characteristic carrying pool assignment bounds and algorithm" + plural_name :assignable_characteristics + end + + attributes do + attribute :first, :integer do + description "the first assignable value in the pool" + public? true + default 1 + constraints min: 0 + end + + attribute :last, :integer do + description "the last assignable value in the pool" + public? true + default 1 + constraints min: 0 + end + + attribute :assignable_type, :string do + description "the type label of the assignable thing (e.g. \"ADSL2+\")" + public? true + allow_nil? true + end + + attribute :algorithm, :atom do + description "the selection algorithm for auto-assign" + public? true + default :lowest + constraints one_of: [:lowest, :highest, :random] + end + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + + calculate :assigned_values, {:array, :integer}, + Diffo.Provider.Calculations.AssignedValues do + public? true + argument :thing, :atom, allow_nil?: false + end + end + + actions do + create :create do + accept [:name, :first, :last, :assignable_type, :algorithm] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:first, :last, :assignable_type, :algorithm] + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end diff --git a/lib/diffo/provider/assigner/assignable_characteristic/value.ex b/lib/diffo/provider/assigner/assignable_characteristic/value.ex new file mode 100644 index 0000000..10f7d63 --- /dev/null +++ b/lib/diffo/provider/assigner/assignable_characteristic/value.ex @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.AssignableCharacteristic.Value do + @moduledoc "JSON value struct for AssignableCharacteristic." + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + typed_struct do + field :first, :integer, description: "the first assignable value in the pool" + field :last, :integer, description: "the last assignable value in the pool" + field :assignable_type, :string, description: "the type label of the assignable thing" + field :algorithm, :atom, description: "the selection algorithm for auto-assign" + end + + jason do + pick [:first, :last, :assignable_type, :algorithm] + compact true + rename assignable_type: :type + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/assigner/assigned_to_relationship.ex b/lib/diffo/provider/assigner/assigned_to_relationship.ex new file mode 100644 index 0000000..1926d88 --- /dev/null +++ b/lib/diffo/provider/assigner/assigned_to_relationship.ex @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.AssignedToRelationship do + @moduledoc """ + Ash Resource for a pool assignment relationship. + + Carries the assignment attributes (`pool`, `thing`, `assigned`) that link a + source instance to an assignee instance. Stored as an `:AssignedToRelationship` + Neo4j node, distinct from the `:Relationship` nodes used for TMF service/resource + relationships. Accessible on an instance via `instance.assignments`. + + Created by `Diffo.Provider.Assigner` via `Diffo.Provider.create_assigned_to_relationship/1`. + """ + use Ash.Resource, + fragments: [Diffo.Provider.BaseRelationship], + otp_app: :diffo, + domain: Diffo.Provider + + resource do + description "An Ash Resource for a pool assignment relationship" + plural_name :assigned_to_relationships + end + + neo4j do + relate [ + {:source, :RELATES, :incoming, :Instance}, + {:target, :RELATES, :outgoing, :Instance} + ] + end + + jason do + pick [:type] + + customize fn result, record -> + target_type = Map.get(record, :target_type) + + reference = %Diffo.Provider.Reference{ + id: record.target_id, + href: Map.get(record, :target_href) + } + + list_name = + Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) + + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.set(list_name, [%{name: record.thing, value: record.assigned}]) + end + + order [ + :type, + :service, + :resource, + :serviceRelationshipCharacteristic, + :resourceRelationshipCharacteristic + ] + end + + actions do + create :create_assignment do + description "creates an assignedTo relationship with pool/thing/assigned attributes" + accept [:pool, :thing, :assigned] + + argument :source_id, :uuid + argument :target_id, :string + + change set_attribute(:type, :assignedTo) + change manage_relationship(:source_id, :source, type: :append) + change manage_relationship(:target_id, :target, type: :append) + change Diffo.Changes.DetailRelationship + end + end + + attributes do + attribute :pool, :atom do + description "the pool name on the source instance" + allow_nil? true + public? true + end + + attribute :thing, :atom do + description "the kind of thing being assigned within the pool" + allow_nil? true + public? true + end + + attribute :assigned, :integer do + description "the assigned value from the pool" + allow_nil? true + public? true + end + end + + identities do + identity :unique_assignment, [:source_id, :target_id, :pool, :thing, :assigned] + end + + preparations do + prepare build(sort: [created_at: :asc]) + end +end diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 32b47e2..daf5d39 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -4,16 +4,31 @@ defmodule Diffo.Provider.Assigner do @moduledoc """ - Helper to perform Assignment maintaining AssignableValue + Helper to perform Assignment using `Diffo.Provider.AssignedToRelationship`. + + Assignment state is stored on `AssignedToRelationship` nodes (pool, thing, assigned), + distinct from regular TMF `Diffo.Provider.Relationship` nodes. """ - alias Diffo.Provider.AssignableValue - alias Diffo.Type.Value + alias Diffo.Provider.AssignableCharacteristic + alias Diffo.Provider.AssignedToRelationship @doc """ - Assign a thing using the instance changeset assignment + Assign a thing using the pool declared via `pools do` on the instance module. + The thing name is looked up from the pool declaration. """ - def assign(result, changeset, things, thing) - when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(things) and + def assign(result, changeset, pool_name) + when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(pool_name) do + case result.__struct__.pool(pool_name) do + nil -> {:error, "pool #{pool_name} not declared on #{result.__struct__}"} + pool -> assign(result, changeset, pool_name, pool.thing) + end + end + + @doc """ + Assign a thing using the instance changeset assignment. + """ + def assign(result, changeset, pool, thing) + when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_atom(pool) and is_atom(thing) do assignment = Map.get(changeset.arguments, :assignment, %{}) assignee_id = Map.get(assignment, :assignee_id) @@ -25,241 +40,121 @@ defmodule Diffo.Provider.Assigner do _ -> case Map.get(assignment, :operation, :auto_assign) do :auto_assign -> - case next(result, things, thing) do + case next(result, pool, thing) do {:ok, assigned} -> - relate_is_assigned(result, things, thing, assigned, assignee_id) + relate_is_assigned(result, pool, thing, assigned, assignee_id) {:error, error} -> {:error, error} end :assign -> - case assignable?(result, things, thing, assignment.id) do + case assignable?(result, pool, thing, assignment.id) do true -> - relate_is_assigned(result, things, thing, assignment.id, assignee_id) + relate_is_assigned(result, pool, thing, assignment.id, assignee_id) false -> {:error, "#{thing} #{assignment.id} is not assignable"} end :unassign -> - unrelate_is_assigned(result, things, thing, assignment.id, assignee_id) + unrelate_is_assigned(result, pool, thing, assignment.id, assignee_id) end end end - defp relate_is_assigned(result, things, thing, value, assignee_id) - when is_struct(result) and is_atom(things) and is_atom(thing) and is_integer(value) and + defp relate_is_assigned(result, pool, thing, value, assignee_id) + when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do - case Diffo.Provider.create_characteristic(%{ - name: thing, - value: Value.primitive("integer", value), - type: :relationship + case Diffo.Provider.create_assigned_to_relationship(%{ + pool: pool, + thing: thing, + assigned: value, + source_id: result.id, + target_id: assignee_id }) do - {:ok, characteristic} -> - case Diffo.Provider.create_relationship(%{ - type: :assignedTo, - source_id: result.id, - target_id: assignee_id, - characteristics: [characteristic.id] - }) do - {:ok, _relationship} -> - case decrement_free(result, things) do - :ok -> - {:ok, result} - - {:error, error} -> - {:error, error} - end - - {:error, error} -> - {:error, error} - end + {:ok, _relationship} -> + {:ok, result} {:error, error} -> {:error, error} end end - defp unrelate_is_assigned(result, things, thing, value, assignee_id) - when is_struct(result) and is_atom(things) and is_atom(thing) and is_integer(value) and + defp unrelate_is_assigned(result, pool, thing, value, assignee_id) + when is_struct(result) and is_atom(pool) and is_atom(thing) and is_integer(value) and is_bitstring(assignee_id) do - relationships = - Enum.filter(result.forward_relationships, fn %{ - type: type, - target_id: target_id, - characteristics: characteristics - } -> - type == :assignedTo and target_id == assignee_id and - Enum.any?(characteristics, fn %{name: name, value: v} -> - name == thing and Diffo.Unwrap.unwrap(v) == value - end) - end) - - case length(relationships) do - 0 -> + case find_assignment(result.id, assignee_id, pool, thing, value) do + {:ok, nil} -> {:error, "#{thing} #{value} is not assigned to assignee #{assignee_id}"} - 1 -> - relationship = hd(relationships) - - characteristic = - Enum.find(relationship.characteristics, fn %{name: n} -> n == thing end) - - relationship = - Diffo.Provider.unrelate_relationship_characteristics!(relationship, %{ - characteristics: [characteristic.id] - }) - - Diffo.Provider.delete_characteristic(characteristic.id) - - case Diffo.Provider.delete_relationship(relationship.id) do + {:ok, relationship} -> + case Ash.destroy(relationship, domain: Diffo.Provider) do :ok -> - case increment_free(result, things) do - :ok -> - {:ok, result} - - {:error, error} -> - {:error, error} - end + {:ok, result} {:error, error} -> {:error, error} end - _ -> - {:error, "multiple relationships found for #{thing} #{value} and assignee #{assignee_id}"} + {:error, error} -> + {:error, error} end end - defp assignments(instance, thing) when is_struct(instance) and is_atom(thing) do - Enum.reduce(instance.forward_relationships, [], fn %{ - type: type, - characteristics: characteristics, - target_id: target_id - }, - acc -> - case type do - :assignedTo -> - characteristic = Enum.find(characteristics, fn %{name: n} -> n == thing end) - - if characteristic do - assignment = - struct(Diffo.Provider.Assignment, %{ - id: Diffo.Unwrap.unwrap(characteristic.value), - assignable_type: thing, - assignee_id: target_id - }) - - [assignment | acc] - else - acc - end - - _ -> - acc - end - end) - |> Enum.sort(Diffo.Provider.Assignment) + defp find_assignment(source_id, target_id, pool, thing, value) do + AssignedToRelationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: source_id, + target_id: target_id, + pool: pool, + thing: thing, + assigned: value + ) + |> Ash.read_one(domain: Diffo.Provider) end - defp next(instance, things, thing) - when is_struct(instance) and is_atom(things) and is_atom(thing) do - characteristic = Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - algorithm = Map.get(assignable_value, :algorithm) + defp next(instance, pool, thing) + when is_struct(instance) and is_atom(pool) and is_atom(thing) do + case pool_characteristic(instance.id, pool, thing) do + {:ok, nil} -> + {:error, "pool #{pool} not found on instance #{instance.id}"} - case free = free(instance, thing, assignable_value) do - [] -> - {:error, "all things are assigned"} - - _ -> - case algorithm do - :lowest -> - {:ok, hd(free)} + {:ok, char} -> + free = Enum.to_list(char.first..char.last) -- char.assigned_values - :random -> - {:ok, Enum.random(free)} + case free do + [] -> + {:error, "all things are assigned"} - :highest -> - {:ok, List.last(free)} + _ -> + case char.algorithm do + :lowest -> {:ok, hd(free)} + :random -> {:ok, Enum.random(free)} + :highest -> {:ok, List.last(free)} + end end - end - end - - defp assignable?(instance, things, thing, value) - when is_struct(instance) and is_atom(things) and is_atom(thing) and is_integer(value) do - characteristic = Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - free = free(instance, thing, assignable_value) - - value in free - end - - defp decrement_free(instance, things) when is_struct(instance) and is_atom(things) do - characteristic = - Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - - {_free, updated} = - Map.get_and_update(assignable_value, :free, fn free -> {free - 1, free - 1} end) - - {:ok, new_struct} = - Ash.Type.cast_input( - AssignableValue, - Map.from_struct(updated), - AssignableValue.subtype_constraints() - ) - - new_value = Value.dynamic(new_struct) - - case Diffo.Provider.update_characteristic(characteristic, %{value: new_value}) do - {:ok, _characteristic} -> - :ok {:error, error} -> {:error, error} end end - defp increment_free(instance, things) when is_struct(instance) and is_atom(things) do - characteristic = - Enum.find(instance.characteristics, fn %{name: name} -> name == things end) - - assignable_value = Diffo.Unwrap.unwrap(characteristic.value) - - {_free, updated} = - Map.get_and_update(assignable_value, :free, fn free -> {free + 1, free + 1} end) - - {:ok, new_struct} = - Ash.Type.cast_input( - AssignableValue, - Map.from_struct(updated), - AssignableValue.subtype_constraints() - ) - - new_value = Value.dynamic(new_struct) - - case Diffo.Provider.update_characteristic(characteristic, %{value: new_value}) do - {:ok, _characteristic} -> - :ok - - {:error, error} -> - {:error, error} + defp assignable?(instance, pool, thing, value) + when is_struct(instance) and is_atom(pool) and is_atom(thing) and is_integer(value) do + case pool_characteristic(instance.id, pool, thing) do + {:ok, nil} -> false + {:ok, char} -> value in Enum.to_list(char.first..char.last) -- char.assigned_values + {:error, _} -> false end end - defp free(instance, thing, assignable_value) - when is_struct(instance) and is_atom(thing) and - is_struct(assignable_value, AssignableValue) do - assigned = - assignments(instance, thing) - |> Enum.into([], &Map.get(&1, :id)) - - first = Map.get(assignable_value, :first) - last = Map.get(assignable_value, :last) - - Enum.to_list(first..last) -- assigned + defp pool_characteristic(instance_id, pool, thing) do + AssignableCharacteristic + |> Ash.Query.new() + |> Ash.Query.filter_input(instance_id: instance_id, name: pool) + |> Ash.Query.load(assigned_values: [thing: thing]) + |> Ash.read_one(domain: Diffo.Provider) end end diff --git a/lib/diffo/provider/components/base_characteristic.ex b/lib/diffo/provider/components/base_characteristic.ex new file mode 100644 index 0000000..6e4118b --- /dev/null +++ b/lib/diffo/provider/components/base_characteristic.ex @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.BaseCharacteristic do + @moduledoc """ + Ash Resource Fragment which is the point of extension for typed TMF Characteristics. + + `BaseCharacteristic` is the foundation for domain-specific Characteristic kinds. + Include it as a fragment on an `Ash.Resource` to get a typed characteristic node + in Neo4j with real Ash attributes — no `Ash.Type.Dynamic` required. + + `Diffo.Provider.Characteristic` remains available as the generic dynamic option + (storing values via `Diffo.Type.Value`); it includes `Characteristic.Extension` so + the DSL verifier accepts it alongside typed resources. + + ## Usage + + defmodule MyApp.CircuitCharacteristic do + use Ash.Resource, fragments: [BaseCharacteristic], domain: MyApp.Domain + + attributes do + attribute :bandwidth_mbps, :integer, public?: true + attribute :technology, :atom, public?: true + end + + actions do + create :create do + accept [:name, :bandwidth_mbps, :technology] + argument :instance_id, :uuid + argument :feature_id, :uuid + end + + update :update do + accept [:bandwidth_mbps, :technology] + end + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end + end + + ## DSL declaration + + provider do + characteristics do + characteristic :circuit, MyApp.CircuitCharacteristic + end + end + + At build time a `CircuitCharacteristic` node is created and connected to the + instance via an `:HAS` edge. The `name` attribute (e.g. `:circuit`) identifies + the characteristic's role on the instance. + + ## Typed vs dynamic + + | Style | DSL target | Neo4j node | Value storage | + |-------|-----------|------------|---------------| + | Typed | `BaseCharacteristic`-derived | per-type label (e.g. `:CircuitCharacteristic`) | direct Ash attributes | + | Dynamic | `Diffo.Provider.Characteristic` | `:Characteristic` | `Diffo.Type.Value` dynamic | + """ + use Spark.Dsl.Fragment, + of: Ash.Resource, + otp_app: :diffo, + domain: Diffo.Provider, + data_layer: AshNeo4j.DataLayer, + extensions: [ + AshJason.Resource, + Diffo.Provider.Characteristic.Extension + ] + + + neo4j do + relate [ + {:instance, :HAS, :incoming, :Instance}, + {:feature, :HAS, :incoming, :Feature} + ] + + guard [ + {:HAS, :incoming, :Instance}, + {:HAS, :incoming, :Feature} + ] + end + + attributes do + uuid_primary_key :id do + public? false + end + + attribute :name, :atom do + description "the role name of this characteristic on the owning instance or feature" + allow_nil? false + public? true + end + + create_timestamp :created_at + update_timestamp :updated_at + end + + relationships do + belongs_to :instance, Diffo.Provider.Instance do + allow_nil? true + public? true + end + + belongs_to :feature, Diffo.Provider.Feature do + allow_nil? true + public? true + end + end + + validations do + validate present([:instance_id, :feature_id], at_most: 1) do + message "characteristic must belong to at most one of an instance or feature" + end + end + + actions do + defaults [:read, :destroy] + end +end diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 463856b..13e6b90 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -174,6 +174,7 @@ defmodule Diffo.Provider.BaseInstance do AshOutstanding.Resource, AshJason.Resource, AshStateMachine, + Diffo.Provider.Extension, Diffo.Provider.Instance.Extension ] @@ -187,6 +188,7 @@ defmodule Diffo.Provider.BaseInstance do {:process_statuses, :STATUSES, :incoming, :ProcessStatus}, {:forward_relationships, :RELATES, :outgoing, :Relationship}, {:reverse_relationships, :RELATES, :incoming, :Relationship}, + {:assignments, :RELATES, :outgoing, :AssignedToRelationship}, {:features, :HAS, :outgoing, :Feature}, {:characteristics, :HAS, :outgoing, :Characteristic}, {:entities, :RELATES, :outgoing, :EntityRef}, @@ -208,6 +210,7 @@ defmodule Diffo.Provider.BaseInstance do :specification, :process_statuses, :forward_relationships, + :assignments, :features, :characteristics, :entities, @@ -406,6 +409,12 @@ defmodule Diffo.Provider.BaseInstance do public? true end + has_many :assignments, Diffo.Provider.AssignedToRelationship do + description "the instance's outgoing pool assignment relationships" + destination_attribute :source_id + public? true + end + has_many :features, Diffo.Provider.Feature do description "the instance's collection of defining features" public? true @@ -654,6 +663,7 @@ defmodule Diffo.Provider.BaseInstance do :specification, :process_statuses, :forward_relationships, + :assignments, :entities, :notes, :features, diff --git a/lib/diffo/provider/components/base_party.ex b/lib/diffo/provider/components/base_party.ex index 5840ecc..054fa2d 100644 --- a/lib/diffo/provider/components/base_party.ex +++ b/lib/diffo/provider/components/base_party.ex @@ -120,6 +120,7 @@ defmodule Diffo.Provider.BaseParty do extensions: [ AshOutstanding.Resource, AshJason.Resource, + Diffo.Provider.Extension, Diffo.Provider.Party.Extension ] diff --git a/lib/diffo/provider/components/base_place.ex b/lib/diffo/provider/components/base_place.ex index f29005c..a9f5a74 100644 --- a/lib/diffo/provider/components/base_place.ex +++ b/lib/diffo/provider/components/base_place.ex @@ -93,6 +93,7 @@ defmodule Diffo.Provider.BasePlace do extensions: [ AshOutstanding.Resource, AshJason.Resource, + Diffo.Provider.Extension, Diffo.Provider.Place.Extension ] diff --git a/lib/diffo/provider/components/base_relationship.ex b/lib/diffo/provider/components/base_relationship.ex new file mode 100644 index 0000000..9de441b --- /dev/null +++ b/lib/diffo/provider/components/base_relationship.ex @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.BaseRelationship do + @moduledoc """ + Ash Resource Fragment which is the shared foundation for TMF Relationship resources. + + Provides the common attributes, relationships, validations, and actions shared + between `Diffo.Provider.Relationship` (TMF service/resource relationships) and + `Diffo.Provider.AssignedToRelationship` (pool assignment relationships). + + ## Common attributes + + - `id` — uuid4 primary key + - `type` — relationship type atom + - `target_href` — denormalised target href (set by the `DetailRelationship` change) + - `target_type` — denormalised target type (`:service` or `:resource`) + - `created_at`, `updated_at` — timestamps + + ## Common Ash relationships + + - `belongs_to :source, Diffo.Provider.Instance` + - `belongs_to :target, Diffo.Provider.Instance` + """ + use Spark.Dsl.Fragment, + of: Ash.Resource, + otp_app: :diffo, + domain: Diffo.Provider, + data_layer: AshNeo4j.DataLayer, + extensions: [AshJason.Resource] + + attributes do + uuid_primary_key :id do + description "a uuid4, unique to this relationship, generated by default" + public? true + end + + attribute :type, :atom do + description "the type of the relationship from the source to the target" + allow_nil? false + public? true + end + + attribute :target_href, :string do + description "the target href, denormalised from the target instance" + allow_nil? true + writable? false + public? true + end + + attribute :target_type, :atom do + description "the target type, denormalised from the target instance" + allow_nil? true + writable? false + public? true + end + + create_timestamp :created_at + update_timestamp :updated_at + end + + relationships do + belongs_to :source, Diffo.Provider.Instance do + description "the source instance which originates this relationship" + allow_nil? false + public? true + end + + belongs_to :target, Diffo.Provider.Instance do + description "the target instance which is the destination of this relationship" + allow_nil? false + public? true + end + end + + validations do + validate {Diffo.Validations.IsUuid4OrNil, attribute: :source_id}, on: :create + validate {Diffo.Validations.IsUuid4OrNil, attribute: :target_id}, on: :create + end + + actions do + defaults [:read, :destroy] + end +end diff --git a/lib/diffo/provider/components/calculations/assigned_values.ex b/lib/diffo/provider/components/calculations/assigned_values.ex new file mode 100644 index 0000000..6ac879f --- /dev/null +++ b/lib/diffo/provider/components/calculations/assigned_values.ex @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.AssignedValues do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, _opts, context) do + thing = context.arguments[:thing] + + Enum.map(records, fn record -> + Diffo.Provider.AssignedToRelationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: record.instance_id, + pool: record.name, + thing: thing + ) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.assigned) + end) + end +end diff --git a/lib/diffo/provider/components/calculations/characteristic_value.ex b/lib/diffo/provider/components/calculations/characteristic_value.ex new file mode 100644 index 0000000..e870d88 --- /dev/null +++ b/lib/diffo/provider/components/calculations/characteristic_value.ex @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Calculations.CharacteristicValue do + @moduledoc false + use Ash.Resource.Calculation + + @impl true + def load(_query, _opts, _context), do: [] + + @impl true + def calculate(records, _opts, _context) do + Enum.map(records, fn record -> + value_module = Module.concat(record.__struct__, :Value) + field_names = value_module |> struct() |> Map.from_struct() |> Map.keys() + struct(value_module, Map.take(Map.from_struct(record), field_names)) + end) + end +end diff --git a/lib/diffo/provider/components/characteristic.ex b/lib/diffo/provider/components/characteristic.ex index 64f49dd..53b67e2 100644 --- a/lib/diffo/provider/components/characteristic.ex +++ b/lib/diffo/provider/components/characteristic.ex @@ -10,7 +10,7 @@ defmodule Diffo.Provider.Characteristic do otp_app: :diffo, domain: Diffo.Provider, data_layer: AshNeo4j.DataLayer, - extensions: [AshOutstanding.Resource, AshJason.Resource] + extensions: [AshOutstanding.Resource, AshJason.Resource, Diffo.Provider.Characteristic.Extension] resource do description "An Ash Resource for a TMF Characteristic" diff --git a/lib/diffo/provider/components/characteristic/extension.ex b/lib/diffo/provider/components/characteristic/extension.ex new file mode 100644 index 0000000..a5f4046 --- /dev/null +++ b/lib/diffo/provider/components/characteristic/extension.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Characteristic.Extension do + @moduledoc "Marker extension identifying a module as a valid characteristic resource." + use Spark.Dsl.Extension, sections: [] +end diff --git a/lib/diffo/provider/components/instance/extension.ex b/lib/diffo/provider/components/instance/extension.ex index 9fc716c..6dfabdb 100644 --- a/lib/diffo/provider/components/instance/extension.ex +++ b/lib/diffo/provider/components/instance/extension.ex @@ -3,414 +3,6 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Instance.Extension do - @moduledoc """ - DSL Extension customising an Instance. - - Provides two top-level sections: - - ## structure - - Describes the static shape of the Instance kind — what it is, what values it carries, - and what parties it relates to. All structure declarations are baked into the resource - module at compile time via persisters and are introspectable at runtime via - `Diffo.Provider.Instance.Info` or directly as generated functions on the resource module. - - - `specification do` — the TMF Specification (id, name, type, version, description, category). - The id is a stable UUID4 that is the same across all environments for this Instance kind. - - `characteristics do` — typed value slots carried by instances of this kind, each backed - by an `Ash.TypedStruct`. - - `features do` — optional capabilities of this kind, each with its own typed characteristic - 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 - - Declares which Ash actions should be wired for instance build lifecycle management. - Currently supports `create` declarations; future sections will cover triggers and other - lifecycle concerns. - - Declaring `create :name` in `behaviour do actions do` causes the `TransformBehaviour` - transformer to inject `:specified_by`, `:features`, and `:characteristics` arguments onto - the named Ash create action. These arguments carry the UUIDs of the TMF entities created - by `build_before/1` and consumed by the Ash relationship management in the action. - - See the [DSL cheat sheet](DSL-Diffo.Provider.Instance.Extension.html) for the full DSL reference. - See `Diffo.Provider.BaseInstance` for full usage documentation including generated functions. - """ - - # ── structure ────────────────────────────────────────────────────────────── - - @specification %Spark.Dsl.Section{ - name: :specification, - describe: "Defines the Instance Specification", - examples: [ - """ - specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - major_version 1 - description "An access network service connecting a subscriber premises to an access NNI via DSL" - category "Network Service" - end - """ - ], - schema: [ - id: [ - type: :string, - doc: - "The id of the specification, a uuid4 the same in all environments, unique for name and major_version.", - required: true - ], - name: [ - type: :string, - doc: "The name of the specification, unique to a service but common for all versions.", - required: true - ], - type: [ - type: :atom, - doc: "The type of the specification.", - default: :serviceSpecification - ], - major_version: [ - type: :integer, - doc: "The major_version of the specification.", - default: 1 - ], - minor_version: [ - type: :integer, - doc: "The minor_version of the specification." - ], - patch_version: [ - type: :integer, - doc: "The patch_version of the specification." - ], - tmf_version: [ - type: :integer, - doc: "The TMF API version of the specification, e.g. 4." - ], - description: [ - type: :string, - doc: "A generic description of the specified service or resource." - ], - category: [ - type: :string, - doc: "The category the specified service or resource belongs to." - ] - ] - } - - @characteristic %Spark.Dsl.Entity{ - name: :characteristic, - describe: "Adds a Characteristic", - target: Diffo.Provider.Instance.Characteristic, - args: [:name, :value_type], - schema: [ - name: [ - doc: "The name of the characteristic, an atom", - type: :atom, - required: true - ], - value_type: [ - doc: - "The type of the characteristic's value. An atom module name such as an Ash.TypedStruct for a scalar value, or `{:array, module}` for an array of values of that type.", - type: :any - ] - ] - } - - @characteristics %Spark.Dsl.Section{ - name: :characteristics, - describe: "List of Instance Characteristics", - examples: [ - """ - characteristics do - characteristic :dslam, Diffo.Access.Dslam - characteristic :aggregate_interface, Diffo.Access.AggregateInterface - characteristic :circuit, Diffo.Access.Circuit - characteristic :line, Diffo.Access.Line - end - """ - ], - entities: [@characteristic] - } - - @feature %Spark.Dsl.Entity{ - name: :feature, - describe: "Adds a Feature", - target: Diffo.Provider.Instance.Feature, - args: [:name], - schema: [ - name: [ - doc: "The name of the feature, an atom", - type: :atom, - required: true - ], - is_enabled?: [ - doc: "Whether the feature is enabled by default, defaults true", - type: :boolean - ] - ], - entities: [ - characteristics: [@characteristic] - ] - } - - @features %Spark.Dsl.Section{ - name: :features, - describe: "Configuration for Instance Features", - examples: [ - """ - features do - feature :dynamic_line_management do - is_enabled? true - characteristics do - characteristic :constraints, Diffo.Access.Constraints - end - end - end - """ - ], - entities: [@feature] - } - - @party_schema [ - role: [ - doc: "The role name, an atom", - type: :atom, - required: true - ], - party_type: [ - doc: - "The module of the Party kind. An atom module name such as a BaseParty-derived resource.", - type: :any - ], - reference: [ - doc: - "If true, no direct PartyRef edge is created; the party is reachable by graph traversal.", - type: :boolean, - default: false - ], - calculate: [ - doc: "Name of an Ash calculation on this resource that produces the party at build time.", - type: :atom - ] - ] - - @party_entity %Spark.Dsl.Entity{ - name: :party, - describe: "Declares a singular party role on this Instance", - target: Diffo.Provider.Instance.Extension.PartyDeclaration, - args: [:role, :party_type], - auto_set_fields: [multiple: false], - schema: @party_schema - } - - @parties_entity %Spark.Dsl.Entity{ - name: :parties, - describe: "Declares a plural party role on this Instance", - target: Diffo.Provider.Instance.Extension.PartyDeclaration, - args: [:role, :party_type], - auto_set_fields: [multiple: true], - schema: - @party_schema ++ - [ - constraints: [ - doc: - "Multiplicity constraints on the number of parties in this role, e.g. [min: 1, max: 3]", - type: :keyword_list - ] - ] - } - - @parties %Spark.Dsl.Section{ - name: :parties, - describe: "List of Instance Party roles", - examples: [ - """ - parties do - party :provider, MyApp.Provider, calculate: :provider_calculation - parties :technician, MyApp.Technician, constraints: [min: 1, max: 3] - party :owner, MyApp.InfrastructureCo, reference: true - end - """ - ], - 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, parties, and places", - examples: [ - """ - structure do - specification do - id "da9b207a-26c3-451d-8abd-0640c6349979" - name "DSL Access Service" - type :serviceSpecification - end - - characteristics do - characteristic :circuit, Diffo.Access.Circuit - end - - parties do - party :provider, MyApp.Provider - end - - places do - place :installation_site, MyApp.GeographicSite - end - end - """ - ], - sections: [@specification, @characteristics, @features, @parties, @places] - } - - # ── behaviour ────────────────────────────────────────────────────────────── - - @action_create %Spark.Dsl.Entity{ - name: :create, - describe: - "Marks a create action for instance build wiring, injecting :specified_by, :features, and :characteristics arguments", - target: Diffo.Provider.Instance.Extension.ActionCreate, - args: [:name], - schema: [ - name: [ - type: :atom, - required: true, - doc: "The name of the create action to wire" - ] - ] - } - - @action_update %Spark.Dsl.Entity{ - name: :update, - describe: "Marks an update action for instance behaviour wiring", - target: Diffo.Provider.Instance.Extension.ActionUpdate, - args: [:name], - schema: [ - name: [ - type: :atom, - required: true, - doc: "The name of the update action to wire" - ] - ] - } - - @behaviour_actions %Spark.Dsl.Section{ - name: :actions, - describe: "Declares which actions to wire for instance behaviour", - examples: [ - """ - actions do - create :build - update :define - end - """ - ], - entities: [@action_create, @action_update] - } - - @behaviour_section %Spark.Dsl.Section{ - name: :behaviour, - describe: - "Defines the behavioural wiring for the Instance — actions, and in future triggers and tasks", - examples: [ - """ - behaviour do - actions do - create :build - update :define - end - end - """ - ], - sections: [@behaviour_actions] - } - - use Spark.Dsl.Extension, - sections: [@structure, @behaviour_section], - persisters: [ - Diffo.Provider.Instance.Extension.Persisters.PersistSpecification, - 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: [ - Diffo.Provider.Instance.Extension.Verifiers.VerifySpecification, - Diffo.Provider.Instance.Extension.Verifiers.VerifyCharacteristics, - Diffo.Provider.Instance.Extension.Verifiers.VerifyFeatures, - Diffo.Provider.Instance.Extension.Verifiers.VerifyParties, - Diffo.Provider.Instance.Extension.Verifiers.VerifyBehaviour - ] + @moduledoc "Marker extension — identifies BaseInstance-derived resources. DSL is in `Diffo.Provider.Extension`." + use Spark.Dsl.Extension, sections: [] end diff --git a/lib/diffo/provider/components/instance/extension/info.ex b/lib/diffo/provider/components/instance/extension/info.ex index 3356537..e43f86a 100644 --- a/lib/diffo/provider/components/instance/extension/info.ex +++ b/lib/diffo/provider/components/instance/extension/info.ex @@ -3,14 +3,21 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Instance.Extension.Info do - use Spark.InfoGenerator, - extension: Diffo.Provider.Instance.Extension, - sections: [:structure] + alias Diffo.Provider.Extension.Info, as: ExtInfo @doc "Returns true if the module is a BaseInstance-derived resource" @spec instance?(module()) :: boolean() - def instance?(module) do - Code.ensure_loaded?(module) and - Diffo.Provider.Instance.Extension in Ash.Resource.Info.extensions(module) - end + defdelegate instance?(module), to: ExtInfo + + @doc false + defdelegate structure_parties(module), to: ExtInfo, as: :provider_parties + + @doc false + defdelegate structure_places(module), to: ExtInfo, as: :provider_places + + @doc false + defdelegate structure_characteristics(module), to: ExtInfo, as: :provider_characteristics + + @doc false + defdelegate structure_features(module), to: ExtInfo, as: :provider_features end diff --git a/lib/diffo/provider/components/instance/util.ex b/lib/diffo/provider/components/instance/util.ex index 3092cf2..9460c6a 100644 --- a/lib/diffo/provider/components/instance/util.ex +++ b/lib/diffo/provider/components/instance/util.ex @@ -74,40 +74,40 @@ defmodule Diffo.Provider.Instance.Util do @doc false def relationships(result) do - if relationships = Diffo.Util.get(result, :forward_relationships) do + fwd = Diffo.Util.get(result, :forward_relationships) + asgn = Diffo.Util.get(result, :assignments) + + if fwd != nil or asgn != nil do + all_relationships = List.wrap(fwd) ++ List.wrap(asgn) + service_relationships = - relationships - |> Enum.filter(fn relationship -> - relationship.target != nil && relationship.target_type == :service + Enum.filter(all_relationships, fn rel -> + rel.target != nil && rel.target_type == :service end) resource_relationships = - relationships - |> Enum.filter(fn relationship -> - relationship.target != nil && relationship.target_type == :resource + Enum.filter(all_relationships, fn rel -> + rel.target != nil && rel.target_type == :resource end) supporting_services = service_relationships - |> Enum.filter(fn relationship -> - relationship.alias != nil - end) - |> Enum.into([], fn aliased -> + |> Enum.filter(fn rel -> Map.get(rel, :alias) != nil end) + |> Enum.map(fn aliased -> %Diffo.Provider.Reference{id: aliased.alias, href: Map.get(aliased, :target_href)} end) supporting_resources = resource_relationships - |> Enum.filter(fn relationship -> - relationship.alias != nil - end) - |> Enum.into([], fn aliased -> + |> Enum.filter(fn rel -> Map.get(rel, :alias) != nil end) + |> Enum.map(fn aliased -> %Diffo.Provider.Reference{id: aliased.alias, href: Map.get(aliased, :target_href)} end) result |> Diffo.Util.remove(:forward_relationships) |> Diffo.Util.remove(:reverse_relationships) + |> Diffo.Util.remove(:assignments) |> Diffo.Util.set(:serviceRelationship, service_relationships) |> Diffo.Util.set(:resourceRelationship, resource_relationships) |> Diffo.Util.set(:supportingService, supporting_services) @@ -116,6 +116,7 @@ defmodule Diffo.Provider.Instance.Util do result |> Diffo.Util.remove(:forward_relationships) |> Diffo.Util.remove(:reverse_relationships) + |> Diffo.Util.remove(:assignments) end end diff --git a/lib/diffo/provider/components/party/extension.ex b/lib/diffo/provider/components/party/extension.ex index a205402..d76fd37 100644 --- a/lib/diffo/provider/components/party/extension.ex +++ b/lib/diffo/provider/components/party/extension.ex @@ -3,116 +3,6 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Party.Extension do - @moduledoc """ - DSL Extension customising a Party. - - Provides compile-time declaration blocks for domain-specific Party kinds - built on `Diffo.Provider.BaseParty`. All declarations are introspectable via - `Diffo.Provider.Party.Extension.Info`. - - See the [DSL cheat sheet](DSL-Diffo.Provider.Party.Extension.html) for the full DSL reference. - """ - @role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Party kind plays", - target: Diffo.Provider.Party.Extension.InstanceRole, - 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 resource" - ] - ] - } - - @party_role %Spark.Dsl.Entity{ - name: :role, - describe: "Declares a role this Party kind plays with respect to other Parties", - target: Diffo.Provider.Party.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 kind" - ] - ] - } - - @instances %Spark.Dsl.Section{ - name: :instances, - describe: "Declares the roles this Party kind plays with respect to Instances", - examples: [ - """ - instances do - role :facilitates, MyApp.AccessService - end - """ - ], - entities: [@role] - } - - @parties %Spark.Dsl.Section{ - name: :parties, - describe: "Declares the roles this Party kind plays with respect to other Parties", - examples: [ - """ - parties do - role :managed_by, MyApp.Person - end - """ - ], - 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, @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 - ] + @moduledoc "Marker extension — identifies BaseParty-derived resources. DSL is in `Diffo.Provider.Extension`." + use Spark.Dsl.Extension, sections: [] end diff --git a/lib/diffo/provider/components/party/extension/info.ex b/lib/diffo/provider/components/party/extension/info.ex index 2ca0532..bdf1d9b 100644 --- a/lib/diffo/provider/components/party/extension/info.ex +++ b/lib/diffo/provider/components/party/extension/info.ex @@ -3,14 +3,18 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Party.Extension.Info do - use Spark.InfoGenerator, - extension: Diffo.Provider.Party.Extension, - sections: [:instances, :parties, :places] + alias Diffo.Provider.Extension.Info, as: ExtInfo @doc "Returns true if the module is a BaseParty-derived resource" @spec party?(module()) :: boolean() - def party?(module) do - Code.ensure_loaded?(module) and - Diffo.Provider.Party.Extension in Ash.Resource.Info.extensions(module) - end + defdelegate party?(module), to: ExtInfo + + @doc false + defdelegate instances(module), to: ExtInfo, as: :provider_instances + + @doc false + defdelegate parties(module), to: ExtInfo, as: :provider_parties + + @doc false + defdelegate places(module), to: ExtInfo, as: :provider_places end diff --git a/lib/diffo/provider/components/place/extension.ex b/lib/diffo/provider/components/place/extension.ex index 74df77a..ea07279 100644 --- a/lib/diffo/provider/components/place/extension.ex +++ b/lib/diffo/provider/components/place/extension.ex @@ -3,117 +3,6 @@ # 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. - """ - @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], - 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 - ] + @moduledoc "Marker extension — identifies BasePlace-derived resources. DSL is in `Diffo.Provider.Extension`." + 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 index 4023595..d319d70 100644 --- a/lib/diffo/provider/components/place/extension/info.ex +++ b/lib/diffo/provider/components/place/extension/info.ex @@ -3,14 +3,18 @@ # SPDX-License-Identifier: MIT defmodule Diffo.Provider.Place.Extension.Info do - use Spark.InfoGenerator, - extension: Diffo.Provider.Place.Extension, - sections: [:instances, :parties, :places] + alias Diffo.Provider.Extension.Info, as: ExtInfo @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 + defdelegate place?(module), to: ExtInfo + + @doc false + defdelegate instances(module), to: ExtInfo, as: :provider_instances + + @doc false + defdelegate parties(module), to: ExtInfo, as: :provider_parties + + @doc false + defdelegate places(module), to: ExtInfo, as: :provider_places end diff --git a/lib/diffo/provider/components/relationship.ex b/lib/diffo/provider/components/relationship.ex index 28ceb03..474c8d5 100644 --- a/lib/diffo/provider/components/relationship.ex +++ b/lib/diffo/provider/components/relationship.ex @@ -8,10 +8,10 @@ defmodule Diffo.Provider.Relationship do Ash Resource for a TMF Service or Resource Relationship """ use Ash.Resource, + fragments: [Diffo.Provider.BaseRelationship], + extensions: [AshOutstanding.Resource], otp_app: :diffo, - domain: Diffo.Provider, - data_layer: AshNeo4j.DataLayer, - extensions: [AshOutstanding.Resource, AshJason.Resource] + domain: Diffo.Provider resource do description "An Ash Resource for a TMF Service or Resource Relationship" @@ -65,8 +65,6 @@ defmodule Diffo.Provider.Relationship do end actions do - defaults [:read, :destroy] - create :create do description "creates a relationship between a source and target instance" accept [:source_id, :target_id, :type, :alias] @@ -117,55 +115,14 @@ defmodule Diffo.Provider.Relationship do end attributes do - uuid_primary_key :id do - description "a uuid4, unique to this instance, generated by default" - public? true - end - attribute :alias, :atom do description "the alias of this relationship, used for supporting service or resource" allow_nil? true public? true end - - attribute :type, :atom do - description "the type of the relationship from the source to the target" - allow_nil? false - public? true - end - - attribute :target_href, :string do - description "the target href" - allow_nil? true - writable? false - public? true - end - - attribute :target_type, :atom do - description "the target type" - allow_nil? true - writable? false - public? true - end - - create_timestamp :created_at - - update_timestamp :updated_at end relationships do - belongs_to :source, Diffo.Provider.Instance do - description "the source instance which relates to the target instance via this relationship" - allow_nil? false - public? true - end - - belongs_to :target, Diffo.Provider.Instance do - description "the target instance which is related from the source instance via this relationship" - allow_nil? false - public? true - end - has_many :characteristics, Diffo.Provider.Characteristic do description "the relationship's collection of defining characteristics" public? true @@ -176,21 +133,6 @@ defmodule Diffo.Provider.Relationship do identity :unique_source_and_target, [:source_id, :target_id] end - validations do - validate {Diffo.Validations.IsUuid4OrNil, attribute: :source_id}, on: :create - validate {Diffo.Validations.IsUuid4OrNil, attribute: :target_id}, on: :create - - # validate present(:alias) do - # on [:create, :update] - # where [one_of(:source_type, [:resource]), one_of(:target_type, [:service])] - # message "a resource cannot have a supporting service" - # end - - # validate {Diffo.Validations.RelatedResourcesDifferent, - # relationship: :characteristic, attribute: :name}, - # on: :update - end - preparations do prepare build( load: [:characteristics], diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex new file mode 100644 index 0000000..36a8e58 --- /dev/null +++ b/lib/diffo/provider/extension.ex @@ -0,0 +1,557 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension do + @moduledoc """ + Unified DSL extension for all Diffo provider resource kinds. + + Provides a single `provider do` section for Instance, Party, and Place kinds. + The sections within `provider do` are self-similar across kinds — each kind uses + the sections relevant to it, and verifiers enforce correct usage. + + ## Instance + + provider do + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + end + + characteristics do + characteristic :circuit, Diffo.Access.Circuit + end + + features do + feature :dynamic_line_management do + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end + end + + pools do + pool :ports, :port + end + + parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] + end + + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + behaviour do + actions do + create :build + end + end + end + + ## Party + + provider do + instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService + end + parties do + role :employer, MyApp.Person + end + places do + role :headquarters, MyApp.GeographicSite + end + end + + ## Place + + provider do + instances do + role :site_for, MyApp.AccessService + end + parties do + role :managed_by, MyApp.Organization + end + places do + role :within, MyApp.GeographicSite + end + end + + See `Diffo.Provider.Extension.Info` for runtime introspection. + See `Diffo.Provider.BaseInstance`, `Diffo.Provider.BaseParty`, `Diffo.Provider.BasePlace` + for full usage documentation. + """ + + alias Diffo.Provider.Extension.{ + ActionCreate, + ActionUpdate, + Characteristic, + Feature, + InstanceRole, + PartyDeclaration, + PartyRole, + PlaceDeclaration, + PlaceRole, + Pool + } + + # ── specification ────────────────────────────────────────────────────────── + + @specification %Spark.Dsl.Section{ + name: :specification, + describe: "Defines the Instance Specification", + examples: [ + """ + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "DSL Access Service" + type :serviceSpecification + major_version 1 + description "An access network service" + category "Network Service" + end + """ + ], + schema: [ + id: [ + type: :string, + doc: + "The id of the specification, a uuid4 the same in all environments, unique for name and major_version.", + required: true + ], + name: [ + type: :string, + doc: "The name of the specification.", + required: true + ], + type: [ + type: :atom, + doc: "The type of the specification.", + default: :serviceSpecification + ], + major_version: [ + type: :integer, + doc: "The major version of the specification.", + default: 1 + ], + minor_version: [ + type: :integer, + doc: "The minor version of the specification." + ], + patch_version: [ + type: :integer, + doc: "The patch version of the specification." + ], + tmf_version: [ + type: :integer, + doc: "The TMF API version of the specification, e.g. 4." + ], + description: [ + type: :string, + doc: "A generic description of the specified service or resource." + ], + category: [ + type: :string, + doc: "The category the specified service or resource belongs to." + ] + ] + } + + # ── characteristics ──────────────────────────────────────────────────────── + + @characteristic_entity %Spark.Dsl.Entity{ + name: :characteristic, + describe: "Adds a Characteristic", + target: Characteristic, + args: [:name, :value_type], + schema: [ + name: [ + type: :atom, + doc: "The name of the characteristic.", + required: true + ], + value_type: [ + type: :any, + doc: + "The type of the characteristic value — a module or `{:array, module}` for an array." + ] + ] + } + + @characteristics %Spark.Dsl.Section{ + name: :characteristics, + describe: "List of Instance Characteristics", + examples: [ + """ + characteristics do + characteristic :circuit, Diffo.Access.Circuit + characteristic :line, Diffo.Access.Line + end + """ + ], + entities: [@characteristic_entity] + } + + # ── features ─────────────────────────────────────────────────────────────── + + @feature_entity %Spark.Dsl.Entity{ + name: :feature, + describe: "Adds a Feature", + target: Feature, + args: [:name], + schema: [ + name: [ + type: :atom, + doc: "The name of the feature.", + required: true + ], + is_enabled?: [ + type: :boolean, + doc: "Whether the feature is enabled by default, defaults true." + ] + ], + entities: [ + characteristics: [@characteristic_entity] + ] + } + + @features %Spark.Dsl.Section{ + name: :features, + describe: "Configuration for Instance Features", + examples: [ + """ + features do + feature :dynamic_line_management do + is_enabled? true + characteristics do + characteristic :constraints, Diffo.Access.Constraints + end + end + end + """ + ], + entities: [@feature_entity] + } + + # ── parties ──────────────────────────────────────────────────────────────── + + @party_schema [ + role: [type: :atom, doc: "The role name.", required: true], + party_type: [type: :any, doc: "The module of the Party kind."], + calculate: [type: :atom, doc: "Ash calculation on this resource that produces the party."] + ] + + @party_entity %Spark.Dsl.Entity{ + name: :party, + describe: "Declares a singular party role on this Instance", + target: PartyDeclaration, + args: [:role, :party_type], + auto_set_fields: [multiple: false, reference: false], + schema: @party_schema + } + + @parties_entity %Spark.Dsl.Entity{ + name: :parties, + describe: "Declares a plural party role on this Instance", + target: PartyDeclaration, + args: [:role, :party_type], + auto_set_fields: [multiple: true, reference: false], + schema: + @party_schema ++ + [ + constraints: [ + type: :keyword_list, + doc: "Multiplicity constraints, e.g. [min: 1, max: 3]." + ] + ] + } + + @party_ref_entity %Spark.Dsl.Entity{ + name: :party_ref, + describe: + "Declares a singular reference party role — no direct PartyRef edge, reachable by graph traversal", + target: PartyDeclaration, + args: [:role, :party_type], + auto_set_fields: [multiple: false, reference: true], + schema: @party_schema + } + + @party_role_entity %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party or Place kind plays with respect to other Parties", + target: PartyRole, + args: [:role, :party_type], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + party_type: [type: :any, doc: "The module of the related Party kind."] + ] + } + + @parties %Spark.Dsl.Section{ + name: :parties, + describe: + "Party roles on this resource — `party`/`parties`/`party_ref` for Instance kinds; `role` for Party and Place kinds", + examples: [ + """ + # Instance + parties do + party :provider, MyApp.Provider + party_ref :owner, MyApp.InfrastructureCo + parties :technicians, MyApp.Technician, constraints: [min: 1] + end + + # Party or Place + parties do + role :employer, MyApp.Person + end + """ + ], + entities: [@party_entity, @parties_entity, @party_ref_entity, @party_role_entity] + } + + # ── places ───────────────────────────────────────────────────────────────── + + @place_schema [ + role: [type: :atom, doc: "The role name.", required: true], + place_type: [type: :any, doc: "The module of the Place kind."], + calculate: [type: :atom, doc: "Ash calculation on this resource that produces the place."] + ] + + @place_entity %Spark.Dsl.Entity{ + name: :place, + describe: "Declares a singular place role on this Instance", + target: PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: false, reference: false], + schema: @place_schema + } + + @places_entity %Spark.Dsl.Entity{ + name: :places, + describe: "Declares a plural place role on this Instance", + target: PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: true, reference: false], + schema: + @place_schema ++ + [ + constraints: [ + type: :keyword_list, + doc: "Multiplicity constraints, e.g. [min: 1, max: 3]." + ] + ] + } + + @place_ref_entity %Spark.Dsl.Entity{ + name: :place_ref, + describe: + "Declares a singular reference place role — no direct PlaceRef edge, reachable by graph traversal", + target: PlaceDeclaration, + args: [:role, :place_type], + auto_set_fields: [multiple: false, reference: true], + schema: @place_schema + } + + @place_role_entity %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party or Place kind plays with respect to Places", + target: PlaceRole, + args: [:role, :place_type], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + place_type: [type: :any, doc: "The module of the related Place kind."] + ] + } + + @places %Spark.Dsl.Section{ + name: :places, + describe: + "Place roles on this resource — `place`/`places`/`place_ref` for Instance kinds; `role` for Party and Place kinds", + examples: [ + """ + # Instance + places do + place :installation_site, MyApp.GeographicSite + place_ref :billing_address, MyApp.GeographicAddress + end + + # Party or Place + places do + role :headquarters, MyApp.GeographicSite + end + """ + ], + entities: [@place_entity, @places_entity, @place_ref_entity, @place_role_entity] + } + + # ── instances ────────────────────────────────────────────────────────────── + + @instance_role_entity %Spark.Dsl.Entity{ + name: :role, + describe: "Declares a role this Party or Place kind plays with respect to Instances", + target: InstanceRole, + args: [:role, :instance_type], + auto_set_fields: [reference: false], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + instance_type: [type: :any, doc: "The module of the related Instance kind."] + ] + } + + @instance_ref_entity %Spark.Dsl.Entity{ + name: :instance_ref, + describe: + "Declares a reference instance role — no direct edge created, reachable by graph traversal", + target: InstanceRole, + args: [:role, :instance_type], + auto_set_fields: [reference: true], + schema: [ + role: [type: :atom, doc: "The role name.", required: true], + instance_type: [type: :any, doc: "The module of the related Instance kind."] + ] + } + + @instances %Spark.Dsl.Section{ + name: :instances, + describe: "Declares the roles this Party or Place kind plays with respect to Instances", + examples: [ + """ + instances do + role :facilitates, MyApp.AccessService + instance_ref :manages, MyApp.InternalService + end + """ + ], + entities: [@instance_role_entity, @instance_ref_entity] + } + + # ── pools ────────────────────────────────────────────────────────────────── + + @pool_entity %Spark.Dsl.Entity{ + name: :pool, + describe: "Declares an assignable pool — a named range of values for auto-assignment", + target: Pool, + args: [:name, :thing], + schema: [ + name: [ + type: :atom, + doc: "The pool name (matches the AssignableCharacteristic name).", + required: true + ], + thing: [ + type: :atom, + doc: "The name of the thing being assigned within the pool (e.g. :port).", + required: true + ] + ] + } + + @pools %Spark.Dsl.Section{ + name: :pools, + describe: "Assignable pools on this Instance — each pool maps to an AssignableCharacteristic", + examples: [ + """ + pools do + pool :ports, :port + end + """ + ], + entities: [@pool_entity] + } + + # ── behaviour ────────────────────────────────────────────────────────────── + + @action_create_entity %Spark.Dsl.Entity{ + name: :create, + describe: "Marks a create action for instance build wiring", + target: ActionCreate, + args: [:name], + schema: [ + name: [type: :atom, doc: "The name of the create action to wire.", required: true] + ] + } + + @action_update_entity %Spark.Dsl.Entity{ + name: :update, + describe: "Marks an update action for instance behaviour wiring", + target: ActionUpdate, + args: [:name], + schema: [ + name: [type: :atom, doc: "The name of the update action to wire.", required: true] + ] + } + + @behaviour_actions %Spark.Dsl.Section{ + name: :actions, + describe: "Declares which actions to wire for instance behaviour", + examples: [ + """ + actions do + create :build + update :define + end + """ + ], + entities: [@action_create_entity, @action_update_entity] + } + + @behaviour_section %Spark.Dsl.Section{ + name: :behaviour, + describe: "Defines the behavioural wiring for the Instance — actions, and in future triggers", + examples: [ + """ + behaviour do + actions do + create :build + end + end + """ + ], + sections: [@behaviour_actions] + } + + # ── provider (top-level wrapper) ─────────────────────────────────────────── + + @provider %Spark.Dsl.Section{ + name: :provider, + describe: "Provider DSL — structure, roles, and behaviour for this resource kind", + sections: [ + @specification, + @characteristics, + @features, + @pools, + @parties, + @places, + @instances, + @behaviour_section + ] + } + + use Spark.Dsl.Extension, + sections: [@provider], + persisters: [ + Diffo.Provider.Extension.Persisters.PersistSpecification, + Diffo.Provider.Extension.Persisters.PersistCharacteristics, + Diffo.Provider.Extension.Persisters.PersistFeatures, + Diffo.Provider.Extension.Persisters.PersistPools, + Diffo.Provider.Extension.Persisters.PersistParties, + Diffo.Provider.Extension.Persisters.PersistPlaces, + Diffo.Provider.Extension.Persisters.PersistInstances, + Diffo.Provider.Extension.Transformers.TransformBehaviour + ], + verifiers: [ + Diffo.Provider.Extension.Verifiers.VerifySpecification, + Diffo.Provider.Extension.Verifiers.VerifyCharacteristics, + Diffo.Provider.Extension.Verifiers.VerifyFeatures, + Diffo.Provider.Extension.Verifiers.VerifyPools, + Diffo.Provider.Extension.Verifiers.VerifyParties, + Diffo.Provider.Extension.Verifiers.VerifyPlaces, + Diffo.Provider.Extension.Verifiers.VerifyInstances, + Diffo.Provider.Extension.Verifiers.VerifyBehaviour + ] +end diff --git a/lib/diffo/provider/extension/action_create.ex b/lib/diffo/provider/extension/action_create.ex new file mode 100644 index 0000000..a6eb7ab --- /dev/null +++ b/lib/diffo/provider/extension/action_create.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.ActionCreate do + @moduledoc false + defstruct [:name, __spark_metadata__: nil] +end diff --git a/lib/diffo/provider/extension/action_update.ex b/lib/diffo/provider/extension/action_update.ex new file mode 100644 index 0000000..6453f76 --- /dev/null +++ b/lib/diffo/provider/extension/action_update.ex @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.ActionUpdate do + @moduledoc false + defstruct [:name, __spark_metadata__: nil] +end diff --git a/lib/diffo/provider/extension/characteristic.ex b/lib/diffo/provider/extension/characteristic.ex new file mode 100644 index 0000000..f548dfb --- /dev/null +++ b/lib/diffo/provider/extension/characteristic.ex @@ -0,0 +1,203 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Characteristic do + @moduledoc false + require Logger + + alias Diffo.Provider + alias Diffo.Provider.Instance + alias Diffo.Type.Value + + defstruct [:name, :value_type, __spark_metadata__: nil] + + # ── build_before: dynamic characteristics only ───────────────────────────── + + def set_characteristics_argument(changeset, declarations) + when is_struct(changeset, Ash.Changeset) and is_list(declarations) do + dynamic = Enum.reject(declarations, &typed?(&1.value_type)) + + case characteristics = create_characteristics_from_declarations(dynamic, :instance) do + [] -> + changeset + + {:error, error} -> + Ash.Changeset.add_error(changeset, error) + + _ -> + characteristic_ids = Enum.map(characteristics, &Map.get(&1, :id)) + Ash.Changeset.force_set_argument(changeset, :characteristics, characteristic_ids) + end + end + + defp create_characteristics_from_declarations(declarations, type) do + Enum.reduce_while(declarations, [], fn %{name: name, value_type: value_type}, acc -> + try do + attrs = + case value_type do + {:array, _inner} -> + %{name: name, type: type, values: [], is_array: true} + + module -> + %{name: name, type: type, value: Value.dynamic(struct(module))} + end + + case Provider.create_characteristic(attrs) do + {:ok, result} -> + {:cont, [result | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + rescue + _e in UndefinedFunctionError -> + {:halt, + {:error, "couldn't create characteristic with value of unknown type #{value_type}"}} + end + end) + end + + # ── build_after: relate dynamic, create typed ────────────────────────────── + + def relate_instance(result, changeset) + when is_struct(result) and is_struct(changeset, Ash.Changeset) do + characteristics = Ash.Changeset.get_argument(changeset, :characteristics) + + Provider.relate_instance_characteristics(%Instance{id: result.id}, %{ + characteristics: characteristics + }) + end + + def create_typed(result, declarations) when is_struct(result) and is_list(declarations) do + typed = Enum.filter(declarations, &typed?(&1.value_type)) + + Enum.reduce_while(typed, {:ok, result}, fn %{name: name, value_type: module}, {:ok, acc} -> + case module + |> Ash.Changeset.for_create(:create, %{name: name, instance_id: acc.id}) + |> Ash.create() do + {:ok, _} -> {:cont, {:ok, acc}} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + # ── update: handle both typed and dynamic characteristics ────────────────── + + def update_values(result, changeset) + when is_struct(result) and is_struct(changeset, Ash.Changeset) do + update_all(result, changeset, []) + end + + def update_all(result, changeset, declarations) + when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_list(declarations) do + characteristic_value_updates = + Ash.Changeset.get_argument(changeset, :characteristic_value_updates) + + case characteristic_value_updates do + nil -> {:ok, result} + [] -> {:ok, result} + _ -> apply_updates(result, characteristic_value_updates, declarations) + end + end + + defp apply_updates(result, updates, declarations) do + Enum.reduce_while(updates, {:ok, result}, fn {name, update}, {:ok, acc} -> + decl = Enum.find(declarations, &(&1.name == name)) + dynamic = Enum.find(acc.characteristics, fn %{name: n} -> n == name end) + + cond do + decl && typed?(decl.value_type) -> apply_typed_update(acc, name, decl.value_type, update) + decl || dynamic -> apply_dynamic_update(acc, name, update) + true -> {:cont, {:ok, acc}} + end + end) + end + + defp apply_typed_update(result, name, module, field_updates) do + case module + |> Ash.Query.new() + |> Ash.Query.filter_input(instance_id: result.id, name: name) + |> Ash.read_one() do + {:ok, nil} -> + Logger.warning("couldn't find typed characteristic #{name}") + {:cont, {:ok, result}} + + {:ok, char} -> + attrs = if is_list(field_updates), do: Map.new(field_updates), else: field_updates + + case char + |> Ash.Changeset.for_update(:update, attrs) + |> Ash.update() do + {:ok, _} -> {:cont, {:ok, result}} + {:error, error} -> {:halt, {:error, error}} + end + + {:error, error} -> + {:halt, {:error, error}} + end + end + + defp apply_dynamic_update(result, name, update) do + characteristic = Enum.find(result.characteristics, fn %{name: n} -> n == name end) + + if characteristic do + new_value = + cond do + is_list(update) -> + unwrapped = Diffo.Unwrap.unwrap(characteristic.value) + value_type = unwrapped.__struct__ + + updated = + Enum.reduce(update, unwrapped, fn {field, val}, acc -> + Map.put(acc, field, val) + end) + + Value.dynamic(struct(value_type, Map.from_struct(updated))) + + true -> + update + end + + case Provider.update_characteristic(characteristic, %{value: new_value}) do + {:ok, updated_char} -> + updated_chars = + Enum.map(result.characteristics, fn c -> + if c.id == updated_char.id, do: updated_char, else: c + end) + + {:cont, {:ok, %{result | characteristics: updated_chars}}} + + {:error, error} -> + {:halt, {:error, error}} + end + else + Logger.warning("couldn't find characteristic #{name}") + {:cont, {:ok, result}} + end + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end + + # ── helpers ──────────────────────────────────────────────────────────────── + + def typed?(module) when is_atom(module) and not is_nil(module) do + case Code.ensure_loaded(module) do + {:module, _} -> + try do + module != Diffo.Provider.Characteristic and + Diffo.Provider.Characteristic.Extension in Ash.Resource.Info.extensions(module) + rescue + _ -> false + end + + _ -> + false + end + end + + def typed?(_), do: false + +end diff --git a/lib/diffo/provider/extension/feature.ex b/lib/diffo/provider/extension/feature.ex new file mode 100644 index 0000000..d56ebaa --- /dev/null +++ b/lib/diffo/provider/extension/feature.ex @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Feature do + @moduledoc false + require Logger + + alias Diffo.Provider + alias Diffo.Provider.Instance + alias Diffo.Provider.Extension.Characteristic + alias Diffo.Type.Value + + defstruct [:name, :is_enabled?, :characteristics, __spark_metadata__: nil] + + def set_features_argument(changeset, declarations) + when is_struct(changeset, Ash.Changeset) and is_list(declarations) do + case features = create_features_from_declarations(declarations) do + [] -> + changeset + + {:error, error} -> + Ash.Changeset.add_error(changeset, error) + + _ -> + feature_ids = Enum.map(features, &Map.get(&1, :id)) + Ash.Changeset.force_set_argument(changeset, :features, feature_ids) + end + end + + defp create_features_from_declarations(declarations) do + Enum.reduce_while( + declarations, + [], + fn %{name: name, is_enabled?: isEnabled, characteristics: characteristics}, acc -> + dynamic = Enum.reject(characteristics, &Characteristic.typed?(&1.value_type)) + + characteristic_ids = + Enum.reduce_while(dynamic, [], fn %{name: name, value_type: value_type}, acc -> + try do + attrs = + case value_type do + {:array, _inner} -> + %{name: name, type: :feature, values: [], is_array: true} + + module -> + %{name: name, type: :feature, value: Value.dynamic(struct(module))} + end + + case Provider.create_characteristic(attrs) do + {:ok, result} -> + {:cont, [result.id | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + rescue + _e in UndefinedFunctionError -> + {:halt, + {:error, + "couldn't create feature characteristic with value of unknown type #{value_type}"}} + end + end) + + case characteristic_ids do + {:error, error} -> + {:halt, {:error, error}} + + _ -> + case Provider.create_feature(%{ + name: name, + isEnabled: isEnabled, + characteristics: characteristic_ids + }) do + {:ok, result} -> + {:cont, [result | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + end + end + ) + end + + def relate_instance(result, changeset) + when is_struct(result) and is_struct(changeset, Ash.Changeset) do + features = Ash.Changeset.get_argument(changeset, :features) + Provider.relate_instance_features(%Instance{id: result.id}, %{features: features}) + end + + def create_typed_feature_chars(result, declarations) + when is_struct(result) and is_list(declarations) do + Enum.reduce_while(declarations, {:ok, result}, fn %{name: name, characteristics: char_decls}, + {:ok, acc} -> + feature = Enum.find(acc.features, fn f -> f.name == name end) + + if feature do + typed = Enum.filter(char_decls, &Characteristic.typed?(&1.value_type)) + + case create_typed_for_feature(feature, typed) do + :ok -> {:cont, {:ok, acc}} + {:error, error} -> {:halt, {:error, error}} + end + else + {:cont, {:ok, acc}} + end + end) + end + + defp create_typed_for_feature(feature, typed_decls) do + Enum.reduce_while(typed_decls, :ok, fn %{name: char_name, value_type: module}, :ok -> + case module + |> Ash.Changeset.for_create(:create, %{name: char_name, feature_id: feature.id}) + |> Ash.create() do + {:ok, _} -> {:cont, :ok} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/info.ex b/lib/diffo/provider/extension/info.ex new file mode 100644 index 0000000..2617ef5 --- /dev/null +++ b/lib/diffo/provider/extension/info.ex @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Info do + use Spark.InfoGenerator, + extension: Diffo.Provider.Extension, + sections: [:provider] + + @doc "Returns true if the module is a BaseInstance-derived resource" + @spec instance?(module()) :: boolean() + def instance?(module) do + Code.ensure_loaded?(module) and + Diffo.Provider.Instance.Extension in Ash.Resource.Info.extensions(module) + end + + @doc "Returns true if the module is a BaseParty-derived resource" + @spec party?(module()) :: boolean() + def party?(module) do + Code.ensure_loaded?(module) and + Diffo.Provider.Party.Extension in Ash.Resource.Info.extensions(module) + end + + @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 diff --git a/lib/diffo/provider/extension/instance_role.ex b/lib/diffo/provider/extension/instance_role.ex new file mode 100644 index 0000000..1247386 --- /dev/null +++ b/lib/diffo/provider/extension/instance_role.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InstanceRole do + @moduledoc "DSL entity declaring a role a Party or Place kind plays with respect to Instances" + defstruct [:role, :instance_type, :reference, __spark_metadata__: nil] + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/party_declaration.ex b/lib/diffo/provider/extension/party_declaration.ex new file mode 100644 index 0000000..589af5b --- /dev/null +++ b/lib/diffo/provider/extension/party_declaration.ex @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PartyDeclaration do + @moduledoc "DSL entity declaring a party role on an Instance" + defstruct [ + :role, + :party_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/extension/party_role.ex b/lib/diffo/provider/extension/party_role.ex new file mode 100644 index 0000000..5f52b32 --- /dev/null +++ b/lib/diffo/provider/extension/party_role.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PartyRole do + @moduledoc "DSL entity declaring a role a Party or 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/extension/persisters/persist_characteristics.ex b/lib/diffo/provider/extension/persisters/persist_characteristics.ex new file mode 100644 index 0000000..6b8d352 --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_characteristics.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistCharacteristics do + @moduledoc "Persists characteristic declarations and bakes characteristics/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :characteristics]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :characteristics, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def characteristics, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_features.ex b/lib/diffo/provider/extension/persisters/persist_features.ex new file mode 100644 index 0000000..5b02846 --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_features.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistFeatures do + @moduledoc "Persists feature declarations and bakes features/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :features]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :features, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def features, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_instances.ex b/lib/diffo/provider/extension/persisters/persist_instances.ex new file mode 100644 index 0000000..313885c --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_instances.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.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, [:provider, :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/extension/persisters/persist_parties.ex b/lib/diffo/provider/extension/persisters/persist_parties.ex new file mode 100644 index 0000000..0998990 --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_parties.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistParties do + @moduledoc "Persists party declarations/roles 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, [:provider, :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/extension/persisters/persist_places.ex b/lib/diffo/provider/extension/persisters/persist_places.ex new file mode 100644 index 0000000..824000f --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_places.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistPlaces do + @moduledoc "Persists place declarations/roles 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, [:provider, :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/extension/persisters/persist_pools.ex b/lib/diffo/provider/extension/persisters/persist_pools.ex new file mode 100644 index 0000000..8a365eb --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_pools.ex @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistPools do + @moduledoc "Persists pool declarations and bakes pools/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + declarations = Transformer.get_entities(dsl_state, [:provider, :pools]) + escaped = Macro.escape(declarations) + dsl_state = Transformer.persist(dsl_state, :pools, declarations) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def pools, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/persisters/persist_specification.ex b/lib/diffo/provider/extension/persisters/persist_specification.ex new file mode 100644 index 0000000..5363e5a --- /dev/null +++ b/lib/diffo/provider/extension/persisters/persist_specification.ex @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Persisters.PersistSpecification do + @moduledoc "Normalises specification DSL options, persists them, and bakes specification/0" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + @impl true + def transform(dsl_state) do + spec = [ + id: Transformer.get_option(dsl_state, [:provider, :specification], :id), + name: Transformer.get_option(dsl_state, [:provider, :specification], :name), + type: + Transformer.get_option(dsl_state, [:provider, :specification], :type, :serviceSpecification), + major_version: + Transformer.get_option(dsl_state, [:provider, :specification], :major_version, 1), + minor_version: + Transformer.get_option(dsl_state, [:provider, :specification], :minor_version), + patch_version: + Transformer.get_option(dsl_state, [:provider, :specification], :patch_version), + tmf_version: Transformer.get_option(dsl_state, [:provider, :specification], :tmf_version), + description: Transformer.get_option(dsl_state, [:provider, :specification], :description), + category: Transformer.get_option(dsl_state, [:provider, :specification], :category) + ] + + escaped = Macro.escape(spec) + dsl_state = Transformer.persist(dsl_state, :specification, spec) + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def specification, do: unquote(escaped) + end + )} + end +end diff --git a/lib/diffo/provider/extension/place_declaration.ex b/lib/diffo/provider/extension/place_declaration.ex new file mode 100644 index 0000000..db7fad4 --- /dev/null +++ b/lib/diffo/provider/extension/place_declaration.ex @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PlaceDeclaration do + @moduledoc "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/extension/place_role.ex b/lib/diffo/provider/extension/place_role.ex new file mode 100644 index 0000000..a4361e2 --- /dev/null +++ b/lib/diffo/provider/extension/place_role.ex @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.PlaceRole do + @moduledoc "DSL entity declaring a role a Party or Place 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/lib/diffo/provider/extension/pool.ex b/lib/diffo/provider/extension/pool.ex new file mode 100644 index 0000000..53a2ebc --- /dev/null +++ b/lib/diffo/provider/extension/pool.ex @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Pool do + @moduledoc false + require Logger + + defstruct [:name, :thing, __spark_metadata__: nil] + + @doc "Creates AssignableCharacteristic nodes for each declared pool during the build action" + def create_pools(result, pools) when is_struct(result) and is_list(pools) do + Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name}, {:ok, acc} -> + case Diffo.Provider.AssignableCharacteristic + |> Ash.Changeset.for_create(:create, %{name: name, instance_id: acc.id}) + |> Ash.create() do + {:ok, _} -> {:cont, {:ok, acc}} + {:error, error} -> {:halt, {:error, error}} + end + end) + end + + @doc "Applies characteristic_value_updates to pool AssignableCharacteristic records" + def update_pools(result, changeset, pools) + when is_struct(result) and is_struct(changeset, Ash.Changeset) and is_list(pools) do + characteristic_value_updates = + Ash.Changeset.get_argument(changeset, :characteristic_value_updates) + + case characteristic_value_updates do + nil -> {:ok, result} + [] -> {:ok, result} + _ -> apply_pool_updates(result, pools, characteristic_value_updates) + end + end + + defp apply_pool_updates(result, pools, updates) do + Enum.reduce_while(pools, {:ok, result}, fn %__MODULE__{name: name}, {:ok, acc} -> + case Keyword.get(updates, name) do + nil -> + {:cont, {:ok, acc}} + + update -> + case Diffo.Provider.AssignableCharacteristic + |> Ash.Query.new() + |> Ash.Query.filter_input(instance_id: acc.id, name: name) + |> Ash.read_one() do + {:ok, nil} -> + Logger.warning("pool #{name} not found on instance #{acc.id}") + {:cont, {:ok, acc}} + + {:ok, char} -> + attrs = if is_list(update), do: Map.new(update), else: update + + case char |> Ash.Changeset.for_update(:update, attrs) |> Ash.update() do + {:ok, _} -> {:cont, {:ok, acc}} + {:error, error} -> {:halt, {:error, error}} + end + + {:error, error} -> + {:halt, {:error, error}} + end + end + end) + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/extension/transformers/transform_behaviour.ex new file mode 100644 index 0000000..7bc70ba --- /dev/null +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do + @moduledoc "Generates build_before/1 and build_after/2, and injects build arguments into declared create actions" + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + alias Diffo.Provider.Extension.ActionCreate + + @build_args [ + specified_by: :uuid, + features: {:array, :uuid}, + characteristics: {:array, :uuid} + ] + + @impl true + def transform(dsl_state) do + spec = Transformer.get_persisted(dsl_state, :specification, []) + + dsl_state = inject_create_arguments(dsl_state) + + {build_before_body, build_after_body} = + if spec[:id] do + before_body = + quote do + changeset + |> Diffo.Provider.Instance.Specification.set_specified_by_argument(specification()) + |> Diffo.Provider.Extension.Feature.set_features_argument(features()) + |> Diffo.Provider.Extension.Characteristic.set_characteristics_argument( + characteristics() + ) + |> Diffo.Provider.Instance.Party.validate_parties(parties()) + end + + after_body = + quote do + with {:ok, result} <- + Diffo.Provider.Instance.ActionHelper.build_after(changeset, result), + {:ok, result} <- + Diffo.Provider.Extension.Characteristic.create_typed(result, characteristics()), + {:ok, result} <- + Diffo.Provider.Extension.Feature.create_typed_feature_chars( + result, + features() + ), + {:ok, result} <- + Diffo.Provider.Extension.Pool.create_pools(result, pools()), + do: {:ok, result} + end + + {before_body, after_body} + else + {quote(do: changeset), quote(do: {:ok, result})} + end + + {:ok, + Transformer.eval( + dsl_state, + [], + quote do + @doc false + def build_before(changeset), do: unquote(build_before_body) + + @doc false + def build_after(changeset, result), do: unquote(build_after_body) + + @doc false + def characteristic(name), do: Enum.find(characteristics(), &(&1.name == name)) + + @doc false + def feature(name), do: Enum.find(features(), &(&1.name == name)) + + @doc false + def feature_characteristic(feature_name, char_name) do + case feature(feature_name) do + nil -> nil + f -> Enum.find(f.characteristics, &(&1.name == char_name)) + end + end + + @doc false + def pool(name), do: Enum.find(pools(), &(&1.name == name)) + + @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 + + defp inject_create_arguments(dsl_state) do + action_create_declarations = + Transformer.get_entities(dsl_state, [:provider, :behaviour, :actions]) + |> Enum.filter(&is_struct(&1, ActionCreate)) + + Enum.reduce(action_create_declarations, dsl_state, fn %ActionCreate{name: action_name}, + dsl_state -> + action = + Transformer.get_entities(dsl_state, [:actions]) + |> Enum.find(&(is_struct(&1, Ash.Resource.Actions.Create) and &1.name == action_name)) + + if action do + existing = MapSet.new(action.arguments, & &1.name) + + new_args = + @build_args + |> Enum.reject(fn {name, _} -> MapSet.member?(existing, name) end) + |> Enum.map(fn {name, type} -> + %Ash.Resource.Actions.Argument{ + name: name, + type: type, + public?: false, + allow_nil?: true + } + end) + + updated = %{action | arguments: action.arguments ++ new_args} + + Transformer.replace_entity(dsl_state, [:actions], updated, fn entity -> + is_struct(entity, Ash.Resource.Actions.Create) and entity.name == action_name + end) + else + dsl_state + end + end) + end + + @impl true + def after?(Diffo.Provider.Extension.Persisters.PersistSpecification), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistCharacteristics), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistFeatures), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistPools), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistParties), do: true + def after?(Diffo.Provider.Extension.Persisters.PersistPlaces), do: true + def after?(_), do: false +end diff --git a/lib/diffo/provider/extension/verifiers/verify_behaviour.ex b/lib/diffo/provider/extension/verifiers/verify_behaviour.ex new file mode 100644 index 0000000..d321c6f --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_behaviour.ex @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyBehaviour do + @moduledoc "Verifies that actions declared in behaviour do exist as Ash actions of the correct type" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.ActionCreate + alias Diffo.Provider.Extension.ActionUpdate + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + behaviour_actions = Verifier.get_entities(dsl_state, [:provider, :behaviour, :actions]) + ash_actions = Verifier.get_entities(dsl_state, [:actions]) + + create_names = + ash_actions + |> Enum.filter(&is_struct(&1, Ash.Resource.Actions.Create)) + |> MapSet.new(& &1.name) + + update_names = + ash_actions + |> Enum.filter(&is_struct(&1, Ash.Resource.Actions.Update)) + |> MapSet.new(& &1.name) + + errors = + Enum.flat_map(behaviour_actions, fn + %ActionCreate{name: name} -> + if MapSet.member?(create_names, name) do + [] + else + [ + DslError.exception( + module: resource, + path: [:provider, :behaviour, :actions], + message: + "behaviour: create #{inspect(name)} does not exist as a create action on this resource" + ) + ] + end + + %ActionUpdate{name: name} -> + if MapSet.member?(update_names, name) do + [] + else + [ + DslError.exception( + module: resource, + path: [:provider, :behaviour, :actions], + message: + "behaviour: update #{inspect(name)} does not exist as an update action on this resource" + ) + ] + end + end) + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_characteristics.ex b/lib/diffo/provider/extension/verifiers/verify_characteristics.ex new file mode 100644 index 0000000..bf05037 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_characteristics.ex @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyCharacteristics do + @moduledoc "Verifies characteristic names are unique and value_type modules exist" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + characteristics = Verifier.get_entities(dsl_state, [:provider, :characteristics]) + + duplicate_errors = + characteristics + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, chars} -> length(chars) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :characteristics], + message: "characteristics: name #{inspect(name)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(characteristics, [], fn char, acc -> + case module_from_value_type(char.value_type) do + {:ok, module} -> + if Code.ensure_loaded?(module) do + acc + else + [ + DslError.exception( + module: resource, + path: [:provider, :characteristics, char.name], + message: "characteristics: value_type #{inspect(module)} does not exist" + ) + | acc + ] + end + + :error -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end + + defp module_from_value_type({:array, module}) when is_atom(module), do: {:ok, module} + defp module_from_value_type(module) when is_atom(module), do: {:ok, module} + defp module_from_value_type(_), do: :error +end diff --git a/lib/diffo/provider/extension/verifiers/verify_features.ex b/lib/diffo/provider/extension/verifiers/verify_features.ex new file mode 100644 index 0000000..882dcdc --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_features.ex @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyFeatures do + @moduledoc "Verifies feature names are unique and feature characteristic value_type modules exist" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + features = Verifier.get_entities(dsl_state, [:provider, :features]) + + duplicate_errors = + features + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, fs} -> length(fs) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :features], + message: "features: name #{inspect(name)} is declared more than once" + ) + end) + + char_errors = + Enum.reduce(features, [], fn feature, acc -> + duplicate_char_errors = + feature.characteristics + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, chars} -> length(chars) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :features, feature.name, :characteristics], + message: + "features: characteristic name #{inspect(name)} is declared more than once in #{inspect(feature.name)}" + ) + end) + + type_errors = + Enum.reduce(feature.characteristics || [], [], fn char, inner_acc -> + case module_from_value_type(char.value_type) do + {:ok, module} -> + if Code.ensure_loaded?(module) do + inner_acc + else + [ + DslError.exception( + module: resource, + path: [:provider, :features, feature.name, :characteristics, char.name], + message: + "features: characteristic value_type #{inspect(module)} does not exist" + ) + | inner_acc + ] + end + + :error -> + inner_acc + end + end) + + acc ++ duplicate_char_errors ++ type_errors + end) + + case duplicate_errors ++ char_errors do + [] -> :ok + errors -> {:error, errors} + end + end + + defp module_from_value_type({:array, module}) when is_atom(module), do: {:ok, module} + defp module_from_value_type(module) when is_atom(module), do: {:ok, module} + defp module_from_value_type(_), do: :error +end diff --git a/lib/diffo/provider/extension/verifiers/verify_instances.ex b/lib/diffo/provider/extension/verifiers/verify_instances.ex new file mode 100644 index 0000000..c259e17 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_instances.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyInstances do + @moduledoc "Verifies instance role declarations — no duplicates, instance_type modules must exist and extend BaseInstance" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + instances = Verifier.get_entities(dsl_state, [:provider, :instances]) + + duplicate_errors = + instances + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, roles} -> length(roles) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [:provider, :instances], + message: "instances: role #{inspect(role)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(instances, [], fn role, acc -> + mod = role.instance_type + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :instances, role.role], + message: "instances: instance_type #{inspect(mod)} does not exist" + ) + | acc + ] + + !Info.instance?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :instances, role.role], + message: "instances: instance_type #{inspect(mod)} does not extend BaseInstance" + ) + | acc + ] + + true -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_parties.ex b/lib/diffo/provider/extension/verifiers/verify_parties.ex new file mode 100644 index 0000000..8a67bed --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_parties.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyParties do + @moduledoc "Verifies party declarations and roles — no duplicates, party_type modules must exist and extend BaseParty" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + parties = Verifier.get_entities(dsl_state, [:provider, :parties]) + + duplicate_errors = + parties + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, declarations} -> length(declarations) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [:provider, :parties], + message: "parties: role #{inspect(role)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(parties, [], fn party, acc -> + mod = Map.get(party, :party_type) + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :parties, party.role], + message: "parties: party_type #{inspect(mod)} does not exist" + ) + | acc + ] + + !Info.party?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :parties, party.role], + message: "parties: party_type #{inspect(mod)} does not extend BaseParty" + ) + | acc + ] + + true -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_places.ex b/lib/diffo/provider/extension/verifiers/verify_places.ex new file mode 100644 index 0000000..c15f9e0 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_places.ex @@ -0,0 +1,68 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyPlaces do + @moduledoc "Verifies place declarations and roles — no duplicates, place_type modules must exist and extend BasePlace" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + alias Diffo.Provider.Extension.Info + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + places = Verifier.get_entities(dsl_state, [:provider, :places]) + + duplicate_errors = + places + |> Enum.group_by(& &1.role) + |> Enum.filter(fn {_role, declarations} -> length(declarations) > 1 end) + |> Enum.map(fn {role, _} -> + DslError.exception( + module: resource, + path: [:provider, :places], + message: "places: role #{inspect(role)} is declared more than once" + ) + end) + + type_errors = + Enum.reduce(places, [], fn place, acc -> + mod = Map.get(place, :place_type) + + cond do + is_nil(mod) -> + acc + + !Code.ensure_loaded?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :places, place.role], + message: "places: place_type #{inspect(mod)} does not exist" + ) + | acc + ] + + !Info.place?(mod) -> + [ + DslError.exception( + module: resource, + path: [:provider, :places, place.role], + message: "places: place_type #{inspect(mod)} does not extend BasePlace" + ) + | acc + ] + + true -> + acc + end + end) + + case duplicate_errors ++ type_errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_pools.ex b/lib/diffo/provider/extension/verifiers/verify_pools.ex new file mode 100644 index 0000000..b6a4a90 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_pools.ex @@ -0,0 +1,34 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifyPools do + @moduledoc "Verifies pool names are unique" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + pools = Verifier.get_entities(dsl_state, [:provider, :pools]) + + errors = + pools + |> Enum.group_by(& &1.name) + |> Enum.filter(fn {_name, ps} -> length(ps) > 1 end) + |> Enum.map(fn {name, _} -> + DslError.exception( + module: resource, + path: [:provider, :pools], + message: "pools: name #{inspect(name)} is declared more than once" + ) + end) + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end +end diff --git a/lib/diffo/provider/extension/verifiers/verify_specification.ex b/lib/diffo/provider/extension/verifiers/verify_specification.ex new file mode 100644 index 0000000..f0f0c45 --- /dev/null +++ b/lib/diffo/provider/extension/verifiers/verify_specification.ex @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.Verifiers.VerifySpecification do + @moduledoc "Verifies specification DSL values satisfy the Specification resource constraints" + use Spark.Dsl.Verifier + + alias Spark.Dsl.Verifier + alias Spark.Error.DslError + + @spec_fields [ + :name, + :type, + :major_version, + :minor_version, + :patch_version, + :tmf_version, + :description, + :category + ] + + @impl true + def verify(dsl_state) do + resource = Verifier.get_persisted(dsl_state, :module) + errors = check_id(dsl_state, resource) ++ check_attributes(dsl_state, resource) + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end + + defp check_id(dsl_state, resource) do + spec_id = Verifier.get_option(dsl_state, [:provider, :specification], :id) + + if spec_id && !Diffo.Uuid.uuid4?(spec_id) do + [ + DslError.exception( + module: resource, + path: [:provider, :specification, :id], + message: "specification: id must be a valid UUID4" + ) + ] + else + [] + end + end + + defp check_attributes(dsl_state, resource) do + spec_attrs = + Ash.Resource.Info.attributes(Diffo.Provider.Specification) + |> Map.new(&{&1.name, &1}) + + Enum.flat_map(@spec_fields, fn field -> + value = Verifier.get_option(dsl_state, [:provider, :specification], field) + attr = Map.get(spec_attrs, field) + + if not is_nil(value) && not is_nil(attr) do + case Ash.Type.apply_constraints(attr.type, value, attr.constraints) do + {:ok, _} -> + [] + + {:error, errors} -> + [ + DslError.exception( + module: resource, + path: [:provider, :specification, field], + message: "specification: #{field} - #{format_errors(errors)}" + ) + ] + end + else + [] + end + end) + end + + defp format_errors(errors) when is_list(errors) do + if Keyword.keyword?(errors) do + format_error(errors) + else + errors |> Enum.map(&format_error/1) |> Enum.join(", ") + end + end + + defp format_error(kwlist) do + {message, bindings} = Keyword.pop(kwlist, :message, "invalid value") + + Enum.reduce(bindings, message, fn {key, val}, msg -> + String.replace(msg, "%{#{key}}", to_string(val)) + end) + end +end diff --git a/lib/diffo/type/characteristic_value.ex b/lib/diffo/type/characteristic_value.ex new file mode 100644 index 0000000..29edd07 --- /dev/null +++ b/lib/diffo/type/characteristic_value.ex @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Type.CharacteristicValue do + @moduledoc """ + Ash type for a typed characteristic value. + + Used as the return type for `:value` calculations on `BaseCharacteristic`-derived resources. + The actual value is a `TypedStruct` defined by the extender (e.g. `Card.Value`, `Shelf.Value`), + which controls field ordering and JSON encoding via `AshJason.TypedStruct`. + """ + use Ash.Type.NewType, + subtype_of: Ash.Type.Struct +end diff --git a/mix.exs b/mix.exs index ebfe7e3..fe686ab 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,7 @@ defmodule Diffo.MixProject do @moduledoc false use Mix.Project - @version "0.2.2" + @version "0.3.0" @name "Diffo" @description "TMF Service and Resource Manager with a difference" @github_url "https://github.com/diffo-dev/diffo" @@ -67,16 +67,22 @@ defmodule Diffo.MixProject do extras: [ "README.md": [title: "Guide"], "LICENSES/MIT.md": [title: "License"], - "documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md": [ - title: "DSL: Diffo.Provider.Instance.Extension", - search_data: Spark.Docs.search_data_for(Diffo.Provider.Instance.Extension) + "diffo.livemd": [title: "Tutorial"], + "documentation/dsls/DSL-Diffo.Provider.Extension.md": [ + title: "DSL: Diffo.Provider.Extension", + search_data: Spark.Docs.search_data_for(Diffo.Provider.Extension) ], - "documentation/dsls/DSL-Diffo.Provider.Party.Extension.md": [ - title: "DSL: Diffo.Provider.Party.Extension", - search_data: Spark.Docs.search_data_for(Diffo.Provider.Party.Extension) + "documentation/how_to/use_diffo_type.livemd": [title: "Using Diffo.Type"], + "documentation/how_to/use_diffo_provider_extension.livemd": [ + title: "Using the Diffo Provider Extension" + ], + "documentation/how_to/use_diffo_provider_versioning.livemd": [ + title: "Instance Versioning with the Diffo Provider" ] ], groups_for_extras: [ + "How-to": ~r/documentation\/how_to\//, + Tutorials: ~r/\.livemd$/, DSLs: ~r/documentation\/dsls\// ] ] @@ -138,9 +144,9 @@ defmodule Diffo.MixProject do "spark.replace_doc_links" ], "spark.cheat_sheets": - "spark.cheat_sheets --extensions Diffo.Provider.Instance.Extension,Diffo.Provider.Party.Extension", + "spark.cheat_sheets --extensions Diffo.Provider.Extension", "spark.formatter": [ - "spark.formatter --extensions Diffo.Provider.Instance.Extension,Diffo.Provider.Party.Extension", + "spark.formatter --extensions Diffo.Provider.Extension", "format .formatter.exs" ] ] diff --git a/mix.lock b/mix.lock index a8c2edd..90e01e6 100644 --- a/mix.lock +++ b/mix.lock @@ -1,29 +1,30 @@ %{ "ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"}, "ash_jason": {:hex, :ash_jason, "3.1.0", "84a88dfe5e25a20d55cf2d2664885cd086fa45871e8777aedc3ad96a282e2a6f", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.1.21 and < 3.0.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "71e6bbc421fb2cf7079f8804814145cca458116c839fc798f9606b806e07eb2b"}, - "ash_neo4j": {:hex, :ash_neo4j, "0.5.0", "7e19abf973cd86fb67fa8b3544daef68be1ad3f912a2c4b3c6c3ddd7244d7e52", [:mix], [{:ash, ">= 3.24.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:bolty, ">= 0.0.12", [hex: :bolty, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:usage_rules, "~> 1.2", [hex: :usage_rules, repo: "hexpm", optional: true]}], "hexpm", "76de0829dddfce12b53869e4e129a19a14b4474178f3189bfd97a5aae6b096ae"}, + "ash_neo4j": {:hex, :ash_neo4j, "0.5.1", "cc42a577bb1608ad576872babd3a774cc3bbb540f7e8cee2208562fb203aae59", [:mix], [{:ash, ">= 3.24.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:bolty, ">= 0.0.12", [hex: :bolty, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:usage_rules, "~> 1.2", [hex: :usage_rules, repo: "hexpm", optional: true]}], "hexpm", "ccd993b5856923122784d8fd8090c98f7996f72718f88e649b68fb3fc4fa776d"}, "ash_outstanding": {:hex, :ash_outstanding, "0.2.4", "c72b91f1b8e4859fb033eddf66d0ba36cfd8af0c2a9748c7ef9e6ccfdb5d093d", [:mix], [{:ash, ">= 3.6.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:outstanding, "~> 0.2.4", [hex: :outstanding, repo: "hexpm", optional: false]}], "hexpm", "64ba8f582ce69c9050352c75f0895db186c7a56f35039dab34c8e1ab7516f9ce"}, "ash_state_machine": {:hex, :ash_state_machine, "0.2.13", "e1c368ebf01ef73477739ee76d53e513d073b141ec11e7bf7f91d8f2d8fc9569", [:mix], [{:ash, ">= 3.4.66 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "aa21c92a8950850df69b5205bf41efc1e502f5ab839425ba08561f0421c9f226"}, "bolty": {:hex, :bolty, "0.0.12", "5311de46c29c71000c51cfb23fc181359daa49cedb9c8c4ba1e245f3e54079ae", [:mix], [{:db_connection, "~> 2.7.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "0760661dd2f0ba9f2901448c1be00fc1ed228780644ba21a2400d0662595ee10"}, "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, - "decimal": {:hex, :decimal, "3.0.0", "ce2befbd7218427e4a57d1c6efa6bf50cfc7d0c480c422e70f4fb533074a5f33", [:mix], [], "hexpm", "7a6ab3f806f09738991fc951b2fd2390b3377113feec605a540121aaf772a87b"}, + "decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, - "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, - "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"}, + "ex_doc": {:hex, :ex_doc, "0.40.2", "f50edec428c4b0a457a167de42414c461122a3585a99515a69d09fff19e5597e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4fa426e2beb47854a162e2c488727fdec51cd4692e319b23810c2804cb1a40fe"}, + "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, + "igniter": {:hex, :igniter, "0.8.0", "c7cab589440e5f20ff68e00f60eb094378114dab3105c0784ce8140f8dfdd2c0", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "fcd99096fde4797f7b48bebddcfc58785569acd696346a3eb385bf813f47a7cc"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, @@ -37,7 +38,7 @@ "spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"}, "splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, diff --git a/test/instance_extension/assigner_test.exs b/test/provider/extension/assigner_test.exs similarity index 73% rename from test/instance_extension/assigner_test.exs rename to test/provider/extension/assigner_test.exs index 9c5ca9e..a24c229 100644 --- a/test/instance_extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -2,17 +2,16 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.AssignerTest do +defmodule Diffo.Provider.Extension.AssignerTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Provider.Specification alias Diffo.Provider.Characteristic alias Diffo.Provider.Assignment - alias Diffo.Test.Characteristics alias Diffo.Test.Parties alias Diffo.Test.Servo - alias Diffo.Test.Card + alias Diffo.Test.Instance.CardInstance setup do AshNeo4j.Sandbox.checkout() @@ -24,10 +23,8 @@ defmodule Diffo.InstanceExtension.AssignerTest do test "create a card" do {:ok, card} = Servo.build_card(%{}) - # check the instance is a Card - assert is_struct(card, Card) + assert is_struct(card, CardInstance) - # check specification resource enrichment and node relationship refute is_nil(card.specification_id) assert is_struct(card.specification, Specification) @@ -40,9 +37,9 @@ defmodule Diffo.InstanceExtension.AssignerTest do :outgoing ) - # check characteristic resource enrichment and node relationships + # both :card and :ports are now typed (BaseCharacteristic), not in dynamic characteristics assert is_list(card.characteristics) - assert length(card.characteristics) == 2 + assert length(card.characteristics) == 0 Enum.each(card.characteristics, fn characteristic -> assert is_struct(characteristic, Characteristic) @@ -60,7 +57,7 @@ defmodule Diffo.InstanceExtension.AssignerTest do encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceCharacteristic\":[{\"name\":\"card\",\"value\":{}},{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":1,\"free\":1,\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"}}) end test "define card" do @@ -68,15 +65,25 @@ defmodule Diffo.InstanceExtension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card_value} = + Diffo.Test.Characteristic.CardCharacteristic + |> Ash.Query.new() + |> Ash.Query.filter_input(instance_id: card.id) + |> Ash.read_one() + + assert card_value.family == :ISAM + assert card_value.model == "EBLT48" + assert card_value.technology == :adsl2Plus + encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceCharacteristic\":[{\"name\":\"card\",\"value\":{\"family\":\"ISAM\",\"model\":\"EBLT48\",\"technology\":\"adsl2Plus\"}},{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":48,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"}}) end test "auto assign port to resource" do @@ -86,7 +93,7 @@ defmodule Diffo.InstanceExtension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -96,12 +103,12 @@ defmodule Diffo.InstanceExtension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assert length(card.assignments) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]}],\"resourceCharacteristic\":[{\"name\":\"card\",\"value\":{\"family\":\"ISAM\",\"model\":\"EBLT48\",\"technology\":\"adsl2Plus\"}},{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":47,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]}]}) end test "auto assign two ports to same resource" do @@ -111,7 +118,7 @@ defmodule Diffo.InstanceExtension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -126,12 +133,12 @@ defmodule Diffo.InstanceExtension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 46]], card) + assert length(card.assignments) == 2 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]},{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":2}]}],\"resourceCharacteristic\":[{\"name\":\"card\",\"value\":{\"family\":\"ISAM\",\"model\":\"EBLT48\",\"technology\":\"adsl2Plus\"}},{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":46,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":1}]},{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":2}]}]}) end test "specific assignment rejects duplicate request" do @@ -141,7 +148,7 @@ defmodule Diffo.InstanceExtension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -156,12 +163,12 @@ defmodule Diffo.InstanceExtension.AssignerTest do assignment: %Assignment{id: 5, assignee_id: assignee.id, operation: :assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assert length(card.assignments) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":5}]}],\"resourceCharacteristic\":[{\"name\":\"card\",\"value\":{\"family\":\"ISAM\",\"model\":\"EBLT48\",\"technology\":\"adsl2Plus\"}},{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":47,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceRelationship\":[{\"type\":\"assignedTo\",\"resource\":{\"id\":\"#{assignee.id}\",\"href\":\"resourceInventoryManagement/v4/resource/#{assignee.id}\"},\"resourceRelationshipCharacteristic\":[{\"name\":\"port\",\"value\":5}]}]}) end test "unassign an auto-assigned port from a resource" do @@ -171,7 +178,7 @@ defmodule Diffo.InstanceExtension.AssignerTest do updates = [ card: [family: :ISAM, model: "EBLT48", technology: :adsl2Plus], - ports: [first: 1, last: 48, free: 48, assignable_type: "ADSL2+"] + ports: [first: 1, last: 48, assignable_type: "ADSL2+"] ] {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) @@ -181,14 +188,9 @@ defmodule Diffo.InstanceExtension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assert length(card.assignments) == 1 - assigned_port = - Enum.find(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - |> Map.get(:characteristics) - |> Enum.find(fn char -> char.name == :port end) - |> Map.get(:value) - |> Diffo.Unwrap.unwrap() + assigned_port = hd(card.assignments).assigned {:ok, card} = Servo.assign_port(card, %{ @@ -199,12 +201,12 @@ defmodule Diffo.InstanceExtension.AssignerTest do } }) - Characteristics.check_values([ports: [free: 48]], card) + assert length(card.assignments) == 0 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() assert encoding == - ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"},\"resourceCharacteristic\":[{\"name\":\"card\",\"value\":{\"family\":\"ISAM\",\"model\":\"EBLT48\",\"technology\":\"adsl2Plus\"}},{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":48,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) + ~s({\"id\":\"#{card.id}",\"href\":\"resourceInventoryManagement/v4/resource/#{card.id}",\"category\":\"Network Resource\",\"description\":\"A Card Resource Instance\",\"resourceSpecification\":{\"id\":\"cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"href\":\"resourceCatalogManagement/v4/resourceSpecification/cd29956f-6c68-44cc-bf54-705eb8d2f754\",\"name\":\"card\",\"version\":\"v1.0.0\"}}) end end end diff --git a/test/instance_extension/characteristic_test.exs b/test/provider/extension/characteristic_test.exs similarity index 92% rename from test/instance_extension/characteristic_test.exs rename to test/provider/extension/characteristic_test.exs index bdb6234..c961704 100644 --- a/test/instance_extension/characteristic_test.exs +++ b/test/provider/extension/characteristic_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.CharacteristicTest do +defmodule Diffo.Provider.Extension.CharacteristicTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Parties diff --git a/test/instance_extension/feature_test.exs b/test/provider/extension/feature_test.exs similarity index 94% rename from test/instance_extension/feature_test.exs rename to test/provider/extension/feature_test.exs index f572d34..1122379 100644 --- a/test/instance_extension/feature_test.exs +++ b/test/provider/extension/feature_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.FeatureTest do +defmodule Diffo.Provider.Extension.FeatureTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Parties diff --git a/test/provider/extension/info_test.exs b/test/provider/extension/info_test.exs new file mode 100644 index 0000000..3a508f4 --- /dev/null +++ b/test/provider/extension/info_test.exs @@ -0,0 +1,76 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Provider.Extension.InfoTest do + @moduledoc false + use ExUnit.Case, async: true + + alias Diffo.Provider.Extension.Info + + describe "instance?/1" do + test "returns true for a BaseInstance-derived resource" do + assert Info.instance?(Diffo.Test.Instance.ShelfInstance) == true + end + + test "returns true for the base Instance resource" do + assert Info.instance?(Diffo.Provider.Instance) == true + end + + test "returns false for a BaseParty-derived resource" do + assert Info.instance?(Diffo.Test.Party.Organization) == false + end + + test "returns false for a BasePlace-derived resource" do + assert Info.instance?(Diffo.Test.Place.GeographicSite) == false + end + + test "returns false for a non-existent module" do + assert Info.instance?(NonExistent.Module) == false + end + end + + describe "party?/1" do + test "returns true for a BaseParty-derived resource" do + assert Info.party?(Diffo.Test.Party.Organization) == true + end + + test "returns true for the base Party resource" do + assert Info.party?(Diffo.Provider.Party) == true + end + + test "returns false for a BaseInstance-derived resource" do + assert Info.party?(Diffo.Test.Instance.ShelfInstance) == false + end + + test "returns false for a BasePlace-derived resource" do + assert Info.party?(Diffo.Test.Place.GeographicSite) == false + end + + test "returns false for a non-existent module" do + assert Info.party?(NonExistent.Module) == false + end + end + + describe "place?/1" do + test "returns true for a BasePlace-derived resource" do + assert Info.place?(Diffo.Test.Place.GeographicSite) == true + end + + test "returns true for the base Place resource" do + assert Info.place?(Diffo.Provider.Place) == true + end + + test "returns false for a BaseInstance-derived resource" do + assert Info.place?(Diffo.Test.Instance.ShelfInstance) == false + end + + test "returns false for a BaseParty-derived resource" do + assert Info.place?(Diffo.Test.Party.Organization) == false + end + + test "returns false for a non-existent module" do + assert Info.place?(NonExistent.Module) == false + end + end +end diff --git a/test/instance_extension/transformer_test.exs b/test/provider/extension/instance_transformer_test.exs similarity index 63% rename from test/instance_extension/transformer_test.exs rename to test/provider/extension/instance_transformer_test.exs index 1e1ed66..e08b364 100644 --- a/test/instance_extension/transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -2,20 +2,20 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.TransformerTest do +defmodule Diffo.Provider.Extension.InstanceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true - alias Diffo.Test.Shelf - alias Diffo.Test.Card - alias Diffo.Provider.Instance.Characteristic - alias Diffo.Provider.Instance.Feature + alias Diffo.Test.Instance.ShelfInstance + alias Diffo.Test.Instance.CardInstance + alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Feature + alias Diffo.Provider.Extension.PlaceDeclaration alias Diffo.Provider.Instance.Info - alias Diffo.Provider.Instance.Extension.PlaceDeclaration describe "PersistSpecification" do test "bakes specification/0 onto the resource" do - spec = Shelf.specification() + spec = ShelfInstance.specification() assert spec[:id] == "ef016d85-9dbd-429c-84da-1df56cc7dda5" assert spec[:name] == "shelf" assert spec[:type] == :resourceSpecification @@ -25,21 +25,21 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "card specification is baked correctly" do - spec = Card.specification() + spec = CardInstance.specification() assert spec[:id] == "cd29956f-6c68-44cc-bf54-705eb8d2f754" assert spec[:name] == "card" assert spec[:type] == :resourceSpecification end test "specification is also accessible via Info" do - assert Info.specification(Shelf)[:name] == "shelf" - assert Info.specification(Card)[:name] == "card" + assert Info.specification(ShelfInstance)[:name] == "shelf" + assert Info.specification(CardInstance)[:name] == "card" end end describe "PersistCharacteristics" do test "bakes characteristics/0 onto the resource" do - chars = Shelf.characteristics() + chars = ShelfInstance.characteristics() assert is_list(chars) assert length(chars) == 3 names = Enum.map(chars, & &1.name) @@ -49,28 +49,29 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "each characteristic is a Characteristic struct" do - [first | _] = Shelf.characteristics() + [first | _] = ShelfInstance.characteristics() assert is_struct(first, Characteristic) end test "characteristics are also accessible via Info" do - assert length(Info.characteristics(Shelf)) == 3 - assert length(Info.characteristics(Card)) == 2 + assert length(Info.characteristics(ShelfInstance)) == 3 + # Card has :card characteristic; :ports moved to pools do + assert length(Info.characteristics(CardInstance)) == 1 end test "Info.characteristic/2 returns the named characteristic" do - char = Info.characteristic(Shelf, :shelves) + char = Info.characteristic(ShelfInstance, :shelves) assert char.name == :shelves end test "Info.characteristic/2 returns nil for unknown name" do - assert Info.characteristic(Shelf, :nonexistent) == nil + assert Info.characteristic(ShelfInstance, :nonexistent) == nil end end describe "PersistFeatures" do test "bakes features/0 onto the resource" do - features = Shelf.features() + features = ShelfInstance.features() assert is_list(features) assert length(features) == 1 [feature] = features @@ -79,12 +80,12 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "each feature is a Feature struct" do - [feature] = Shelf.features() + [feature] = ShelfInstance.features() assert is_struct(feature, Feature) end test "feature characteristics are nested in the declaration" do - [feature] = Shelf.features() + [feature] = ShelfInstance.features() assert length(feature.characteristics) == 2 char_names = Enum.map(feature.characteristics, & &1.name) assert :deploymentClass in char_names @@ -92,36 +93,36 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "features are also accessible via Info" do - assert length(Info.features(Shelf)) == 1 - assert Info.features(Card) == [] + assert length(Info.features(ShelfInstance)) == 1 + assert Info.features(CardInstance) == [] end test "Info.feature/2 returns the named feature" do - feature = Info.feature(Shelf, :spectralManagement) + feature = Info.feature(ShelfInstance, :spectralManagement) assert feature.name == :spectralManagement end test "Info.feature/2 returns nil for unknown name" do - assert Info.feature(Shelf, :nonexistent) == nil + assert Info.feature(ShelfInstance, :nonexistent) == nil end test "Info.feature_characteristic/3 returns the named characteristic within a feature" do - char = Info.feature_characteristic(Shelf, :spectralManagement, :deploymentClass) + char = Info.feature_characteristic(ShelfInstance, :spectralManagement, :deploymentClass) assert char.name == :deploymentClass end test "Info.feature_characteristic/3 returns nil for unknown feature" do - assert Info.feature_characteristic(Shelf, :nonexistent, :deploymentClass) == nil + assert Info.feature_characteristic(ShelfInstance, :nonexistent, :deploymentClass) == nil end test "Info.feature_characteristic/3 returns nil for unknown characteristic" do - assert Info.feature_characteristic(Shelf, :spectralManagement, :nonexistent) == nil + assert Info.feature_characteristic(ShelfInstance, :spectralManagement, :nonexistent) == nil end end describe "PersistParties" do test "bakes parties/0 onto the resource" do - parties = Shelf.parties() + parties = ShelfInstance.parties() assert is_list(parties) assert length(parties) == 5 roles = Enum.map(parties, & &1.role) @@ -133,39 +134,39 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "reference party has reference flag set" do - provider = Enum.find(Shelf.parties(), &(&1.role == :provider)) + provider = Enum.find(ShelfInstance.parties(), &(&1.role == :provider)) assert provider.reference == true end test "calculate party has calculate set" do - manager = Enum.find(Shelf.parties(), &(&1.role == :manager)) + manager = Enum.find(ShelfInstance.parties(), &(&1.role == :manager)) assert manager.calculate == :manager_calc end test "plural party has constraints" do - installer = Enum.find(Shelf.parties(), &(&1.role == :installer)) + installer = Enum.find(ShelfInstance.parties(), &(&1.role == :installer)) assert installer.multiple == true assert installer.constraints == [min: 1, max: 3] end test "parties are also accessible via Info" do - assert length(Info.parties(Shelf)) == 5 - assert Info.parties(Card) == [] + assert length(Info.parties(ShelfInstance)) == 5 + assert Info.parties(CardInstance) == [] end test "Info.party/2 returns the named party declaration by role" do - p = Info.party(Shelf, :facilitator) + p = Info.party(ShelfInstance, :facilitator) assert p.role == :facilitator end test "Info.party/2 returns nil for unknown role" do - assert Info.party(Shelf, :nonexistent) == nil + assert Info.party(ShelfInstance, :nonexistent) == nil end end describe "PersistPlaces" do test "bakes places/0 onto the resource" do - places = Shelf.places() + places = ShelfInstance.places() assert is_list(places) assert length(places) == 2 roles = Enum.map(places, & &1.role) @@ -174,55 +175,55 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "each place is a PlaceDeclaration struct" do - [first | _] = Shelf.places() + [first | _] = ShelfInstance.places() assert is_struct(first, PlaceDeclaration) end test "reference place has reference flag set" do - billing = Enum.find(Shelf.places(), &(&1.role == :billing_address)) + billing = Enum.find(ShelfInstance.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) == [] + assert length(Info.places(ShelfInstance)) == 2 + assert Info.places(CardInstance) == [] end test "Info.place/2 returns the named place declaration by role" do - p = Info.place(Shelf, :installation_site) + p = Info.place(ShelfInstance, :installation_site) assert p.role == :installation_site end test "Info.place/2 returns nil for unknown role" do - assert Info.place(Shelf, :nonexistent) == nil + assert Info.place(ShelfInstance, :nonexistent) == nil end end describe "TransformBehaviour" do setup do - Code.ensure_loaded!(Shelf) - Code.ensure_loaded!(Card) + Code.ensure_loaded!(ShelfInstance) + Code.ensure_loaded!(CardInstance) :ok end test "build_before/1 is defined on shelf" do - assert function_exported?(Shelf, :build_before, 1) + assert function_exported?(ShelfInstance, :build_before, 1) end test "build_after/2 is defined on shelf" do - assert function_exported?(Shelf, :build_after, 2) + assert function_exported?(ShelfInstance, :build_after, 2) end test "build_before/1 is defined on card" do - assert function_exported?(Card, :build_before, 1) + assert function_exported?(CardInstance, :build_before, 1) end test "build_after/2 is defined on card" do - assert function_exported?(Card, :build_after, 2) + assert function_exported?(CardInstance, :build_after, 2) end test "action_create injects :specified_by argument into :build" do - action = Ash.Resource.Info.action(Shelf, :build) + action = Ash.Resource.Info.action(ShelfInstance, :build) arg_names = Enum.map(action.arguments, & &1.name) assert :specified_by in arg_names assert :features in arg_names @@ -230,7 +231,7 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "injected arguments are not public" do - action = Ash.Resource.Info.action(Shelf, :build) + action = Ash.Resource.Info.action(ShelfInstance, :build) injected = Enum.filter(action.arguments, &(&1.name in [:specified_by, :features, :characteristics])) @@ -239,56 +240,56 @@ defmodule Diffo.InstanceExtension.TransformerTest do end test "characteristic/1 returns the named characteristic" do - char = Shelf.characteristic(:shelves) + char = ShelfInstance.characteristic(:shelves) assert char.name == :shelves - assert char.value_type == {:array, Diffo.Test.ShelfValue} + assert char.value_type == {:array, Diffo.Test.Characteristic.ShelfCharacteristic} end test "characteristic/1 returns nil for unknown name" do - assert Shelf.characteristic(:nonexistent) == nil + assert ShelfInstance.characteristic(:nonexistent) == nil end test "feature/1 returns the named feature" do - feature = Shelf.feature(:spectralManagement) + feature = ShelfInstance.feature(:spectralManagement) assert feature.name == :spectralManagement assert feature.is_enabled? == true end test "feature/1 returns nil for unknown name" do - assert Shelf.feature(:nonexistent) == nil + assert ShelfInstance.feature(:nonexistent) == nil end test "feature_characteristic/2 returns the named characteristic within a feature" do - char = Shelf.feature_characteristic(:spectralManagement, :deploymentClass) + char = ShelfInstance.feature_characteristic(:spectralManagement, :deploymentClass) assert char.name == :deploymentClass end test "feature_characteristic/2 returns nil for unknown feature" do - assert Shelf.feature_characteristic(:nonexistent, :deploymentClass) == nil + assert ShelfInstance.feature_characteristic(:nonexistent, :deploymentClass) == nil end test "feature_characteristic/2 returns nil for unknown characteristic" do - assert Shelf.feature_characteristic(:spectralManagement, :nonexistent) == nil + assert ShelfInstance.feature_characteristic(:spectralManagement, :nonexistent) == nil end test "party/1 returns the named party declaration by role" do - p = Shelf.party(:facilitator) + p = ShelfInstance.party(:facilitator) assert p.role == :facilitator assert p.multiple == false end test "party/1 returns nil for unknown role" do - assert Shelf.party(:nonexistent) == nil + assert ShelfInstance.party(:nonexistent) == nil end test "place/1 returns the named place declaration by role" do - p = Shelf.place(:installation_site) + p = ShelfInstance.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 + assert ShelfInstance.place(:nonexistent) == nil end end end diff --git a/test/instance_extension/verifier_test.exs b/test/provider/extension/instance_verifier_test.exs similarity index 68% rename from test/instance_extension/verifier_test.exs rename to test/provider/extension/instance_verifier_test.exs index 0273c97..8fce63a 100644 --- a/test/instance_extension/verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.VerifierTest do +defmodule Diffo.Provider.Extension.InstanceVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false alias Diffo.Test.Util @@ -21,7 +21,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with invalid spec id" end - structure do + provider do specification do id "ef016d85-9dbd-429c-04da-1df56cc7dda5" name "invalid" @@ -45,7 +45,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-camelCase specification name" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "not camel case" @@ -69,7 +69,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with invalid specification type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -94,7 +94,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with negative major_version" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -119,7 +119,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with tmf_version below minimum" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -146,15 +146,15 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate characteristic name" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" end characteristics do - characteristic :foo, Diffo.Test.ShelfValue - characteristic :foo, Diffo.Test.ShelfValue + characteristic :foo, Diffo.Test.Characteristic.ShelfCharacteristic + characteristic :foo, Diffo.Test.Characteristic.ShelfCharacteristic end end end @@ -175,7 +175,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent characteristic value_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -203,7 +203,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent array characteristic value_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -233,7 +233,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate feature names" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -265,7 +265,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate feature characteristic names" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -273,8 +273,8 @@ defmodule Diffo.InstanceExtension.VerifierTest do features do feature :my_feature do - characteristic :baz, Diffo.Test.ShelfValue - characteristic :baz, Diffo.Test.ShelfValue + characteristic :baz, Diffo.Test.Characteristic.ShelfCharacteristic + characteristic :baz, Diffo.Test.Characteristic.ShelfCharacteristic end end end @@ -296,7 +296,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent feature characteristic value_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -322,22 +322,22 @@ defmodule Diffo.InstanceExtension.VerifierTest do fn -> defmodule DuplicatePartyRole do alias Diffo.Provider.BaseInstance - alias Diffo.Test.Shelf + alias Diffo.Test.Instance.ShelfInstance use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo resource do description "resource with duplicate party roles" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" end parties do - party :operator, Shelf - party :operator, Shelf + party :operator, ShelfInstance + party :operator, ShelfInstance end end end @@ -358,7 +358,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with non-existent party_type" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -376,7 +376,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do test "party_type not extending BaseParty warns DslError on compilation" do Util.assert_compile_time_warning( Spark.Error.DslError, - "parties: party_type Diffo.Test.Shelf does not extend BaseParty", + "parties: party_type Diffo.Test.Instance.ShelfInstance does not extend BaseParty", fn -> defmodule InvalidPartyBaseType do alias Diffo.Provider.BaseInstance @@ -386,14 +386,14 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with party_type that is not a BaseParty" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" end parties do - party :operator, Diffo.Test.Shelf + party :operator, Diffo.Test.Instance.ShelfInstance end end end @@ -416,16 +416,16 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with behaviour referencing a missing create action" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" end - end - behaviour do - actions do - create :nonexistent + behaviour do + actions do + create :nonexistent + end end end end @@ -446,16 +446,168 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with behaviour referencing a missing update action" end - structure do + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + behaviour do + actions do + update :nonexistent + end + end + end + end + end + ) + end + + test "create declared for an update action warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "behaviour: create :define does not exist as a create action on this resource", + fn -> + defmodule BehaviourWrongActionType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with create behaviour pointing at an update action" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + behaviour do + actions do + create :define + end + end + end + + actions do + update :define do + accept [] + end + end + end + end + ) + end + end + + describe "party_ref verifier" do + test "non-existent party_type on party_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type NonExistent.RefParty does not exist", + fn -> + defmodule InvalidPartyRefType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with party_ref pointing to a non-existent module" + end + + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" end + + parties do + party_ref :owner, NonExistent.RefParty + end end + end + end + ) + end + + test "party_ref with non-BaseParty type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "parties: party_type Diffo.Test.Instance.ShelfInstance does not extend BaseParty", + fn -> + defmodule InvalidPartyRefBaseType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with party_ref pointing to a non-party module" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + parties do + party_ref :owner, Diffo.Test.Instance.ShelfInstance + end + end + end + end + ) + end + end + + describe "place_ref verifier" do + test "non-existent place_type on place_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type NonExistent.RefPlace does not exist", + fn -> + defmodule InvalidPlaceRefType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with place_ref pointing to a non-existent module" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end + + places do + place_ref :billing, NonExistent.RefPlace + end + end + end + end + ) + end + + test "place_ref with non-BasePlace type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "places: place_type Diffo.Test.Instance.ShelfInstance does not extend BasePlace", + fn -> + defmodule InvalidPlaceRefBaseType do + alias Diffo.Provider.BaseInstance + use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo + + resource do + description "resource with place_ref pointing to a non-place module" + end + + provider do + specification do + id "cd29956f-6c68-44cc-bf54-705eb8d2f754" + name "invalid" + end - behaviour do - actions do - update :nonexistent + places do + place_ref :billing, Diffo.Test.Instance.ShelfInstance end end end diff --git a/test/instance_extension/party_test.exs b/test/provider/extension/party_test.exs similarity index 88% rename from test/instance_extension/party_test.exs rename to test/provider/extension/party_test.exs index 56f717a..34178bf 100644 --- a/test/instance_extension/party_test.exs +++ b/test/provider/extension/party_test.exs @@ -2,16 +2,16 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.PartyTest do +defmodule Diffo.Provider.Extension.PartyTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Provider.Instance.Extension.Info, as: InstanceInfo alias Diffo.Provider.Party.Extension.Info, as: PartyInfo - alias Diffo.Test.Organization - alias Diffo.Test.Person + alias Diffo.Test.Party.Organization + alias Diffo.Test.Party.Person - alias Diffo.Test.Shelf + alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Nbn alias Diffo.Test.Servo alias Diffo.Provider.Instance.Party @@ -26,7 +26,7 @@ defmodule Diffo.InstanceExtension.PartyTest do roles = PartyInfo.instances(Organization) assert length(roles) == 1 assert hd(roles).role == :facilitator - assert hd(roles).party_type == Diffo.Provider.Instance + assert hd(roles).instance_type == Diffo.Provider.Instance end test "party roles are declared" do @@ -41,7 +41,7 @@ defmodule Diffo.InstanceExtension.PartyTest do roles = PartyInfo.parties(Person) assert length(roles) == 1 assert hd(roles).role == :manager - assert hd(roles).party_type == Diffo.Test.Person + assert hd(roles).party_type == Diffo.Test.Party.Person end test "instance roles are declared" do @@ -53,14 +53,14 @@ defmodule Diffo.InstanceExtension.PartyTest do describe "Instance DSL — Shelf parties" do test "party declarations are accessible via info" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) roles = Enum.map(parties, & &1.role) assert :facilitator in roles assert :overseer in roles end test "party types are correct" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) facilitator = Enum.find(parties, &(&1.role == :facilitator)) overseer = Enum.find(parties, &(&1.role == :overseer)) assert facilitator.party_type == Organization @@ -68,38 +68,38 @@ defmodule Diffo.InstanceExtension.PartyTest do end test "singular party defaults to multiple: false" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) facilitator = Enum.find(parties, &(&1.role == :facilitator)) assert facilitator.multiple == false end test "reference: true is declared" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) provider = Enum.find(parties, &(&1.role == :provider)) assert provider.reference == true assert provider.multiple == false end test "reference defaults to false" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) facilitator = Enum.find(parties, &(&1.role == :facilitator)) assert facilitator.reference == false end test "calculate: is declared" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) manager = Enum.find(parties, &(&1.role == :manager)) assert manager.calculate == :manager_calc end test "parties (plural) sets multiple: true" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) installer = Enum.find(parties, &(&1.role == :installer)) assert installer.multiple == true end test "parties (plural) constraints are declared" do - parties = InstanceInfo.structure_parties(Shelf) + parties = InstanceInfo.structure_parties(ShelfInstance) installer = Enum.find(parties, &(&1.role == :installer)) assert installer.constraints == [min: 1, max: 3] end diff --git a/test/party_extension/transformer_test.exs b/test/provider/extension/party_transformer_test.exs similarity index 89% rename from test/party_extension/transformer_test.exs rename to test/provider/extension/party_transformer_test.exs index 3614d8b..4386133 100644 --- a/test/party_extension/transformer_test.exs +++ b/test/provider/extension/party_transformer_test.exs @@ -2,15 +2,15 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PartyExtension.TransformerTest do +defmodule Diffo.Provider.Extension.PartyTransformerTest do @moduledoc false use ExUnit.Case, async: true, 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.Test.Party.Organization + alias Diffo.Test.Party.Person + alias Diffo.Provider.Extension.InstanceRole + alias Diffo.Provider.Extension.PartyRole + alias Diffo.Provider.Extension.PlaceRole alias Diffo.Provider.Party.Extension.Info describe "PersistInstances" do diff --git a/test/party_extension/verifier_test.exs b/test/provider/extension/party_verifier_test.exs similarity index 62% rename from test/party_extension/verifier_test.exs rename to test/provider/extension/party_verifier_test.exs index d4d64ea..a4bec88 100644 --- a/test/party_extension/verifier_test.exs +++ b/test/provider/extension/party_verifier_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PartyExtension.VerifierTest do +defmodule Diffo.Provider.Extension.PartyVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false alias Diffo.Test.Util @@ -21,9 +21,11 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with duplicate instance role" end - instances do - role :operator, Diffo.Provider.Instance - role :operator, Diffo.Provider.Instance + provider do + instances do + role :operator, Diffo.Provider.Instance + role :operator, Diffo.Provider.Instance + end end end end @@ -43,8 +45,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with non-existent instance type" end - instances do - role :operator, NonExistent.InstanceModule + provider do + instances do + role :operator, NonExistent.InstanceModule + end end end end @@ -54,7 +58,7 @@ defmodule Diffo.PartyExtension.VerifierTest do 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", + "instances: instance_type Diffo.Test.Party.Organization does not extend BaseInstance", fn -> defmodule WrongInstanceType do alias Diffo.Provider.BaseParty @@ -64,8 +68,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with party as instance type" end - instances do - role :operator, Diffo.Test.Organization + provider do + instances do + role :operator, Diffo.Test.Party.Organization + end end end end @@ -87,9 +93,11 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with duplicate party role" end - parties do - role :employer, Diffo.Test.Organization - role :employer, Diffo.Test.Organization + provider do + parties do + role :employer, Diffo.Test.Party.Organization + role :employer, Diffo.Test.Party.Organization + end end end end @@ -109,8 +117,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with non-existent party type" end - parties do - role :employer, NonExistent.PartyModule + provider do + parties do + role :employer, NonExistent.PartyModule + end end end end @@ -130,8 +140,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with instance as party type" end - parties do - role :employer, Diffo.Provider.Instance + provider do + parties do + role :employer, Diffo.Provider.Instance + end end end end @@ -153,9 +165,11 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with duplicate place role" end - places do - role :headquarters, Diffo.Provider.Place - role :headquarters, Diffo.Provider.Place + provider do + places do + role :headquarters, Diffo.Provider.Place + role :headquarters, Diffo.Provider.Place + end end end end @@ -175,8 +189,10 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with non-existent place type" end - places do - role :headquarters, NonExistent.PlaceModule + provider do + places do + role :headquarters, NonExistent.PlaceModule + end end end end @@ -186,9 +202,9 @@ defmodule Diffo.PartyExtension.VerifierTest do 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", + "places: place_type Diffo.Test.Party.Organization does not extend BasePlace", fn -> - defmodule WrongPlaceRoleType do + defmodule WrongPartyPlaceRoleType do alias Diffo.Provider.BaseParty use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn @@ -196,8 +212,58 @@ defmodule Diffo.PartyExtension.VerifierTest do description "resource with party as place type" end - places do - role :headquarters, Diffo.Test.Organization + provider do + places do + role :headquarters, Diffo.Test.Party.Organization + end + end + end + end + ) + end + end + + describe "instance_ref verifier" do + test "non-existent instance_type on instance_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type NonExistent.RefInstance does not exist", + fn -> + defmodule InvalidPartyInstanceRefType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "party with instance_ref pointing to a non-existent module" + end + + provider do + instances do + instance_ref :manages, NonExistent.RefInstance + end + end + end + end + ) + end + + test "instance_ref with non-BaseInstance type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type Diffo.Test.Party.Organization does not extend BaseInstance", + fn -> + defmodule InvalidPartyInstanceRefBaseType do + alias Diffo.Provider.BaseParty + use Ash.Resource, fragments: [BaseParty], domain: Diffo.Test.Nbn + + resource do + description "party with instance_ref pointing to a non-instance module" + end + + provider do + instances do + instance_ref :manages, Diffo.Test.Party.Organization + end end end end diff --git a/test/instance_extension/place_test.exs b/test/provider/extension/place_test.exs similarity index 89% rename from test/instance_extension/place_test.exs rename to test/provider/extension/place_test.exs index 87e5cf5..9318745 100644 --- a/test/instance_extension/place_test.exs +++ b/test/provider/extension/place_test.exs @@ -2,16 +2,16 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.PlaceTest do +defmodule Diffo.Provider.Extension.PlaceTest do @moduledoc false use ExUnit.Case, async: true 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.Party.Organization + alias Diffo.Test.Place.GeographicSite - alias Diffo.Test.Shelf + alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Nbn setup do @@ -44,33 +44,33 @@ defmodule Diffo.InstanceExtension.PlaceTest do describe "Instance DSL — Shelf places" do test "place declarations are accessible via info" do - places = InstanceInfo.structure_places(Shelf) + places = InstanceInfo.structure_places(ShelfInstance) 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) + places = InstanceInfo.structure_places(ShelfInstance) 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) + places = InstanceInfo.structure_places(ShelfInstance) 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) + places = InstanceInfo.structure_places(ShelfInstance) 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) + places = InstanceInfo.structure_places(ShelfInstance) installation_site = Enum.find(places, &(&1.role == :installation_site)) assert installation_site.reference == false end diff --git a/test/place_extension/transformer_test.exs b/test/provider/extension/place_transformer_test.exs similarity index 87% rename from test/place_extension/transformer_test.exs rename to test/provider/extension/place_transformer_test.exs index 98c5158..e0137c6 100644 --- a/test/place_extension/transformer_test.exs +++ b/test/provider/extension/place_transformer_test.exs @@ -2,14 +2,14 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PlaceExtension.TransformerTest do +defmodule Diffo.Provider.Extension.PlaceTransformerTest do @moduledoc false use ExUnit.Case, async: true, 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.Test.Place.GeographicSite + alias Diffo.Provider.Extension.InstanceRole + alias Diffo.Provider.Extension.PartyRole + alias Diffo.Provider.Extension.PlaceRole alias Diffo.Provider.Place.Extension.Info describe "PersistInstances" do diff --git a/test/place_extension/verifier_test.exs b/test/provider/extension/place_verifier_test.exs similarity index 62% rename from test/place_extension/verifier_test.exs rename to test/provider/extension/place_verifier_test.exs index 48c4eb1..3d10534 100644 --- a/test/place_extension/verifier_test.exs +++ b/test/provider/extension/place_verifier_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.PlaceExtension.VerifierTest do +defmodule Diffo.Provider.Extension.PlaceVerifierTest do @moduledoc false use ExUnit.Case, async: true, async: false alias Diffo.Test.Util @@ -21,9 +21,11 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with duplicate instance role" end - instances do - role :site_for, Diffo.Provider.Instance - role :site_for, Diffo.Provider.Instance + provider do + instances do + role :site_for, Diffo.Provider.Instance + role :site_for, Diffo.Provider.Instance + end end end end @@ -43,8 +45,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with non-existent instance type" end - instances do - role :site_for, NonExistent.InstanceModule + provider do + instances do + role :site_for, NonExistent.InstanceModule + end end end end @@ -54,7 +58,7 @@ defmodule Diffo.PlaceExtension.VerifierTest do 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", + "instances: instance_type Diffo.Test.Party.Organization does not extend BaseInstance", fn -> defmodule WrongPlaceInstanceType do alias Diffo.Provider.BasePlace @@ -64,8 +68,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with party as instance type" end - instances do - role :site_for, Diffo.Test.Organization + provider do + instances do + role :site_for, Diffo.Test.Party.Organization + end end end end @@ -87,9 +93,11 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with duplicate party role" end - parties do - role :managed_by, Diffo.Test.Organization - role :managed_by, Diffo.Test.Organization + provider do + parties do + role :managed_by, Diffo.Test.Party.Organization + role :managed_by, Diffo.Test.Party.Organization + end end end end @@ -109,8 +117,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with non-existent party type" end - parties do - role :managed_by, NonExistent.PartyModule + provider do + parties do + role :managed_by, NonExistent.PartyModule + end end end end @@ -130,8 +140,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with instance as party type" end - parties do - role :managed_by, Diffo.Provider.Instance + provider do + parties do + role :managed_by, Diffo.Provider.Instance + end end end end @@ -153,9 +165,11 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with duplicate place role" end - places do - role :contained_in, Diffo.Provider.Place - role :contained_in, Diffo.Provider.Place + provider do + places do + role :contained_in, Diffo.Provider.Place + role :contained_in, Diffo.Provider.Place + end end end end @@ -175,8 +189,10 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with non-existent place type" end - places do - role :contained_in, NonExistent.PlaceModule + provider do + places do + role :contained_in, NonExistent.PlaceModule + end end end end @@ -186,9 +202,9 @@ defmodule Diffo.PlaceExtension.VerifierTest do 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", + "places: place_type Diffo.Test.Party.Organization does not extend BasePlace", fn -> - defmodule WrongPlacePlaceType do + defmodule WrongPlacePlaceRoleType do alias Diffo.Provider.BasePlace use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn @@ -196,8 +212,58 @@ defmodule Diffo.PlaceExtension.VerifierTest do description "place with party as place type" end - places do - role :contained_in, Diffo.Test.Organization + provider do + places do + role :contained_in, Diffo.Test.Party.Organization + end + end + end + end + ) + end + end + + describe "instance_ref verifier" do + test "non-existent instance_type on instance_ref warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type NonExistent.RefInstance does not exist", + fn -> + defmodule InvalidPlaceInstanceRefType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with instance_ref pointing to a non-existent module" + end + + provider do + instances do + instance_ref :site_for, NonExistent.RefInstance + end + end + end + end + ) + end + + test "instance_ref with non-BaseInstance type warns DslError on compilation" do + Util.assert_compile_time_warning( + Spark.Error.DslError, + "instances: instance_type Diffo.Test.Party.Organization does not extend BaseInstance", + fn -> + defmodule InvalidPlaceInstanceRefBaseType do + alias Diffo.Provider.BasePlace + use Ash.Resource, fragments: [BasePlace], domain: Diffo.Test.Nbn + + resource do + description "place with instance_ref pointing to a non-instance module" + end + + provider do + instances do + instance_ref :site_for, Diffo.Test.Party.Organization + end end end end diff --git a/test/instance_extension/specification_test.exs b/test/provider/extension/specification_test.exs similarity index 67% rename from test/instance_extension/specification_test.exs rename to test/provider/extension/specification_test.exs index 87349c4..c14f7e9 100644 --- a/test/instance_extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -2,11 +2,11 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.SpecificationTest do +defmodule Diffo.Provider.Extension.SpecificationTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Servo - alias Diffo.Test.Shelf + alias Diffo.Test.Instance.ShelfInstance setup do AshNeo4j.Sandbox.checkout() @@ -15,8 +15,8 @@ defmodule Diffo.InstanceExtension.SpecificationTest do describe "specification" do test "description declared in specification DSL roundtrips to the persisted specification" do - spec_id = Shelf.specification()[:id] - description = Shelf.specification()[:description] + spec_id = ShelfInstance.specification()[:id] + description = ShelfInstance.specification()[:description] Servo.build_shelf(%{name: "s"}) @@ -27,22 +27,22 @@ defmodule Diffo.InstanceExtension.SpecificationTest do test "minor_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(Shelf.specification()[:id]) - assert specification.minor_version == Shelf.specification()[:minor_version] + {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.minor_version == ShelfInstance.specification()[:minor_version] end test "patch_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(Shelf.specification()[:id]) - assert specification.patch_version == Shelf.specification()[:patch_version] + {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.patch_version == ShelfInstance.specification()[:patch_version] end test "tmf_version declared in specification DSL roundtrips to the persisted specification" do Servo.build_shelf(%{name: "s"}) - {:ok, specification} = Diffo.Provider.get_specification_by_id(Shelf.specification()[:id]) - assert specification.tmf_version == Shelf.specification()[:tmf_version] + {:ok, specification} = Diffo.Provider.get_specification_by_id(ShelfInstance.specification()[:id]) + assert specification.tmf_version == ShelfInstance.specification()[:tmf_version] end end end diff --git a/test/provider/versioning_test.exs b/test/provider/versioning_test.exs index d8fc68b..555350d 100644 --- a/test/provider/versioning_test.exs +++ b/test/provider/versioning_test.exs @@ -7,8 +7,8 @@ defmodule Diffo.Provider.VersioningTest do use ExUnit.Case, async: true alias Diffo.Test.Servo - alias Diffo.Test.Broadband - alias Diffo.Test.BroadbandV2 + alias Diffo.Test.Instance.Broadband + alias Diffo.Test.Instance.BroadbandV2 setup do AshNeo4j.Sandbox.checkout() diff --git a/test/support/nbn.ex b/test/support/nbn.ex index 0c99bdc..337aac4 100644 --- a/test/support/nbn.ex +++ b/test/support/nbn.ex @@ -12,11 +12,11 @@ defmodule Diffo.Test.Nbn do otp_app: :diffo, validate_config_inclusion?: false - alias Diffo.Test.Organization - alias Diffo.Test.Person - alias Diffo.Test.Carrier - alias Diffo.Test.GeographicSite - alias Diffo.Test.ExchangeBuilding + alias Diffo.Test.Party.Organization + alias Diffo.Test.Party.Person + alias Diffo.Test.Party.Carrier + alias Diffo.Test.Place.GeographicSite + alias Diffo.Test.Place.ExchangeBuilding domain do description "NBN party and place domain" diff --git a/test/support/resource/card_value.ex b/test/support/resource/card_value.ex deleted file mode 100644 index f6dce53..0000000 --- a/test/support/resource/card_value.ex +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-FileCopyrightText: 2025 diffo contributors -# -# SPDX-License-Identifier: MIT - -defmodule Diffo.Test.CardValue do - @moduledoc """ - Diffo - TMF Service and Resource Management with a difference - - CardValue - AshTyped Struct for Card Characteristic Value - """ - use Ash.TypedStruct, extensions: [AshJason.TypedStruct, AshOutstanding.TypedStruct] - - jason do - pick [:name, :family, :model, :technology] - compact true - end - - outstanding do - expect [:name] - end - - typed_struct do - field :name, :string, description: "the card name" - - field :family, :atom, description: "the card family name" - - field :model, :string, description: "the card model name" - - field :technology, :atom, description: "the card technology" - end - - defimpl String.Chars do - def to_string(struct) do - inspect(struct) - end - end -end diff --git a/test/support/resource/characteristic/card_characteristic.ex b/test/support/resource/characteristic/card_characteristic.ex new file mode 100644 index 0000000..0d85b4c --- /dev/null +++ b/test/support/resource/characteristic/card_characteristic.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.CardCharacteristic do + @moduledoc "Typed characteristic for a Card's identity." + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: Diffo.Test.Servo + + resource do + description "Typed characteristic carrying card identity fields" + plural_name :card_values + end + + attributes do + attribute :family, :atom, public?: true, description: "the card family name" + attribute :model, :string, public?: true, description: "the card model name" + attribute :technology, :atom, public?: true, description: "the card technology" + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + actions do + create :create do + accept [:name, :family, :model, :technology] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:family, :model, :technology] + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end diff --git a/test/support/resource/characteristic/card_characteristic/value.ex b/test/support/resource/characteristic/card_characteristic/value.ex new file mode 100644 index 0000000..e1d4835 --- /dev/null +++ b/test/support/resource/characteristic/card_characteristic/value.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.CardCharacteristic.Value do + @moduledoc "Typed value struct for a Card characteristic." + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + typed_struct do + field :family, :atom, description: "the card family name" + field :model, :string, description: "the card model name" + field :technology, :atom, description: "the card technology" + end + + jason do + pick [:family, :model, :technology] + compact true + end +end diff --git a/test/support/resource/characteristic/deployment_class.ex b/test/support/resource/characteristic/deployment_class.ex new file mode 100644 index 0000000..d816f2c --- /dev/null +++ b/test/support/resource/characteristic/deployment_class.ex @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.DeploymentClass do + @moduledoc "Typed characteristic for a deployment class within a spectral management feature." + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: Diffo.Test.Servo + + resource do + description "Typed characteristic carrying deployment class fields" + plural_name :deployment_class_values + end + + attributes do + attribute :class, :string, public?: true, description: "the deployment class" + attribute :mask, :string, public?: true, description: "the mask name" + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + actions do + create :create do + accept [:name, :class, :mask] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:class, :mask] + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end diff --git a/test/support/resource/characteristic/deployment_class/value.ex b/test/support/resource/characteristic/deployment_class/value.ex new file mode 100644 index 0000000..53a6179 --- /dev/null +++ b/test/support/resource/characteristic/deployment_class/value.ex @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.DeploymentClass.Value do + @moduledoc "Typed value struct for a DeploymentClass characteristic." + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + typed_struct do + field :class, :string, description: "the deployment class" + field :mask, :string, description: "the mask name" + end + + jason do + pick [:class, :mask] + compact true + end +end diff --git a/test/support/resource/characteristic/shelf_characteristic.ex b/test/support/resource/characteristic/shelf_characteristic.ex new file mode 100644 index 0000000..7545df1 --- /dev/null +++ b/test/support/resource/characteristic/shelf_characteristic.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.ShelfCharacteristic do + @moduledoc "Typed characteristic for a Shelf's identity." + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: Diffo.Test.Servo + + resource do + description "Typed characteristic carrying shelf identity fields" + plural_name :shelf_values + end + + attributes do + attribute :family, :atom, public?: true, description: "the shelf family name" + attribute :model, :string, public?: true, description: "the shelf model name" + attribute :technology, :atom, public?: true, description: "the shelf technology" + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + actions do + create :create do + accept [:name, :family, :model, :technology] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:family, :model, :technology] + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end diff --git a/test/support/resource/characteristic/shelf_characteristic/value.ex b/test/support/resource/characteristic/shelf_characteristic/value.ex new file mode 100644 index 0000000..57aaf70 --- /dev/null +++ b/test/support/resource/characteristic/shelf_characteristic/value.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.ShelfCharacteristic.Value do + @moduledoc "Typed value struct for a Shelf characteristic." + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + typed_struct do + field :family, :atom, description: "the shelf family name" + field :model, :string, description: "the shelf model name" + field :technology, :atom, description: "the shelf technology" + end + + jason do + pick [:family, :model, :technology] + compact true + end +end diff --git a/test/support/resource/deployment_class_value.ex b/test/support/resource/deployment_class_value.ex deleted file mode 100644 index c82933e..0000000 --- a/test/support/resource/deployment_class_value.ex +++ /dev/null @@ -1,32 +0,0 @@ -# SPDX-FileCopyrightText: 2025 diffo contributors -# -# SPDX-License-Identifier: MIT - -defmodule Diffo.Test.DeploymentClassValue do - @moduledoc """ - Diffo - TMF Service and Resource Management with a difference - - DeploymentClassValue - AshTyped Struct for DeploymentClass Feature Characteristic Value - """ - use Ash.TypedStruct, extensions: [AshJason.TypedStruct, AshOutstanding.TypedStruct] - - jason do - pick [:class, :mask] - compact true - end - - outstanding do - expect [:name] - end - - typed_struct do - field :class, :string, description: "the deployment class" - field :mask, :string, description: "the mask name" - end - - defimpl String.Chars do - def to_string(struct) do - inspect(struct) - end - end -end diff --git a/test/support/resource/broadband.ex b/test/support/resource/instance/broadband.ex similarity index 89% rename from test/support/resource/broadband.ex rename to test/support/resource/instance/broadband.ex index c09df65..946967a 100644 --- a/test/support/resource/broadband.ex +++ b/test/support/resource/instance/broadband.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Broadband do +defmodule Diffo.Test.Instance.Broadband do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -22,7 +22,7 @@ defmodule Diffo.Test.Broadband do plural_name :broadbands end - structure do + provider do specification do id "a1b2c3d4-e5f6-4a7b-8c9d-e0f1a2b3c4d5" name "broadband" @@ -31,11 +31,11 @@ defmodule Diffo.Test.Broadband do description "A broadband access service" category "Access" end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end diff --git a/test/support/resource/broadband_v2.ex b/test/support/resource/instance/broadband_v2.ex similarity index 90% rename from test/support/resource/broadband_v2.ex rename to test/support/resource/instance/broadband_v2.ex index 4abf3c4..ceea732 100644 --- a/test/support/resource/broadband_v2.ex +++ b/test/support/resource/instance/broadband_v2.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.BroadbandV2 do +defmodule Diffo.Test.Instance.BroadbandV2 do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -22,7 +22,7 @@ defmodule Diffo.Test.BroadbandV2 do plural_name :broadband_v2s end - structure do + provider do specification do id "f6e5d4c3-b2a1-4f0e-9d8c-7b6a5f4e3d2c" name "broadband" @@ -31,11 +31,11 @@ defmodule Diffo.Test.BroadbandV2 do description "A broadband access service — :fttb technology retired" category "Access" end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end diff --git a/test/support/resource/card.ex b/test/support/resource/instance/card_instance.ex similarity index 80% rename from test/support/resource/card.ex rename to test/support/resource/instance/card_instance.ex index 9792b8e..8e432ce 100644 --- a/test/support/resource/card.ex +++ b/test/support/resource/instance/card_instance.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Card do +defmodule Diffo.Test.Instance.CardInstance do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -10,12 +10,12 @@ defmodule Diffo.Test.Card do """ alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship - alias Diffo.Provider.Instance.Characteristic + alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Pool alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableValue alias Diffo.Test.Servo - alias Diffo.Test.CardValue + alias Diffo.Test.Characteristic.CardCharacteristic use Ash.Resource, fragments: [BaseInstance], @@ -26,7 +26,7 @@ defmodule Diffo.Test.Card do plural_name :Cards end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "card" @@ -36,14 +36,17 @@ defmodule Diffo.Test.Card do end characteristics do - characteristic :card, CardValue - characteristic :ports, AssignableValue + characteristic :card, CardCharacteristic + end + + pools do + pool :ports, :port end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end @@ -65,7 +68,9 @@ defmodule Diffo.Test.Card do argument :characteristic_value_updates, {:array, :term} change after_action(fn changeset, result, _context -> - with {:ok, result} <- Characteristic.update_values(result, changeset), + with {:ok, result} <- + Characteristic.update_all(result, changeset, characteristics()), + {:ok, result} <- Pool.update_pools(result, changeset, pools()), {:ok, result} <- Servo.get_card_by_id(result.id), do: {:ok, result} end) @@ -87,7 +92,7 @@ defmodule Diffo.Test.Card do argument :assignment, :struct, constraints: [instance_of: Assignment] change after_action(fn changeset, result, _context -> - with {:ok, result} <- Assigner.assign(result, changeset, :ports, :port), + with {:ok, result} <- Assigner.assign(result, changeset, :ports), {:ok, result} <- Servo.get_card_by_id(result.id), do: {:ok, result} end) diff --git a/test/support/resource/shelf.ex b/test/support/resource/instance/shelf_instance.ex similarity index 75% rename from test/support/resource/shelf.ex rename to test/support/resource/instance/shelf_instance.ex index b2f87b9..baeee6e 100644 --- a/test/support/resource/shelf.ex +++ b/test/support/resource/instance/shelf_instance.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Shelf do +defmodule Diffo.Test.Instance.ShelfInstance do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -11,14 +11,15 @@ defmodule Diffo.Test.Shelf do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship - alias Diffo.Provider.Instance.Characteristic + alias Diffo.Provider.Extension.Characteristic alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue - alias Diffo.Test.Servo - alias Diffo.Test.ShelfValue - alias Diffo.Test.DeploymentClassValue + alias Diffo.Test.Characteristic.ShelfCharacteristic + alias Diffo.Test.Characteristic.DeploymentClass + alias Diffo.Test.Party.Organization + alias Diffo.Test.Party.Person use Ash.Resource, fragments: [BaseInstance], @@ -29,7 +30,7 @@ defmodule Diffo.Test.Shelf do plural_name :Shelves end - structure do + provider do specification do id "ef016d85-9dbd-429c-84da-1df56cc7dda5" name "shelf" @@ -45,34 +46,34 @@ defmodule Diffo.Test.Shelf do features do feature :spectralManagement do is_enabled? true - characteristic :deploymentClass, DeploymentClassValue - characteristic :deploymentClasses, {:array, DeploymentClassValue} + characteristic :deploymentClass, DeploymentClass + characteristic :deploymentClasses, {:array, DeploymentClass} end end characteristics do - characteristic :shelf, ShelfValue + characteristic :shelf, ShelfCharacteristic characteristic :slots, AssignableValue - characteristic :shelves, {:array, ShelfValue} + characteristic :shelves, {:array, ShelfCharacteristic} end parties do - party :facilitator, Diffo.Test.Organization - party :overseer, Diffo.Test.Person - party :provider, Diffo.Test.Organization, reference: true - party :manager, Diffo.Test.Organization, calculate: :manager_calc - parties :installer, Diffo.Test.Person, constraints: [min: 1, max: 3] + party :facilitator, Organization + party :overseer, Person + party_ref :provider, Organization + party :manager, Organization, calculate: :manager_calc + parties :installer, Person, constraints: [min: 1, max: 3] end places do place :installation_site, Diffo.Provider.Place - place :billing_address, Diffo.Provider.Place, reference: true + place_ref :billing_address, Diffo.Provider.Place end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end @@ -94,7 +95,8 @@ defmodule Diffo.Test.Shelf do argument :characteristic_value_updates, {:array, :term} change after_action(fn changeset, result, _context -> - with {:ok, result} <- Characteristic.update_values(result, changeset), + with {:ok, result} <- + Characteristic.update_all(result, changeset, characteristics()), {:ok, result} <- Servo.get_shelf_by_id(result.id), do: {:ok, result} end) diff --git a/test/support/resource/carrier.ex b/test/support/resource/party/carrier.ex similarity index 86% rename from test/support/resource/carrier.ex rename to test/support/resource/party/carrier.ex index c99abf6..f41c71e 100644 --- a/test/support/resource/carrier.ex +++ b/test/support/resource/party/carrier.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Carrier do +defmodule Diffo.Test.Party.Carrier do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -52,11 +52,13 @@ defmodule Diffo.Test.Carrier do end end - instances do - role :provider, Diffo.Provider.Instance - end + provider do + instances do + role :provider, Diffo.Provider.Instance + end - places do - role :exchange, Diffo.Provider.Place + places do + role :exchange, Diffo.Provider.Place + end end end diff --git a/test/support/resource/organization.ex b/test/support/resource/party/organization.ex similarity index 73% rename from test/support/resource/organization.ex rename to test/support/resource/party/organization.ex index 0a7c7bc..b49d08d 100644 --- a/test/support/resource/organization.ex +++ b/test/support/resource/party/organization.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Organization do +defmodule Diffo.Test.Party.Organization do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -37,15 +37,17 @@ defmodule Diffo.Test.Organization do end end - instances do - role :facilitator, Diffo.Provider.Instance - end + provider do + instances do + role :facilitator, Diffo.Provider.Instance + end - parties do - role :employer, Diffo.Test.Person - end + parties do + role :employer, Diffo.Test.Party.Person + end - places do - role :headquarters, Diffo.Provider.Place + places do + role :headquarters, Diffo.Provider.Place + end end end diff --git a/test/support/resource/person.ex b/test/support/resource/party/person.ex similarity index 73% rename from test/support/resource/person.ex rename to test/support/resource/party/person.ex index e260004..25d82f4 100644 --- a/test/support/resource/person.ex +++ b/test/support/resource/party/person.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Person do +defmodule Diffo.Test.Party.Person do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -37,15 +37,17 @@ defmodule Diffo.Test.Person do end end - instances do - role :overseer, Diffo.Provider.Instance - end + provider do + instances do + role :overseer, Diffo.Provider.Instance + end - parties do - role :manager, Diffo.Test.Person - end + parties do + role :manager, Diffo.Test.Party.Person + end - places do - role :residence, Diffo.Provider.Place + places do + role :residence, Diffo.Provider.Place + end end end diff --git a/test/support/resource/exchange_building.ex b/test/support/resource/place/exchange_building.ex similarity index 86% rename from test/support/resource/exchange_building.ex rename to test/support/resource/place/exchange_building.ex index d12d96c..ae5281a 100644 --- a/test/support/resource/exchange_building.ex +++ b/test/support/resource/place/exchange_building.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.ExchangeBuilding do +defmodule Diffo.Test.Place.ExchangeBuilding do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -54,11 +54,13 @@ defmodule Diffo.Test.ExchangeBuilding do end end - parties do - role :operator, Diffo.Test.Carrier - end + provider do + instances do + role :host, Diffo.Provider.Instance + end - instances do - role :host, Diffo.Provider.Instance + parties do + role :operator, Diffo.Test.Party.Carrier + end end end diff --git a/test/support/resource/geographic_site.ex b/test/support/resource/place/geographic_site.ex similarity index 73% rename from test/support/resource/geographic_site.ex rename to test/support/resource/place/geographic_site.ex index 81ce42c..41e5be2 100644 --- a/test/support/resource/geographic_site.ex +++ b/test/support/resource/place/geographic_site.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.GeographicSite do +defmodule Diffo.Test.Place.GeographicSite do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -38,15 +38,17 @@ defmodule Diffo.Test.GeographicSite do end end - instances do - role :installed_at, Diffo.Provider.Instance - end + provider do + instances do + role :installed_at, Diffo.Provider.Instance + end - parties do - role :managed_by, Diffo.Test.Organization - end + parties do + role :managed_by, Diffo.Test.Party.Organization + end - places do - role :contained_in, Diffo.Provider.Place + places do + role :contained_in, Diffo.Provider.Place + end end end diff --git a/test/support/resource/shelf_value.ex b/test/support/resource/shelf_value.ex deleted file mode 100644 index 0cdbc0e..0000000 --- a/test/support/resource/shelf_value.ex +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-FileCopyrightText: 2025 diffo contributors -# -# SPDX-License-Identifier: MIT - -defmodule Diffo.Test.ShelfValue do - @moduledoc """ - Diffo - TMF Service and Resource Management with a difference - - ShelfValue - AshTyped Struct for Shelf Characteristic Value - """ - use Ash.TypedStruct, extensions: [AshJason.TypedStruct, AshOutstanding.TypedStruct] - - jason do - pick [:name, :family, :model, :technology] - compact true - end - - outstanding do - expect [:name] - end - - typed_struct do - field :name, :string, description: "the shelf name" - - field :family, :atom, description: "the shelf family name" - - field :model, :string, description: "the shelf model name" - - field :technology, :atom, description: "the shelf technology" - end - - defimpl String.Chars do - def to_string(struct) do - inspect(struct) - end - end -end diff --git a/test/support/servo.ex b/test/support/servo.ex index 0121a5b..7a2f49b 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -12,17 +12,21 @@ defmodule Diffo.Test.Servo do otp_app: :diffo, validate_config_inclusion?: false - alias Diffo.Test.Shelf - alias Diffo.Test.Card - alias Diffo.Test.Broadband - alias Diffo.Test.BroadbandV2 + alias Diffo.Test.Instance.ShelfInstance + alias Diffo.Test.Instance.CardInstance + alias Diffo.Test.Instance.Broadband + alias Diffo.Test.Instance.BroadbandV2 + alias Diffo.Test.Characteristic.ShelfCharacteristic + alias Diffo.Test.Characteristic.CardCharacteristic + alias Diffo.Test.Characteristic.DeploymentClass + alias Diffo.Provider.AssignableCharacteristic domain do description "service and resource management" end resources do - resource Shelf do + resource ShelfInstance do define :get_shelf_by_id, action: :read, get_by: :id define :build_shelf, action: :build define :define_shelf, action: :define @@ -30,7 +34,7 @@ defmodule Diffo.Test.Servo do define :assign_slot, action: :assign_slot end - resource Card do + resource CardInstance do define :get_card_by_id, action: :read, get_by: :id define :build_card, action: :build define :define_card, action: :define @@ -47,5 +51,10 @@ defmodule Diffo.Test.Servo do define :build_broadband_v2, action: :build define :get_broadband_v2_by_id, action: :read, get_by: :id end + + resource ShelfCharacteristic + resource CardCharacteristic + resource DeploymentClass + resource AssignableCharacteristic end end diff --git a/test/type/dynamic_test.exs b/test/type/dynamic_test.exs index 2778b52..8989f73 100644 --- a/test/type/dynamic_test.exs +++ b/test/type/dynamic_test.exs @@ -8,7 +8,7 @@ defmodule Diffo.Type.DynamicTest do use Outstand alias Diffo.Type.Dynamic alias Diffo.Test.Patch - alias Diffo.Test.CardValue + alias Diffo.Test.Characteristic.CardCharacteristic, as: CardValue describe "dynamic type validation" do test "cast_input rejects non-NewType scalar Ash type" do diff --git a/usage-rules.md b/usage-rules.md index 0afb07d..1381c65 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -10,17 +10,20 @@ SPDX-License-Identifier: MIT Diffo is an Ash Framework layer that models [TM Forum](https://www.tmforum.org/) (TMF) Service and Resource Management domains on top of a Neo4j graph database. It provides three base -fragments — `BaseInstance`, `BaseParty`, `BasePlace` — plus the `Diffo.Provider.Instance.Extension` -and `Diffo.Provider.Party.Extension` DSLs. Read these rules and the Ash/AshNeo4j usage rules -**before** writing any domain code. +fragments — `BaseInstance`, `BaseParty`, `BasePlace` — plus the unified `Diffo.Provider.Extension` +DSL. Read these rules and the Ash/AshNeo4j usage rules **before** writing any domain code. ## The three kinds of domain resource -| Kind | Base fragment | DSL extension | +| Kind | Base fragment | Marker extension | |---|---|---| | Instance (service or resource) | `Diffo.Provider.BaseInstance` | `Diffo.Provider.Instance.Extension` | | Party (organisation, person, entity) | `Diffo.Provider.BaseParty` | `Diffo.Provider.Party.Extension` | -| Place (site, address, location) | `Diffo.Provider.BasePlace` | `Diffo.Provider.Party.Extension` | +| Place (site, address, location) | `Diffo.Provider.BasePlace` | `Diffo.Provider.Place.Extension` | + +All three kinds use the same unified `Diffo.Provider.Extension` DSL with a single `provider do` +section. The marker extensions are zero-section extensions used only for kind identification +via `Ash.Resource.Info.extensions/1` — they carry no DSL of their own. Do **not** use plain `Ash.Resource` + `AshNeo4j.DataLayer` directly for domain resources. Always start from the appropriate base fragment: @@ -32,19 +35,25 @@ defmodule MyApp.BroadbandService do end ``` -## Instance Extension DSL +## The unified `provider do` DSL + +All DSL declarations live inside a single `provider do` block. The sections available +depend on the resource kind: -Every resource using `BaseInstance` gains two top-level DSL sections: `structure do` and -`behaviour do`. +- **Instance** — `specification`, `characteristics`, `features`, `pools`, `parties`, `places`, `behaviour` +- **Party** — `instances`, `parties`, `places` +- **Place** — `instances`, `parties`, `places` -### structure +Verifiers enforce that each kind uses only the sections relevant to it. -`specification do` — declares the TMF Specification for this Instance kind. The `id` is a -**stable UUID4 that must be the same in every environment** — generate it once and never -change it. A new major version requires a new module with a new `id`. +### `specification do` — Instance only + +Declares the TMF Specification for this Instance kind. The `id` is a **stable UUID4 that +must be the same in every environment** — generate it once and never change it. A new major +version requires a new module with a new `id`. ```elixir -structure do +provider do specification do id "da9b207a-26c3-451d-8abd-0640c6349979" name "DSL Access Service" @@ -56,64 +65,237 @@ structure do end ``` -`characteristics do` — declares typed value slots. Each characteristic is backed by an -`Ash.TypedStruct`. Do **not** add plain Ash attributes for data that belongs in a characteristic. +### `characteristics do` — Instance only + +Declares typed value slots. Each characteristic is a `Diffo.Provider.BaseCharacteristic`-derived +Ash resource with direct typed attributes. A companion `.Value` TypedStruct (using +`AshJason.TypedStruct`) drives ordered JSON encoding via a `:value` calculation. Do **not** +add plain Ash attributes for data that belongs in a characteristic. + +```elixir +provider do + characteristics do + characteristic :downstream_speed, MyApp.SpeedCharacteristic + characteristic :access_technology, MyApp.AccessTechnologyCharacteristic + characteristic :ports, {:array, MyApp.PortCharacteristic} + end +end +``` + +Each characteristic module uses `Diffo.Provider.BaseCharacteristic` as a fragment and declares +its own attributes, a `:value` calculation, and create/update actions: ```elixir -characteristics do - characteristic :downstream_speed, MyApp.Speed - characteristic :access_technology, MyApp.AccessTechnology +defmodule MyApp.SpeedCharacteristic do + use Ash.Resource, + fragments: [Diffo.Provider.BaseCharacteristic], + domain: MyApp.Domain + + attributes do + attribute :downstream_mbps, :integer, public?: true + attribute :upstream_mbps, :integer, public?: true + end + + calculations do + calculate :value, Diffo.Type.CharacteristicValue, + Diffo.Provider.Calculations.CharacteristicValue do + public? true + end + end + + actions do + create :create do + accept [:name, :downstream_mbps, :upstream_mbps] + argument :instance_id, :uuid + argument :feature_id, :uuid + change manage_relationship(:instance_id, :instance, type: :append) + change manage_relationship(:feature_id, :feature, type: :append) + end + + update :update do + accept [:downstream_mbps, :upstream_mbps] + end + end + + preparations do + prepare build(load: [:value]) + end + + jason do + pick [:name, :value] + compact true + end +end + +defmodule MyApp.SpeedCharacteristic.Value do + use Ash.TypedStruct, extensions: [AshJason.TypedStruct] + + typed_struct do + field :downstream_mbps, :integer + field :upstream_mbps, :integer + end + + jason do + pick [:downstream_mbps, :upstream_mbps] + compact true + end end ``` -`features do` — declares optional capabilities, each with an enabled/disabled default and -optionally its own typed characteristic payload: +### `features do` — Instance only + +Declares optional capabilities, each with an enabled/disabled default and optionally its +own typed characteristic payload. ```elixir -features do - feature :voice, is_enabled?: false - feature :static_ip, is_enabled?: false do - characteristic :ip_address, MyApp.IpAddress +provider do + features do + feature :voice, is_enabled?: false + feature :static_ip, is_enabled?: false do + characteristic :ip_address, MyApp.IpAddress + end end end ``` -`parties do` — declares party roles. Use `party` for singular (at most one) and `parties` -for plural relationships: +### `parties do` — all kinds, different keywords per kind + +**For Instance kinds** use `party`, `parties`, and `party_ref`: ```elixir -parties do - party :provider, MyApp.RSP - parties :installer, MyApp.Engineer, constraints: [min: 1, max: 3] - party :owner, MyApp.Organization, reference: true - party :operator, MyApp.RSP, calculate: :derive_operator +provider do + parties do + party :provider, MyApp.RSP # singular, direct edge + parties :installer, MyApp.Engineer, constraints: [min: 1, max: 3] # plural + party_ref :owner, MyApp.Organization # reference — no direct edge + party :operator, MyApp.RSP, calculate: :derive_operator # calculated + end end ``` -- `reference: true` — no direct `PartyRef` edge is created; the party is reachable by graph - traversal. Do not add a `PartyRef` relationship manually when `reference: true` is set. +- `party` — singular (at most one); creates a `PartyRef` edge on build. +- `parties` — plural; accepts `constraints: [min: n, max: m]`. +- `party_ref` — no direct `PartyRef` edge is created; the party is reachable by graph + traversal. Do not add a `PartyRef` relationship manually when `party_ref` is declared. - `calculate:` — names an Ash calculation on this resource that produces the party struct at - build time. The calculation runs inside `build_before/1`; do not call it manually. + build time. Runs inside `build_before/1`; do not call it manually. -`places do` — mirrors `parties do` in structure and options: +**For Party and Place kinds** use `role`: ```elixir -places do - place :installation_site, MyApp.GeographicSite - places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] +provider do + parties do + role :employer, MyApp.Organization + end end ``` -### behaviour +### `places do` — all kinds, different keywords per kind -`behaviour do actions do create :name end end` — marks a named create action for build -wiring. This injects the `:specified_by`, `:features`, and `:characteristics` Ash action -arguments automatically. Do **not** declare these arguments in the action body. +Mirrors `parties do` in structure. For Instance kinds: `place`, `places`, `place_ref`. +For Party/Place kinds: `role`. ```elixir -behaviour do - actions do - create :build +# Instance +provider do + places do + place :installation_site, MyApp.GeographicSite + places :coverage_areas, MyApp.GeographicLocation, constraints: [min: 1] + place_ref :billing_address, MyApp.GeographicAddress + end +end + +# Party or Place +provider do + places do + role :headquarters, MyApp.GeographicSite + end +end +``` + +### `instances do` — Party and Place only + +Declares the Instance kinds this Party or Place kind plays a role with respect to. +Use `role` for a direct relationship, `instance_ref` for a reference (no direct edge). + +```elixir +provider do + instances do + role :provider, MyApp.BroadbandService + role :provider, MyApp.VoiceService + instance_ref :manages, MyApp.InternalService + end +end +``` + +Role names are domain nouns from the party's/place's perspective — timeless, +`snake_case` atoms. Use `camelCase` atoms for multi-word names that follow TMF +conventions (e.g. `:dataCentre`, not `:data_centre`). + +### `pools do` — Instance only + +Declares named assignable pools. Each pool maps to a `Diffo.Provider.AssignableCharacteristic` +node that is created automatically during the `build` action. Use this instead of declaring +`characteristic :name, AssignableCharacteristic` in `characteristics do`. + +```elixir +provider do + pools do + pool :cores, :core # pool name :cores, thing name :core + pool :ports, :port + end +end +``` + +- **`pool name, thing`** — `name` is the pool atom (also the AssignableCharacteristic name); + `thing` is the atom identifying what is being assigned within the pool (stored on assignment + Relationships as the `thing` attribute). +- Pool bounds (`first`, `last`, `algorithm`, `assignable_type`) are set via `Pool.update_pools/3` + in a `:define` action; they are not declared in the DSL. +- Each Instance module gets `pools/0` (list of declarations) and `pool/1` (lookup by name) + generated at compile time. + +In the `:define` action, apply updates for both characteristics and pools: + +```elixir +update :define do + argument :characteristic_value_updates, {:array, :term} + + change after_action(fn changeset, result, _context -> + with {:ok, result} <- Characteristic.update_all(result, changeset, characteristics()), + {:ok, result} <- Pool.update_pools(result, changeset, pools()), + {:ok, result} <- MyDomain.get_by_id(result.id), + do: {:ok, result} + end) +end +``` + +In assignment actions, use `Assigner.assign/3` (thing is looked up from the pool declaration): + +```elixir +update :assign_core do + argument :assignment, :struct, constraints: [instance_of: Assignment] + + change after_action(fn changeset, result, _context -> + with {:ok, result} <- Assigner.assign(result, changeset, :cores), + {:ok, result} <- MyDomain.get_by_id(result.id), + do: {:ok, result} + end) +end +``` + +### `behaviour do` — Instance only + +Marks a named create action for build wiring. Declaring `create :name` injects the +`:specified_by`, `:features`, and `:characteristics` Ash action arguments automatically. +Do **not** declare these arguments in the action body. + +```elixir +provider do + behaviour do + actions do + create :build + end end end ``` @@ -123,8 +305,8 @@ end Every resource with a complete `specification do` block gets these compile-time generated functions: -- `specification/0`, `characteristics/0`, `features/0`, `parties/0`, `places/0` -- `characteristic/1`, `feature/1`, `feature_characteristic/2`, `party/1`, `place/1` +- `specification/0`, `characteristics/0`, `features/0`, `pools/0`, `parties/0`, `places/0` +- `characteristic/1`, `feature/1`, `feature_characteristic/2`, `pool/1`, `party/1`, `place/1` - `build_before/1` — upserts the Specification node; creates Feature, Characteristic, and Party nodes; sets action argument ids. Called automatically before every create action. - `build_after/2` — relates the created TMF entities to the new instance node. Called @@ -134,6 +316,21 @@ functions: They are wired to every create action via global `BuildBefore` and `BuildAfter` changes on `BaseInstance`. +## Runtime introspection + +Use `Diffo.Provider.Extension.Info` to introspect any provider resource at runtime: + +```elixir +Diffo.Provider.Extension.Info.provider_parties(MyApp.BroadbandService) +Diffo.Provider.Extension.Info.provider_places(MyApp.BroadbandService) +Diffo.Provider.Extension.Info.provider_instances(MyApp.RSP) +Diffo.Provider.Extension.Info.instance?(MyApp.BroadbandService) # true +Diffo.Provider.Extension.Info.party?(MyApp.RSP) # true +``` + +The old `Instance.Extension.Info`, `Party.Extension.Info`, and `Place.Extension.Info` +modules are still available as thin delegating wrappers for backwards compatibility. + ## Instance versioning - **Minor/patch version bumps** — update `minor_version` or `patch_version` in `specification do`. @@ -143,31 +340,133 @@ They are wired to every create action via global `BuildBefore` and `BuildAfter` - **Never change the `id`** of an existing specification. It is a stable cross-environment identity; changing it orphans existing instances. -## Party and Place resources +## Neo4j label naming convention + +AshNeo4j derives each resource's primary node label from the **last segment** of the module +name. If two different resource kinds share the same last segment, all reads and writes for +one will also match nodes belonging to the other — a silent data corruption. + +**Always suffix the module with its resource kind** so the derived label is unique: -Party and Place resources use `BaseParty`/`BasePlace` and the Party Extension DSL to declare -the Instance and Party roles they participate in: +| Kind | Pattern | Example | +|------|---------|---------| +| Instance | `…Instance` | `MyApp.Instance.WidgetInstance` → `:WidgetInstance` | +| Characteristic | `…Characteristic` | `MyApp.Characteristic.SpeedCharacteristic` → `:SpeedCharacteristic` | +| Party | `…Party` or unique name | `MyApp.Party.ProviderOrganization` → `:ProviderOrganization` | +| Place | `…Place` or unique name | `MyApp.Place.InstallationSite` → `:InstallationSite` | + +If a domain has both `MyApp.Instance.Card` and `MyApp.Characteristic.Card`, both resolve to +label `:Card` and queries are ambiguous. Rename to `CardInstance` and `CardCharacteristic`. + +## Complete example ```elixir +# Instance resource +defmodule MyApp.BroadbandService do + use Ash.Resource, fragments: [Diffo.Provider.BaseInstance], domain: MyApp.Domain + + resource do + description "An ADSL broadband service" + plural_name :broadband_services + end + + provider do + specification do + id "da9b207a-26c3-451d-8abd-0640c6349979" + name "broadbandService" + type :serviceSpecification + major_version 1 + category "Network Service" + end + + characteristics do + characteristic :circuit, MyApp.CircuitValue + end + + parties do + party :provider, MyApp.RSP + party_ref :owner, MyApp.Organization + end + + places do + place :installation_site, MyApp.GeographicSite + end + + behaviour do + actions do + create :build + end + end + end + + actions do + create :build do + accept [:name] + argument :parties, {:array, :struct} + argument :places, {:array, :struct} + end + end +end + +# Party resource defmodule MyApp.RSP do use Ash.Resource, fragments: [Diffo.Provider.BaseParty], domain: MyApp.Domain - instances do - role :provider, MyApp.BroadbandService - role :provider, MyApp.VoiceService + resource do + description "A Retail Service Provider" + plural_name :rsps end - parties do - role :employer, MyApp.Organization + provider do + instances do + role :provider, MyApp.BroadbandService + end + parties do + role :employer, MyApp.Organization + end + end + + actions do + create :build do + accept [:id, :name] + change set_attribute(:type, :Organization) + end end end -``` -Role names are domain nouns from the party's perspective — timeless, `camelCase` when -multi-word (e.g. `:dataCentre`, not `:data_centre`). +# Place resource +defmodule MyApp.GeographicSite do + use Ash.Resource, fragments: [Diffo.Provider.BasePlace], domain: MyApp.Domain + + resource do + description "A geographic site" + plural_name :geographic_sites + end + + provider do + instances do + role :installation_site, MyApp.BroadbandService + end + parties do + role :managed_by, MyApp.RSP + end + end + + actions do + create :build do + accept [:id, :name] + change set_attribute(:type, :GeographicSite) + end + end +end +``` ## Common mistakes +- **Do not use `structure do` or top-level `instances do`/`parties do`/`places do`** — these + are the old pre-0.3.0 syntax. All declarations belong inside `provider do`. +- **Do not use `party :role, Type, reference: true`** — use `party_ref :role, Type` instead. +- **Do not use `place :role, Type, reference: true`** — use `place_ref :role, Type` instead. - **Do not add raw Ash attributes for TMF-modelled data** — use `characteristics`, `features`, `parties`, and `places` in the DSL instead. - **Do not declare `:specified_by`, `:features`, or `:characteristics` Ash action arguments** @@ -177,5 +476,15 @@ multi-word (e.g. `:dataCentre`, not `:data_centre`). managed entirely by the `build_before/1` generated function. - **Do not use `party/1` in place of `parties/3`** (and vice versa) — `party` declares a singular role; `parties` declares a plural role. Mismatching causes compile-time errors. -- **Do not set a `referred_type` without also setting `type: :PartyRef`** — TMF requires - both fields when using a party reference. +- **Do not use `characteristic :name, Diffo.Provider.AssignableCharacteristic`** for assignable + pools — use `pools do / pool :name, :thing / end` instead. The `pools do` section creates the + `AssignableCharacteristic` node automatically during `build` and generates `pools/0` / `pool/1`. +- **Do not use the old `AssignableValue` TypedStruct** — it is removed. Use `pools do`. +- **Do not call `Assigner.assign/4` when a pool declaration exists** — prefer `Assigner.assign/3` + which looks up the thing name from the pool automatically. `assign/4` is still available for + cases without a `pools do` declaration. +- **Do not query `Diffo.Provider.Relationship` for `type: :assignedTo` records** — assignment + relationships live on `Diffo.Provider.AssignedToRelationship`. Access them via `instance.assignments`. +- **Do not filter `instance.forward_relationships` for `type == :assignedTo`** — those records no + longer exist there. `forward_relationships` contains only regular TMF relationships; + `assignments` contains pool assignment relationships.