feat: Implement embedded schemas

This commit is contained in:
Robert Prehn 2024-02-24 10:42:27 -06:00
parent a6016e31fd
commit 0bd0153204
No known key found for this signature in database
11 changed files with 192 additions and 7 deletions

2
.gitignore vendored
View file

@ -24,3 +24,5 @@ kindling-*.tar
# Temporary files, for example, from tests.
/tmp/
/lib/fhir/

19
lib/kindling/config.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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"}

View file

@ -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"},