loading...

Comparing ActiveRecord and LuckyRecord

jwoertink profile image Jeremy Woertink Updated on ・8 min read

TL;DR LuckyRecord was a great move, and is getting better all the time.

This isn't really a tutorial, but more just thoughts between how it feels to write code with ActiveRecord as my ORM VS using LuckyRecord as my ORM. They each use different patterns, so the thought process between the two are quite different.

I'm using postgres, and I have a database already setup with a users table that looks sort of like:

CREATE TABLE actors(
  id SERIAL PRIMARY KEY,
  name character varying,
  slug character varying,
  created_at timestamp NOT NULL DEFAULT now(),
  updated_at timestamp NOT NULL DEFAULT now()
);

Life with ActiveRecord

The gems needed are

# Gemfile
source "https://rubygems.org/"

# This comes with rails
gem 'activerecord'
gem 'pg'

Then to break out all the bits in to a single file

require "pg"
require "active_record" #c'mon rails, get your naming together >_<

ActiveRecord::Base.establish_connection(
  adapter: "postgresql",
  database: "my_database_name",
  username: "postgres",
  host: "localhost"
)

class Actor < ActiveRecord::Base
end

Now we can use our model to search and manipulate.

person = Actor.find(1)
puts "They call me Funky #{actor.name.capitalize}"

person.name = "Chris Pratt"
person.save

We find a very specific record by an ID, and then we can manipulate that record. Great stuff... That is, unless there isn't a record returned from the database. With ActiveRecord's find method, it will raise an exception when the record isn't found. This example is a bit contrived, so in reality you might have something more like

actor = Actor.find_by(slug: params[:slug])
puts "They call me Funky #{actor.name.capitalize}"

Where params being some input that comes from the user. Maybe you have the ID in your URL, but IDs in a URL can be leaky metadata, and not look as nice, especially for SEO reasons. So in my case I have slugs which are parameterized versions of the name like chris-pratt. This way the URL reads more like /actors/chris-pratt vs /actors/145432523.

With ActiveRecord's find_by method, it will return nil if no record is found. This would cause an issue in the above example. You'd see a Undefined method name for NilClass error.

actor = Actor.find_by(slug: params[:slug])
if actor.present?
    puts "They call me Funky #{actor.name.capitalize}"
end

Ok, now it won't perform our action unless we have a record. Alternatively, we could use the convention find_by! to have it raise an exception if no record was found. In a legacy app, you may find a mix of these two conventions (like in one of my apps).

We have taken the time to catch when the record exists or not, but one thing we missed is that with the old schema, it was first_name and last_name. At some point, it was merged in to just name, but that field was never set as being required, or had any validation on if it should be unique. I guess it is possible 2 actors could have the same name. Like Michael Jordan (Basketball player / Space Jam), and Michael B Jordan (Black Panther's cousin), or Kate Hudson (Goldie's daughter), and Kate Hudson (Katy Perry's real name).

In this case, we now are noticing sometimes our actors index page blows up with Undefined method capitalize for NilClass. That damn NilClass error again! This leaves our code riddled with name.try(:capitalize) and my favorite name&.capitalize. It works, but it's really starting to get messy catching these fires in bugsnag and deploying every day with some "nil catch fix".

Aside from all the bad data we have to deal with, we have some serious speed issues. We have an index page that lists all the actors. This list is sorted by the latest movie release. So the first actor on the page is staring in the most recent movie. We also have a lot of different sites that display different types of movies to the audience. Certain movies will be displayed on certain sites (Think kids movies on a kids site, comedy movies on a comedy site, etc...). This structure gets a bit confusing.

SELECT actors.*, movie_releases.latest_release_date FROM actors
INNER JOIN performances ON performances.actor_id = actors.id
INNER JOIN movies ON movies.id = performances.movie_id
INNER JOIN movie_releases ON movie_releases.releasable_id = movies.id
WHERE movie_releases.genre_site_id = ?
ORDER BY movie_releases.latest_release_date DESC

With ActiveRecord, when you have a gnarly query like this, it's going to generate an ActiveRecord object for each record it finds. This page for us loads in about 12 seconds before we add in a massive cache mechanism. We're using Varnish to handle this.There's a whole slew of other issues around this.

Life with LuckyRecord

Ok, so LuckyRecord isn't written in Ruby, it's written in Crystal. Now, it's not really fair to compare Ruby to Crystal when it comes to speed. Crystal is compiled, and Ruby isn't. But since Crystal is compiled, we know that we get speed out of the box. This should fix our issue of long load times, and allow us to get rid of the heavy caching stuff. In turn, this should save us money.

Why LuckyRecord? If there's so many other frameworks and stuff, why not one of the others? Well, Lucky is written by Paul Smith who happens to work for ThoughtBot, and they are a pretty big part of the Rails community. That means I can at least trust the code to be pretty solid. Secondly, it wasn't our first choice. Our first choice we couldn't get booted. Docs were bad, and it felt too clunky. We built a proof of concept in a few different frameworks, and the Lucky app was the only one that got from A to B without us throwing a laptop against the wall. I'm sure if we had started that today, things might have turned out different since everything else has made so much progress. In any case, we're pretty happy with our choice, so it worked out.

Let's look at the setup above using LuckyRecord.

You'll first need to add these to your shard.yml

dependencies:
  pg:
    github: will/crystal-pg
  # these come with lucky
  lucky_record:
    github: luckyframework/lucky_record
  lucky_inflector:
    github: luckyframework/lucky_inflector

And now to set it up.

require "pg"
require "lucky_inflector"
require "lucky_record"

LuckyRecord::Repo.configure do
  settings.url = "postgres://postgres@localhost/my_database_name"
end

class Actor < LuckyRecord::Model
  table :users do
    column name : String?
    column slug : String?
  end
end

class ActorForm < Actor::BaseForm
  fillable name
end

First up you'll notice a few things. Lucky breaks each component in to smaller components (similar to rails). This means if you wanted to use LuckyRecord outside of Lucky, you're gonna need a few bits. Next we'll see that the schema is right there in the model. In Ruby, I remember using MongoDB one time, and having to setup the schema in the model. I really like that because the model is the first place I go for all my model needs. Lastly you'll notice there's an extra class here with the ActorForm.

Forms

With ActiveRecord, your model connects to the DB, handles queries, business logic, and all kinds of stuff. Most rubyists will abstract out these patterns and add in Mutations, Serializers, and other patterns. This is great, except developers tend to be overly opinionated when it comes to patterns. This means that less and less you start to see these rails apps looking more and more different from each other.

Lucky sort of brings that approach back from classic rails since devs are breaking things out anyway, why not just do that from the beginning? The ActorForm is used for creating and updating your records. Imagine you have a form that the user is allowed to enter in data and create/update records. LuckyRecord gives you a safe way to allow which fields they can update, and a place to put all the logic around creating and updating records. This is nice because maybe when you create one record, you also need to create a few others. Now your model isn't responsible for the creating of other models. It's a form object that responsible for that form a user uses to do what it needs to do.

This also lets you create other forms like AdminActorForm, or whatever. Maybe you have users with elevated privileges that can do more things with records. It would look like this:

actor = ActorForm.new
actor.name.value = "Chris Pratt"
actor.slug.value = "chris-pratt"
actor.save!

Of course you can do validations and all that fun stuff too. The main thing is that this is all handled outside of the model.

Ok, so what about querying the model?

query = Actor::BaseQuery.new.name("Chris Pratt")
query.map(&.name) #=> ["Chris Pratt"]

LuckyRecord gives you a few namespaced classes out of the box when you define your model. One for the Form object, and another one for the Query object. This allows you to store all of the different SQL queries you need. You can create a child class that has these all setup for you!

class ActorQueries < Actor::BaseQuery
  # SELECT actors.* FROM actors WHERE actors.name = ? ORDER BY actors.name ASC
  # returns Array(String)
  def self.by_name(name : String)
    self.new.name(name).name.asc_order.map(&.name)
  end
  # or a chainable query method
  def by_name(_name : String)
    name(_name).name.asc_order
  end
end

ActorQueries.by_name("Chris Pratt")

The nice thing here from the crystal side is that if the compiler catches a spot where you might potentially pass nil to this method, it would throw a compile-time error. No method overload matches by_name for ActorQueries with compile-time (String | Nil). Or at least I think that's how the error reads 😅. The basic idea is that the compiler says that the method signature only takes a String as an argument, but you have a spot in your code that could potentially be nil.

The whole ideals around Lucky is that all of these error you normally see in production you'll see in development before your app even boots. The abstractions, and different use of objects even down to how Lucky has you create the HTML is all designed so things are just rock solid in production.

Of course there's always going to be a few things that slip through, but it's a TON less than it would be in Ruby.

Recap Time

There's some pros and cons to this, so let's list them out (in no particular order).

  1. Lucky catches compile-time errors so you have less issues in production
  2. Crystal gives you speed from being compiled
  3. LuckyRecord breaks out logic in to smaller components to make testing easier, and focusing on what matters
  4. LuckyRecord ONLY supports postgres. You can use Lucky with a different ORM if you need MySQL, or something else.
  5. LuckyRecord takes a small performance hit for how it builds queries. This may improve later, but it's not a huge deal. It's still about 12x faster than ActiveRecord.
  6. Having all these compile-time errors slows development down a LOT. This isn't really a con because you have to pay a price either way. The choice is development, or production.
  7. There's some features lacking in LuckyRecord currently, but as the whole crystal ecosystem is still new, you'll get this will anything you try to use. In fact, I've used all of the ORMs in crystal, and NONE of them do everything we needed for our app.
  8. The docs for Lucky are really good. And really pretty! (yes, this matters).
  9. The community for Lucky is also really great! Help is usually very quick.
  10. I like tacos.

Thanks for reading!

(authors note: this article was written 9 different ways with different thought processes over a few days. If it's confusing, let me know so I can make it more coherent)

Discussion

pic
Editor guide