Swift on Server Tour 2: Connecting Your Database to the Server

Kevin
8 min readJul 11, 2023

--

In this chapter, we will design the data model for posts in Micro Blog and use PostgreSQL as our database to store the content. Finally, we will write a unit test to test the creation of posts.

Designing the Post Data Model

In the microblog system we typically use, posts are created by users. Therefore, the usual order for designing the data model is to design the user first and then design the post data model.

But in order to better understand how to build relationships between data, I have decided to start with the post data model this time.

A simple post has the following two properties:

  • content: The content of the post.
  • createdAt: The creation time.

If we represent our data in a table, it would look like this:

As more data is published, the table content would look like this:

It is best to add a unique ID to each record so that we can refer to a specific post quickly and accurately using the ID.

In fact, when data is stored in a database, it is stored in tables similar to this.

Next, let’s try using a class in Swift to represent this data structure.

class Post {
let id: Int
let content: String
let createdAt: Date
}

In this way, we have the most basic state of the Post data model.

Introducing Post to Vapor

Currently, Vapor doesn’t know how to interact with the Post data type in the database because we haven’t provided enough information to Vapor yet. For example:

  1. Vapor doesn’t know the mapping between the Post data model and the “table structure stored in the database”.
  2. Vapor doesn’t know what database we are using or how to connect to it.

Let’s solve these problems step by step.

Writing Post as a Vapor Model

Vapor uses its own Fluent to communicate with the database, and this functionality is commonly referred to as Object-relational mapping (ORM).

We modify Package.swift to add the Fluent dependency. Since we are no longer a simple HelloWorld, let's also change the name to MicroBlog.

// swift-tools-version:5.8
import PackageDescription
let package = Package(
name: "MicroBlog",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.77.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.4.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
]
),
]
)

Next, we create Sources/App/Models/Post.swift.

import Vapor
import Fluent

final class Post: Model {
// The name of the table in the database.
static let schema = "posts"
// The unique identifier.
@ID(key: .id)
var id: UUID?
// The content.
@Field(key: "content")
var content: String
// The creation time.
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
init() { }
init(id: UUID? = nil, content: String) {
self.id = id
self.content = content
}
}

In Vapor, we use some Property Wrappers to assist in mapping the model to the database table.

  • schema is the "table name" where this type of data is stored in the database, which is posts in this case.
  • @ID is the "unique identifier" of the data in the database table. By default, Vapor recommends using a UUID random string as the ID and will use the string id as the field name in the database table. You can modify this field name by using @ID(custom: "").
  • @Field represents the data attribute to be stored in the database. With @Field(key: "content"), we explicitly declare that var content: String corresponds to the content field in the database table.
  • @Timestamp is a special type of @Field used to represent time-based storage. It also has a trigger feature. Here, we use on: .create to indicate that the time should be automatically recorded when a Post is created.

Now Vapor recognizes the Post data type.

Starting a PostgreSQL Database Using Docker

We use PostgreSQL as our database service. Installing PostgreSQL directly on a computer can be complex, but Docker simplifies this process.

Docker is a lightweight containerization technology that bundles the runtime needed by an app into a sandbox environment. With this technology, we can seamlessly start other apps on a Linux system without having to install various dependencies on the host. You can use this technology by installing Docker Desktop.

Writing a docker-compose.yml

docker-compose.yml is a container orchestration file. In this file, we describe the container Apps we need and their environment variables, network settings, etc.

First, create docker-compose.yml in the root directory of the project and write the following content

version: '3.7'  # Defines the version of the Docker Compose file, version 3.7 is used here
volumes: # Defines the volumes part
db_data: # Docker will use this key as the name, and automatically create the db_data volume to store data
services: # Defines the services part
db: # db service configuration
image: 'postgres:15-alpine' # Uses the image of PostgreSQL 15 Alpine version
volumes: # Defines the mounted volumes
- 'db_data:/var/lib/postgresql/data/pgdata' # Mounts the db_data volume to the /var/lib/postgresql/data/pgdata directory in the container
environment: # Defines the environment variables
PGDATA: '/var/lib/postgresql/data/pgdata' # Sets the PGDATA environment variable to /var/lib/postgresql/data/pgdata
POSTGRES_USER: 'vapor_username' # Sets the POSTGRES_USER environment variable to vapor_username
POSTGRES_PASSWORD: 'vapor_password' # Sets the POSTGRES_PASSWORD environment variable to vapor_password
POSTGRES_DB: 'vapor_database' # Sets the POSTGRES_DB environment variable to vapor_database
ports: # Defines port mapping, maps the host's 5432 port to the container's 5432 port
- '5432:5432'

Now, we go to the location of docker-compose.yml in the terminal and use the command docker-compose up db to start the database service. The database will listen on port 5432 on the local machine.

If you see this error: Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? Please check whether Docker Desktop is started.

Letting Vapor Connect to the Database

Next, our goal is to let Vapor get our server information and connect to our server.

We first modify Package.swift to add Fluent support for PostgreSQL

// swift-tools-version:5.8
import PackageDescription
let package = Package(
name: "MicroBlog",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.77.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.4.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
]
),
]
)

Then, add the database connection information in Sources/App/main.swift

// ...
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: "localhost",
port: 5432,
username: "vapor_username",
password: "vapor_password",
database: "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)

try app.run()

At this point, Vapor knows how to connect to the database. However, the database is still a blank slate and does not have the tables we need. Therefore, finally, we need to write something called a Migration to update the table structure on the database.

Creating a Database Table Using Migration

Migration is a function in Fluent used to migrate the structure of database tables. Next, we will learn how to use this tool.

Write the following content in Sources/App/Migrations/1_CreatePost.swift

import Fluent 

// Define CreatePost struct, implement AsyncMigration protocol
struct CreatePost: AsyncMigration {
// Prepare method, prepare operations on the database
func prepare(on database: Database) async throws {
// Create database schema object for the Post table
try await database.schema(Post.schema)
.id() // Add id column
.field("content", .string, .required) // Add content column, type is string, cannot be null
.field("created_at", .datetime) // Add created_at column, type is date time
.create() // Create Post table
}
// Revert method, rollback operations on the database
func revert(on database: Database) async throws {
try await database.schema(Post.schema).delete() // Delete the database schema object of the Post table
}
}

At runtime, the above code will be converted into SQL statements. For example, the code in prepare will be converted into the following SQL code

CREATE TABLE IF NOT EXISTS public.posts
(
id uuid NOT NULL,
content text COLLATE pg_catalog."default" NOT NULL,
created_at timestamp with time zone,
CONSTRAINT posts_pkey PRIMARY KEY (id)
)

Therefore, Migration is just Fluent’s ORM encapsulation of SQL. Through this encapsulation, we can greatly reduce errors when writing SQL, and improve performance by optimizing common SQL statement scenarios.

If you need to use advanced features of the database, Fluent also supports direct use of SQL statements for Migration.

Register and Run Migration

We need to register CreatePost in Sources/main.swit so that our app knows what content it will be dealing with when we run the migration later.

//...
app.migrations.add([CreatePost()])

try app.run()

Now, execute swift run App migrate in the root directory of the project, and enter y to complete the update of the table structure in the database.

The following migration(s) will be prepared:
+ App.CreatePost on default
Would you like to continue?
y/n> y

Write Unit Test to Test Post CURD

Next, we will write a unit test to test the post creation function. Write the following content in Tests/AppTests/PostTests.swift.

Writing unit tests can perform automated tests for functions, ensuring that our server functions do not have exceptions. We will continue to discuss the use of unit tests in subsequent chapters.

@testable import App
import XCTVapor

final class PostTests: XCTestCase {
func testCreateUser() async throws {
let app = Application(.testing)
defer { app.shutdown() }

// autoRevert will automatically perform all the content in revert in Migration
try await app.autoRevert()
// autoMigrate will automatically perform all the content in prepare in Migration
// These two steps will rebuild our database, providing us with a clean testing environment
try await app.autoMigrate()
let post = Post(content: "Hello, world!")

try await post.save(on: app.db)
let postID = try? post.requireID()
// If postID is not nil, it is successfully created, and the test passes
XCTAssertNotNil(postID)
}
}

Then, modify our Package.swift file to add a description about Test.

// swift-tools-version:5.8
import PackageDescription

let package = Package(
name: "MicroBlog",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.77.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.4.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.7.2"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
]
),
.testTarget(name: "AppTests", dependencies: [
.target(name: "App"),
.product(name: "XCTVapor", package: "vapor"),
])
]
)

Now, we can run unit tests by executing swift test at the path where Package.swift is located.

We will now get a prompt that the test has not passed.

FluentKit/Databases.swift:162: Fatal error: No default database configured.
error: Exited with signal code 5

This is because the content about database connection written in main.swift will not be executed in the test. We need to refactor this part of the code so that both sides can use it.

Refactor App Initialization Using configure.swift

Write the following code in Sources/App/configure.swift

import Fluent
import FluentPostgresDriver
import Vapor

public func configure(_ app: Application) async throws {
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
hostname: "localhost",
port: 5432,
username: "vapor_username",
password: "vapor_password",
database: "vapor_database",
tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)
app.migrations.add([CreatePost()])
}

Modify main.swift to use configure

import Vapor
import Fluent
import FluentPostgresDriver

let app = Application()
app.http.server.configuration.port = 8080
defer { app.shutdown() }
app.get { req async in
"It works!"
}
try await configure(app)
try app.run()

Modify PostTests.swift to use configure

@testable import App
import XCTVapor

final class PostTests: XCTestCase {
func testCreateUser() async throws {
let app = Application(.testing)
defer { app.shutdown() }
try await configure(app)

// autoRevert will automatically perform all the content in revert in Migration
try await app.autoRevert()
// autoMigrate will automatically perform all the content in prepare in Migration
// These two steps will rebuild our database, providing us with a clean testing environment
try await app.autoMigrate()
let post = Post(content: "Hello, world!")

try await post.save(on: app.db)
let postID = try? post.requireID()
// If postID is not nil, it is successfully created, and the test passes
XCTAssertNotNil(postID)
}
}

Now run swift test we will see the test pass message

Test Case '-[AppTests.PostTests testCreateUser]' passed (0.263 seconds).

Congratulations, Vapor and the database are connected!

Code for This Chapter

You can find the relevant code for this chapter at https://github.com/kevinzhow/swift-on-server-tour/tree/main/2

Extension: View Database Content Using pgAdmin

If you want to see what content has been created in the database, you can connect to PostgreSQL using pgAdmin.

Preview of Next Chapter

In the next chapter, we will write APIs to implement Post’s CURD (Create Update Read Delete) and further learn about testing.

--

--

Kevin
Kevin

Written by Kevin

Indie Developer | Author | Oyomi | KanaOrigin

No responses yet