SQLite3는 C/C++API를 제공하고 있으며, 사용하기도 그리 어렵지 않다. Swift에서 SQLite3를 사용하기 위한 가장 간단한 방법은 Obejctive-C로 DB를 액세스하는 함수나 클래스를 작성하고, Xcode 프로젝트에서 이 클래스를 추가해 Swift에서 사용하는 방법이다. 특히 코코아 클래스들과 Swift 타입들 간에는 바로 브릿징되면서 자동으로 변환되는 것들이 있기 때문에 사용하기에 편리한 점은 있지만, Objective-C에 익숙하지 않거나, 혹은 그냥 아무 이유없이 C API와의 연동을 해보고 싶은 경우가 있을 수 있기에 방법을 소개한다.
SQLite3의 C API와 연동하는 방법에 대해서는 별도의 포스팅으로 내용을 분리하였다.
이 글에서는 C API를 직접 사용하면서 쿼리를 실행할 수 있는 클래스를 하나 작성해보도록 하겠다. 먼저 클래스 내에서 별도로 사용할 enum 타입들을 정의한다. 하나는 에러들이고, 다른 하나는 칼럼의 타입을 구분하기 위한 값으로 쓴다.
class SQLite {
enum SQLError: Error {
case connectionError
case queryError
case otherError
}
enum ColumnType {
case int
case double
case text
}
...
}
데이터베이스를 연결하고, 쿼리를 저장할 두 개의 객체 포인터가 필요하다. 그리고 파일 경로의 경우에는 바꾸는 사람 마음이겠지만, 간단히 앱 라이브러리 디렉토리에 저장되도록 하겠다.
var db: OpaquePointer?
var stmt: OpaquePointer?
let path: String = {
let fm = FileManager.default
return fm.urls(for:.libraryDirectory, in:.userDomainMask).last!
.appendingPathComponent("db.sqlite").path
}()
이 클래스는 초기화 될 때 자동으로 파일을 찾아서 열고 쿼리를 받을 준비를 하도록 한다. 또 제거될 때 DB를 닫도록한다.
init() throws {
if sqlite3_open(path, &db) == SQLITE_OK {
return
}
throw SQLError.connectionError
}
deinit {
sqlite3_finalize(stmt)
sqlite3_close(db)
}
이전의 예들에서는 간단하게 고정된 스키마의 테이블을 쓰면서 정수값 하나를 읽고, 쓰고 하는 예를 보였는데 여기에서는 필요할 때마다 쿼리를 인스톨하고 (또 필요하면 바인딩하고) 쿼리를 실행하도록 해보자.
func install(query: String) throws {
sqlite3_stmt(stmt); stmt = nil
if sqlite3_prepare_v2(db, query, -1, &stmt, nil) != SQLITE_OK {
throw SQLError.queryError
}
}
쿼리의 내용에 맞게 바인딩을 할 수 있게 해준다. 바인딩되는 칼럼의 순서와 타입은 쓰는 사람이 정확하게 쓰는 수 밖에…
func bind(data:Any, withType type:ColumnType at col:Int32 = 1) {
switch type {
case .int:
if let value = data as? Int {
sqlite3_bind_int(stmt, col, Int(value))
}
case .double:
if let value = data as? Double {
sqlite3_bind_double(stmt, col, value)
}
case .text:
if let value = data as? String {
sqlite3_bind_text(stmt, col, value, -1, nil)
}
}
다음은 쿼리를 실행하는 함수인데, 이 결과는 SQLITE_DONE이거나 SQLITE_ROW일 것이다. (그 외에는 뭔가 에러가 발생했다는 의미가 될테고) 따라서 이 두 값에 따라서 DONE이면 실행을 종료하고, ROW이면 읽어온 row가 하나 있으니 그것을 핸들러로 처리하도록 한다. 사실 x 번째 칼럼에서 y 타입의 값을 얻어오는 메소드를 만드는 것도 나쁘지 않을 것 같기는 하다.
func execute(rowHanlder:((OpaquePointer) -> Void)? = nil) throws {
while true {
switch sqlite3_step(stmt) {
case SQLITE_DONE: return
case SQLITE_ROW: rowHandler?(stmt!)
default:
throw SQLError.otherError
}
}
}
최종적으로 테스트 코드이다.
do {
let db = try SQLite()
// 테이블 생성
try db.install(query:"CREATE TABLE IF NOT EXISTS test (num INTEGER)")
try db.execute()
// 데이터를 삽입해보자.
for i in 10..<20 {
try db.install(query:"INSERT INTO test VALUES (?)")
db.bind(data: i, withType: .int)
try db.execute()
}
/// 조회
try db.install(query:"select * from test")
try db.execute(){ stmt in
let n = sqlite3_column_int(stmt, 0)
print(n)
}
} catch {
print(error)
}
다음은 전체 코드를 하나로 묶은 Gist이다.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
class SQLite { | |
enum SQLError: Error { | |
case connectionError | |
case queryError | |
case otherError | |
} | |
enum ColumnType { | |
case int | |
case double | |
case text | |
} | |
var db: OpaquePointer? | |
var stmt: OpaquePointer? | |
let path: String = { | |
let fm = FileManager.default | |
return fm.urls(for:.libraryDirectory, in:.userDomainMask).last! | |
.appendingPathComponent("data.db").path | |
}() | |
init() throws { | |
guard sqlite3_open(path, &db) == SQLITE_OK | |
else { | |
throw SQLError.connectionError | |
} | |
} | |
func install(query: String) throws { | |
sqlite3_finalize(stmt) | |
stmt = nil | |
if sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK { | |
return | |
} | |
throw SQLError.queryError | |
} | |
func bind(data:Any, withType type: ColumnType, at col: Int32 = 1) { | |
switch type { | |
case .int: | |
if let value = data as? Int { | |
sqlite3_bind_int(stmt, col, Int32(value)) | |
} | |
case .double: | |
if let value = data as? Double { | |
sqlite3_bind_double(stmt, col, value) | |
} | |
case .text: | |
if let value = data as? String { | |
sqlite3_bind_text(stmt, col, value, -1, nil) | |
} | |
} | |
} | |
func execute(rowHandler:((OpaquePointer) -> Void)? = nil) throws { | |
while true { | |
switch sqlite3_step(stmt) { | |
case SQLITE_DONE: | |
return | |
case SQLITE_ROW: | |
rowHandler?(stmt!) | |
default: | |
throw SQLError.otherError | |
} | |
} | |
} | |
deinit { | |
sqlite3_finalize(stmt) | |
sqlite3_close(db) | |
} | |
} | |
do { | |
let db = try SQLite() | |
// 테이블 생성 | |
try db.install(query:"CREATE TABLE IF NOT EXISTS test (num INTEGER)") | |
try db.execute() | |
for i in 10..<20 { | |
try db.install(query:"INSERT INTO test VALUES (?)") | |
db.bind(data: i, withType: .int) | |
try db.execute() | |
} | |
/// 조회 | |
try db.install(query:"select * from test") | |
try db.execute(){ stmt in | |
let n = sqlite3_column_int(stmt, 0) | |
print(n) | |
} | |
} catch { | |
print(error) | |
} |