Swift에서 SQLite3 사용하기

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이다.