지난 시간까지 로컬과 Heroku를 통해 원격에 배포한 서버에 접근하는 과정까지 다루었습니다.
이번에는 본격적으로 원하는 API를 구성하기 위해 request를 통해 전달 받은 데이터의 디코딩 타입을 만들고, DB를 구성하는 과정으로 넘어가볼텐데요, 이번에는 먼저 Fluent 모델에 대한 이론적인 내용을 다루어 보겠습니다.
모델은 데이터베이스의 테이블 또는 컬렉션에 저장된 데이터를 나타냅니다. 모델은 Codable 값을 저장할 수 있는 필드를 가질 수 있습니다. 모든 모델들은 고유 식별자가 있고, Property wrapper가 식별자, 필드와 관계를 나타내는데 사용됩니다.
아래 코드는 하나의 필드를 가지고 있는 예시 모델을 나타내고 있습니다. 한 가지 주의하여야 할 사항은 모델이 제약사항, 인덱스, foreign key와 같은 데이터베이스의 모든 스키마를 나타내고 있지 않다는 점입니다. 모델은 단지 데이터가 데이터베이스 스키마에 저장되는 모양을 나타내는 수단일 뿐이에요.
final class Planet: Model {
// Name of the table or collection.
static let schema = "planets"
// Unique identifier for this Planet.
@ID(key: .id)
var id: UUID?
// The Planet's name.
@Field(key: "name")
var name: String
// Creates a new, empty Planet.
init() { }
// Creates a new Planet with all properties set.
init(id: UUID? = nil, name: String) {
self.id = id
self.name = name
}
}
아래부터는 위의 문단에서 다룬 새로운 용어들에 대해서 다루어보겠습니다.
모든 모델들은 schema
라는 읽기 전용 프로퍼티를 필요로 합니다. 이 문자열은 모델이 나타내는 테이블 또는 컬렉션의 이름을 나타냅니다.
final class Planet: Model {
// Name of the table or collection.
static let schema = "planets"
}
이 모델에 쿼리(데이터 요청)를 할 때, planets
라 이름 붙여진 스키마에 데이터를 저장하거나 스키마로부터 데이터를 불러옵니다.
schema 이름은 통상적으로 클래스 이름을 소문자, 복수형으로 정의합니다.
모든 모델들은 @ID
property wrapper를 이용해 정의된 id
라는 프로퍼티를 가져야 합니다. 이 필드는 모델의 인스턴스를 고유하게 식별하는데 사용됩니다.
final class Planet: Model {
// Unique identifier for this Planet.
@ID(key: .id)
var id: UUID?
}
기본적으로 @ID
프로퍼티는 사용하고 있는 데이터베이스 드라이버에 적절한 키를 사용할 수 있도록 조정해주는 .id
라는 특별한 키를 사용해야 합니다. SQL에서는 "id"
, NoSQL에서는 "_id"
로 자동 적용됩니다.
@ID
는 또한 UUID
타입이어야 합니다. 이는 모든 데이터베이스 드라이버가 지원하는 유일한 식별값이며, Fluent는 모델이 새로 생성될 때 자동적으로 새로운 UUID 식별자를 생성하여 부여해줍니다.
@ID
는 저장되지 않은 모델들이 식별자를 아직 가지고 있지 않을 수 있기 때문에 optional로 정의되어 있습니다. 식별자를 가져오면서 식별자가 없는 경우 에러를 던져주고자 한다면 아래처럼 requireID
메서드를 사용하시면 됩니다.
let id = try planet.requireID()
@ID
는 데이터베이스에 존재하는지 여부를 나타내는 exists
라는 프로퍼티를 가지고 있습니다. 모델을 초기화할 때 이 값은 false
이지만, 저장하거나 데이터베이스에서 모델을 가져온 후에는 true
입니다. 이 값은 변경할 수 있습니다.
if planet.$id.exists {
// This model exists in database.
}
Fluent는 @ID(custom:)
를 통해 사용자 정의 식별자 키와 타입을 정의할 수 있습니다.
final class Planet: Model {
// Unique identifier for this Planet.
@ID(custom: "foo")
var id: Int?
}
위의 예시는 Int
타입을 가진 "foo"
라는 사용자 정의 식별자 키를 가지고 @ID
를 정의했습니다. 이는 자동으로 증가하는 primary key를 이용하는 SQL 데이터베이스와 호환될 수 있습니다만 NoSQL과는 호환되지 않습니다.
사용자 정의된 @ID
들은 generatedBy
파라미터를 통해 식별자가 생성되는 방식을 지정할 수 있습니다.
@ID(custom: "foo", generatedBy: .user)
Generated By | Description |
---|---|
.user | 새로운 모델을 저장하기 전에 사용자가 @ID 프로퍼티를 세팅함 |
.random | @ID 값 타입은 RandomGeneratable 프로토콜을 채택해야 함 (자동 생성) |
.database | 저장 시 데이터베이스가 값을 생성함 |
generatedBy
파라미터를 생략하면 Fluent는 @ID
값 타입에 따라 적절하게 추론하여 적용합니다. 예를 들어 별도로 지정되지 않으면 Int
는 기본적으로 .database
설정으로 값을 생성합니다.
모델은 반드시 빈 이니셜라이저 메서드를 가지고 있어야 합니다.
final class Planet: Model {
// Creates a new, empty Planet.
init() { }
}
Fluent는 쿼리의 결과로 반환되는 모델을 내부적으로 생성하기 위해 이 메서드를 필요로 합니다.
원한다면 모든 프로퍼티를 가지는 convenience initializer를 추가할 수 있습니다.
final class Planet: Model {
// Creates a new Planet with all properties set.
init(id: UUID? = nil, name: String) {
self.id = id
self.name = name
}
}
Convenience initializer를 이용하면 향후 모델에 새로운 프로퍼티를 추가하는 작업을 편하게 만들어줄 수 있습니다.
모델들은 데이터를 저장하기 위해 @Field
프로퍼티들을 가질 수 있습니다.
final class Planet: Model {
// The Planet's name.
@Field(key: "name")
var name: String
}
필드를 사용하려면 위의 예시와 같이 데이터베이스 키를 명시적으로 정의하여야 합니다. 다만 프로퍼티 이름과 반드시 같을 필요는 없습니다.
Fluent는 데이터베이스 키를
snake_case
식 이름으로, 프로퍼티 이름을camelCase
식 이름으로 짓는 것을 권장합니다.
필드 값들은 Codable
을 따르는 모든 타입으로 설정할 수 있습니다. @Field
는 중첩 구조 (nested structures)와 배열을 저장할 수 있지만 이를 이용한 필터링 작업은 제한됩니다. 필터링이 필요하다면 @Group
을 대체재로 알아보시면 됩니다.
옵셔널 값을 가지는 필드를 정의하고자 한다면 @OptionalField
를 사용하세요.
@OptionalField(key: "tag")
var tag: String?
모델들은 @Parent
, @Children
, @Siblings
와 같은 다른 모델들을 참조할 수 있는 관계 프로퍼티를 가질 수 있습니다. 관련한 내용은 Relation 섹션을 확인해보세요.
@Timestamp
는 Foundation.Date
를 저장하는 @Field
의 특수한 타입입니다. Timestamp는 선택된 트리거에 따라 Fluent가 자동적으로 세팅합니다.
final class Planet: Model {
// When this Planet was created.
@Timestamp(key: "created_at", on: .create)
var createdAt: Date?
// When this Planet was last updated.
@Timestamp(key: "updated_at", on: .update)
var updatedAt: Date?
}
@Timestamp
는 아래의 트리거를 지원합니다.
Trigger | Description |
---|---|
.create | 새로운 모델 인스턴스가 데이터베이스에 저장될 때 세팅. |
.update | 기존의 모델 인스턴스가 데이터베이스에 저장될 때 세팅. |
.delete | 모델이 데이터베이스에서 삭제될 때 세팅. soft delete 참조 |
기본적으로 @Timestamp
는 사용하는 데이터베이스 드라이버에 따라 인코딩하기 효율적인 datetime을 사용합니다. format
파라미터를 통해 데이터베이스에 timestamp가 저장되는 방식을 설정할 수 있습니다.
// Stores an ISO 8601 formatted timestamp representing
// when this model was last updated.
@Timestamp(key: "updated_at", on: .update, format: .iso8601)
var updatedAt: Date?
위 예시에 표현한 .iso8601
은 마이그레이션 시 아래와 같이 .string
형식으로 저장되어야 한다는 것을 기억해주세요.
.field("updated_at", .string)
이용할 수 있는 timestamp 형식은 아래와 같습니다.
Format | Description | Type |
---|---|---|
.default | 데이터베이스에 따라 인코딩하기 효율적인 datetime을 사용함 | Date |
.iso8601 | ISO 8601 문자열. withMilliseconds 파라미터 사용을 지원함 | String |
.unix | fraction을 포함한 Unix epoch 시점으로부터의 경과된 시간(초) | Double |
timestamp
프로퍼티를 통해 raw timestamp 값에 직접 접근하실 수 있습니다.
// Manually set the timestamp value on this ISO 8601
// formatted @Timestamp.
model.$updatedAt.timestamp = "2020-06-03T16:20:14+00:00"
.delete
를 @Timestamp
에 추가하면 모델이 soft-deletion을 지원합니다.
final class Planet: Model {
// When this Planet was deleted.
@Timestamp(key: "deleted_at", on: .delete)
var deletedAt: Date?
}
Soft하게 삭제된 모델들은 삭제된 후에도 데이터베이스에 존재하지만 쿼리로부터 반환되지는 않습니다.
삭제 시 timestamp를 미래의 시점으로 설정할 수 있습니다. 이는 실제로 데이터가 삭제되는 만료 시점으로 사용할 수 있습니다.
데이터베이스로부터 soft-delete를 지원하는 모델을 강제로 삭제하고자 한다면 delete
시 force
파라미터를 사용하시면 됩니다.
// Deletes from the database even if the model
// is soft deletable.
model.delete(force: true, on: database)
soft하게 삭제된 모델을 복구할 때는 restore
메서드를 사용하시면 됩니다.
// Clears the on delete timestamp allowing this
// model to be returned in queries.
model.restore(on: database)
soft 삭제된 모델들을 쿼리에 포함하고 싶으시다면 withDeleted
를 사용하시면 됩니다.
// Fetches all planets including soft deleted.
Planet.query(on: database).withDeleted().all()
@Enum
은 문자열으로 표현할 수 있는 타입을 네이티브 데이터베이스 열거값으로 저장하기 위한 특수 타입 @Field
입니다. 네이티브 데이터베이스 열거형은 데이터베이스에 타입 안전 계층을 추가하므로 raw 열거형보다 성능이 더 우수할 수 있습니다.
// String representable, Codable enum for animal types.
enum Animal: String, Codable {
case dog, cat
}
final class Pet: Model {
// Stores type of animal as a native database enum.
@Enum(key: "type")
var type: Animal
}
RawRepresentable
(RawValue가 String인 경우)을 준수하는 타입만 @Enum
과 호환됩니다. 문자열 String이 채택된 열거형은 기본적으로 이 요구 사항을 충족합니다.
옵셔널 형태의 열거형을 저장하고자 한다면 @OptionalEnum
을 사용하세요.
마이그레이션을 통해 열거형을 다룰 수 있도록 데이터베이스를 준비해야 합니다. 자세한 내용은 enum을 참조하세요.
String 또는 Int와 같이 Codable
이 지원되는 모든 열거형을 @Field
에 저장할 수 있습니다. 이 값은 데이터베이스에 raw value로 저장됩니다.
@Group
을 사용하면 중첩된 필드 그룹을 모델의 단일 속성으로써 저장할 수 있습니다. @Field
에 저장된 Codable
구조체들과 달리 @Group
의 필드는 쿼리할 수 있습니다. Fluent는 데이터베이스에 @Group
을 플랫한 구조로 저장하여 쿼리할 수 있도록 만듭니다.
@Group
을 사용하려면 먼저 저장하고자 하는 중첩 구조체를 작성하고 Fields
프로토콜을 채택합니다. 이는 식별자나 스키마 이름이 필요하지 않다는 것을 제외하고 Model
과 매우 유사합니다. 이를 이용하여 Model
이 지원하는 @Field
, @Enum
, @Group
을 통해 많은 프로퍼티들을 저장할 수 있습니다.
// A pet with name and animal type.
final class Pet: Fields {
// The pet's name.
@Field(key: "name")
var name: String
// The type of pet.
@Field(key: "type")
var type: String
// Creates a new, empty Pet.
init() { }
}
필드들을 만든 후에 @Group
프로퍼티를 값으로 사용할 수 있습니다.
final class User: Model {
// The user's nested pet.
@Group(key: "pet")
var pet: Pet
}
@Group
의 필드들에는 dot 문법으로 접근할 수 있습니다.
let user: User = ...
print(user.pet.name) // String
property wrapper에서 사용하던 dot 문법과 같이 중첩된 필드에 쿼리할 수 있습니다.
User.query(on: database).filter(\.$pet.$name == "Zizek").all()
데이터베이스에서 @Group
은 _
로 연결된 키를 가진 플랫 구조로 저장됩니다. 아래는 데이터베이스에서 User
가 어떻게 표시되는지 보여 주는 예시입니다.
id | name | pet_name | pet_type |
---|---|---|---|
1 | Tanner | Zizek | Cat |
2 | Logan | Runa | Dog |
모델들은 기본적으로 Codable
을 채택하고 있습니다. 모델에 Vapor의 content
를 채택함으로써 Codable을 함께 채택하게 됩니다.
extension Planet: Content { }
app.get("planets") { req in
// Return an array of all planets.
Planet.query(on: req.db).all()
}
Codable
을 통해 데이터를 인코딩/디코딩할 때 모델 프로퍼티들은 키 대신에 가지고 있는 변수 이름을 사용합니다.
이 내용은 추후 Create, Read, Update, Delete (CRUD) 기능을 구현할 때 다시 다루도록 하겠습니다.