├── priv └── plts │ └── .keep ├── VERSION ├── .dialyzer_ignore.exs ├── config ├── dev.exs ├── prod.exs ├── config.exs └── test.exs ├── test ├── test_helper.exs ├── grax │ ├── id │ │ ├── counter │ │ │ ├── dets_test.exs │ │ │ ├── text_file_test.exs │ │ │ └── supervisor_test.exs │ │ ├── namespace_test.exs │ │ ├── urn_test.exs │ │ ├── extensions │ │ │ ├── hash_test.exs │ │ │ └── uuid_test.exs │ │ ├── id_schema_test.exs │ │ └── spec_test.exs │ ├── schema │ │ ├── registry_test.exs │ │ └── mapping_test.exs │ ├── config_test.exs │ ├── callbacks_test.exs │ ├── json_property_test.exs │ └── schema_test.exs └── support │ ├── example_ns.ex │ ├── id_counter_test_helper.ex │ ├── uuid_test_helper.ex │ ├── test_case.ex │ ├── test_data.ex │ └── id_counter_adapter_test_case.ex ├── lib └── grax │ ├── schema │ ├── registerable.ex │ ├── custom_field.ex │ ├── struct.ex │ ├── mapping.ex │ ├── registry.ex │ ├── registry │ │ └── state.ex │ ├── additional_statements.ex │ ├── type.ex │ ├── link_property_union.ex │ ├── inheritance.ex │ └── property.ex │ ├── utils.ex │ ├── callbacks.ex │ ├── application.ex │ ├── id │ ├── counter │ │ ├── supervisor.ex │ │ ├── adapter.ex │ │ ├── dets.ex │ │ └── text_file.ex │ ├── counter.ex │ ├── urn_namespace.ex │ ├── namespace.ex │ ├── schema_extension.ex │ ├── extensions │ │ ├── hash.ex │ │ └── uuid.ex │ ├── schema.ex │ └── spec.ex │ ├── datatype.ex │ ├── rdf │ ├── access.ex │ ├── mapper.ex │ ├── loader.ex │ └── preloader.ex │ ├── exceptions.ex │ ├── validator.ex │ └── schema.ex ├── .credo.exs ├── .editorconfig ├── CONTRIBUTING.md ├── .iex.exs ├── .gitignore ├── .formatter.exs ├── bench └── counter.exs ├── LICENSE.md ├── .github ├── workflows │ ├── elixir-quality-checks.yml │ ├── elixir-build-and-test.yml │ └── elixir-dialyzer.yml └── actions │ └── elixir-setup │ └── action.yml ├── mix.exs ├── CODE_OF_CONDUCT.md ├── README.md ├── mix.lock └── CHANGELOG.md /priv/plts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.6.1-pre 2 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | ] 3 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :grax, counter_dir: "tmp/test_counter" 4 | -------------------------------------------------------------------------------- /lib/grax/schema/registerable.ex: -------------------------------------------------------------------------------- 1 | defprotocol Grax.Schema.Registerable do 2 | @moduledoc false 3 | def register(schema) 4 | end 5 | -------------------------------------------------------------------------------- /test/grax/id/counter/dets_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.DetsTest do 2 | use Grax.Id.Counter.Adapter.TestCase, adapter: Grax.Id.Counter.Dets 3 | end 4 | -------------------------------------------------------------------------------- /test/grax/id/counter/text_file_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.TextFileTest do 2 | use Grax.Id.Counter.Adapter.TestCase, adapter: Grax.Id.Counter.TextFile 3 | end 4 | -------------------------------------------------------------------------------- /test/support/example_ns.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.NS do 2 | use RDF.Vocabulary.Namespace 3 | 4 | defvocab EX, 5 | base_iri: "http://example.com/", 6 | terms: [], 7 | strict: false 8 | 9 | defvocab FOAF, 10 | base_iri: "http://xmlns.com/foaf/0.1/", 11 | terms: [:Person, :foaf, :mbox], 12 | strict: false 13 | end 14 | -------------------------------------------------------------------------------- /lib/grax/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Utils do 2 | @moduledoc false 3 | 4 | def rename_keyword(opts, old_name, new_name) do 5 | if Keyword.has_key?(opts, old_name) do 6 | opts 7 | |> Keyword.put(new_name, Keyword.get(opts, old_name)) 8 | |> Keyword.delete_first(old_name) 9 | else 10 | opts 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/grax/callbacks.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Callbacks do 2 | alias Grax.Schema 3 | alias RDF.{Graph, Description} 4 | 5 | @callback on_load(Schema.t(), Graph.t() | Description.t(), opts :: keyword()) :: 6 | {:ok, Schema.t()} | {:error, any} 7 | 8 | @callback on_to_rdf(Schema.t(), Graph.t(), opts :: keyword()) :: 9 | {:ok, Graph.t()} | {:error, any} 10 | end 11 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | checks: [ 6 | {Credo.Check.Design.TagTODO, false}, 7 | {Credo.Check.Refactor.Nesting, false}, 8 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 9 | # TODO: reenable this when we've added API docs 10 | {Credo.Check.Readability.ModuleDoc, false}, 11 | ], 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # 2 space indentation 12 | [*.{ex,exs,erl,xrl,yrl,json}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | # 4 space indentation 17 | [*.ttl] 18 | indent_style = space 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /lib/grax/schema/custom_field.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.CustomField do 2 | @moduledoc false 3 | 4 | defstruct [:name, :from_rdf, :default] 5 | 6 | alias Grax.Schema.DataProperty 7 | 8 | def new(schema, name, opts) when is_atom(name) do 9 | struct!(__MODULE__, 10 | name: name, 11 | default: opts[:default], 12 | from_rdf: DataProperty.normalize_custom_mapping_fun(opts[:from_rdf], schema) 13 | ) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/grax/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | Grax.Schema.Registry, 10 | Grax.Id.Counter.Supervisor, 11 | {Registry, keys: :unique, name: Grax.Id.Counter.registry()} 12 | ] 13 | 14 | opts = [strategy: :one_for_one, name: Grax.Supervisor] 15 | Supervisor.start_link(children, opts) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/id_counter_test_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.CounterTestHelper do 2 | import ExUnit.Callbacks 3 | 4 | def with_counter(adapter, name) do 5 | start_supervised!({adapter, name}) 6 | end 7 | 8 | def with_clean_fs(adapter, name) do 9 | remove_counter_file(adapter, name) 10 | on_exit(fn -> remove_counter_file(adapter, name) end) 11 | :ok 12 | end 13 | 14 | def remove_counter_file(adapter, name) do 15 | name 16 | |> adapter.file_path() 17 | |> File.rm() 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 1. Fork it ( ) 2 | 2. Create your feature branch (`git checkout -b my-new-feature`) 3 | 3. Make your changes, with new passing tests. Follow this [style guide]. 4 | 4. Execute all tests and quality checks (`mix check && mix dialyzer`). 5 | 5. Commit your changes (`git commit -am 'Add some feature'`) 6 | 6. Push to the branch (`git push origin my-new-feature`) 7 | 7. Create a new Pull Request 8 | 9 | [style guide]: https://github.com/christopheradams/elixir_style_guide 10 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | import RDF.Sigils 2 | import RDF.Guards 3 | 4 | alias RDF.NS 5 | alias RDF.NS.{RDFS, OWL, SKOS} 6 | 7 | alias RDF.{ 8 | Term, 9 | IRI, 10 | BlankNode, 11 | Literal, 12 | XSD, 13 | 14 | Triple, 15 | Quad, 16 | Statement, 17 | 18 | Description, 19 | Graph, 20 | Dataset, 21 | 22 | PrefixMap, 23 | PropertyMap 24 | } 25 | 26 | alias RDF.BlankNode, as: BNode 27 | 28 | alias RDF.{NTriples, NQuads, Turtle} 29 | 30 | alias Decimal, as: D 31 | 32 | c "test/support/example_ns.ex" 33 | c "test/support/example_id_specs.ex" 34 | c "test/support/example_schemas.ex" 35 | 36 | alias Example.NS.EX 37 | -------------------------------------------------------------------------------- /lib/grax/id/counter/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.Supervisor do 2 | use DynamicSupervisor 3 | 4 | def start_link(_arg) do 5 | DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) 6 | end 7 | 8 | def init(_arg) do 9 | DynamicSupervisor.init(strategy: :one_for_one) 10 | end 11 | 12 | def start_counter(adapter, name) do 13 | DynamicSupervisor.start_child(__MODULE__, {adapter, name}) 14 | end 15 | 16 | def start_counter!(adapter, name) do 17 | case start_counter(adapter, name) do 18 | {:ok, pid} -> pid 19 | {:error, error} -> raise error 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/grax/id/namespace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.NamespaceTest do 2 | use Grax.TestCase 3 | 4 | alias Grax.Id.Namespace 5 | 6 | describe "uri/1" do 7 | test "root namespace" do 8 | assert %Namespace{uri: "http://example.com/"} |> Namespace.uri() == 9 | "http://example.com/" 10 | end 11 | 12 | test "nested namespace" do 13 | assert %Namespace{ 14 | uri: "http://example.com/foo", 15 | parent: %Namespace{uri: "http://example.com/"} 16 | } 17 | |> Namespace.uri() == 18 | "http://example.com/foo" 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/grax/id/counter.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter do 2 | def default_adapter, do: Grax.Id.Counter.Dets 3 | 4 | def path, do: Application.get_env(:grax, :counter_dir, ".") 5 | 6 | def path(filename) do 7 | path = path() 8 | File.mkdir_p!(path) 9 | Path.join(path, filename) 10 | end 11 | 12 | def registry, do: Grax.Id.Counter.Registry 13 | 14 | def process_name(adapter, name), do: {adapter, name} 15 | 16 | def process(adapter, name) do 17 | registry() 18 | |> Registry.lookup(process_name(adapter, name)) 19 | |> case do 20 | [] -> Grax.Id.Counter.Supervisor.start_counter!(adapter, name) 21 | [{pid, nil}] -> pid 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/grax/id/urn_namespace.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.UrnNamespace do 2 | @type t :: %__MODULE__{ 3 | nid: String.t(), 4 | string: String.t(), 5 | prefix: atom | nil, 6 | options: Keyword.t() | nil 7 | } 8 | 9 | @enforce_keys [:nid, :string] 10 | defstruct [:nid, :string, :prefix, :options] 11 | 12 | def new(nid, opts) do 13 | {prefix, opts} = Keyword.pop(opts, :prefix) 14 | 15 | %__MODULE__{ 16 | nid: nid, 17 | string: "urn:#{nid}:", 18 | prefix: prefix, 19 | options: unless(Enum.empty?(opts), do: opts) 20 | } 21 | end 22 | 23 | defimpl String.Chars do 24 | def to_string(namespace), do: namespace.string 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/grax/id/counter/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.Adapter do 2 | alias Grax.Id.Counter 3 | 4 | @type name :: atom 5 | @type value :: non_neg_integer 6 | 7 | @callback value(name) :: {:ok, value} | {:error, any} 8 | 9 | @callback inc(name) :: {:ok, value} | {:error, any} 10 | 11 | @callback reset(name, value) :: :ok | {:error, any} 12 | 13 | defmacro __using__(_opts) do 14 | quote do 15 | use GenServer 16 | 17 | @behaviour unquote(__MODULE__) 18 | 19 | @default_value 0 20 | 21 | def via_process_name(name) do 22 | {:via, Registry, {Counter.registry(), Counter.process_name(__MODULE__, name)}} 23 | end 24 | 25 | def process(name) do 26 | Grax.Id.Counter.process(__MODULE__, name) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | grax-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # Dialyzer 29 | /priv/plts/*.plt 30 | /priv/plts/*.plt.hash 31 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | # Grax.Schema 3 | property: 1, 4 | property: 2, 5 | property: 3, 6 | link: 1, 7 | link: 3, 8 | field: 1, 9 | field: 2, 10 | 11 | # Grax.Id.Spec 12 | namespace: 2, 13 | base: 2, 14 | blank_node: 1, 15 | id_schema: 2, 16 | id: 1, 17 | id: 2, 18 | id: 3, 19 | hash: 1, 20 | hash: 2, 21 | uuid: 1, 22 | uuid: 2, 23 | uuid1: 1, 24 | uuid1: 2, 25 | uuid3: 1, 26 | uuid3: 2, 27 | uuid4: 1, 28 | uuid4: 2, 29 | uuid5: 1, 30 | uuid5: 2 31 | ] 32 | 33 | [ 34 | inputs: ["{mix,.formatter}.exs", "{bench,config,lib,test}/**/*.{ex,exs}"], 35 | import_deps: [:rdf], 36 | locals_without_parens: [{:assert_order_independent, 1} | locals_without_parens], 37 | export: [ 38 | locals_without_parens: locals_without_parens 39 | ] 40 | ] 41 | -------------------------------------------------------------------------------- /lib/grax/datatype.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Datatype do 2 | @moduledoc false 3 | 4 | alias RDF.{IRI, Literal, XSD} 5 | 6 | @builtin_type_mapping Map.new( 7 | Literal.Datatype.Registry.builtin_datatypes(), 8 | &{&1.name() |> Macro.underscore() |> String.to_atom(), &1} 9 | ) 10 | |> Map.put(:numeric, XSD.Numeric) 11 | |> Map.put(:json, RDF.JSON) 12 | |> Map.put(:any, nil) 13 | 14 | def builtins, do: @builtin_type_mapping 15 | 16 | def get(:iri), do: {:ok, IRI} 17 | 18 | Enum.each(@builtin_type_mapping, fn {name, type} -> 19 | def get(unquote(name)), do: {:ok, unquote(type)} 20 | end) 21 | 22 | def get(type), do: {:error, "unknown type: #{inspect(type)}"} 23 | end 24 | -------------------------------------------------------------------------------- /lib/grax/schema/struct.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.Struct do 2 | @moduledoc false 3 | 4 | alias Grax.Schema.{Property, DataProperty, LinkProperty, CustomField, AdditionalStatements} 5 | 6 | @additional_statements_field :__additional_statements__ 7 | 8 | def fields(properties, custom_fields, class) do 9 | [ 10 | {@additional_statements_field, AdditionalStatements.default(class)}, 11 | :__id__ 12 | | property_fields(properties) ++ custom_fields(custom_fields) 13 | ] 14 | end 15 | 16 | defp property_fields(properties) do 17 | Enum.map(properties, fn 18 | {name, %DataProperty{default: default}} -> {name, default} 19 | {name, %LinkProperty{type: type}} -> {name, Property.default(type)} 20 | end) 21 | end 22 | 23 | defp custom_fields(fields) do 24 | Enum.map(fields, fn 25 | {name, %CustomField{default: default}} -> {name, default} 26 | end) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /bench/counter.exs: -------------------------------------------------------------------------------- 1 | Grax.Id.Counter.Dets.start_link(:dets_counter) 2 | Grax.Id.Counter.TextFile.start_link(:text_file_counter) 3 | 4 | IO.puts("---------------------------------------------------------------------") 5 | IO.puts("Read benchmark") 6 | IO.puts("---------------------------------------------------------------------\n") 7 | 8 | Benchee.run(%{ 9 | "text file" => fn -> Grax.Id.Counter.TextFile.value(:text_file_counter) end, 10 | "dets" => fn -> Grax.Id.Counter.Dets.value(:dets_counter) end 11 | }) 12 | 13 | IO.puts("\n\n") 14 | IO.puts("---------------------------------------------------------------------") 15 | IO.puts("Write benchmark") 16 | IO.puts("---------------------------------------------------------------------\n") 17 | 18 | Benchee.run(%{ 19 | "text file" => fn -> Grax.Id.Counter.TextFile.inc(:text_file_counter) end, 20 | "dets" => fn -> Grax.Id.Counter.Dets.inc(:dets_counter) end 21 | }) 22 | 23 | Grax.Id.Counter.Dets.file_path(:dets_counter) |> File.rm() 24 | Grax.Id.Counter.TextFile.file_path(:text_file_counter) |> File.rm() 25 | -------------------------------------------------------------------------------- /lib/grax/schema/mapping.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.Mapping do 2 | @moduledoc false 3 | 4 | def from(value, to_schema) do 5 | if Grax.Schema.struct?(value) do 6 | with {:ok, graph} <- Grax.to_rdf(value), 7 | {:ok, mapped} <- to_schema.load(graph, value.__id__) do 8 | Grax.put(mapped, extracted_field_values(value, to_schema)) 9 | end 10 | else 11 | {:error, "invalid value #{inspect(value)}; only Grax.Schema structs are supported"} 12 | end 13 | end 14 | 15 | def from!(value, to_schema) do 16 | case from(value, to_schema) do 17 | {:ok, struct} -> struct 18 | {:error, error} -> raise error 19 | end 20 | end 21 | 22 | defp extracted_field_values(%from_schema{} = from, to_schema) do 23 | from_fields = Map.keys(from_schema.__custom_fields__()) 24 | 25 | Enum.flat_map(to_schema.__custom_fields__(), fn {field, _} -> 26 | if field in from_fields do 27 | [{field, Map.get(from, field)}] 28 | else 29 | [] 30 | end 31 | end) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2020-present Marcel Otto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/elixir-quality-checks.yml: -------------------------------------------------------------------------------- 1 | name: Elixir Quality Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | quality_checks: 13 | name: Formatting and Unused Deps 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | elixir: ["1.18.3"] 18 | otp: ["27.3.0"] 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Elixir Project 25 | uses: ./.github/actions/elixir-setup 26 | with: 27 | elixir-version: ${{ matrix.elixir }} 28 | otp-version: ${{ matrix.otp }} 29 | build-app: false 30 | 31 | - name: Check for unused deps 32 | run: mix deps.unlock --check-unused 33 | - name: Check code formatting 34 | run: mix format --check-formatted 35 | # Check formatting even if there were unused deps so that 36 | # we give devs as much feedback as possible & save some time. 37 | if: always() 38 | - name: Run Credo 39 | run: mix credo suggest --min-priority=normal 40 | # Run Credo even if formatting or the unused deps check failed 41 | if: always() 42 | -------------------------------------------------------------------------------- /lib/grax/schema/registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.Registry do 2 | @moduledoc """ 3 | A global registry of Grax schemas. 4 | """ 5 | use GenServer 6 | 7 | alias Grax.Schema.Registry.State 8 | 9 | def start_link(opts) do 10 | GenServer.start_link(__MODULE__, opts, name: __MODULE__) 11 | end 12 | 13 | def reset(opts \\ []) do 14 | GenServer.cast(__MODULE__, {:reset, opts}) 15 | end 16 | 17 | def register(modules) do 18 | GenServer.cast(__MODULE__, {:register, modules}) 19 | end 20 | 21 | def schema(iri) do 22 | GenServer.call(__MODULE__, {:schema, iri}) 23 | end 24 | 25 | def all_schemas do 26 | GenServer.call(__MODULE__, :all_schemas) 27 | end 28 | 29 | @impl true 30 | def init(opts) do 31 | {:ok, State.build(opts)} 32 | end 33 | 34 | @impl true 35 | def handle_cast({:reset, opts}, _) do 36 | {:noreply, State.build(opts)} 37 | end 38 | 39 | @impl true 40 | def handle_cast({:register, modules}, state) do 41 | {:noreply, State.register(state, modules)} 42 | end 43 | 44 | @impl true 45 | def handle_call({:schema, iri}, _from, state) do 46 | {:reply, State.schema(state, iri), state} 47 | end 48 | 49 | @impl true 50 | def handle_call(:all_schemas, _from, state) do 51 | {:reply, State.all_schemas(state), state} 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/grax/id/urn_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.UrnTest do 2 | use Grax.TestCase 3 | 4 | alias Grax.Id 5 | alias Example.{IdSpecs, User, Post, Comment} 6 | 7 | test "URN ids" do 8 | assert {:ok, %RDF.IRI{value: "urn:example:John%20Doe"}} = 9 | IdSpecs.UrnIds.expected_id_schema(User) 10 | |> Id.Schema.generate_id(Example.user(EX.User0)) 11 | 12 | assert {:ok, %RDF.IRI{value: "urn:example:lorem-ipsum"}} = 13 | IdSpecs.UrnIds.expected_id_schema(Post) 14 | |> Id.Schema.generate_id(Example.post()) 15 | 16 | assert {:ok, %RDF.IRI{value: "urn:other:42"}} = 17 | IdSpecs.UrnIds.expected_id_schema(:integer) 18 | |> Id.Schema.generate_id(%{integer: 42}) 19 | end 20 | 21 | test "hash URN ids" do 22 | assert {:ok, %RDF.IRI{value: "urn:sha1:4a197ebdd564ae83a8aedcf387da409c3d94bfbd"}} = 23 | IdSpecs.HashUrns.expected_id_schema(Post) 24 | |> Id.Schema.generate_id(Example.post()) 25 | 26 | assert {:ok, 27 | %RDF.IRI{ 28 | value: 29 | "urn:hash::sha256:a151ceb1711aad529a7704248f03333990022ebbfa07a7f04c004d70c167919f" 30 | }} = 31 | IdSpecs.HashUrns.expected_id_schema(Comment) 32 | |> Id.Schema.generate_id(Example.comment(EX.Comment1, depth: 0)) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/grax/id/namespace.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Namespace do 2 | alias Grax.Id.UrnNamespace 3 | 4 | @type t :: %__MODULE__{ 5 | parent: t | nil, 6 | uri: String.t(), 7 | prefix: atom | nil, 8 | options: Keyword.t() | nil 9 | } 10 | 11 | @enforce_keys [:uri] 12 | defstruct [:parent, :uri, :prefix, :options] 13 | 14 | def new(parent, segment, opts) do 15 | {prefix, opts} = Keyword.pop(opts, :prefix) 16 | 17 | %__MODULE__{ 18 | uri: initialize_uri(parent, segment), 19 | parent: parent, 20 | prefix: prefix, 21 | options: unless(Enum.empty?(opts), do: opts) 22 | } 23 | end 24 | 25 | defp initialize_uri(nil, uri), do: uri 26 | defp initialize_uri(%__MODULE__{} = parent, segment), do: uri(parent) <> segment 27 | 28 | def uri(%__MODULE__{} = namespace), do: namespace.uri 29 | 30 | def option(%__MODULE__{} = namespace, key) do 31 | get_option(namespace.options, namespace.parent, key) 32 | end 33 | 34 | def option(%UrnNamespace{} = namespace, key) do 35 | get_option(namespace.options, nil, key) 36 | end 37 | 38 | defp get_option(nil, nil, _), do: nil 39 | defp get_option(nil, parent, key), do: option(parent, key) 40 | defp get_option(options, nil, key), do: Keyword.get(options, key) 41 | defp get_option(options, parent, key), do: get_option(options, nil, key) || option(parent, key) 42 | 43 | defimpl String.Chars do 44 | def to_string(namespace), do: Grax.Id.Namespace.uri(namespace) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/support/uuid_test_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.UuidTestHelper do 2 | import ExUnit.Assertions 3 | alias RDF.IRI 4 | 5 | alias Uniq.UUID 6 | 7 | def assert_valid_uuid(%IRI{} = iri, prefix, opts) do 8 | assert_valid_uuid(IRI.to_string(iri), prefix, opts) 9 | end 10 | 11 | def assert_valid_uuid(iri, "urn:" <> _ = prefix, opts) do 12 | assert String.starts_with?(iri, prefix) 13 | assert_valid_uuid(iri, opts) 14 | end 15 | 16 | def assert_valid_uuid(iri, prefix, opts) do 17 | assert String.starts_with?(iri, prefix) 18 | 19 | iri 20 | |> String.replace_prefix(prefix, "") 21 | |> assert_valid_uuid(opts) 22 | end 23 | 24 | def assert_valid_uuid(uuid, opts) do 25 | assert {:ok, uuid_info} = UUID.info(uuid) 26 | 27 | if expected_version = Keyword.get(opts, :version) do 28 | version = uuid_info.version 29 | 30 | assert version == expected_version, 31 | "UUID version mismatch; expected #{expected_version}, but got #{version}" 32 | end 33 | 34 | if expected_format = Keyword.get(opts, :format) do 35 | format = uuid_info.format 36 | 37 | assert format == expected_format, 38 | "UUID format mismatch; expected #{expected_format}, but got #{format}" 39 | end 40 | 41 | if expected_variant = Keyword.get(opts, :variant) do 42 | variant = uuid_info.variant 43 | 44 | assert variant == expected_variant, 45 | "UUID type mismatch; expected #{expected_variant}, but got #{variant}" 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.TestCase do 2 | use ExUnit.CaseTemplate 3 | 4 | alias RDF.{Dataset, Graph, Description, IRI} 5 | 6 | using do 7 | quote do 8 | alias RDF.{Dataset, Graph, Description, IRI, XSD, PrefixMap} 9 | alias RDF.NS.{RDFS, OWL} 10 | alias Example.NS.{EX, FOAF} 11 | 12 | import unquote(__MODULE__) 13 | import Grax.TestData 14 | import RDF, only: [iri: 1, literal: 1, bnode: 1] 15 | import RDF.Sigils 16 | 17 | @compile {:no_warn_undefined, Example.NS.EX} 18 | @compile {:no_warn_undefined, Example.NS.FOAF} 19 | end 20 | end 21 | 22 | def order_independent({:ok, %Example.Datatypes{} = datatypes}), 23 | do: 24 | {:ok, 25 | %{ 26 | datatypes 27 | | integers: Enum.sort(datatypes.integers), 28 | numerics: Enum.sort(datatypes.numerics) 29 | }} 30 | 31 | def order_independent({:error, %Grax.ValidationError{errors: errors} = error}), 32 | do: {:error, %{error | errors: Enum.sort(errors)}} 33 | 34 | def order_independent({:error, %Grax.Schema.DetectionError{candidates: candidates} = error}), 35 | do: {:error, %{error | candidates: Enum.sort(candidates)}} 36 | 37 | def order_independent({:ok, elements}), do: {:ok, Enum.sort(elements)} 38 | def order_independent(elements), do: Enum.sort(elements) 39 | 40 | defmacro assert_order_independent({:==, _, [left, right]}) do 41 | quote do 42 | assert order_independent(unquote(left)) == order_independent(unquote(right)) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/grax/id/extensions/hash_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Types.HashTest do 2 | use Grax.TestCase 3 | 4 | alias Grax.Id 5 | alias Example.{IdSpecs, User, Post, Comment} 6 | 7 | import RDF.Sigils 8 | 9 | test "hash id" do 10 | assert {:ok, 11 | ~I} = 12 | IdSpecs.Hashing.expected_id_schema(User) 13 | |> Id.Schema.generate_id(Example.user(EX.User0)) 14 | 15 | assert {:ok, 16 | ~I} = 17 | IdSpecs.Hashing.expected_id_schema(Post) 18 | |> Id.Schema.generate_id(Example.post()) 19 | 20 | assert {:ok, ~I} = 21 | IdSpecs.Hashing.expected_id_schema(Comment) 22 | |> Id.Schema.generate_id(Example.comment(EX.Comment1, depth: 1)) 23 | end 24 | 25 | test "when no value for the name present" do 26 | assert IdSpecs.Hashing.expected_id_schema(Post) 27 | |> Id.Schema.generate_id(content: nil) == 28 | {:error, "no :content value for hashing present"} 29 | end 30 | 31 | test "with var_mapping" do 32 | assert {:ok, ~I} = 33 | Id.Schema.generate_id(IdSpecs.VarMapping.expected_id_schema(Example.VarMappingC), %{ 34 | name: "foo" 35 | }) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/grax/schema/registry/state.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.Registry.State do 2 | @moduledoc false 3 | 4 | alias Grax.Schema 5 | 6 | require Logger 7 | 8 | defstruct schemas_by_iri: %{}, schemas_without_iri: [] 9 | 10 | def build(additional \\ []) do 11 | %__MODULE__{} 12 | |> register(Grax.Schema.known_schemas()) 13 | |> register(additional) 14 | end 15 | 16 | def register(state, modules) when is_list(modules) do 17 | Enum.reduce(modules, state, ®ister(&2, &1)) 18 | end 19 | 20 | def register(state, module) do 21 | cond do 22 | not Schema.schema?(module) -> 23 | state 24 | 25 | class_iri = module.__class__() -> 26 | %__MODULE__{ 27 | state 28 | | schemas_by_iri: add_schema_iri(state.schemas_by_iri, module, class_iri) 29 | } 30 | 31 | true -> 32 | %__MODULE__{state | schemas_without_iri: [module | state.schemas_without_iri]} 33 | end 34 | end 35 | 36 | defp add_schema_iri(schemas_by_iri, _, nil), do: schemas_by_iri 37 | 38 | defp add_schema_iri(schemas_by_iri, schema, iri) do 39 | Map.update(schemas_by_iri, RDF.iri(iri), schema, &[schema | List.wrap(&1)]) 40 | end 41 | 42 | def schema(%{schemas_by_iri: schemas_by_iri}, iri) do 43 | schemas_by_iri[iri] 44 | end 45 | 46 | def all_schemas(%{schemas_by_iri: schemas_by_iri, schemas_without_iri: schemas_without_iri}) do 47 | Enum.uniq( 48 | schemas_without_iri ++ 49 | (schemas_by_iri 50 | |> Map.values() 51 | |> Enum.flat_map(&List.wrap/1)) 52 | ) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /.github/workflows/elixir-build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | name: Build and test 14 | runs-on: ubuntu-latest 15 | env: 16 | MIX_ENV: test 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | strategy: 19 | matrix: 20 | include: 21 | - pair: 22 | elixir: 1.14.5 23 | otp: 24.3 24 | build-flags: --warnings-as-errors 25 | - pair: 26 | elixir: 1.15.7 27 | otp: 25.3 28 | build-flags: --warnings-as-errors 29 | - pair: 30 | elixir: 1.16.2 31 | otp: 26.2 32 | build-flags: --warnings-as-errors 33 | - pair: 34 | elixir: 1.17.3 35 | otp: 27.3 36 | build-flags: --warnings-as-errors 37 | - pair: 38 | elixir: 1.18.3 39 | otp: 27.3 40 | build-flags: --warnings-as-errors 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | 45 | - name: Setup Elixir Project 46 | uses: ./.github/actions/elixir-setup 47 | with: 48 | elixir-version: ${{ matrix.pair.elixir }} 49 | otp-version: ${{ matrix.pair.otp }} 50 | build-flags: --all-warnings ${{ matrix.build-flags }} 51 | 52 | - name: Run Tests 53 | run: mix coveralls.github ${{ matrix.build-flags }} 54 | if: always() 55 | -------------------------------------------------------------------------------- /lib/grax/id/counter/dets.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.Dets do 2 | use Grax.Id.Counter.Adapter 3 | 4 | def start_link(name) do 5 | GenServer.start_link(__MODULE__, name, name: via_process_name(name)) 6 | end 7 | 8 | @impl true 9 | def init(name) do 10 | name 11 | |> table_name() 12 | |> :dets.open_file( 13 | type: :set, 14 | file: name |> file_path() |> to_charlist(), 15 | repair: true 16 | ) 17 | |> case do 18 | {:ok, table_name} -> 19 | :dets.insert_new(table_name, {:value, @default_value}) 20 | {:ok, name} 21 | 22 | error -> 23 | error 24 | end 25 | end 26 | 27 | @impl true 28 | def terminate(_reason, counter_name) do 29 | counter_name 30 | |> table_name() 31 | |> :dets.close() 32 | 33 | :normal 34 | end 35 | 36 | @impl true 37 | def value(counter) do 38 | process(counter) 39 | 40 | counter 41 | |> table_name 42 | |> :dets.lookup(:value) 43 | |> case do 44 | [value: value] -> {:ok, value} 45 | [] -> {:error, "missing counter in DETS table #{table_name(counter)}"} 46 | end 47 | end 48 | 49 | @impl true 50 | def inc(counter) do 51 | process(counter) 52 | 53 | {:ok, 54 | counter 55 | |> table_name 56 | |> :dets.update_counter(:value, {2, 1})} 57 | end 58 | 59 | @impl true 60 | def reset(counter, value \\ @default_value) do 61 | process(counter) 62 | 63 | counter 64 | |> table_name 65 | |> :dets.insert({:value, value}) 66 | end 67 | 68 | def table_name(counter_name), do: Module.concat(__MODULE__, counter_name) 69 | 70 | def file_path(counter_name) do 71 | Grax.Id.Counter.path(Atom.to_string(counter_name) <> ".dets") 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/grax/schema/additional_statements.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.AdditionalStatements do 2 | @moduledoc false 3 | 4 | alias RDF.Description 5 | 6 | @pseudo_subject RDF.bnode("") 7 | 8 | @empty %{} 9 | def empty, do: @empty 10 | 11 | def default(nil), do: @empty 12 | def default(class), do: new({RDF.type(), class}) 13 | 14 | def new(predications) do 15 | RDF.description(@pseudo_subject, init: predications).predications 16 | end 17 | 18 | def description(%{__id__: subject, __additional_statements__: additional_statements}) do 19 | %Description{ 20 | subject: subject, 21 | predications: additional_statements 22 | } 23 | end 24 | 25 | def clear(%schema{} = mapping, opts) do 26 | %{ 27 | mapping 28 | | __additional_statements__: 29 | if(Keyword.get(opts, :clear_schema_class, false), 30 | do: empty(), 31 | else: schema.__additional_statements__() 32 | ) 33 | } 34 | end 35 | 36 | def get(mapping, property) do 37 | mapping 38 | |> description() 39 | |> Description.get(property) 40 | end 41 | 42 | def update(mapping, fun) do 43 | %{mapping | __additional_statements__: fun.(description(mapping)).predications} 44 | end 45 | 46 | def add_filtered_description(mapping, statements, rejected_properties) do 47 | description = description(mapping) 48 | 49 | updated = 50 | Enum.reduce(statements, description, fn 51 | {_, p, o}, description -> 52 | if p in rejected_properties do 53 | description 54 | else 55 | Description.add(description, {p, o}) 56 | end 57 | end) 58 | 59 | %{mapping | __additional_statements__: updated.predications} 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/grax/schema/registry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.RegistryTest do 2 | use Grax.TestCase 3 | 4 | alias Grax.Schema.Registry 5 | 6 | describe "schema/1" do 7 | test "when no schema for the given IRI exists" do 8 | assert Registry.schema(RDF.iri(EX.Unknown)) == nil 9 | end 10 | 11 | test "when a unique schema exists for a given iri" do 12 | assert Registry.schema(RDF.iri(EX.Post)) == Example.Post 13 | end 14 | 15 | test "when multiple schemas exists for a given iri" do 16 | assert EX.User |> RDF.iri() |> Registry.schema() |> Enum.sort() == 17 | Enum.sort([Example.UserWithCallbacks, Example.User]) 18 | end 19 | end 20 | 21 | test "all_schemas/0" do 22 | all_schemas = Registry.all_schemas() 23 | 24 | assert is_list(all_schemas) 25 | refute Enum.empty?(all_schemas) 26 | assert Enum.all?(all_schemas, &Grax.Schema.schema?/1) 27 | assert all_schemas == Enum.uniq(all_schemas) 28 | end 29 | 30 | describe "register/1" do 31 | test "with a Grax schema" do 32 | defmodule DynamicallyCreatedSchema do 33 | use Grax.Schema 34 | 35 | schema EX.DynamicallyCreatedSchema < Example.ParentSchema do 36 | end 37 | end 38 | 39 | refute Registry.schema(RDF.iri(EX.DynamicallyCreatedSchema)) 40 | assert :ok = Registry.register(DynamicallyCreatedSchema) 41 | assert Registry.schema(RDF.iri(EX.DynamicallyCreatedSchema)) == DynamicallyCreatedSchema 42 | 43 | assert Registry.reset() 44 | end 45 | end 46 | 47 | test "completeness" do 48 | for schema <- Grax.Schema.known_schemas(), not is_nil(schema.__class__()) do 49 | assert Registry.schema(RDF.iri(schema.__class__())) == schema or 50 | schema in Registry.schema(RDF.iri(schema.__class__())) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/grax/schema/type.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.Type do 2 | defmodule Constructors do 3 | @moduledoc !""" 4 | These are type constructor functions available in a Grax schema block. 5 | """ 6 | 7 | def list(opts \\ []), do: list_of(nil, opts) 8 | 9 | def list_of(type, opts \\ []) do 10 | cond do 11 | card = Keyword.get(opts, :card) -> {:list_set, type, cardinality(card)} 12 | min = Keyword.get(opts, :min) -> {:list_set, type, min_cardinality(min)} 13 | true -> {:list_set, type, nil} 14 | end 15 | end 16 | 17 | def ordered_list(opts \\ []), do: ordered_list_of(nil, opts) 18 | 19 | def ordered_list_of(type, opts \\ []) do 20 | cond do 21 | card = Keyword.get(opts, :card) -> {:rdf_list, type, cardinality(card)} 22 | min = Keyword.get(opts, :min) -> {:rdf_list, type, min_cardinality(min)} 23 | true -> {:rdf_list, type, nil} 24 | end 25 | end 26 | 27 | defp cardinality(%Range{} = range) do 28 | cond do 29 | range.first == range.last -> range.first 30 | range.first > range.last -> range.last..range.first 31 | true -> range 32 | end 33 | end 34 | 35 | defp cardinality(number) when is_integer(number) and number >= 0, do: number 36 | defp cardinality(invalid), do: raise("invalid cardinality: #{inspect(invalid)}") 37 | 38 | defp min_cardinality(0), do: nil 39 | defp min_cardinality(number) when is_integer(number) and number >= 0, do: {:min, number} 40 | defp min_cardinality(invalid), do: raise("invalid min cardinality: #{inspect(invalid)}") 41 | end 42 | 43 | @list_types [:list_set, :rdf_list] 44 | def list_types, do: @list_types 45 | 46 | defguard is_list_type(list_type) when list_type in @list_types 47 | 48 | def set?({list_type, _}) when list_type in @list_types, do: true 49 | def set?(_), do: false 50 | end 51 | -------------------------------------------------------------------------------- /test/support/test_data.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.TestData do 2 | @moduledoc """ 3 | Example data for the tests. 4 | """ 5 | 6 | alias RDF.Graph 7 | alias Example.NS.EX 8 | 9 | @example_user EX.User0 10 | |> RDF.type([EX.User, EX.PremiumUser]) 11 | |> EX.name("John Doe") 12 | |> EX.age(42) 13 | |> EX.email("jd@example.com", "john@doe.com") 14 | |> EX.post(EX.Post0) 15 | 16 | @example_post EX.Post0 17 | |> RDF.type(EX.Post) 18 | |> EX.title("Lorem ipsum") 19 | |> EX.content("Lorem ipsum dolor sit amet, …") 20 | |> EX.author(EX.User0) 21 | |> EX.comment([EX.Comment1, EX.Comment2]) 22 | 23 | @example_comments [ 24 | EX.Comment1 25 | |> RDF.type(EX.Comment) 26 | |> EX.content("First") 27 | |> EX.about(EX.Post0) 28 | |> EX.author(EX.User1), 29 | EX.Comment2 30 | |> RDF.type(EX.Comment) 31 | |> EX.content("Second") 32 | |> EX.about(EX.Post0) 33 | |> EX.author(EX.User2) 34 | ] 35 | 36 | @example_comment_authors [ 37 | EX.User1 38 | |> RDF.type(EX.User) 39 | |> EX.name("Erika Mustermann") 40 | |> EX.email("erika@mustermann.de"), 41 | EX.User2 42 | |> RDF.type(EX.User) 43 | |> EX.name("Max Mustermann") 44 | |> EX.email("max@mustermann.de") 45 | ] 46 | 47 | @example_graph Graph.new( 48 | [@example_user, @example_post] ++ @example_comments ++ @example_comment_authors 49 | ) 50 | 51 | def example_description(:user), do: @example_user 52 | def example_description(:post), do: @example_post 53 | def example_description(:comment), do: hd(@example_comments) 54 | 55 | def example_graph(content \\ nil) 56 | def example_graph(nil), do: @example_graph 57 | def example_graph(content), do: content |> example_description() |> Graph.new() 58 | end 59 | -------------------------------------------------------------------------------- /lib/grax/rdf/access.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.RDF.Access do 2 | @moduledoc !""" 3 | This encapsulates the access functions to the RDF data. 4 | 5 | It is intended to become an adapter to different types of data sources. 6 | """ 7 | 8 | alias RDF.{Description, Graph, Query} 9 | alias Grax.Schema.LinkProperty 10 | 11 | def description(graph, id) do 12 | Graph.description(graph, id) 13 | end 14 | 15 | def objects(_graph, description, property_iri) 16 | 17 | def objects(graph, description, {:inverse, property_iri}) do 18 | inverse_values(graph, description.subject, property_iri) 19 | end 20 | 21 | def objects(_graph, description, property_iri) do 22 | Description.get(description, property_iri) 23 | end 24 | 25 | def filtered_objects(graph, description, %property_type{} = property_schema) do 26 | case objects(graph, description, property_schema.iri) do 27 | nil -> 28 | {:ok, nil} 29 | 30 | objects when property_type == LinkProperty -> 31 | Enum.reduce_while(objects, {:ok, []}, fn object, {:ok, objects} -> 32 | case LinkProperty.determine_schema(property_schema, description(graph, object)) do 33 | {:ok, nil} -> {:cont, {:ok, objects}} 34 | {:ok, _} -> {:cont, {:ok, [object | objects]}} 35 | {:error, _} = error -> {:halt, error} 36 | end 37 | end) 38 | |> case do 39 | {:ok, objects} -> {:ok, Enum.reverse(objects)} 40 | other -> other 41 | end 42 | 43 | # We currently have no filter logic on data properties 44 | objects -> 45 | {:ok, objects} 46 | end 47 | end 48 | 49 | defp inverse_values(graph, subject, property) do 50 | {:object?, property, subject} 51 | |> Query.execute!(graph) 52 | |> case do 53 | [] -> nil 54 | results -> Enum.map(results, &Map.fetch!(&1, :object)) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/grax/id/schema_extension.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Schema.Extension do 2 | alias Grax.Id 3 | 4 | @type t :: struct | module 5 | 6 | @callback init(Id.Schema.t(), opts :: keyword()) :: Id.Schema.t() 7 | 8 | @callback call(extension :: t, Id.Schema.t(), variables :: map, opts :: keyword()) :: 9 | {:ok, Id.Schema.t()} | {:error, any} 10 | 11 | defmacro __using__(_opts) do 12 | quote do 13 | @behaviour unquote(__MODULE__) 14 | import unquote(__MODULE__), only: [install: 2, extension_opt: 2] 15 | 16 | @impl unquote(__MODULE__) 17 | def init(id_schema, _opts), do: install(id_schema, __MODULE__) 18 | 19 | defoverridable init: 2 20 | end 21 | end 22 | 23 | @doc false 24 | def init(id_schema, extensions, opts) 25 | 26 | def init(id_schema, nil, _opts), do: id_schema 27 | 28 | def init(id_schema, extensions, opts) when is_list(extensions) do 29 | Enum.reduce(extensions, id_schema, fn extension, id_schema -> 30 | extension.init(id_schema, opts) 31 | end) 32 | end 33 | 34 | def init(id_schema, extension, opts), do: init(id_schema, List.wrap(extension), opts) 35 | 36 | @doc false 37 | def call(%{extensions: nil}, variables, _), do: {:ok, variables} 38 | 39 | def call(id_schema, variables, opts) do 40 | Enum.reduce_while(id_schema.extensions, {:ok, variables}, fn 41 | %type{} = extension, {:ok, variables} -> 42 | case type.call(extension, id_schema, variables, opts) do 43 | {:ok, _} = result -> {:cont, result} 44 | error -> {:halt, error} 45 | end 46 | 47 | extension, {:ok, variables} -> 48 | case extension.call(extension, id_schema, variables, opts) do 49 | {:ok, _} = result -> {:cont, result} 50 | error -> {:halt, error} 51 | end 52 | end) 53 | end 54 | 55 | def install(id_schema, extensions) do 56 | %Id.Schema{id_schema | extensions: List.wrap(id_schema.extensions) ++ List.wrap(extensions)} 57 | end 58 | 59 | def extension_opt(extension, opts) do 60 | Keyword.update(opts, :extensions, [extension], &(&1 ++ [extension])) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/grax/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.ConfigTest do 2 | use Grax.TestCase 3 | 4 | # credo:disable-for-next-line Credo.Check.Warning.ApplicationConfigInModuleAttribute 5 | @original_config Map.new([:grax, :example_app], &{&1, Application.get_all_env(&1)}) 6 | 7 | setup %{config: config} do 8 | Application.put_all_env(config) 9 | 10 | on_exit(&reset_config/0) 11 | 12 | {:ok, config} 13 | end 14 | 15 | describe "application id_spec" do 16 | @tag config: [example_app: [grax_id_spec: Example.IdSpecs.AppConfigIdSpec]] 17 | test "with id_spec_from_otp_app option" do 18 | defmodule TestSchema1 do 19 | use Grax.Schema, id_spec_from_otp_app: :example_app 20 | 21 | schema do 22 | property foo: EX.foo() 23 | end 24 | end 25 | 26 | assert TestSchema1.__id_spec__() == Example.IdSpecs.AppConfigIdSpec 27 | 28 | assert TestSchema1.__id_schema__() == 29 | Example.IdSpecs.AppConfigIdSpec.expected_id_schema(TestSchema1) 30 | 31 | assert {:ok, %{__struct__: TestSchema1, __id__: %RDF.IRI{value: _}, foo: "foo"}} = 32 | TestSchema1.build(foo: "foo") 33 | end 34 | 35 | @tag skip: "TODO: How can we test this case" 36 | test "with application name detection" do 37 | defmodule TestSchema2 do 38 | use Grax.Schema 39 | 40 | schema do 41 | property foo: EX.foo() 42 | end 43 | end 44 | 45 | assert TestSchema2.__id_spec__() == Example.IdSpecs.AppConfigIdSpec 46 | 47 | assert TestSchema2.__id_schema__() == 48 | Example.IdSpecs.AppConfigIdSpec.expected_id_schema(TestSchema2) 49 | 50 | assert {:ok, %{__struct__: TestSchema2, __id__: %RDF.IRI{value: _}, foo: "foo"}} = 51 | TestSchema2.build(foo: "foo") 52 | end 53 | end 54 | 55 | def reset_config do 56 | @original_config 57 | |> Enum.each(fn {app_key, config} -> 58 | Application.get_all_env(app_key) 59 | |> Enum.each(fn {key, _} -> Application.delete_env(app_key, key) end) 60 | 61 | Application.put_all_env([{app_key, config}]) 62 | end) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/grax/callbacks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.CallbacksTest do 2 | use Grax.TestCase 3 | 4 | test "on_load/3" do 5 | assert {:ok, %Example.UserWithCallbacks{} = user} = 6 | Example.UserWithCallbacks.load(example_graph(), EX.User0, test: 42) 7 | 8 | assert user == 9 | user0_with_callback() 10 | |> Grax.put_additional_statements(%{RDF.type() => [EX.User, EX.PremiumUser]}) 11 | end 12 | 13 | test "on_load/3 during preloading" do 14 | assert {:ok, %Example.UserWithCallbacks{} = user} = 15 | example_graph() 16 | |> Graph.add([ 17 | EX.friend(EX.User0, EX.User1), 18 | RDF.type(EX.User1, EX.PremiumUser) 19 | ]) 20 | |> Example.UserWithCallbacks.load(EX.User0, test: 42) 21 | 22 | assert user == 23 | user0_with_callback() 24 | |> Grax.put!( 25 | friends: [ 26 | Example.UserWithCallbacks.build!(EX.User1, 27 | name: "Erika Mustermann", 28 | email: "erika@mustermann.de", 29 | canonical_email: "mailto:erika@mustermann.de", 30 | comments: [~I], 31 | customer_type: :admin 32 | ) 33 | |> Grax.put_additional_statements(%{RDF.type() => [EX.User, EX.PremiumUser]}) 34 | ] 35 | ) 36 | |> Grax.put_additional_statements(%{RDF.type() => [EX.User, EX.PremiumUser]}) 37 | end 38 | 39 | test "on_to_rdf3" do 40 | assert user0_with_callback() |> Grax.to_rdf(test: 42) == 41 | {:ok, 42 | :post 43 | |> example_graph() 44 | |> Graph.add( 45 | :user 46 | |> example_description() 47 | |> Description.put({RDF.type(), [EX.User, EX.Admin]}) 48 | )} 49 | end 50 | 51 | def user0_with_callback() do 52 | %{ 53 | struct( 54 | Example.UserWithCallbacks, 55 | Map.from_struct(Example.user(EX.User0, depth: 1)) 56 | ) 57 | | customer_type: :admin 58 | } 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/grax/id/extensions/hash.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Hash do 2 | use Grax.Id.Schema.Extension 3 | 4 | alias Grax.Id 5 | 6 | import Grax.Utils, only: [rename_keyword: 3] 7 | 8 | defstruct [:algorithm, :data_variable] 9 | 10 | defp __hash__(opts) do 11 | opts = extension_opt(__MODULE__, opts) 12 | template = Keyword.get(opts, :template, default_template(opts)) 13 | 14 | quote do 15 | id_schema unquote(template), unquote(opts) 16 | end 17 | end 18 | 19 | defmacro hash({{:., _, [schema, property]}, _, []}) do 20 | __hash__(schema: schema, data: property) 21 | end 22 | 23 | defmacro hash(opts) do 24 | __hash__(opts) 25 | end 26 | 27 | defmacro hash({{:., _, [schema, property]}, _, []}, opts) do 28 | opts 29 | |> Keyword.put(:schema, schema) 30 | |> Keyword.put(:data, property) 31 | |> __hash__() 32 | end 33 | 34 | defmacro hash(schema, opts) do 35 | opts 36 | |> Keyword.put(:schema, schema) 37 | |> __hash__() 38 | end 39 | 40 | defp default_template(_opts), do: "{hash}" 41 | 42 | @impl true 43 | def init(id_schema, opts) do 44 | opts = 45 | opts 46 | |> rename_keyword(:algorithm, :hash_algorithm) 47 | |> rename_keyword(:data, :hash_data_variable) 48 | 49 | install( 50 | id_schema, 51 | %__MODULE__{ 52 | algorithm: Id.Schema.option!(opts, :hash_algorithm, id_schema), 53 | data_variable: Keyword.fetch!(opts, :hash_data_variable) 54 | } 55 | ) 56 | end 57 | 58 | @impl true 59 | def call(%{algorithm: algorithm, data_variable: variable}, _, variables, _) do 60 | with {:ok, data} <- get_data(variables, variable) do 61 | set_hash(variables, calculate(data, algorithm)) 62 | end 63 | end 64 | 65 | def calculate(data, algorithm) do 66 | :crypto.hash(algorithm, data) 67 | |> Base.encode16() 68 | |> String.downcase() 69 | end 70 | 71 | defp get_data(variables, variable) do 72 | case variables[variable] do 73 | nil -> {:error, "no #{inspect(variable)} value for hashing present"} 74 | name -> {:ok, to_string(name)} 75 | end 76 | end 77 | 78 | defp set_hash(variables, hash), 79 | do: {:ok, Map.put(variables, :hash, hash)} 80 | end 81 | -------------------------------------------------------------------------------- /.github/workflows/elixir-dialyzer.yml: -------------------------------------------------------------------------------- 1 | name: Dialyzer 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | name: Run Dialyzer 14 | runs-on: ubuntu-latest 15 | env: 16 | MIX_ENV: dev 17 | strategy: 18 | matrix: 19 | elixir: ["1.18.3"] 20 | otp: ["27.3.0"] 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Setup Elixir Project 27 | uses: ./.github/actions/elixir-setup 28 | id: beam 29 | with: 30 | elixir-version: ${{ matrix.elixir }} 31 | otp-version: ${{ matrix.otp }} 32 | build-app: false 33 | 34 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 35 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 36 | - name: Restore PLT cache 37 | uses: actions/cache@v4 38 | id: plt_cache 39 | with: 40 | key: plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} 41 | restore-keys: | 42 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} 43 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }}- 44 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- 45 | plt-${{ runner.os }}-${{ steps.beam.outputs.otp-version }}- 46 | path: priv/plts 47 | 48 | # Create PLTs if no cache was found. 49 | # Always rebuild PLT when a job is retried 50 | # (If they were cached at all, they'll be updated when we run mix dialyzer with no flags.) 51 | - name: Create PLTs 52 | if: steps.plt_cache.outputs.cache-hit != 'true' || github.run_attempt != '1' 53 | run: mix dialyzer --plt 54 | 55 | - name: Run Dialyzer 56 | run: mix dialyzer --format github 57 | -------------------------------------------------------------------------------- /test/support/id_counter_adapter_test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.Adapter.TestCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using(opts) do 5 | adapter = Keyword.get(opts, :adapter) 6 | 7 | quote do 8 | alias Grax.Id.{Counter, CounterTestHelper} 9 | 10 | @test_counter_name :example_counter 11 | 12 | describe "initialized counter" do 13 | setup [:with_clean_fs, :with_counter] 14 | 15 | test "counter behaviour", %{counter: counter} do 16 | assert unquote(adapter).value(counter) == {:ok, 0} 17 | assert unquote(adapter).inc(counter) == {:ok, 1} 18 | assert unquote(adapter).value(counter) == {:ok, 1} 19 | assert unquote(adapter).inc(counter) == {:ok, 2} 20 | assert unquote(adapter).inc(counter) == {:ok, 3} 21 | assert unquote(adapter).inc(counter) == {:ok, 4} 22 | assert unquote(adapter).value(counter) == {:ok, 4} 23 | assert unquote(adapter).reset(counter) == :ok 24 | assert unquote(adapter).value(counter) == {:ok, 0} 25 | assert unquote(adapter).inc(counter) == {:ok, 1} 26 | assert unquote(adapter).value(counter) == {:ok, 1} 27 | assert unquote(adapter).reset(counter, 42) == :ok 28 | assert unquote(adapter).value(counter) == {:ok, 42} 29 | assert unquote(adapter).inc(counter) == {:ok, 43} 30 | assert unquote(adapter).value(counter) == {:ok, 43} 31 | end 32 | 33 | test "inc when the counter file does not exist", %{counter: counter} do 34 | assert unquote(adapter).inc(counter) == {:ok, 1} 35 | end 36 | 37 | test "reset when the counter file does not exist", %{counter: counter} do 38 | assert unquote(adapter).reset(counter) == :ok 39 | end 40 | end 41 | 42 | def with_counter(context) do 43 | CounterTestHelper.with_counter(unquote(adapter), @test_counter_name) 44 | {:ok, Map.put(context, :counter, @test_counter_name)} 45 | end 46 | 47 | def with_clean_fs(context) do 48 | CounterTestHelper.with_clean_fs(unquote(adapter), @test_counter_name) 49 | {:ok, context} 50 | end 51 | 52 | defoverridable with_clean_fs: 1, with_counter: 1 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/grax/schema/link_property_union.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.LinkProperty.Union do 2 | @moduledoc false 3 | 4 | defstruct [:types] 5 | 6 | alias Grax.Schema.Inheritance 7 | alias Grax.InvalidResourceTypeError 8 | alias RDF.Description 9 | 10 | def new(class_mapping) do 11 | {:ok, %__MODULE__{types: normalize_class_mapping(class_mapping)}} 12 | end 13 | 14 | defp normalize_class_mapping(class_mapping) do 15 | Map.new(class_mapping, fn 16 | {nil, schema} -> 17 | {nil, schema} 18 | 19 | {class, schema} -> 20 | {RDF.iri(class), schema} 21 | 22 | schema when is_atom(schema) -> 23 | cond do 24 | not Grax.Schema.schema?(schema) -> 25 | raise "invalid union type definition: #{inspect(schema)}" 26 | 27 | class = schema.__class__() -> 28 | {RDF.iri(class), schema} 29 | 30 | true -> 31 | raise "invalid union type definition: #{inspect(schema)} does not specify a class" 32 | end 33 | 34 | invalid -> 35 | raise "invalid union type definition: #{inspect(invalid)}" 36 | end) 37 | end 38 | 39 | def determine_schema(%Description{} = description, class_mapping, property_schema) do 40 | description 41 | |> Description.get(RDF.type(), []) 42 | |> determine_schema(class_mapping, property_schema) 43 | end 44 | 45 | def determine_schema(types, class_mapping, property_schema) do 46 | types 47 | |> Enum.reduce([], fn class, candidates -> 48 | case class_mapping[class] do 49 | nil -> candidates 50 | schema -> [schema | candidates] 51 | end 52 | end) 53 | |> do_determine_schema(types, class_mapping, property_schema) 54 | end 55 | 56 | defp do_determine_schema([schema], _, _, _), do: {:ok, schema} 57 | 58 | defp do_determine_schema([], types, class_mapping, property_schema) do 59 | type_mismatch(class_mapping[nil], property_schema.on_rdf_type_mismatch, types) 60 | end 61 | 62 | defp do_determine_schema(candidates, _, _, _) do 63 | case Inheritance.most_specific_schema(candidates) do 64 | multiple when is_list(multiple) -> 65 | {:error, 66 | InvalidResourceTypeError.exception(type: :multiple_matches, resource_types: multiple)} 67 | 68 | result -> 69 | {:ok, result} 70 | end 71 | end 72 | 73 | defp type_mismatch(fallback_schema, on_rdf_type_mismatch, types) 74 | defp type_mismatch(nil, :ignore, _), do: {:ok, nil} 75 | 76 | defp type_mismatch(nil, :error, types), 77 | do: {:error, InvalidResourceTypeError.exception(type: :no_match, resource_types: types)} 78 | 79 | defp type_mismatch(fallback, _, _), do: {:ok, fallback} 80 | end 81 | -------------------------------------------------------------------------------- /lib/grax/id/counter/text_file.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.TextFile do 2 | use Grax.Id.Counter.Adapter 3 | 4 | @io_read_mode :eof 5 | 6 | def start_link(name) do 7 | GenServer.start_link(__MODULE__, name, name: via_process_name(name)) 8 | end 9 | 10 | @impl true 11 | def init(name) do 12 | {:ok, file_path(name)} 13 | end 14 | 15 | @impl true 16 | def value(counter) do 17 | counter 18 | |> process() 19 | |> GenServer.call(:value) 20 | end 21 | 22 | @impl true 23 | def inc(counter) do 24 | counter 25 | |> process() 26 | |> GenServer.call(:inc) 27 | end 28 | 29 | @impl true 30 | def reset(counter, value \\ @default_value) do 31 | counter 32 | |> process() 33 | |> GenServer.call({:reset, value}) 34 | end 35 | 36 | @impl true 37 | def handle_call(:value, _from, path) do 38 | {:reply, read(path), path} 39 | end 40 | 41 | @impl true 42 | def handle_call(:inc, _from, path) do 43 | {:reply, atomic_inc(path), path} 44 | end 45 | 46 | @impl true 47 | def handle_call({:reset, value}, _from, path) do 48 | {:reply, write(path, value), path} 49 | end 50 | 51 | def file_path(counter_name) do 52 | Grax.Id.Counter.path(Atom.to_string(counter_name)) 53 | end 54 | 55 | defp read(path) do 56 | if File.exists?(path) do 57 | do_read(path) 58 | else 59 | with :ok <- create(path) do 60 | {:ok, @default_value} 61 | end 62 | end 63 | end 64 | 65 | defp do_read(path) do 66 | with {:ok, content} <- File.read(path) do 67 | to_integer(content, path) 68 | end 69 | end 70 | 71 | defp to_integer(:eof, _), do: {:ok, @default_value} 72 | defp to_integer("", _), do: {:ok, @default_value} 73 | 74 | defp to_integer(string, path) do 75 | case Integer.parse(string) do 76 | {integer, ""} -> {:ok, integer} 77 | _ -> {:error, "Invalid counter value in #{path}: #{inspect(string)}"} 78 | end 79 | end 80 | 81 | defp write(path, new_value) do 82 | File.write(path, to_string(new_value)) 83 | end 84 | 85 | defp create(path) do 86 | write(path, @default_value) 87 | end 88 | 89 | defp atomic_inc(path) do 90 | File.open(path, [:read, :write], fn file -> 91 | file 92 | |> IO.read(@io_read_mode) 93 | |> to_integer(path) 94 | |> case do 95 | {:ok, value} -> 96 | inc_value = value + 1 97 | :file.position(file, 0) 98 | IO.write(file, to_string(inc_value)) 99 | {:ok, inc_value} 100 | 101 | error -> 102 | error 103 | end 104 | end) 105 | |> case do 106 | {:ok, {:ok, _} = ok} -> ok 107 | {:ok, {:error, _} = error} -> error 108 | error -> error 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/grax/id/counter/supervisor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Counter.SupervisorTest do 2 | use ExUnit.Case 3 | 4 | alias Grax.Id.Counter 5 | 6 | @text_file_counter_name :text_file_counter 7 | @dets_counter_name :dets_counter 8 | 9 | setup do 10 | with {:ok, context} <- with_text_file_counter(%{}) do 11 | with_dets_counter(context) 12 | end 13 | end 14 | 15 | test "counters are restarted and persisted", %{ 16 | text_file_counter_pid: text_file_counter_pid, 17 | text_file_counter_value: text_file_counter_value, 18 | dets_counter_pid: dets_counter_pid, 19 | dets_counter_value: dets_counter_value 20 | } do 21 | assert Counter.TextFile.process(@text_file_counter_name) == text_file_counter_pid 22 | Process.exit(text_file_counter_pid, :kill) 23 | Process.sleep(100) 24 | 25 | refute text_file_counter_pid == 26 | (new_text_file_counter_pid = Counter.TextFile.process(@text_file_counter_name)) 27 | 28 | assert Counter.TextFile.value(@text_file_counter_name) == {:ok, text_file_counter_value} 29 | 30 | assert Counter.Dets.process(@dets_counter_name) == dets_counter_pid 31 | Process.exit(dets_counter_pid, :kill) 32 | Process.sleep(100) 33 | 34 | refute dets_counter_pid == 35 | (new_dets_counter_pid = Counter.Dets.process(@dets_counter_name)) 36 | 37 | assert Counter.Dets.value(@dets_counter_name) == {:ok, dets_counter_value} 38 | 39 | Counter.TextFile.inc(@text_file_counter_name) 40 | Counter.Dets.inc(@dets_counter_name) 41 | 42 | Counter.Supervisor 43 | |> Process.whereis() 44 | |> Process.exit(:kill) 45 | 46 | Process.sleep(100) 47 | 48 | refute Counter.TextFile.process(@text_file_counter_name) == new_text_file_counter_pid 49 | refute Counter.Dets.process(@dets_counter_name) == new_dets_counter_pid 50 | 51 | assert Counter.TextFile.value(@text_file_counter_name) == {:ok, text_file_counter_value + 1} 52 | assert Counter.Dets.value(@dets_counter_name) == {:ok, dets_counter_value + 1} 53 | end 54 | 55 | def with_text_file_counter(context) do 56 | counter_path = Counter.TextFile.file_path(@text_file_counter_name) 57 | Enum.each(1..3, fn _ -> Counter.TextFile.inc(@text_file_counter_name) end) 58 | on_exit(fn -> File.rm(counter_path) end) 59 | 60 | {:ok, 61 | context 62 | |> Map.put( 63 | :text_file_counter_value, 64 | 3 = Counter.TextFile.value(@text_file_counter_name) |> elem(1) 65 | ) 66 | |> Map.put(:text_file_counter_pid, Counter.TextFile.process(@text_file_counter_name))} 67 | end 68 | 69 | def with_dets_counter(context) do 70 | counter_path = Counter.Dets.file_path(@dets_counter_name) 71 | Enum.each(1..4, fn _ -> Counter.Dets.inc(@dets_counter_name) end) 72 | # TODO: This doesn't work across multiple test cases, since the ets table is still present 73 | on_exit(fn -> File.rm(counter_path) end) 74 | 75 | {:ok, 76 | context 77 | |> Map.put(:dets_counter_value, 4 = Counter.Dets.value(@dets_counter_name) |> elem(1)) 78 | |> Map.put(:dets_counter_pid, Counter.Dets.process(@dets_counter_name))} 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /test/grax/id/id_schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.SchemaTest do 2 | use Grax.TestCase 3 | 4 | import RDF.Sigils 5 | 6 | alias Grax.Id 7 | alias Example.{IdSpecs, User, Post} 8 | 9 | describe "generate_id/2" do 10 | test "based on another field" do 11 | assert IdSpecs.GenericIds.expected_id_schema(Post) 12 | |> Id.Schema.generate_id(Example.post()) == 13 | {:ok, ~I} 14 | 15 | keyword_list = Example.post() |> Map.from_struct() |> Keyword.new() 16 | 17 | assert IdSpecs.GenericIds.expected_id_schema(Post) 18 | |> Id.Schema.generate_id(keyword_list) == 19 | {:ok, ~I} 20 | 21 | assert IdSpecs.GenericIds.expected_id_schema(User) 22 | |> Id.Schema.generate_id(Example.user(EX.User0)) == 23 | {:ok, ~I} 24 | end 25 | 26 | test "based on a counter" do 27 | Id.Counter.Dets.reset(:user, 42) 28 | Id.Counter.TextFile.reset(:post, 23) 29 | Id.Counter.TextFile.reset(:comment, 99) 30 | 31 | assert IdSpecs.WithCounter.expected_id_schema(Post) 32 | |> Id.Schema.generate_id(Example.post()) == 33 | {:ok, ~I} 34 | 35 | assert IdSpecs.WithCounter.expected_id_schema(User) 36 | |> Id.Schema.generate_id(Example.user(EX.User0)) == 37 | {:ok, ~I} 38 | 39 | keyword_list = Example.post() |> Map.from_struct() |> Keyword.new() 40 | 41 | assert IdSpecs.WithCounter.expected_id_schema(Post) 42 | |> Id.Schema.generate_id(keyword_list) == 43 | {:ok, ~I} 44 | 45 | assert IdSpecs.WithCounter.expected_id_schema(Example.Comment) 46 | |> Id.Schema.generate_id([]) == 47 | {:ok, ~I} 48 | end 49 | 50 | test "with var_mapping" do 51 | assert IdSpecs.VarMapping.expected_id_schema(Example.VarMappingA) 52 | |> Map.put(:schema, Example.VarMappingA) 53 | |> Id.Schema.generate_id(%{name: "foo"}) == 54 | {:ok, ~I} 55 | 56 | assert IdSpecs.VarMapping.expected_id_schema(Example.VarMappingC) 57 | |> Id.Schema.generate_id(%{name: "foo"}) == 58 | {:ok, ~I} 59 | end 60 | 61 | test "non-string values are converted to strings" do 62 | assert IdSpecs.Foo.expected_id_schema(Example.WithIdSchemaNested) 63 | |> Id.Schema.generate_id(bar: 42) == 64 | {:ok, ~I} 65 | end 66 | 67 | test "when no values for the template parameters present" do 68 | assert IdSpecs.GenericIds.expected_id_schema(User) 69 | |> Id.Schema.generate_id(%{}) == 70 | {:error, "no value for id schema template parameter: name"} 71 | 72 | assert IdSpecs.GenericIds.expected_id_schema(User) 73 | |> Id.Schema.generate_id(%{name: nil}) == 74 | {:error, "no value for id schema template parameter: name"} 75 | end 76 | 77 | # generation of UUID-based ids are tested in uuid_test.exs 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.MixProject do 2 | use Mix.Project 3 | 4 | @repo_url "https://github.com/rdf-elixir/grax" 5 | 6 | @version File.read!("VERSION") |> String.trim() 7 | 8 | def project do 9 | [ 10 | app: :grax, 11 | version: @version, 12 | elixir: "~> 1.14", 13 | start_permanent: Mix.env() == :prod, 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | consolidate_protocols: Mix.env() != :test, 16 | deps: deps(), 17 | aliases: aliases(), 18 | 19 | # Dialyzer 20 | dialyzer: dialyzer(), 21 | 22 | # Hex 23 | package: package(), 24 | description: description(), 25 | 26 | # Docs 27 | name: "Grax", 28 | docs: [ 29 | main: "Grax", 30 | source_url: @repo_url, 31 | source_ref: "v#{@version}", 32 | extras: ["CHANGELOG.md"] 33 | ], 34 | 35 | # ExCoveralls 36 | test_coverage: [tool: ExCoveralls], 37 | preferred_cli_env: [ 38 | check: :test, 39 | coveralls: :test, 40 | "coveralls.detail": :test, 41 | "coveralls.post": :test, 42 | "coveralls.html": :test 43 | ] 44 | ] 45 | end 46 | 47 | defp description do 48 | """ 49 | A light-weight RDF graph data mapper for Elixir. 50 | """ 51 | end 52 | 53 | defp package do 54 | [ 55 | maintainers: ["Marcel Otto"], 56 | licenses: ["MIT"], 57 | links: %{ 58 | "Homepage" => "https://rdf-elixir.dev", 59 | "GitHub" => @repo_url, 60 | "Changelog" => @repo_url <> "/blob/master/CHANGELOG.md" 61 | }, 62 | files: ~w[lib priv mix.exs .formatter.exs VERSION *.md] 63 | ] 64 | end 65 | 66 | def application do 67 | [ 68 | extra_applications: [:logger, :crypto], 69 | mod: {Grax.Application, []} 70 | ] 71 | end 72 | 73 | defp deps do 74 | [ 75 | rdf_ex_dep(:rdf, "~> 2.1"), 76 | {:uniq, "~> 0.6"}, 77 | {:yuri_template, "~> 1.1"}, 78 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 79 | {:dialyxir, "~> 1.4", only: :dev, runtime: false}, 80 | {:ex_doc, "~> 0.37", only: :dev, runtime: false}, 81 | {:excoveralls, "~> 0.18", only: :test}, 82 | # This dependency is needed for ExCoveralls when OTP < 25 83 | {:castore, "~> 1.0", only: :test}, 84 | {:benchee, "~> 1.3", only: :dev} 85 | ] 86 | end 87 | 88 | defp rdf_ex_dep(dep, version) do 89 | case System.get_env("RDF_EX_PACKAGES_SRC") do 90 | "LOCAL" -> {dep, path: "../#{dep}"} 91 | _ -> {dep, version} 92 | end 93 | end 94 | 95 | defp dialyzer do 96 | [ 97 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 98 | ignore_warnings: ".dialyzer_ignore.exs", 99 | # Error out when an ignore rule is no longer useful so we can remove it 100 | list_unused_filters: true 101 | ] 102 | end 103 | 104 | defp aliases do 105 | [ 106 | check: [ 107 | "clean", 108 | "deps.unlock --check-unused", 109 | "compile --all-warnings --warnings-as-errors", 110 | "format --check-formatted", 111 | "test --warnings-as-errors", 112 | "credo" 113 | ] 114 | ] 115 | end 116 | 117 | defp elixirc_paths(:test), do: ["lib", "test/support"] 118 | defp elixirc_paths(_), do: ["lib"] 119 | end 120 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Contact: rdfex@googlegroups.com 4 | 5 | ## Why have a Code of Conduct? 6 | 7 | As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 8 | 9 | The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about RDF on Elixir effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise. 10 | 11 | ## Our Values 12 | 13 | These are the values RDF on Elixir developers should aspire to: 14 | 15 | * Be friendly and welcoming 16 | * Be patient 17 | * Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) 18 | * Be thoughtful 19 | * Productive communication requires effort. Think about how your words will be interpreted. 20 | * Remember that sometimes it is best to refrain entirely from commenting. 21 | * Be respectful 22 | * In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively. 23 | * Avoid destructive behavior 24 | * Derailing: stay on topic; if you want to talk about something else, start a new conversation. 25 | * Unconstructive criticism: don't merely decry the current state of affairs; offer (or at least solicit) suggestions as to how things may be improved. 26 | * Snarking (pithy, unproductive, sniping comments). 27 | 28 | The following actions are explicitly forbidden: 29 | 30 | * Insulting, demeaning, hateful, or threatening remarks. 31 | * Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 32 | * Bullying or systematic harassment. 33 | * Unwelcome sexual advances. 34 | * Incitement to any of these. 35 | 36 | ## Where does the Code of Conduct apply? 37 | 38 | If you participate in or contribute to the RDF on Elixir ecosystem in any way, you are encouraged to follow the Code of Conduct while doing so. 39 | 40 | Explicit enforcement of the Code of Conduct applies to the official mediums operated by the RDF on Elixir project: 41 | 42 | * The official GitHub projects, code reviews and discussions. 43 | 44 | Project maintainers may remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. 45 | 46 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by emailing: rdfex@googlegroups.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. **All reports will be kept confidential**. 47 | 48 | **The goal of the Code of Conduct is to resolve conflicts in the most harmonious way possible**. We hope that in most cases issues may be resolved through polite discussion and mutual agreement. Bannings and other forceful measures are to be employed only as a last resort. **Do not** post about the issue publicly or try to rally sentiment against a particular individual or group. 49 | 50 | ## Acknowledgements 51 | 52 | This document was based on the Code of Conduct from the Elixir project. 53 | -------------------------------------------------------------------------------- /test/grax/id/extensions/uuid_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Types.UuidTest do 2 | use Grax.TestCase 3 | 4 | import Grax.UuidTestHelper 5 | 6 | alias Grax.Id 7 | alias Example.{IdSpecs, User, Post, Comment} 8 | 9 | alias Uniq.UUID 10 | 11 | describe "generic uuid" do 12 | test "random UUIDs" do 13 | assert {:ok, %RDF.IRI{} = id} = 14 | IdSpecs.GenericUuids.expected_id_schema(User) 15 | |> Id.Schema.generate_id(Example.user(EX.User0)) 16 | 17 | assert_valid_uuid(id, "http://example.com/", version: 4, format: :hex) 18 | 19 | assert {:ok, %RDF.IRI{} = id} = 20 | IdSpecs.GenericUuids.expected_id_schema(Post) 21 | |> Id.Schema.generate_id(Example.post()) 22 | 23 | assert_valid_uuid(id, "http://example.com/posts/", version: 4, format: :default) 24 | 25 | assert {:ok, %RDF.IRI{} = id} = 26 | IdSpecs.ShortUuids.expected_id_schema(Post) 27 | |> Id.Schema.generate_id(Example.post()) 28 | 29 | assert_valid_uuid(id, "http://example.com/", version: 4, format: :default) 30 | 31 | assert {:ok, %RDF.IRI{} = id} = 32 | IdSpecs.ShortUuids.expected_id_schema(Comment) 33 | |> Id.Schema.generate_id(%{}) 34 | 35 | assert_valid_uuid(id, "http://example.com/comments/", version: 1, format: :hex) 36 | end 37 | 38 | test "hash-based UUIDs" do 39 | assert {:ok, %RDF.IRI{} = id} = 40 | IdSpecs.HashUuids.expected_id_schema(User) 41 | |> Id.Schema.generate_id(Example.user(EX.User0)) 42 | 43 | # test that the generated UUIDs are reproducible 44 | assert {:ok, ^id} = 45 | Id.Schema.generate_id( 46 | IdSpecs.HashUuids.expected_id_schema(User), 47 | Example.user(EX.User0) 48 | ) 49 | 50 | assert_valid_uuid(id, "http://example.com/", version: 5, format: :default) 51 | 52 | assert {:ok, %RDF.IRI{} = id} = 53 | IdSpecs.HashUuids.expected_id_schema(Post) 54 | |> Id.Schema.generate_id(Example.post()) 55 | 56 | # test that the generated UUIDs are reproducible 57 | assert {:ok, ^id} = 58 | IdSpecs.HashUuids.expected_id_schema(Post) 59 | |> Id.Schema.generate_id(Example.post() |> Map.from_struct()) 60 | 61 | assert_valid_uuid(id, "http://example.com/", version: 3, format: :default) 62 | 63 | id = 64 | RDF.iri( 65 | "http://example.com/#{UUID.uuid5(:url, Example.user(EX.User0).canonical_email, :hex)}" 66 | ) 67 | 68 | assert {:ok, %RDF.IRI{} = ^id} = 69 | IdSpecs.ShortUuids.expected_id_schema(User) 70 | |> Id.Schema.generate_id(Example.user(EX.User0)) 71 | 72 | # test that the generated UUIDs are reproducible 73 | assert {:ok, ^id} = 74 | IdSpecs.ShortUuids.expected_id_schema(User) 75 | |> Id.Schema.generate_id( 76 | Example.user(EX.User0) 77 | |> Map.from_struct() 78 | |> Keyword.new() 79 | ) 80 | 81 | assert_valid_uuid(id, "http://example.com/", version: 5, format: :hex) 82 | end 83 | 84 | test "URN UUIDs" do 85 | assert {:ok, %RDF.IRI{} = id} = 86 | IdSpecs.UuidUrns.expected_id_schema(User) 87 | |> Id.Schema.generate_id(Example.user(EX.User0)) 88 | 89 | assert_valid_uuid(id, "urn:uuid:", version: 4, format: :urn) 90 | 91 | assert {:ok, %RDF.IRI{} = id} = 92 | IdSpecs.UuidUrns.expected_id_schema(Post) 93 | |> Id.Schema.generate_id(Example.post()) 94 | 95 | assert_valid_uuid(id, "urn:uuid:", version: 5, format: :urn) 96 | end 97 | 98 | test "when no value for the name present" do 99 | assert IdSpecs.ShortUuids.expected_id_schema(User) 100 | |> Id.Schema.generate_id(name: nil) == 101 | {:error, "no value for field :canonical_email for UUID name present"} 102 | end 103 | 104 | test "with var_mapping" do 105 | id = RDF.iri("http://example.com/#{UUID.uuid5(:oid, "FOO", :default)}") 106 | 107 | assert {:ok, ^id} = 108 | Id.Schema.generate_id( 109 | IdSpecs.VarMapping.expected_id_schema(Example.VarMappingB), 110 | %{name: "foo"} 111 | ) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/grax/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.ValidationError do 2 | @moduledoc """ 3 | Raised when the validation of a Grax fails. 4 | """ 5 | defexception [:errors, :context] 6 | 7 | @type t :: %__MODULE__{errors: list, context: any} 8 | 9 | def exception(opts \\ []) do 10 | errors = Keyword.get(opts, :errors, []) |> List.wrap() 11 | context = Keyword.get(opts, :context) 12 | %__MODULE__{errors: errors, context: context} 13 | end 14 | 15 | def message(validation_error) do 16 | "validation failed" <> 17 | if(validation_error.context, do: " in #{inspect(validation_error.context)}", else: "") <> 18 | if Enum.empty?(validation_error.errors) do 19 | "" 20 | else 21 | """ 22 | : 23 | 24 | - #{Enum.map_join(validation_error.errors, "\n- ", fn {property, error} -> "#{property}: #{Exception.message(error)}" end)} 25 | """ 26 | end 27 | end 28 | 29 | def add_error(%__MODULE__{} = validation_error, property, error) do 30 | %__MODULE__{validation_error | errors: [{property, error} | validation_error.errors]} 31 | end 32 | end 33 | 34 | defmodule Grax.Schema.TypeError do 35 | @moduledoc """ 36 | Raised when a property value doesn't match the specified type during decoding from RDF. 37 | """ 38 | defexception [:message, :type, :value] 39 | 40 | def exception(opts) do 41 | type = 42 | case Keyword.fetch!(opts, :type) do 43 | {:resource, type} -> type 44 | type -> type 45 | end 46 | 47 | value = Keyword.fetch!(opts, :value) 48 | msg = opts[:message] || "value #{inspect(value)} does not match type #{inspect(type)}" 49 | %__MODULE__{message: msg, type: type, value: value} 50 | end 51 | end 52 | 53 | defmodule Grax.Schema.CardinalityError do 54 | @moduledoc """ 55 | Raised when a the number of property values doesn't match the specified cardinality during decoding from RDF. 56 | """ 57 | defexception [:message, :cardinality, :value] 58 | 59 | def exception(opts) do 60 | cardinality = Keyword.fetch!(opts, :cardinality) 61 | value = Keyword.fetch!(opts, :value) 62 | msg = opts[:message] || "#{inspect(value)} does not match cardinality #{inspect(cardinality)}" 63 | %__MODULE__{message: msg, cardinality: cardinality, value: value} 64 | end 65 | end 66 | 67 | defmodule Grax.Schema.InvalidPropertyError do 68 | @moduledoc """ 69 | Raised when accessing a property that is not defined on a schema. 70 | """ 71 | defexception [:message, :property] 72 | 73 | def exception(opts) do 74 | property = Keyword.fetch!(opts, :property) 75 | msg = opts[:message] || "undefined property #{inspect(property)}" 76 | %__MODULE__{message: msg, property: property} 77 | end 78 | end 79 | 80 | defmodule Grax.InvalidIdError do 81 | @moduledoc """ 82 | Raised when a Grax has an invalid subject id. 83 | """ 84 | defexception [:message, :id] 85 | 86 | def exception(opts) do 87 | id = Keyword.fetch!(opts, :id) 88 | msg = opts[:message] || "invalid subject id: #{inspect(id)}" 89 | %__MODULE__{message: msg, id: id} 90 | end 91 | end 92 | 93 | defmodule Grax.InvalidValueError do 94 | @moduledoc """ 95 | Raised when an invalid literal is encountered during decoding from RDF. 96 | """ 97 | defexception [:message, :value] 98 | 99 | def exception(opts) do 100 | value = Keyword.fetch!(opts, :value) 101 | msg = opts[:message] || "invalid value: #{inspect(value)}" 102 | %__MODULE__{message: msg, value: value} 103 | end 104 | end 105 | 106 | defmodule Grax.InvalidResourceTypeError do 107 | @moduledoc """ 108 | Raised when a linked resource doesn't match any of the specified classes. 109 | """ 110 | defexception [:message, :type, :resource_types] 111 | 112 | def exception(opts) do 113 | type = Keyword.fetch!(opts, :type) 114 | resource_types = Keyword.fetch!(opts, :resource_types) |> List.wrap() 115 | 116 | msg = 117 | opts[:message] || 118 | "invalid type of linked resource: " <> 119 | case type do 120 | :no_match -> "none of the types #{inspect(resource_types)} matches" 121 | :multiple_matches -> "multiple matches for types #{inspect(resource_types)}" 122 | end 123 | 124 | %__MODULE__{message: msg, type: type, resource_types: resource_types} 125 | end 126 | end 127 | 128 | defmodule Grax.Schema.DetectionError do 129 | @moduledoc """ 130 | Raised when no schema could be detected with `Grax.load/3`. 131 | """ 132 | defexception [:candidates, :context] 133 | 134 | def message(%{candidates: nil, context: context}) do 135 | "No schema could be detected for #{context}" 136 | end 137 | 138 | def message(%{candidates: multiple, context: context}) when is_list(multiple) do 139 | "Multiple possible schemas detected for #{context}" 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grax 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/grax.svg?style=flat-square)](https://hex.pm/packages/grax) 4 | [![License](https://img.shields.io/hexpm/l/grax.svg)](https://github.com/rdf-elixir/grax/blob/master/LICENSE.md) 5 | 6 | [![ExUnit Tests](https://github.com/rdf-elixir/grax/actions/workflows/elixir-build-and-test.yml/badge.svg)](https://github.com/rdf-elixir/grax/actions/workflows/elixir-build-and-test.yml) 7 | [![Dialyzer](https://github.com/rdf-elixir/grax/actions/workflows/elixir-dialyzer.yml/badge.svg)](https://github.com/rdf-elixir/grax/actions/workflows/elixir-dialyzer.yml) 8 | [![Quality Checks](https://github.com/rdf-elixir/grax/actions/workflows/elixir-quality-checks.yml/badge.svg)](https://github.com/rdf-elixir/grax/actions/workflows/elixir-quality-checks.yml) 9 | 10 | 11 | A light-weight graph data mapper which maps RDF graph data from [RDF.ex] data structures to schema-conform Elixir structs and vice versa. 12 | 13 | For a guide and more information about Grax, and it's related projects, go to . 14 | 15 | 16 | ## Usage 17 | 18 | Let's assume we have a graph like this: 19 | 20 | ```ttl 21 | {:ok, graph} = 22 | """ 23 | @prefix : . 24 | @prefix schema: . 25 | @prefix foaf: . 26 | 27 | :User1 28 | schema:name "Jane" ; 29 | schema:email "jane@example.com" ; 30 | foaf:age 30 ; 31 | foaf:friend :User2. 32 | 33 | :Post1 34 | schema:author :User1 ; 35 | schema:name "Lorem" ; 36 | schema:articleBody """Lorem ipsum dolor sit amet, consectetur adipisicing elit. Provident, nihil, dignissimos. Nesciunt aut totam eius. Magnam quaerat modi vel sed, ipsam atque rem, eos vero ducimus beatae harum explicabo labore!""" . 37 | 38 | # ... 39 | """ 40 | |> RDF.Turtle.read_string() 41 | ``` 42 | 43 | Grax allows us to define a schema for the mapping of this kind of data to Elixir structs. 44 | 45 | ```elixir 46 | defmodule User do 47 | use Grax.Schema 48 | 49 | alias NS.{SchemaOrg, FOAF} 50 | 51 | schema SchemaOrg.Person do 52 | property name: SchemaOrg.name, type: :string 53 | property email: SchemaOrg.email, type: :string 54 | property age: FOAF.age, type: :integer 55 | 56 | link friends: FOAF.friend, type: list_of(User) 57 | link posts: -SchemaOrg.author, type: list_of(Post) 58 | 59 | field :password 60 | end 61 | end 62 | 63 | defmodule Post do 64 | use Grax.Schema 65 | 66 | alias NS.SchemaOrg 67 | 68 | schema SchemaOrg.BlogPosting do 69 | property title: SchemaOrg.name(), type: :string 70 | property content: SchemaOrg.articleBody(), type: :string 71 | 72 | link author: SchemaOrg.author(), type: User 73 | end 74 | end 75 | ``` 76 | 77 | With that we can create an instance of our `User` struct from an `RDF.Graph`. 78 | 79 | ```elixir 80 | iex> User.load(graph, EX.User1) 81 | {:ok, 82 | %User{ 83 | __id__: ~I, 84 | age: nil, 85 | email: ["jane@example.com", "jane@work.com"], 86 | friends: [], 87 | name: "Jane", 88 | password: nil, 89 | posts: [ 90 | %Post{ 91 | __id__: ~I, 92 | author: ~I, 93 | content: "Lorem ipsum dolor sit amet, consectetur adipisicing elit. Provident, nihil, dignissimos. Nesciunt aut totam eius. Magnam quaerat modi vel sed, ipsam atque rem, eos vero ducimus beatae harum explicabo labore!", 94 | title: "Lorem" 95 | } 96 | ] 97 | }} 98 | ``` 99 | 100 | And do some transformation on the struct and write it back to an RDF graph. 101 | 102 | ```elixir 103 | user 104 | |> Grax.put!(:age, user.age + 1) 105 | |> Grax.to_rdf!() 106 | |> RDF.Serialization.write_file!("user.ttl") 107 | ``` 108 | 109 | 110 | ## Future Work 111 | 112 | - I18n support (localization with language-tagged string literals) 113 | - Storage adapters (e.g. accessing SPARQL endpoints directly and support for non-RDF-based graph databases) 114 | - RDFS support (e.g. for class-based query builders) 115 | - More preloading strategies (eg. pattern- and path-based preloading) 116 | 117 | 118 | ## Contributing 119 | 120 | See [CONTRIBUTING](CONTRIBUTING.md) for details. 121 | 122 | 123 | ## Acknowledgements 124 | 125 | The development of this project was sponsored by [NetzeBW](https://www.netze-bw.de/) for [NETZlive](https://www.netze-bw.de/unsernetz/netzinnovationen/digitalisierung/netzlive). 126 | 127 | 128 | ## Consulting 129 | 130 | If you need help with your Elixir and Linked Data projects, just contact [NinjaConcept](https://www.ninjaconcept.com/) via . 131 | 132 | 133 | ## License and Copyright 134 | 135 | (c) 2020-present Marcel Otto. MIT Licensed, see [LICENSE](LICENSE.md) for details. 136 | 137 | 138 | [RDF.ex]: https://github.com/rdf-elixir/rdf-ex 139 | -------------------------------------------------------------------------------- /lib/grax/rdf/mapper.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.RDF.Mapper do 2 | @moduledoc false 3 | 4 | alias RDF.{IRI, BlankNode, Literal, XSD, Graph, Description} 5 | alias Grax.Validator 6 | alias Grax.Schema.TypeError 7 | 8 | def call(%schema{} = mapping, opts) do 9 | {validate, opts} = Keyword.pop(opts, :validate, true) 10 | 11 | opts = 12 | if id_spec = schema.__id_spec__() do 13 | Keyword.put_new_lazy(opts, :prefixes, fn -> 14 | RDF.default_prefixes() 15 | |> RDF.PrefixMap.merge!(id_spec.prefix_map(), :overwrite) 16 | end) 17 | else 18 | opts 19 | end 20 | 21 | with {:ok, mapping} <- validate(validate, mapping, opts) do 22 | schema.__properties__() 23 | |> Enum.reduce_while( 24 | {:ok, Grax.additional_statements(mapping), Graph.new(opts)}, 25 | fn {property_name, property_schema}, {:ok, description, graph} -> 26 | case Map.get(mapping, property_name) do 27 | nil -> 28 | {:cont, {:ok, description, graph}} 29 | 30 | [] -> 31 | {:cont, {:ok, description, graph}} 32 | 33 | values -> 34 | case handle(values, mapping, property_schema, opts) do 35 | {:ok, values, additions} -> 36 | {:cont, 37 | add_statements(graph, description, property_schema.iri, values, additions)} 38 | 39 | error -> 40 | {:halt, error} 41 | end 42 | end 43 | end 44 | ) 45 | |> case do 46 | {:ok, description, graph} -> {:ok, Graph.add(graph, description)} 47 | error -> error 48 | end 49 | end 50 | end 51 | 52 | defp validate(true, mapping, opts), do: Validator.call(mapping, opts) 53 | defp validate(false, mapping, _opts), do: {:ok, mapping} 54 | 55 | defp add_statements(graph, description, {:inverse, property}, values, additions) do 56 | { 57 | :ok, 58 | description, 59 | if(additions, do: Graph.add(graph, additions), else: graph) 60 | |> Graph.add( 61 | Enum.map(List.wrap(values), fn value -> 62 | {value, property, description.subject} 63 | end) 64 | ) 65 | } 66 | end 67 | 68 | defp add_statements(graph, description, property, values, additions) do 69 | { 70 | :ok, 71 | Description.add(description, {property, values}), 72 | if(additions, do: Graph.add(graph, additions), else: graph) 73 | } 74 | end 75 | 76 | defp handle(values, mapping, %{to_rdf: {mod, fun}}, _opts) do 77 | case apply(mod, fun, [values, mapping]) do 78 | {:ok, values} -> {:ok, values, nil} 79 | pass_through -> pass_through 80 | end 81 | end 82 | 83 | defp handle(values, _, property_schema, opts) do 84 | map_values(values, property_schema.type, property_schema, opts) 85 | end 86 | 87 | defp map_values(values, {:list_set, type}, property_schema, opts) when is_list(values) do 88 | Enum.reduce_while( 89 | values, 90 | {:ok, [], Graph.new()}, 91 | fn value, {:ok, mapped, graph} -> 92 | case map_values(value, type, property_schema, opts) do 93 | {:ok, mapped_value, nil} -> 94 | {:cont, {:ok, [mapped_value | mapped], graph}} 95 | 96 | {:ok, mapped_value, additions} -> 97 | {:cont, {:ok, [mapped_value | mapped], Graph.add(graph, additions)}} 98 | 99 | {:error, _} = error -> 100 | {:halt, error} 101 | end 102 | end 103 | ) 104 | end 105 | 106 | defp map_values(values, {:rdf_list, type}, property_schema, opts) when is_list(values) do 107 | with {:ok, values, graph} <- map_values(values, {:list_set, type}, property_schema, opts) do 108 | list = values |> Enum.reverse() |> RDF.List.from() 109 | {:ok, list.head, Graph.add(graph, list.graph)} 110 | end 111 | end 112 | 113 | defp map_values(value, RDF.JSON, _, _) when is_list(value) do 114 | {:ok, RDF.JSON.new(value), nil} 115 | end 116 | 117 | defp map_values(values, type, _, _) when is_list(values) do 118 | {:error, TypeError.exception(value: values, type: type)} 119 | end 120 | 121 | defp map_values(%{__id__: id} = mapping, {:resource, _}, _property_schema, opts) do 122 | with {:ok, graph} <- Grax.to_rdf(mapping, opts) do 123 | {:ok, id, graph} 124 | end 125 | end 126 | 127 | defp map_values(%IRI{} = iri, _, _, _), do: {:ok, iri, nil} 128 | defp map_values(%BlankNode{} = bnode, nil, _, _), do: {:ok, bnode, nil} 129 | defp map_values(value, nil, _, _), do: {:ok, Literal.new(value), nil} 130 | defp map_values(value, XSD.Numeric, _, _), do: {:ok, Literal.new(value), nil} 131 | defp map_values(:null, RDF.JSON, _, _), do: {:ok, RDF.JSON.new(nil), nil} 132 | defp map_values(value, RDF.JSON, _, _), do: {:ok, RDF.JSON.new(value, as_value: true), nil} 133 | defp map_values(value, type, _, _), do: {:ok, type.new(value), nil} 134 | end 135 | -------------------------------------------------------------------------------- /lib/grax/rdf/loader.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.RDF.Loader do 2 | @moduledoc false 3 | 4 | alias RDF.{Literal, IRI, BlankNode, Graph, Description} 5 | alias Grax.Schema.AdditionalStatements 6 | alias Grax.RDF.Preloader 7 | alias Grax.InvalidValueError 8 | 9 | import Grax.RDF.Access 10 | import RDF.Utils 11 | 12 | def call(schema, initial, %Graph{} = graph, opts) do 13 | id = initial.__id__ 14 | 15 | {description, opts} = Keyword.pop_lazy(opts, :description, fn -> description(graph, id) end) 16 | 17 | # TODO: Get rid of this! It's required currently for the case that the call received directly from load/4. 18 | opts = 19 | if Keyword.has_key?(opts, :depth) do 20 | Grax.setup_depth_preload_opts(opts) 21 | else 22 | opts 23 | end 24 | 25 | mapping = load_additional_statements(schema, description, initial) 26 | 27 | with {:ok, mapping} <- 28 | load_properties(schema.__properties__(:data), mapping, graph, description), 29 | {:ok, mapping} <- 30 | init_custom_fields(schema, mapping, graph, description) do 31 | Preloader.call(schema, mapping, graph, description, opts) 32 | end 33 | end 34 | 35 | def call(mapping, initial, %Description{} = description, opts) do 36 | call(mapping, initial, Graph.new(description), opts) 37 | end 38 | 39 | def call(_, _, invalid, _) do 40 | raise ArgumentError, "invalid input data: #{inspect(invalid)}" 41 | end 42 | 43 | def load_additional_statements(schema, description, initial) do 44 | if schema.__load_additional_statements__?() do 45 | AdditionalStatements.add_filtered_description( 46 | initial, 47 | description, 48 | schema.__domain_properties__() 49 | ) 50 | else 51 | initial 52 | end 53 | end 54 | 55 | def load_properties(property_schemas, initial, graph, description) do 56 | Enum.reduce_while(property_schemas, {:ok, initial}, fn 57 | {property, property_schema}, {:ok, mapping} -> 58 | case filtered_objects(graph, description, property_schema) do 59 | {:ok, objects} -> 60 | add_objects(mapping, property, objects, description, graph, property_schema) 61 | 62 | {:error, _} = error -> 63 | {:halt, error} 64 | end 65 | end) 66 | end 67 | 68 | def add_objects(mapping, property, objects, description, graph, property_schema) 69 | 70 | def add_objects(mapping, _, nil, _, _, _), do: {:cont, {:ok, mapping}} 71 | 72 | def add_objects(mapping, property, objects, description, graph, property_schema) do 73 | case handle(objects, description, graph, property_schema) do 74 | {:ok, mapped_objects} -> {:cont, {:ok, Map.put(mapping, property, mapped_objects)}} 75 | {:error, _} = error -> {:halt, error} 76 | end 77 | end 78 | 79 | defp init_custom_fields(schema, mapping, graph, description) do 80 | Enum.reduce_while(schema.__custom_fields__(), {:ok, mapping}, fn 81 | {_, %{from_rdf: nil}}, mapping -> 82 | {:cont, mapping} 83 | 84 | {field, %{from_rdf: {mod, fun}}}, {:ok, mapping} -> 85 | case apply(mod, fun, [description, graph]) do 86 | {:ok, result} -> {:cont, {:ok, Map.put(mapping, field, result)}} 87 | error -> {:halt, error} 88 | end 89 | end) 90 | end 91 | 92 | defp handle(objects, description, graph, property_schema) 93 | 94 | defp handle(objects, description, graph, %{from_rdf: {mod, fun}}) do 95 | apply(mod, fun, [objects, description, graph]) 96 | end 97 | 98 | defp handle(objects, _description, graph, property_schema) do 99 | map_values(objects, property_schema.type, graph) 100 | end 101 | 102 | defp map_values([value], {:rdf_list, _} = type, graph) do 103 | if list = RDF.List.new(value, graph) do 104 | list |> RDF.List.values() |> map_while_ok(&map_value(&1, type)) 105 | else 106 | {:error, InvalidValueError.exception(value: value, message: "ill-formed RDF list")} 107 | end 108 | end 109 | 110 | defp map_values(values, {:rdf_list, _type}, _) do 111 | {:error, 112 | InvalidValueError.exception( 113 | value: values, 114 | message: "multiple RDF lists as values are not supported yet" 115 | )} 116 | end 117 | 118 | defp map_values(values, {:list_set, _} = type, _), 119 | do: map_while_ok(values, &map_value(&1, type)) 120 | 121 | defp map_values([value], type, _), do: map_value(value, type) 122 | defp map_values(values, type, _), do: map_while_ok(values, &map_value(&1, type)) 123 | 124 | @null RDF.JSON.new(nil) 125 | defp map_value(@null, type) when not is_tuple(type), do: {:ok, :null} 126 | 127 | defp map_value(%Literal{} = literal, _) do 128 | if Literal.valid?(literal) do 129 | {:ok, Literal.value(literal)} 130 | else 131 | {:error, InvalidValueError.exception(value: literal)} 132 | end 133 | end 134 | 135 | defp map_value(%IRI{} = iri, _), do: {:ok, iri} 136 | defp map_value(%BlankNode{} = bnode, _), do: {:ok, bnode} 137 | end 138 | -------------------------------------------------------------------------------- /.github/actions/elixir-setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Elixir Project 2 | description: Checks out the code, configures Elixir, fetches dependencies, and manages build caching. 3 | inputs: 4 | elixir-version: 5 | required: true 6 | type: string 7 | description: Elixir version to set up 8 | otp-version: 9 | required: true 10 | type: string 11 | description: OTP version to set up 12 | ################################################################# 13 | # Everything below this line is optional. 14 | # 15 | # It's designed to make compiling a reasonably standard Elixir 16 | # codebase "just work," though there may be speed gains to be had 17 | # by tweaking these flags. 18 | ################################################################# 19 | build-deps: 20 | required: false 21 | type: boolean 22 | default: true 23 | description: True if we should compile dependencies 24 | build-app: 25 | required: false 26 | type: boolean 27 | default: true 28 | description: True if we should compile the application itself 29 | build-flags: 30 | required: false 31 | type: string 32 | default: '--all-warnings' 33 | description: Flags to pass to mix compile 34 | install-rebar: 35 | required: false 36 | type: boolean 37 | default: false 38 | description: By default, we will install Rebar (mix local.rebar --force). 39 | install-hex: 40 | required: false 41 | type: boolean 42 | default: false 43 | description: By default, we will install Hex (mix local.hex --force). 44 | cache-key: 45 | required: false 46 | type: string 47 | default: 'v1' 48 | description: If you need to reset the cache for some reason, you can change this key. 49 | outputs: 50 | otp-version: 51 | description: "Exact OTP version selected by the BEAM setup step" 52 | value: ${{ steps.beam.outputs.otp-version }} 53 | elixir-version: 54 | description: "Exact Elixir version selected by the BEAM setup step" 55 | value: ${{ steps.beam.outputs.elixir-version }} 56 | runs: 57 | using: "composite" 58 | steps: 59 | - name: Setup elixir 60 | uses: erlef/setup-beam@v1 61 | id: beam 62 | with: 63 | elixir-version: ${{ inputs.elixir-version }} 64 | otp-version: ${{ inputs.otp-version }} 65 | 66 | - name: Get deps cache 67 | uses: actions/cache@v4 68 | with: 69 | path: deps/ 70 | key: deps-${{ inputs.cache-key }}-${{ runner.os }}-${{ hashFiles('**/mix.lock') }} 71 | restore-keys: | 72 | deps-${{ inputs.cache-key }}-${{ runner.os }}- 73 | 74 | - name: Get build cache 75 | uses: actions/cache@v4 76 | id: build-cache 77 | with: 78 | path: _build/${{env.MIX_ENV}}/ 79 | key: build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}-${{ hashFiles('**/mix.lock') }} 80 | restore-keys: | 81 | build-${{ inputs.cache-key }}-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ env.MIX_ENV }}- 82 | 83 | - name: Get Hex cache 84 | uses: actions/cache@v4 85 | id: hex-cache 86 | with: 87 | path: ~/.hex 88 | key: build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} 89 | restore-keys: | 90 | build-${{ runner.os }}-${{ inputs.otp-version }}-${{ inputs.elixir-version }}- 91 | 92 | # In my experience, I have issues with incremental builds maybe 1 in 100 93 | # times that are fixed by doing a full recompile. 94 | # In order to not waste dev time on such trivial issues (while also reaping 95 | # the time savings of incremental builds for *most* day-to-day development), 96 | # I force a full recompile only on builds that we retry. 97 | - name: Clean to rule out incremental build as a source of flakiness 98 | if: github.run_attempt != '1' 99 | run: | 100 | mix deps.clean --all 101 | mix clean 102 | shell: sh 103 | 104 | - name: Install Rebar 105 | run: mix local.rebar --force 106 | shell: sh 107 | if: inputs.install-rebar == 'true' 108 | 109 | - name: Install Hex 110 | run: mix local.hex --force 111 | shell: sh 112 | if: inputs.install-hex == 'true' 113 | 114 | - name: Install Dependencies 115 | run: mix deps.get 116 | shell: sh 117 | 118 | # Normally we'd use `mix deps.compile` here, however that incurs a large 119 | # performance penalty when the dependencies are already fully compiled: 120 | # https://elixirforum.com/t/github-action-cache-elixir-always-recompiles-dependencies-elixir-1-13-3/45994/12 121 | # 122 | # According to Jose Valim at the above link `mix loadpaths` will check and 123 | # compile missing dependencies 124 | - name: Compile Dependencies 125 | run: mix loadpaths 126 | shell: sh 127 | if: inputs.build-deps == 'true' 128 | 129 | - name: Compile Application 130 | run: mix compile ${{ inputs.build-flags }} 131 | shell: sh 132 | if: inputs.build-app == 'true' 133 | -------------------------------------------------------------------------------- /lib/grax/id/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Schema do 2 | alias Grax.Id.Namespace 3 | alias Grax.Id.Schema.Extension 4 | 5 | alias Uniq.UUID 6 | 7 | @type template :: struct 8 | @type t :: %__MODULE__{ 9 | namespace: Namespace.t(), 10 | template: template | :bnode, 11 | schema: module | [module], 12 | selector: {module, atom} | nil, 13 | counter: {module, atom} | nil, 14 | var_mapping: {module, atom} | nil, 15 | extensions: list | nil 16 | } 17 | 18 | @enforce_keys [:namespace, :template, :schema] 19 | defstruct [:namespace, :template, :schema, :selector, :counter, :var_mapping, :extensions] 20 | 21 | @bnode_template :bnode 22 | 23 | def new(namespace, template, opts) do 24 | selector = Keyword.get(opts, :selector) 25 | schema = Keyword.get(opts, :schema) 26 | 27 | unless schema || selector do 28 | raise ArgumentError, "no :schema or :selector provided on Grax.Id.Schema" 29 | end 30 | 31 | case init_template(template) do 32 | {:ok, template} -> 33 | %__MODULE__{ 34 | namespace: namespace, 35 | template: template, 36 | schema: schema, 37 | counter: counter_tuple(namespace, opts), 38 | var_mapping: Keyword.get(opts, :var_mapping), 39 | selector: selector 40 | } 41 | |> Extension.init(Keyword.get(opts, :extensions), opts) 42 | 43 | {:error, error} -> 44 | raise error 45 | end 46 | end 47 | 48 | def new_blank_node_schema(namespace, schema) do 49 | %__MODULE__{ 50 | namespace: namespace, 51 | schema: schema, 52 | template: @bnode_template 53 | } 54 | end 55 | 56 | defp init_template(template) do 57 | YuriTemplate.parse(template) 58 | end 59 | 60 | defp counter_tuple(namespace, opts) do 61 | opts 62 | |> Keyword.get(:counter) 63 | |> counter_tuple(namespace, opts) 64 | end 65 | 66 | defp counter_tuple(nil, _, _), do: nil 67 | 68 | defp counter_tuple(name, namespace, opts) do 69 | { 70 | Keyword.get(opts, :counter_adapter) || 71 | Namespace.option(namespace, :counter_adapter) || 72 | Grax.Id.Counter.default_adapter(), 73 | name 74 | } 75 | end 76 | 77 | def generate_id(id_schema, variables, opts \\ []) 78 | 79 | def generate_id(%__MODULE__{template: @bnode_template}, _, _) do 80 | {:ok, RDF.BlankNode.new("_" <> UUID.uuid4(:hex))} 81 | end 82 | 83 | def generate_id(%__MODULE__{} = id_schema, variables, opts) when is_list(variables) do 84 | generate_id(id_schema, Map.new(variables), opts) 85 | end 86 | 87 | def generate_id(%__MODULE__{} = id_schema, %_{} = mapping, opts) do 88 | generate_id(id_schema, Map.from_struct(mapping), opts) 89 | end 90 | 91 | def generate_id(%__MODULE__{} = id_schema, variables, opts) do 92 | variables = 93 | variables 94 | |> add_schema_var(id_schema) 95 | |> add_counter_var(id_schema) 96 | 97 | with {:ok, variables} <- var_mapping(id_schema, variables), 98 | {:ok, variables} <- Extension.call(id_schema, variables, opts), 99 | {:ok, variables} <- preprocess_variables(id_schema, variables), 100 | {:ok, segment} <- YuriTemplate.expand(id_schema.template, variables) do 101 | {:ok, expand(id_schema, segment, opts)} 102 | end 103 | end 104 | 105 | def parameters(%{template: template}), do: YuriTemplate.parameters(template) 106 | 107 | defp preprocess_variables(id_schema, variables) do 108 | parameters = parameters(id_schema) 109 | 110 | parameters 111 | |> Enum.filter(fn parameter -> is_nil(Map.get(variables, parameter)) end) 112 | |> case do 113 | [] -> 114 | {:ok, 115 | variables 116 | |> Map.take(parameters) 117 | |> Map.new(fn {variable, value} -> {variable, to_string(value)} end)} 118 | 119 | missing -> 120 | {:error, "no value for id schema template parameter: #{Enum.join(missing, ", ")}"} 121 | end 122 | end 123 | 124 | defp add_schema_var(_, %{schema: nil} = id_schema) do 125 | raise "no schema found in id schema #{inspect(id_schema)}" 126 | end 127 | 128 | defp add_schema_var(variables, %{schema: schema}) do 129 | Map.put(variables, :__schema__, schema) 130 | end 131 | 132 | defp add_counter_var(variables, %{counter: nil}), do: variables 133 | 134 | defp add_counter_var(variables, %{counter: {adapter, name}}) do 135 | case adapter.inc(name) do 136 | {:ok, value} -> Map.put(variables, :counter, value) 137 | {:error, error} -> raise error 138 | end 139 | end 140 | 141 | defp var_mapping(%__MODULE__{var_mapping: {mod, fun}}, variables), 142 | do: apply(mod, fun, [variables]) 143 | 144 | defp var_mapping(_, variables), do: {:ok, variables} 145 | 146 | def expand(id_schema, id_segment, opts \\ []) 147 | 148 | def expand(%__MODULE__{} = id_schema, id_segment, opts) do 149 | expand(id_schema.namespace, id_segment, opts) 150 | end 151 | 152 | def expand(namespace, id_segment, _opts) do 153 | RDF.iri(to_string(namespace) <> id_segment) 154 | end 155 | 156 | def option(opts, key, id_schema) do 157 | Keyword.get(opts, key) || 158 | Namespace.option(id_schema.namespace, key) 159 | end 160 | 161 | def option!(opts, key, id_schema) do 162 | option(opts, key, id_schema) || 163 | raise ArgumentError, "required #{inspect(key)} keyword argument missing" 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /test/grax/schema/mapping_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.MappingTest do 2 | use Grax.TestCase 3 | 4 | doctest Grax.Schema.Mapping 5 | 6 | defmodule Person do 7 | use Grax.Schema 8 | 9 | @compile {:no_warn_undefined, Example.NS.EX} 10 | @compile {:no_warn_undefined, Example.NS.FOAF} 11 | 12 | schema FOAF.Person do 13 | property name: EX.name(), type: :string 14 | property mbox: FOAF.mbox(), type: :string 15 | property homepage: FOAF.homepage(), type: :iri 16 | 17 | field :password 18 | field :test, default: :some 19 | end 20 | end 21 | 22 | defmodule UserWithIris do 23 | use Grax.Schema 24 | 25 | @compile {:no_warn_undefined, Example.NS.EX} 26 | 27 | schema do 28 | property posts: EX.post(), type: list_of(:iri) 29 | end 30 | end 31 | 32 | @additional_user_statements [ 33 | {RDF.type(), [EX.User, EX.PremiumUser]}, 34 | {EX.age(), 42}, 35 | {EX.email(), ["jd@example.com", "john@doe.com"]}, 36 | {EX.post(), EX.Post0} 37 | ] 38 | 39 | test "maps all properties from the other schema struct" do 40 | user = Example.user(EX.User0, depth: 1) 41 | 42 | assert Person.from!(user) == 43 | Person.build!(EX.User0, name: user.name) 44 | |> Grax.add_additional_statements(@additional_user_statements) 45 | end 46 | 47 | test "maps fields from the other schema struct" do 48 | user = 49 | Example.user(EX.User0, depth: 1) 50 | |> Grax.put!(:password, "secret") 51 | 52 | assert Person.from!(user) == 53 | Person.build!(EX.User0, name: user.name, password: "secret", test: :some) 54 | |> Grax.add_additional_statements(@additional_user_statements) 55 | end 56 | 57 | test "maps values from additional_statements" do 58 | user = 59 | example_description(:user) 60 | |> FOAF.mbox("foo@bar.com") 61 | |> Example.User.load!(EX.User0) 62 | 63 | assert Person.from!(user) == 64 | Person.build!(EX.User0, name: user.name, mbox: "foo@bar.com") 65 | |> Grax.add_additional_statements(@additional_user_statements) 66 | end 67 | 68 | test "values are selected by property iri, not by name" do 69 | defmodule UserWithOtherProperties do 70 | use Grax.Schema 71 | 72 | @compile {:no_warn_undefined, Example.NS.EX} 73 | 74 | schema do 75 | property foo: EX.name(), type: :string 76 | property name: EX.otherName(), type: :string 77 | end 78 | end 79 | 80 | user = Example.user(EX.User0, depth: 1) 81 | 82 | assert UserWithOtherProperties.from!(user) == 83 | UserWithOtherProperties.build!(EX.User0, foo: user.name) 84 | |> Grax.add_additional_statements(@additional_user_statements) 85 | end 86 | 87 | test "with non-Grax schema struct input values" do 88 | assert {:error, "invalid value 42" <> _} = Person.from(42) 89 | assert {:error, "invalid value" <> _} = Person.from("user") 90 | assert {:error, "invalid value" <> _} = Person.from(~r/foo/) 91 | end 92 | 93 | describe "value mapping" do 94 | test "list with a single value are mapped to the single value, when required" do 95 | defmodule UserWithSingleEmail do 96 | use Grax.Schema 97 | 98 | @compile {:no_warn_undefined, Example.NS.EX} 99 | 100 | schema do 101 | property mail: EX.email(), type: :string 102 | end 103 | end 104 | 105 | user = 106 | Example.user(EX.User0, depth: 1) 107 | |> Grax.put!(email: "john@doe.com") 108 | 109 | assert UserWithSingleEmail.from!(user) == 110 | UserWithSingleEmail.build!(EX.User0, mail: hd(user.email)) 111 | |> Grax.add_additional_statements(@additional_user_statements) 112 | |> Grax.delete_additional_statements([ 113 | {EX.email(), ["jd@example.com", "john@doe.com"]} 114 | ]) 115 | |> Grax.add_additional_statements([{EX.name(), "John Doe"}]) 116 | end 117 | 118 | test "single values are mapped to a list, when required" do 119 | defmodule UserWithMultipleNames do 120 | use Grax.Schema 121 | 122 | @compile {:no_warn_undefined, Example.NS.EX} 123 | 124 | schema do 125 | property names: EX.name(), type: list_of(:string) 126 | end 127 | end 128 | 129 | user = Example.user(EX.User0, depth: 1) 130 | 131 | assert UserWithMultipleNames.from!(user) == 132 | UserWithMultipleNames.build!(EX.User0, names: [user.name]) 133 | |> Grax.add_additional_statements(@additional_user_statements) 134 | end 135 | end 136 | 137 | test "links are mapped to an iri, when required" do 138 | user = Example.user(EX.User0, depth: 0) 139 | 140 | assert UserWithIris.from!(user) == 141 | UserWithIris.build!(EX.User0, posts: user.posts) 142 | |> Grax.add_additional_statements(@additional_user_statements) 143 | |> Grax.add_additional_statements([{EX.name(), "John Doe"}]) 144 | |> Grax.delete_additional_statements([{EX.post(), ~I}]) 145 | 146 | user_with_links = Example.user(EX.User0, depth: 1) 147 | 148 | assert UserWithIris.from!(user_with_links) == 149 | UserWithIris.build!(EX.User0, posts: user.posts) 150 | |> Grax.add_additional_statements(@additional_user_statements) 151 | |> Grax.add_additional_statements([{EX.name(), "John Doe"}]) 152 | |> Grax.delete_additional_statements([{EX.post(), ~I}]) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/grax/id/extensions/uuid.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.UUID do 2 | use Grax.Id.Schema.Extension 3 | 4 | alias Grax.Id 5 | alias Grax.Id.UrnNamespace 6 | 7 | alias Uniq.UUID 8 | 9 | import Grax.Utils, only: [rename_keyword: 3] 10 | 11 | defstruct [:version, :format, :namespace, :name_var] 12 | 13 | defp __uuid__(opts) do 14 | opts = extension_opt(__MODULE__, opts) 15 | template = Keyword.get(opts, :template, default_template(opts)) 16 | 17 | quote do 18 | id_schema unquote(template), unquote(opts) 19 | end 20 | end 21 | 22 | defmacro uuid({{:., _, [schema, property]}, _, []}) do 23 | __uuid__(schema: schema, uuid_name_var: property) 24 | end 25 | 26 | defmacro uuid(opts) do 27 | __uuid__(opts) 28 | end 29 | 30 | defmacro uuid(schema, opts) do 31 | opts 32 | |> Keyword.put(:schema, schema) 33 | |> __uuid__() 34 | end 35 | 36 | Enum.each([1, 3, 4, 5], fn version -> 37 | name = String.to_atom("uuid#{version}") 38 | 39 | if version in [3, 5] do 40 | defmacro unquote(name)({{:., _, [schema, uuid_name_var]}, _, []}) do 41 | [schema: schema, uuid_name_var: uuid_name_var] 42 | |> normalize_opts(unquote(name), unquote(version)) 43 | |> __uuid__() 44 | end 45 | end 46 | 47 | defmacro unquote(name)(opts) when is_list(opts) do 48 | opts 49 | |> normalize_opts(unquote(name), unquote(version)) 50 | |> __uuid__() 51 | end 52 | 53 | defmacro unquote(name)(schema) do 54 | __uuid__(schema: schema, uuid_version: unquote(version)) 55 | end 56 | 57 | if version in [3, 5] do 58 | defmacro unquote(name)({{:., _, [schema, uuid_name_var]}, _, []}, opts) do 59 | opts 60 | |> Keyword.put(:schema, schema) 61 | |> Keyword.put(:uuid_name_var, uuid_name_var) 62 | |> normalize_opts(unquote(name), unquote(version)) 63 | |> __uuid__() 64 | end 65 | end 66 | 67 | defmacro unquote(name)(schema, opts) do 68 | opts 69 | |> Keyword.put(:schema, schema) 70 | |> normalize_opts(unquote(name), unquote(version)) 71 | |> __uuid__() 72 | end 73 | end) 74 | 75 | defp normalize_opts(opts, name, version) do 76 | if Keyword.has_key?(opts, :uuid_version) do 77 | raise ArgumentError, "trying to set :uuid_version on #{name}" 78 | end 79 | 80 | Keyword.put(opts, :uuid_version, version) 81 | end 82 | 83 | defp default_template(_opts), do: "{uuid}" 84 | 85 | @impl true 86 | def init(id_schema, opts) do 87 | opts = 88 | opts 89 | |> rename_keyword(:version, :uuid_version) 90 | |> rename_keyword(:format, :uuid_format) 91 | 92 | version = init_version(Id.Schema.option(opts, :uuid_version, id_schema)) 93 | format = init_format(Id.Schema.option(opts, :uuid_format, id_schema), id_schema.namespace) 94 | 95 | opts = 96 | cond do 97 | version in [1, 4] -> 98 | opts 99 | 100 | version in [3, 5] -> 101 | opts 102 | |> rename_keyword(:namespace, :uuid_namespace) 103 | |> rename_keyword(:name_var, :uuid_name_var) 104 | 105 | true -> 106 | raise ArgumentError, "invalid UUID version: #{inspect(version)}" 107 | end 108 | 109 | install( 110 | id_schema, 111 | %__MODULE__{version: version, format: format} 112 | |> init_name_params(id_schema, opts) 113 | ) 114 | end 115 | 116 | defp init_version(version) when version in [1, 3, 4, 5], do: version 117 | 118 | defp init_version(nil), 119 | do: raise(ArgumentError, "required :uuid_version keyword argument missing") 120 | 121 | defp init_version(invalid), 122 | do: raise(ArgumentError, "invalid :uuid_version: #{inspect(invalid)}") 123 | 124 | defp init_format(nil, %UrnNamespace{nid: :uuid}), do: :urn 125 | defp init_format(nil, _), do: :default 126 | defp init_format(format, %UrnNamespace{}) when format in ~w[default hex urn]a, do: format 127 | defp init_format(format, _) when format in ~w[default hex]a, do: format 128 | 129 | defp init_format(invalid, _), 130 | do: raise(ArgumentError, "invalid :uuid_format: #{inspect(invalid)}") 131 | 132 | defp init_name_params(%{version: version} = uuid_schema, id_schema, opts) 133 | when version in [3, 5] do 134 | %__MODULE__{ 135 | uuid_schema 136 | | namespace: Id.Schema.option(opts, :uuid_namespace, id_schema), 137 | name_var: Id.Schema.option(opts, :uuid_name_var, id_schema) 138 | } 139 | end 140 | 141 | defp init_name_params(%{version: version} = uuid_schema, _id_schema, opts) do 142 | if Keyword.has_key?(opts, :uuid_namespace) do 143 | raise(ArgumentError, "uuid version #{version} doesn't support name arguments") 144 | else 145 | uuid_schema 146 | end 147 | end 148 | 149 | @impl true 150 | def call(%{version: 1, format: format}, _, variables, _), 151 | do: set_uuid(variables, UUID.uuid1(format), format) 152 | 153 | def call(%{version: 4, format: format}, _, variables, _), 154 | do: set_uuid(variables, UUID.uuid4(format), format) 155 | 156 | def call( 157 | %{version: 3, format: format, namespace: namespace, name_var: variable}, 158 | _, 159 | variables, 160 | _ 161 | ) do 162 | with {:ok, name} <- get_name(variables, variable) do 163 | set_uuid(variables, UUID.uuid3(namespace, name, format), format) 164 | end 165 | end 166 | 167 | def call( 168 | %{version: 5, format: format, namespace: namespace, name_var: variable}, 169 | _, 170 | variables, 171 | _ 172 | ) do 173 | with {:ok, name} <- get_name(variables, variable) do 174 | set_uuid(variables, UUID.uuid5(namespace, name, format), format) 175 | end 176 | end 177 | 178 | defp get_name(variables, variable) do 179 | case variables[variable] do 180 | nil -> {:error, "no value for field #{inspect(variable)} for UUID name present"} 181 | name -> {:ok, to_string(name)} 182 | end 183 | end 184 | 185 | defp set_uuid(variables, "urn:uuid:" <> uuid, :urn), do: set_uuid(variables, uuid, nil) 186 | defp set_uuid(variables, uuid, _), do: {:ok, Map.put(variables, :uuid, uuid)} 187 | end 188 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "benchee": {:hex, :benchee, "1.3.1", "c786e6a76321121a44229dde3988fc772bca73ea75170a73fd5f4ddf1af95ccf", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.0", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "76224c58ea1d0391c8309a8ecbfe27d71062878f59bd41a390266bf4ac1cc56d"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, 5 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 6 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 | "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, 8 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 10 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 11 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [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", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 12 | "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, 13 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 14 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 15 | "jcs": {:hex, :jcs, "0.2.0", "e0524c23b576e8247f9f5f09d1b82cb3f92c7b132932b82d5d656461831c6c99", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f52e86571f56fab695682bf0ab7fd697768acb20de16b30809e9654d1ec1c9dd"}, 16 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 17 | "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"}, 18 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 20 | "protocol_ex": {:hex, :protocol_ex, "0.4.4", "c9717d1c0bdabe37d7653965dc02e78580d0d06a1f86d737b7941b55241f70d6", [:mix], [], "hexpm", "2b78ed0e5ec76f62b0debaf92dc8e795551cdaf7fc22b8816b3d57225020ac99"}, 21 | "rdf": {:hex, :rdf, "2.1.0", "315dea8d4b49a0d5cafa8e7685f59888b09cc2d49bb525e5bbf6c7b9ca0e5593", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:jcs, "~> 0.2", [hex: :jcs, repo: "hexpm", optional: false]}, {:protocol_ex, "~> 0.4.4", [hex: :protocol_ex, repo: "hexpm", optional: false]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "e05852a9d70360716ba1b687644935d0220fb6ff6d96f50b76ea63da53efd2ee"}, 22 | "statistex": {:hex, :statistex, "1.0.0", "f3dc93f3c0c6c92e5f291704cf62b99b553253d7969e9a5fa713e5481cd858a5", [:mix], [], "hexpm", "ff9d8bee7035028ab4742ff52fc80a2aa35cece833cf5319009b52f1b5a86c27"}, 23 | "uniq": {:hex, :uniq, "0.6.1", "369660ecbc19051be526df3aa85dc393af5f61f45209bce2fa6d7adb051ae03c", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "6426c34d677054b3056947125b22e0daafd10367b85f349e24ac60f44effb916"}, 24 | "yuri_template": {:hex, :yuri_template, "1.1.0", "04809d53daffd50bf49845568038a4a6eace30561f66a55ee2478024f2bc1abb", [:mix], [{:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5b147fe675d396d687f3902f8eb27ec498630ad3541f182985526d17ddd335c2"}, 25 | } 26 | -------------------------------------------------------------------------------- /lib/grax/schema/inheritance.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.Inheritance do 2 | @moduledoc false 3 | 4 | alias Grax.Schema.Registry 5 | alias Grax.InvalidResourceTypeError 6 | 7 | alias RDF.Description 8 | 9 | def inherit_properties(_, nil, properties), do: properties 10 | 11 | def inherit_properties(child_schema, parent_schema, properties) do 12 | parent_schema 13 | |> inherited_properties(Map.keys(properties)) 14 | |> Map.new(fn {name, property_schema} -> 15 | {name, %{property_schema | schema: child_schema}} 16 | end) 17 | |> Map.merge(properties) 18 | end 19 | 20 | defp inherited_properties([parent_schema], _) do 21 | parent_schema.__properties__() 22 | end 23 | 24 | defp inherited_properties(parent_schemas, child_properties) do 25 | Enum.reduce(parent_schemas, %{}, fn parent_schema, properties -> 26 | Map.merge(properties, Map.drop(parent_schema.__properties__(), child_properties), fn 27 | _, property1, property2 -> 28 | if Map.put(property1, :schema, nil) == Map.put(property2, :schema, nil) do 29 | property1 30 | else 31 | raise """ 32 | conflicting definitions in inherited property #{property1.name}: 33 | #{inspect(property1)} vs. 34 | #{inspect(property2)} 35 | """ 36 | end 37 | end) 38 | end) 39 | end 40 | 41 | def inherit_custom_fields(_, nil, custom_fields), do: custom_fields 42 | 43 | def inherit_custom_fields(_child_schema, parent_schema, custom_fields) do 44 | parent_schema 45 | |> inherited_custom_fields(Map.keys(custom_fields)) 46 | |> Map.merge(custom_fields) 47 | end 48 | 49 | defp inherited_custom_fields([parent_schema], _) do 50 | parent_schema.__custom_fields__() 51 | end 52 | 53 | defp inherited_custom_fields(parent_schemas, child_fields) do 54 | Enum.reduce(parent_schemas, %{}, fn parent_schema, custom_fields -> 55 | Map.merge(custom_fields, Map.drop(parent_schema.__custom_fields__(), child_fields), fn 56 | _, custom_field1, custom_field2 -> 57 | if custom_field1 == custom_field2 do 58 | custom_field1 59 | else 60 | raise """ 61 | conflicting definitions in inherited custom field #{custom_field1.name}: 62 | #{inspect(custom_field1)} vs. 63 | #{inspect(custom_field2)} 64 | """ 65 | end 66 | end) 67 | end) 68 | end 69 | 70 | def determine_schema(%Description{} = description) do 71 | description 72 | |> Description.get(RDF.type(), []) 73 | |> determine_schema() 74 | end 75 | 76 | def determine_schema([]), do: nil 77 | 78 | def determine_schema(types) do 79 | case Enum.flat_map(types, &(&1 |> Registry.schema() |> List.wrap())) do 80 | [] -> nil 81 | [schema] -> schema 82 | multiple -> most_specific_schema(multiple) 83 | end 84 | end 85 | 86 | def determine_schema(%Description{} = description, schema, property_schema) do 87 | description 88 | |> Description.get(RDF.type(), []) 89 | |> determine_schema(schema, property_schema) 90 | end 91 | 92 | def determine_schema(types, schema, property_schema) do 93 | types 94 | |> Enum.flat_map(&(&1 |> Registry.schema() |> List.wrap())) 95 | |> Enum.filter(&inherited_schema?(&1, schema)) 96 | |> case do 97 | [result_schema] -> 98 | {:ok, result_schema} 99 | 100 | [] -> 101 | case property_schema.on_rdf_type_mismatch do 102 | :force -> 103 | {:ok, schema} 104 | 105 | :ignore -> 106 | {:ok, nil} 107 | 108 | :error -> 109 | {:error, InvalidResourceTypeError.exception(type: :no_match, resource_types: types)} 110 | end 111 | 112 | multiple -> 113 | paths = Enum.flat_map(multiple, &paths_to(&1, schema)) 114 | 115 | multiple 116 | |> Enum.reject(fn candidate -> Enum.any?(paths, &(candidate in &1)) end) 117 | |> case do 118 | [result] -> 119 | {:ok, result} 120 | 121 | [] -> 122 | raise "Oops, something went fundamentally wrong. Please report this at https://github.com/rdf-elixir/grax/issues" 123 | 124 | remaining -> 125 | {:error, 126 | InvalidResourceTypeError.exception( 127 | type: :multiple_matches, 128 | resource_types: remaining 129 | )} 130 | end 131 | end 132 | end 133 | 134 | def most_specific_schema(candidates) do 135 | paths = Enum.flat_map(candidates, &paths/1) 136 | 137 | candidates 138 | |> Enum.reject(fn candidate -> Enum.any?(paths, &(candidate in &1)) end) 139 | |> case do 140 | [] -> 141 | raise "Oops, something went fundamentally wrong. Please report this at https://github.com/rdf-elixir/grax/issues" 142 | 143 | [result] -> 144 | result 145 | 146 | multiple -> 147 | multiple 148 | end 149 | end 150 | 151 | def inherited_schema?(schema, root) 152 | def inherited_schema?(schema, schema), do: true 153 | def inherited_schema?(nil, _), do: false 154 | 155 | def inherited_schema?(schemas, root_schema) when is_list(schemas) do 156 | Enum.any?(schemas, &inherited_schema?(&1, root_schema)) 157 | end 158 | 159 | def inherited_schema?(schema, root_schema) do 160 | inherited_schema?(schema.__super__(), root_schema) 161 | end 162 | 163 | def matches_rdf_types?(%Description{} = description, schema) do 164 | description 165 | |> RDF.Description.get(RDF.type(), []) 166 | |> matches_rdf_types?(schema) 167 | end 168 | 169 | def matches_rdf_types?(rdf_types, schema) do 170 | rdf_types 171 | |> Enum.flat_map(&(&1 |> Registry.schema() |> List.wrap())) 172 | |> Enum.any?(&inherited_schema?(&1, schema)) 173 | end 174 | 175 | def paths(schema) do 176 | if parent_schemas = schema.__super__() do 177 | Enum.flat_map(parent_schemas, fn parent_schema -> 178 | case paths(parent_schema) do 179 | [] -> [[parent_schema]] 180 | paths -> Enum.map(paths, &[parent_schema | &1]) 181 | end 182 | end) 183 | else 184 | [] 185 | end 186 | end 187 | 188 | def paths_to(root, root), do: [] 189 | 190 | def paths_to(schema, root) do 191 | parent_schemas = schema.__super__() 192 | 193 | cond do 194 | parent_schemas -> 195 | Enum.flat_map(parent_schemas, fn parent_schema -> 196 | case paths_to(parent_schema, root) do 197 | nil -> [] 198 | [] -> [[parent_schema]] 199 | paths -> Enum.map(paths, &[parent_schema | &1]) 200 | end 201 | end) 202 | 203 | schema != root -> 204 | nil 205 | 206 | true -> 207 | [] 208 | end 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /lib/grax/id/spec.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.Spec do 2 | @moduledoc """ 3 | A DSL for the specification of identifier schemas for `Grax.Schema`s. 4 | """ 5 | 6 | alias Grax.Id.{Namespace, UrnNamespace} 7 | 8 | defmacro __using__(opts) do 9 | quote do 10 | import unquote(__MODULE__) 11 | 12 | @before_compile unquote(__MODULE__) 13 | 14 | @options unquote(opts) 15 | 16 | Module.register_attribute(__MODULE__, :namespaces, accumulate: true) 17 | Module.register_attribute(__MODULE__, :id_schemas, accumulate: true) 18 | Module.register_attribute(__MODULE__, :custom_id_schema_selectors, accumulate: true) 19 | 20 | @base_namespace nil 21 | @parent_namespace nil 22 | end 23 | end 24 | 25 | defmacro __before_compile__(_env) do 26 | quote do 27 | def namespaces, do: @namespaces 28 | def base_namespace, do: @base_namespace 29 | 30 | if @base_namespace do 31 | def base_iri, do: RDF.iri(@base_namespace.uri) 32 | else 33 | def base_iri, do: nil 34 | end 35 | 36 | def id_schemas, do: @id_schemas 37 | 38 | @id_schema_index unquote(__MODULE__).id_schema_index(@id_schemas) 39 | def id_schema(schema), do: @id_schema_index[schema] 40 | 41 | def custom_id_schema_selectors, do: @custom_id_schema_selectors 42 | 43 | @prefix_map @namespaces 44 | |> Enum.reject(&is_nil(&1.prefix)) 45 | |> Map.new(&{&1.prefix, Namespace.uri(&1)}) 46 | |> RDF.PrefixMap.new() 47 | def prefix_map, do: @prefix_map 48 | end 49 | end 50 | 51 | @doc false 52 | def id_schema_index(id_schemas) do 53 | Enum.flat_map(id_schemas, fn 54 | %{schema: schemas} = id_schema when is_list(schemas) -> 55 | Enum.map(schemas, &{&1, %{id_schema | schema: &1}}) 56 | 57 | %{schema: schema} = id_schema -> 58 | [{schema, id_schema}] 59 | end) 60 | |> Map.new() 61 | end 62 | 63 | defmacro namespace(segment, opts, do_block) 64 | 65 | defmacro namespace(segment, opts, do: block) do 66 | {segment, absolute?} = 67 | case segment do 68 | {:__aliases__, _, _} = vocab_namespace -> 69 | {vocab_namespace 70 | |> Macro.expand(__CALLER__) 71 | |> apply(:__base_iri__, []), true} 72 | 73 | segment -> 74 | {segment, nil} 75 | end 76 | 77 | quote do 78 | if @parent_namespace && unquote(absolute?) do 79 | raise ArgumentError, "absolute URIs are only allowed on the top-level namespace" 80 | end 81 | 82 | previous_parent_namespace = @parent_namespace 83 | 84 | namespace = 85 | Namespace.new( 86 | previous_parent_namespace, 87 | unquote(segment), 88 | if @parent_namespace do 89 | unquote(opts) 90 | else 91 | Keyword.merge(@options, unquote(opts)) 92 | end 93 | ) 94 | 95 | @namespaces namespace 96 | @parent_namespace namespace 97 | 98 | unquote(block) 99 | 100 | @parent_namespace previous_parent_namespace 101 | end 102 | end 103 | 104 | defmacro namespace(segment, do: block) do 105 | quote do 106 | namespace(unquote(segment), [], do: unquote(block)) 107 | end 108 | end 109 | 110 | defmacro namespace(segment, opts) do 111 | quote do 112 | namespace(unquote(segment), unquote(opts), do: nil) 113 | end 114 | end 115 | 116 | defmacro base(segment, opts, do_block) 117 | 118 | defmacro base(segment, opts, do: block) do 119 | quote do 120 | if @base_namespace do 121 | raise "already a base namespace defined: #{Namespace.uri(@base_namespace)}" 122 | end 123 | 124 | namespace(unquote(segment), unquote(opts), do: unquote(block)) 125 | @base_namespace List.first(@namespaces) 126 | end 127 | end 128 | 129 | defmacro base(segment, do: block) do 130 | quote do 131 | base(unquote(segment), [], do: unquote(block)) 132 | end 133 | end 134 | 135 | defmacro base(segment, opts) do 136 | quote do 137 | base(unquote(segment), unquote(opts), do: nil) 138 | end 139 | end 140 | 141 | defmacro blank_node(schema) do 142 | quote do 143 | @id_schemas Grax.Id.Schema.new_blank_node_schema(@parent_namespace, unquote(schema)) 144 | end 145 | end 146 | 147 | defmacro urn(nid, opts \\ [], do_block) 148 | 149 | defmacro urn(nid, opts, do: block) do 150 | quote do 151 | if @parent_namespace do 152 | raise "urn namespaces can only be defined on the top-level" 153 | end 154 | 155 | namespace = UrnNamespace.new(unquote(nid), unquote(opts)) 156 | 157 | @namespaces namespace 158 | @parent_namespace namespace 159 | 160 | unquote(block) 161 | 162 | @parent_namespace nil 163 | end 164 | end 165 | 166 | defmacro id_schema(template, opts) do 167 | opts = 168 | case Keyword.get(opts, :var_mapping) do 169 | nil -> opts 170 | name when is_atom(name) -> Keyword.put(opts, :var_mapping, {__CALLER__.module, name}) 171 | _ -> opts 172 | end 173 | 174 | {opts, custom_selector} = 175 | case Keyword.get(opts, :selector) do 176 | nil -> 177 | {opts, nil} 178 | 179 | name when is_atom(name) -> 180 | custom_selector = {__CALLER__.module, name} 181 | {Keyword.put(opts, :selector, custom_selector), custom_selector} 182 | 183 | custom_selector -> 184 | {opts, custom_selector} 185 | end 186 | 187 | quote do 188 | if Enum.find(@custom_id_schema_selectors, fn {existing, _} -> 189 | existing == unquote(custom_selector) 190 | end) do 191 | raise ArgumentError, 192 | "custom selector #{inspect(unquote(custom_selector))} is already used for another id schema" 193 | end 194 | 195 | id_schema = Grax.Id.Schema.new(@parent_namespace, unquote(template), unquote(opts)) 196 | @id_schemas id_schema 197 | if unquote(custom_selector) do 198 | @custom_id_schema_selectors {unquote(custom_selector), id_schema} 199 | end 200 | end 201 | end 202 | 203 | defmacro id(schema_with_property) do 204 | quote do 205 | id unquote(schema_with_property), [] 206 | end 207 | end 208 | 209 | defmacro id({{:., _, [schema, property]}, _, []}, opts) do 210 | quote do 211 | id unquote(schema), "{#{unquote(property)}}", unquote(opts) 212 | end 213 | end 214 | 215 | defmacro id(schema, template) when is_binary(template) do 216 | quote do 217 | id unquote(schema), unquote(template), [] 218 | end 219 | end 220 | 221 | defmacro id(schema, template, opts) do 222 | opts = Keyword.put(opts, :schema, schema) 223 | 224 | quote do 225 | id_schema unquote(template), unquote(opts) 226 | end 227 | end 228 | 229 | def custom_select_id_schema(spec, schema, attributes) do 230 | Enum.find_value(spec.custom_id_schema_selectors(), fn {{mod, fun}, id_schema} -> 231 | apply(mod, fun, [schema, attributes]) && %{id_schema | schema: schema} 232 | end) 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /test/grax/json_property_test.exs: -------------------------------------------------------------------------------- 1 | if String.to_integer(System.otp_release()) >= 25 do 2 | defmodule Grax.JsonPropertyTest do 3 | use Grax.TestCase 4 | 5 | alias Grax.ValidationError 6 | alias Grax.Schema.{TypeError, CardinalityError} 7 | 8 | @valid_values [ 9 | "foo", 10 | 42, 11 | true, 12 | false, 13 | [1, 2, 3], 14 | %{"a" => 1}, 15 | %{ 16 | "array" => [1, "two", true], 17 | "object" => %{"nested" => "value"}, 18 | "null" => nil 19 | } 20 | ] 21 | 22 | @invalid_values [ 23 | :foo, 24 | [:bar], 25 | %{%{a: 1} => 2} 26 | ] 27 | 28 | test "Grax.build!/2" do 29 | assert Example.JsonType.build!(EX.Foo) == 30 | %Example.JsonType{ 31 | __id__: IRI.new(EX.Foo), 32 | foo: nil, 33 | bar: [] 34 | } 35 | 36 | Enum.each(@valid_values, fn valid_value -> 37 | assert Example.JsonType.build!(EX.Foo, foo: valid_value) == 38 | %Example.JsonType{ 39 | __id__: IRI.new(EX.Foo), 40 | foo: valid_value, 41 | bar: [] 42 | } 43 | end) 44 | end 45 | 46 | describe "Grax.put/2" do 47 | test "with valid values" do 48 | Enum.each(@valid_values, fn valid_value -> 49 | assert Example.JsonType.build!(EX.Foo) 50 | |> Grax.put(:foo, valid_value) == 51 | {:ok, 52 | %Example.JsonType{ 53 | __id__: IRI.new(EX.Foo), 54 | foo: valid_value 55 | }} 56 | 57 | assert Example.JsonType.build!(EX.Foo) 58 | |> Grax.put(:bar, [valid_value]) == 59 | {:ok, 60 | %Example.JsonType{ 61 | __id__: IRI.new(EX.Foo), 62 | bar: [valid_value] 63 | }} 64 | end) 65 | end 66 | 67 | test "with null value" do 68 | assert Example.JsonType.build!(EX.Foo) 69 | |> Grax.put(:foo, :null) == 70 | {:ok, 71 | %Example.JsonType{ 72 | __id__: IRI.new(EX.Foo), 73 | foo: :null 74 | }} 75 | end 76 | 77 | test "with nil value" do 78 | assert Example.JsonType.build!(EX.Foo) 79 | |> Grax.put(:foo, nil) == 80 | {:ok, 81 | %Example.JsonType{ 82 | __id__: IRI.new(EX.Foo), 83 | foo: nil 84 | }} 85 | 86 | assert Example.JsonType.build!(EX.Foo) 87 | |> Grax.put(:bar, nil) == 88 | {:ok, 89 | %Example.JsonType{ 90 | __id__: IRI.new(EX.Foo), 91 | bar: [] 92 | }} 93 | end 94 | 95 | test "with invalid values" do 96 | Enum.each(@invalid_values, fn invalid_value -> 97 | assert {:error, %TypeError{}} = 98 | Example.JsonType.build!(EX.Foo) 99 | |> Grax.put(:foo, invalid_value) 100 | 101 | assert {:error, %TypeError{}} = 102 | Example.JsonType.build!(EX.Foo) 103 | |> Grax.put(:bar, [invalid_value]) 104 | end) 105 | end 106 | end 107 | 108 | describe "load/2" do 109 | test "with valid values" do 110 | Enum.each(@valid_values, fn value -> 111 | assert Graph.new() 112 | |> Graph.add({EX.S, EX.foo(), RDF.JSON.new(value, as_value: true)}) 113 | |> Example.JsonType.load(EX.S) == 114 | Example.JsonType.build(EX.S, foo: value) 115 | 116 | assert Graph.new() 117 | |> Graph.add({EX.S, EX.bar(), RDF.JSON.new(value, as_value: true)}) 118 | |> Example.JsonType.load(EX.S) == 119 | Example.JsonType.build(EX.S, bar: [value]) 120 | 121 | assert Graph.new() 122 | |> Graph.add({EX.S, EX.foo(), RDF.JSON.new(value, as_value: true)}) 123 | |> Graph.add({EX.S, EX.bar(), RDF.JSON.new(value, as_value: true)}) 124 | |> Example.JsonTypeRequired.load(EX.S) == 125 | Example.JsonTypeRequired.build(EX.S, foo: value, bar: [value]) 126 | end) 127 | end 128 | 129 | test "with null value" do 130 | assert Graph.new() 131 | |> Graph.add({EX.S, EX.foo(), RDF.JSON.new(nil)}) 132 | |> Example.JsonType.load(EX.S) == 133 | Example.JsonType.build(EX.S, foo: :null) 134 | 135 | assert Graph.new() 136 | |> Graph.add({EX.S, EX.bar(), RDF.JSON.new(nil)}) 137 | |> Example.JsonType.load(EX.S) == 138 | Example.JsonType.build(EX.S, bar: [nil]) 139 | 140 | assert Graph.new() 141 | |> Graph.add({EX.S, EX.foo(), RDF.JSON.new(nil)}) 142 | |> Graph.add({EX.S, EX.bar(), RDF.JSON.new(nil)}) 143 | |> Example.JsonTypeRequired.load(EX.S) == 144 | Example.JsonTypeRequired.build(EX.S, foo: :null, bar: [nil]) 145 | end 146 | 147 | test "without value" do 148 | assert Graph.new() |> Example.JsonType.load(EX.S) == 149 | Example.JsonType.build(EX.S, foo: nil, bar: []) 150 | 151 | assert { 152 | :error, 153 | %ValidationError{ 154 | errors: [foo: %CardinalityError{cardinality: 1, value: nil}] 155 | } 156 | } = 157 | Graph.new() 158 | |> Graph.add({EX.S, EX.bar(), RDF.JSON.new(nil)}) 159 | |> Example.JsonTypeRequired.load(EX.S) 160 | 161 | assert { 162 | :error, 163 | %ValidationError{ 164 | errors: [bar: %CardinalityError{cardinality: {:min, 1}, value: []}] 165 | } 166 | } = 167 | Graph.new() 168 | |> Graph.add({EX.S, EX.foo(), RDF.JSON.new(nil)}) 169 | |> Example.JsonTypeRequired.load(EX.S) 170 | end 171 | end 172 | 173 | describe "Grax.to_rdf/2" do 174 | test "with valid value" do 175 | Enum.each(@valid_values, fn value -> 176 | assert Example.JsonType.build!(EX.S, foo: value) 177 | |> Grax.to_rdf() == 178 | {:ok, 179 | EX.S 180 | |> EX.foo(RDF.JSON.new(value, as_value: true)) 181 | |> RDF.graph()} 182 | 183 | assert Example.JsonType.build!(EX.S, bar: [value]) 184 | |> Grax.to_rdf() == 185 | {:ok, 186 | EX.S 187 | |> EX.bar(RDF.JSON.new(value, as_value: true)) 188 | |> RDF.graph()} 189 | end) 190 | end 191 | 192 | test "with null value" do 193 | assert Example.JsonType.build!(EX.S, foo: :null) 194 | |> Grax.to_rdf() == 195 | {:ok, 196 | EX.S 197 | |> EX.foo(RDF.JSON.new(nil)) 198 | |> RDF.graph()} 199 | end 200 | 201 | test "without value" do 202 | assert Example.JsonType.build!(EX.S, foo: nil, bar: []) 203 | |> Grax.to_rdf() == 204 | {:ok, RDF.graph()} 205 | end 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /lib/grax/rdf/preloader.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.RDF.Preloader do 2 | @moduledoc false 3 | 4 | alias Grax.RDF.Loader 5 | alias Grax.Schema.LinkProperty 6 | alias Grax.InvalidValueError 7 | alias RDF.Description 8 | 9 | import Grax.RDF.Access 10 | import RDF.Guards 11 | import RDF.Utils 12 | 13 | defmodule Error do 14 | defexception [:message] 15 | end 16 | 17 | @default {:depth, 1} 18 | 19 | def default, do: @default 20 | 21 | def call(schema, mapping, graph, opts) do 22 | call(schema, mapping, graph, description(graph, mapping.__id__), opts) 23 | end 24 | 25 | def call(schema, mapping, graph, description, opts) do 26 | graph_load_path = Keyword.get(opts, :__graph_load_path__, []) 27 | depth = length(graph_load_path) 28 | graph_load_path = [mapping.__id__ | graph_load_path] 29 | opts = Keyword.put(opts, :__graph_load_path__, graph_load_path) 30 | link_schemas = schema.__properties__(:link) 31 | 32 | Enum.reduce_while(link_schemas, {:ok, mapping}, fn {link, link_schema}, {:ok, mapping} -> 33 | {preload?, next_preload_opt, max_preload_depth} = 34 | next_preload_opt( 35 | Keyword.get(opts, :preload), 36 | link_schema.preload, 37 | schema, 38 | link, 39 | depth, 40 | Keyword.get(opts, :__max_preload_depth__) 41 | ) 42 | 43 | if preload? do 44 | objects = objects(graph, description, link_schema.iri) 45 | 46 | cond do 47 | is_nil(objects) -> 48 | {:cont, {:ok, mapping}} 49 | 50 | # The circle check is not needed when preload opts are given as their finite depth 51 | # overwrites any additive preload depths of properties which may cause infinite preloads 52 | is_nil(next_preload_opt) and circle?(objects, graph_load_path) -> 53 | Loader.add_objects(mapping, link, objects, description, graph, link_schema) 54 | 55 | true -> 56 | opts = 57 | if next_preload_opt do 58 | Keyword.put(opts, :preload, next_preload_opt) 59 | else 60 | opts 61 | end 62 | |> Keyword.put(:__max_preload_depth__, max_preload_depth) 63 | 64 | handle(link, objects, description, graph, link_schema, opts) 65 | |> case do 66 | {:ok, mapped_objects} -> 67 | {:cont, {:ok, Map.put(mapping, link, mapped_objects)}} 68 | 69 | {:error, _} = error -> 70 | {:halt, error} 71 | end 72 | end 73 | else 74 | Loader.load_properties([{link, link_schema}], mapping, graph, description) 75 | |> case do 76 | {:ok, _} = ok_mapping -> {:cont, ok_mapping} 77 | {:error, _} = error -> {:halt, error} 78 | end 79 | end 80 | end) 81 | end 82 | 83 | def next_preload_opt(nil, nil, schema, link, depth, max_depth) do 84 | next_preload_opt( 85 | nil, 86 | schema.__preload_default__() || @default, 87 | schema, 88 | link, 89 | depth, 90 | max_depth 91 | ) 92 | end 93 | 94 | def next_preload_opt(nil, {:depth, max_depth}, _mapping_mod, _link, 0, _max_depth) do 95 | {max_depth > 0, nil, max_depth} 96 | end 97 | 98 | def next_preload_opt(nil, {:depth, _}, _mapping_mod, _link, depth, max_depth) do 99 | {max_depth - depth > 0, nil, max_depth} 100 | end 101 | 102 | def next_preload_opt(nil, {:add_depth, add_depth}, _mapping_mod, _link, depth, _max_depth) do 103 | new_depth = depth + add_depth 104 | {new_depth - depth > 0, nil, new_depth} 105 | end 106 | 107 | def next_preload_opt(nil, depth, _mapping_mod, _link, _depth, _max_depth), 108 | do: raise(ArgumentError, "invalid depth: #{inspect(depth)}") 109 | 110 | def next_preload_opt( 111 | {:depth, max_depth} = depth_tuple, 112 | preload_spec, 113 | schema, 114 | _link, 115 | depth, 116 | parent_max_depth 117 | ) do 118 | {max_depth - depth > 0, depth_tuple, max_depth(parent_max_depth, preload_spec, schema, depth)} 119 | end 120 | 121 | def next_preload_opt({:add_depth, add_depth}, preload_spec, schema, _, depth, _) do 122 | new_depth = depth + add_depth 123 | 124 | {new_depth - depth > 0, {:depth, new_depth}, 125 | max_depth(new_depth, preload_spec, schema, depth)} 126 | end 127 | 128 | defp max_depth(_, {:depth, max_depth}, _, 0), do: max_depth 129 | defp max_depth(_, {:add_depth, add_depth}, _, depth), do: depth + add_depth 130 | defp max_depth(max_depth, _, _, _) when is_integer(max_depth), do: max_depth 131 | 132 | defp max_depth(nil, nil, schema, depth), 133 | do: max_depth(nil, schema.__preload_default__() || @default, nil, depth) 134 | 135 | defp circle?(objects, graph_load_path) do 136 | Enum.any?(objects, &(&1 in graph_load_path)) 137 | end 138 | 139 | defp handle(property, objects, description, graph, property_schema, opts) 140 | 141 | defp handle(_property, objects, _description, graph, property_schema, opts) do 142 | map_links(objects, property_schema.type, property_schema, graph, opts) 143 | end 144 | 145 | defp map_links(values, {:list_set, type}, property_schema, graph, opts) do 146 | with {:ok, mapped} <- map_links(values, type, property_schema, graph, opts) do 147 | {:ok, List.wrap(mapped)} 148 | end 149 | end 150 | 151 | defp map_links([value], {:rdf_list, _type}, property_schema, graph, opts) do 152 | if list = RDF.List.new(value, graph) do 153 | list 154 | |> RDF.List.values() 155 | |> map_while_ok(&map_link(&1, property_schema, graph, opts)) 156 | else 157 | {:error, InvalidValueError.exception(value: value, message: "ill-formed RDF list")} 158 | end 159 | end 160 | 161 | defp map_links(values, {:rdf_list, _type}, _property_schema, _graph, _opts) do 162 | {:error, 163 | InvalidValueError.exception( 164 | value: values, 165 | message: "multiple RDF lists as values are not supported yet" 166 | )} 167 | end 168 | 169 | defp map_links([value], _type, property_schema, graph, opts) do 170 | map_link(value, property_schema, graph, opts) 171 | end 172 | 173 | defp map_links(values, _type, property_schema, graph, opts) do 174 | Enum.reduce_while(values, {:ok, []}, fn value, {:ok, mapped} -> 175 | case map_link(value, property_schema, graph, opts) do 176 | {:ok, nil} -> {:cont, {:ok, mapped}} 177 | {:ok, mapping} -> {:cont, {:ok, [mapping | mapped]}} 178 | error -> {:halt, error} 179 | end 180 | end) 181 | |> case do 182 | {:ok, []} -> {:ok, nil} 183 | {:ok, [mapped]} -> {:ok, mapped} 184 | {:ok, mapped} -> {:ok, Enum.reverse(mapped)} 185 | error -> error 186 | end 187 | end 188 | 189 | defp map_link(resource, property_schema, _graph, _opts) 190 | when not is_rdf_resource(resource) do 191 | {:error, 192 | Error.exception( 193 | "unable to preload #{inspect(property_schema.name)} of #{inspect(property_schema.schema)} from value #{inspect(resource)}" 194 | )} 195 | end 196 | 197 | defp map_link(resource, property_schema, graph, opts) do 198 | description = description(graph, resource) 199 | 200 | if Description.empty?(description) && property_schema.on_missing_description == :use_rdf_node do 201 | {:ok, resource} 202 | else 203 | case LinkProperty.determine_schema(property_schema, description) do 204 | {:ok, nil} -> 205 | {:ok, nil} 206 | 207 | {:ok, schema} -> 208 | schema.load(graph, resource, Keyword.put(opts, :description, description)) 209 | 210 | error -> 211 | error 212 | end 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /lib/grax/validator.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Validator do 2 | @moduledoc false 3 | 4 | alias Grax.{ValidationError, InvalidIdError} 5 | alias Grax.Schema.{Inheritance, TypeError, CardinalityError} 6 | alias Grax.Schema.LinkProperty.Union 7 | alias RDF.{IRI, BlankNode, Literal, XSD} 8 | 9 | import Grax.Schema.Type 10 | import ValidationError, only: [add_error: 3] 11 | 12 | def call(mapping, opts) do 13 | ValidationError.exception(context: Map.get(mapping, :__id__)) 14 | |> check_subject_iri(mapping, opts) 15 | |> check_properties(mapping, opts) 16 | |> check_links(mapping, opts) 17 | |> case do 18 | %{errors: []} -> {:ok, mapping} 19 | validation -> {:error, validation} 20 | end 21 | end 22 | 23 | defp check_subject_iri(validation, %{__id__: %IRI{}}, _), do: validation 24 | defp check_subject_iri(validation, %{__id__: %BlankNode{}}, _), do: validation 25 | 26 | defp check_subject_iri(validation, %{__id__: id}, _) do 27 | add_error(validation, :__id__, InvalidIdError.exception(id: id)) 28 | end 29 | 30 | defp check_properties(validation, %schema{} = mapping, opts) do 31 | schema.__properties__(:data) 32 | |> Enum.reduce(validation, fn {property, property_schema}, validation -> 33 | value = Map.get(mapping, property) 34 | check_property(validation, property, value, property_schema, opts) 35 | end) 36 | end 37 | 38 | defp check_links(validation, %schema{} = mapping, opts) do 39 | schema.__properties__(:link) 40 | |> Enum.reduce(validation, fn {link, link_schema}, validation -> 41 | value = Map.get(mapping, link) 42 | check_link(validation, link, value, link_schema, opts) 43 | end) 44 | end 45 | 46 | @doc false 47 | def check_property(validation, property, value, property_schema, opts) do 48 | type = property_schema.type 49 | 50 | validation 51 | |> check_cardinality(property, value, type, property_schema.cardinality) 52 | |> check_datatype(property, value, type, opts) 53 | end 54 | 55 | @doc false 56 | def check_link(validation, link, value, link_schema, opts) do 57 | type = link_schema.type 58 | 59 | validation 60 | |> check_cardinality(link, value, type, link_schema.cardinality) 61 | |> check_resource_type(link, value, type, link_schema, opts) 62 | end 63 | 64 | defp check_cardinality(validation, property, values, {list_type, _}, cardinality) 65 | when is_list(values) and is_list_type(list_type) do 66 | count = length(values) 67 | 68 | case cardinality do 69 | nil -> 70 | validation 71 | 72 | {:min, cardinality} when count >= cardinality -> 73 | validation 74 | 75 | cardinality when is_integer(cardinality) and count == cardinality -> 76 | validation 77 | 78 | %Range{first: min, last: max} when count >= min and count <= max -> 79 | validation 80 | 81 | _ -> 82 | add_error( 83 | validation, 84 | property, 85 | CardinalityError.exception(cardinality: cardinality, value: values) 86 | ) 87 | end 88 | end 89 | 90 | defp check_cardinality(validation, property, value, {list_type, _} = type, _) 91 | when is_list_type(list_type) do 92 | add_error(validation, property, TypeError.exception(value: value, type: type)) 93 | end 94 | 95 | defp check_cardinality(validation, property, value, type, _) 96 | when is_list(value) and type != RDF.JSON do 97 | add_error(validation, property, TypeError.exception(value: value, type: type)) 98 | end 99 | 100 | defp check_cardinality(validation, property, nil, _, 1) do 101 | add_error(validation, property, CardinalityError.exception(cardinality: 1, value: nil)) 102 | end 103 | 104 | defp check_cardinality(validation, _, _, _, _), do: validation 105 | 106 | defp check_datatype(validation, _, _, nil, _), do: validation 107 | defp check_datatype(validation, _, nil, _, _), do: validation 108 | defp check_datatype(validation, _, [], _, _), do: validation 109 | 110 | defp check_datatype(validation, property, values, {list_type, type}, opts) 111 | when is_list_type(list_type) do 112 | check_datatype(validation, property, values, type, opts) 113 | end 114 | 115 | defp check_datatype(validation, property, values, type, opts) when is_list(values) do 116 | Enum.reduce(values, validation, &check_datatype(&2, property, &1, type, opts)) 117 | end 118 | 119 | defp check_datatype(validation, property, value, type, _opts) do 120 | if value |> in_value_space?(type) do 121 | validation 122 | else 123 | add_error(validation, property, TypeError.exception(value: value, type: type)) 124 | end 125 | end 126 | 127 | defp in_value_space?(value, nil), do: value |> Literal.new() |> Literal.valid?() 128 | defp in_value_space?(%BlankNode{}, _), do: false 129 | defp in_value_space?(%IRI{}, IRI), do: true 130 | defp in_value_space?(_, IRI), do: false 131 | defp in_value_space?(value, XSD.String), do: is_binary(value) 132 | defp in_value_space?(%URI{}, XSD.AnyURI), do: true 133 | defp in_value_space?(_, XSD.AnyURI), do: false 134 | defp in_value_space?(value, XSD.Boolean), do: is_boolean(value) 135 | defp in_value_space?(value, XSD.Integer), do: is_integer(value) 136 | defp in_value_space?(value, XSD.Float), do: is_float(value) 137 | defp in_value_space?(value, XSD.Double), do: is_float(value) 138 | defp in_value_space?(%Decimal{}, XSD.Decimal), do: true 139 | defp in_value_space?(_, XSD.Decimal), do: false 140 | defp in_value_space?(%Decimal{}, XSD.Numeric), do: true 141 | defp in_value_space?(value, XSD.Numeric), do: is_number(value) 142 | defp in_value_space?(:null, RDF.JSON), do: true 143 | 144 | defp in_value_space?(value, type) do 145 | # credo:disable-for-this-file Credo.Check.Refactor.CondStatements 146 | cond do 147 | XSD.Numeric.datatype?(type) -> is_number(value) or match?(%Decimal{}, value) 148 | true -> true 149 | end 150 | |> if do 151 | value |> type.new(as_value: true) |> Literal.valid?() 152 | end 153 | end 154 | 155 | defp check_resource_type(validation, _, %IRI{}, {:resource, _}, _, _), do: validation 156 | defp check_resource_type(validation, _, %BlankNode{}, {:resource, _}, _, _), do: validation 157 | defp check_resource_type(validation, _, nil, _, _, _), do: validation 158 | defp check_resource_type(validation, _, [], _, _, _), do: validation 159 | 160 | defp check_resource_type(validation, link, values, {list_type, type}, link_schema, opts) 161 | when is_list_type(list_type) do 162 | check_resource_type(validation, link, values, type, link_schema, opts) 163 | end 164 | 165 | defp check_resource_type(validation, link, values, type, link_schema, opts) 166 | when is_list(values) do 167 | Enum.reduce(values, validation, &check_resource_type(&2, link, &1, type, link_schema, opts)) 168 | end 169 | 170 | defp check_resource_type( 171 | validation, 172 | link, 173 | %type{} = value, 174 | {:resource, base_type}, 175 | link_schema, 176 | opts 177 | ) do 178 | if resource_type_matches?(type, base_type, link_schema) do 179 | case call(value, opts) do 180 | {:ok, _} -> validation 181 | {:error, nested_validation} -> add_error(validation, link, nested_validation) 182 | end 183 | else 184 | add_error(validation, link, TypeError.exception(value: value, type: base_type)) 185 | end 186 | end 187 | 188 | defp check_resource_type(validation, link, value, type, _link_schema, _opts) do 189 | add_error(validation, link, TypeError.exception(value: value, type: type)) 190 | end 191 | 192 | defp resource_type_matches?(schema, %Union{types: class_mapping}, link_schema) do 193 | if link_schema.polymorphic do 194 | class_mapping 195 | |> Map.values() 196 | |> Enum.any?(&Inheritance.inherited_schema?(schema, &1)) 197 | else 198 | schema in Map.values(class_mapping) 199 | end 200 | end 201 | 202 | defp resource_type_matches?(schema, resource_type, link_schema) do 203 | if link_schema.polymorphic do 204 | Inheritance.inherited_schema?(schema, resource_type) 205 | else 206 | schema == resource_type 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /lib/grax/schema/property.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema.Property do 2 | @moduledoc false 3 | 4 | alias Grax.Schema.Type 5 | import Grax.Schema.Type 6 | 7 | @shared_attrs [:schema, :name, :iri, :type, :cardinality] 8 | 9 | def shared_attrs, do: @shared_attrs 10 | 11 | def init(property_schema, schema, name, iri, _opts) when is_atom(name) do 12 | struct!(property_schema, 13 | schema: schema, 14 | name: name, 15 | iri: normalize_iri(iri) 16 | ) 17 | end 18 | 19 | defp normalize_iri({:inverse, iri}), do: {:inverse, RDF.iri!(iri)} 20 | defp normalize_iri(iri), do: RDF.iri!(iri) 21 | 22 | def value_set?(%{type: type}), do: value_set?(type) 23 | def value_set?(type), do: Type.set?(type) 24 | 25 | def default({list_type, _}) when is_list_type(list_type), do: [] 26 | def default(_), do: nil 27 | 28 | def type_with_cardinality(name, opts, property_type) do 29 | type_with_cardinality( 30 | name, 31 | opts[:type], 32 | Keyword.get(opts, :required, false), 33 | property_type 34 | ) 35 | end 36 | 37 | def type_with_cardinality(name, type, false, property_type) do 38 | case initial_value_type(type, property_type) do 39 | {:ok, type, card} -> 40 | {type, card} 41 | 42 | {:ok, type} -> 43 | {type, nil} 44 | 45 | {:error, nil} -> 46 | raise ArgumentError, "invalid type definition #{inspect(type)} for property #{name}" 47 | 48 | {:error, error} -> 49 | raise ArgumentError, "invalid type definition for property #{name}: #{error}" 50 | end 51 | end 52 | 53 | def type_with_cardinality(name, type, true, property_type) do 54 | with {type, cardinality} <- type_with_cardinality(name, type, false, property_type) do 55 | cond do 56 | not Type.set?(type) -> 57 | {type, 1} 58 | 59 | is_nil(cardinality) -> 60 | {type, {:min, 1}} 61 | 62 | true -> 63 | raise ArgumentError, 64 | "property #{name}: required option is not allowed when cardinality constraints are given" 65 | end 66 | end 67 | end 68 | 69 | defp initial_value_type({list_type, type, cardinality}, property_type) 70 | when is_list_type(list_type) do 71 | with {:ok, inner_type} <- property_type.initial_value_type(type) do 72 | {:ok, {list_type, inner_type}, cardinality} 73 | end 74 | end 75 | 76 | defp initial_value_type(type, property_type) do 77 | property_type.initial_value_type(type) 78 | end 79 | 80 | def value_type(%mod{} = schema), do: mod.value_type(schema) 81 | end 82 | 83 | defmodule Grax.Schema.DataProperty do 84 | @moduledoc false 85 | 86 | alias Grax.Schema.Property 87 | alias Grax.Datatype 88 | alias RDF.Literal 89 | 90 | import Grax.Schema.Type 91 | 92 | defstruct Property.shared_attrs() ++ [:default, :from_rdf, :to_rdf] 93 | 94 | @default_type :any 95 | 96 | def new(schema, name, iri, opts) do 97 | {type, cardinality} = Property.type_with_cardinality(name, opts, __MODULE__) 98 | 99 | __MODULE__ 100 | |> Property.init(schema, name, iri, opts) 101 | |> struct!( 102 | type: type, 103 | cardinality: cardinality, 104 | default: init_default(type, opts[:default]), 105 | from_rdf: normalize_custom_mapping_fun(opts[:from_rdf], schema), 106 | to_rdf: normalize_custom_mapping_fun(opts[:to_rdf], schema) 107 | ) 108 | end 109 | 110 | def initial_value_type(nil), do: initial_value_type(@default_type) 111 | def initial_value_type(type), do: Datatype.get(type) 112 | 113 | defp init_default(type, nil), do: Property.default(type) 114 | 115 | defp init_default({list_type, _}, _) when is_list_type(list_type), 116 | do: raise(ArgumentError, "the :default option is not supported on list types") 117 | 118 | defp init_default(nil, default), do: default 119 | defp init_default(RDF.XSD.Float, default) when is_float(default), do: default 120 | 121 | defp init_default(type, default) do 122 | if Literal.new(default) |> Literal.is_a?(type) do 123 | default 124 | else 125 | raise ArgumentError, "default value #{inspect(default)} doesn't match type #{inspect(type)}" 126 | end 127 | end 128 | 129 | @doc false 130 | def normalize_custom_mapping_fun(nil, _), do: nil 131 | def normalize_custom_mapping_fun({_, _} = mod_fun, _), do: mod_fun 132 | def normalize_custom_mapping_fun(fun, schema), do: {schema, fun} 133 | 134 | def value_type(%__MODULE__{} = schema), do: do_value_type(schema.type) 135 | 136 | defp do_value_type({list_type, type}) when is_list_type(list_type), do: do_value_type(type) 137 | defp do_value_type(type), do: type 138 | end 139 | 140 | defmodule Grax.Schema.LinkProperty do 141 | @moduledoc false 142 | 143 | alias Grax.Schema.Property 144 | alias Grax.Schema.LinkProperty.Union 145 | alias Grax.Schema.Inheritance 146 | alias Grax.InvalidResourceTypeError 147 | 148 | import Grax.Schema.Type 149 | 150 | defstruct Property.shared_attrs() ++ 151 | [:preload, :polymorphic, :on_rdf_type_mismatch, :on_missing_description] 152 | 153 | def new(schema, name, iri, opts) do 154 | {type, cardinality} = Property.type_with_cardinality(name, opts, __MODULE__) 155 | 156 | if Keyword.has_key?(opts, :default) do 157 | raise ArgumentError, "the :default option is not supported on links" 158 | end 159 | 160 | union_type? = match?(%Union{}, do_value_type(type)) 161 | 162 | __MODULE__ 163 | |> Property.init(schema, name, iri, opts) 164 | |> struct!( 165 | type: type, 166 | cardinality: cardinality, 167 | polymorphic: Keyword.get(opts, :polymorphic, true), 168 | preload: opts[:preload], 169 | on_rdf_type_mismatch: init_on_rdf_type_mismatch(union_type?, opts[:on_rdf_type_mismatch]), 170 | on_missing_description: init_on_missing_description(opts[:on_missing_description]) 171 | ) 172 | end 173 | 174 | @valid_on_rdf_type_mismatch_values ~w[ignore force error]a 175 | 176 | defp init_on_rdf_type_mismatch(false, nil), do: :force 177 | defp init_on_rdf_type_mismatch(true, nil), do: :ignore 178 | 179 | defp init_on_rdf_type_mismatch(true, :force) do 180 | raise ArgumentError, 181 | "on_rdf_type_mismatch: :force is not supported on union types; use a nil fallback instead to enforce a certain schema" 182 | end 183 | 184 | defp init_on_rdf_type_mismatch(_, value) when value in @valid_on_rdf_type_mismatch_values, 185 | do: value 186 | 187 | defp init_on_rdf_type_mismatch(_, value) do 188 | raise ArgumentError, 189 | "invalid on_rdf_type_mismatch value: #{inspect(value)} (valid values: #{inspect(@valid_on_rdf_type_mismatch_values)})" 190 | end 191 | 192 | @valid_on_missing_description_values ~w[empty_schema use_rdf_node]a 193 | 194 | defp init_on_missing_description(nil), do: :empty_schema 195 | 196 | defp init_on_missing_description(valid) when valid in @valid_on_missing_description_values, 197 | do: valid 198 | 199 | defp init_on_missing_description(invalid) do 200 | raise ArgumentError, 201 | "invalid on_missing_description value: #{inspect(invalid)} (valid values: #{inspect(@valid_on_missing_description_values)})" 202 | end 203 | 204 | def initial_value_type(nil), do: {:error, "type missing"} 205 | 206 | def initial_value_type(class_mapping) when is_map(class_mapping) or is_list(class_mapping) do 207 | with {:ok, union} <- Union.new(class_mapping) do 208 | {:ok, {:resource, union}} 209 | end 210 | end 211 | 212 | def initial_value_type(schema), do: {:ok, {:resource, schema}} 213 | 214 | def value_type(%__MODULE__{} = schema), do: do_value_type(schema.type) 215 | def value_type(_), do: nil 216 | defp do_value_type({list_type, type}) when is_list_type(list_type), do: do_value_type(type) 217 | defp do_value_type({:resource, type}), do: type 218 | defp do_value_type(_), do: nil 219 | 220 | def union_type?(schema) do 221 | match?(%Union{}, value_type(schema)) 222 | end 223 | 224 | def determine_schema(property_schema, description) do 225 | determine_schema(property_schema, value_type(property_schema), description) 226 | end 227 | 228 | def determine_schema(property_schema, %Union{types: class_mapping}, description) do 229 | Union.determine_schema(description, class_mapping, property_schema) 230 | end 231 | 232 | def determine_schema(%{polymorphic: false, on_rdf_type_mismatch: :force}, schema, _) do 233 | {:ok, schema} 234 | end 235 | 236 | def determine_schema(%{polymorphic: false} = property_schema, schema, description) do 237 | if Inheritance.matches_rdf_types?(description, schema) do 238 | {:ok, schema} 239 | else 240 | case property_schema.on_rdf_type_mismatch do 241 | :ignore -> 242 | {:ok, nil} 243 | 244 | :error -> 245 | {:error, 246 | InvalidResourceTypeError.exception( 247 | type: :no_match, 248 | resource_types: description[RDF.type()] 249 | )} 250 | end 251 | end 252 | end 253 | 254 | def determine_schema(property_schema, schema, description) do 255 | Inheritance.determine_schema(description, schema, property_schema) 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /test/grax/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.SchemaTest do 2 | use Grax.TestCase 3 | 4 | alias Grax.Schema 5 | alias Example.{IdSpecs, User, Post, Comment} 6 | 7 | describe "default values" do 8 | test "on properties and links" do 9 | assert %Example.DefaultValues{} == 10 | %Example.DefaultValues{ 11 | foo: "foo", 12 | bar: "bar", 13 | baz: 42, 14 | user: nil, 15 | posts: [] 16 | } 17 | end 18 | 19 | test "links don't support custom defaults" do 20 | assert_raise ArgumentError, "the :default option is not supported on links", fn -> 21 | defmodule LinkWithDefault do 22 | use Grax.Schema 23 | 24 | schema do 25 | link a: EX.a(), type: A, default: :foo 26 | end 27 | end 28 | end 29 | end 30 | 31 | test "lists don't support custom defaults" do 32 | assert_raise ArgumentError, "the :default option is not supported on list types", fn -> 33 | defmodule ListWithDefault do 34 | use Grax.Schema 35 | 36 | schema do 37 | property a: EX.a(), type: list(), default: :foo 38 | end 39 | end 40 | end 41 | end 42 | 43 | test "ordered lists don't support custom defaults" do 44 | assert_raise ArgumentError, "the :default option is not supported on list types", fn -> 45 | defmodule OrderedListWithDefault do 46 | use Grax.Schema 47 | 48 | schema do 49 | property a: EX.a(), type: ordered_list(), default: :foo 50 | end 51 | end 52 | end 53 | end 54 | end 55 | 56 | test "type of default values must match the type" do 57 | assert_raise ArgumentError, 58 | ~S(default value "foo" doesn't match type RDF.XSD.Integer), 59 | fn -> 60 | defmodule DefaultValueTypeMismatch do 61 | use Grax.Schema 62 | 63 | schema do 64 | property a: EX.a(), type: :integer, default: "foo" 65 | end 66 | end 67 | end 68 | end 69 | 70 | test "links without a type raise a proper error" do 71 | assert_raise ArgumentError, "invalid type definition for property a: type missing", fn -> 72 | defmodule NilLink do 73 | use Grax.Schema 74 | 75 | schema do 76 | link a: EX.a(), type: nil 77 | end 78 | end 79 | end 80 | end 81 | 82 | describe "cardinality" do 83 | test "property schema" do 84 | assert Example.Cardinalities.__property__(:p1).cardinality == 2 85 | assert Example.Cardinalities.__property__(:p2).cardinality == 2..4 86 | assert Example.Cardinalities.__property__(:p3).cardinality == {:min, 3} 87 | assert Example.Cardinalities.__property__(:l1).cardinality == 2..3 88 | assert Example.Cardinalities.__property__(:l2).cardinality == {:min, 2} 89 | end 90 | 91 | test "normalization of equivalent cardinalities" do 92 | defmodule EquivalentCardinalities do 93 | use Grax.Schema 94 | 95 | schema do 96 | property p1: EX.p1(), type: list(card: 1..1) 97 | property p2: EX.p2(), type: list(card: 3..2//-1) 98 | property p3: EX.p3(), type: list(min: 0) 99 | property p12: EX.p12(), type: ordered_list(card: 1..1) 100 | property p22: EX.p22(), type: ordered_list(card: 3..2//-1) 101 | property p32: EX.p32(), type: ordered_list(min: 0) 102 | end 103 | end 104 | 105 | assert EquivalentCardinalities.__property__(:p1).cardinality == 1 106 | assert EquivalentCardinalities.__property__(:p2).cardinality == 2..3 107 | assert EquivalentCardinalities.__property__(:p3).cardinality == nil 108 | assert EquivalentCardinalities.__property__(:p12).cardinality == 1 109 | assert EquivalentCardinalities.__property__(:p22).cardinality == 2..3 110 | assert EquivalentCardinalities.__property__(:p32).cardinality == nil 111 | end 112 | 113 | test "mapping of required flag to cardinalities" do 114 | defmodule RequiredAsCardinalities do 115 | use Grax.Schema 116 | 117 | schema do 118 | property p1: EX.p1(), type: :string, required: true 119 | property p2: EX.p1(), type: :string, required: false 120 | property p3: EX.p3(), type: list(), required: true 121 | property p4: EX.p4(), type: list(), required: false 122 | property p5: EX.p5(), type: ordered_list(), required: true 123 | property p6: EX.p6(), type: ordered_list(), required: false 124 | link l1: EX.l1(), type: User, required: true 125 | link l2: EX.l2(), type: list_of(User), required: true 126 | link l3: EX.l3(), type: ordered_list_of(User), required: true 127 | end 128 | end 129 | 130 | assert RequiredAsCardinalities.__property__(:p1).cardinality == 1 131 | assert RequiredAsCardinalities.__property__(:p2).cardinality == nil 132 | assert RequiredAsCardinalities.__property__(:p3).cardinality == {:min, 1} 133 | assert RequiredAsCardinalities.__property__(:p4).cardinality == nil 134 | assert RequiredAsCardinalities.__property__(:p5).cardinality == {:min, 1} 135 | assert RequiredAsCardinalities.__property__(:p6).cardinality == nil 136 | assert RequiredAsCardinalities.__property__(:l1).cardinality == 1 137 | assert RequiredAsCardinalities.__property__(:l2).cardinality == {:min, 1} 138 | assert RequiredAsCardinalities.__property__(:l3).cardinality == {:min, 1} 139 | end 140 | 141 | test "required flag with cardinalities causes an error" do 142 | error_message = 143 | "property foo: required option is not allowed when cardinality constraints are given" 144 | 145 | assert_raise ArgumentError, error_message, fn -> 146 | defmodule RequiredWithCardinalities1 do 147 | use Grax.Schema 148 | 149 | schema do 150 | property foo: EX.foo(), type: list(card: 2), required: true 151 | end 152 | end 153 | end 154 | 155 | assert_raise ArgumentError, error_message, fn -> 156 | defmodule RequiredWithCardinalities2 do 157 | use Grax.Schema 158 | 159 | schema do 160 | link foo: EX.foo(), type: list_of(User, card: 2..3), required: true 161 | end 162 | end 163 | end 164 | end 165 | end 166 | 167 | test "__class__/0" do 168 | assert Example.ClassDeclaration.__class__() == IRI.to_string(EX.Class) 169 | assert Example.Datatypes.__class__() == nil 170 | end 171 | 172 | test "__load_additional_statements__?/0" do 173 | assert Example.User.__load_additional_statements__?() == true 174 | assert Example.IgnoreAdditionalStatements.__load_additional_statements__?() == false 175 | end 176 | 177 | describe "__id_spec__/0" do 178 | test "when no id spec set or application configured" do 179 | assert User.__id_spec__() == nil 180 | end 181 | 182 | test "when an id spec is set explicitly" do 183 | assert Example.WithIdSchema.__id_spec__() == IdSpecs.Foo 184 | end 185 | 186 | # tests for the application configured Id.Spec are in Grax.ConfigTest 187 | end 188 | 189 | describe "__id_schema__/0" do 190 | test "when no id spec set or application configured" do 191 | assert User.__id_schema__() == nil 192 | end 193 | 194 | test "when an id spec is set explicitly" do 195 | assert Example.WithIdSchema.__id_schema__() == 196 | IdSpecs.Foo.expected_id_schema(Example.WithIdSchema) 197 | end 198 | 199 | # tests for the application configured Id.Spec are in Grax.ConfigTest 200 | end 201 | 202 | describe "__id_schema__/1" do 203 | test "when an Id.Schema can be found for a given Grax schema module" do 204 | assert User.__id_schema__(IdSpecs.GenericIds) == 205 | IdSpecs.GenericIds.expected_id_schema(User) 206 | 207 | assert Post.__id_schema__(IdSpecs.GenericIds) == 208 | IdSpecs.GenericIds.expected_id_schema(Post) 209 | end 210 | 211 | test "BlankNode" do 212 | assert User.__id_schema__(IdSpecs.BlankNodes) == 213 | IdSpecs.BlankNodes.expected_id_schema(User) 214 | 215 | assert Post.__id_schema__(IdSpecs.BlankNodes) == 216 | IdSpecs.BlankNodes.expected_id_schema(Example.WithBlankNodeIdSchema) 217 | |> Map.put(:schema, Post) 218 | 219 | assert Comment.__id_schema__(IdSpecs.BlankNodes) == 220 | IdSpecs.BlankNodes.expected_id_schema(Example.WithBlankNodeIdSchema) 221 | |> Map.put(:schema, Comment) 222 | 223 | assert Example.WithBlankNodeIdSchema.__id_schema__(IdSpecs.BlankNodes) == 224 | IdSpecs.BlankNodes.expected_id_schema(Example.WithBlankNodeIdSchema) 225 | |> Map.put(:schema, Example.WithBlankNodeIdSchema) 226 | end 227 | 228 | test "with an Id.Schema for multiple Grax schema modules" do 229 | assert Example.MultipleSchemasA.__id_schema__(IdSpecs.MultipleSchemas) == 230 | IdSpecs.MultipleSchemas.expected_id_schema(:foo) 231 | |> Map.put(:schema, Example.MultipleSchemasA) 232 | 233 | assert Example.MultipleSchemasB.__id_schema__(IdSpecs.MultipleSchemas) == 234 | IdSpecs.MultipleSchemas.expected_id_schema(:foo) 235 | |> Map.put(:schema, Example.MultipleSchemasB) 236 | 237 | assert Post.__id_schema__(IdSpecs.MultipleSchemas) == 238 | IdSpecs.MultipleSchemas.expected_id_schema(:content) 239 | |> Map.put(:schema, Post) 240 | 241 | assert Comment.__id_schema__(IdSpecs.MultipleSchemas) == 242 | IdSpecs.MultipleSchemas.expected_id_schema(:content) 243 | |> Map.put(:schema, Comment) 244 | end 245 | 246 | test "when no Id.Schema can be found" do 247 | assert Comment.__id_schema__(IdSpecs.GenericIds) == nil 248 | end 249 | end 250 | 251 | test "schema?/2" do 252 | assert Schema.schema?(User) 253 | refute Schema.schema?(Regex) 254 | refute Schema.schema?(:random) 255 | refute Schema.schema?(42) 256 | 257 | assert Example.user(EX.User0) |> Schema.schema?() 258 | refute ~D[2023-05-16] |> Schema.schema?() 259 | end 260 | 261 | test "inherited_from?/2" do 262 | assert Example.ParentSchema |> Schema.inherited_from?(Example.ParentSchema) 263 | assert Example.ChildSchema |> Schema.inherited_from?(Example.ParentSchema) 264 | refute Example.ChildOfMany |> Schema.inherited_from?(User) 265 | refute Example.AnotherParentSchema |> Schema.inherited_from?(Example.ParentSchema) 266 | 267 | assert Example.user(EX.User0) |> Schema.inherited_from?(User) 268 | refute Example.user(EX.User0) |> Schema.inherited_from?(ParentSchema) 269 | end 270 | 271 | describe "known_schemas/0" do 272 | test "returns all known grax schemas, derived from code base" do 273 | all_schemas = Grax.Schema.known_schemas() 274 | 275 | assert is_list(all_schemas) 276 | refute Enum.empty?(all_schemas) 277 | assert Enum.all?(all_schemas, &Grax.Schema.schema?/1) 278 | assert all_schemas == Enum.uniq(all_schemas) 279 | assert Example.User in all_schemas 280 | end 281 | end 282 | end 283 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/) and 5 | [Keep a CHANGELOG](http://keepachangelog.com). 6 | 7 | 8 | ## Unreleased 9 | 10 | ### Changed 11 | 12 | - The `build`, `load` and `from` functions on Grax schema modules are all 13 | overridable now. 14 | 15 | ### Fixed 16 | 17 | - Defining a default value on a `:float` property was causing an error 18 | - Fix unwanted normalization of list values in `custom_fields` 19 | 20 | 21 | [Compare v0.6.0...HEAD](https://github.com/rdf-elixir/grax/compare/v0.6.0...HEAD) 22 | 23 | 24 | 25 | ## v0.6.0 - 2025-04-09 26 | 27 | Elixir versions < 1.14 and OTP version < 24 are no longer supported. 28 | 29 | ### Added 30 | 31 | - Support for RDF lists via the new `ordered_list_of/2` type constructor that 32 | can be used on the `:type` of `property` and `link` definitions in a `schema` 33 | similarly to the `list_of/2` type constructor 34 | - Support for properties with `rdf:JSON` values with the new `:json` type 35 | 36 | 37 | [Compare v0.5.0...v0.6.0](https://github.com/rdf-elixir/grax/compare/v0.5.0...v0.6.0) 38 | 39 | 40 | 41 | ## v0.5.0 - 2024-08-07 42 | 43 | This version upgrades to RDF.ex v2.0. 44 | 45 | Elixir versions < 1.13 and OTP version < 23 are no longer supported. 46 | 47 | ### Added 48 | 49 | - option `:on_missing_description` on `link` macro which allows to specify 50 | with value `:use_rdf_node` that a linked resource without a description should 51 | be kept as an RDF resource (`RDF.IRI` or `RDF.BlankNode`) when preloaded 52 | (instead of the default behaviour `:empty_schema` which creates an empty 53 | schema for the linked resource) 54 | - flag option `:validate` on `Grax.to_rdf/2` which allows to turn off validation 55 | 56 | ### Changed 57 | 58 | - Replacement of `elixir_uuid` with `uniq` dependency 59 | 60 | 61 | [Compare v0.4.1...v0.5.0](https://github.com/rdf-elixir/grax/compare/v0.4.1...v0.5.0) 62 | 63 | 64 | 65 | ## v0.4.1 - 2023-07-03 66 | 67 | ### Fixed 68 | 69 | - slow startup on large code bases due to long `Grax.Schema.Registry` initialization 70 | (thanks @semarco) 71 | 72 | 73 | [Compare v0.4.0...v0.4.1](https://github.com/rdf-elixir/grax/compare/v0.4.0...v0.4.1) 74 | 75 | 76 | 77 | ## v0.4.0 - 2023-06-12 78 | 79 | ### Added 80 | 81 | - Grax schema mapping functions `from/1` and `from!/1` on Grax schema modules, 82 | which allows to map one schema struct to another 83 | - `Grax.load/4` and `Grax.load!/4` can now be called without providing a schema, 84 | which will be automatically detected based on the `rdf:type` of the loaded 85 | resource. The most specific schema with a class declaration matching one of 86 | the `rdf:type`s will be selected. 87 | - The class mapping on a union type can now also be provided as a list of 88 | `{class_iri, schema}` tuples or just Grax schemas, for those which are 89 | associated with a class IRI with a class declaration. 90 | - the `:on_rdf_type_mismatch` option is now supported on all types of links, 91 | including inverse links (previously it was available on union links only) 92 | - `Grax.Schema.schema?/1` and `Grax.Schema.struct?/1` to determine whether a given 93 | module or struct is a Grax schema resp. Grax schema struct 94 | - `Grax.schema/1` to get the schema(s) of a given class IRI 95 | - `Grax.Schema.schema?/1` to check if a given module or struct is a `Grax.Schema` 96 | - `Grax.Schema.inherited_from?/1` to check if a given module or struct is 97 | inherited from another `Grax.Schema` 98 | - `Grax.id/1` to get the id of a Grax struct (rather than of having to access 99 | `:__id__` field) 100 | - `Grax.reset_id/2` to set a new id on a Grax struct 101 | - `Grax.reset_id/1` to set a new id on a Grax struct by reapplying its id schema 102 | - `Grax.delete_additional_predicates/2` to delete all additional statements 103 | with specific predicates 104 | 105 | ### Changed 106 | 107 | - Links now have become polymorphic, i.e. the most specific 108 | inherited schema matching one of the types of a linked resource is used. 109 | Opting-out to non-polymorphic behaviour is possible by setting the 110 | `:polymorphic` option to `false` on a `link` definition. However, the 111 | non-polymorphic behaviour still differs slightly from the previous version, 112 | in that, when `:on_rdf_type_mismatch` is set to `:error`, preloading of an RDF 113 | resource which is not typed with the class of the specified schema, but the 114 | class of an inherited schema, no longer leads to an error. 115 | - Preloading of union links whose schemas are in an inheritance relationship 116 | are resolved to the most specific class and no longer result in an 117 | `:multiple_matches` when the resource is typed also with the broader classes. 118 | - The argument order of `Grax.load/4` and `Grax.load!/4` has been changed to be 119 | the same as on the generated `load` functions of Grax schemas. 120 | - The internal representation of the `__additional_statements__` field of Grax 121 | schema structs was changed to use now the same format as the internal 122 | `predications` field of `RDF.Description`s. This allows various optimizations 123 | and a richer API for accessing the additional statements, e.g. all the 124 | functions to update the additional statements like `Grax.add_additional_statements/2` 125 | now can handle the multitude of inputs as the respective `RDF.Description` 126 | counterpart. Unfortunately, this brings two additional breaking changes: 127 | - You no longer can pass the contents of the `__additional_statements__` 128 | field of one Grax schema as the additional statements to another one. 129 | You should instead pass the result of `Grax.additional_statements/1` now. 130 | - You no longer can use `nil` values on a property with `Grax.put_additional_statements/2` 131 | to remove statements with this property. You must use the new 132 | `Grax.delete_additional_predicates/2` function for this now. 133 | - Rename `Grax.id/2` to `Grax.build_id/2` and the generated `__id__/1` function 134 | on the Grax schema modules to `build_id/1` 135 | - Rename `:on_type_mismatch` link option to `:on_rdf_type_mismatch` to make it 136 | clearer that it is only relevant during preloading from RDF data 137 | - Rename `Grax.Schema.InvalidProperty` to `Grax.Schema.InvalidPropertyError` for 138 | consistency reasons 139 | - "heterogeneous link properties" are now called "union link properties" 140 | (since this name didn't appear in the code, this change only affects the documentation) 141 | 142 | ### Fixed 143 | 144 | - a bug when preloading a nested schema with union links without values 145 | - union links weren't validated properly 146 | 147 | 148 | [Compare v0.3.5...v0.4.0](https://github.com/rdf-elixir/grax/compare/v0.3.5...v0.4.0) 149 | 150 | 151 | 152 | ## v0.3.5 - 2023-01-18 153 | 154 | ### Fixed 155 | 156 | - additional statements with objects in MapSets weren't handled properly, which 157 | is critical in particular, because the objects in additional statements are kept 158 | in MapSets internally, which meant additional statements from one schema couldn't 159 | be passed to another schema 160 | - raise a proper error when preloading of a link fails because a literal is given 161 | 162 | 163 | [Compare v0.3.4...v0.3.5](https://github.com/rdf-elixir/grax/compare/v0.3.4...v0.3.5) 164 | 165 | 166 | 167 | ## v0.3.4 - 2022-11-03 168 | 169 | This version upgrades to RDF.ex 1.0. 170 | 171 | Elixir versions < 1.11 are no longer supported 172 | 173 | 174 | ### Fixed 175 | 176 | - a bug when reading the counter of a `Grax.Id.Counter.TextFile/2` under Elixir 1.14 177 | 178 | 179 | [Compare v0.3.3...v0.3.4](https://github.com/rdf-elixir/grax/compare/v0.3.3...v0.3.4) 180 | 181 | 182 | 183 | ## v0.3.3 - 2022-06-29 184 | 185 | ### Added 186 | 187 | - `Grax.delete_additional_statements/2` 188 | 189 | ### Fixed 190 | 191 | - `to_rdf/2` failed when an inverse property was not a list (#1) 192 | 193 | 194 | [Compare v0.3.2...v0.3.3](https://github.com/rdf-elixir/grax/compare/v0.3.2...v0.3.3) 195 | 196 | 197 | 198 | ## v0.3.2 - 2022-01-28 199 | 200 | ### Added 201 | 202 | - `on_load/3` and `on_to_rdf/3` callbacks on `Grax.Schema`s 203 | - `Grax.Schema`s now have an `__additional_statements__` field, which holds 204 | additional statements about the resource, which are not mapped to schema 205 | fields, but should be mapped back to RDF 206 | - `Grax.to_rdf!/2` bang variant of `Grax.to_rdf/2` 207 | 208 | 209 | [Compare v0.3.1...v0.3.2](https://github.com/rdf-elixir/grax/compare/v0.3.1...v0.3.2) 210 | 211 | 212 | 213 | ## v0.3.1 - 2021-07-16 214 | 215 | ### Added 216 | 217 | - support for counter-based Grax ids 218 | - support for blank nodes as Grax ids 219 | 220 | 221 | ### Optimized 222 | 223 | - improved Grax id schema lookup performance 224 | 225 | 226 | [Compare v0.3.0...v0.3.1](https://github.com/rdf-elixir/grax/compare/v0.3.0...v0.3.1) 227 | 228 | 229 | 230 | ## v0.3.0 - 2021-05-26 231 | 232 | ### Added 233 | 234 | - Grax ids - see the [new chapter in the Grax guide](https://rdf-elixir.dev/grax/ids.html) 235 | for more on this bigger feature 236 | 237 | 238 | ### Changed 239 | 240 | - not loaded links are no longer represented with `Grax.Link.NotLoaded` structs, 241 | but with `RDF.IRI` or `RDF.BlankNode`s instead 242 | - the value of link properties can be given as plain `RDF.IRI`, `RDF.BlankNode` 243 | values or as vocabulary namespace terms on `Grax.new` and `Grax.build` 244 | - the value of properties with type `:iri` can be given as vocabulary namespace 245 | terms on `Grax.new` and `Grax.build` 246 | 247 | 248 | [Compare v0.2.0...v0.3.0](https://github.com/rdf-elixir/grax/compare/v0.2.0...v0.3.0) 249 | 250 | 251 | 252 | ## v0.2.0 - 2021-03-16 253 | 254 | ### Added 255 | 256 | - heterogeneous link properties which can link different types of resources 257 | to different schemas 258 | - schema inheritance 259 | - support for cardinality constraints on properties 260 | - support for `:required` on link properties 261 | - support for custom `:from_rdf` mappings on custom fields 262 | - support for custom mappings in separate modules 263 | - the `build` functions can now be called with a single map when the map contains 264 | an id an `:__id__` field 265 | - `:context` field on `Grax.ValidationError` exception with more context specific information 266 | 267 | ### Changed 268 | 269 | - the way in which list types are defined in a schema has been changed from putting the 270 | base type in square bracket to using one of the new `list_of/1` or `list/0` type builder 271 | functions 272 | - the default value of link properties has been changed to `nil` respective the empty list 273 | (previously it was a `Grax.Link.NotLoaded` struct, which is now set explicitly 274 | during loading) 275 | - in the `Grax.build` and `Grax.put` functions duplicates in the given values are ignored 276 | - in the `Grax.build` and `Grax.put` functions a single value in a list for a non-list 277 | property will now be extracted, instead of leading to a validation error 278 | - failing `:required` requirements result in a `Grax.Schema.CardinalityError` instead 279 | of a `Grax.Schema.RequiredPropertyMissing` exception 280 | - the opts on `Grax.to_rdf/2` are now passed-through to the `RDF.Graph.new/2` constructor 281 | allowing to set the name of the graph, prefixes etc. 282 | 283 | 284 | [Compare v0.1.0...v0.2.0](https://github.com/rdf-elixir/grax/compare/v0.1.0...v0.2.0) 285 | 286 | 287 | 288 | ## v0.1.0 - 2021-01-06 289 | 290 | Initial release 291 | -------------------------------------------------------------------------------- /lib/grax/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Grax.Schema do 2 | @moduledoc """ 3 | A special type of struct for graph structures whose fields are mapped to RDF properties and 4 | the types of values can be specified. 5 | 6 | For now there is no API documentation. 7 | Read about schemas in the guide [here](https://rdf-elixir.dev/grax/schemas.html). 8 | """ 9 | 10 | alias Grax.Schema.{ 11 | Struct, 12 | Inheritance, 13 | DataProperty, 14 | LinkProperty, 15 | CustomField, 16 | AdditionalStatements 17 | } 18 | 19 | alias RDF.IRI 20 | 21 | import RDF.Utils.Guards 22 | 23 | @type t() :: struct 24 | 25 | defmacro __using__(opts) do 26 | preload_default = opts |> Keyword.get(:depth) |> Grax.normalize_preload_spec() 27 | 28 | id_spec_from_otp_app = 29 | if id_spec_from_otp_app = Keyword.get(opts, :id_spec_from_otp_app) do 30 | Application.get_env(id_spec_from_otp_app, :grax_id_spec) 31 | end 32 | 33 | id_spec = Keyword.get(opts, :id_spec, id_spec_from_otp_app) 34 | 35 | quote do 36 | @behaviour Grax.Callbacks 37 | 38 | import unquote(__MODULE__), only: [schema: 1, schema: 2] 39 | 40 | @before_compile unquote(__MODULE__) 41 | 42 | @grax_preload_default unquote(preload_default) 43 | def __preload_default__(), do: @grax_preload_default 44 | 45 | if unquote(id_spec) do 46 | def __id_spec__(), do: unquote(id_spec) 47 | else 48 | def __id_spec__() do 49 | __MODULE__ 50 | |> Application.get_application() 51 | |> Application.get_env(:grax_id_spec) 52 | end 53 | end 54 | 55 | def __id_schema__(id_spec \\ nil) 56 | def __id_schema__(nil), do: if(id_spec = __id_spec__(), do: __id_schema__(id_spec)) 57 | def __id_schema__(id_spec), do: id_spec.id_schema(__MODULE__) 58 | 59 | def build_id(attributes), do: Grax.build_id(__MODULE__, attributes) 60 | 61 | def build(id), do: Grax.build(__MODULE__, id) 62 | def build(id, initial), do: Grax.build(__MODULE__, id, initial) 63 | def build!(id), do: Grax.build!(__MODULE__, id) 64 | def build!(id, initial), do: Grax.build!(__MODULE__, id, initial) 65 | 66 | @spec load( 67 | RDF.Graph.t() | RDF.Description.t(), 68 | RDF.IRI.coercible() | RDF.BlankNode.t(), 69 | opts :: keyword() 70 | ) :: 71 | {:ok, __MODULE__.t()} | {:error, any} 72 | def load(graph, id, opts \\ []), do: Grax.load(graph, id, __MODULE__, opts) 73 | 74 | @spec load!( 75 | RDF.Graph.t() | RDF.Description.t(), 76 | RDF.IRI.coercible() | RDF.BlankNode.t(), 77 | opts :: keyword() 78 | ) :: 79 | __MODULE__.t() 80 | def load!(graph, id, opts \\ []), do: Grax.load!(graph, id, __MODULE__, opts) 81 | 82 | @spec from(Grax.Schema.t()) :: {:ok, __MODULE__.t()} | {:error, any} 83 | def from(value), do: Grax.Schema.Mapping.from(value, __MODULE__) 84 | 85 | @spec from!(Grax.Schema.t()) :: __MODULE__.t() 86 | def from!(value), do: Grax.Schema.Mapping.from!(value, __MODULE__) 87 | 88 | @impl Grax.Callbacks 89 | def on_load(schema, _graph, _opts), do: {:ok, schema} 90 | 91 | @impl Grax.Callbacks 92 | def on_to_rdf(_schema, graph, _opts), do: {:ok, graph} 93 | 94 | defimpl Grax.Schema.Registerable do 95 | def register(schema), do: schema 96 | end 97 | 98 | defoverridable build_id: 1, 99 | build: 1, 100 | build: 2, 101 | build!: 1, 102 | build!: 2, 103 | load: 3, 104 | load!: 3, 105 | from: 1, 106 | from!: 1, 107 | on_load: 3, 108 | on_to_rdf: 3 109 | end 110 | end 111 | 112 | defmacro __before_compile__(_env) do 113 | quote do 114 | Module.delete_attribute(__MODULE__, :rdf_property_acc) 115 | Module.delete_attribute(__MODULE__, :custom_field_acc) 116 | end 117 | end 118 | 119 | defmacro schema(class \\ nil, do_block) 120 | 121 | defmacro schema({:<, _, [class, nil]}, do: block) do 122 | schema(__CALLER__, class, [], block) 123 | end 124 | 125 | defmacro schema({:<, _, [class, parent_schema]}, do: block) do 126 | schema(__CALLER__, class, [inherit: parent_schema], block) 127 | end 128 | 129 | defmacro schema(opts, do: block) when is_list(opts) do 130 | {class, opts} = Keyword.pop(opts, :type) 131 | schema(__CALLER__, class, opts, block) 132 | end 133 | 134 | defmacro schema(class, do: block) do 135 | schema(__CALLER__, class, [], block) 136 | end 137 | 138 | defp schema(caller, class, opts, block) do 139 | parent_schema = if parent_schema = Keyword.get(opts, :inherit), do: List.wrap(parent_schema) 140 | load_additional_statements = Keyword.get(opts, :load_additional_statements, true) 141 | 142 | prelude = 143 | quote do 144 | if line = Module.get_attribute(__MODULE__, :grax_schema_defined) do 145 | raise "schema already defined for #{inspect(__MODULE__)} on line #{line}" 146 | end 147 | 148 | @grax_schema_defined unquote(caller.line) 149 | 150 | @grax_parent_schema unquote(parent_schema) 151 | def __super__(), do: @grax_parent_schema 152 | 153 | @grax_schema_class unquote(class) 154 | @grax_schema_class_string if @grax_schema_class, do: IRI.to_string(@grax_schema_class) 155 | def __class__(), do: @grax_schema_class_string 156 | 157 | @additional_statements AdditionalStatements.default(unquote(class)) 158 | def __additional_statements__(), do: @additional_statements 159 | 160 | @load_additional_statements unquote(load_additional_statements) 161 | def __load_additional_statements__?(), do: @load_additional_statements 162 | 163 | Module.register_attribute(__MODULE__, :rdf_property_acc, accumulate: true) 164 | Module.register_attribute(__MODULE__, :custom_field_acc, accumulate: true) 165 | 166 | try do 167 | import unquote(__MODULE__) 168 | import Grax.Schema.Type.Constructors 169 | unquote(block) 170 | after 171 | :ok 172 | end 173 | end 174 | 175 | postlude = 176 | quote unquote: false do 177 | @type t() :: %__MODULE__{} 178 | 179 | @__properties__ Inheritance.inherit_properties( 180 | __MODULE__, 181 | @grax_parent_schema, 182 | Map.new(@rdf_property_acc) 183 | ) 184 | 185 | @__custom_fields__ Inheritance.inherit_custom_fields( 186 | __MODULE__, 187 | @grax_parent_schema, 188 | Map.new(@custom_field_acc) 189 | ) 190 | 191 | defstruct Struct.fields(@__properties__, @__custom_fields__, @grax_schema_class) 192 | 193 | def __properties__, do: @__properties__ 194 | 195 | def __properties__(:data), 196 | do: @__properties__ |> Enum.filter(&match?({_, %DataProperty{}}, &1)) 197 | 198 | def __properties__(:link), 199 | do: @__properties__ |> Enum.filter(&match?({_, %LinkProperty{}}, &1)) 200 | 201 | def __property__(property), do: @__properties__[property] 202 | def __domain_properties__(), do: Grax.Schema.domain_properties(@__properties__) 203 | 204 | def __custom_fields__, do: @__custom_fields__ 205 | end 206 | 207 | quote do 208 | unquote(prelude) 209 | unquote(postlude) 210 | end 211 | end 212 | 213 | defmacro field(name, opts \\ []) do 214 | quote do 215 | Grax.Schema.__custom_field__(__MODULE__, unquote(name), unquote(opts)) 216 | end 217 | end 218 | 219 | defmacro property([{name, iri} | opts]) do 220 | quote do 221 | Grax.Schema.__property__(__MODULE__, unquote(name), unquote(iri), unquote(opts)) 222 | end 223 | end 224 | 225 | defmacro property(name, iri, opts \\ []) do 226 | quote do 227 | Grax.Schema.__property__(__MODULE__, unquote(name), unquote(iri), unquote(opts)) 228 | end 229 | end 230 | 231 | defmacro link(name, iri, opts) do 232 | iri = property_mapping_destination(iri) 233 | 234 | unless Keyword.has_key?(opts, :type), 235 | do: raise(ArgumentError, "type missing for link #{name}") 236 | 237 | opts = 238 | Keyword.put(opts, :preload, opts |> Keyword.get(:depth) |> Grax.normalize_preload_spec()) 239 | 240 | quote do 241 | Grax.Schema.__link__(__MODULE__, unquote(name), unquote(iri), unquote(opts)) 242 | end 243 | end 244 | 245 | defmacro link([{name, iri} | opts]) do 246 | quote do 247 | link(unquote(name), unquote(iri), unquote(opts)) 248 | end 249 | end 250 | 251 | @doc false 252 | def __custom_field__(mod, name, opts) do 253 | custom_field_schema = CustomField.new(mod, name, opts) 254 | Module.put_attribute(mod, :custom_field_acc, {name, custom_field_schema}) 255 | end 256 | 257 | @doc false 258 | def __property__(mod, name, iri, opts) when not is_nil(iri) do 259 | property_schema = DataProperty.new(mod, name, iri, opts) 260 | Module.put_attribute(mod, :rdf_property_acc, {name, property_schema}) 261 | end 262 | 263 | @doc false 264 | def __link__(mod, name, iri, opts) do 265 | property_schema = LinkProperty.new(mod, name, iri, opts) 266 | 267 | Module.put_attribute(mod, :rdf_property_acc, {name, property_schema}) 268 | end 269 | 270 | defp property_mapping_destination({:-, _line, [iri_expr]}), do: {:inverse, iri_expr} 271 | defp property_mapping_destination(iri_expr), do: iri_expr 272 | 273 | @doc false 274 | def domain_properties(properties) do 275 | properties 276 | |> Map.values() 277 | |> Enum.map(& &1.iri) 278 | |> Enum.reject(&match?({:inverse, _}, &1)) 279 | end 280 | 281 | @doc false 282 | def has_field?(schema, field_name) do 283 | Map.has_key?(schema.__properties__(), field_name) or 284 | Map.has_key?(schema.__custom_fields__(), field_name) 285 | end 286 | 287 | @doc """ 288 | Checks if the given value is a `Grax.Schema` struct. 289 | """ 290 | @spec struct?(any) :: boolean 291 | def struct?(%mod{__id__: _, __additional_statements__: _}), do: schema?(mod) 292 | def struct?(_), do: false 293 | 294 | @doc """ 295 | Checks if the given module or struct is a `Grax.Schema`. 296 | """ 297 | @spec schema?(module | struct) :: boolean 298 | def schema?(mod_or_struct) 299 | 300 | def schema?(%mod{}), do: schema?(mod) 301 | 302 | def schema?(mod) when maybe_module(mod) do 303 | case Code.ensure_compiled(mod) do 304 | {:module, mod} -> function_exported?(mod, :__properties__, 1) 305 | _ -> false 306 | end 307 | end 308 | 309 | def schema?(_), do: false 310 | 311 | @doc """ 312 | Returns all modules using `Grax.Schema`. 313 | """ 314 | # ignore dialyzer assumes Grax.Schema.Registerable is always consolidated 315 | @dialyzer {:nowarn_function, known_schemas: 0} 316 | @spec known_schemas :: [module] 317 | def known_schemas do 318 | case Grax.Schema.Registerable.__protocol__(:impls) do 319 | {:consolidated, modules} -> 320 | modules 321 | 322 | :not_consolidated -> 323 | Protocol.extract_impls(Grax.Schema.Registerable, :code.get_path()) 324 | end 325 | end 326 | 327 | @doc """ 328 | Checks if the given `Grax.Schema` or `Grax.Schema` struct is inherited from another `Grax.Schema`. 329 | """ 330 | @spec inherited_from?(module | struct, module) :: boolean 331 | def inherited_from?(schema, parent) 332 | 333 | def inherited_from?(%schema{}, parent), do: inherited_from?(schema, parent) 334 | 335 | def inherited_from?(schema, parent) do 336 | schema?(schema) and Inheritance.inherited_schema?(schema, parent) 337 | end 338 | end 339 | -------------------------------------------------------------------------------- /test/grax/id/spec_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Grax.Id.SpecTest do 2 | use Grax.TestCase 3 | 4 | alias Grax.Id 5 | alias RDF.PrefixMap 6 | alias Example.{IdSpecs, User, Post, Comment} 7 | 8 | describe "namespaces/0" do 9 | test "returns all namespaces" do 10 | namespace = %Id.Namespace{uri: "http://example.com/", prefix: :ex} 11 | 12 | assert IdSpecs.FlatNs.namespaces() == [namespace] 13 | assert IdSpecs.FlatNsWithVocabTerms.namespaces() == [namespace] 14 | end 15 | 16 | test "includes base namespaces" do 17 | assert IdSpecs.FlatBase.namespaces() == [ 18 | %Id.Namespace{uri: "http://example.com/"} 19 | ] 20 | end 21 | 22 | test "includes URN namespaces" do 23 | assert IdSpecs.UrnNs.namespaces() == [ 24 | %Id.UrnNamespace{nid: :uuid, string: "urn:uuid:"}, 25 | %Id.UrnNamespace{nid: :isbn, string: "urn:isbn:"} 26 | ] 27 | end 28 | 29 | test "includes nested namespaces" do 30 | root_namespace = %Id.Namespace{uri: "http://example.com/", prefix: :ex} 31 | 32 | foo_namespace = %Id.Namespace{ 33 | parent: root_namespace, 34 | uri: "http://example.com/foo/", 35 | prefix: :foo 36 | } 37 | 38 | assert IdSpecs.NestedNs.namespaces() == [ 39 | %Id.Namespace{ 40 | parent: root_namespace, 41 | uri: "http://example.com/qux/", 42 | prefix: :qux 43 | }, 44 | %Id.Namespace{ 45 | parent: foo_namespace, 46 | uri: "http://example.com/foo/baz/", 47 | prefix: :baz 48 | }, 49 | %Id.Namespace{ 50 | parent: foo_namespace, 51 | uri: "http://example.com/foo/bar/", 52 | prefix: :bar 53 | }, 54 | foo_namespace, 55 | root_namespace 56 | ] 57 | end 58 | 59 | test "expressions in namespaces" do 60 | root_namespace = %Id.Namespace{uri: "http://example.com/"} 61 | 62 | assert IdSpecs.NsWithExpressions.namespaces() == [ 63 | %Id.Namespace{parent: root_namespace, uri: "http://example.com/sub/"}, 64 | root_namespace 65 | ] 66 | 67 | assert IdSpecs.NsWithExpressions2.namespaces() == [ 68 | %Id.Namespace{root_namespace | prefix: :ex} 69 | ] 70 | end 71 | end 72 | 73 | test "vocab terms are only allowed on the top-level namespace" do 74 | assert_raise ArgumentError, "absolute URIs are only allowed on the top-level namespace", fn -> 75 | defmodule VocabTermsOnNestedNamespace do 76 | use Grax.Id.Spec 77 | 78 | namespace "http://example.com/" do 79 | namespace EX do 80 | end 81 | end 82 | end 83 | end 84 | end 85 | 86 | test "urn namespaces are only allowed on the top-level" do 87 | assert_raise RuntimeError, "urn namespaces can only be defined on the top-level", fn -> 88 | defmodule VocabTermsOnNestedNamespace do 89 | use Grax.Id.Spec 90 | 91 | namespace "http://example.com/" do 92 | urn :uuid do 93 | end 94 | end 95 | end 96 | end 97 | end 98 | 99 | test "only one base namespace allowed" do 100 | assert_raise RuntimeError, "already a base namespace defined: http://example.com/foo/", fn -> 101 | defmodule VocabTermsOnNestedNamespace do 102 | use Grax.Id.Spec 103 | 104 | namespace "http://example.com/" do 105 | base "foo/" do 106 | end 107 | 108 | base "bar/" do 109 | end 110 | end 111 | end 112 | end 113 | end 114 | 115 | describe "base_namespace/0" do 116 | test "when base namespace defined" do 117 | assert IdSpecs.FlatBase.base_namespace() == 118 | %Id.Namespace{uri: "http://example.com/"} 119 | 120 | assert IdSpecs.NestedBase.base_namespace() == 121 | %Id.Namespace{ 122 | parent: %Id.Namespace{uri: "http://example.com/"}, 123 | uri: "http://example.com/foo/" 124 | } 125 | end 126 | 127 | test "when no base namespace defined" do 128 | refute IdSpecs.FlatNs.base_namespace() 129 | refute IdSpecs.FlatNsWithVocabTerms.base_namespace() 130 | refute IdSpecs.NestedNs.base_namespace() 131 | end 132 | end 133 | 134 | describe "base_iri/0" do 135 | test "when base namespace defined" do 136 | assert IdSpecs.FlatBase.base_iri() == ~I 137 | assert IdSpecs.NestedBase.base_iri() == ~I 138 | end 139 | 140 | test "when no base namespace defined" do 141 | refute IdSpecs.FlatNs.base_iri() 142 | refute IdSpecs.FlatNsWithVocabTerms.base_iri() 143 | refute IdSpecs.NestedNs.base_iri() 144 | end 145 | end 146 | 147 | describe "prefix_map/0" do 148 | test "returns a RDF.PrefixMap of all namespaces with prefixes defined" do 149 | assert IdSpecs.FlatNs.prefix_map() == PrefixMap.new(ex: EX) 150 | assert IdSpecs.FlatNsWithVocabTerms.prefix_map() == PrefixMap.new(ex: EX) 151 | assert IdSpecs.FlatBase.prefix_map() == PrefixMap.new() 152 | 153 | assert IdSpecs.NestedNs.prefix_map() == 154 | PrefixMap.new( 155 | ex: EX, 156 | foo: "#{EX.foo()}/", 157 | bar: "#{EX.foo()}/bar/", 158 | baz: "#{EX.foo()}/baz/", 159 | qux: EX.__base_iri__() <> "qux/" 160 | ) 161 | end 162 | end 163 | 164 | describe "id_schemas/0" do 165 | test "returns all id schemas" do 166 | assert IdSpecs.GenericIds.id_schemas() == 167 | [ 168 | IdSpecs.GenericIds.expected_id_schema(Post), 169 | IdSpecs.GenericIds.expected_id_schema(User) 170 | ] 171 | 172 | assert IdSpecs.GenericShortIds.id_schemas() == 173 | [ 174 | IdSpecs.GenericShortIds.expected_id_schema(Post), 175 | IdSpecs.GenericShortIds.expected_id_schema(User) 176 | ] 177 | 178 | assert IdSpecs.UrnIds.id_schemas() == 179 | [ 180 | IdSpecs.UrnIds.expected_id_schema(:integer), 181 | IdSpecs.UrnIds.expected_id_schema(Post), 182 | IdSpecs.UrnIds.expected_id_schema(User) 183 | ] 184 | 185 | assert IdSpecs.GenericUuids.id_schemas() == 186 | [ 187 | IdSpecs.GenericUuids.expected_id_schema(Comment), 188 | IdSpecs.GenericUuids.expected_id_schema(Post), 189 | IdSpecs.GenericUuids.expected_id_schema(User) 190 | ] 191 | 192 | assert IdSpecs.HashUuids.id_schemas() == 193 | [ 194 | IdSpecs.HashUuids.expected_id_schema(Post), 195 | IdSpecs.HashUuids.expected_id_schema(User) 196 | ] 197 | 198 | assert IdSpecs.ShortUuids.id_schemas() == 199 | [ 200 | IdSpecs.ShortUuids.expected_id_schema(Example.SelfLinked), 201 | IdSpecs.ShortUuids.expected_id_schema(Comment), 202 | IdSpecs.ShortUuids.expected_id_schema(Post), 203 | IdSpecs.ShortUuids.expected_id_schema(User) 204 | ] 205 | 206 | assert IdSpecs.Hashing.id_schemas() == 207 | [ 208 | IdSpecs.Hashing.expected_id_schema(Comment), 209 | IdSpecs.Hashing.expected_id_schema(Post), 210 | IdSpecs.Hashing.expected_id_schema(User) 211 | ] 212 | 213 | assert IdSpecs.HashUrns.id_schemas() == 214 | [ 215 | IdSpecs.HashUrns.expected_id_schema(Comment), 216 | IdSpecs.HashUrns.expected_id_schema(Post) 217 | ] 218 | 219 | assert IdSpecs.UuidUrns.id_schemas() == 220 | [ 221 | IdSpecs.UuidUrns.expected_id_schema(Post), 222 | IdSpecs.UuidUrns.expected_id_schema(User) 223 | ] 224 | 225 | assert IdSpecs.BlankNodes.id_schemas() == 226 | [ 227 | IdSpecs.BlankNodes.expected_id_schema(Example.Datatypes), 228 | IdSpecs.BlankNodes.expected_id_schema(Example.WithBlankNodeIdSchema), 229 | IdSpecs.BlankNodes.expected_id_schema(Example.SelfLinked), 230 | IdSpecs.BlankNodes.expected_id_schema(User) 231 | ] 232 | 233 | assert IdSpecs.WithCounter.id_schemas() == 234 | [ 235 | IdSpecs.WithCounter.expected_id_schema(Comment), 236 | IdSpecs.WithCounter.expected_id_schema(Post), 237 | IdSpecs.WithCounter.expected_id_schema(User) 238 | ] 239 | 240 | assert IdSpecs.OptionInheritance.id_schemas() == 241 | [ 242 | IdSpecs.OptionInheritance.expected_id_schema(Example.Datatypes), 243 | IdSpecs.OptionInheritance.expected_id_schema(Example.SelfLinked), 244 | IdSpecs.OptionInheritance.expected_id_schema(Comment), 245 | IdSpecs.OptionInheritance.expected_id_schema(Post), 246 | IdSpecs.OptionInheritance.expected_id_schema(User) 247 | ] 248 | end 249 | 250 | test "id schemas for multiple schemas" do 251 | assert IdSpecs.MultipleSchemas.id_schemas() == 252 | [ 253 | IdSpecs.MultipleSchemas.expected_id_schema(:content), 254 | IdSpecs.MultipleSchemas.expected_id_schema(:foo) 255 | ] 256 | end 257 | 258 | test "id schemas with custom selectors" do 259 | assert IdSpecs.CustomSelector.id_schemas() == 260 | [ 261 | IdSpecs.CustomSelector.expected_id_schema(:uuid4), 262 | IdSpecs.CustomSelector.expected_id_schema(:uuid5), 263 | IdSpecs.CustomSelector.expected_id_schema(:foo) 264 | ] 265 | end 266 | 267 | test "id schemas with var_mapping" do 268 | assert IdSpecs.VarMapping.id_schemas() == 269 | [ 270 | IdSpecs.VarMapping.expected_id_schema(Example.VarMappingC), 271 | IdSpecs.VarMapping.expected_id_schema(Example.VarMappingB), 272 | IdSpecs.VarMapping.expected_id_schema(Example.VarMappingA) 273 | ] 274 | end 275 | end 276 | 277 | describe "custom_select_id_schema/2" do 278 | test "when Id.Schema can be found" do 279 | assert Id.Spec.custom_select_id_schema( 280 | IdSpecs.CustomSelector, 281 | Example.WithCustomSelectedIdSchemaA, 282 | %{} 283 | ) == 284 | %{ 285 | IdSpecs.CustomSelector.expected_id_schema(:foo) 286 | | schema: Example.WithCustomSelectedIdSchemaA 287 | } 288 | 289 | assert Id.Spec.custom_select_id_schema( 290 | IdSpecs.CustomSelector, 291 | Example.WithCustomSelectedIdSchemaB, 292 | %{bar: "bar"} 293 | ) == 294 | %{ 295 | IdSpecs.CustomSelector.expected_id_schema(:uuid4) 296 | | schema: Example.WithCustomSelectedIdSchemaB 297 | } 298 | 299 | assert Id.Spec.custom_select_id_schema( 300 | IdSpecs.CustomSelector, 301 | Example.WithCustomSelectedIdSchemaB, 302 | %{bar: "test"} 303 | ) == 304 | %{ 305 | IdSpecs.CustomSelector.expected_id_schema(:uuid5) 306 | | schema: Example.WithCustomSelectedIdSchemaB 307 | } 308 | end 309 | 310 | test "when no Id.Schema can be found" do 311 | assert Id.Spec.custom_select_id_schema( 312 | IdSpecs.CustomSelector, 313 | Example.WithCustomSelectedIdSchemaB, 314 | %{bar: ""} 315 | ) == 316 | nil 317 | end 318 | end 319 | 320 | test "multiple usages of the same custom selector raises an error" do 321 | assert_raise ArgumentError, 322 | "custom selector {Grax.Id.SpecTest.MultipleCustomSelectorUsage, :test_selector} is already used for another id schema", 323 | fn -> 324 | defmodule MultipleCustomSelectorUsage do 325 | use Grax.Id.Spec 326 | 327 | namespace "http://example.com/" do 328 | id_schema "foo/{foo}", selector: :test_selector 329 | id_schema "bar/{bar}", selector: :test_selector 330 | end 331 | 332 | def test_selector(_, _), do: true 333 | end 334 | end 335 | end 336 | end 337 | --------------------------------------------------------------------------------