From 0bd0153204815119c632a134c7d33696785374fe Mon Sep 17 00:00:00 2001 From: Robert Prehn <3952444+prehnRA@users.noreply.github.com> Date: Sat, 24 Feb 2024 10:42:27 -0600 Subject: [PATCH] feat: Implement embedded schemas --- .gitignore | 2 + lib/kindling/config.ex | 19 ++++++++ lib/kindling/embed_template.eex | 50 ++++++++++++++++++++++ lib/kindling/schema.ex | 28 ++++++++++++ lib/kindling/schema/resource.ex | 40 ++++++++++++++++- lib/kindling/template.eex | 12 +++++- lib/kindling/templates.ex | 15 ++++++- lib/kindling/templates/functions.ex | 26 ++++++++++- lib/mix/tasks/kindling/generate_schemas.ex | 4 +- mix.exs | 1 + mix.lock | 2 + 11 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 lib/kindling/config.ex create mode 100644 lib/kindling/embed_template.eex diff --git a/.gitignore b/.gitignore index 3b2bbf4..4276f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ kindling-*.tar # Temporary files, for example, from tests. /tmp/ + +/lib/fhir/ diff --git a/lib/kindling/config.ex b/lib/kindling/config.ex new file mode 100644 index 0000000..53cbe1e --- /dev/null +++ b/lib/kindling/config.ex @@ -0,0 +1,19 @@ +defmodule Kindling.Config do + @default_embedded_resources [ + "Coding", + "Duration", + "Extension", + "Identifier", + "Meta", + "Element", + "CodeableConcept", + "Narrative", + "Period", + "Reference", + "ResourceList" + ] + + def embedded_resources() do + Application.get_env(:kindling, :embedded_resources, @default_embedded_resources) + end +end diff --git a/lib/kindling/embed_template.eex b/lib/kindling/embed_template.eex new file mode 100644 index 0000000..f4f5e09 --- /dev/null +++ b/lib/kindling/embed_template.eex @@ -0,0 +1,50 @@ +defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do + use Ecto.Schema + import Ecto.Changeset + + @fields [ + <%= @all_fields |> Enum.map(fn name -> ~s("#{Recase.to_snake(name)}") end) |> Enum.join(",\n") %> + ] + @required_fields [ + <%= @required_fields |> Enum.map(fn name -> ~s("#{Recase.to_snake(name)}") end) |> Enum.join(",\n") %> + ] + + embedded_schema do + <%= if @properties.const != [] do %># Constants<% end %> + <%= for {name, df, _} <- @properties.const do %>field :<%= Recase.to_snake(name) %>, :string, virtual: true, default: "<%= df["const"] %>" + <% end %> + <%= if @properties.value != [] do %># Fields<% end %> + <%= for {name, df, _} <- @properties.value do %>field :<%= Recase.to_snake(name) %>, :<%= fhir_type_to_ecto(df) %> + <% end %> + <%= for {name, df, _} <- @properties.array do %>field :<%= Recase.to_snake(name) %>, <%= inspect(fhir_type_to_ecto(df)) %> + <% end %> + <%= if @properties.enum != [] do %># Enum<% end %> + <%= for {name, df, _} <- @properties.enum do %>field :<%= Recase.to_snake(name) %>, Ecto.Enum, values: <%= inspect(df["enum"] |> Enum.map(&Recase.to_snake(&1)) |> Enum.map(&String.to_atom/1)) %> + <% end %> + <%= if @properties.embed_one != [] do %># Embed One<% end %> + <%= for {name, df, _} <- @properties.embed_one do %>embed_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %> + <% end %> + <%= if @properties.embed_many != [] do %># Embed Many<% end %> + <%= for {name, df, _} <- @properties.embed_many do %>embed_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %> + <% end %> + <%= if @properties.has_one != [] do %># Has One<% end %> + <%= for {name, df, _} <- @properties.has_one do %>has_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %> + <% end %> + <%= if @properties.has_many != [] do %># Has Many<% end %> + <%= for {name, df, _} <- @properties.has_many do %>has_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %> + <% end %> + <%= if @backlinks != [] do %># belongs_to<% end %> + <%= for name <- @backlinks do %>belongs_to :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= class_name(name) %> + <% end %> + end + + def base_changeset(data \\ %__MODULE__{}, attrs) do + data + |> cast(attrs, @fields) + <%= for {name, df, _} <- @properties.has_one do %>|> cast_assoc(:<%= Recase.to_snake(name) %>, with: &<%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %>.base_changeset/1) + <% end %> + <%= for {name, df, _} <- @properties.has_many do %>|> cast_assoc(:<%= Recase.to_snake(name) %>, with: &<%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %>.base_changeset/1) + <% end %> + |> validate_required(@required_fields) + end +end diff --git a/lib/kindling/schema.ex b/lib/kindling/schema.ex index f786e0b..05f0a64 100644 --- a/lib/kindling/schema.ex +++ b/lib/kindling/schema.ex @@ -1,4 +1,5 @@ defmodule Kindling.Schema do + alias Kindling.Config alias Kindling.Version alias Kindling.Schema.Resource @@ -8,6 +9,33 @@ defmodule Kindling.Schema do filename |> File.read!() |> Jason.decode!() + |> build_backlinks() + end + + def build_backlinks(schema) do + new_defs = + schema["definitions"] + |> Enum.map(fn {name, def} -> {name, Map.put(def, "__backlinks", [])} end) + |> Map.new() + + schema = + Map.put(schema, "definitions", new_defs) + + defs = + schema["definitions"] + |> Map.drop(Config.embedded_resources()) + |> Enum.reduce(schema["definitions"], fn {name, df}, definitions -> + refs = Resource.refs(df) + + Enum.reduce(refs, definitions, fn ref, definitions -> + prior_backlinks = definitions[ref]["__backlinks"] + new_backlinks = [name | prior_backlinks] + new_def = Map.put(Map.get(definitions, ref), "__backlinks", new_backlinks) + Map.put(definitions, ref, new_def) + end) + end) + + Map.put(schema, "definitions", defs) end def refs_recursive(schema, root_name) do diff --git a/lib/kindling/schema/resource.ex b/lib/kindling/schema/resource.ex index 623a696..e592663 100644 --- a/lib/kindling/schema/resource.ex +++ b/lib/kindling/schema/resource.ex @@ -1,4 +1,6 @@ defmodule Kindling.Schema.Resource do + alias Kindling.Config + @empty_properties %{array: [], const: [], has_one: [], has_many: [], enum: [], value: []} def properties(df) do @@ -8,6 +10,8 @@ defmodule Kindling.Schema.Resource do def grouped_properties(df) do properties = (df["properties"] || %{}) + |> Map.delete("id") + |> Enum.reject(&is_element/1) |> Enum.map(fn {key, value} -> {key, value, property_type(value)} end) |> Enum.group_by(fn {_key, _value, type} -> type @@ -42,8 +46,31 @@ defmodule Kindling.Schema.Resource do end def property_type(%{"const" => _}), do: :const - def property_type(%{"$ref" => _}), do: :has_one - def property_type(%{"items" => %{"$ref" => _}}), do: :has_many + + def property_type(%{"$ref" => "#/definitions/" <> name}) do + if is_class_name(name) do + :has_one + else + :value + end + end + + for embed <- Config.embedded_resources() do + def property_type(%{"$ref" => "#/definitions/" <> unquote(embed)}), do: :embed_one + + def property_type(%{"items" => %{"$ref" => "#/definitions/" <> unquote(embed)}}) do + :embed_many + end + end + + def property_type(%{"items" => %{"$ref" => "#/definitions/" <> name}}) do + if is_class_name(name) do + :has_many + else + :array + end + end + def property_type(%{"items" => %{"enum" => _}}), do: :array def property_type(%{"enum" => _}), do: :enum def property_type(_), do: :value @@ -61,6 +88,15 @@ defmodule Kindling.Schema.Resource do nil end) |> Enum.reject(&is_nil/1) + |> Enum.filter(&is_class_name/1) |> MapSet.new() end + + def is_class_name(name) do + !(name in Config.embedded_resources()) && Regex.match?(~r/^[A-Z]/, name) + end + + def is_element({_name, %{"$ref" => "#/definitions/Element"}}), do: true + def is_element({name, %{"items" => items}}), do: is_element({name, items}) + def is_element(_), do: false end diff --git a/lib/kindling/template.eex b/lib/kindling/template.eex index 8544436..061e3fc 100644 --- a/lib/kindling/template.eex +++ b/lib/kindling/template.eex @@ -1,4 +1,4 @@ -defmodule <%= @namespace %>.<%= @version %>.<%= @resource_name %> do +defmodule <%= @namespace %>.<%= @version %>.<%= class_name(@resource_name) %> do use Ecto.Schema import Ecto.Changeset @@ -9,6 +9,7 @@ defmodule <%= @namespace %>.<%= @version %>.<%= @resource_name %> do <%= @required_fields |> Enum.map(fn name -> ~s("#{Recase.to_snake(name)}") end) |> Enum.join(",\n") %> ] + @primary_key {:id, :binary_id, autogenerate: true} schema "<%= Recase.to_snake(@resource_name) %>" do <%= if @properties.const != [] do %># Constants<% end %> <%= for {name, df, _} <- @properties.const do %>field :<%= Recase.to_snake(name) %>, :string, virtual: true, default: "<%= df["const"] %>" @@ -21,12 +22,21 @@ defmodule <%= @namespace %>.<%= @version %>.<%= @resource_name %> do <%= if @properties.enum != [] do %># Enum<% end %> <%= for {name, df, _} <- @properties.enum do %>field :<%= Recase.to_snake(name) %>, Ecto.Enum, values: <%= inspect(df["enum"] |> Enum.map(&Recase.to_snake(&1)) |> Enum.map(&String.to_atom/1)) %> <% end %> + <%= if @properties.embed_one != [] do %># Embed One<% end %> + <%= for {name, df, _} <- @properties.embed_one do %>embed_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %> + <% end %> + <%= if @properties.embed_many != [] do %># Embed Many<% end %> + <%= for {name, df, _} <- @properties.embed_many do %>embed_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %> + <% end %> <%= if @properties.has_one != [] do %># Has One<% end %> <%= for {name, df, _} <- @properties.has_one do %>has_one :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["$ref"]) %> <% end %> <%= if @properties.has_many != [] do %># Has Many<% end %> <%= for {name, df, _} <- @properties.has_many do %>has_many :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= ref_to_class_name(df["items"]["$ref"]) %> <% end %> + <%= if @backlinks != [] do %># belongs_to<% end %> + <%= for name <- @backlinks do %>belongs_to :<%= Recase.to_snake(name) %>, <%= @namespace %>.<%= @version %>.<%= class_name(name) %> + <% end %> end def base_changeset(data \\ %__MODULE__{}, attrs) do diff --git a/lib/kindling/templates.ex b/lib/kindling/templates.ex index 314253d..856ff4c 100644 --- a/lib/kindling/templates.ex +++ b/lib/kindling/templates.ex @@ -3,9 +3,11 @@ defmodule Kindling.Templates do import Kindling.Templates.Functions + alias Kindling.Config alias Kindling.Schema.Resource EEx.function_from_file(:def, :render, "lib/kindling/template.eex", [:assigns]) + EEx.function_from_file(:def, :render_embedded, "lib/kindling/embed_template.eex", [:assigns]) def resource_code(namespace, version, resource_name, resource) do assigns = %{ @@ -14,10 +16,19 @@ defmodule Kindling.Templates do resource_name: resource_name, properties: Resource.grouped_properties(resource), all_fields: Resource.all_fields(resource), - required_fields: Resource.required_fields(resource) + required_fields: Resource.required_fields(resource), + backlinks: resource["__backlinks"] } - assigns |> render() |> Code.format_string!(file: "#{resource_name}.ex") + if resource_name in Config.embedded_resources() do + assigns + |> render_embedded() + |> Code.format_string!(file: "#{resource_name}.ex") + else + assigns + |> render() + |> Code.format_string!(file: "#{resource_name}.ex") + end end def write_code(namespace, version, resource_name, resource) do diff --git a/lib/kindling/templates/functions.ex b/lib/kindling/templates/functions.ex index e286365..27b2928 100644 --- a/lib/kindling/templates/functions.ex +++ b/lib/kindling/templates/functions.ex @@ -18,8 +18,32 @@ defmodule Kindling.Templates.Functions do when pattern in @decimal_patterns, do: :decimal + def fhir_type_to_ecto(%{"$ref" => "#/definitions/boolean"}), do: :boolean + def fhir_type_to_ecto(%{"$ref" => "#/definitions/string"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/integer"}), do: :integer + def fhir_type_to_ecto(%{"$ref" => "#/definitions/positiveInt"}), do: :integer + def fhir_type_to_ecto(%{"$ref" => "#/definitions/unsignedInt"}), do: :integer + def fhir_type_to_ecto(%{"$ref" => "#/definitions/decimal"}), do: :decimal + def fhir_type_to_ecto(%{"$ref" => "#/definitions/code"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/uri"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/url"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/xhtml"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/markdown"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/base64Binary"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/dateTime"}), do: :utc_datetime_usec + def fhir_type_to_ecto(%{"$ref" => "#/definitions/instant"}), do: :utc_datetime_usec + def fhir_type_to_ecto(%{"$ref" => "#/definitions/time"}), do: :time_usec + def fhir_type_to_ecto(%{"$ref" => "#/definitions/canonical"}), do: :string + def fhir_type_to_ecto(%{"$ref" => "#/definitions/id"}), do: :binary_id + def fhir_type_to_ecto(%{"type" => "array", "items" => %{"enum" => _}}), do: {:array, :string} + def fhir_type_to_ecto(%{"type" => "array", "items" => items}), + do: {:array, fhir_type_to_ecto(items)} + def ref_to_class_name("#/definitions/" <> name), - do: name |> String.replace("_", ".") |> Recase.to_pascal() + do: class_name(name) + + def class_name(name), + do: name |> String.split("_") |> Enum.map(&Recase.to_pascal/1) |> Enum.join(".") end diff --git a/lib/mix/tasks/kindling/generate_schemas.ex b/lib/mix/tasks/kindling/generate_schemas.ex index ada570b..b22c7e7 100644 --- a/lib/mix/tasks/kindling/generate_schemas.ex +++ b/lib/mix/tasks/kindling/generate_schemas.ex @@ -15,7 +15,9 @@ defmodule Mix.Tasks.Kindling.GenerateSchemas do [namespace, version, root] = args schema = Kindling.Schema.schema_object(version) - to_generate = Kindling.Schema.refs_recursive(schema, root) + + to_generate = + Kindling.Schema.refs_recursive(schema, root) Enum.each( to_generate, diff --git a/mix.exs b/mix.exs index 771b5d5..e2c1452 100644 --- a/mix.exs +++ b/mix.exs @@ -21,6 +21,7 @@ defmodule Kindling.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ + {:ecto, "~> 3.11", only: [:dev, :test]}, {:jason, "~> 1.4"}, {:recase, "~> 0.7.0"}, {:req, "~> 0.4.11"} diff --git a/mix.lock b/mix.lock index d772a51..ac91e31 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,7 @@ %{ "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.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", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},