Setting up a CRUD app in Lucky
Tags: lucky, crystal, crud
Published: 2021-01-15 04:21:55

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.

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/q0o9q5cm9kreqw57dbxw.jpg)

On the [Lucky framework](https://www.luckyframework.org/), your hand isn't held quite so much. Creating CRUD functionality touches several sections of the framework which are covered separately in the [docs](https://www.luckyframework.org/guides/getting-started/why-lucky) - 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](https://github.com/jfhinchcliffe/tipapp).

## What The CRUD?
[CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) 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](https://www.luckyframework.org/guides/getting-started/installing)

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](https://github.com/jfhinchcliffe/tipapp/blob/main/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](https://www.luckyframework.org/guides/getting-started/starting-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 tipapp` to navigate into your shiny new Lucky app.
- Run `script/setup` to 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 dev` and wait while your app shakes the dust from its shoulders and shambles on over to `http://localhost:3001`.

## Step 2 - Create the Tip Resource 🏭
_Related Documentation - [Database Models](https://www.luckyframework.org/guides/database/models), [Migrating Data](https://www.luckyframework.org/guides/database/migrations)_

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:
```crystal
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:
```crystal
lucky db.migrate
```

All things going well, you'll now have a `tips` table in your database.

To create the [association](https://www.luckyframework.org/guides/database/models#model-associations) between the `User` and the `Tip`, add the following to your `tipapp/src/models/user.cr` file within the table block:
```crystal
has_many tips : Tip
```
and within your `Tip` file at `tipapp/src/models/tip.cr`, inside the table block:
```crystal
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](https://luckyframework.org/guides/database/database-setup#seeding-data)_

We want to seed `User` and `Tip` information to the database, so first, create a `SaveUser` operation with the following content:

```crystal
  # 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:

```crystal
  # 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:
```crystal
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:
```crystal
# 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:

```crystal
# 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:
```crystal
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`:
```crystal
# 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:
```crystal
# 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.

![show Tip page](https://dev-to-uploads.s3.amazonaws.com/i/zbsr3dkv1309ftmf88kv.png)


## 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:
```crystal
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`:
```crystal
# 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:

```crystal
# 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:
```crystal
  # 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.cr` file, create a link to the 'New Tip' page using `link "New Tip", to: Tips::New`
- In the `tipapp/src/pages/tips/index_page.cr` file, in the table of Tips, create a link to each Tip's show page using `link "New Tip", to: Tips::New`
- In the MainLayout page at `tipapp/src/pages/main_layout.cr`, add a link to `Tips::Index` after the `render_signed_in_user` method so we can always get back to our Tips:
```crystal
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:
```crystal
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:
```crystal
  # 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:
```crystal
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:
```crystal
  # 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:
```crystal
  # 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:
```crystal
lucky gen.action.browser Tips::Delete
```
And set up the action to destroy the Task based the ID passed in as a param:
```crystal
  # 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:
```crystal
# 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:
```crystal
# 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!

![The finished product!](https://dev-to-uploads.s3.amazonaws.com/i/ztbezoxez2dbeyu98nq2.png)


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.