Skip to content

Conversation

@leonqadirie
Copy link
Contributor

@leonqadirie leonqadirie commented Jan 18, 2026

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

Partially addresses #51 by providing a builder API for Sections and Entities.
This was an experiment to understand Spark better, I thought the builder API might be interesting to dive into how the DSL works and things got a bit out of hand .

More of a discussion PR. Happy to know if it seems valuable and if yes, if the API matches expectations and what would be the required feature set to suffice for incorporation. Otherwise, it can also just be closed.

Caution

Disclaimer: I used AI to discuss the codebase and some parts were contributed by it (vetted by me, but I'm not an Elixir pro and not confident I understood all intricacies)

Also of note:
This builder here validates a bit 'earlier' than the DSL instead of waiting for Spark.Dsl.Entity.build/5:

  • requires :name
  • requires :target
  • args reference schema keys
  • no duplicate args
  • not singleton_entity_keys as I don't fully understand yet how sth. like patchable or overall how Extensions should work in a builder.

It could also just not validate them akin to the DSL itself, but in a builder API, it felt better to fail earlier?


Example usage

attribute =
  Entity.new(:attribute, MyApp.Attribute)
  |> Entity.args([:name, :type])
  |> Entity.schema([
    Field.new(:name) |> Field.type(:atom) |> Field.required() |> Field.doc("The attribute name"),
    # this syntax would also work: name: [type: :atom, required: true, doc: "The attribute name"]
    Field.new(:type) |> Field.type(Type.one_of([:string, :integer, :boolean])) |> Field.required(),
    Field.new(:default) |> Field.type(:any),
    Field.new(:constraints) |> Field.type(Type.keyword_list()) |> Field.default([])
  ])
  |> Entity.identifier(:name)

belongs_to =
  Entity.new(:belongs_to, MyApp.Relationship)
  |> Entity.args([:name])
  |> Entity.schema([
    Field.new(:name) |> Field.type(:atom) |> Field.required(),
    Field.new(:target) |> Field.type(Type.behaviour(MyApp.Resource)) |> Field.required(),
    Field.new(:foreign_key) |> Field.type(:atom),
    Field.new(:define_attribute?) |> Field.type(Type.boolean()) |> Field.default(true)
  ])

Section.new(:resource)
|> Section.describe("Define a data resource")
|> Section.top_level()
|> Section.entities([attribute, belongs_to])
|> Section.nested_section(
  Section.new(:timestamps)
  |> Section.schema([
    Field.new(:enabled?) |> Field.type(Type.boolean()) |> Field.default(true),
    Field.new(:inserted_at) |> Field.type(:atom) |> Field.default(:inserted_at),
    Field.new(:updated_at) |> Field.type(:atom) |> Field.default(:updated_at)
  ])
)
|> Section.build!()

Entities can be passed as builder structs (auto-built), explicitly built, or as functions:

# Builder struct (auto-built)
Section.new(:resource) |> Section.entity(attribute)

# Explicitly built
Section.new(:resource) |> Section.entity(Entity.build!(attribute))

# Function (lazy, auto-built)
Section.new(:resource) |> Section.entity(fn -> attribute end)

@zachdaniel
Copy link
Contributor

This is pretty cool! One major piece of feedback: IMO for the field/value pairs, those should just be options to a function, i.e

Field.new(:type) |> Field.type(Type.one_of([:string, :integer, :boolean])) |> Field.required(),

# should just be:

Field.new(:type, type: .., required?: ...)

I'm also not sure about enumerating all of the types as functions like that, i.e Type.one_of .... It has some benefits of autocomplete, but also would require maintaining corresponding functions for all types.

I don't think I see a test for using this in an actual DSL (the tests test the built data) but it might be good to have those and maybe an example guide on how to use this to define an extension.

@leonqadirie
Copy link
Contributor Author

leonqadirie commented Jan 20, 2026

Cool!

  • Adopted the more data-focused API style and removed alternatives for Fields. NOT for Sections or Entities, though. Shall these also be refactored accordingly?
  • Removed the Type helper module
  • Added dsl_integration_test.exs
  • Added build-extensions-with-builders.md
  • Removed function acceptance from Entity.nested_entity/2, Section.entity/1, and Section.nested_section/1, as I'm unsure of actual use cases and adding them if needed is better than wanting to remove them once released. Lazy evaluation seems to happen via recursive_as if I understand this correctly. Are they important to have?

I also stumbled over lib/spark/options/options.ex:486's {:rename_to, atom} as part of the @type option_schema. Currently seems to be dead code, shall this be removed or handled in any way?


Finally, I suspect the failing mix spark.cheat_sheets --check CI job to be due to stale cached expectations of which modules should exist, but not to be handled in this PR?

@leonqadirie leonqadirie marked this pull request as ready for review January 20, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants