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:
- Vapor doesn’t know the mapping between the Post data model and the “table structure stored in the database”.
- 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 isposts
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 stringid
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 thatvar content: String
corresponds to thecontent
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 useon: .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.