Querying the Database in Go
Welcome back to my tutorial! Last time, we set up an instance of the PostgreSQL database and connected it to our backend. This time, we will use the database and learn how to query it.
A quick intro to SQL databases
Although we have set up the database connection, chances are that you still don’t understand how a database works. Is it like a file explorer on my computer? How does it store data? What does it mean to query the database? I think SQL syntax gets a lot easier if we understand how a database works.
PostgreSQL is an example of what is called an SQL database. An SQL database is a type of database that stores data in the format of a defined schema. Simply put, it is a glorified spreadsheet where each data is stored as a row inside a table. An SQL database will typically have several schemas (tables) that define the data’s format.
In our example, we want a table that stores book data. Our struct definition in Go looks like this:
type BookData struct {
title string
author string
isbn string
}
Each book has a title, an author, and an ISBN. The table in our database will look something like this:
Title | Author | ISBN |
---|---|---|
Tools of Titans | Tim Ferriss | 9781328683786 |
Siddhartha | Herman Hesse | 9781529024043 |
Atomic Habits | James Clear | 9780735211292 |
We will redefine the schema and the struct in this guide to better suit our needs.
Simple SQL syntax
Now that we know how a database stores data, it is time to learn how to use it. Unless you use a GUI tool to simplify the operations, you will need to use a special language to manipulate the database. Even if you have a GUI tool, it is good to know how to use SQL.
SQL is short for Structured Query Language and is the primary way to manipulate the database. The syntax reads like English, so it is very easy to understand what each line does. The act of requesting and editing data is called a query.
Let’s start with learning a couple of the most important commands. Also, yes, it is idiomatic to type SQL commands in all caps to distinguish them from the data. For the sake of organization and readability, you may opt to break the query statement into multiple lines.
CREATE DATABASE myDatabase;
This command creates a database. We have already created the database in the last tutorial, so we can skip this step.
CREATE TABLE myTable (
column1 dataType columnConstraints,
column2 dataType columnConstraints,
column3 dataType columnConstraints,
tableContrainsts
);
This is how you create a table like the one in the first part of this tutorial.
-
You choose the name of the table.
-
You must then specify the column names, the data types, and constraints.
Constraints are options that you assign to each column. For example, a NOT NULL
constraint will enforce the column to have non-null values. A PRIMARY KEY
constraint will designate the column as unique values that can identify each row.
SELECT column1 FROM myTable WHERE condition;
This is how you select certain data from the table. The command is self-explanatory: you choose a column or set of columns from the table where a condition is met.
INSERT INTO myTable (column1, column2, ...)
VALUES (value1, value2, ...);
This is how you insert data into certain columns. value1
gets inserted into column1
, and vice versa.
UPDATE myTable
SET column1 = value1, column2 = value2, ...
WHERE condition;
This is how you update existing values at given columns.
DELETE myTable WHERE condition;
This is how you delete existing values that match the condition.
Mise en place
Our database currently has nothing inside it. We should prep it before we can do anything in our backend. Let’s create our table first.
CREATE TABLE books (
isbn VARCHAR(13) PRIMARY KEY,
title VARCHAR(100) NOT NULL,
author VARCHAR(50) NOT NULL
);
This script creates a table named books
that has columns isbn
, title
, and author
.
-
VARCHAR(length)
is a type used by strings with maximum length.VARCHAR(13)
will only accept strings with 13 characters or less. We will use ISBN 13 with no hyphens in the middle, so allocating only 13 characters sounds good. -
PRIMARY KEY
is a constraint applied to columns to identify a row uniquely in a table. All items in thePRIMARY KEY
column must be unique and not be empty. -
NOT NULL
is a constraint used to ensure no data in the column is empty.
Ok, we created the table. We could seed the database by inserting new rows right now, but we will do this in our backend instead. If you want to add some books now, you can go ahead and use this script:
INSERT INTO books (isbn, title, author)
VALUES ("9781328683786", "Teen Titans", "Tim Ferriss");
INSERT INTO books (isbn, title, author)
VALUES ("9781529024043", "Siddhartha", "Herman Hesse");
INSERT INTO books (isbn, title, author)
VALUES ("9780735211292", "Atomic Habits", "James Clear");
Whoops, we made a typo in our first book. The title should be Tools of Titans, not Teen Titans! To fix this, we can use this query statement:
UPDATE books
SET title = "Tools of Titans"
WHERE isbn = "9781328683786";
Pretty straightforward, right?
Executing queries in Go
Good job following the guide so far! Now we will learn how to execute these queries from our backend. Let’s go back to our code.
// main.go
package main
import (
"database/sql"
"fmt"
"log"
"github.com/gorilla/mux"
_"github.com/lib/pq"
)
var DB *sql.DB
const (
HOST = "localhost"
PORT = 5432
USER = "jacob"
PASSWORD = "password"
DBNAME = "bookstoreDB"
)
func main() {
connString := fmt.Sprintf(
"host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
HOST, PORT, USER, PASSWORD, DBNAME,
)
DB, err := sql.Open("postgres", connString)
if err != nil {
log.Fatal(err)
}
defer DB.Close()
// mux definition and route registration (from last tutorial)
r := mux.NewRouter()
r.HandleFunc("/", homeHandler)
booksSubR := r.PathPrefix("/books").Subrouter()
booksSubR.HandleFunc("/all", AllHandler).Methods(http.MethodGet)
booksSubR.HandleFunc("/{isbn}", IspnHandler).Methods(http.MethodGet)
booksSubR.HandleFunc("/new", NewHandler).Methods(http.MethodPost)
booksSubR.HandleFunc("/update", UpdateHandler).Methods(http.MethodPut)
booksSubR.HandleFunc("/delete/{isbn}", DeleteIspnHandler).Methods(http.MethodDelete)
log.Fatal(http.ListenAndServe(":8090", r))
}
We should define the handlers that fire when a corresponding endpoint is reached. To make things less cluttered, let’s create a new file named controllers.go
and write our controllers there. In case you are wondering, I am using the terms controllers and handlers interchangeably.
Before we create controllers.go
, we need to create a defined model that reflects the database’s schema. These models should go inside models.go
.
// models.go
package main
type Book struct {
Isbn string `json:"isbn"`
Title string `json:"title"`
Author string `json:"author"`
}
Now that we have Book
defined, we can use that in our controllers.go
file.
// controllers.go
package main
import (
"database/sql"
"net/http"
)
func AllHandler(w http.ResponseWriter, r *http.Request) {
var books = make([]Book, 0)
results, err := DB.Query("SELECT * FROM books;")
if err != nil {
log.Println("failed to execute query", err)
w.WriteHeader(500)
return
}
for results.Next() {
var b Book
err = results.Scan(&b.isbn, &b.title, &b.author)
if err != nil {
log.Println("failed to scan", err)
w.WriteHeader(500)
return
}
books = append(books, b)
}
json.NewEncoder(w).Encode(books)
}
// other handler definitions go here
This is an example of how a controller function can query the database, parse the results as JSON, then send them back to the user.
var books = make([]Book, 0)
We create a slice of Book
objects. This is used to store the query results.
results, err := DB.Query("SELECT * FROM books;")
if err != nil {
log.Println("failed to execute query", err)
w.WriteHeader(500)
return
}
We haven’t seen this code before. This is the snippet that executes the query.
-
We created
DB
in themain
function. We use theQuery
method onDB
to execute a query. -
The query statement is a string:
SELECT * FROM books;
. The asterisk denotes that we want to select all records from the tablebooks
. -
The method returns
results
, which is of typesql.Rows
. This stores the result of a query, row by row. Getting this alone isn’t enough; we need to scan the contents into another variable. -
If there is an error, we respond with an HTTP error code of 500, which denotes an issue on the server-side.
for results.Next() {
var b Book
err = results.Scan(&b.isbn, &b.title, &b.author)
if err != nil {
log.Println("failed to scan", err)
w.WriteHeader(500)
return
}
books = append(books, b)
}
This is where we save the query results.
-
results.Next
iterates through the rows ofresults
. Because we queried for all records, there are many rows that we need to iterate through. -
results.Scan
scans the row data to the target destination. Here, we are scanning to an instance ofBook
calledb
. -
Once the scanning is complete, we append
b
to thebooks
slice. -
If there is an error, we respond with an HTTP error code of 500, which denotes an issue on the server-side.
json.NewEncoder(w).Encode(books)
This is simple. It encodes the books
slice into JSON, then sends it back to the user.
Handling POST requests
We need to handle requests to add new books to our database. The basics are the same as in the previous example, but the querying method will be slightly different.
// controllers.go
package main
import (
"database/sql"
"net/http"
)
func NewHandler(w http.ResponseWriter, r *http.Request) {
var b Book
err := json.NewDecoder(r.Body).Decode(&b)
if err != nil {
log.Println("error while decoding r.Body", err)
w.WriteHeader(400)
return
}
queryStmt := `
INSERT INTO books (isbn, title, author)
VALUES ($1, $2, $3)
RETURNING isbn;
`
var isbn string
err := DB.QueryRow(queryStmt, &b.isbn, &b.title, &b.author).Scan(&isbn)
if err != nil {
log.Println("failed to execute query", err)
w.WriteHeader(500)
return
}
log.Println("%s has been added to the database", isbn)
}
The code looks similar to our first example, but it is slightly different.
var b Book
err := json.NewDecoder(r.Body).Decode(&b)
if err != nil {
log.Println("error while decoding r.Body", err)
w.WriteHeader(400)
return
}
-
Because the user will send a JSON object as a request body, we need to marshal it in a Go-friendly format.
-
We create an instance of
Book
calledb
, and we decode the request body to it. -
If an error occurs, this means that the request body is invalid, so we return an error code of 400.
queryStmt := `
INSERT INTO books (isbn, title, author)
VALUES ($1, $2, $3)
RETURNING isbn;
`
var isbn string
err := DB.QueryRow(queryStmt, &b.isbn, &b.title, &b.author).Scan(&isbn)
if err != nil {
log.Println("failed to execute query", err)
w.WriteHeader(500)
return
}
log.Println("%s has been added to the database", isbn)
This is how we handle INSERT
statements or any statements that require us to dynamically create new query statements such as UPDATE
.
-
queryStmt
is the query statement. Focus on the second line - it has$1, $2, $3
. These are placeholders for data we will insert later. Because one person’sINSERT
statement is going to be different from someone else’s, we can’t just have a static query statement. How are we to know what book the users want to add before they even tell us? -
The
RETURNING isbn;
line at the end will return theisbn
of the book added. This is useful data to keep, and we can use it to do whatever we please. -
Based on the query statement, we know for sure that there is only going to be one row returned after the query. This is why we use
sql.QueryRow
instead ofsql.Query
. -
In
DB.QueryRow
, we pass in our query statement, and the data to replace$1, $2, $3
. Order matters, so in our example,&b.isbn
will replace$1
, and vice versa.&b.isbn
,&b.title
, and&b.author
are the fields ofb
that we have decoded previously. -
If there is an error, we respond with an HTTP error code of 500, which denotes an issue on the server-side.
You will be able to figure out how to write other controller functions from here. Best of luck! Check out these resources to help you:
Conclusion
Congratulations on completing this part of the tutorial. We now have a very basic REST API up and running. Run the code, fire up your favorite client (mine is cURL and Postman), and start testing! Feel free to add more controllers and expand. There are more advanced topics in building APIs, but for learning purposes, this will be enough. I will get to more advanced topics soon, so stay tuned!