From 3eb7b459464b23bcd272c7d85b32182c2473af55 Mon Sep 17 00:00:00 2001 From: Jason Allum <21417+jallum@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:51:59 -0400 Subject: [PATCH] Support aligned and nested structs --- CHANGELOG.md | 6 ++ README.md | 2 +- lib/flatbuffer/reading.ex | 13 +-- lib/flatbuffer/schema.ex | 87 ++++++++++++++++++++- lib/flatbuffer/utils.ex | 20 ++++- lib/flatbuffer/writer.ex | 38 ++++++--- test/fixtures/flatc/interop.fbs | 15 ++++ test/fixtures/flatc/interop.json | 14 ++++ test/fixtures/flatc/read_interop.cc | 18 +++++ test/flatbuffer/schema/table_test.exs | 12 ++- test/flatbuffer/schema/union_types_test.exs | 6 +- test/flatbuffer/schema_test.exs | 36 +++++++++ test/flatbuffer/sharing_test.exs | 51 ++++++++++++ test/integration/flatc_interop_test.exs | 12 ++- 14 files changed, 299 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e76062..2e28d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Structs now follow FlatBuffers alignment rules, including internal and trailing padding, + nested structs, and aligned struct vectors. Buffers are verified bidirectionally against + `flatc`. + ## 0.5.2 — 2026-08-28 - **Schema includes no longer require a `root_type` in every file.** Previously each included `.fbs` file had to declare its own `root_type` or the whole load failed with `:root_type_is_undefined`, which made real-world, flatc-style type-only include files unusable. The root type is now resolved once, after all includes are merged, which also means the top-level `root_type` can reference a type defined in an included file: diff --git a/README.md b/README.md index 8e512ee..467784b 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ Flatbuffer. CI runs them on the newest supported Elixir/OTP combination. * shared strings (`shared` field attribute) * shared vtables * structs +* alignment * unions * enums * defaults @@ -181,5 +182,4 @@ Flatbuffer. CI runs them on the newest supported Elixir/OTP combination. ### features only in flatc -* alignment * additional attributes other than `shared` diff --git a/lib/flatbuffer/reading.ex b/lib/flatbuffer/reading.ex index 9bb9f41..3502c5b 100644 --- a/lib/flatbuffer/reading.ex +++ b/lib/flatbuffer/reading.ex @@ -48,16 +48,11 @@ defmodule Flatbuffer.Reading do end def read({:struct, %{name: struct_name}}, c, schema) do - {:struct, %{members: members}} = Map.get(schema.entities, struct_name) + {:struct, %{layout: layout}} = Map.fetch!(schema.entities, struct_name) - {struct, _offset} = - members - |> Enum.reduce({%{}, 0}, fn {name, type}, {acc, offset} -> - value = read({type, %{}}, Cursor.skip(c, offset), schema) - {Map.put(acc, name, value), offset + Utils.scalar_size(type)} - end) - - struct + Enum.reduce(layout, %{}, fn {name, type, offset}, struct -> + Map.put(struct, name, read(type, Cursor.skip(c, offset), schema)) + end) end def read({:table, %{name: table_name}}, c, schema) do diff --git a/lib/flatbuffer/schema.ex b/lib/flatbuffer/schema.ex index 9348edc..58167da 100644 --- a/lib/flatbuffer/schema.ex +++ b/lib/flatbuffer/schema.ex @@ -76,7 +76,14 @@ defmodule Flatbuffer.Schema do field_ids: %{String.t() => non_neg_integer()} }} - @type struct_def :: {:struct, %{members: [{field_name(), type_def()}]}} + @type struct_def :: + {:struct, + %{ + members: [{field_name(), type_def()}], + layout: [{field_name(), type_ref(), non_neg_integer()}], + alignment: pos_integer(), + size: non_neg_integer() + }} @type union_def :: {:union, @@ -98,6 +105,8 @@ defmodule Flatbuffer.Schema do | {:error, {:root_type_not_found, type_name :: String.t()}} | {:error, {:root_type_is_not_a_table, type_name :: String.t()}} | {:error, {:type_not_found, type_name :: String.t()}} + | {:error, {:recursive_struct, [type_name()]}} + | {:error, {:invalid_struct_member, type_name(), type_ref()}} @doc """ Reads and parses a FlatBuffer schema from a file. @@ -321,15 +330,85 @@ defmodule Flatbuffer.Schema do Map.put(acc, key, {:union, %{members: members}}) {key, {:struct, fields}}, acc -> - members = Enum.map(fields, fn {name, type} -> {output_name(name, safe), type} end) - Map.put(acc, key, {:struct, %{members: members}}) + Map.put(acc, key, {:struct, struct_options(key, fields, entities, safe)}) end )} catch - {:error, {:type_not_found, _type_name}} = error -> + {:error, _reason} = error -> error end + defp struct_options(name, fields, entities, safe) do + {members, layout, offset, alignment} = + Enum.reduce(fields, {[], [], 0, 1}, fn {field_name, field_type}, + {members, layout, offset, struct_alignment} -> + resolved_type = resolve_field_type(field_type, entities) + {size, alignment} = inline_type_measure(resolved_type, entities, [name]) + offset = Flatbuffer.Utils.align(offset, alignment) + output_name = output_name(field_name, safe) + + { + [{output_name, field_type} | members], + [{output_name, resolved_type, offset} | layout], + offset + size, + max(struct_alignment, alignment) + } + end) + + %{ + members: Enum.reverse(members), + layout: Enum.reverse(layout), + alignment: alignment, + size: Flatbuffer.Utils.align(offset, alignment) + } + end + + defp inline_type_measure({:struct, %{name: name}}, entities, parents) do + if name in parents do + throw({:error, {:recursive_struct, Enum.reverse([name | parents])}}) + end + + case Map.fetch!(entities, name) do + {:struct, fields} -> + {offset, alignment} = + Enum.reduce(fields, {0, 1}, fn {_field_name, field_type}, {offset, struct_alignment} -> + resolved_type = resolve_field_type(field_type, entities) + {size, alignment} = inline_type_measure(resolved_type, entities, [name | parents]) + offset = Flatbuffer.Utils.align(offset, alignment) + {offset + size, max(struct_alignment, alignment)} + end) + + {Flatbuffer.Utils.align(offset, alignment), alignment} + end + end + + defp inline_type_measure({:enum, %{name: name}}, entities, _parents) do + {{:enum, type}, _members} = Map.fetch!(entities, name) + size = Flatbuffer.Utils.scalar_size(type) + {size, size} + end + + defp inline_type_measure({type, _options}, _entities, _parents) + when type in [ + :bool, + :byte, + :ubyte, + :short, + :ushort, + :int, + :uint, + :float, + :long, + :ulong, + :double + ] do + size = Flatbuffer.Utils.scalar_size(type) + {size, size} + end + + defp inline_type_measure(type, _entities, parents), + do: throw({:error, {:invalid_struct_member, List.first(parents), type}}) + defp enumerate_members(members, safe) do {members, _next_value} = Enum.reduce(members, {%{}, 0}, fn member, {acc, next_value} -> diff --git a/lib/flatbuffer/utils.ex b/lib/flatbuffer/utils.ex index 480b006..96b2d89 100644 --- a/lib/flatbuffer/utils.ex +++ b/lib/flatbuffer/utils.ex @@ -1,6 +1,8 @@ defmodule Flatbuffer.Utils do @moduledoc false + def align(offset, alignment), do: offset + Integer.mod(-offset, alignment) + def scalar_size({type, _options}), do: scalar_size(type) def scalar_size(:byte), do: 1 def scalar_size(:ubyte), do: 1 @@ -21,8 +23,8 @@ defmodule Flatbuffer.Utils do end def sizeof({:struct, %{name: struct_name}}, schema) do - {:struct, %{members: members}} = Map.get(schema.entities, struct_name) - Enum.reduce(members, 0, fn {_, type}, acc -> acc + sizeof(type, schema) end) + {:struct, %{size: size}} = Map.fetch!(schema.entities, struct_name) + size end def sizeof({:table, _}, _), do: 4 @@ -31,4 +33,18 @@ defmodule Flatbuffer.Utils do def sizeof({:string, _}, _), do: 4 def sizeof(type, _), do: scalar_size(type) + + def alignment({:enum, %{name: enum_name}}, schema) do + {:enum, %{type: type}} = Map.fetch!(schema.entities, enum_name) + alignment(type, schema) + end + + def alignment({:struct, %{name: struct_name}}, schema) do + {:struct, %{alignment: alignment}} = Map.fetch!(schema.entities, struct_name) + alignment + end + + def alignment({type, _}, _schema) when type in [:string, :vector, :table], do: 4 + def alignment({type, _}, _schema), do: scalar_size(type) + def alignment(type, _schema), do: scalar_size(type) end diff --git a/lib/flatbuffer/writer.ex b/lib/flatbuffer/writer.ex index f5083a0..0cb2d29 100644 --- a/lib/flatbuffer/writer.ex +++ b/lib/flatbuffer/writer.ex @@ -269,11 +269,30 @@ defmodule Flatbuffer.Writer do end defp inline({:struct, %{name: struct_name}}, map, path, schema) when is_map(map) do - {:struct, %{members: members}} = Map.fetch!(schema.entities, struct_name) + {:struct, %{layout: layout, size: struct_size}} = + Map.fetch!(schema.entities, struct_name) + + {chunks, end_offset} = + Enum.reduce(layout, {[], 0}, fn {name, type, offset}, {chunks, end_offset} -> + padding = zero_padding(offset - end_offset) + data = inline(type, get_field(map, name), [name | path], schema) + size = Flatbuffer.Utils.sizeof(type, schema) + + chunks = + case padding do + <<>> -> [data | chunks] + _ -> [data, padding | chunks] + end + + {chunks, offset + size} + end) - Enum.map(members, fn {name, type} -> - inline({type, %{}}, get_field(map, name), [name | path], schema) - end) + tail_padding = zero_padding(struct_size - end_offset) + + case tail_padding do + <<>> -> Enum.reverse(chunks) + _ -> Enum.reverse([tail_padding | chunks]) + end end defp inline({:bool, _}, true, _path, _schema), do: <<1>> @@ -327,15 +346,10 @@ defmodule Flatbuffer.Writer do defp without_default(type), do: type - defp alignment({:enum, %{name: enum_name}}, schema) do - {:enum, %{type: type}} = Map.fetch!(schema.entities, enum_name) - alignment(type, schema) - end + defp alignment(type, schema), do: Flatbuffer.Utils.alignment(type, schema) - defp alignment({:struct, _}, _schema), do: 1 - defp alignment({type, _}, _schema) when type in [:string, :vector, :table], do: 4 - defp alignment({type, _}, _schema), do: Flatbuffer.Utils.scalar_size(type) - defp alignment(type, _schema), do: Flatbuffer.Utils.scalar_size(type) + defp zero_padding(0), do: <<>> + defp zero_padding(size), do: :binary.copy(<<0>>, size) defp push_raw(%State{} = state, data) when is_binary(data), do: %{state | chunks: [data | state.chunks], size: state.size + byte_size(data)} diff --git a/test/fixtures/flatc/interop.fbs b/test/fixtures/flatc/interop.fbs index 0b751b0..5b2f47a 100644 --- a/test/fixtures/flatc/interop.fbs +++ b/test/fixtures/flatc/interop.fbs @@ -12,6 +12,18 @@ struct Position { z: float; } +struct ScalarMix { + prefix: ubyte; + wide: ulong; + suffix: ushort; +} + +struct NestedMix { + flag: ubyte; + value: ScalarMix; + count: uint; +} + table Child { id: int; label: string; @@ -26,6 +38,9 @@ table Root { name: string; values: [int]; child: Child; + scalar_mix: ScalarMix; + scalar_mixes: [ScalarMix]; + nested_mix: NestedMix; } root_type Root; diff --git a/test/fixtures/flatc/interop.json b/test/fixtures/flatc/interop.json index c64513f..2734b7e 100644 --- a/test/fixtures/flatc/interop.json +++ b/test/fixtures/flatc/interop.json @@ -13,5 +13,19 @@ child: { id: 7, label: "nested" + }, + scalar_mix: { + prefix: 1, + wide: 72623859790382856, + suffix: 515 + }, + scalar_mixes: [ + { prefix: 2, wide: 17, suffix: 18 }, + { prefix: 3, wide: 19, suffix: 20 } + ], + nested_mix: { + flag: 4, + value: { prefix: 5, wide: 21, suffix: 22 }, + count: 4000000001 } } diff --git a/test/fixtures/flatc/read_interop.cc b/test/fixtures/flatc/read_interop.cc index a5ca209..8d6c9a2 100644 --- a/test/fixtures/flatc/read_interop.cc +++ b/test/fixtures/flatc/read_interop.cc @@ -50,5 +50,23 @@ int main(int argc, char **argv) { if (child == nullptr || child->id() != 7) return 16; if (child->label() == nullptr || child->label()->str() != "nested") return 17; + const ScalarMix *scalar_mix = root->scalar_mix(); + if (scalar_mix == nullptr || scalar_mix->prefix() != 1) return 18; + if (scalar_mix->wide() != UINT64_C(72623859790382856)) return 19; + if (scalar_mix->suffix() != 515) return 20; + + const auto *scalar_mixes = root->scalar_mixes(); + if (scalar_mixes == nullptr || scalar_mixes->size() != 2) return 21; + if (scalar_mixes->Get(0)->prefix() != 2 || scalar_mixes->Get(0)->wide() != 17 || + scalar_mixes->Get(0)->suffix() != 18) return 22; + if (scalar_mixes->Get(1)->prefix() != 3 || scalar_mixes->Get(1)->wide() != 19 || + scalar_mixes->Get(1)->suffix() != 20) return 23; + + const NestedMix *nested_mix = root->nested_mix(); + if (nested_mix == nullptr || nested_mix->flag() != 4) return 24; + if (nested_mix->value().prefix() != 5 || nested_mix->value().wide() != 21 || + nested_mix->value().suffix() != 22) return 25; + if (nested_mix->count() != UINT32_C(4000000001)) return 26; + return 0; } diff --git a/test/flatbuffer/schema/table_test.exs b/test/flatbuffer/schema/table_test.exs index 51bace8..f69456e 100644 --- a/test/flatbuffer/schema/table_test.exs +++ b/test/flatbuffer/schema/table_test.exs @@ -236,7 +236,17 @@ defmodule Flatbuffer.Schema.TableTest do "vector_of_struct" => 27 } }}, - "Struct" => {:struct, %{members: [field1: :byte, field2: :short]}}, + "Struct" => + {:struct, + %{ + members: [field1: :byte, field2: :short], + layout: [ + {:field1, {:byte, %{default: 0}}, 0}, + {:field2, {:short, %{default: 0}}, 2} + ], + alignment: 2, + size: 4 + }}, "ByteEnum" => {:enum, %{ diff --git a/test/flatbuffer/schema/union_types_test.exs b/test/flatbuffer/schema/union_types_test.exs index 0b44724..b6a3dc0 100644 --- a/test/flatbuffer/schema/union_types_test.exs +++ b/test/flatbuffer/schema/union_types_test.exs @@ -48,7 +48,11 @@ defmodule FlatBuffer.Schema.UnionTypesTest do field_ids: %{"data_type" => 0, "data" => 1, "additions_value" => 2} }}, "hello" => - {:table, %{fields: {{:salute, {:string, %{}}}}, field_ids: %{"salute" => 0}}} + {:table, + %{ + fields: {{:salute, {:string, %{}}}}, + field_ids: %{"salute" => 0} + }} }, root_type: {:table, %{name: "command_root"}}, id: "cmnd" diff --git a/test/flatbuffer/schema_test.exs b/test/flatbuffer/schema_test.exs index c9a35b9..7137a4f 100644 --- a/test/flatbuffer/schema_test.exs +++ b/test/flatbuffer/schema_test.exs @@ -36,6 +36,42 @@ defmodule Flatbuffer.SchemaTest do assert {:error, {:type_not_found, "Missing"}} == Schema.from_string(schema) end + test "rejects recursively defined structs" do + schema = """ + struct Recursive { + value: Recursive; + } + + table Root { + value: Recursive; + } + + root_type Root; + """ + + assert {:error, {:recursive_struct, ["Recursive", "Recursive"]}} == + Schema.from_string(schema) + end + + test "rejects reference types inside structs" do + schema = """ + table Child {} + + struct Invalid { + child: Child; + } + + table Root { + value: Invalid; + } + + root_type Root; + """ + + assert {:error, {:invalid_struct_member, "Invalid", {:table, %{name: "Child"}}}} == + Schema.from_string(schema) + end + test "ignores field attributes it does not recognize" do schema = """ table Root { diff --git a/test/flatbuffer/sharing_test.exs b/test/flatbuffer/sharing_test.exs index 78e8ebf..61f6165 100644 --- a/test/flatbuffer/sharing_test.exs +++ b/test/flatbuffer/sharing_test.exs @@ -220,6 +220,57 @@ defmodule Flatbuffer.SharingTest do assert rem(byte_size(buffer), 8) == 0 end end + + test "round-trips padded and nested structs as fields and vector elements" do + schema = + schema!(""" + enum Code : ushort { + FIRST, + SECOND + } + + struct ScalarMix { + prefix: ubyte; + code: Code; + wide: ulong; + suffix: ushort; + } + + struct NestedMix { + flag: ubyte; + value: ScalarMix; + count: uint; + } + + table Root { + scalar_mix: ScalarMix; + scalar_mixes: [ScalarMix]; + nested_mix: NestedMix; + nested_mixes: [NestedMix]; + } + + root_type Root; + """) + + scalar_mix = %{prefix: 1, code: :SECOND, wide: 72_623_859_790_382_856, suffix: 515} + + nested_mix = %{ + flag: 2, + value: %{prefix: 3, code: :FIRST, wide: 17, suffix: 18}, + count: 4_000_000_001 + } + + data = %{ + scalar_mix: scalar_mix, + scalar_mixes: [scalar_mix, %{prefix: 4, code: :SECOND, wide: 19, suffix: 20}], + nested_mix: nested_mix, + nested_mixes: [nested_mix] + } + + assert 24 == Flatbuffer.Utils.sizeof({:struct, %{name: "ScalarMix"}}, schema) + assert 40 == Flatbuffer.Utils.sizeof({:struct, %{name: "NestedMix"}}, schema) + assert data == data |> Flatbuffer.to_binary(schema) |> Flatbuffer.read!(schema) + end end defp schema!(source) do diff --git a/test/integration/flatc_interop_test.exs b/test/integration/flatc_interop_test.exs index eda5c46..df43206 100644 --- a/test/integration/flatc_interop_test.exs +++ b/test/integration/flatc_interop_test.exs @@ -18,7 +18,17 @@ defmodule Flatbuffer.FlatcInteropTest do position: %{x: 1.5, y: -2.25, z: 3.75}, name: "interoperable", values: [-2, 0, 3], - child: %{id: 7, label: "nested"} + child: %{id: 7, label: "nested"}, + scalar_mix: %{prefix: 1, wide: 72_623_859_790_382_856, suffix: 515}, + scalar_mixes: [ + %{prefix: 2, wide: 17, suffix: 18}, + %{prefix: 3, wide: 19, suffix: 20} + ], + nested_mix: %{ + flag: 4, + value: %{prefix: 5, wide: 21, suffix: 22}, + count: 4_000_000_001 + } } setup_all do