Updates in the Athenaverse
The major highlight of this release is the initial 0.1.0 release of the ORM component! It’s been a long time coming, and now ships with migrations and testing utilities. The other big theme is modularity: Athena is now the closest its ever been in its goal of having an ecosystem of independent, and reusable components. Athena Framework 0.23.0 made many of the components that used to be required, optional. Each component is also more aware of what components are currently installed and are generally wired up/integrated automatically when required.
Highlights:
- Initial release of
orm0.1.0, including database migrations and testing utilities - The
Serializer,Validator,Console,Clock, andNegotiationcomponents are now optional within theframeworkcomponent, with each component shipping its own bundle - Console-only applications without the framework, still with DI-powered commands
- Improved sub-command support in the
Consolecomponent, e.g../console docker compose up - The
Mercurecomponent now speaks version 1.0 of the Mercure protocol - Opt out of linking
libmagic, OpenSSL, and zlib via compile time flags
ORM 0.1.0
Alongside the last release I shared an alpha of the Athena ORM to start gathering feedback. I’m pleased to say it’s now released as 0.1.0!
As a quick recap of what makes it different from the other ORMs in Crystal land:
- No custom DSL, entities are normal Crystal classes that use annotations to map properties to columns, primary keys, and relationships
- Data Mapper instead of ActiveRecord, entities know nothing about the database, and are instead managed by an
AORM::EntityManager - A Unit of Work tracks changes to entities and writes them all in a single transaction when flushed. Only changed values are included in an
UPDATE - An Identity Map ensures each row is only ever represented by one object
- Native relationships, with collections lazily loaded the first time they’re used
require "athena-orm"
require "pg"
@[AORMA::Entity]
@[AORMA::Table(name: "posts")]
class Post < AORM::Entity
@[AORMA::Column]
@[AORMA::ID]
@[AORMA::GeneratedValue]
property! id : Int64
@[AORMA::Column]
property! title : String
end
ORM = AORM::EntityManagerFactory.new DB.open ENV["DATABASE_URL"]
ORM.with_entity_manager do |em|
post = Post.new
post.title = "Hello World"
em.persist post
em.flush # => INSERT INTO posts (title) VALUES (?)
post.title = "Hello Athena"
em.flush # => UPDATE posts SET title = ? WHERE id = ?
em.find(Post, post.id).same? post # => true
em.repository(Post).find_one_by title: "Hello Athena"
end
PostgreSQL, MySQL, MariaDB, and SQLite are supported out of the box, using the standard crystal-db driver shards. More could be added in the future, but I think this covers the more populare/common ones.
The ORM component of course works best when used within the framework/ecosystem, but can be used outside of it, even within other frameworks; mainly via the .with_entity_manager method in the earlier example. An example usage within the Athena ecosystem would be something along the lines of:
require "athena"
require "athena-orm/bundle" # This implicitly requires `athena-dependency_injection`
ADI.configure({
orm: {
url: ENV["DATABASE_URL"],
},
})
@[ADI::Register]
class PostController < ATH::Controller
def initialize(@em : AORM::EntityManagerInterface); end
@[ARTA::Get("/post/{id}")]
def post(id : Int64) : Post?
@em.find Post, id
end
end
It is worth calling out that “within the framework” really just means any shard using athena-dependency_injection for things to be wired up, NOT the entire framework component.
Migrations
Database migrations are opt-in via require "athena-orm/migrations", and are managed via orm:migrations:* console commands when the console component is installed and its bundle is registered. Each migration is a Crystal type that adds the SQL to apply its change in #up, and the SQL to revert it in #down:
class App::Migrations::Version20261014120000 < AORM::Migrations::AbstractMigration
def description : String
"Create the posts table"
end
def up : Nil
self.add_sql "CREATE TABLE posts (id BIGSERIAL NOT NULL, title VARCHAR(255) NOT NULL, PRIMARY KEY (id))"
end
def down : Nil
self.add_sql "DROP TABLE posts"
end
end
orm:migrations:generate generates a blank migration, orm:migrations:migrate executes the ones that haven’t been executed yet, and orm:migrations:list/orm:migrations:status show where things stand. Being Crystal types, migrations can also contain logic, such as skipping a migration on a specific platform, or migrating data. See the Database Migrations guide for more information.
Testing
The ORM also comes with helpers that run each test in a database transaction that is rolled back once the test is done, so tests don’t see each other’s data. Within the framework, include AORM::Spec::DatabaseTransaction into an ATH::Spec::APITestCase. Its #em is the same entity manager the container injects, so the requests made by the test run within the same transaction:
require "athena-orm/spec"
# Execute any pending migrations before the specs run
Spec.before_suite do
AORM::Spec.migrate_database
end
struct PostControllerTest < ATH::Spec::APITestCase
include AORM::Spec::DatabaseTransaction
def test_show : Nil
post = Post.new
post.title = "Hello World"
self.em.persist post
self.em.flush
self.get("/post/#{post.id}").body.should contain "Hello World"
end
end
Applications using an AORM::EntityManagerFactory directly can inherit from AORM::Spec::DBTestCase instead.
Modularity
Bundles went thru a re-think. The athena-mercure_bundle introduced last release, has been merged into the mercure component itself as an optional opt-in entrypoint. Additionally, components are now more aware of what other components are installed. E.g. the Validator component no longer requires the ImageSize component directly, but must be installed in order to validate the dimensions of images. Compile time errors are raised where possible, but some runtime exceptions could not be avoided.
The idea of making almost every component optional was touched on in the last release. This release essentially accomplished that goal! An Athena Framework application no longer comes bundled with the Serializer, Validator, Console, Clock, and Negotiation components. This ultimately results in smaller binaries that are faster to compile due to there being less extra code you don’t need.
The component based bundle approach used by the mercure component was also expanded to many of the other components. This is the primary mechanism used to integrate the optional components into the framework. First add the ones you want to your shard.yml and require them:
require "athena"
# Only require what the application needs
require "athena-serializer/bundle"
require "athena-validator/bundle"
require "athena-console/bundle"
require "athena-orm/bundle"
require "athena-mercure/bundle"
require "athena-clock"
require "athena-negotiation"
See the Optional Components section of the getting started guide for the full list. This change is of course breaking, but as usual, reference each component’s UPGRADING.md for more detailed migration instructions.
Console-only Applications
A nice side effect of this work is that the Dependency Injection component itself now registers the clock and event_dispatcher services, along with any AEDA::AsEventListener listeners, when those components are installed. Combined with the new console bundle, this means a console application gets the same DI-powered, lazily instantiated commands as it would within the framework, without having to require the framework, and with it, routing, the HTTP stack, etc:
require "athena-dependency_injection"
require "athena-console/bundle"
@[ADI::Register]
@[ACONA::AsCommand("app:create-user", description: "Creates a user")]
class CreateUserCommand < ACON::Command
def initialize(@user_repository : UserRepository)
super()
end
protected def execute(input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status
# ...
Status::SUCCESS
end
end
ACON::Application.new("My App", "1.0.0", ADI.container).run
System Dependencies
On the same theme, this release also does a pass on the system libraries a framework application links against. A web framework needs Crystal’s HTTP stack, and with it OpenSSL and zlib, but applications that don’t need some of them can now opt out at build time:
-Dwithout_libmagic- MIME types are only guessed based on file extensions-Dwithout_openssl-ATH.runcannot serve HTTPS, such as when TLS is terminated by a reverse proxy-Dwithout_zlib- Crystal’s gzip/deflate support is unavailable
An application built with all three only links PCRE2 and the C runtime libraries Crystal always requires. See the System Dependencies section of the getting started guide for more information.
Console Sub-commands
Commands that are namespaced, such as docker:compose:up, may now be invoked as ./console docker compose up, with each level parsing its own options:
# `docker` parses `--context`, `docker:compose` parses `-f`, and `docker:compose:up` parses `web`
./console docker --context=prod compose -f app.yaml up web
The new ACON::Commands::Group command type can be used for a level that only groups other commands, listing its sub-commands when invoked without one. The running command can also read the options of its ancestors via ACON::Application#command_chain. See the API Docs for more information.
Mercure Protocol 1.0
The Mercure protocol had its 1.0 release a while ago, which included quite a few breaking changes. The Mercure component, and its bundle, now speak it by default. Most notably the JWTs are now RFC 9068 access tokens built from AMC::Grants, and the default authorization cookie name is now __Secure-mercure_access_token.
Hubs older than 1.0 can keep using the previous version of the protocol:
mercure: {
hubs: {
default: {
url: ENV["MERCURE_URL"],
protocol_version: "0.x",
jwt: {
secret: ENV["MERCURE_JWT_SECRET"],
},
},
},
},
This release also fixes non-default hubs being removed from the hub registry, and only the last hub’s token aliases being registered, when configuring multiple hubs via the bundle. See the Mercure UPGRADING.md for more detailed instructions.
Smaller Items
- Event Dispatcher: Event listeners typed with a generic event, such as
MyEvent(Animal), are now invoked for events of all descendants of the generic type, e.g.MyEvent(Dog)andMyEvent(Cat) - Dependency Injection:
ADI::Spec::ContainerTestCasefor testing services that require a container, and an@[ADI::AsAlias]annotation can now replace an alias defined by a bundle - Spec: The
ASPEC::Methodsassertions accept aflagsparameter to compile the code with compile time flags
What’s Next?
At this point Athena has quite a few components that can be used in a wide range of contexts. However it is still missing some pretty core things as well; the main one being security related abstractions/features. As such, the next area of focus is going to be introducing some of the foundational parts of this that allow users to implement robust authorization and authentication logic without having to roll it themselves. I have some good plans for the design/implementation of this, but would love some feedback if anyone wants to share their specific use cases.
That’s it for this update! As usual, feel free to join me in the Athena Discord server if you have any suggestions, questions, ideas, or just want to chat!