I primarily use Ruby on Rails at work, and Rails, like Ludo from The Labyrinth, is a large and very helpful beast. Did you want to create a model and all of its CRUD routes at the same time? Type in rails g scaffold Post title content, run a migration and you're there.
On the Lucky framework, your hand isn't held quite so much. Creating CRUD functionality touches several sections of the framework which are covered separately in the docs - actions, pages, routes, and forms and so on - but as there aren't a squillion tutorials online (like there are for Rails) I found it a bit hard to grasp.
As a result, I'm putting it all in a single guide to try and show how everything hangs together in a simple CRUD app.
This guide won't get into the nitty gritty of Crystal or Lucky, nor is it exhaustive. Rather it's a way to draw parallels between Rails and Lucky through a commonly built, minimal feature (CRUD for a resource) in a way that would have helped me to learn Lucky coming from Rails.
The repo for this demonstration tipapp project can be found here.
What The CRUD?
CRUD stands for Create, Read, Update and Delete. These are common actions that you may want to perform on a resource in an application.
What are we doing to do?
We're going to create a basic Lucky app which saves coding tips.
At work I pair a lot and I often see a colleague do something cool with their editor that I have no idea how to do. I ask them how to do it, they tell me, I nod, and the information promptly falls out of my ears. With this app, I'll never forget a handy tip again!
Before we start...
Make sure you have Crystal and Lucky installed. The official guide can be found here
When you go to a terminal, typing the commands lucky -v, crystal -v, psql --version, node -v and yarn -v should return the version of Lucky, Crystal, Postgres, Node and Yarn you have installed respectively. If they don't error, you should be ready to roll.
Note
I've got my psql database running locally in Docker on port 5432. See the docker-compose.yml file on the example repo for an example if you don't want to install Postgres locally. If you do have Postgres running locally... ignore this note and continue living your life.
Step 1 - Create a new Lucky app 🌱
Related Documentation - Starting a Lucky Project
In your terminal, type lucky init and you'll be taken through the setup of a new Lucky app.
- Enter the name of your project - I'm calling it
tipapp - Choose whether you'd like it to be a 'full' app, or 'API Only' - Select
full - Choose whether you'd like authentication generated for you - Select
y
The app should be generated for you.
- Run
cd tipappto navigate into your shiny new Lucky app. - Run
script/setupto install all JS dependencies, Crystal shards, setup the DB and perform various other little machinations for your new project (warning: this may take a little while) - After setup is complete, run
lucky devand wait while your app shakes the dust from its shoulders and shambles on over tohttp://localhost:3001.
Step 2 - Create the Tip Resource 🏭
Related Documentation - Database Models, Migrating Data
We need something to CRUD, so let's add the Tip model.
Much like Rails, we get a generator with Lucky which we can use to create a model. Our Tip model will have the following properties:
-
category- eg Bash, SQL, Ruby -
description- a detailed writeup of the tip -
user_id- the ID of the User who created this Tip
We can generate this model and a few other required files (query, migration etc.) with the following command:
lucky gen.model Tip category:String description:String user_id:Int64
Review your migration file (eg tipapp/db/migrations/20210114223610_create_tips.cr) to confirm the fields are correct, and then to put the new table in the database, run:
lucky db.migrate
All things going well, you'll now have a tips table in your database.
To create the association between the User and the Tip, add the following to your tipapp/src/models/user.cr file within the table block:
has_many tips : Tip
and within your Tip file at tipapp/src/models/tip.cr, inside the table block:
belongs_to user : User
Now our app knows that a Tip belongs to a User, and a User can have many Tips.
Step 3 - Seed Some Data! 🐿️
Related Documentation - Seeding Data
We want to seed User and Tip information to the database, so first, create a SaveUser operation with the following content:
# tipapp/src/operations/save_user.cr
class SaveUser < User::SaveOperation
end
This will allow us to save a User from our seed file.
Replace the call method in your seeds file with:
# tipapp/tasks/create_sample_seeds.cr
def call
unless UserQuery.new.email("test-account@test.com").first?
SaveUser.create!(
email: "test-account@test.com",
encrypted_password: Authentic.generate_encrypted_password("password")
)
end
SaveTip.create!(
user_id: UserQuery.new.email("test-account@test.com").first.id,
category: "git",
description: "`git log --oneline` will display a lost of recent commit subjects, without the body"
)
SaveTip.create!(
user_id: UserQuery.new.email("test-account@test.com").first.id,
category: "vscode",
description: "`command + alt + arrow` will toggle between VS Code terminal windows"
)
puts "Done adding sample data"
end
and run lucky db.create_sample_seeds
Congratulations! That was a big step, but now you should have an app with authentication, a User, some Tips and the required associations between them.
Step 4.1 - The 'R' in CRUD - Creating an Index Page for all Tips 📝
Lucky uses Actions to route requests to their appropriate pages. To create an action for Tips::Index, run:
lucky gen.action.browser Tips::Index
And head to http://localhost:3001/tips to see the plaintext output of this file.
We want to render something a little more interesting than plaintext, so edit the Tips::Index action to point at a yet-to-be-created page called Tips::IndexPage, along with all of the current_user's tips:
# tipapp/src/actions/tips/index.cr
class Tips::Index < BrowserAction
get "/tips" do
html Tips::IndexPage, tips: UserQuery.new.preload_tips.find(current_user.id).tips
end
end
You should be getting type errors letting you know that we have no Tips::IndexPage, so let's create it in the pages directory at tipapp/src/pages/tips/index_page.cr. We'll render a simple table which iterates over the tips using the following code:
# tipapp/src/pages/tips/index_page.cr
class Tips::IndexPage < MainLayout
needs tips : Array(Tip)
def content
h1 "Tips"
table do
tr do
th "ID"
th "Category"
th "Description"
end
tips.each do |tip|
tr do
td tip.id
td tip.category
td tip.description
end
end
end
end
end
Check out http://localhost:3001/tips to see your current_user's tips.
Step 4.2 - The 'R' in CRUD - Creating a Show page for a Tip 🖼️
This step will be pretty similar to the Index page. To create an action for the Tips show page, run:
lucky gen.action.browser Tips::Show
And update the generated file to point at a Tips::Show page, using the :tip_id as param to find the relevant Tip:
# tipapp/src/actions/tips/show.cr
class Tips::Show < BrowserAction
get "/tips/:tipid" do
html Tips::ShowPage, tip: TipQuery.new.user_id(current_user.id).find(tipid)
end
end
Next, create the Tips::Show page and add the following code to show the Tip information:
# tipapp/src/pages/tips/show_page.cr
class Tips::ShowPage < MainLayout
needs tip : Tip
def content
h1 "Tip ##{tip.id}"
para "Category: #{tip.category}"
para "Description #{tip.description}"
end
end
Visit the tips path with the ID of a Tip - eg. http://localhost:3001/tips/2 and voila! A beautiful show page. Clearly, design is my passion.
Step 5 - The 'C' in CRUD - Creating a Tip 🧱
Now things are getting interesting! We want to be able to create a new Tip by entering the information into a form at http://localhost:3001/tips/new.
First, we need an Action that will handle that route:
lucky gen.action.browser Tips::New
And in that action, we want to create a new instance of SaveTip and pass it to the Tips::NewPage as operation:
# tipapp/src/actions/tips/new.cr
class Tips::New < BrowserAction
get "/tips/new" do
html Tips::NewPage, operation: SaveTip.new
end
end
The Tips::NewPage page should construct a form for the Tip like so:
# tipapp/src/pages/tips/new_page.cr
class Tips::NewPage < MainLayout
needs operation : SaveTip
def content
h1 "Create New Tip"
form_for(Tip::Create) do
label_for(operation.category, "Category")
text_input(operation.category, attrs: [:required])
label_for(operation.description, "Description")
text_input(operation.description, attrs: [:required])
submit "Create Tip"
end
end
end
And just for something different, we need to permit the category and description params in the SaveTip Operation:
# tipapp/src/operations/save_tip.cr
class SaveTip < Tip::SaveOperation
permit_columns category, description
end
Whew! Done! Now navigate to the http://localhost:3001/tips/new, create your new tip and you should see it once you're redirected to the http://localhost:3001/tips page! This app is going to make billions!
Interlude - 🎶 Gettin' Linky wit' it 🎵
With all these routes, the app is a bit of a pain to navigate around at the moment. Let's add a few links to make our lives a bit easier.
- In the
tipapp/src/pages/tips/index_page.crfile, create a link to the 'New Tip' page usinglink "New Tip", to: Tips::New - In the
tipapp/src/pages/tips/index_page.crfile, in the table of Tips, create a link to each Tip's show page usinglink "New Tip", to: Tips::New - In the MainLayout page at
tipapp/src/pages/main_layout.cr, add a link toTips::Indexafter therender_signed_in_usermethod so we can always get back to our Tips:
text " | "
link "Tips Index Page", to: Tips::Index
Now it certainly ain't pretty, but it's less of a hassle to navigate around the app.
Step 6 - The 'U' in CRUD - Updating a Tip 🎨
Once again, generate an Action, this time we want the action to display the form of the Tip we'd like to update:
lucky gen.action.browser Tips::Edit
In that action, we want to get the ID of the Tip we're editing, and return that as an argument to a Tips::EditPage like so:
# tipapp/src/actions/tips/edit.cr
class Tips::Edit < BrowserAction
get "/tips/:tipid/edit" do
tip = TipQuery.new.user_id(current_user.id).find(tipid)
if tip
html Tips::EditPage, tip: tip, operation: SaveTip.new(tip)
else
flash.info = "Tip with id #{tipid} not found"
redirect to: Tips::Index
end
end
end
While we're at it, we need to create an action for the route that we'll send the updated Tip information to, so create yet another action:
lucky gen.action.browser Tips::Update
And use this action to update the Tip or redirect back to the Edit page if we have errors:
# tipapp/src/actions/tips/update.cr
class Tips::Update < BrowserAction
put "/tips/:tipid" do
tip = TipQuery.new.user_id(current_user.id).find(tipid)
SaveTip.update(tip, params) do |form, item|
if form.saved?
flash.success = "Tip with id #{tipid} updated"
redirect to: Tips::Index
else
flash.info = "Tip with id #{tipid} could not be saved"
html Tips::EditPage, operation: form, tip: item
end
end
end
end
Finally, we need to create the Tips::EditPage with a form for the Tip we're updating:
# tipapp/src/pages/tips/edit_page.cr
class Tips::EditPage < MainLayout
needs tip : Tip
needs operation : SaveTip
def content
h1 "Edit Tip"
form_for(Tips::Update.with(tip)) do
label_for(@operation.category, "Category")
text_input(@operation.category, attrs: [:required])
label_for(@operation.description, "Description")
text_input(@operation.description, attrs: [:required])
submit "Update Tip"
end
end
end
So now put the ID of a Tip in your URL - eg http://localhost:3001/tips/5/edit - and change the details to confirm you can update it!
Step 7 - The 'D' in CRUD - Deleting a Tip 💣
The final step! And thankfully, a simple one. First, add a Delete action:
lucky gen.action.browser Tips::Delete
And set up the action to destroy the Task based the ID passed in as a param:
# tipapp/src/actions/tips/delete.cr
class Tips::Delete < BrowserAction
delete "/tips/:tipid" do
tip = TipQuery.new.user_id(current_user.id).find(tipid)
if tip
tip.delete
flash.info = "Tip with id #{tipid} deleted"
else
flash.info = "Tip with id #{tipid} not found"
end
redirect to: Tips::Index
end
end
And that's it for deletion! We'll confirm it's all working in the next section.
Step 8 - Clean up and Confirm! 💅
Our app, if the stars have aligned, should be up and working. We just need a few more little niceties before we can fully test it out.
Firstly, on the Tips::Index page, update the table to include links to the Show, Edit and Delete routes for each Tip:
# tipapp/src/pages/tips/index_page.cr
...
td do
ul do
li do
link "Edit", to: Tips::Edit.with(tip.id)
end
li do
link "Show", to: Tips::Show.with(tip.id)
end
li do
link "Delete", to: Tips::Delete.with(tip.id)
end
end
end
...
And on the Tips::Show page at, add the same links so that the Tip can be updated or deleted from there:
# tipapp/src/pages/tips/show_page.cr
ul do
li do
link "Edit", to: Tips::Edit.with(tip.id)
end
li do
link "Show", to: Tips::Show.with(tip.id)
end
li do
link "Delete", to: Tips::Delete.with(tip.id)
end
end
And that's it! Witness its magnificence!
To confirm it's working, go to the index page and create, update, and delete some tips.
Denouement ⌛
I'm thoroughly enjoying working with Lucky and Crystal. The addition of types is an incredible time-saver and the similarity to Ruby makes Crystal a pleasure to work with. With any luck the language will get a bit more popular and more guides like these will become available to get people up and running.
Any feedback or things I've gotten wrong, please let me know.



Top comments (0)