PromtGarageMac / Sources /PromptGarageCore /Database /PromptRepository.swift
smlflg's picture
Initial public upload from Projekte/PromtGarageMac
4977db2 verified
Raw History Blame Contribute Delete
32.1 kB
import Foundation
import GRDB
public final class PromptRepository: Sendable {
public let databaseURL: URL?
public let isReadOnly: Bool
private let database: DatabaseQueue
private let activityLogger: ActivityLogger?
private let supportsPromptedAt: Bool
public init(
databaseURL: URL,
readOnly: Bool = false,
activityLogger: ActivityLogger? = nil
) throws {
let databaseAlreadyExists = FileManager.default.fileExists(
atPath: databaseURL.path
)
if !readOnly {
try FileManager.default.createDirectory(
at: databaseURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
}
var configuration = Configuration()
configuration.label = "PromptGarage"
configuration.readonly = readOnly
configuration.busyMode = .timeout(5)
if !readOnly {
configuration.journalMode = .wal
}
let database = try DatabaseQueue(
path: databaseURL.path,
configuration: configuration
)
if !readOnly {
if databaseAlreadyExists {
// Do not silently turn an arbitrary/invalid existing SQLite
// file into an app database. Existing app copies may receive
// the nullable native migration and an FTS rebuild only after
// their legacy structure and row parity have passed.
_ = try DatabaseSchema.validateStructureAndCounts(database)
try database.write { db in
try DatabaseSchema.migrateDestinationIfNeeded(in: db)
try DatabaseSchema.repairSearchIndexIfNeeded(in: db)
}
} else {
try database.write { db in
try DatabaseSchema.createIfNeeded(in: db)
}
}
}
let validation = readOnly
? try DatabaseSchema.validateReadOnly(database)
: try DatabaseSchema.validate(database)
self.databaseURL = databaseURL
self.isReadOnly = readOnly
self.database = database
self.supportsPromptedAt = validation.hasPromptedAt
if let activityLogger {
self.activityLogger = activityLogger
} else if !readOnly {
self.activityLogger = try? ActivityLogger(
logURL: databaseURL
.deletingLastPathComponent()
.appendingPathComponent("activity.jsonl")
)
} else {
self.activityLogger = nil
}
}
private init(
database: DatabaseQueue,
databaseURL: URL?,
isReadOnly: Bool,
activityLogger: ActivityLogger?
) throws {
if !isReadOnly {
try database.write { db in
try DatabaseSchema.createIfNeeded(in: db)
}
}
let validation = isReadOnly
? try DatabaseSchema.validateReadOnly(database)
: try DatabaseSchema.validate(database)
self.database = database
self.databaseURL = databaseURL
self.isReadOnly = isReadOnly
self.activityLogger = activityLogger
self.supportsPromptedAt = validation.hasPromptedAt
}
public static func inMemory() throws -> PromptRepository {
var configuration = Configuration()
configuration.label = "PromptGarage.inMemory"
let database = try DatabaseQueue(configuration: configuration)
return try PromptRepository(
database: database,
databaseURL: nil,
isReadOnly: false,
activityLogger: nil
)
}
public func prompt(id: Int64) throws -> PromptRecord? {
try database.read { db in
try Self.fetchPrompt(id: id, in: db)
}
}
public func page(matching query: PromptQuery = PromptQuery()) throws -> PromptPage {
try database.read { db in
let topicKey = try Self.requestedTopicKey(query.tag)
var databaseQuery = query
databaseQuery.tag = nil
let built = try buildQuery(databaseQuery)
if let topicKey {
let prompts = try Self.fetchAll(in: db, built: built)
.filter { Self.hasTopic($0.tags, matching: topicKey) }
let page = Array(
prompts
.dropFirst(query.offset)
.prefix(query.limit)
)
return PromptPage(prompts: page, total: prompts.count)
}
let total = try Self.fetchCount(in: db, built: built)
var arguments = built.arguments
_ = arguments.append(contentsOf: [query.limit, query.offset])
let sql = """
SELECT p.*
FROM prompts AS p
\(built.joinSQL)
\(built.whereSQL)
ORDER BY \(built.orderSQL)
LIMIT ? OFFSET ?
"""
let prompts = try PromptRecord.fetchAll(db, sql: sql, arguments: arguments)
return PromptPage(prompts: prompts, total: total)
}
}
public func page(
matching query: PromptQuery = PromptQuery(),
promptedOn day: String?
) throws -> PromptPage {
var query = query
query.promptedDay = day
return try page(matching: query)
}
public func count(matching query: PromptQuery = PromptQuery()) throws -> Int {
try database.read { db in
let topicKey = try Self.requestedTopicKey(query.tag)
var databaseQuery = query
databaseQuery.tag = nil
let built = try buildQuery(databaseQuery)
if let topicKey {
return try Self.fetchAll(in: db, built: built)
.lazy
.filter { Self.hasTopic($0.tags, matching: topicKey) }
.count
}
return try Self.fetchCount(in: db, built: built)
}
}
public func count(
matching query: PromptQuery = PromptQuery(),
promptedOn day: String?
) throws -> Int {
var query = query
query.promptedDay = day
return try count(matching: query)
}
public func create(_ draft: PromptDraft) throws -> PromptRecord {
try ensureWritable()
try validate(draft)
return try database.write { db in
try db.execute(
sql: """
INSERT INTO prompts (
title, content, source, tags, model, rating, is_starred,
prompted_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
""",
arguments: [
draft.title,
draft.content,
draft.source,
draft.tags,
draft.model,
draft.rating,
draft.isStarred,
]
)
return try Self.requirePrompt(id: db.lastInsertedRowID, in: db)
}
}
public func update(id: Int64, with draft: PromptDraft) throws -> PromptRecord {
try ensureWritable()
try validate(draft)
return try database.write { db in
try db.execute(
sql: """
UPDATE prompts
SET title = ?,
content = ?,
source = ?,
tags = ?,
model = ?,
rating = ?,
is_starred = ?,
updated_at = datetime('now')
WHERE id = ?
""",
arguments: [
draft.title,
draft.content,
draft.source,
draft.tags,
draft.model,
draft.rating,
draft.isStarred,
id,
]
)
guard db.changesCount > 0 else {
throw PromptRepositoryError.promptNotFound(id)
}
return try Self.requirePrompt(id: id, in: db)
}
}
@discardableResult
public func delete(id: Int64) throws -> Bool {
try ensureWritable()
let deletedSourceFamily: String? = try database.write { db in
guard let prompt = try Self.fetchPrompt(id: id, in: db) else {
return nil
}
try db.execute(sql: "DELETE FROM prompts WHERE id = ?", arguments: [id])
return prompt.sourceFamily
}
guard let deletedSourceFamily else { return false }
// Audit logging is intentionally best-effort after the database commit:
// a log I/O failure must not make a successful delete look rolled back.
try? activityLogger?.logDeletion(
promptID: id,
sourceFamily: deletedSourceFamily
)
return true
}
public func setStarred(_ isStarred: Bool, for id: Int64) throws -> PromptRecord {
try ensureWritable()
return try mutatePrompt(
id: id,
sql: """
UPDATE prompts
SET is_starred = ?, updated_at = datetime('now')
WHERE id = ?
""",
arguments: [isStarred, id]
)
}
public func toggleStar(id: Int64) throws -> PromptRecord {
try ensureWritable()
return try mutatePrompt(
id: id,
sql: """
UPDATE prompts
SET is_starred = 1 - is_starred, updated_at = datetime('now')
WHERE id = ?
""",
arguments: [id]
)
}
public func setRating(_ rating: Int?, for id: Int64) throws -> PromptRecord {
try ensureWritable()
if let rating, !(1...5).contains(rating) {
throw PromptRepositoryError.invalidRating(rating)
}
return try mutatePrompt(
id: id,
sql: """
UPDATE prompts
SET rating = ?, updated_at = datetime('now')
WHERE id = ?
""",
arguments: [rating, id]
)
}
public func markUsed(id: Int64) throws -> PromptRecord {
try ensureWritable()
return try mutatePrompt(
id: id,
sql: """
UPDATE prompts
SET use_count = use_count + 1, updated_at = datetime('now')
WHERE id = ?
""",
arguments: [id]
)
}
public func statistics() throws -> PromptStatistics {
try database.read { db in
let totals = try Row.fetchOne(
db,
sql: """
SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN is_starred = 1 THEN 1 ELSE 0 END), 0) AS starred,
COALESCE(SUM(use_count), 0) AS total_uses
FROM prompts
"""
)
let sourceFamilies = try Self.fetchSourceFamilies(in: db)
let models = try Self.fetchModels(in: db)
let tags = try Self.fetchTopics(in: db)
let ratingRows = try Row.fetchAll(
db,
sql: """
SELECT COALESCE(rating, 0) AS rating, COUNT(*) AS count
FROM prompts
GROUP BY COALESCE(rating, 0)
"""
)
var ratingDistribution = Dictionary(
uniqueKeysWithValues: (0...5).map { ($0, 0) }
)
for row in ratingRows {
let rating: Int = row["rating"]
let count: Int = row["count"]
ratingDistribution[rating] = count
}
return PromptStatistics(
total: totals?["total"] ?? 0,
starred: totals?["starred"] ?? 0,
totalUses: totals?["total_uses"] ?? 0,
sourceFamilies: sourceFamilies,
models: models,
tags: tags,
ratingDistribution: ratingDistribution
)
}
}
public func allTags() throws -> [CountedValue] {
try topicGroups()
}
public func topicGroups() throws -> [CountedValue] {
try database.read { db in
try Self.fetchTopics(in: db)
}
}
public func browsingTopicGroups() throws -> [PromptTopicSummary] {
try database.read { db in
var groups = try Self.fetchTopics(in: db).map {
PromptTopicSummary(topic: $0.name, count: $0.count)
}
let untaggedCount = try Self.fetchUntaggedCount(in: db)
if untaggedCount > 0 {
groups.append(PromptTopicSummary(topic: nil, count: untaggedCount))
}
return groups
}
}
public func sourceFamilies() throws -> [CountedValue] {
try database.read { db in
try Self.fetchSourceFamilies(in: db)
}
}
public func dayGroups() throws -> [CountedValue] {
guard supportsPromptedAt else { return [] }
return try database.read { db in
let rows = try Row.fetchAll(
db,
sql: """
SELECT date(prompted_at, 'localtime') AS name, COUNT(*) AS count
FROM prompts
WHERE prompted_at IS NOT NULL AND trim(prompted_at) != ''
AND date(prompted_at, 'localtime') IS NOT NULL
GROUP BY date(prompted_at, 'localtime')
ORDER BY name DESC
"""
)
return rows.map {
CountedValue(name: $0["name"], count: $0["count"])
}
}
}
public func legacyUndatedCount() throws -> Int {
guard supportsPromptedAt else {
return try database.read { db in
try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM prompts") ?? 0
}
}
return try database.read { db in
try Int.fetchOne(
db,
sql: """
SELECT COUNT(*)
FROM prompts
WHERE prompted_at IS NULL
OR trim(prompted_at) = ''
OR date(prompted_at, 'localtime') IS NULL
"""
) ?? 0
}
}
public func makeDedupIndex() throws -> DedupIndex {
try database.read { db in
try Self.fetchDedupIndex(in: db)
}
}
public func importCandidates(
_ candidates: [ImportCandidate],
baseStatistics: ImportStatistics = ImportStatistics()
) throws -> ImportStatistics {
try ensureWritable()
let result: (statistics: ImportStatistics, sourceFamilies: Set<String>) =
try database.write { db in
var statistics = baseStatistics
// Preview parsers use `imported` as "eligible candidates".
// The commit report must instead contain the number actually
// inserted after the final, transaction-local dedup check.
statistics.imported = 0
var dedup = try Self.fetchDedupIndex(in: db)
var exactContents = Set(dedup.existingContents.map(ImportText.trimmed))
var sourceFamilies = Set<String>()
// One fallback per transaction keeps every new import dated
// while giving candidates without a usable source timestamp
// one stable commit time. Existing rows are never updated.
let fallbackPromptedAt = ImportTimestamp.now()
for candidate in candidates {
let normalizedContent = ImportText.trimmed(candidate.content)
guard !candidate.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!normalizedContent.isEmpty
else {
statistics.skippedMalformed += 1
continue
}
let hash = Self.dedupHash(from: candidate.source)
let isDuplicate = exactContents.contains(normalizedContent)
|| hash.map { dedup.contains($0) } == true
if isDuplicate {
statistics.skippedDuplicate += 1
continue
}
// Parsers normally provide a normalized ISO-8601 value.
// Direct callers may provide nil, blank, or malformed
// values; all of those use the transaction-stable fallback
// so newly committed imports are never undated.
let promptedAt = candidate.promptedAt
.flatMap { ImportTimestamp.normalize($0) }
?? fallbackPromptedAt
try db.execute(
sql: """
INSERT INTO prompts (
title, content, source, tags, model, prompted_at
)
VALUES (?, ?, ?, ?, ?, ?)
""",
arguments: [
candidate.title,
candidate.content,
candidate.source,
candidate.tags,
candidate.model,
promptedAt,
]
)
statistics.imported += 1
exactContents.insert(normalizedContent)
if let hash {
dedup.hashes.insert(hash)
}
sourceFamilies.insert(Self.sourceFamily(of: candidate.source))
}
return (statistics, sourceFamilies)
}
try? activityLogger?.logImport(
statistics: result.statistics,
sourceFamilies: Array(result.sourceFamilies)
)
return result.statistics
}
private func mutatePrompt(
id: Int64,
sql: String,
arguments: StatementArguments
) throws -> PromptRecord {
try database.write { db in
try db.execute(sql: sql, arguments: arguments)
guard db.changesCount > 0 else {
throw PromptRepositoryError.promptNotFound(id)
}
return try Self.requirePrompt(id: id, in: db)
}
}
private func buildQuery(_ query: PromptQuery) throws -> BuiltQuery {
var clauses: [String] = []
var arguments: [(any DatabaseValueConvertible)?] = []
let ftsQuery = Self.ftsPrefixQuery(query.text)
let hasText = !ftsQuery.isEmpty
if hasText {
clauses.append("prompts_fts MATCH ?")
arguments.append(ftsQuery)
}
if query.untaggedOnly {
clauses.append("""
trim(
replace(COALESCE(p.tags, ''), ',', ''),
char(9) || char(10) || char(13) || ' '
) = ''
""")
}
if let rawFamily = query.sourceFamily?
.trimmingCharacters(in: .whitespacesAndNewlines),
!rawFamily.isEmpty
{
if rawFamily.caseInsensitiveCompare("unknown") == .orderedSame {
clauses.append("(p.source IS NULL OR trim(p.source) = '')")
} else {
clauses.append("""
(lower(p.source) = lower(?) OR lower(p.source) LIKE lower(?) ESCAPE '\\')
""")
arguments.append(rawFamily)
arguments.append("\(Self.escapeLike(rawFamily)):%")
}
}
if let rawDay = query.promptedDay?
.trimmingCharacters(in: .whitespacesAndNewlines),
!rawDay.isEmpty
{
let day = try Self.validatedDay(rawDay)
if supportsPromptedAt {
clauses.append("date(p.prompted_at, 'localtime') = ?")
arguments.append(day)
} else {
clauses.append("0 = 1")
}
}
if query.starredOnly {
clauses.append("p.is_starred = 1")
}
if query.usedOnly {
clauses.append("p.use_count > 0")
}
let requestedOrder: String
switch query.sort {
case .updatedDescending:
requestedOrder = "p.updated_at DESC, p.id DESC"
case .titleAscending:
requestedOrder = "p.title COLLATE NOCASE ASC, p.id ASC"
case .mostUsed:
requestedOrder = "p.use_count DESC, p.updated_at DESC, p.id DESC"
}
return BuiltQuery(
joinSQL: hasText ? "JOIN prompts_fts ON prompts_fts.rowid = p.id" : "",
whereSQL: clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))",
orderSQL: hasText
? "bm25(prompts_fts) ASC, \(requestedOrder)"
: requestedOrder,
arguments: StatementArguments(arguments)
)
}
private func validate(_ draft: PromptDraft) throws {
if let message = draft.validationMessage {
throw PromptRepositoryError.invalidDraft(message)
}
}
private func ensureWritable() throws {
guard !isReadOnly else { throw PromptRepositoryError.readOnly }
}
private static func fetchPrompt(id: Int64, in db: Database) throws -> PromptRecord? {
try PromptRecord.fetchOne(
db,
sql: "SELECT * FROM prompts WHERE id = ?",
arguments: [id]
)
}
private static func requirePrompt(id: Int64, in db: Database) throws -> PromptRecord {
guard let prompt = try fetchPrompt(id: id, in: db) else {
throw PromptRepositoryError.promptNotFound(id)
}
return prompt
}
private static func fetchCount(in db: Database, built: BuiltQuery) throws -> Int {
try Int.fetchOne(
db,
sql: """
SELECT COUNT(*)
FROM prompts AS p
\(built.joinSQL)
\(built.whereSQL)
""",
arguments: built.arguments
) ?? 0
}
private static func fetchAll(
in db: Database,
built: BuiltQuery
) throws -> [PromptRecord] {
try PromptRecord.fetchAll(
db,
sql: """
SELECT p.*
FROM prompts AS p
\(built.joinSQL)
\(built.whereSQL)
ORDER BY \(built.orderSQL)
""",
arguments: built.arguments
)
}
private static func fetchSourceFamilies(in db: Database) throws -> [CountedValue] {
let rows = try Row.fetchAll(
db,
sql: """
SELECT CASE
WHEN source IS NULL OR trim(source) = '' THEN 'unknown'
WHEN instr(source, ':') > 0
THEN substr(source, 1, instr(source, ':') - 1)
ELSE source
END AS name,
COUNT(*) AS count
FROM prompts
GROUP BY name COLLATE NOCASE
ORDER BY count DESC, name COLLATE NOCASE ASC
"""
)
return rows.map {
CountedValue(name: $0["name"], count: $0["count"])
}
}
private static func fetchModels(in db: Database) throws -> [CountedValue] {
let rows = try Row.fetchAll(
db,
sql: """
SELECT trim(model) AS name, COUNT(*) AS count
FROM prompts
WHERE model IS NOT NULL AND trim(model) != ''
GROUP BY trim(model) COLLATE NOCASE
ORDER BY count DESC, name COLLATE NOCASE ASC
"""
)
return rows.map {
CountedValue(name: $0["name"], count: $0["count"])
}
}
private static func fetchTopics(in db: Database) throws -> [CountedValue] {
let rows = try String.fetchAll(
db,
sql: "SELECT tags FROM prompts WHERE tags IS NOT NULL AND trim(tags) != ''"
)
let locale = Locale(identifier: "en_US_POSIX")
var counts: [String: (displayName: String, count: Int)] = [:]
for tags in rows {
var seenInPrompt = Set<String>()
for topic in tags.split(separator: ",") {
let name = topic.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { continue }
guard let key = topicKey(name, locale: locale) else { continue }
guard seenInPrompt.insert(key).inserted else { continue }
if let existing = counts[key] {
counts[key] = (existing.displayName, existing.count + 1)
} else {
counts[key] = (name, 1)
}
}
}
return counts
.map { CountedValue(name: $0.value.displayName, count: $0.value.count) }
.sorted {
if $0.count != $1.count { return $0.count > $1.count }
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
}
private static func fetchUntaggedCount(in db: Database) throws -> Int {
let rows = try Row.fetchAll(db, sql: "SELECT tags FROM prompts")
return rows.reduce(into: 0) { count, row in
let tags: String? = row["tags"]
let hasTopic = tags?
.split(separator: ",")
.contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
?? false
if !hasTopic { count += 1 }
}
}
/// Topic grouping and filtering share this exact Unicode normalization.
/// This runs entirely in memory and never rewrites legacy tag strings.
private static func topicKey(
_ raw: String,
locale: Locale = Locale(identifier: "en_US_POSIX")
) -> String? {
let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return nil }
return name
.folding(options: [.caseInsensitive], locale: locale)
.lowercased()
}
private static func requestedTopicKey(_ raw: String?) throws -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard !trimmed.contains(",") else {
throw PromptRepositoryError.invalidQuery(
"Ein Topic-Filter darf kein Komma enthalten."
)
}
return topicKey(trimmed)
}
private static func hasTopic(_ rawTags: String?, matching key: String) -> Bool {
guard let rawTags else { return false }
return rawTags
.split(separator: ",", omittingEmptySubsequences: false)
.contains { topicKey(String($0)) == key }
}
private static func fetchDedupIndex(in db: Database) throws -> DedupIndex {
let rows = try Row.fetchAll(db, sql: "SELECT source, content FROM prompts")
var hashes = Set<String>()
var contents: [String] = []
contents.reserveCapacity(rows.count)
for row in rows {
let source: String? = row["source"]
let content: String = row["content"]
if let source, let hash = dedupHash(from: source) {
hashes.insert(hash)
}
hashes.insert(ImportText.contentHash(ImportText.trimmed(content)))
contents.append(content)
}
return DedupIndex(hashes: hashes, existingContents: contents)
}
private static func ftsPrefixQuery(_ input: String) -> String {
input
.split(whereSeparator: { $0.isWhitespace })
.map { token in
let escaped = token.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\"*"
}
.joined(separator: " AND ")
}
private static func escapeLike(_ input: String) -> String {
input
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "%", with: "\\%")
.replacingOccurrences(of: "_", with: "\\_")
}
private static func validatedDay(_ input: String) throws -> String {
let parts = input.split(separator: "-", omittingEmptySubsequences: false)
guard parts.count == 3,
parts[0].count == 4,
parts[1].count == 2,
parts[2].count == 2,
let year = Int(parts[0]),
let month = Int(parts[1]),
let day = Int(parts[2])
else {
throw PromptRepositoryError.invalidQuery(
"Der Tagesfilter muss das Format YYYY-MM-DD haben."
)
}
var calendar = Calendar(identifier: .gregorian)
guard let utc = TimeZone(secondsFromGMT: 0) else {
throw PromptRepositoryError.invalidQuery("UTC ist auf diesem System nicht verfügbar.")
}
calendar.timeZone = utc
let components = DateComponents(
calendar: calendar,
timeZone: calendar.timeZone,
year: year,
month: month,
day: day
)
guard let date = calendar.date(from: components) else {
throw PromptRepositoryError.invalidQuery("Der Tagesfilter ist ungültig.")
}
let checked = calendar.dateComponents([.year, .month, .day], from: date)
guard checked.year == year, checked.month == month, checked.day == day else {
throw PromptRepositoryError.invalidQuery("Der Tagesfilter ist ungültig.")
}
return String(format: "%04d-%02d-%02d", year, month, day)
}
private static func dedupHash(from source: String) -> String? {
guard let candidate = source.split(separator: ":").last else { return nil }
let hash = candidate.lowercased()
guard hash.utf8.count == 12,
hash.utf8.allSatisfy({ byte in
(48...57).contains(byte) || (97...102).contains(byte)
})
else {
return nil
}
return hash
}
private static func sourceFamily(of source: String) -> String {
let family = source.split(separator: ":", maxSplits: 1).first.map(String.init)
return family?.isEmpty == false ? family! : "unknown"
}
}
public enum PromptRepositoryError: LocalizedError, Equatable, Sendable {
case invalidDraft(String)
case promptNotFound(Int64)
case invalidRating(Int?)
case invalidQuery(String)
case readOnly
public var errorDescription: String? {
switch self {
case let .invalidDraft(message):
message
case let .promptNotFound(id):
"Prompt #\(id) wurde nicht gefunden."
case let .invalidRating(rating):
"Ungültige Bewertung \(rating.map(String.init) ?? "nil"); erlaubt sind 1 bis 5 oder keine Bewertung."
case let .invalidQuery(message):
message
case .readOnly:
"Die PromptGarage-Datenbank ist schreibgeschützt."
}
}
}
private struct BuiltQuery {
let joinSQL: String
let whereSQL: String
let orderSQL: String
let arguments: StatementArguments
}