From 3ebeb67ff24abcc4f7af205902b3bc3e17fc7f3f Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 07:16:28 +0930 Subject: [PATCH 01/15] fix mix.lock conflict --- mix.lock | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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"}, From 8649120adfe0ecb6c5368ce969e677516ffa1653 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 07:17:39 +0930 Subject: [PATCH 02/15] add how tos --- mix.exs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mix.exs b/mix.exs index ebfe7e3..07d767e 100644 --- a/mix.exs +++ b/mix.exs @@ -74,9 +74,17 @@ defmodule Diffo.MixProject do "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 Instance 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\//, DSLs: ~r/documentation\/dsls\// ] ] From b255bbd846c0bb8632ff1501f8bf9b3f5d0722ad Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 07:37:28 +0930 Subject: [PATCH 03/15] add diffo.livemd to hexdocs extras, fix broken relative link warning --- mix.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mix.exs b/mix.exs index 07d767e..75a612e 100644 --- a/mix.exs +++ b/mix.exs @@ -67,6 +67,7 @@ defmodule Diffo.MixProject do extras: [ "README.md": [title: "Guide"], "LICENSES/MIT.md": [title: "License"], + "diffo.livemd": [title: "Tutorial"], "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) @@ -85,6 +86,7 @@ defmodule Diffo.MixProject do ], groups_for_extras: [ "How-to": ~r/documentation\/how_to\//, + Tutorials: ~r/\.livemd$/, DSLs: ~r/documentation\/dsls\// ] ] From 912697bcd3ce0bbdc61d3942cdffac7d7c018177 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 14:10:56 +0930 Subject: [PATCH 04/15] refactor provider extension and tests --- .../provider/components/base_instance.ex | 1 + lib/diffo/provider/components/base_party.ex | 1 + lib/diffo/provider/components/base_place.ex | 1 + .../provider/components/instance/extension.ex | 412 +------------- .../components/instance/extension/info.ex | 21 +- .../provider/components/party/extension.ex | 114 +--- .../components/party/extension/info.ex | 18 +- .../provider/components/place/extension.ex | 115 +--- .../components/place/extension/info.ex | 18 +- lib/diffo/provider/extension.ex | 515 ++++++++++++++++++ lib/diffo/provider/extension/action_create.ex | 8 + lib/diffo/provider/extension/action_update.ex | 8 + .../provider/extension/characteristic.ex | 134 +++++ lib/diffo/provider/extension/feature.ex | 92 ++++ lib/diffo/provider/extension/info.ex | 30 + lib/diffo/provider/extension/instance_role.ex | 12 + .../provider/extension/party_declaration.ex | 20 + lib/diffo/provider/extension/party_role.ex | 12 + .../persisters/persist_characteristics.ex | 26 + .../extension/persisters/persist_features.ex | 26 + .../extension/persisters/persist_instances.ex | 26 + .../extension/persisters/persist_parties.ex | 26 + .../extension/persisters/persist_places.ex | 26 + .../persisters/persist_specification.ex | 41 ++ .../provider/extension/place_declaration.ex | 20 + lib/diffo/provider/extension/place_role.ex | 12 + .../transformers/transform_behaviour.ex | 124 +++++ .../extension/verifiers/verify_behaviour.ex | 66 +++ .../verifiers/verify_characteristics.ex | 60 ++ .../extension/verifiers/verify_features.ex | 79 +++ .../extension/verifiers/verify_instances.ex | 68 +++ .../extension/verifiers/verify_parties.ex | 68 +++ .../extension/verifiers/verify_places.ex | 68 +++ .../verifiers/verify_specification.ex | 94 ++++ .../extension}/assigner_test.exs | 2 +- .../extension}/characteristic_test.exs | 2 +- .../extension}/feature_test.exs | 2 +- .../extension/instance_transformer_test.exs} | 8 +- .../extension/instance_verifier_test.exs} | 50 +- .../extension}/party_test.exs | 4 +- .../extension/party_transformer_test.exs} | 8 +- .../extension/party_verifier_test.exs} | 62 ++- .../extension}/place_test.exs | 2 +- .../extension/place_transformer_test.exs} | 8 +- .../extension/place_verifier_test.exs} | 62 ++- .../extension}/specification_test.exs | 2 +- test/support/resource/broadband.ex | 10 +- test/support/resource/broadband_v2.ex | 10 +- test/support/resource/card.ex | 12 +- test/support/resource/carrier.ex | 12 +- test/support/resource/exchange_building.ex | 12 +- test/support/resource/geographic_site.ex | 18 +- test/support/resource/organization.ex | 18 +- test/support/resource/person.ex | 18 +- test/support/resource/shelf.ex | 16 +- 55 files changed, 1898 insertions(+), 802 deletions(-) create mode 100644 lib/diffo/provider/extension.ex create mode 100644 lib/diffo/provider/extension/action_create.ex create mode 100644 lib/diffo/provider/extension/action_update.ex create mode 100644 lib/diffo/provider/extension/characteristic.ex create mode 100644 lib/diffo/provider/extension/feature.ex create mode 100644 lib/diffo/provider/extension/info.ex create mode 100644 lib/diffo/provider/extension/instance_role.ex create mode 100644 lib/diffo/provider/extension/party_declaration.ex create mode 100644 lib/diffo/provider/extension/party_role.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_characteristics.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_features.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_instances.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_parties.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_places.ex create mode 100644 lib/diffo/provider/extension/persisters/persist_specification.ex create mode 100644 lib/diffo/provider/extension/place_declaration.ex create mode 100644 lib/diffo/provider/extension/place_role.ex create mode 100644 lib/diffo/provider/extension/transformers/transform_behaviour.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_behaviour.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_characteristics.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_features.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_instances.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_parties.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_places.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_specification.ex rename test/{instance_extension => provider/extension}/assigner_test.exs (99%) rename test/{instance_extension => provider/extension}/characteristic_test.exs (92%) rename test/{instance_extension => provider/extension}/feature_test.exs (94%) rename test/{instance_extension/transformer_test.exs => provider/extension/instance_transformer_test.exs} (97%) rename test/{instance_extension/verifier_test.exs => provider/extension/instance_verifier_test.exs} (95%) rename test/{instance_extension => provider/extension}/party_test.exs (98%) rename test/{party_extension/transformer_test.exs => provider/extension/party_transformer_test.exs} (92%) rename test/{party_extension/verifier_test.exs => provider/extension/party_verifier_test.exs} (79%) rename test/{instance_extension => provider/extension}/place_test.exs (98%) rename test/{place_extension/transformer_test.exs => provider/extension/place_transformer_test.exs} (89%) rename test/{place_extension/verifier_test.exs => provider/extension/place_verifier_test.exs} (79%) rename test/{instance_extension => provider/extension}/specification_test.exs (96%) diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 463856b..94fa443 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 ] 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/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/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/extension.ex b/lib/diffo/provider/extension.ex new file mode 100644 index 0000000..bbc7042 --- /dev/null +++ b/lib/diffo/provider/extension.ex @@ -0,0 +1,515 @@ +# 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 + + 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 + } + + # ── 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] + } + + # ── 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, + @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.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.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..f931331 --- /dev/null +++ b/lib/diffo/provider/extension/characteristic.ex @@ -0,0 +1,134 @@ +# 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] + + def set_characteristics_argument(changeset, declarations) + when is_struct(changeset, Ash.Changeset) and is_list(declarations) do + case characteristics = create_characteristics_from_declarations(declarations, :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 + + 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 update_values(result, changeset) + when is_struct(result) and is_struct(changeset, Ash.Changeset) do + characteristic_value_updates = + Ash.Changeset.get_argument(changeset, :characteristic_value_updates) + + case characteristic_value_updates do + nil -> + {:ok, result} + + [] -> + {:ok, result} + + _ -> + characteristic_updates = + Enum.reduce(characteristic_value_updates, [], fn {name, update}, acc -> + characteristic = + Enum.find(changeset.data.characteristics, fn %{name: n} -> n == name end) + + if characteristic do + 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) + + new_value = Value.dynamic(struct(value_type, Map.from_struct(updated))) + [{characteristic, new_value} | acc] + + true -> + [{characteristic, update} | acc] + end + else + Logger.warning("couldn't find characteristic #{name}") + acc + end + end) + + characteristics = + Enum.reduce_while(characteristic_updates, [], fn {characteristic, value}, acc -> + case Provider.update_characteristic(characteristic, %{value: value}) do + {:ok, characteristic} -> + {:cont, [characteristic | acc]} + + {:error, error} -> + {:halt, {:error, error}} + end + end) + + case characteristics do + {:error, error} -> + {:error, error} + + [] -> + {:error, "couldn't update characteristics"} + + _ -> + {:ok, Map.put(result, :characteristics, characteristics)} + end + end + end + + defimpl String.Chars do + def to_string(struct), do: inspect(struct) + end +end diff --git a/lib/diffo/provider/extension/feature.ex b/lib/diffo/provider/extension/feature.ex new file mode 100644 index 0000000..38b5578 --- /dev/null +++ b/lib/diffo/provider/extension/feature.ex @@ -0,0 +1,92 @@ +# 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.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 -> + characteristic_ids = + Enum.reduce_while(characteristics, [], 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 + + 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_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/transformers/transform_behaviour.ex b/lib/diffo/provider/extension/transformers/transform_behaviour.ex new file mode 100644 index 0000000..efb5f47 --- /dev/null +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -0,0 +1,124 @@ +# 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 + Diffo.Provider.Instance.ActionHelper.build_after(changeset, 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 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.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_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/test/instance_extension/assigner_test.exs b/test/provider/extension/assigner_test.exs similarity index 99% rename from test/instance_extension/assigner_test.exs rename to test/provider/extension/assigner_test.exs index 9c5ca9e..c6e1632 100644 --- a/test/instance_extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -2,7 +2,7 @@ # # 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 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/instance_extension/transformer_test.exs b/test/provider/extension/instance_transformer_test.exs similarity index 97% rename from test/instance_extension/transformer_test.exs rename to test/provider/extension/instance_transformer_test.exs index 1e1ed66..c5b0395 100644 --- a/test/instance_extension/transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -2,16 +2,16 @@ # # 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.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 diff --git a/test/instance_extension/verifier_test.exs b/test/provider/extension/instance_verifier_test.exs similarity index 95% rename from test/instance_extension/verifier_test.exs rename to test/provider/extension/instance_verifier_test.exs index 0273c97..dc2dba4 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,7 +146,7 @@ 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" @@ -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" @@ -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" @@ -329,7 +329,7 @@ defmodule Diffo.InstanceExtension.VerifierTest do description "resource with duplicate party roles" end - structure do + provider do specification do id "cd29956f-6c68-44cc-bf54-705eb8d2f754" name "invalid" @@ -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" @@ -386,7 +386,7 @@ 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" @@ -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,16 @@ 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 - end - behaviour do - actions do - update :nonexistent + behaviour do + actions do + update :nonexistent + end end end end diff --git a/test/instance_extension/party_test.exs b/test/provider/extension/party_test.exs similarity index 98% rename from test/instance_extension/party_test.exs rename to test/provider/extension/party_test.exs index 56f717a..530089f 100644 --- a/test/instance_extension/party_test.exs +++ b/test/provider/extension/party_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.PartyTest do +defmodule Diffo.Provider.Extension.PartyTest do @moduledoc false use ExUnit.Case, async: true @@ -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 diff --git a/test/party_extension/transformer_test.exs b/test/provider/extension/party_transformer_test.exs similarity index 92% rename from test/party_extension/transformer_test.exs rename to test/provider/extension/party_transformer_test.exs index 3614d8b..ead64ec 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.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 79% rename from test/party_extension/verifier_test.exs rename to test/provider/extension/party_verifier_test.exs index d4d64ea..505dc32 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 @@ -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.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.Organization + role :employer, Diffo.Test.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 @@ -196,8 +212,10 @@ 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.Organization + end end end end diff --git a/test/instance_extension/place_test.exs b/test/provider/extension/place_test.exs similarity index 98% rename from test/instance_extension/place_test.exs rename to test/provider/extension/place_test.exs index 87e5cf5..990733d 100644 --- a/test/instance_extension/place_test.exs +++ b/test/provider/extension/place_test.exs @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.InstanceExtension.PlaceTest do +defmodule Diffo.Provider.Extension.PlaceTest do @moduledoc false use ExUnit.Case, async: true diff --git a/test/place_extension/transformer_test.exs b/test/provider/extension/place_transformer_test.exs similarity index 89% rename from test/place_extension/transformer_test.exs rename to test/provider/extension/place_transformer_test.exs index 98c5158..80871e1 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.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 79% rename from test/place_extension/verifier_test.exs rename to test/provider/extension/place_verifier_test.exs index 48c4eb1..232e21a 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 @@ -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.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.Organization + role :managed_by, Diffo.Test.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 @@ -196,8 +212,10 @@ 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.Organization + end end end end diff --git a/test/instance_extension/specification_test.exs b/test/provider/extension/specification_test.exs similarity index 96% rename from test/instance_extension/specification_test.exs rename to test/provider/extension/specification_test.exs index 87349c4..952da58 100644 --- a/test/instance_extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -2,7 +2,7 @@ # # 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 diff --git a/test/support/resource/broadband.ex b/test/support/resource/broadband.ex index c09df65..7eac2d6 100644 --- a/test/support/resource/broadband.ex +++ b/test/support/resource/broadband.ex @@ -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/broadband_v2.ex index 4abf3c4..3dc4b1b 100644 --- a/test/support/resource/broadband_v2.ex +++ b/test/support/resource/broadband_v2.ex @@ -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/card.ex index 9792b8e..6ec064b 100644 --- a/test/support/resource/card.ex +++ b/test/support/resource/card.ex @@ -10,7 +10,7 @@ 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.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue @@ -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" @@ -39,11 +39,11 @@ defmodule Diffo.Test.Card do characteristic :card, CardValue characteristic :ports, AssignableValue end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end diff --git a/test/support/resource/carrier.ex b/test/support/resource/carrier.ex index c99abf6..3614c5c 100644 --- a/test/support/resource/carrier.ex +++ b/test/support/resource/carrier.ex @@ -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/exchange_building.ex b/test/support/resource/exchange_building.ex index d12d96c..83e9f4b 100644 --- a/test/support/resource/exchange_building.ex +++ b/test/support/resource/exchange_building.ex @@ -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.Carrier + end end end diff --git a/test/support/resource/geographic_site.ex b/test/support/resource/geographic_site.ex index 81ce42c..614964e 100644 --- a/test/support/resource/geographic_site.ex +++ b/test/support/resource/geographic_site.ex @@ -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.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/organization.ex b/test/support/resource/organization.ex index 0a7c7bc..bbe81c4 100644 --- a/test/support/resource/organization.ex +++ b/test/support/resource/organization.ex @@ -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.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/person.ex index e260004..1e559aa 100644 --- a/test/support/resource/person.ex +++ b/test/support/resource/person.ex @@ -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.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/shelf.ex b/test/support/resource/shelf.ex index b2f87b9..4b24237 100644 --- a/test/support/resource/shelf.ex +++ b/test/support/resource/shelf.ex @@ -11,7 +11,7 @@ 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 @@ -29,7 +29,7 @@ defmodule Diffo.Test.Shelf do plural_name :Shelves end - structure do + provider do specification do id "ef016d85-9dbd-429c-84da-1df56cc7dda5" name "shelf" @@ -59,20 +59,20 @@ defmodule Diffo.Test.Shelf do parties do party :facilitator, Diffo.Test.Organization party :overseer, Diffo.Test.Person - party :provider, Diffo.Test.Organization, reference: true + party_ref :provider, Diffo.Test.Organization party :manager, Diffo.Test.Organization, calculate: :manager_calc parties :installer, Diffo.Test.Person, constraints: [min: 1, max: 3] end places do place :installation_site, Diffo.Provider.Place - place :billing_address, Diffo.Provider.Place, reference: true + place_ref :billing_address, Diffo.Provider.Place end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end From c54eae3a95d25b94dab71b59c02a709ea34913e9 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 16:30:54 +0930 Subject: [PATCH 05/15] documents and guidance --- AGENTS.md | 152 ++++ .../dsls/DSL-Diffo.Provider.Extension.md | 762 ++++++++++++++++++ .../DSL-Diffo.Provider.Instance.Extension.md | 530 ------------ .../DSL-Diffo.Provider.Party.Extension.md | 163 ---- .../use_diffo_provider_extension.livemd | 133 +-- mix.exs | 16 +- usage-rules.md | 283 +++++-- 7 files changed, 1212 insertions(+), 827 deletions(-) create mode 100644 AGENTS.md create mode 100644 documentation/dsls/DSL-Diffo.Provider.Extension.md delete mode 100644 documentation/dsls/DSL-Diffo.Provider.Instance.Extension.md delete mode 100644 documentation/dsls/DSL-Diffo.Provider.Party.Extension.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..130e77b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ + + +# 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 + 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 + 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/ + 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.SlotValue + characteristic :ports, {:array, MyApp.Port} + 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 +``` + +## 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`. +- 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/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md new file mode 100644 index 0000000..ba5e90f --- /dev/null +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -0,0 +1,762 @@ + +# 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 + + 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 + * [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.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..7870bde 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,37 @@ 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 `Ash.TypedStruct`. -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`. +**`parties do`** — party roles: `party` (singular), `parties` (plural), `party_ref` (reference, no direct edge). -**`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. +**`places do`** — place roles: `place` (singular), `places` (plural), `place_ref` (reference). -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. +**`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. -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. +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. -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. +For partial resource allocation and assignment we've created Diffo.Provider.Assigner. It is used by the host resource, which declares a characteristic with a `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. 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 +163,7 @@ 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. ```elixir defmodule Diffo.Compute.Cluster do @@ -192,7 +188,7 @@ defmodule Diffo.Compute.Cluster do plural_name :Clusters end - structure do + provider do specification do id "4bcfc4c9-e776-4878-a658-e8d81857bed7" name "cluster" @@ -213,11 +209,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 @@ -301,7 +297,7 @@ end ### Using the Assigner -We'll now define a GPU Resource which uses the Diffo.Provider.Assigner functionality. +We'll now define a GPU Resource which uses the `Diffo.Provider.Assigner` functionality. ```elixir defmodule Diffo.Compute.GPU do @@ -327,7 +323,7 @@ defmodule Diffo.Compute.GPU do plural_name :gpus end - structure do + provider do specification do id "ad50073f-17e0-45cb-b9b1-aa4296876156" name "gpu" @@ -340,11 +336,11 @@ defmodule Diffo.Compute.GPU do characteristic :gpu, GPUValue characteristic :cores, AssignableValue end - end - behaviour do - actions do - create :build + behaviour do + actions do + create :build + end end end @@ -435,11 +431,11 @@ 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 +466,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 +500,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 +556,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 ``` @@ -738,10 +737,14 @@ 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 with GPU core assignment via `Diffo.Provider.Assigner` +- `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/mix.exs b/mix.exs index 75a612e..8efb173 100644 --- a/mix.exs +++ b/mix.exs @@ -68,17 +68,13 @@ defmodule Diffo.MixProject do "README.md": [title: "Guide"], "LICENSES/MIT.md": [title: "License"], "diffo.livemd": [title: "Tutorial"], - "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) - ], - "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/dsls/DSL-Diffo.Provider.Extension.md": [ + title: "DSL: Diffo.Provider.Extension", + search_data: Spark.Docs.search_data_for(Diffo.Provider.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 Instance Extension" + title: "Using the Diffo Provider Extension" ], "documentation/how_to/use_diffo_provider_versioning.livemd": [ title: "Instance Versioning with the Diffo Provider" @@ -148,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/usage-rules.md b/usage-rules.md index 0afb07d..2a9e034 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: + +- **Instance** — `specification`, `characteristics`, `features`, `parties`, `places`, `behaviour` +- **Party** — `instances`, `parties`, `places` +- **Place** — `instances`, `parties`, `places` -Every resource using `BaseInstance` gains two top-level DSL sections: `structure do` and -`behaviour do`. +Verifiers enforce that each kind uses only the sections relevant to it. -### structure +### `specification do` — Instance only -`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`. +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,123 @@ 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 backed by an `Ash.TypedStruct`. Do **not** +add plain Ash attributes for data that belongs in a characteristic. ```elixir -characteristics do - characteristic :downstream_speed, MyApp.Speed - characteristic :access_technology, MyApp.AccessTechnology +provider do + characteristics do + characteristic :downstream_speed, MyApp.Speed + characteristic :access_technology, MyApp.AccessTechnology + characteristic :ports, {:array, MyApp.Port} + 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`). + +### `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 ``` @@ -134,6 +202,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 +226,115 @@ 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 - -Party and Place resources use `BaseParty`/`BasePlace` and the Party Extension DSL to declare -the Instance and Party roles they participate in: +## 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 +344,3 @@ 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. From 1f2d27986933b0abb94ed1be80add318369a7d55 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sat, 16 May 2026 16:47:39 +0930 Subject: [PATCH 06/15] improve test coverage --- test/provider/extension/info_test.exs | 76 +++++++++ .../extension/instance_verifier_test.exs | 152 ++++++++++++++++++ .../extension/party_verifier_test.exs | 50 +++++- .../extension/place_verifier_test.exs | 50 +++++- 4 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 test/provider/extension/info_test.exs diff --git a/test/provider/extension/info_test.exs b/test/provider/extension/info_test.exs new file mode 100644 index 0000000..d54e039 --- /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.Shelf) == 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.Organization) == false + end + + test "returns false for a BasePlace-derived resource" do + assert Info.instance?(Diffo.Test.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.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.Shelf) == false + end + + test "returns false for a BasePlace-derived resource" do + assert Info.party?(Diffo.Test.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.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.Shelf) == false + end + + test "returns false for a BaseParty-derived resource" do + assert Info.place?(Diffo.Test.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/provider/extension/instance_verifier_test.exs b/test/provider/extension/instance_verifier_test.exs index dc2dba4..19a3d7f 100644 --- a/test/provider/extension/instance_verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -462,5 +462,157 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do 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.Shelf 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.Shelf + 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.Shelf 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 + + places do + place_ref :billing, Diffo.Test.Shelf + end + end + end + end + ) + end end end diff --git a/test/provider/extension/party_verifier_test.exs b/test/provider/extension/party_verifier_test.exs index 505dc32..ac4fd3b 100644 --- a/test/provider/extension/party_verifier_test.exs +++ b/test/provider/extension/party_verifier_test.exs @@ -204,7 +204,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do Spark.Error.DslError, "places: place_type Diffo.Test.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 @@ -222,4 +222,52 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do ) 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.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.Organization + end + end + end + end + ) + end + end end diff --git a/test/provider/extension/place_verifier_test.exs b/test/provider/extension/place_verifier_test.exs index 232e21a..d57bc05 100644 --- a/test/provider/extension/place_verifier_test.exs +++ b/test/provider/extension/place_verifier_test.exs @@ -204,7 +204,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do Spark.Error.DslError, "places: place_type Diffo.Test.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 @@ -222,4 +222,52 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do ) 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.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.Organization + end + end + end + end + ) + end + end end From 84efbde9736c20ffde220867b9b7e94192c5d9ca Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 00:00:12 +0930 Subject: [PATCH 07/15] base characteristic --- .../components/base_characteristic.ex | 133 ++++++++++++++ .../calculations/characteristic_value.ex | 20 ++ .../provider/components/characteristic.ex | 2 +- .../components/characteristic/extension.ex | 8 + .../provider/extension/characteristic.ex | 172 ++++++++++++------ lib/diffo/provider/extension/feature.ex | 35 +++- .../transformers/transform_behaviour.ex | 11 +- lib/diffo/type/characteristic_value.ex | 15 ++ test/provider/extension/assigner_test.exs | 29 ++- test/provider/extension/info_test.exs | 18 +- .../extension/instance_transformer_test.exs | 6 +- .../extension/instance_verifier_test.exs | 22 +-- test/provider/extension/party_test.exs | 8 +- .../extension/party_transformer_test.exs | 4 +- .../extension/party_verifier_test.exs | 16 +- test/provider/extension/place_test.exs | 6 +- .../extension/place_transformer_test.exs | 2 +- .../extension/place_verifier_test.exs | 16 +- .../provider/extension/specification_test.exs | 2 +- test/provider/versioning_test.exs | 4 +- test/support/nbn.ex | 10 +- test/support/resource/card_value.ex | 37 ---- test/support/resource/characteristic/card.ex | 50 +++++ .../resource/characteristic/card/value.ex | 19 ++ .../characteristic/deployment_class.ex | 49 +++++ .../characteristic/deployment_class/value.ex | 18 ++ test/support/resource/characteristic/shelf.ex | 50 +++++ .../resource/characteristic/shelf/value.ex | 19 ++ .../resource/deployment_class_value.ex | 32 ---- .../resource/{ => instance}/broadband.ex | 2 +- .../resource/{ => instance}/broadband_v2.ex | 2 +- test/support/resource/{ => instance}/card.ex | 9 +- test/support/resource/{ => instance}/shelf.ex | 29 +-- test/support/resource/{ => party}/carrier.ex | 2 +- .../resource/{ => party}/organization.ex | 4 +- test/support/resource/{ => party}/person.ex | 4 +- .../resource/{ => place}/exchange_building.ex | 4 +- .../resource/{ => place}/geographic_site.ex | 4 +- test/support/resource/shelf_value.ex | 37 ---- test/support/servo.ex | 15 +- test/type/dynamic_test.exs | 2 +- 41 files changed, 667 insertions(+), 260 deletions(-) create mode 100644 lib/diffo/provider/components/base_characteristic.ex create mode 100644 lib/diffo/provider/components/calculations/characteristic_value.ex create mode 100644 lib/diffo/provider/components/characteristic/extension.ex create mode 100644 lib/diffo/type/characteristic_value.ex delete mode 100644 test/support/resource/card_value.ex create mode 100644 test/support/resource/characteristic/card.ex create mode 100644 test/support/resource/characteristic/card/value.ex create mode 100644 test/support/resource/characteristic/deployment_class.ex create mode 100644 test/support/resource/characteristic/deployment_class/value.ex create mode 100644 test/support/resource/characteristic/shelf.ex create mode 100644 test/support/resource/characteristic/shelf/value.ex delete mode 100644 test/support/resource/deployment_class_value.ex rename test/support/resource/{ => instance}/broadband.ex (96%) rename test/support/resource/{ => instance}/broadband_v2.ex (96%) rename test/support/resource/{ => instance}/card.ex (90%) rename test/support/resource/{ => instance}/shelf.ex (80%) rename test/support/resource/{ => party}/carrier.ex (97%) rename test/support/resource/{ => party}/organization.ex (91%) rename test/support/resource/{ => party}/person.ex (91%) rename test/support/resource/{ => place}/exchange_building.ex (93%) rename test/support/resource/{ => place}/geographic_site.ex (90%) delete mode 100644 test/support/resource/shelf_value.ex 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/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/extension/characteristic.ex b/lib/diffo/provider/extension/characteristic.ex index f931331..865fe68 100644 --- a/lib/diffo/provider/extension/characteristic.ex +++ b/lib/diffo/provider/extension/characteristic.ex @@ -12,9 +12,13 @@ defmodule Diffo.Provider.Extension.Characteristic do 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 - case characteristics = create_characteristics_from_declarations(declarations, :instance) do + dynamic = Enum.reject(declarations, &typed?(&1.value_type)) + + case characteristics = create_characteristics_from_declarations(dynamic, :instance) do [] -> changeset @@ -54,6 +58,8 @@ defmodule Diffo.Provider.Extension.Characteristic do 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) @@ -63,72 +69,134 @@ defmodule Diffo.Provider.Extension.Characteristic do }) 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} + nil -> {:ok, result} + [] -> {:ok, result} + _ -> apply_updates(result, characteristic_value_updates, declarations) + end + end - [] -> - {:ok, result} + 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)) - _ -> - characteristic_updates = - Enum.reduce(characteristic_value_updates, [], fn {name, update}, acc -> - characteristic = - Enum.find(changeset.data.characteristics, fn %{name: n} -> n == name end) - - if characteristic do - 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) - - new_value = Value.dynamic(struct(value_type, Map.from_struct(updated))) - [{characteristic, new_value} | acc] - - true -> - [{characteristic, update} | acc] - end - else - Logger.warning("couldn't find characteristic #{name}") - acc - end - end) - - characteristics = - Enum.reduce_while(characteristic_updates, [], fn {characteristic, value}, acc -> - case Provider.update_characteristic(characteristic, %{value: value}) do - {:ok, characteristic} -> - {:cont, [characteristic | acc]} - - {:error, error} -> - {:halt, {:error, error}} - end - end) - - case characteristics do - {:error, error} -> - {:error, error} + if decl && typed?(decl.value_type) do + apply_typed_update(acc, name, decl.value_type, update) + else + apply_dynamic_update(acc, name, update) + end + end) + end - [] -> - {:error, "couldn't update characteristics"} + 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 - _ -> - {:ok, Map.put(result, :characteristics, characteristics)} + {: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 index 38b5578..d56ebaa 100644 --- a/lib/diffo/provider/extension/feature.ex +++ b/lib/diffo/provider/extension/feature.ex @@ -8,6 +8,7 @@ defmodule Diffo.Provider.Extension.Feature do alias Diffo.Provider alias Diffo.Provider.Instance + alias Diffo.Provider.Extension.Characteristic alias Diffo.Type.Value defstruct [:name, :is_enabled?, :characteristics, __spark_metadata__: nil] @@ -32,8 +33,10 @@ defmodule Diffo.Provider.Extension.Feature do declarations, [], fn %{name: name, is_enabled?: isEnabled, characteristics: characteristics}, acc -> + dynamic = Enum.reject(characteristics, &Characteristic.typed?(&1.value_type)) + characteristic_ids = - Enum.reduce_while(characteristics, [], fn %{name: name, value_type: value_type}, acc -> + Enum.reduce_while(dynamic, [], fn %{name: name, value_type: value_type}, acc -> try do attrs = case value_type do @@ -86,6 +89,36 @@ defmodule Diffo.Provider.Extension.Feature do 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 diff --git a/lib/diffo/provider/extension/transformers/transform_behaviour.ex b/lib/diffo/provider/extension/transformers/transform_behaviour.ex index efb5f47..e456331 100644 --- a/lib/diffo/provider/extension/transformers/transform_behaviour.ex +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -35,7 +35,16 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do after_body = quote do - Diffo.Provider.Instance.ActionHelper.build_after(changeset, result) + 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() + ), + do: {:ok, result} end {before_body, after_body} 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/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index c6e1632..3e8d8f2 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -12,7 +12,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do alias Diffo.Test.Characteristics alias Diffo.Test.Parties alias Diffo.Test.Servo - alias Diffo.Test.Card + alias Diffo.Test.Instance.Card setup do AshNeo4j.Sandbox.checkout() @@ -40,9 +40,10 @@ defmodule Diffo.Provider.Extension.AssignerTest do :outgoing ) - # check characteristic resource enrichment and node relationships + # check dynamic characteristic resource enrichment and node relationships + # :card is now a typed CardValue node (not in characteristics); only :ports remains dynamic assert is_list(card.characteristics) - assert length(card.characteristics) == 2 + assert length(card.characteristics) == 1 Enum.each(card.characteristics, fn characteristic -> assert is_struct(characteristic, Characteristic) @@ -60,7 +61,7 @@ defmodule Diffo.Provider.Extension.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\"},\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":1,\"free\":1,\"algorithm\":\"lowest\"}}]}) end test "define card" do @@ -73,10 +74,20 @@ defmodule Diffo.Provider.Extension.AssignerTest do {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) + {:ok, card_value} = + Diffo.Test.Characteristic.Card + |> 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\"},\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":48,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) end test "auto assign port to resource" do @@ -101,7 +112,7 @@ defmodule Diffo.Provider.Extension.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\"},\"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}]}],\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":47,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) end test "auto assign two ports to same resource" do @@ -131,7 +142,7 @@ defmodule Diffo.Provider.Extension.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\"},\"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}]}],\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":46,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) end test "specific assignment rejects duplicate request" do @@ -161,7 +172,7 @@ defmodule Diffo.Provider.Extension.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\"},\"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}]}],\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":47,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) end test "unassign an auto-assigned port from a resource" do @@ -204,7 +215,7 @@ defmodule Diffo.Provider.Extension.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\":{\"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\"},\"resourceCharacteristic\":[{\"name\":\"ports\",\"value\":{\"first\":1,\"last\":48,\"free\":48,\"type\":\"ADSL2+\",\"algorithm\":\"lowest\"}}]}) end end end diff --git a/test/provider/extension/info_test.exs b/test/provider/extension/info_test.exs index d54e039..a26441f 100644 --- a/test/provider/extension/info_test.exs +++ b/test/provider/extension/info_test.exs @@ -10,7 +10,7 @@ defmodule Diffo.Provider.Extension.InfoTest do describe "instance?/1" do test "returns true for a BaseInstance-derived resource" do - assert Info.instance?(Diffo.Test.Shelf) == true + assert Info.instance?(Diffo.Test.Instance.Shelf) == true end test "returns true for the base Instance resource" do @@ -18,11 +18,11 @@ defmodule Diffo.Provider.Extension.InfoTest do end test "returns false for a BaseParty-derived resource" do - assert Info.instance?(Diffo.Test.Organization) == false + assert Info.instance?(Diffo.Test.Party.Organization) == false end test "returns false for a BasePlace-derived resource" do - assert Info.instance?(Diffo.Test.GeographicSite) == false + assert Info.instance?(Diffo.Test.Place.GeographicSite) == false end test "returns false for a non-existent module" do @@ -32,7 +32,7 @@ defmodule Diffo.Provider.Extension.InfoTest do describe "party?/1" do test "returns true for a BaseParty-derived resource" do - assert Info.party?(Diffo.Test.Organization) == true + assert Info.party?(Diffo.Test.Party.Organization) == true end test "returns true for the base Party resource" do @@ -40,11 +40,11 @@ defmodule Diffo.Provider.Extension.InfoTest do end test "returns false for a BaseInstance-derived resource" do - assert Info.party?(Diffo.Test.Shelf) == false + assert Info.party?(Diffo.Test.Instance.Shelf) == false end test "returns false for a BasePlace-derived resource" do - assert Info.party?(Diffo.Test.GeographicSite) == false + assert Info.party?(Diffo.Test.Place.GeographicSite) == false end test "returns false for a non-existent module" do @@ -54,7 +54,7 @@ defmodule Diffo.Provider.Extension.InfoTest do describe "place?/1" do test "returns true for a BasePlace-derived resource" do - assert Info.place?(Diffo.Test.GeographicSite) == true + assert Info.place?(Diffo.Test.Place.GeographicSite) == true end test "returns true for the base Place resource" do @@ -62,11 +62,11 @@ defmodule Diffo.Provider.Extension.InfoTest do end test "returns false for a BaseInstance-derived resource" do - assert Info.place?(Diffo.Test.Shelf) == false + assert Info.place?(Diffo.Test.Instance.Shelf) == false end test "returns false for a BaseParty-derived resource" do - assert Info.place?(Diffo.Test.Organization) == false + assert Info.place?(Diffo.Test.Party.Organization) == false end test "returns false for a non-existent module" do diff --git a/test/provider/extension/instance_transformer_test.exs b/test/provider/extension/instance_transformer_test.exs index c5b0395..b4d5b8a 100644 --- a/test/provider/extension/instance_transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -6,8 +6,8 @@ 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.Test.Instance.Shelf + alias Diffo.Test.Instance.Card alias Diffo.Provider.Extension.Characteristic alias Diffo.Provider.Extension.Feature alias Diffo.Provider.Extension.PlaceDeclaration @@ -241,7 +241,7 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do test "characteristic/1 returns the named characteristic" do char = Shelf.characteristic(:shelves) assert char.name == :shelves - assert char.value_type == {:array, Diffo.Test.ShelfValue} + assert char.value_type == {:array, Diffo.Test.Characteristic.Shelf} end test "characteristic/1 returns nil for unknown name" do diff --git a/test/provider/extension/instance_verifier_test.exs b/test/provider/extension/instance_verifier_test.exs index 19a3d7f..cab2649 100644 --- a/test/provider/extension/instance_verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -153,8 +153,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end characteristics do - characteristic :foo, Diffo.Test.ShelfValue - characteristic :foo, Diffo.Test.ShelfValue + characteristic :foo, Diffo.Test.Characteristic.Shelf + characteristic :foo, Diffo.Test.Characteristic.Shelf end end end @@ -273,8 +273,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do features do feature :my_feature do - characteristic :baz, Diffo.Test.ShelfValue - characteristic :baz, Diffo.Test.ShelfValue + characteristic :baz, Diffo.Test.Characteristic.Shelf + characteristic :baz, Diffo.Test.Characteristic.Shelf end end end @@ -322,7 +322,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do fn -> defmodule DuplicatePartyRole do alias Diffo.Provider.BaseInstance - alias Diffo.Test.Shelf + alias Diffo.Test.Instance.Shelf use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo resource do @@ -376,7 +376,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest 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.Shelf does not extend BaseParty", fn -> defmodule InvalidPartyBaseType do alias Diffo.Provider.BaseInstance @@ -393,7 +393,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party :operator, Diffo.Test.Shelf + party :operator, Diffo.Test.Instance.Shelf end end end @@ -532,7 +532,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do 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.Shelf does not extend BaseParty", + "parties: party_type Diffo.Test.Instance.Shelf does not extend BaseParty", fn -> defmodule InvalidPartyRefBaseType do alias Diffo.Provider.BaseInstance @@ -549,7 +549,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party_ref :owner, Diffo.Test.Shelf + party_ref :owner, Diffo.Test.Instance.Shelf end end end @@ -590,7 +590,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do 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.Shelf does not extend BasePlace", + "places: place_type Diffo.Test.Instance.Shelf does not extend BasePlace", fn -> defmodule InvalidPlaceRefBaseType do alias Diffo.Provider.BaseInstance @@ -607,7 +607,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end places do - place_ref :billing, Diffo.Test.Shelf + place_ref :billing, Diffo.Test.Instance.Shelf end end end diff --git a/test/provider/extension/party_test.exs b/test/provider/extension/party_test.exs index 530089f..ef8fd2e 100644 --- a/test/provider/extension/party_test.exs +++ b/test/provider/extension/party_test.exs @@ -8,10 +8,10 @@ defmodule Diffo.Provider.Extension.PartyTest do 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.Shelf alias Diffo.Test.Nbn alias Diffo.Test.Servo alias Diffo.Provider.Instance.Party @@ -41,7 +41,7 @@ defmodule Diffo.Provider.Extension.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 diff --git a/test/provider/extension/party_transformer_test.exs b/test/provider/extension/party_transformer_test.exs index ead64ec..4386133 100644 --- a/test/provider/extension/party_transformer_test.exs +++ b/test/provider/extension/party_transformer_test.exs @@ -6,8 +6,8 @@ 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.Test.Party.Organization + alias Diffo.Test.Party.Person alias Diffo.Provider.Extension.InstanceRole alias Diffo.Provider.Extension.PartyRole alias Diffo.Provider.Extension.PlaceRole diff --git a/test/provider/extension/party_verifier_test.exs b/test/provider/extension/party_verifier_test.exs index ac4fd3b..a4bec88 100644 --- a/test/provider/extension/party_verifier_test.exs +++ b/test/provider/extension/party_verifier_test.exs @@ -58,7 +58,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest 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 @@ -70,7 +70,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do provider do instances do - role :operator, Diffo.Test.Organization + role :operator, Diffo.Test.Party.Organization end end end @@ -95,8 +95,8 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do provider do parties do - role :employer, Diffo.Test.Organization - role :employer, Diffo.Test.Organization + role :employer, Diffo.Test.Party.Organization + role :employer, Diffo.Test.Party.Organization end end end @@ -202,7 +202,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest 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 WrongPartyPlaceRoleType do alias Diffo.Provider.BaseParty @@ -214,7 +214,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do provider do places do - role :headquarters, Diffo.Test.Organization + role :headquarters, Diffo.Test.Party.Organization end end end @@ -250,7 +250,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do 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.Organization does not extend BaseInstance", + "instances: instance_type Diffo.Test.Party.Organization does not extend BaseInstance", fn -> defmodule InvalidPartyInstanceRefBaseType do alias Diffo.Provider.BaseParty @@ -262,7 +262,7 @@ defmodule Diffo.Provider.Extension.PartyVerifierTest do provider do instances do - instance_ref :manages, Diffo.Test.Organization + instance_ref :manages, Diffo.Test.Party.Organization end end end diff --git a/test/provider/extension/place_test.exs b/test/provider/extension/place_test.exs index 990733d..b159366 100644 --- a/test/provider/extension/place_test.exs +++ b/test/provider/extension/place_test.exs @@ -8,10 +8,10 @@ defmodule Diffo.Provider.Extension.PlaceTest do 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.Shelf alias Diffo.Test.Nbn setup do diff --git a/test/provider/extension/place_transformer_test.exs b/test/provider/extension/place_transformer_test.exs index 80871e1..e0137c6 100644 --- a/test/provider/extension/place_transformer_test.exs +++ b/test/provider/extension/place_transformer_test.exs @@ -6,7 +6,7 @@ defmodule Diffo.Provider.Extension.PlaceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true - alias Diffo.Test.GeographicSite + alias Diffo.Test.Place.GeographicSite alias Diffo.Provider.Extension.InstanceRole alias Diffo.Provider.Extension.PartyRole alias Diffo.Provider.Extension.PlaceRole diff --git a/test/provider/extension/place_verifier_test.exs b/test/provider/extension/place_verifier_test.exs index d57bc05..3d10534 100644 --- a/test/provider/extension/place_verifier_test.exs +++ b/test/provider/extension/place_verifier_test.exs @@ -58,7 +58,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest 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 @@ -70,7 +70,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do provider do instances do - role :site_for, Diffo.Test.Organization + role :site_for, Diffo.Test.Party.Organization end end end @@ -95,8 +95,8 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do provider do parties do - role :managed_by, Diffo.Test.Organization - role :managed_by, Diffo.Test.Organization + role :managed_by, Diffo.Test.Party.Organization + role :managed_by, Diffo.Test.Party.Organization end end end @@ -202,7 +202,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest 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 WrongPlacePlaceRoleType do alias Diffo.Provider.BasePlace @@ -214,7 +214,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do provider do places do - role :contained_in, Diffo.Test.Organization + role :contained_in, Diffo.Test.Party.Organization end end end @@ -250,7 +250,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do 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.Organization does not extend BaseInstance", + "instances: instance_type Diffo.Test.Party.Organization does not extend BaseInstance", fn -> defmodule InvalidPlaceInstanceRefBaseType do alias Diffo.Provider.BasePlace @@ -262,7 +262,7 @@ defmodule Diffo.Provider.Extension.PlaceVerifierTest do provider do instances do - instance_ref :site_for, Diffo.Test.Organization + instance_ref :site_for, Diffo.Test.Party.Organization end end end diff --git a/test/provider/extension/specification_test.exs b/test/provider/extension/specification_test.exs index 952da58..c30016d 100644 --- a/test/provider/extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -6,7 +6,7 @@ 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.Shelf setup do AshNeo4j.Sandbox.checkout() 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.ex b/test/support/resource/characteristic/card.ex new file mode 100644 index 0000000..455a082 --- /dev/null +++ b/test/support/resource/characteristic/card.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.Card 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/value.ex b/test/support/resource/characteristic/card/value.ex new file mode 100644 index 0000000..2e52661 --- /dev/null +++ b/test/support/resource/characteristic/card/value.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.Card.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.ex b/test/support/resource/characteristic/shelf.ex new file mode 100644 index 0000000..a400bf9 --- /dev/null +++ b/test/support/resource/characteristic/shelf.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.Shelf 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/value.ex b/test/support/resource/characteristic/shelf/value.ex new file mode 100644 index 0000000..11cb450 --- /dev/null +++ b/test/support/resource/characteristic/shelf/value.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2025 diffo contributors +# +# SPDX-License-Identifier: MIT + +defmodule Diffo.Test.Characteristic.Shelf.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 96% rename from test/support/resource/broadband.ex rename to test/support/resource/instance/broadband.ex index 7eac2d6..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 diff --git a/test/support/resource/broadband_v2.ex b/test/support/resource/instance/broadband_v2.ex similarity index 96% rename from test/support/resource/broadband_v2.ex rename to test/support/resource/instance/broadband_v2.ex index 3dc4b1b..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 diff --git a/test/support/resource/card.ex b/test/support/resource/instance/card.ex similarity index 90% rename from test/support/resource/card.ex rename to test/support/resource/instance/card.ex index 6ec064b..bad90e8 100644 --- a/test/support/resource/card.ex +++ b/test/support/resource/instance/card.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Card do +defmodule Diffo.Test.Instance.Card do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -15,7 +15,7 @@ defmodule Diffo.Test.Card do alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue alias Diffo.Test.Servo - alias Diffo.Test.CardValue + alias Diffo.Test.Characteristic.Card, as: CardCharacteristic use Ash.Resource, fragments: [BaseInstance], @@ -36,7 +36,7 @@ defmodule Diffo.Test.Card do end characteristics do - characteristic :card, CardValue + characteristic :card, CardCharacteristic characteristic :ports, AssignableValue end @@ -65,7 +65,8 @@ 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} <- 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.ex similarity index 80% rename from test/support/resource/shelf.ex rename to test/support/resource/instance/shelf.ex index 4b24237..1643880 100644 --- a/test/support/resource/shelf.ex +++ b/test/support/resource/instance/shelf.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Shelf do +defmodule Diffo.Test.Instance.Shelf do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -17,8 +17,10 @@ defmodule Diffo.Test.Shelf do alias Diffo.Provider.AssignableValue alias Diffo.Test.Servo - alias Diffo.Test.ShelfValue - alias Diffo.Test.DeploymentClassValue + alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic + alias Diffo.Test.Characteristic.DeploymentClass + alias Diffo.Test.Party.Organization + alias Diffo.Test.Party.Person use Ash.Resource, fragments: [BaseInstance], @@ -45,23 +47,23 @@ 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_ref :provider, Diffo.Test.Organization - 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 @@ -94,7 +96,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 97% rename from test/support/resource/carrier.ex rename to test/support/resource/party/carrier.ex index 3614c5c..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 diff --git a/test/support/resource/organization.ex b/test/support/resource/party/organization.ex similarity index 91% rename from test/support/resource/organization.ex rename to test/support/resource/party/organization.ex index bbe81c4..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 @@ -43,7 +43,7 @@ defmodule Diffo.Test.Organization do end parties do - role :employer, Diffo.Test.Person + role :employer, Diffo.Test.Party.Person end places do diff --git a/test/support/resource/person.ex b/test/support/resource/party/person.ex similarity index 91% rename from test/support/resource/person.ex rename to test/support/resource/party/person.ex index 1e559aa..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 @@ -43,7 +43,7 @@ defmodule Diffo.Test.Person do end parties do - role :manager, Diffo.Test.Person + role :manager, Diffo.Test.Party.Person end places do diff --git a/test/support/resource/exchange_building.ex b/test/support/resource/place/exchange_building.ex similarity index 93% rename from test/support/resource/exchange_building.ex rename to test/support/resource/place/exchange_building.ex index 83e9f4b..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 @@ -60,7 +60,7 @@ defmodule Diffo.Test.ExchangeBuilding do end parties do - role :operator, Diffo.Test.Carrier + 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 90% rename from test/support/resource/geographic_site.ex rename to test/support/resource/place/geographic_site.ex index 614964e..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 @@ -44,7 +44,7 @@ defmodule Diffo.Test.GeographicSite do end parties do - role :managed_by, Diffo.Test.Organization + role :managed_by, Diffo.Test.Party.Organization end places do 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..20acdf6 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -12,10 +12,13 @@ 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.Shelf + alias Diffo.Test.Instance.Card + alias Diffo.Test.Instance.Broadband + alias Diffo.Test.Instance.BroadbandV2 + alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic + alias Diffo.Test.Characteristic.Card, as: CardCharacteristic + alias Diffo.Test.Characteristic.DeploymentClass domain do description "service and resource management" @@ -47,5 +50,9 @@ 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 end end diff --git a/test/type/dynamic_test.exs b/test/type/dynamic_test.exs index 2778b52..f30dfec 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.Card, as: CardValue describe "dynamic type validation" do test "cast_input rejects non-NewType scalar Ash type" do From a8a04c5f281d92d4a74f5adcf25ec60c4bc1ff34 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 00:07:14 +0930 Subject: [PATCH 08/15] docs and guidance --- AGENTS.md | 8 +- .../use_diffo_provider_extension.livemd | 248 +++++++++++------- usage-rules.md | 70 ++++- 3 files changed, 229 insertions(+), 97 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 130e77b..37da489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,9 @@ lib/diffo/provider/ 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 + calculations/ + characteristic_value.ex # Calculation: builds .Value TypedStruct from record fields instance/extension.ex # Thin marker (sections: []) — kind identification party/extension.ex # Thin marker place/extension.ex # Thin marker @@ -82,8 +85,8 @@ provider do end characteristics do - characteristic :slot_value, MyApp.SlotValue - characteristic :ports, {:array, MyApp.Port} + characteristic :slot_value, MyApp.SlotCharacteristic + characteristic :ports, {:array, MyApp.PortCharacteristic} end features do @@ -144,6 +147,7 @@ mix test --max-failures 5 # stop early - 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`. - 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; diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index 7870bde..954e651 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -137,7 +137,7 @@ The `provider do` section contains: **`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. -**`characteristics do`** — typed value slots backed by `Ash.TypedStruct`. +**`characteristics do`** — typed value slots backed by `Diffo.Provider.BaseCharacteristic`-derived resources. **`features do`** — optional capabilities with their own typed characteristic payload. @@ -149,7 +149,7 @@ The id is a stable UUID4, the same in every environment for this Instance kind. Declaring `create :name` injects `:specified_by`, `:features`, and `:characteristics` arguments automatically onto that action. -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. +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. It is used by the host resource, which declares a characteristic with a `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. @@ -165,6 +165,73 @@ We'll define all the resources first, then declare the `Diffo.Compute` domain on 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 @moduledoc """ @@ -173,9 +240,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 @@ -198,7 +264,7 @@ defmodule Diffo.Compute.Cluster do end characteristics do - characteristic :cluster, ClusterValue + characteristic :cluster, ClusterCharacteristic end parties do @@ -230,17 +296,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} @@ -255,49 +310,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 keeps `AssignableValue` for the `:cores` allocation pool (the assigner still uses the dynamic characteristic pattern). The `update :define` action now only needs to handle the dynamic `:cores` update — the typed `:gpu` characteristic is updated directly on the characteristic resource: ```elixir defmodule Diffo.Compute.GPU do @@ -312,7 +396,7 @@ defmodule Diffo.Compute.GPU do alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue alias Diffo.Compute - alias Diffo.Compute.GPUValue + alias Diffo.Compute.GpuCharacteristic use Ash.Resource, fragments: [BaseInstance], @@ -333,7 +417,7 @@ defmodule Diffo.Compute.GPU do end characteristics do - characteristic :gpu, GPUValue + characteristic :gpu, GpuCharacteristic characteristic :cores, AssignableValue end @@ -358,7 +442,7 @@ defmodule Diffo.Compute.GPU do end update :define do - description "defines the GPU" + description "allocates the GPU cores (AssignableValue)" argument :characteristic_value_updates, {:array, :term} change after_action(fn changeset, result, _context -> @@ -392,41 +476,6 @@ defmodule Diffo.Compute.GPU do end 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 @@ -579,8 +628,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 @@ -594,6 +645,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 @@ -605,10 +660,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 @@ -687,19 +745,27 @@ 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 set the typed `:gpu` characteristic directly on the characteristic resource, then allocate the `:cores` AssignableValue via `update :define`: ```elixir -updates = [ - gpu: [family: :nvidia, model: "GeForce RTX5090", technology: :blackwell], - cores: [first: 1, last: 680, free: 680, assignable_type: "tensor"] -] +# Update the typed GpuCharacteristic on each GPU +[gpu_char_1] = Enum.filter(gpu_1.characteristics, fn c -> c.name == :gpu end) +[gpu_char_2] = Enum.filter(gpu_2.characteristics, fn c -> c.name == :gpu end) + +gpu_attrs = %{family: :nvidia, model: "GeForce RTX5090", technology: :blackwell} +Compute.update_gpu_characteristic!(gpu_char_1, gpu_attrs) +Compute.update_gpu_characteristic!(gpu_char_2, gpu_attrs) +``` + +```elixir +# Allocate the cores pool (AssignableValue — dynamic characteristic) +core_updates = [cores: [first: 1, last: 680, free: 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: core_updates}) +gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: core_updates}) ``` -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 GPU's `:cores` characteristic is an AssignableValue that tracks how many cores are free (unassigned). We can render one as json: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts diff --git a/usage-rules.md b/usage-rules.md index 2a9e034..beac8e4 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -67,15 +67,77 @@ end ### `characteristics do` — Instance only -Declares typed value slots. Each characteristic is backed by an `Ash.TypedStruct`. Do **not** +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.Speed - characteristic :access_technology, MyApp.AccessTechnology - characteristic :ports, {:array, MyApp.Port} + 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 +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 ``` From f5c9448b8689c4c40a76f076ba33128049220bef Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 08:02:23 +0930 Subject: [PATCH 09/15] phases 1,2,3 --- lib/diffo/provider.ex | 8 + .../assigner/assignable_characteristic.ex | 81 ++++++ .../assignable_characteristic/value.ex | 25 ++ lib/diffo/provider/assigner/assigner.ex | 271 ++++++------------ lib/diffo/provider/components/relationship.ex | 51 +++- test/provider/extension/assigner_test.exs | 50 ++-- test/support/resource/instance/card.ex | 4 +- test/support/servo.ex | 2 + 8 files changed, 274 insertions(+), 218 deletions(-) create mode 100644 lib/diffo/provider/assigner/assignable_characteristic.ex create mode 100644 lib/diffo/provider/assigner/assignable_characteristic/value.ex diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index 74987a4..abe503d 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -59,6 +59,7 @@ defmodule Diffo.Provider do resource Diffo.Provider.Relationship do define :create_relationship, action: :create + define :create_assignment_relationship, action: :create_assignment define :get_relationship_by_id, action: :read, get_by: :id define :list_relationships, action: :list @@ -77,6 +78,13 @@ defmodule Diffo.Provider do define :delete_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..38cfefe --- /dev/null +++ b/lib/diffo/provider/assigner/assignable_characteristic.ex @@ -0,0 +1,81 @@ +# 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 + 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/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 32b47e2..8d93608 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -4,16 +4,19 @@ defmodule Diffo.Provider.Assigner do @moduledoc """ - Helper to perform Assignment maintaining AssignableValue + Helper to perform Assignment using Relationship attributes. + + Assignment state is stored directly on `Diffo.Provider.Relationship` nodes + (pool, thing, assigned) rather than creating a separate Characteristic node. """ - alias Diffo.Provider.AssignableValue - alias Diffo.Type.Value + alias Diffo.Provider.AssignableCharacteristic + alias Diffo.Provider.Relationship @doc """ - Assign a thing using the instance changeset assignment + Assign a thing using the instance changeset assignment. """ - 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, 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,240 +28,136 @@ 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_assignment_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 + Relationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: source_id, + target_id: target_id, + pool: pool, + thing: thing, + assigned: value, + type: :assignedTo + ) + |> 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) - - case free = free(instance, thing, assignable_value) do - [] -> - {:error, "all things are assigned"} + defp next(instance, pool, thing) + when is_struct(instance) and is_atom(pool) and is_atom(thing) do + case pool_characteristic(instance.id, pool) do + {:ok, nil} -> + {:error, "pool #{pool} not found on instance #{instance.id}"} - _ -> - case algorithm do - :lowest -> - {:ok, hd(free)} + {:ok, char} -> + free = free_values(instance.id, pool, thing, char.first, char.last) - :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) do + {:ok, nil} -> false + {:ok, char} -> value in free_values(instance.id, pool, thing, char.first, char.last) + {: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)) + defp pool_characteristic(instance_id, pool) do + AssignableCharacteristic + |> Ash.Query.new() + |> Ash.Query.filter_input(instance_id: instance_id, name: pool) + |> Ash.read_one(domain: Diffo.Provider) + end - first = Map.get(assignable_value, :first) - last = Map.get(assignable_value, :last) + defp free_values(source_id, pool, thing, first, last) do + assigned = + Relationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: source_id, + pool: pool, + thing: thing, + type: :assignedTo + ) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.assigned) Enum.to_list(first..last) -- assigned end diff --git a/lib/diffo/provider/components/relationship.ex b/lib/diffo/provider/components/relationship.ex index 28ceb03..fdcc560 100644 --- a/lib/diffo/provider/components/relationship.ex +++ b/lib/diffo/provider/components/relationship.ex @@ -44,10 +44,18 @@ defmodule Diffo.Provider.Relationship do list_name = Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) - result - |> Diffo.Util.set(target_type, reference) - |> Diffo.Util.suppress_rename(:characteristics, list_name) - |> Diffo.Util.suppress(:alias) + if record.type == :assignedTo and not is_nil(record.assigned) do + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.remove(:alias) + |> Diffo.Util.remove(:characteristics) + |> Diffo.Util.set(list_name, [%{name: record.thing, value: record.assigned}]) + else + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.suppress_rename(:characteristics, list_name) + |> Diffo.Util.suppress(:alias) + end end order [ @@ -82,6 +90,19 @@ defmodule Diffo.Provider.Relationship do change load [:characteristics] end + create :create_assignment do + description "creates an assignment 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 + read :list do description "lists all relationships" end @@ -148,6 +169,24 @@ defmodule Diffo.Provider.Relationship do public? true end + attribute :pool, :atom do + description "the pool name on the source instance (assignedTo relationships only)" + allow_nil? true + public? true + end + + attribute :thing, :atom do + description "the kind of thing being assigned within the pool (assignedTo relationships only)" + allow_nil? true + public? true + end + + attribute :assigned, :integer do + description "the assigned value from the pool (assignedTo relationships only)" + allow_nil? true + public? true + end + create_timestamp :created_at update_timestamp :updated_at @@ -174,6 +213,10 @@ defmodule Diffo.Provider.Relationship do identities do identity :unique_source_and_target, [:source_id, :target_id] + + identity :unique_assignment, [:source_id, :target_id, :pool, :thing, :assigned] do + where expr(type == :assignedTo) + end end validations do diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index 3e8d8f2..a3d2574 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -9,7 +9,6 @@ defmodule Diffo.Provider.Extension.AssignerTest do alias Diffo.Provider.Characteristic alias Diffo.Provider.Assignment - alias Diffo.Test.Characteristics alias Diffo.Test.Parties alias Diffo.Test.Servo alias Diffo.Test.Instance.Card @@ -24,10 +23,8 @@ defmodule Diffo.Provider.Extension.AssignerTest do test "create a card" do {:ok, card} = Servo.build_card(%{}) - # check the instance is a Card assert is_struct(card, Card) - # check specification resource enrichment and node relationship refute is_nil(card.specification_id) assert is_struct(card.specification, Specification) @@ -40,10 +37,9 @@ defmodule Diffo.Provider.Extension.AssignerTest do :outgoing ) - # check dynamic characteristic resource enrichment and node relationships - # :card is now a typed CardValue node (not in characteristics); only :ports remains dynamic + # both :card and :ports are now typed (BaseCharacteristic), not in dynamic characteristics assert is_list(card.characteristics) - assert length(card.characteristics) == 1 + assert length(card.characteristics) == 0 Enum.each(card.characteristics, fn characteristic -> assert is_struct(characteristic, Characteristic) @@ -61,7 +57,7 @@ defmodule Diffo.Provider.Extension.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\":\"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 @@ -69,7 +65,7 @@ defmodule Diffo.Provider.Extension.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}) @@ -87,7 +83,7 @@ defmodule Diffo.Provider.Extension.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\":\"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 @@ -97,7 +93,7 @@ defmodule Diffo.Provider.Extension.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}) @@ -107,12 +103,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 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\":\"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 @@ -122,7 +119,7 @@ defmodule Diffo.Provider.Extension.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}) @@ -137,12 +134,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 46]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 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\":\"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 @@ -152,7 +150,7 @@ defmodule Diffo.Provider.Extension.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}) @@ -167,12 +165,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{id: 5, assignee_id: assignee.id, operation: :assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 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\":\"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 @@ -182,7 +181,7 @@ defmodule Diffo.Provider.Extension.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}) @@ -192,14 +191,12 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - Characteristics.check_values([ports: [free: 47]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 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() + |> Map.get(:assigned) {:ok, card} = Servo.assign_port(card, %{ @@ -210,12 +207,13 @@ defmodule Diffo.Provider.Extension.AssignerTest do } }) - Characteristics.check_values([ports: [free: 48]], card) + assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) + assert length(assigned_rels) == 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\":\"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/support/resource/instance/card.ex b/test/support/resource/instance/card.ex index bad90e8..ce6269e 100644 --- a/test/support/resource/instance/card.ex +++ b/test/support/resource/instance/card.ex @@ -13,7 +13,7 @@ defmodule Diffo.Test.Instance.Card do alias Diffo.Provider.Extension.Characteristic alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableValue + alias Diffo.Provider.AssignableCharacteristic alias Diffo.Test.Servo alias Diffo.Test.Characteristic.Card, as: CardCharacteristic @@ -37,7 +37,7 @@ defmodule Diffo.Test.Instance.Card do characteristics do characteristic :card, CardCharacteristic - characteristic :ports, AssignableValue + characteristic :ports, AssignableCharacteristic end behaviour do diff --git a/test/support/servo.ex b/test/support/servo.ex index 20acdf6..54bafeb 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -19,6 +19,7 @@ defmodule Diffo.Test.Servo do alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic alias Diffo.Test.Characteristic.Card, as: CardCharacteristic alias Diffo.Test.Characteristic.DeploymentClass + alias Diffo.Provider.AssignableCharacteristic domain do description "service and resource management" @@ -54,5 +55,6 @@ defmodule Diffo.Test.Servo do resource ShelfCharacteristic resource CardCharacteristic resource DeploymentClass + resource AssignableCharacteristic end end From 7eb1faf59c948210b64f1fcf232c259ab0042793 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 14:48:22 +0930 Subject: [PATCH 10/15] phase 4 --- .../assigner/assignable_characteristic.ex | 6 ++++ lib/diffo/provider/assigner/assigner.ex | 27 ++++------------- .../calculations/assigned_values.ex | 29 +++++++++++++++++++ 3 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 lib/diffo/provider/components/calculations/assigned_values.ex diff --git a/lib/diffo/provider/assigner/assignable_characteristic.ex b/lib/diffo/provider/assigner/assignable_characteristic.ex index 38cfefe..3e4b568 100644 --- a/lib/diffo/provider/assigner/assignable_characteristic.ex +++ b/lib/diffo/provider/assigner/assignable_characteristic.ex @@ -54,6 +54,12 @@ defmodule Diffo.Provider.AssignableCharacteristic do 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 diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 8d93608..33b7c52 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -106,12 +106,12 @@ defmodule Diffo.Provider.Assigner do defp next(instance, pool, thing) when is_struct(instance) and is_atom(pool) and is_atom(thing) do - case pool_characteristic(instance.id, pool) do + case pool_characteristic(instance.id, pool, thing) do {:ok, nil} -> {:error, "pool #{pool} not found on instance #{instance.id}"} {:ok, char} -> - free = free_values(instance.id, pool, thing, char.first, char.last) + free = Enum.to_list(char.first..char.last) -- char.assigned_values case free do [] -> @@ -132,33 +132,18 @@ defmodule Diffo.Provider.Assigner do 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) do + case pool_characteristic(instance.id, pool, thing) do {:ok, nil} -> false - {:ok, char} -> value in free_values(instance.id, pool, thing, char.first, char.last) + {:ok, char} -> value in Enum.to_list(char.first..char.last) -- char.assigned_values {:error, _} -> false end end - defp pool_characteristic(instance_id, pool) do + 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 - - defp free_values(source_id, pool, thing, first, last) do - assigned = - Relationship - |> Ash.Query.new() - |> Ash.Query.filter_input( - source_id: source_id, - pool: pool, - thing: thing, - type: :assignedTo - ) - |> Ash.read!(domain: Diffo.Provider) - |> Enum.map(& &1.assigned) - - Enum.to_list(first..last) -- assigned - 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..fdd712a --- /dev/null +++ b/lib/diffo/provider/components/calculations/assigned_values.ex @@ -0,0 +1,29 @@ +# 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.Relationship + |> Ash.Query.new() + |> Ash.Query.filter_input( + source_id: record.instance_id, + pool: record.name, + thing: thing, + type: :assignedTo + ) + |> Ash.read!(domain: Diffo.Provider) + |> Enum.map(& &1.assigned) + end) + end +end From 6757a8f9d994b534c2db81343235f540dbc0b8c7 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 16:47:31 +0930 Subject: [PATCH 11/15] stage 5 - pools DSL --- lib/diffo/provider/assigner/assigner.ex | 12 ++++ lib/diffo/provider/extension.ex | 40 ++++++++++- .../provider/extension/characteristic.ex | 9 +-- .../extension/persisters/persist_pools.ex | 26 +++++++ lib/diffo/provider/extension/pool.ex | 69 +++++++++++++++++++ .../transformers/transform_behaviour.ex | 6 ++ .../extension/verifiers/verify_pools.ex | 34 +++++++++ .../extension/instance_transformer_test.exs | 3 +- test/support/resource/instance/card.ex | 10 ++- 9 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 lib/diffo/provider/extension/persisters/persist_pools.ex create mode 100644 lib/diffo/provider/extension/pool.ex create mode 100644 lib/diffo/provider/extension/verifiers/verify_pools.ex diff --git a/lib/diffo/provider/assigner/assigner.ex b/lib/diffo/provider/assigner/assigner.ex index 33b7c52..14930e7 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -12,6 +12,18 @@ defmodule Diffo.Provider.Assigner do alias Diffo.Provider.AssignableCharacteristic alias Diffo.Provider.Relationship + @doc """ + 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, 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. """ diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index bbc7042..299a9c2 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -92,7 +92,8 @@ defmodule Diffo.Provider.Extension do PartyDeclaration, PartyRole, PlaceDeclaration, - PlaceRole + PlaceRole, + Pool } # ── specification ────────────────────────────────────────────────────────── @@ -425,6 +426,40 @@ defmodule Diffo.Provider.Extension do 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{ @@ -485,6 +520,7 @@ defmodule Diffo.Provider.Extension do @specification, @characteristics, @features, + @pools, @parties, @places, @instances, @@ -498,6 +534,7 @@ defmodule Diffo.Provider.Extension do 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, @@ -507,6 +544,7 @@ defmodule Diffo.Provider.Extension do 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, diff --git a/lib/diffo/provider/extension/characteristic.ex b/lib/diffo/provider/extension/characteristic.ex index 865fe68..f548dfb 100644 --- a/lib/diffo/provider/extension/characteristic.ex +++ b/lib/diffo/provider/extension/characteristic.ex @@ -104,11 +104,12 @@ defmodule Diffo.Provider.Extension.Characteristic do 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) - if decl && typed?(decl.value_type) do - apply_typed_update(acc, name, decl.value_type, update) - else - apply_dynamic_update(acc, name, update) + 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 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/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 index e456331..7bc70ba 100644 --- a/lib/diffo/provider/extension/transformers/transform_behaviour.ex +++ b/lib/diffo/provider/extension/transformers/transform_behaviour.ex @@ -44,6 +44,8 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do result, features() ), + {:ok, result} <- + Diffo.Provider.Extension.Pool.create_pools(result, pools()), do: {:ok, result} end @@ -77,6 +79,9 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do 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)) @@ -127,6 +132,7 @@ defmodule Diffo.Provider.Extension.Transformers.TransformBehaviour do 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 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/test/provider/extension/instance_transformer_test.exs b/test/provider/extension/instance_transformer_test.exs index b4d5b8a..c7964dd 100644 --- a/test/provider/extension/instance_transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -55,7 +55,8 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do test "characteristics are also accessible via Info" do assert length(Info.characteristics(Shelf)) == 3 - assert length(Info.characteristics(Card)) == 2 + # Card has :card characteristic; :ports moved to pools do + assert length(Info.characteristics(Card)) == 1 end test "Info.characteristic/2 returns the named characteristic" do diff --git a/test/support/resource/instance/card.ex b/test/support/resource/instance/card.ex index ce6269e..11bf7f4 100644 --- a/test/support/resource/instance/card.ex +++ b/test/support/resource/instance/card.ex @@ -11,9 +11,9 @@ defmodule Diffo.Test.Instance.Card do alias Diffo.Provider.BaseInstance alias Diffo.Provider.Instance.Relationship alias Diffo.Provider.Extension.Characteristic + alias Diffo.Provider.Extension.Pool alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment - alias Diffo.Provider.AssignableCharacteristic alias Diffo.Test.Servo alias Diffo.Test.Characteristic.Card, as: CardCharacteristic @@ -37,7 +37,10 @@ defmodule Diffo.Test.Instance.Card do characteristics do characteristic :card, CardCharacteristic - characteristic :ports, AssignableCharacteristic + end + + pools do + pool :ports, :port end behaviour do @@ -67,6 +70,7 @@ defmodule Diffo.Test.Instance.Card do 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} <- Servo.get_card_by_id(result.id), do: {:ok, result} end) @@ -88,7 +92,7 @@ defmodule Diffo.Test.Instance.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) From 51e43e6029c8baab75b777617b39e9dd16e475ac Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 16:55:36 +0930 Subject: [PATCH 12/15] docs and guidance --- AGENTS.md | 14 ++++ .../dsls/DSL-Diffo.Provider.Extension.md | 50 ++++++++++++++ .../use_diffo_provider_extension.livemd | 58 ++++++++--------- lib/diffo/provider/extension.ex | 4 ++ usage-rules.md | 65 ++++++++++++++++++- 5 files changed, 159 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 37da489..63df0a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ lib/diffo/provider/ 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 @@ -36,6 +37,9 @@ lib/diffo/provider/ 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 base_instance.ex # Ash Fragment for Instance resources base_party.ex # Ash Fragment for Party resources base_place.ex # Ash Fragment for Place resources @@ -43,6 +47,7 @@ lib/diffo/provider/ base_characteristic.ex # Ash Fragment for typed characteristic resources 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 @@ -89,6 +94,11 @@ provider do 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 @@ -148,6 +158,10 @@ mix test --max-failures 5 # stop early - 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. - 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; diff --git a/documentation/dsls/DSL-Diffo.Provider.Extension.md b/documentation/dsls/DSL-Diffo.Provider.Extension.md index ba5e90f..091aea2 100644 --- a/documentation/dsls/DSL-Diffo.Provider.Extension.md +++ b/documentation/dsls/DSL-Diffo.Provider.Extension.md @@ -30,6 +30,10 @@ the sections relevant to it, and verifiers enforce correct usage. end end + pools do + pool :ports, :port + end + parties do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo @@ -92,6 +96,8 @@ Provider DSL — structure, roles, and behaviour for this resource kind * [features](#provider-features) * feature * characteristic + * [pools](#provider-pools) + * pool * [parties](#provider-parties) * party * parties @@ -276,6 +282,50 @@ Adds a Characteristic +### 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 diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index 954e651..df8ad05 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -141,6 +141,8 @@ The id is a stable UUID4, the same in every environment for this Instance kind. **`features do`** — optional capabilities with their own typed characteristic payload. +**`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. + **`parties do`** — party roles: `party` (singular), `parties` (plural), `party_ref` (reference, no direct edge). **`places do`** — place roles: `place` (singular), `places` (plural), `place_ref` (reference). @@ -151,7 +153,7 @@ arguments automatically onto that action. 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. It is used by the host resource, which declares a characteristic with a `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. +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. Assignment to Services or Resources is via `type: :assignedTo` Relationships that carry the assigned value directly on the Relationship node. 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. @@ -381,7 +383,7 @@ defmodule Diffo.Compute.GpuCharacteristic.Value do end ``` -The GPU resource declares `GpuCharacteristic` for the typed `:gpu` slot and keeps `AssignableValue` for the `:cores` allocation pool (the assigner still uses the dynamic characteristic pattern). The `update :define` action now only needs to handle the dynamic `:cores` update — the typed `:gpu` characteristic is updated directly on the characteristic resource: +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 @@ -391,10 +393,10 @@ 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.GpuCharacteristic @@ -418,7 +420,10 @@ defmodule Diffo.Compute.GPU do characteristics do characteristic :gpu, GpuCharacteristic - characteristic :cores, AssignableValue + end + + pools do + pool :cores, :core end behaviour do @@ -442,11 +447,13 @@ defmodule Diffo.Compute.GPU do end update :define do - description "allocates the GPU cores (AssignableValue)" + 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) @@ -464,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) @@ -476,7 +483,6 @@ defmodule Diffo.Compute.GPU do end end ``` -``` ## Party Extension @@ -745,27 +751,19 @@ gpu_1 = Compute.build_gpu!(%{name: "GPU 1"}) gpu_2 = Compute.build_gpu!(%{name: "GPU 2"}) ``` -We set the typed `:gpu` characteristic directly on the characteristic resource, then allocate the `:cores` AssignableValue via `update :define`: +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 -# Update the typed GpuCharacteristic on each GPU -[gpu_char_1] = Enum.filter(gpu_1.characteristics, fn c -> c.name == :gpu end) -[gpu_char_2] = Enum.filter(gpu_2.characteristics, fn c -> c.name == :gpu end) - -gpu_attrs = %{family: :nvidia, model: "GeForce RTX5090", technology: :blackwell} -Compute.update_gpu_characteristic!(gpu_char_1, gpu_attrs) -Compute.update_gpu_characteristic!(gpu_char_2, gpu_attrs) -``` - -```elixir -# Allocate the cores pool (AssignableValue — dynamic characteristic) -core_updates = [cores: [first: 1, last: 680, free: 680, assignable_type: "tensor"]] +gpu_attrs = [ + gpu: [family: :nvidia, model: "GeForce RTX5090", technology: :blackwell], + cores: [first: 1, last: 680, assignable_type: "tensor"] +] -gpu_1 = Compute.define_gpu!(gpu_1, %{characteristic_value_updates: core_updates}) -gpu_2 = Compute.define_gpu!(gpu_2, %{characteristic_value_updates: core_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 `:cores` characteristic is an AssignableValue that tracks 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 `:assignedTo` relationships — there is no stored `free` counter. We can render one as json: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts @@ -785,9 +783,9 @@ 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 `:assignedTo` Relationship nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each Relationship 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 `forward_relationships` include each `:assignedTo` relationship, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`: ```elixir Jason.encode!(gpu_1, pretty: true) |> IO.puts @@ -805,7 +803,9 @@ What happens when I request a specific assignment from an instance to which the In this tutorial you've used Diffo's unified `provider do` extension to define a Compute domain with: -- A composite Cluster resource with GPU core assignment via `Diffo.Provider.Assigner` +- 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 +- Assignment stored directly on `:assignedTo` Relationship nodes (no separate Characteristic nodes for 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 diff --git a/lib/diffo/provider/extension.ex b/lib/diffo/provider/extension.ex index 299a9c2..36a8e58 100644 --- a/lib/diffo/provider/extension.ex +++ b/lib/diffo/provider/extension.ex @@ -31,6 +31,10 @@ defmodule Diffo.Provider.Extension do end end + pools do + pool :ports, :port + end + parties do party :provider, MyApp.Provider party_ref :owner, MyApp.InfrastructureCo diff --git a/usage-rules.md b/usage-rules.md index beac8e4..e88c922 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -40,7 +40,7 @@ end All DSL declarations live inside a single `provider do` block. The sections available depend on the resource kind: -- **Instance** — `specification`, `characteristics`, `features`, `parties`, `places`, `behaviour` +- **Instance** — `specification`, `characteristics`, `features`, `pools`, `parties`, `places`, `behaviour` - **Party** — `instances`, `parties`, `places` - **Place** — `instances`, `parties`, `places` @@ -232,6 +232,58 @@ 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 @@ -253,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 @@ -406,3 +458,10 @@ end 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 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. From ff05068c66e77bb696beeec7af89c5bafeea4778 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 18:43:59 +0930 Subject: [PATCH 13/15] phase 6 - AssignerRelationship extends BaseRelationship --- AGENTS.md | 5 + .../use_diffo_provider_extension.livemd | 14 +-- lib/diffo/provider.ex | 7 +- .../assigner/assigned_to_relationship.ex | 103 ++++++++++++++++ lib/diffo/provider/assigner/assigner.ex | 15 ++- .../provider/components/base_instance.ex | 9 ++ .../provider/components/base_relationship.ex | 85 +++++++++++++ .../calculations/assigned_values.ex | 5 +- .../provider/components/instance/util.ex | 31 ++--- lib/diffo/provider/components/relationship.ex | 115 ++---------------- test/provider/extension/assigner_test.exs | 19 +-- usage-rules.md | 5 + 12 files changed, 258 insertions(+), 155 deletions(-) create mode 100644 lib/diffo/provider/assigner/assigned_to_relationship.ex create mode 100644 lib/diffo/provider/components/base_relationship.ex diff --git a/AGENTS.md b/AGENTS.md index 63df0a6..02f8af3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,13 @@ lib/diffo/provider/ 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 @@ -162,6 +164,9 @@ mix test --max-failures 5 # stop early - 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; diff --git a/documentation/how_to/use_diffo_provider_extension.livemd b/documentation/how_to/use_diffo_provider_extension.livemd index df8ad05..5c9514f 100644 --- a/documentation/how_to/use_diffo_provider_extension.livemd +++ b/documentation/how_to/use_diffo_provider_extension.livemd @@ -153,7 +153,7 @@ arguments automatically onto that action. 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. Assignment to Services or Resources is via `type: :assignedTo` Relationships that carry the assigned value directly on the Relationship node. +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. @@ -763,7 +763,7 @@ 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 `: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 `:assignedTo` relationships — there is no stored `free` counter. 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 @@ -783,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 `:assignedTo` Relationship nodes from `gpu_1` and `gpu_2` to `cluster_1`. There should be four — each Relationship carries `pool: :cores`, `thing: :core`, and the `assigned` integer value (e.g. 1, 2, 3 from gpu_1 and 1 from gpu_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). -The GPU's `forward_relationships` include each `:assignedTo` relationship, showing the assigned core number in the JSON encoding as a `resourceRelationshipCharacteristic`: +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. @@ -805,7 +805,7 @@ In this tutorial you've used Diffo's unified `provider do` extension to define a - 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 -- Assignment stored directly on `:assignedTo` Relationship nodes (no separate Characteristic nodes for assignments) +- 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 diff --git a/lib/diffo/provider.ex b/lib/diffo/provider.ex index abe503d..65d16a6 100644 --- a/lib/diffo/provider.ex +++ b/lib/diffo/provider.ex @@ -59,7 +59,6 @@ defmodule Diffo.Provider do resource Diffo.Provider.Relationship do define :create_relationship, action: :create - define :create_assignment_relationship, action: :create_assignment define :get_relationship_by_id, action: :read, get_by: :id define :list_relationships, action: :list @@ -78,6 +77,12 @@ 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 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 14930e7..daf5d39 100644 --- a/lib/diffo/provider/assigner/assigner.ex +++ b/lib/diffo/provider/assigner/assigner.ex @@ -4,13 +4,13 @@ defmodule Diffo.Provider.Assigner do @moduledoc """ - Helper to perform Assignment using Relationship attributes. + Helper to perform Assignment using `Diffo.Provider.AssignedToRelationship`. - Assignment state is stored directly on `Diffo.Provider.Relationship` nodes - (pool, thing, assigned) rather than creating a separate Characteristic node. + Assignment state is stored on `AssignedToRelationship` nodes (pool, thing, assigned), + distinct from regular TMF `Diffo.Provider.Relationship` nodes. """ alias Diffo.Provider.AssignableCharacteristic - alias Diffo.Provider.Relationship + alias Diffo.Provider.AssignedToRelationship @doc """ Assign a thing using the pool declared via `pools do` on the instance module. @@ -66,7 +66,7 @@ defmodule Diffo.Provider.Assigner do 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_assignment_relationship(%{ + case Diffo.Provider.create_assigned_to_relationship(%{ pool: pool, thing: thing, assigned: value, @@ -103,15 +103,14 @@ defmodule Diffo.Provider.Assigner do end defp find_assignment(source_id, target_id, pool, thing, value) do - Relationship + AssignedToRelationship |> Ash.Query.new() |> Ash.Query.filter_input( source_id: source_id, target_id: target_id, pool: pool, thing: thing, - assigned: value, - type: :assignedTo + assigned: value ) |> Ash.read_one(domain: Diffo.Provider) end diff --git a/lib/diffo/provider/components/base_instance.ex b/lib/diffo/provider/components/base_instance.ex index 94fa443..13e6b90 100644 --- a/lib/diffo/provider/components/base_instance.ex +++ b/lib/diffo/provider/components/base_instance.ex @@ -188,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}, @@ -209,6 +210,7 @@ defmodule Diffo.Provider.BaseInstance do :specification, :process_statuses, :forward_relationships, + :assignments, :features, :characteristics, :entities, @@ -407,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 @@ -655,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_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 index fdd712a..6ac879f 100644 --- a/lib/diffo/provider/components/calculations/assigned_values.ex +++ b/lib/diffo/provider/components/calculations/assigned_values.ex @@ -14,13 +14,12 @@ defmodule Diffo.Provider.Calculations.AssignedValues do thing = context.arguments[:thing] Enum.map(records, fn record -> - Diffo.Provider.Relationship + Diffo.Provider.AssignedToRelationship |> Ash.Query.new() |> Ash.Query.filter_input( source_id: record.instance_id, pool: record.name, - thing: thing, - type: :assignedTo + thing: thing ) |> Ash.read!(domain: Diffo.Provider) |> Enum.map(& &1.assigned) 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/relationship.ex b/lib/diffo/provider/components/relationship.ex index fdcc560..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" @@ -44,18 +44,10 @@ defmodule Diffo.Provider.Relationship do list_name = Diffo.Provider.Relationship.derive_relationship_characteristic_list_name(target_type) - if record.type == :assignedTo and not is_nil(record.assigned) do - result - |> Diffo.Util.set(target_type, reference) - |> Diffo.Util.remove(:alias) - |> Diffo.Util.remove(:characteristics) - |> Diffo.Util.set(list_name, [%{name: record.thing, value: record.assigned}]) - else - result - |> Diffo.Util.set(target_type, reference) - |> Diffo.Util.suppress_rename(:characteristics, list_name) - |> Diffo.Util.suppress(:alias) - end + result + |> Diffo.Util.set(target_type, reference) + |> Diffo.Util.suppress_rename(:characteristics, list_name) + |> Diffo.Util.suppress(:alias) end order [ @@ -73,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] @@ -90,19 +80,6 @@ defmodule Diffo.Provider.Relationship do change load [:characteristics] end - create :create_assignment do - description "creates an assignment 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 - read :list do description "lists all relationships" end @@ -138,73 +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 - - attribute :pool, :atom do - description "the pool name on the source instance (assignedTo relationships only)" - allow_nil? true - public? true - end - - attribute :thing, :atom do - description "the kind of thing being assigned within the pool (assignedTo relationships only)" - allow_nil? true - public? true - end - - attribute :assigned, :integer do - description "the assigned value from the pool (assignedTo relationships only)" - allow_nil? true - 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 @@ -213,25 +131,6 @@ defmodule Diffo.Provider.Relationship do identities do identity :unique_source_and_target, [:source_id, :target_id] - - identity :unique_assignment, [:source_id, :target_id, :pool, :thing, :assigned] do - where expr(type == :assignedTo) - end - 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 diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index a3d2574..17d0f10 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -103,8 +103,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 1 + assert length(card.assignments) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() @@ -134,8 +133,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 2 + assert length(card.assignments) == 2 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() @@ -165,8 +163,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{id: 5, assignee_id: assignee.id, operation: :assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 1 + assert length(card.assignments) == 1 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() @@ -191,12 +188,9 @@ defmodule Diffo.Provider.Extension.AssignerTest do assignment: %Assignment{assignee_id: assignee.id, operation: :auto_assign} }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 1 + assert length(card.assignments) == 1 - assigned_port = - Enum.find(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - |> Map.get(:assigned) + assigned_port = hd(card.assignments).assigned {:ok, card} = Servo.assign_port(card, %{ @@ -207,8 +201,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do } }) - assigned_rels = Enum.filter(card.forward_relationships, fn rel -> rel.type == :assignedTo end) - assert length(assigned_rels) == 0 + assert length(card.assignments) == 0 encoding = Jason.encode!(card) |> Diffo.Util.summarise_dates() diff --git a/usage-rules.md b/usage-rules.md index e88c922..5b71fc3 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -465,3 +465,8 @@ end - **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. From 1d9b43bbff851187d9ec24f47f70e909513c0710 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 19:20:33 +0930 Subject: [PATCH 14/15] phase 7 - resource naming --- AGENTS.md | 13 ++ test/provider/extension/assigner_test.exs | 6 +- test/provider/extension/info_test.exs | 6 +- .../extension/instance_transformer_test.exs | 114 +++++++++--------- .../extension/instance_verifier_test.exs | 26 ++-- test/provider/extension/party_test.exs | 18 +-- test/provider/extension/place_test.exs | 12 +- .../provider/extension/specification_test.exs | 18 +-- .../{card.ex => card_characteristic.ex} | 2 +- .../{card => card_characteristic}/value.ex | 2 +- .../{shelf.ex => shelf_characteristic.ex} | 2 +- .../{shelf => shelf_characteristic}/value.ex | 2 +- .../instance/{card.ex => card_instance.ex} | 4 +- .../instance/{shelf.ex => shelf_instance.ex} | 5 +- test/support/servo.ex | 12 +- test/type/dynamic_test.exs | 2 +- usage-rules.md | 18 +++ 17 files changed, 146 insertions(+), 116 deletions(-) rename test/support/resource/characteristic/{card.ex => card_characteristic.ex} (95%) rename test/support/resource/characteristic/{card => card_characteristic}/value.ex (89%) rename test/support/resource/characteristic/{shelf.ex => shelf_characteristic.ex} (95%) rename test/support/resource/characteristic/{shelf => shelf_characteristic}/value.ex (89%) rename test/support/resource/instance/{card.ex => card_instance.ex} (96%) rename test/support/resource/instance/{shelf.ex => shelf_instance.ex} (97%) diff --git a/AGENTS.md b/AGENTS.md index 02f8af3..25ffd94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,19 @@ 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. diff --git a/test/provider/extension/assigner_test.exs b/test/provider/extension/assigner_test.exs index 17d0f10..a24c229 100644 --- a/test/provider/extension/assigner_test.exs +++ b/test/provider/extension/assigner_test.exs @@ -11,7 +11,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do alias Diffo.Test.Parties alias Diffo.Test.Servo - alias Diffo.Test.Instance.Card + alias Diffo.Test.Instance.CardInstance setup do AshNeo4j.Sandbox.checkout() @@ -23,7 +23,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do test "create a card" do {:ok, card} = Servo.build_card(%{}) - assert is_struct(card, Card) + assert is_struct(card, CardInstance) refute is_nil(card.specification_id) assert is_struct(card.specification, Specification) @@ -71,7 +71,7 @@ defmodule Diffo.Provider.Extension.AssignerTest do {:ok, card} = Servo.define_card(card, %{characteristic_value_updates: updates}) {:ok, card_value} = - Diffo.Test.Characteristic.Card + Diffo.Test.Characteristic.CardCharacteristic |> Ash.Query.new() |> Ash.Query.filter_input(instance_id: card.id) |> Ash.read_one() diff --git a/test/provider/extension/info_test.exs b/test/provider/extension/info_test.exs index a26441f..3a508f4 100644 --- a/test/provider/extension/info_test.exs +++ b/test/provider/extension/info_test.exs @@ -10,7 +10,7 @@ defmodule Diffo.Provider.Extension.InfoTest do describe "instance?/1" do test "returns true for a BaseInstance-derived resource" do - assert Info.instance?(Diffo.Test.Instance.Shelf) == true + assert Info.instance?(Diffo.Test.Instance.ShelfInstance) == true end test "returns true for the base Instance resource" do @@ -40,7 +40,7 @@ defmodule Diffo.Provider.Extension.InfoTest do end test "returns false for a BaseInstance-derived resource" do - assert Info.party?(Diffo.Test.Instance.Shelf) == false + assert Info.party?(Diffo.Test.Instance.ShelfInstance) == false end test "returns false for a BasePlace-derived resource" do @@ -62,7 +62,7 @@ defmodule Diffo.Provider.Extension.InfoTest do end test "returns false for a BaseInstance-derived resource" do - assert Info.place?(Diffo.Test.Instance.Shelf) == false + assert Info.place?(Diffo.Test.Instance.ShelfInstance) == false end test "returns false for a BaseParty-derived resource" do diff --git a/test/provider/extension/instance_transformer_test.exs b/test/provider/extension/instance_transformer_test.exs index c7964dd..e08b364 100644 --- a/test/provider/extension/instance_transformer_test.exs +++ b/test/provider/extension/instance_transformer_test.exs @@ -6,8 +6,8 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do @moduledoc false use ExUnit.Case, async: true, async: true - alias Diffo.Test.Instance.Shelf - alias Diffo.Test.Instance.Card + 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 @@ -15,7 +15,7 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest do 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.Provider.Extension.InstanceTransformerTest 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,29 +49,29 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest 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(ShelfInstance)) == 3 # Card has :card characteristic; :ports moved to pools do - assert length(Info.characteristics(Card)) == 1 + 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 @@ -80,12 +80,12 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest 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 @@ -93,36 +93,36 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest 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) @@ -134,39 +134,39 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest 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) @@ -175,55 +175,55 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest 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 @@ -231,7 +231,7 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest 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])) @@ -240,56 +240,56 @@ defmodule Diffo.Provider.Extension.InstanceTransformerTest 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.Characteristic.Shelf} + 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/provider/extension/instance_verifier_test.exs b/test/provider/extension/instance_verifier_test.exs index cab2649..8fce63a 100644 --- a/test/provider/extension/instance_verifier_test.exs +++ b/test/provider/extension/instance_verifier_test.exs @@ -153,8 +153,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end characteristics do - characteristic :foo, Diffo.Test.Characteristic.Shelf - characteristic :foo, Diffo.Test.Characteristic.Shelf + characteristic :foo, Diffo.Test.Characteristic.ShelfCharacteristic + characteristic :foo, Diffo.Test.Characteristic.ShelfCharacteristic end end end @@ -273,8 +273,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do features do feature :my_feature do - characteristic :baz, Diffo.Test.Characteristic.Shelf - characteristic :baz, Diffo.Test.Characteristic.Shelf + characteristic :baz, Diffo.Test.Characteristic.ShelfCharacteristic + characteristic :baz, Diffo.Test.Characteristic.ShelfCharacteristic end end end @@ -322,7 +322,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do fn -> defmodule DuplicatePartyRole do alias Diffo.Provider.BaseInstance - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance use Ash.Resource, fragments: [BaseInstance], domain: Diffo.Test.Servo resource do @@ -336,8 +336,8 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party :operator, Shelf - party :operator, Shelf + party :operator, ShelfInstance + party :operator, ShelfInstance end end end @@ -376,7 +376,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest 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.Instance.Shelf does not extend BaseParty", + "parties: party_type Diffo.Test.Instance.ShelfInstance does not extend BaseParty", fn -> defmodule InvalidPartyBaseType do alias Diffo.Provider.BaseInstance @@ -393,7 +393,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party :operator, Diffo.Test.Instance.Shelf + party :operator, Diffo.Test.Instance.ShelfInstance end end end @@ -532,7 +532,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do 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.Shelf does not extend BaseParty", + "parties: party_type Diffo.Test.Instance.ShelfInstance does not extend BaseParty", fn -> defmodule InvalidPartyRefBaseType do alias Diffo.Provider.BaseInstance @@ -549,7 +549,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end parties do - party_ref :owner, Diffo.Test.Instance.Shelf + party_ref :owner, Diffo.Test.Instance.ShelfInstance end end end @@ -590,7 +590,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do 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.Shelf does not extend BasePlace", + "places: place_type Diffo.Test.Instance.ShelfInstance does not extend BasePlace", fn -> defmodule InvalidPlaceRefBaseType do alias Diffo.Provider.BaseInstance @@ -607,7 +607,7 @@ defmodule Diffo.Provider.Extension.InstanceVerifierTest do end places do - place_ref :billing, Diffo.Test.Instance.Shelf + place_ref :billing, Diffo.Test.Instance.ShelfInstance end end end diff --git a/test/provider/extension/party_test.exs b/test/provider/extension/party_test.exs index ef8fd2e..34178bf 100644 --- a/test/provider/extension/party_test.exs +++ b/test/provider/extension/party_test.exs @@ -11,7 +11,7 @@ defmodule Diffo.Provider.Extension.PartyTest do alias Diffo.Test.Party.Organization alias Diffo.Test.Party.Person - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Nbn alias Diffo.Test.Servo alias Diffo.Provider.Instance.Party @@ -53,14 +53,14 @@ defmodule Diffo.Provider.Extension.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.Provider.Extension.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/provider/extension/place_test.exs b/test/provider/extension/place_test.exs index b159366..9318745 100644 --- a/test/provider/extension/place_test.exs +++ b/test/provider/extension/place_test.exs @@ -11,7 +11,7 @@ defmodule Diffo.Provider.Extension.PlaceTest do alias Diffo.Test.Party.Organization alias Diffo.Test.Place.GeographicSite - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance alias Diffo.Test.Nbn setup do @@ -44,33 +44,33 @@ defmodule Diffo.Provider.Extension.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/provider/extension/specification_test.exs b/test/provider/extension/specification_test.exs index c30016d..c14f7e9 100644 --- a/test/provider/extension/specification_test.exs +++ b/test/provider/extension/specification_test.exs @@ -6,7 +6,7 @@ defmodule Diffo.Provider.Extension.SpecificationTest do @moduledoc false use ExUnit.Case, async: true alias Diffo.Test.Servo - alias Diffo.Test.Instance.Shelf + alias Diffo.Test.Instance.ShelfInstance setup do AshNeo4j.Sandbox.checkout() @@ -15,8 +15,8 @@ defmodule Diffo.Provider.Extension.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.Provider.Extension.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/support/resource/characteristic/card.ex b/test/support/resource/characteristic/card_characteristic.ex similarity index 95% rename from test/support/resource/characteristic/card.ex rename to test/support/resource/characteristic/card_characteristic.ex index 455a082..0d85b4c 100644 --- a/test/support/resource/characteristic/card.ex +++ b/test/support/resource/characteristic/card_characteristic.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Card do +defmodule Diffo.Test.Characteristic.CardCharacteristic do @moduledoc "Typed characteristic for a Card's identity." use Ash.Resource, fragments: [Diffo.Provider.BaseCharacteristic], diff --git a/test/support/resource/characteristic/card/value.ex b/test/support/resource/characteristic/card_characteristic/value.ex similarity index 89% rename from test/support/resource/characteristic/card/value.ex rename to test/support/resource/characteristic/card_characteristic/value.ex index 2e52661..e1d4835 100644 --- a/test/support/resource/characteristic/card/value.ex +++ b/test/support/resource/characteristic/card_characteristic/value.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Card.Value do +defmodule Diffo.Test.Characteristic.CardCharacteristic.Value do @moduledoc "Typed value struct for a Card characteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] diff --git a/test/support/resource/characteristic/shelf.ex b/test/support/resource/characteristic/shelf_characteristic.ex similarity index 95% rename from test/support/resource/characteristic/shelf.ex rename to test/support/resource/characteristic/shelf_characteristic.ex index a400bf9..7545df1 100644 --- a/test/support/resource/characteristic/shelf.ex +++ b/test/support/resource/characteristic/shelf_characteristic.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Shelf do +defmodule Diffo.Test.Characteristic.ShelfCharacteristic do @moduledoc "Typed characteristic for a Shelf's identity." use Ash.Resource, fragments: [Diffo.Provider.BaseCharacteristic], diff --git a/test/support/resource/characteristic/shelf/value.ex b/test/support/resource/characteristic/shelf_characteristic/value.ex similarity index 89% rename from test/support/resource/characteristic/shelf/value.ex rename to test/support/resource/characteristic/shelf_characteristic/value.ex index 11cb450..57aaf70 100644 --- a/test/support/resource/characteristic/shelf/value.ex +++ b/test/support/resource/characteristic/shelf_characteristic/value.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Characteristic.Shelf.Value do +defmodule Diffo.Test.Characteristic.ShelfCharacteristic.Value do @moduledoc "Typed value struct for a Shelf characteristic." use Ash.TypedStruct, extensions: [AshJason.TypedStruct] diff --git a/test/support/resource/instance/card.ex b/test/support/resource/instance/card_instance.ex similarity index 96% rename from test/support/resource/instance/card.ex rename to test/support/resource/instance/card_instance.ex index 11bf7f4..8e432ce 100644 --- a/test/support/resource/instance/card.ex +++ b/test/support/resource/instance/card_instance.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Instance.Card do +defmodule Diffo.Test.Instance.CardInstance do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -15,7 +15,7 @@ defmodule Diffo.Test.Instance.Card do alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Test.Servo - alias Diffo.Test.Characteristic.Card, as: CardCharacteristic + alias Diffo.Test.Characteristic.CardCharacteristic use Ash.Resource, fragments: [BaseInstance], diff --git a/test/support/resource/instance/shelf.ex b/test/support/resource/instance/shelf_instance.ex similarity index 97% rename from test/support/resource/instance/shelf.ex rename to test/support/resource/instance/shelf_instance.ex index 1643880..baeee6e 100644 --- a/test/support/resource/instance/shelf.ex +++ b/test/support/resource/instance/shelf_instance.ex @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: MIT -defmodule Diffo.Test.Instance.Shelf do +defmodule Diffo.Test.Instance.ShelfInstance do @moduledoc """ Diffo - TMF Service and Resource Management with a difference @@ -15,9 +15,8 @@ defmodule Diffo.Test.Instance.Shelf do alias Diffo.Provider.Assigner alias Diffo.Provider.Assignment alias Diffo.Provider.AssignableValue - alias Diffo.Test.Servo - alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic + alias Diffo.Test.Characteristic.ShelfCharacteristic alias Diffo.Test.Characteristic.DeploymentClass alias Diffo.Test.Party.Organization alias Diffo.Test.Party.Person diff --git a/test/support/servo.ex b/test/support/servo.ex index 54bafeb..7a2f49b 100644 --- a/test/support/servo.ex +++ b/test/support/servo.ex @@ -12,12 +12,12 @@ defmodule Diffo.Test.Servo do otp_app: :diffo, validate_config_inclusion?: false - alias Diffo.Test.Instance.Shelf - alias Diffo.Test.Instance.Card + alias Diffo.Test.Instance.ShelfInstance + alias Diffo.Test.Instance.CardInstance alias Diffo.Test.Instance.Broadband alias Diffo.Test.Instance.BroadbandV2 - alias Diffo.Test.Characteristic.Shelf, as: ShelfCharacteristic - alias Diffo.Test.Characteristic.Card, as: CardCharacteristic + alias Diffo.Test.Characteristic.ShelfCharacteristic + alias Diffo.Test.Characteristic.CardCharacteristic alias Diffo.Test.Characteristic.DeploymentClass alias Diffo.Provider.AssignableCharacteristic @@ -26,7 +26,7 @@ defmodule Diffo.Test.Servo do 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 @@ -34,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 diff --git a/test/type/dynamic_test.exs b/test/type/dynamic_test.exs index f30dfec..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.Characteristic.Card, as: 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 5b71fc3..1381c65 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -340,6 +340,24 @@ modules are still available as thin delegating wrappers for backwards compatibil - **Never change the `id`** of an existing specification. It is a stable cross-environment identity; changing it orphans existing instances. +## 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: + +| 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 From d432d41f85a6f451b8313817c0e36cbda46ee1d1 Mon Sep 17 00:00:00 2001 From: Matt Beanland Date: Sun, 17 May 2026 19:25:03 +0930 Subject: [PATCH 15/15] release 0.3.0 --- CHANGELOG.md | 25 +++++++++++++++++++ README.md | 2 +- diffo.livemd | 2 +- .../use_diffo_provider_versioning.livemd | 2 +- documentation/how_to/use_diffo_type.livemd | 2 +- mix.exs | 2 +- 6 files changed, 30 insertions(+), 5 deletions(-) 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/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/mix.exs b/mix.exs index 8efb173..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"