Absinthe manages to do a lot of interesting things during its compilation process, and today we're going to look a bit at how that works. We'll look closely at how it uses some metaprograming tricks and module attributes to provide compile-time schema validation for us.
It's pretty amazing (to me, at least) that when we use Absinthe, we can have a really simple, easy-to-use API to define our schema and we still get a good amount of compile-time type checking out of it! For example, if we try and use a type that hasn't yet been defined, we'll see an error like this in our terminal when we try and compile our application:
The fact that this happens is cool on its own, but how they manage to do this is what I think is really cool. It takes a lot of really tricky (but interesting) usage of modules and module attributes to make it work, and that's what we'll be covering today. But before we can get to the actual type checking, we need to take a quick look at how one defines a schema with Absinthe, and then how that schema is compiled to create those modules and module attributes using Elixir compilation callbacks.
Defining a Schema with Absinthe
To define our GraphQL schema using Absinthe, we need to write a single module in which that schema
is declared, and in that module we need to use Absinthe.Schema
. If your schema is small enough
then doing that in one file is easy enough:
However, once you start building out your application and things get bigger, you generally end up
breaking the schema up into multiple "schema fragment" files and importing the types defined in
those fragments into your schema using the Absinthe.Schema.Notation.import_types/2
and the Absinthe.Schema.Notation.import_fields/2
macros.
To do that with our schema above we might end up doing something like what is below, with each set
of types defined in its own module, each of which calls use Absinthe.Schema.Notation
.
We can imagine that each module is defined in its own file, although they technically don't need
to be:
But how does Absinthe know that when we're referencing the :post
type in the definition of our
:user
type, the :post
is a valid type to use? Well, that's where the fun stuff come in!
How Elixir's Compilation Callbacks Work
Well, to know how Absinthe works its magic, first we need to know a bit about Elixir's compilation
callbacks. A compilation callback is, as it sounds, a function that is executed either before,
during, or after compilation takes place. There are a three compilation callbacks, but the two we
care about for today are the
@before_compile
and
@after_compile
callbacks.
These are two functions that are called, as you would assume, before and after compilation of
a module. The before_compile
callback receives as an argument the compilation __ENV__
, which
is a struct containing information about the compilation process. More info on what exactly is in
there can be found in the docs for Macro.Env
.
Likewise, the after_compile
callback receives that same compilation __ENV__
, and also the
compiled bytecode for the module.
These two callbacks give us the opportunity to set up some things that might be needed for
compilation in our before_compile
callback, and then some checking of things that have just been
compiled in our after_compile
callback. That's exactly how Absinthe uses those two features
for its schema compilation and schema validation.
How Absinthe Does Schema Validation at Compile Time
So, what exactly is Absinthe doing when it compiles? Well, let's start with the compilation of
those schema fragments. Absinthe.Schema.Notation
contains a definition of a __before_compile__/1
function
which is used as the handler for the @before_compile
callback for each of those schema
fragments.
At first the code in that function might be tricky to understand, but the most important part of
understanding what's going on there is looking at the definition of the __absinthe_blueprint__/0
function. We can see that we're defining a function that returns a map, and that map contains a
lot of information about the state of things before the current schema fragment was compiled.
This __absinthe_blueprint__/0
function will be really important in the final compilation step
that we'll look at in a bit.
One other really intersting thing about this code this is important to notice is how many calls to
Module.get_attribute/2
there are! This is one of the things that Absinthe leans on heavily for
this compilation process - the use of modules and module attributes as essentially defining global
variables that can be accessed by other modules during their compilation! There are a lot of
calls to Module.get_attribute/2
and Module.put_attribute/3
in this module, and recognizing
this pattern helps us put the rest of the process into context.
The other thing happening here is that we're defining a lot of functions in a dynamically named
module! These functions contain yet more information, and we can see a bit more of how this is
used in the __before_compile__/1
function defined in Absinthe.Schema
:
When each schema fragment is defined, it also defines a module that contains the information about
the module that was just defined - so for example, for our BlogWeb.Schema.UserTypes
module that
we used above, it will define a BlogWeb.Schema.UserTypes.Compiled
module. With this convention,
it allows Absinthe know where to look for information for each module that was compiled with some
schema information.
And now that all that work has been done during the compilation process, we can look at the
__after_compile__/2
callback defined in Absinthe.Schema
:
This is where all that information and all that metaprogramming is actually used for some helpful
user features! In short, that callback will use all of the information that's been stored in
various module attributes and exposed by defining all of those different functions in all of those
.Compiled
modules to build up something that Absinthe calls a blueprint. This blueprint is
again what it sounds like - it contains the information for how documents will later by evaluated
against the current GraphQL schema during resolution. It then evaluates this blueprint, and if
there are any errors returned from that evaluation they're raised at the end of the compilation
process!
Clearly this is kind of a compilcated process, but it's also a cool way to use some of the basic features of the Elixir compiler to deliver value to users. Exploring this process helped me learn a lot about this method of compilation of applications, but it also made it clear to me that the Absinthe team has put a great deal of time and effort into making this user experience really great, and for that I'm very thankful!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!