Lucky v0.25 is out. A ton has changed, and we're full steam ahead to 1.0!
Lucky v0.25 is out now, and we have a ton to go over. Let’s get to it!
See the UPGRADE_NOTES.
Or you can view the changes using LuckyDiff. This tool helps see what has changed between versions and will be included in upgrade notes from now on.
Since the last release we’ve welcomed two more developer to the core team, Matthew and Stephen. They have been huge contributors in the community, and since bringing them on, the movement towards 1.0 has more than doubled in speed!
Over the last few months, we’ve made a ton of changes, so let’s break down a couple of the more notable ones:
Avram::Operationgot a facelift
When you need to handle some logic that isn’t tied directly to a single model, you can create an
Avram::Operation to handle this. You’ve probably seen
examples in your app like the
SignInUser that come with an app generated with authentication.
Prior to this update, it was sort of “wild west”, and completely up to you on how to implement these. For consistency sake, we recommended defining a
method, and then returning
yield self, value. These operations were also limited as they couldn’t use callbacks, file attributes, or define errors not tied to
In this release, we’ve created a whole new interface!
class PromoteUserValidator < Avram::Operation param_key :user needs user : User attribute token : String # before callbacks before_run do if user.has_inactive_account? # custom errors add_error(:user_inactive, "My custom error message") end validate_required(token) end # after callbacks after_run do |user| NotifyUser.new(user.email).deliver end # unified interface def run if user.is_promotable? user else nil end end end PromoteUserValidator.run(params) do |operation, user| if user # the user must be promotable else # no user, lets check our custom error operation.errors[:user_inactive] end end
We’ve also made quite a few changes to the
Avram::SaveOperation family as well.
Prior to this update, your
after_commit callbacks couldn’t be used with an anonymous block like how
before_save works. Well,
now they can!
class SaveUser < User::SaveOperation before_save do # before save end after_save do |saved_user| # after save end after_commit do |saved_user| # after DB commit end end
We’ve also added the ability to conditionally trigger these callbacks based on a method.
class SaveUser < User::SaveOperation before_save :validate_card_number, if: :new_transaction? after_save :update_billing, unless: :card_still_valid? private def validate_card_number end private def new_transaction? true end private def update_billing end private def card_still_valid? false end end
We’ve added one additional callback called
after_completed. This callback is always called when the operation is successful (the internal
save_status attribute is set to
This means that even if the record never touches the database, the
after_completed callback will still run.
class SaveUser < User::SaveOperation after_commit do |updated_user| # This is only called if something on the user actually changed AfterCommitJob.perform(updated_user.id) end after_completed do |updated_user| # This is always called if the user is saved MetricsUpdaterJob.perform(updated_user.id) end end
When you create components like
Shared::Field, you can use the
tag_defaults method to apply default attributes to a set of HTML elements.
This method was called
with_defaults before, but has been renamed for some clarity on what it does.
Query objects also got a few new changes. The main one being that queries no longer mutate the object. Prior to this release, appending any
query method would mutate the original object. This made building queries nice and easy, but presented a problem that many faced. For example:
q = UserQuery.new # this would fail because we've already mutated the query # on the count, and can no longer query with the username ordering user_total_count = q.select_count users = q.username.asc_order
In that case, you had to make sure you cloned the query, and the chained methods were called in the correct order. We no longer mutate the query, but this requires a code change.
q = UserQuery.new user_total_count = q.select_count users = q.username.asc_order
With this change, this also means that defining default queries in your query class
initialize need to change. To set a default, you’ll now use the
class AdminQuery < User::BaseQuery def initialize defaults &.admin(true) end end
This was one of our most common requests! We held off since adding this in makes SQL queries quite a bit more complex, but we finally got it! (**sort of… see note below)
# WHERE users.name = 'Billy' OR users.name = 'Kelly' UserQuery.new.name("Billy").or(&.name("Kelly"))
or method which passes in the instance of the query object allowing you to chain additional WHERE clauses.
**NOTE: The above example works great, and will cover plenty of the queries that you may have, but we currently make no assumptions on where to place parenthesis for scoping order of operations. Take this for example:
This query would generate
WHERE name = 'Billy' OR name = 'Kelly' AND age >= 51 OR admin = true, and that may not give you the result you expect. We are working on a solution,
but for now if you need more complex control, you can pass raw SQL to
UserQuery.new.where("name = ? OR (name = ? && age >= ?)", "Billy", "Kelly", 51).
When you’re using an RDBMS like PostgreSQL, table associations become very important for properly structuring your data. There were several bugs fixed when it comes to associations giving you a lot more power and control over your data.
To start, let’s look at the
belongs_to association method. It’s pretty common for the method name to be the same as the model it’s referencing, but in some cases,
you may want your association method to be named something different:
class Employee < BaseModel table do belongs_to boss : Manager end end
But this would throw an error due to how the query methods were generated. In some places we were using the table name to generate code, and in others we were using the association name.
With this release, we no longer make the assumption on what you’re naming your associations. But it’s also worth pointing out that
methods will append the name of the association.
We’ve also fixed some bugs related to using
has_many through. This update will require a slightly different syntax.
# Before update class User < BaseModel table do has_many posts : Post # you specified the Symbol of the has_many method to query through has_many comments : Comment, through: :posts end end # After update class User < BaseModel table do has_many posts : Post # you specify an Array(Symbol) where the first item is the method to query through # and the second item is that association's method. has_many comments : Comment, through: [:posts, :comments] end end
Related to this change, we’ve also fixed bugs that limited the types of associations that could be used for the “through” association 😬
Generally when we think of models, we think of database tables. The models even have a
table() method to denote this.
Well now there’s a
view() method as well!
SQL VIEWs are like tables, but generally READ-ONLY, and might not have a primary key. Their data is usually aggregated from other table sources.
class AdminUser < BaseModel view do column username : String column promoted_to_admin_on : Time end end
All of the columns for your
view must be manually defined. These models do not come with any sort of
timestamps by default. If your view has these columns, you will need to explicitly add them.
View models will not have a
SaveOperation defined as they are meant to be read-only, and if no primary key is added, some features will be missing from the model and
admin_user.reload won’t work since these rely on an
You must implement any of these methods yourself if you need them.
The LuckyRouter got some fancy upgrades that we’re super stoked about! The first one is “glob” routing.
Glob routes are a route where the first part in the path is known, but the end of the route path is a variable length. For example:
myblog.com/posts myblog.com/posts/2020 myblog.com/posts/2020/12 myblog.com/posts/2020/12/25
If these routes all display posts, it’s not useful to duplicate routes or pages over multiple actions. In this case, we can define a glob route to catch all of these in to the same action.
class Posts::Index < BrowserAction get "/posts/*:date" do date_parts = date.try(&.split('/')) ||  of Int32 year = date_parts? month = date_parts? day = date_parts? html IndexPage, posts: PostQuery.new.by_date(year, month, day) end end
Another great update to the router is that Lucky can now catch overridden (duplicate) routes. This helps catch mistakes in development, especially in large project where hundreds or more routes are defined.
# These will now raise an error when starting the app class Api::Posts::Show < ApiAction get "/api/posts/:id" do #... end end class Api::Posts::SearchByYear < ApiAction get "/api/posts/:year" do #... end end
This blog post could go on for days to include all the awesome stuff we’ve added. Here’s a quick run down of a few more:
memoizein any class
Read through the CHANGELOG to see it all!
This is another step towards 1.0, and we’re super stoked. We can’t do this without continued support from our community. The more hands and eyes on the project, the more fine tuned Lucky can be!
Please give it a spin and help us find bugs so our next release is even more solid. If you find any issues, don’t hesitate to report them. If you’re unsure, just hop on Discord chat so we can help you out.
Thanks so much for the support!
If you haven’t already, give us a star on GitHub, and be sure to follow us on Twitter.
For questions, or just to chat, come say hi on Discord.