Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support attributes and direct element content in Builder #7

Merged
merged 7 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ Sassone

Sassone is an XML SAX parser and encoder in Elixir that focuses on speed, usability and standard compliance.

Sassone was born as a fork of the great [saxy][saxy] library to address some limitations we encountered,
fix bugs with XML standards compliance and add features we felt where missing for our specific use cases.
Sassone was born as a fork of the great [saxy][saxy] library to fix bugs, address some limitations
with XML standards compliance and add some missing features like namespaces and mapping to structs.

Comply with [Extensible Markup Language (XML) 1.0 (Fifth Edition)](https://www.w3.org/TR/xml/).

Expand All @@ -16,8 +16,7 @@ Comply with [Extensible Markup Language (XML) 1.0 (Fifth Edition)](https://www.w
* An incredibly fast XML 1.0 SAX parser.
* An extremely fast XML encoder.
* Native support for streaming parsing large XML files.
* Parse XML documents into simple DOM format.
* Support quick returning in event handlers.
* Support for automatically building and parsing XML with structs.

## Installation

Expand Down Expand Up @@ -91,8 +90,8 @@ iex> xml = "<?xml version='1.0' ?><foo bar='value'></foo>"
iex> Sassone.parse_string(xml, MyEventHandler, [])
{:ok,
[{:end_document},
{:end_element, "foo"},
{:start_element, "foo", [{"bar", "value"}]},
{:end_element, {nil, "foo"}},
{:start_element, {nil, "foo"}, [{nil, "bar", "value"}]},
{:start_document, [version: "1.0"]}]}
```

Expand Down Expand Up @@ -178,9 +177,9 @@ iex> struct(struct, map)
%Person{gender: "female", name: "Alice"}
```

In case of deeply nested data, this can prove difficult. In that case, you can use a library
to handle the conversion to struct. `Ecto` with embedded schemas is great to cast and validate
data.
In case of deeply nested data or custom data types, this can prove difficult. In that case, you
can use a library to handle the conversion to struct. `Ecto` with embedded schemas is great to
cast and validate data.

For example, assuming you defined `Person` as an embedded `Ecto` schema with a `changeset/2` function:

Expand All @@ -205,7 +204,7 @@ end

```elixir
iex> struct.changeset(struct(schema), map) |> Ecto.Changeset.apply_action(:cast)
%Person{gender: "female", name: "Alice"}
{:ok, %Person{gender: "female", name: "Alice"}}
```

See `Sassone.Builder` for the full Builder API documentation.
Expand Down Expand Up @@ -256,8 +255,7 @@ Some quick and biased conclusions from the benchmark suite:
* For XML builder and encoding, Sassone is usually 10 to 30 times faster than [XML Builder](https://github.com/joshnuss/xml_builder).
With deeply nested documents, it could be 180 times faster.
* Sassone significantly uses less memory than XML Builder (4 times to 25 times).
* Sassone significantly uses less memory than Xmerl, Erlsom and Exomler (1.4 times
10 times).
* Sassone significantly uses less memory than Xmerl, Erlsom and Exomler (1.4 times to 10 times).

## Limitations

Expand Down
54 changes: 46 additions & 8 deletions lib/sassone/builder.ex
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
defprotocol Sassone.Builder do
@moduledoc """
Protocol to implement XML serialization and deserialization for a struct.

You can derive or implement this protocol for your structs
Protocol to implement XML building and parsing for structs.

You can derive or implement this protocol for your structs.
When deriving the protocol, these are the supported options:

#{Sassone.Builder.Field.__schema__() |> NimbleOptions.new!() |> NimbleOptions.docs()}

The builder allows nesting of other structs implementing `Sassone.Builder`
via the `struct` field option.

The generated parser returns a map with atom keys you can pass to `struct/2`
or `struct!/2` to obtain a struct.

> #### Data validation {: .neutral}
>
> Transforming a map with nested structs and/or values into data
> types other than strings, such as dates, datetimes, etc. might
> prove complex and error prone and is out of scope for `Sassone`.
>
> In this case, using a library to define your struct, validate and
> transform your data, both before building and after parsing, is
> probably a good idea.
>
> `Ecto` with [embedded schemas](https://hexdocs.pm/ecto/embedded-schemas.html)
> is a great way to do this, and naturally fits the `Sassone.Builder` model.

> #### XML elements order {: .warning}
>
> In XML documents, the order in which elements appear is meaningful.
>
> The builder protocol preserves field ordering, so if you need fields to be
> mapped to elments appearing in a a specific order in XML when building with
> `Sassone.XML.build/2`, be sure to list them in that spefic order in the `fields`
> option.
>
> Also note that ordering is not enforced by the parser, so parsing is not strict
> in that sense and the generated parser will parse elements refardless of the order
> in which they appear in the XML document.
"""

alias Sassone.XML
alias Sassone.Builder.Field

@typedoc "A strut implementing `Sassone.Builder`"
Expand All @@ -23,6 +55,7 @@ defprotocol Sassone.Builder do
@doc """
Builds the struct for encoding with `Sassone.encode!/2`
"""
@spec build(t) :: XML.element() | nil
def build(struct)

@doc """
Expand Down Expand Up @@ -79,14 +112,19 @@ defimpl Sassone.Builder, for: Any do

fields =
Enum.map(options[:fields], fn {name, field_options} ->
xml_name = field_options[:name] || recase(to_string(name), options[:case])
case =
if options[:type] == :attribute do
options[:attribute_case]
else
options[:element_case]
end

xml_name = field_options[:name] || recase(to_string(name), case)
%Field{struct(Field, field_options) | xml_name: xml_name, name: name}
end)

{elements, attributes} =
Enum.split_with(fields, fn %Field{} = field ->
field.type == :element
end)
{attributes, elements} =
Enum.split_with(fields, fn %Field{} = field -> field.type == :attribute end)

start_document = generate_start_document(module)
end_document = generate_end_document()
Expand Down
44 changes: 26 additions & 18 deletions lib/sassone/builder/field.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,42 @@ defmodule Sassone.Builder.Field do
A struct representing the builder options for a struct field.
"""

@type type :: :element | :attribute

@type name :: atom()

@type t :: %__MODULE__{
name: name(),
parse: boolean(),
build: boolean(),
many: boolean(),
name: atom(),
namespace: String.t() | nil,
parse: boolean(),
struct: module(),
build: boolean(),
type: type(),
type: :attribute | :content | :element,
xml_name: String.t()
}

@enforce_keys [:name, :parse, :build, :type, :xml_name]
@enforce_keys [:name, :type, :xml_name]
defstruct build: true,
name: nil,
many: false,
name: nil,
namespace: nil,
parse: true,
xml_name: nil,
struct: nil,
type: nil
type: nil,
xml_name: nil

schema = [
case: [
doc: "Recase the struct field names automatically with the given strategy.",
attribute_case: [
doc: "Rename the struct fields of type `:attribute` with the given strategy in XML.",
type: {:in, [:pascal, :camel, :snake, :kebab]},
default: :pascal
default: :snake
],
debug: [doc: "Enable debug for parser generation.", type: :boolean, default: false],
element_case: [
doc: "Rename the struct fields of type `:element` with the given strategy in XML.",
type: {:in, [:pascal, :camel, :snake, :kebab]},
default: :pascal
],
fields: [
doc:
"Resource fields to map to XML. The order of elements will be preserved in the generated XML.",
"Struct fields to map to XML. The order of elements will be preserved in the generated XML.",
type: :keyword_list,
keys: [
*: [
Expand All @@ -60,13 +63,18 @@ defmodule Sassone.Builder.Field do
doc: "Custom field name for parsing and building. It will be used as-is.",
type: :string
],
namespace: [
doc: "Namespace to apply to the field. It will be used as-is.",
type: {:or, [:string, nil]},
default: nil
],
struct: [
doc: "A struct deriving `Sibill.Builder` used to parse and build this element.",
type: :atom
],
type: [
doc: "How the field is represented in XML: `:element`, `:attribute`, `:content`.",
type: {:in, [:element, :attribute]},
type: {:in, [:attribute, :content, :element]},
default: :element
]
]
Expand All @@ -80,7 +88,7 @@ defmodule Sassone.Builder.Field do
default: nil
],
root_element: [
doc: "XML root element. This applies only to the toplevel Resource when (de)serializing.",
doc: "XML root element. This applies only to the toplevel struct when parsing.",
type: :string,
default: "Root"
]
Expand Down
15 changes: 8 additions & 7 deletions lib/sassone/xml.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ defmodule Sassone.XML do
def processing_instruction(name, instruction),
do: {:processing_instruction, name, Encoder.encode(instruction)}

@doc "Builds a struct for encoding with `Sassone.encode!/2`"
@doc "Builds a struct deriving `Sassone.Builder` for encoding with `Sassone.encode!/2`"
@spec build(Builder.t(), name()) :: element()
def build(struct, element_name) do
attributes =
Expand All @@ -118,8 +118,8 @@ defmodule Sassone.XML do

defp build_attribute(_field, nil, attributes), do: attributes

defp build_attribute(field, value, attributes),
do: [attribute(field.xml_name, value) | attributes]
defp build_attribute(%Field{} = field, value, attributes),
do: [attribute(field.namespace, field.xml_name, value) | attributes]

defp build_elements(_struct, %Field{build: false}, elements),
do: elements
Expand All @@ -131,15 +131,16 @@ defmodule Sassone.XML do
do: elements

defp build_element(%Field{} = field, values, elements)
when is_list(values) do
Enum.reduce(values, elements, &build_element(field, &1, &2))
end
when is_list(values),
do: Enum.reduce(values, elements, &build_element(field, &1, &2))

defp build_element(%Field{type: :content}, value, elements), do: [characters(value) | elements]

defp build_element(%Field{} = field, value, elements) do
if Builder.impl_for(value) do
[build(value, field.xml_name) | elements]
else
[element(field.xml_name, [], [characters(value)]) | elements]
[element(field.namespace, field.xml_name, [], [characters(value)]) | elements]
end
end
end
33 changes: 33 additions & 0 deletions test/sassone/builder_test.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
defmodule Sassone.BuilderTest do
use ExUnit.Case, async: true

alias Sassone.Builder
alias Sassone.TestSchemas.Person

describe "building" do
test "encode simple schema" do
assert ~s|<person gender="female"><name>Alice</name><surname>Cooper</surname>A nice girl.</person>| =
Builder.build(%Person{
gender: "female",
name: "Alice",
surname: "Cooper",
bio: "A nice girl."
})
|> Sassone.encode!()
end
end

describe "parsing" do
test "decode simple schema" do
assert {:ok, {struct, attrs}} =
Sassone.parse_string(
~s|<person gender="male"><name>Bob</name><surname>Price</surname>A friendly mate.</person>|,
Builder.handler(%Person{}),
nil
)

assert Person == struct
# assert attrs.gender == "male"
lucacorti marked this conversation as resolved.
Show resolved Hide resolved
assert attrs.name == "Bob"
assert attrs.surname == "Price"
# assert attrs.bio == "A friendly mate."
end
end
end
20 changes: 20 additions & 0 deletions test/support/test_schemas.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
defmodule Sassone.TestSchemas do
@moduledoc false

defmodule Person do
@moduledoc false

@derive {
Sassone.Builder,
element_case: :snake,
root_element: "person",
fields: [
gender: [type: :attribute],
name: [type: :element],
surname: [type: :element],
bio: [type: :content]
]
}
defstruct [:bio, :gender, :name, :surname]
end
end