Introduction
CRUD refers to the four basic operations an app should be able to perform. The acronym represents the Create, Read, Update, and Delete functions.
In the first and second parts of this tutorial series, we learnt how to Create and Read from a database. In this third instalment, we will learn how to Update and Delete data from a database.
Prerequisites
To follow along, you will need to have Docker installed and running.
You will also need the source code from the second part of the tutorial series. If you did not happen to follow that tutorial, you can download the finished code and use it as your starting point.
Remember to also set up your environment variables to avoid errors.
Revisiting fact creation
Before we start adding new functionality, let’s first improve some existing code.
In the previous tutorial, we had set up a confirmation page which we redirected to after we successfully created a new Fact
. We did this to get some practice creating new handlers, now we can update this behaviour so that we are redirecting to the index
page, instead.
In the handlers/facts.go
file, let’s update the CreateFact
handler. If we get errors when trying to parse the body of the request, we will render the new
template. And if we get an error when trying to save the Fact
to the database, we will also just render the new
template. But if can successfully save the new Fact
, then we will redirect to the index page to see our newly created Fact
being listed:
func CreateFact(c *fiber.Ctx) error {
fact := new(models.Fact)
// Parse request body
if err := c.BodyParser(fact); err != nil {
return NewFactView(c)
}
// Create fact in database
result := database.DB.Db.Create(&fact)
if result.Error != nil {
return NewFactView(c)
}
return ListFacts(c)
}
Now that our CreateFact
handler is no longer using the ConfirmationView
handler, we can delete it.
Read
In the first part of this tutorial series, we already learnt how to read all our Facts
from the database. Then in the second part, we built a UI to display them all. To get more practice with reading from the database, we will now learn how to Show
a single fact on a page of its own.
In our browser window, we can navigate to localhost:3000/fact/1 because this will be the url format we will use to show
a fact, given its id
. Since we newly created a fact, we expect its id
to be 1
. However, this currently results in an error because we have nothing set up to support a show
action.
Route
Let’s head into the routes
file to add a new show route so that our app recognises this new url format. Within the body of the setupRoutes()
method, we can add a new route to show
a single Fact
. This route will use the GET
action. It will also have a :id
path parameter.
package main
import (
"github.com/divrhino/divrhino-trivia/handlers"
"github.com/gofiber/fiber/v2"
)
func setupRoutes(app *fiber.App) {
app.Get("/", handlers.ListFacts)
app.Get("/fact", handlers.NewFactView)
app.Post("/fact", handlers.CreateFact)
// Add new route to show single Fact, given `:id`
app.Get("/fact/:id", handlers.ShowFact)
}
We are referencing the ShowFact
handler that does not exist yet. We can create that next.
Handler
Within the handlers
package, we will create the ShowFact
handler that will retrieve and render a single fact.
As with all handlers in Go Fiber, we pass in the context and return an error. Then within the body of the ShowFact
handler, we create a new variable called fact
to store a Fact
model instance to hold the fact we will retrieve.
To be able to retrieve the Fact
from the database, we will need to know its id
. We can use the c.Params()
method to pull this information from the URL.
A typical URL will look something like this:
We use Go Fiber’s c.Params()
method when we’re specifically trying to get values from the path parameters. So c.Params("id")
would get us the id
value from the above example URL.
Once we have the id
value, we can use it to query the database
and get the First
record where the id
matches.
Then we can use the c.Render()
method to render a frontend view.
func ShowFact(c *fiber.Ctx) error {
fact := models.Fact{}
id := c.Params("id")
database.DB.Db.Where("id = ?", id).First(&fact)
return c.Render("show", fiber.Map{
"Title": "Single Fact",
"Fact": fact,
})
}
Let’s use the frontend views we already have to add a new Fact
. If we try to view this newly-created Fact
in our browser, we get the following error:
We can fix this by creating a new template called show
:
touch views/show.html
Then within this view/show.html
file, we can add some markup. The classes used in this markup correspond with styles that have been pre-prepared in the CSS. We will not go into depth about them, as CSS is out of scope for this tutorial.
<div class="container flex">
{{if .Fact}}
<div class="content">
<p>{{ .Fact.Question }}</p>
<p>{{ .Fact.Answer }}</p>
</div>
<div class="actions">
<a href="" class="btn-secondary">Edit</a>
<button class="btn-danger">Delete</button>
</div>
{{end}}
</div>
We use an {{if}}
block to check for the presence of a Fact
and conditionally display content. If a Fact
exists, we will render it along with an Edit
link and a Delete
button. If we head into the browser now, we should be able to see our single Fact
being displayed by the show
template.
However, you may notice that none of our CSS styles are being applied to this page. We can quickly fix this by heading into our view/layouts/main.html
file and update the link to our stylesheet so that is has a preceding /
in the path. So it should look like this:
<link rel="stylesheet" href="/style/main.css?v=1">
And if your logo image is also missing, we can fix this by heading into the views/partials/header.html
file and update the link to our image so that is has a preceding /
in the path. So it will look like this:
<img src="/image/divrhino-logo.png" alt="">
Now when we refresh the browser, we should see our styled show
page
404 page
So far things are looking promising, but what happens if we try to request a fact that does not exist? We can find out by changing the id
in our path parameter to 99
.
We’re getting an empty result but our show
template is still rendering. This behaviour would be confusing to a user because they have no way of knowing what is going on. We can improve the experience by introducing a 404
page. If a fact does not exist, we should render this 404
page instead.
In the cmd/routes.go
file, we update the ShowFact
handler to return a NotFound
when we are unable to retrieve the record. This NotFound
handler does not exist yet, so we will need to create it.
func ShowFact(c *fiber.Ctx) error {
fact := models.Fact{}
id := c.Params("id")
result := database.DB.Db.Where("id = ?", id).First(&fact)
if result.Error != nil {
return NotFound(c)
}
return c.Status(fiber.StatusOK).Render("show", fiber.Map{
"Title": "Single Fact",
"Fact": fact,
})
}
func NotFound(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).SendFile("./public/404.html")
}
Then we can go and update our app config in cmd/main.go
to also use the NotFound
handler. Remember to place this after the other setup, but right before the call to app.Listen
package main
import (
"github.com/divrhino/divrhino-trivia/database"
"github.com/divrhino/divrhino-trivia/handlers"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/template/html"
)
func main() {
database.ConnectDb()
engine := html.New("./views", ".html")
app := fiber.New(fiber.Config{
Views: engine,
ViewsLayout: "layouts/main",
})
setupRoutes(app)
app.Static("/", "./public")
// Set up 404 page
app.Use(handlers.NotFound)
app.Listen(":3000")
}
If we try to test this in the browser now, we will get a blank page. We need to create a 404.html
file in our public folder:
touch public/404.html
Then inside the public/404.html
file, we can paste some ready-made content from the project repository.
All the markup and styles for this page will be inline. It is a good idea to keep error pages self-contained so they don’t throw additional errors if they are unable to location any external dependencies.
Now when we try to navigate to a route that does not exist, we see our new 404
page:
Our show page is done! Let’s keep going.
Update
The next CRUD action we will tackle is the Update
operation. Again let’s start in our cmd/routes.go
file.
If you find yourself not knowing where to start when implementing any action, try adding a new route
and let the errors guide you.
In our cmd/routes.go
file, we need to add 2 new routes. We need a GET
route that will take us to the edit
form and another PATCH
route that will actually update
the record we are editing. GET
and PATCH
are HTTP
verbs (methods) and aren’t specific to Go Fiber.
package main
import (
"github.com/divrhino/divrhino-trivia/handlers"
"github.com/gofiber/fiber/v2"
)
func setupRoutes(app *fiber.App) {
app.Get("/", handlers.ListFacts)
app.Get("/fact", handlers.NewFactView)
app.Post("/fact", handlers.CreateFact)
app.Get("/fact/:id", handlers.ShowFact)
// Display `Edit` form
app.Get("/fact/:id/edit", handlers.EditFact)
// Update fact
app.Patch("/fact/:id", handlers.UpdateFact)
}
Neither of these handlers exist yet, so we can head into our handlers
package to create them.
Within the body of the EditFact
handler, we create a new variable called fact
to store the Fact
we will be editing. Then we use the c.Params("id")
method call to get the id
value from the URL.
We query the database and retrieve the first record where
the id
matches. If we can’t find it, we render the NotFound
handler. Otherwise, we will Render
the view template and send the fact
variable through to the frontend.
You may notice that this handler is very similar to the ShowFact
handler, the main difference being we’re rendering different frontend views for each. If you’re feeling adventurous, you could consider DRYing out the code that is common between the two handlers.
func EditFact(c *fiber.Ctx) error {
fact := models.Fact{}
id := c.Params("id")
result := database.DB.Db.Where("id = ?", id).First(&fact)
if result.Error != nil {
return NotFound(c)
}
return c.Render("edit", fiber.Map{
"Title": "Edit Fact",
"Subtitle": "Edit your interesting fact",
"Fact": fact,
})
}
The next handler we’ll need to create is the UpdateFact
handler which will persist the changes to the database
. If we run into an error during the parsing step, we will send back a Service Unavailable
status. If we get an error when trying to write these changes to the database, we will redirect to the edit
template
func UpdateFact(c *fiber.Ctx) error {
fact := new(models.Fact)
id := c.Params("id")
// Parsing the request body
if err := c.BodyParser(fact); err != nil {
return c.Status(fiber.StatusServiceUnavailable).SendString(err.Error())
}
// Write updated values to the database
result := database.DB.Db.Model(&fact).Where("id = ?", id).Updates(fact)
if result.Error != nil {
return EditFact(c)
}
return ShowFact(c)
}
Now we need a frontend view so users can edit
facts in the browser.
touch views/edit.html
Inside the edit.html
file, we will create a simple form with the id
attribute of form-update-fact
. We will also store the current fact’s id
column value inside a data
attribute called data-factid
. These id
values might be confusing, but just be mindful that the first id
is an HTML
attribute that can be targeted by JavaScript and the fact’s id
refers to the database column.
Then within the form body, we will add an input field for question
, an input field for answer
, and a submit
button.
<div class="container">
<form id="form-update-fact" data-factid="{{ .Fact.ID }}">
<label for="question">
<span>Question</span>
<input type="text" name="question">
</label>
<label for="answer">
<span>Answer</span>
<input type="text" name="answer">
</label>
<input type="submit" value="Update">
</form>
</div>
We will also head into the show.html
page and update the edit
link so that its href
value is pointing to our new edit
route. We use {{ .Fact.ID }}
to populate the id
of the current fact we want to edit.
<div class="container flex">
{{if .Fact}}
<div class="content">
<p>{{ .Fact.Question }}</p>
<p>{{ .Fact.Answer }}</p>
</div>
<div class="actions">
<a href="/fact/{{ .Fact.ID }}/edit" class="btn-secondary">Edit</a>
<button class="btn-danger">Delete</button>
</div>
{{end}}
</div>
If we take a look at your Fact
in the browser, it should be able to click on the Edit
button. This will take you to the edit
form
Currently, our form fields are empty. This isn’t ideal, because we should be able to see the values we want to change. We can remedy this by adding a value
attribute to each of the fields. So the markup for our form should now look like this:
<div class="container">
<form id="form-update-fact" data-factid="{{ .Fact.ID }}">
<label for="question">
<span>Question</span>
<!-- Add value for Question field -->
<input type="text" name="question" value="{{ .Fact.Question }}">
</label>
<label for="answer">
<span>Answer</span>
<!-- Add value for Answer field -->
<input type="text" name="answer" value="{{ .Fact.Answer }}">
</label>
<input type="submit" value="Update">
</form>
</div>
If we refresh our browser, the fields of our edit
form should now have values
But if we try to submit the form now, it does not work as expected. Why is this?
The HTML form
element only supports the GET
and POST
methods, and the default method for a form
is GET
. However, our update route is using the PATCH
method to update our facts. We will need a way to send a PATCH
request from our edit
form. We can achieve this with a little bit of JavaScript.
Send PATCH request with JavaScript
In the previous part of this tutorial series, we added some JavaScript to our views/index.html
page because this was the only place in our app that was using JavaScript. Now, we’ve reached a point where we need JavaScript on other pages too, so we will need to import our script
in a place that is accessible throughout the app. Let’s move our script
tag from views/index.html
into our main
layout. So our views/layout/main.html
file should now look like this:
<!DOCTYPE html>
<head>
<title>Div Rhino Trivia</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style/main.css">
</head>
<body>
...
</body>
</html>
<!-- import javascript file -->
<script src="/javascript/app.js"></script>
By default, all our static assets are cached so that the app is faster. This makes sense when our app is in production, because we do not want to keep fetching styles and scripts that have not changed. However, this can be annoying in development, because we need to make frequent changes to our CSS and JS while we’re building things.
We can work around the caching by appending a timestamp to the end of the asset urls. This hack should only be used, locally, though. We do not want to have this behaviour in production.
First, we can add an id
attribute to our <link>
and <script>
tags so we can target them with JavaScript. Then we can add a little snippet that will append a timestamp to the end of each path. Notice that we also removed the ?v=1
query param from the end of the stylesheet path.
<!DOCTYPE html>
<head>
<title>Div Rhino Trivia</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<!-- Add id to stylesheet link tag -->
<link id="styles" rel="stylesheet" href="/style/main.css">
</head>
<body>
...
</body>
</html>
<!-- Add id to script tag -->
<script id="main" src="/javascript/app.js"></script>
<!-- For local development only: Snippet to append timestamps -->
<script>
const timeStamp = Date.now()
const stylesheetLink = document.querySelector("link#styles")
const appScriptLink = document.querySelector("script#main")
stylesheetLink.href += `?t=${timeStamp}`
appScriptLink.src += `?t=${timeStamp}`
</script>
We have now set up everything we need for the PATCH
request. Inside the public/javascript/app.js
file, we can paste in a snippet. This snippet uses the JavaScript fetch
API to make a PATCH
request to edit the current fact. JavaScript is outside the scope of this tutorial, so we will not step through every line of this snippet, but this is what is should look like:
// Get the editForm element and the current fact ID to edit
const editForm = document.querySelector('#form-update-fact')
const factToEdit = editForm && editForm.dataset.factid
// Add an event listener to listen for the form submit
editForm && editForm.addEventListener('submit', (event) => {
// Prevent the default behaviour of the form element
event.preventDefault()
// Convert form data into a JavaScript object
const formData = Object.fromEntries(new FormData(editForm));
return fetch(`/fact/${factToEdit}`, {
// Use the PATCH method
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
// Convert the form's Object data into JSON
body: JSON.stringify(formData),
})
.then(() => document.location.href=`/fact/${factToEdit}`)// Redirect to show
})
Now we can try this out in the browser. If we visit http://localhost:3000/fact/1/edit and change the values, we can submit the edit
form to update the current Fact
.
Delete
The last CRUD action we will implement is the delete
action. First, we will head into our routes.go
file to add a new delete route.
func setupRoutes(app *fiber.App) {
app.Get("/", handlers.ListFacts)
app.Get("/fact", handlers.NewFactView)
app.Post("/fact", handlers.CreateFact)
app.Get("/fact/:id", handlers.ShowFact)
app.Get("/fact/:id/edit", handlers.EditFact)
app.Patch("/fact/:id", handlers.UpdateFact)
// Delete fact
app.Delete("/fact/:id", handlers.DeleteFact)
}
We are passing in a handler called DeleteFact
, but it does not exist yet, so let’s head into our handlers
package to create it. First we will find the current Fact
using the provided id
and then we will delete
the fact using the Delete()
method that GORM gives us. Once we have successfully deleted the fact, we will redirect to the index
page
func DeleteFact(c *fiber.Ctx) error {
fact := models.Fact{}
id := c.Params("id")
result := database.DB.Db.Where("id = ?", id).Delete(&fact)
if result.Error != nil {
return NotFound(c)
}
return ListFacts(c)
}
Now that we can delete the app in the API, let’s wire up the delete
button that appears on our show
page. We will give it an id
of delete-button
so we can target it with JavaScript. Then we will also add a data-factid
attribute so that we can pass the current fact’s id
column value to JavaScript
<div class="container flex">
{{if .Fact}}
<div class="content">
<p>{{ .Fact.Question }}</p>
<p>{{ .Fact.Answer }}</p>
</div>
<div class="actions">
<a href="/fact/{{ .Fact.ID }}/edit" class="btn-secondary">Edit</a>
<!-- Add `id` and `data-factid` attributes to delete button -->
<button id="delete-button" data-factid="{{ .Fact.ID }}" class="btn-danger">Delete</button>
</div>
{{end}}
</div>
Then in our app.js
file, we can add a little snippet that sends a DELETE
request. We won’t go over this snippet in too much detail, but this is the basic idea:
- When the
deleteButton
is clicked, we ask the user toconfirm
their decision to delete - Then we use the
fetch
API to send aDELETE
request to our endpoint - After the
fact
is successfully deleted, we redirect to theindex
page
// Get deleteButton element and the current fact ID to delete
const deleteButton = document.querySelector('#delete-button')
const factToDelete = deleteButton && deleteButton.dataset.factid
// Add event listener to listen for button click
deleteButton && deleteButton.addEventListener('click', () => {
// We ask the user if they are sure they want to delete the fact
const result = confirm("Are you sure you want to delete this fact?")
// If the user cancels the prompt, we exit here
if (!result) return
// If the user confirms that they want to delete, we send a DELETE request
// URL uses the current fact's ID
// Lastly, we redirect to index
return fetch(`/fact/${factToDelete}`, { method: 'DELETE' })
.then(() => document.location.href="/")
})
If your JavaScript is not updating as expected, you may need to clear your browser’s cache or open the app in a private window.
Improving the index page
So far, we have been manually typing in the URL every time we want to see a single Fact
. It would be a nicer experience if we could click on the facts from our List without having to remember the IDs for each one.
Back in browser, let’s add a few facts to work with:
Since we have a show
page now, we no longer need the toggle answer
button. So we can update our index.html
page to remove it. The updated markup looks like this:
<div class="container">
{{if .Facts}}
{{ range .Facts }}
<a href="/fact/{{ .ID }}">
<div class="fact-item">
<div class="question-wrapper">
<p class="question">{{ .Question }}</p>
<div class="actions">
{{template "icon-arrow-right" .}}
</div>
</div>
</div>
</a>
{{ end }}
{{else}}
<div class="no-facts">
<p>No facts yet.</p>
<p><a href="/fact">Add now</a></p>
</div>
{{end}}
</div>
We are using a new partial
called icon-arrow-right
, but it does not exist yet. So let’s create that now.
First let’s create a new folder to hold all our icon
partials
mkdir views/icons
Then inside this new folder, let’s create our icon-arrow-right
partial
touch views/icons/icon-arrow-right.html
Then within icon-arrow-right.html
, we can paste some SVG code:
{{define "icon-arrow-right"}}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#4b5366" width="24" height="24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12.75 15l3-3m0 0l-3-3m3 3h-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{end}}
If we refresh our app in the browser now, this is what it should look like
Now we can click on each fact and be taken to its individual show
page where we can edit and delete it, easily.
And with that, we’ve come to the end.
Conclusion
In this tutorial we implemented all the CRUD actions - Create, Read, Update and Delete. We used JavaScript’s fetch
API to make PATCH
and DELETE
requests. We also added a little hack to bust our browser’s cache during development.
Congratulations, you did great! Keep learning and keep coding. Bye for now, <3.
divrhino / divrhino-trivia-crud
Build a CRUD app with Go Fiber, docker and Postgres.
Build a CRUD app with Go Fiber, Docker, and Postgres
- Text tutorial: https://divrhino.com/articles/crud-go-fiber-docker-postgres/
- Video tutorial: https://youtu.be/6dZiD5cC69Q
Top comments (15)
Hello, thanks for the tutorial, one question?, I don't know why air doesn't work for me when I edit and save a view, it doesn't reload the page in the browser, thanks.
Hi there! Are you able to see your changes when you refresh the browser? Air rebuilds your project, but it doesn't do hot reloading of the browser, so you still need to manually refresh it.
hello, thanks for answering, looking at the console, it only shows the actions with the database but does not rebuild the main.go when save the go file:
Hello! Would you be able to share the contents of your
Dockerfile
anddocker-compose.yml
files?Hmmm, those files seem like they're all set up correctly. Is your
air.toml
pointing to yourcmd
directory?Hello there. After a bit of googling, it appears that there are others also facing this issue when using Windows. Hoping this gets fixed soon.
Also found another thread in the repo issues that describes a potential fix:
github.com/cosmtrek/air/issues/190...
hello, another question, i cant connect to postgress database from navicat:
Hello there, sorry to hear that you're having trouble with this.
Here are a few things I would check:
First, the error seems to be indicating that the password is not correct? The tutorial is using
divrhinotrivia
. In your screenshot it looks like you are also using this value, but maybe it's worth checking again in case there was a typo.Next, all these values for your connection should match up with the values you have in your
.env
file as well.And lastly, your container should be running. You can get it going by running
docker compose up
.Hello, thanks for answering, I have copied and pasted the values of the env file in the navicat but it keeps giving me a connection error, I have tried restarting the server but it still does not connect externally, I tried with pgAdmin and the same error.
Hi there, are you able to use the UI to perform any CRUD actions?
Yes, the UI works correctly, the crud are ok
is only the remote conection to the database host
Thanks!