世間はiOSDC一色のようですが、辺境地でまったり独自で変な研究をしたのですが、一旦研究をフリーズさせる事にしたので、これまでの成果を記録に残す意味で、ここに記します。
最近、別件で技術的な難問に取り組んでいたのですが、その解決方法にオブジェクトデータベースを使えばいいのではないかと考え始め、試行錯誤した末に独自にオブジェクトデータベースを作ってみようと思い色々試していました。が、本来の技術的な難問は別の方法で解決でできるかもと思い始めたので、このプロジェクトを中断しようと思ったのですが、プロジェクト自体は面白そうなので、将来このプロジェクトに戻ってきた時の為の自分への記録とする事とします。
このプロジェクトが前進する毎に、なんか劣化版Realmを研究開発しているみたいで、モチベーションが下がります。が、テーマとしては面白いと考えているので、気分は持ちようです。
NSObjectはなんだかんだ言ってやはり優秀です。このNSObjectのアーカイバとSQLite3を活用すれば面白そうなオブジェクトデータベースができそうだと思いました。コードはここから取得可能です。
作戦
作戦はこうです。
- データベースに生存するオブジェクトを
NSObjectのサブクラスZObjectのサブクラスとして実装する事とします。 - SQLite3をラップしたデータベース管理クラス
ZStorageを設計します。 -
ZObjectはZStorageに登録時にIntegerのPrimary Keyが採番されるものとします。この値をidentifierとし、一意にアクセスできるようにします。 - あるオブジェクトが別のオブジェクトを
encodeする時は、その別のオブジェクトのidentifierのみをencodeするものとする。 - 別のオブジェクトが
Arrayのようなコレクションだとしても同様にidentifierのみをエンコードする - エンコードされたオブジェクトはデータベースにバイナリとして保存される
- オブジェクトをインスタンス化する時は、
identifierとアーカイブされたデータをNSKeyedUnarchiverで復元させます。 - 復元時に別オブジェクトを復元させる必要がある場合でも、
coderからidentifierのみを取得できれば、再起的に復元できます。
ZObject
ZObjectの実装を見てみる事とします。ZStorageとidentifierがあれば、アンアーカイブされます。サブクラスされていても正確に復元できます。
open class ZObject: NSObject, NSCoding {
private static let idKey = "id"
public weak var storage: ZStorage?
public var identifier: Int
public init(storage: ZStorage) throws {
self.identifier = -1
self.storage = storage
super.init()
try storage.insert(object: self)
assert(self.identifier != -1)
}
public init(identifier: Int, storage: ZStorage) throws {
self.identifier = identifier
self.storage = storage
super.init()
}
required public init?(coder: NSCoder) {
self.identifier = coder.decodeInteger(forKey: Self.idKey)
self.storage = coder.storage!
super.init()
}
deinit {
}
open func encode(with coder: NSCoder) {
coder.encode(self.identifier, forKey: Self.idKey)
self.storage = coder.storage!
}
open func save(storage: ZStorage?) throws {
guard let storage = storage ?? self.storage else { throw ZStorageError.storageNotFound }
try storage.save(object: self)
}
...
}
ZStorage
ZStorageを見てみます。Sqlite3のラッパーに独自のZSQLDatabaseを使っています。いやぁ、車輪の再発明は楽しいです。こんな感じで、オブジェクトのインスタンス化、保存、更新などを司る設計になっています。
open class ZStorage: NSObject {
let database: ZSQLDatabase
let fileURL: URL
private static let objectTableKey = "object"
private static let idKey = "id"
private static let typeColumnKey = "type"
private static let dataColumnKey = "data"
private static let refcountKey = "refcount"
public init(fileURL: URL) throws {
typealias U = ZStorage
self.fileURL = fileURL
try self.database = ZSQLDatabase(fileURL: fileURL)
let query = try self.database.query("""
CREATE TABLE IF NOT EXISTS \(U.objectTableKey)(
\(U.idKey) INTEGER PRIMARY KEY AUTOINCREMENT,
\(U.typeColumnKey) TEXT NOT NULL,
\(U.dataColumnKey) BINARY,
\(U.refcountKey) INTEGER NOT NULL
);
""")
let result = query.step()
guard result.done else { throw ZSQLStatus(result) }
}
public func instanciateObject<T: ZObject>(identifier: Int, of objectType: T.Type) throws -> T? {
return try self.instanciateObjects(identifiers: [identifier], of: T.self).first
}
public func instanciateObjects<T: ZObject>(identifiers: [Int], of objectType: T.Type) throws -> [T] {
typealias U = ZStorage
let typeString = String(describing: T.self)
let identifiersList = identifiers.map { String($0) }.joined(separator: ",")
let query = try self.database.query("""
SELECT \(U.idKey), \(U.typeColumnKey), \(U.dataColumnKey) FROM \(U.objectTableKey)
WHERE \(U.typeColumnKey) = '\(typeString)' AND \(U.idKey) IN (\(identifiersList)) AND \(U.refcountKey) > 0;
""")
var objects = [T]()
while let row = query.step().row {
guard let identifier: Int = row[U.idKey] as? Int else { fatalError() }
if let object = self.objectCache.object(forKey: identifier as NSNumber) as? T {
objects.append(object)
}
else if let typeString = row[U.typeColumnKey] as? String, let data = row[U.dataColumnKey] as? Data {
if let object = try? ZObjectKeyedUnarchiver.unarchiveTopLevelObjectWithData(data, storage: self) as? T {
let objectType = String(describing: type(of: object))
if objectType != typeString {
print("\(Self.self): type mismatch, it is expected '\(typeString)' but '\(objectType)' id=\(identifier)")
}
objects.append(object)
self.objectCache.setObject(object, forKey: identifier as NSNumber)
}
else { fatalError("\(Self.self): unable to unarchive an object id=\(identifier)") }
}
else { fatalError() }
}
return objects
}
public func insert<T: ZObject>(object: T) throws {
typealias U = ZStorage
let data = try! ZObjectKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: false, storage: self)
let typeString = String(describing: type(of: object))
let refcount = 1
let query = try self.database.query(
"INSERT INTO \(U.objectTableKey) (\(U.typeColumnKey), \(U.dataColumnKey), \(U.refcountKey)) VALUES (?1, ?2, ?3);",
typeString, data, refcount)
let result = query.step()
guard result.done else { throw ZSQLStatus(result) }
let identifier = self.database.lastInsertRowID
object.identifier = Int(identifier)
object.storage = self
self.objectCache.setObject(object, forKey: identifier as NSNumber)
}
public func save<T: ZObject>(object: T) throws {
if object.storage == self { try self.update(object: object) }
else { try self.insert(object: object) }
}
public func update<T: ZObject>(object: T) throws {
typealias U = ZStorage
assert(object.storage == self)
guard object.identifier > 0 else { return }
let identifier = object.identifier
let data = try! ZObjectKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: false, storage: self)
let typeString = String(describing: type(of: object))
let query = try self.database.query(
"UPDATE \(U.objectTableKey) SET \(U.dataColumnKey) = ?1, \(U.typeColumnKey) = ?2 WHERE \(U.idKey) = \(identifier);",
data, typeString)
let result = query.step()
guard result.done else { throw ZSQLStatus(result) }
}
...
}
ZObjectはデータベースと常に結び付けられている必要があるので、UnarchiveされたZObjectはZStorageと紐づいている必要があるため。カスタム化したNSKeyedArchiver、KeyedUnarchiverを使って、ZObjectがインスタンス化された瞬間にweak var storage: ZStorage?にstorageを設定するとともに、ZStorageは同オブジェクトを二重にインスタンス化させないためキャッシュします。
ZObjectKeyedArchiver, ZObjectKeyedUnarchiver
ZObjectはアーカイブ、アンアーカイブの際に常にZStorageにアクセスする必要があります。例えばrequired init?(coder: NSCoder)時に別オブジェクトをインスタンス化させるために、ZStorageが必要となりますが、NSCoderはその事を知らないので、NSKeyedUnarchiver、NSKeyedArchiverのサブクラスを用意し、さらに NSCoderのプロトコルエクステンションを用意して、NSCoderはいつでもどのZStorageからZObjectをインスタンス化しているか知ることができます。
public class ZObjectKeyedArchiver: NSKeyedArchiver {
private static let semaphore = DispatchSemaphore(value: 1)
private static var storage: ZStorage?
private (set) public var _storage: ZStorage?
class func archivedData(withRootObject object: Any, requiringSecureCoding requiresSecureCoding: Bool, storage: ZStorage) throws -> Data {
Self.semaphore.wait()
Self.storage = storage
return try super.archivedData(withRootObject: object, requiringSecureCoding: requiresSecureCoding)
}
override init() {
self._storage = Self.storage
super.init()
Self.semaphore.signal()
}
override init(requiringSecureCoding requiresSecureCoding: Bool) {
self._storage = Self.storage
super.init(requiringSecureCoding: requiresSecureCoding)
}
}
public class ZObjectKeyedUnarchiver: NSKeyedUnarchiver {
private static let semaphore = DispatchSemaphore(value: 1)
private static var storage: ZStorage?
private (set) public var _storage: ZStorage?
class func unarchiveTopLevelObjectWithData(_ data: Data, storage: ZStorage) throws -> Any? {
Self.semaphore.wait()
Self.storage = storage
return try super.unarchiveTopLevelObjectWithData(data)
}
override init(forReadingWith data: Data) {
self._storage = Self.storage
super.init(forReadingWith: data)
Self.storage = nil
Self.semaphore.signal()
}
override init(forReadingFrom data: Data) throws {
self._storage = Self.storage
try super.init(forReadingFrom: data)
self.decodingFailurePolicy = .setErrorAndReturn
Self.storage = nil
Self.semaphore.signal()
}
}
public extension NSCoder {
var storage: ZStorage? {
if let archiver = self as? ZObjectKeyedArchiver {
return archiver._storage
}
if let unarchiver = self as? ZObjectKeyedUnarchiver {
return unarchiver._storage
}
return nil
}
}
この記事を書いている時には失念してしまいましたが、NSKeyedUnarchiverだったか、NSKeyedArchiverだったかは、init()で一旦インスタンス化して、archive()したデータはunarchive()で元のオブジェクトが取り出せないので、classメソッドをオーバーライドして実装しています。さらにinit(requiringSecureCoding requiresSecureCoding: Bool)とinit()両方が、ストアドプロパティを初期化せよとうるさいので、こんな変なコーディングになっています。じゃあ、init()を削るとそれもコンパイラに怒られます。おそらく、クラスタクラスか何かの呪いのような気がしますが、深入りしない事としました。
実際の使用例
しょぼいお絵描きアプリを想定してみます。グラフィックオブジェクトは2種類CircleとRectangleの二種類ですが、共通のプロトコルShapeに準拠していルものとします。グラフィックオブジェクトはレイヤー上に配置され、レイヤーはページ内に、さらにページはコンテンツに存在するものとします。それぞれが、ZObjectのサブクラスでデータベース上に永続化されるものとするとこんな感じでコーディングします。
protocol Shape: ZObject {
}
class Circle: ZObject, Shape {
var center: CGPoint
var radius: CGFloat
enum Keys: String { case center, radius }
init(center: CGPoint, radius: CGFloat, storage: ZStorage) throws {
self.center = center
self.radius = radius
try super.init(storage: storage)
}
override func encode(with coder: NSCoder) {
super.encode(with: coder)
coder.encode(self.center, forKey: Self.Keys.center.rawValue)
coder.encode(self.radius, forKey: Self.Keys.radius.rawValue)
}
required init?(coder: NSCoder) {
self.center = coder.decodeCGPoint(forKey: Self.Keys.center.rawValue)
self.radius = coder.decodeObject(forKey: Self.Keys.radius.rawValue) as! CGFloat
super.init(coder: coder)
}
override var description: String {
return "{\(Self.self): center=\(self.center), radius=\(radius)}"
}
}
class Rectangle: ZObject, Shape {
var origin: CGPoint
var size: CGSize
enum Keys: String { case origin, size }
init(origin: CGPoint, size: CGSize, storage: ZStorage) throws {
self.origin = origin
self.size = size
try super.init(storage: storage)
}
override func encode(with coder: NSCoder) {
super.encode(with: coder)
coder.encode(self.origin, forKey: Self.Keys.origin.rawValue)
coder.encode(self.size, forKey: Self.Keys.size.rawValue)
}
required init?(coder: NSCoder) {
self.origin = coder.decodeCGPoint(forKey: Self.Keys.origin.rawValue)
self.size = coder.decodeCGSize(forKey: Self.Keys.size.rawValue)
super.init(coder: coder)
}
override var description: String {
return "{\(Self.self): origin=\(self.origin), size=\(size)}"
}
}
class Layer: ZObject {
var shapes: [Shape]
static let shapesKey = "shapes"
init(shapes: [Shape], storage: ZStorage) throws {
self.shapes = shapes
try super.init(storage: storage)
}
override func encode(with coder: NSCoder) {
coder.encode(shapes, forKey: Self.shapesKey)
super.encode(with: coder)
}
required init?(coder: NSCoder) {
self.shapes = coder.decodeObject(forKey: Self.shapesKey) as! [Shape]
super.init(coder: coder)
}
}
class Page: ZObject {
var layers: [Layer]
static let layersKey = "layers"
init(layers: [Layer], storage: ZStorage) throws {
self.layers = layers
try super.init(storage: storage)
}
override func encode(with coder: NSCoder) {
coder.encode(layers, forKey: Self.layersKey)
super.encode(with: coder)
}
required init?(coder: NSCoder) {
self.layers = coder.decodeObject(forKey: Self.layersKey) as! [Layer]
super.init(coder: coder)
}
}
class Contents: ZObject {
var pages: [Page]
static let pagesKey = "pages"
init(pages: [Page], storage: ZStorage) throws {
self.pages = pages
try super.init(storage: storage)
}
override func encode(with coder: NSCoder) {
coder.encode(pages, forKey: Self.pagesKey)
super.encode(with: coder)
}
required init?(coder: NSCoder) {
self.pages = coder.decodeObject(forKey: Self.pagesKey) as! [Page]
super.init(coder: coder)
}
}
Contentsクラスは、Pageを、PageはLayerを、LayerはShapeを内包する事ができます。コードでモデルを生成する例を以下に示します。
do {
let storage = try ZStorage(fileURL: self.file1URL)
let c1 = try Circle(center: CGPoint(x: 100, y: 200), radius: 300, storage: storage)
let c2 = try Circle(center: CGPoint(x: 400, y: 500), radius: 600, storage: storage)
let c3 = try Circle(center: CGPoint(x: 700, y: 800), radius: 900, storage: storage)
let r1 = try Rectangle(origin: CGPoint(x: 101, y: 102), size: CGSize(width: 103, height: 104), storage: storage)
let r2 = try Rectangle(origin: CGPoint(x: 201, y: 202), size: CGSize(width: 203, height: 204), storage: storage)
let r3 = try Rectangle(origin: CGPoint(x: 301, y: 302), size: CGSize(width: 303, height: 304), storage: storage)
let layer1 = try Layer(shapes: [c1, r1], storage: storage)
let layer2 = try Layer(shapes: [c2, r2], storage: storage)
let layer3 = try Layer(shapes: [c3, r3], storage: storage)
let page1 = try Page(layers: [layer1, layer2], storage: storage)
let page2 = try Page(layers: [layer3], storage: storage)
let contents = try Contents(pages: [page1, page2], storage: storage)
try contents.save(storage: storage)
print(contents.identifier)
} catch { ... }
ZStorageにオブジェクトを登録した時にidentifierが決定するのでそのidをUserDefaultなりなんなり保存しておきます。それを後に、または次回に以下のようにオブジェクトグラフ全体をロードする事ができます。Arrayもです。ここのオブジェクトは常にidentifierからアンアーカイブされています。Shapeのようなプロトコルで抽象化されたオブジェクトでもちゃんとタイプ情報が損失することなく復元できます。NSObjectとNSCodingすごいです。
do {
let identifier = ...
if let contents = try storage.instanciateObject(identifier: identifier, of: Contents.self) {
...
}
} catch { ... }
特定のidentifierを基にではなく、特定のクラスのインスタンスを一括で取得する事もできます。
do {
let storage = ...
let identifier = ...
if let pages = try storage.instanciateObject(of: Page.self) {
for page in pages {
...
}
}
} catch { ... }
ZSQL
再発明した車輪のコードも紹介します。最初は生のsqliteを叩いていましたが、statementの扱いが非常に面倒くさいと思ったので、独自にラッパーを書きました。
import Foundation
import SQLite3
public struct ZSQLStatus: Error, CustomStringConvertible {
let status: Int32
public init(_ status: Int32) {
self.status = status
}
public init(_ result: ZSQLResult) {
self.status = result.status
}
public var description: String {
switch self.status {
case SQLITE_OK: return "SQLITE_OK: Successful result"
case SQLITE_ERROR: return "SQLITE_ERROR: Generic error"
case SQLITE_INTERNAL: return "SQLITE_INTERNAL: Internal logic error in SQLite"
case SQLITE_PERM: return "SQLITE_PERM: Access permission denied"
case SQLITE_ABORT: return "SQLITE_ABORT: Callback routine requested an abort"
case SQLITE_BUSY: return "SQLITE_BUSY: The database file is locked"
case SQLITE_LOCKED: return "SQLITE_LOCKED: A table in the database is locked"
case SQLITE_NOMEM: return "SQLITE_NOMEM: A malloc() failed"
case SQLITE_READONLY: return "SQLITE_READONLY: Attempt to write a readonly database"
case SQLITE_INTERRUPT: return "SQLITE_INTERRUPT: Operation terminated by sqlite3_interrupt()"
case SQLITE_IOERR: return "SQLITE_INTERRUPT: Some kind of disk I/O error occurred"
case SQLITE_CORRUPT: return "SQLITE_CORRUPT: The database disk image is malformed"
case SQLITE_NOTFOUND: return "SQLITE_NOTFOUND: Unknown opcode in sqlite3_file_control()"
case SQLITE_FULL: return "SQLITE_FULL: Insertion failed because database is full"
case SQLITE_CANTOPEN: return "SQLITE_CANTOPEN: Unable to open the database file"
case SQLITE_PROTOCOL: return "SQLITE_PROTOCOL: Database lock protocol error"
case SQLITE_EMPTY: return "SQLITE_EMPTY: Internal use only"
case SQLITE_SCHEMA: return "SQLITE_SCHEMA: The database schema changed"
case SQLITE_TOOBIG: return "SQLITE_TOOBIG: String or BLOB exceeds size limit"
case SQLITE_CONSTRAINT: return "SQLITE_CONSTRAINT: Abort due to constraint violation"
case SQLITE_MISMATCH: return "SQLITE_MISMATCH: Data type mismatch"
case SQLITE_MISUSE: return "SQLITE_MISUSE: Library used incorrectly"
case SQLITE_NOLFS: return "SQLITE_NOLFS: Uses OS features not supported on host"
case SQLITE_AUTH: return "SQLITE_AUTH: Authorization denied"
case SQLITE_FORMAT: return "SQLITE_FORMAT: Not used"
case SQLITE_RANGE: return "SQLITE_RANGE: 2nd parameter to sqlite3_bind out of range"
case SQLITE_NOTADB: return "SQLITE_NOTADB: File opened that is not a database file"
case SQLITE_NOTICE: return "SQLITE_NOTICE: Notifications from sqlite3_log()"
case SQLITE_WARNING: return "SQLITE_WARNING: Warnings from sqlite3_log()"
case SQLITE_ROW: return "SQLITE_ROW: sqlite3_step() has another row ready"
case SQLITE_DONE: return "SQLITE_DONE: sqlite3_step() has finished executing"
default: return "Unknown SQLITE status \(self.status)"
}
}
}
enum ZSQLError: Error {
case failedOpenFile
case columnNotFound
}
public class ZSQLDatabase {
let fileURL: URL
private (set) var database: OpaquePointer?
private (set) var status: Int32
public init(fileURL: URL) throws {
self.fileURL = fileURL
self.status = sqlite3_open(fileURL.path, &self.database)
guard self.status == SQLITE_OK else { fatalError(self.errorMessage ?? ZSQLStatus(self.status).description) }
}
public var errorMessage: String? { String(utf8String: sqlite3_errmsg(self.database)) }
public func query(_ query: String, _ arguments: ZSQLColumnValue...) throws -> ZSQLQuery {
return try ZSQLQuery(database: self, query: query, arguments: arguments)
}
public var lastInsertRowID: Int {
return Int(sqlite3_last_insert_rowid(self.database))
}
public var autocommit: Bool {
return sqlite3_get_autocommit(self.database) != 0
}
public var underTransaction: Bool {
return !self.autocommit
}
}
public class ZSQLQuery {
let database: ZSQLDatabase
let query: String
private (set) var statement: OpaquePointer?
private (set) var status: Int32
init(database: ZSQLDatabase, query: String, arguments: [ZSQLColumnValue]) throws {
self.database = database
self.query = query
self.status = sqlite3_prepare_v2(self.database.database, query, -1, &self.statement, nil)
guard self.status == SQLITE_OK else { throw ZSQLStatus(self.status) }
for (index, argment) in arguments.enumerated() {
let index = Int32(index + 1)
switch argment {
case let value as Int:
sqlite3_bind_int64(self.statement, index, sqlite3_int64(value))
case let value as Double:
sqlite3_bind_double(self.statement, index, value)
case let value as String:
sqlite3_bind_text(self.statement, index, (value as NSString).utf8String, -1, nil)
case let value as Data:
sqlite3_bind_blob(self.statement, index, (value as NSData).bytes, Int32(value.count), nil)
case is NSNull:
sqlite3_bind_null(self.statement, index)
default:
break
}
}
}
deinit {
sqlite3_finalize(statement)
}
func step() -> ZSQLResult {
self.status = sqlite3_step(statement)
let result = ZSQLResult(status: self.status, statement: self.statement, query: self)
self.result = result
return result
}
var result: ZSQLResult?
}
public protocol ZSQLColumnValue {}
extension Int: ZSQLColumnValue {}
extension Double: ZSQLColumnValue {}
extension String: ZSQLColumnValue {}
extension Data: ZSQLColumnValue {}
extension NSNull: ZSQLColumnValue {}
public class ZSQLResult {
let status: Int32
let statement: OpaquePointer?
let query: ZSQLQuery
init(status: Int32, statement: OpaquePointer?, query: ZSQLQuery) {
self.status = status
self.statement = statement
self.query = query
}
var row: ZSQLRow? {
return (self.status == SQLITE_ROW) ? ZSQLRow(statement: self.statement) : nil
}
var OK: Bool { self.status == SQLITE_OK }
var done: Bool { self.status == SQLITE_DONE }
}
public class ZSQLRow {
let statement: OpaquePointer?
public init(statement: OpaquePointer?) {
self.statement = statement
}
public func rawValue(column: Int) -> ZSQLColumnValue? {
let count = sqlite3_column_count(self.statement)
guard column >= 0 && column < count else { fatalError() }
switch sqlite3_column_type(self.statement, Int32(column)) {
case SQLITE_INTEGER:
let value = sqlite3_column_int64(self.statement, Int32(column))
return Int(value)
case SQLITE_FLOAT:
let value = sqlite3_column_double(self.statement, Int32(column))
return Double(value)
case SQLITE_BLOB:
let length = Int(sqlite3_column_bytes(self.statement, Int32(column)))
let data = Data(bytes: sqlite3_column_blob(self.statement, Int32(column)), count: length)
return data
case SQLITE_TEXT:
let string = String(cString: sqlite3_column_text(self.statement, Int32(column))) // type in string
return string
case SQLITE_NULL:
return NSNull()
default:
return nil
}
}
public func value<T: ZSQLColumnValue>(index: Int) -> T? {
return self.rawValue(column: index) as? T
}
public func value<T: ZSQLColumnValue>(named name: String) -> T? {
guard let index = self.columnNames.firstIndex(of: name) else { fatalError() }
return self.rawValue(column: index) as? T
}
public lazy var columnNames: [String] = {
return (0 ..< sqlite3_column_count(self.statement)).map { index in
guard let rawValue = sqlite3_column_name(self.statement, index) else { fatalError() }
return String(cString: rawValue)
}
}()
public lazy var values: [String: ZSQLColumnValue] = {
return (0 ..< sqlite3_column_count(self.statement)).reduce(into: [String: ZSQLColumnValue]()) { dictionary, index in
guard let rawValue = sqlite3_column_name(self.statement, index) else { fatalError() }
let columnName = String(cString: rawValue)
dictionary[columnName] = self.rawValue(column: Int(index))
}
}()
subscript(key: String) -> ZSQLColumnValue? {
return self.values[key]
}
}
最後に
なんか、面白そうと思ったあなたでも、決して遊び以上のプロジェクトではしようしないでください。著者の思いつきや思い込みが満載で、テストも十分にされていません。アイディアの検証程度に書いているコードなので全く責任は取れません。しかし、それでも興味があるひとは、ひとまず遊びで触ってみてください。
また、いつかこのプロジェクトに戻ってくる時があれば、CoreData のオルタナティブくらいの立ち位置ができれは面白そうです。Core Data と違いオブジェクト間のリレーションをスキーマにするのではないので、より複雑なモデルが簡単に実装できそうですが、マイグレーションなどを考えるとなかなか簡単にはいかないようにも思います。
環境
執筆時点での環境は以下の通りです。
macOS Big Sur 11.5.2 (Apple M1)
Xcode Version 13.0 beta 5 (13A5212g)
Apple Swift version 5.5