Refactoring I.
- Revamp whole DB to Display flow - Filter Pipeline, arbitrary filtering and sorting - Binary tree arrays for faster lookup & manipulation - DB: introducing custom functions - DB scheme: split req into heap & cache - cache written by GlassVPN only - heap written by Main App only - Introducing DB separation: DBCore, DBCommon, DBAppOnly - Introducing DB data sources: TestDataSource, GroupedDomainDataSource, RecordingsDB, DomainFilter - Background sync: Move entries from cache to heap and notify all observers - GlassVPN: Binary tree filter lookup - GlassVPN: Reusing prepared statement
This commit is contained in:
386
main/DB/DBAppOnly.swift
Normal file
386
main/DB/DBAppOnly.swift
Normal file
@@ -0,0 +1,386 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
typealias Timestamp = sqlite3_int64
|
||||
|
||||
extension SQLiteDatabase {
|
||||
func initAppOnlyScheme() {
|
||||
try? run(sql: CreateTable.heap)
|
||||
try? run(sql: CreateTable.rec)
|
||||
try? run(sql: CreateTable.recLog)
|
||||
do {
|
||||
try migrateDB()
|
||||
} catch {
|
||||
QLog.Error("during migration: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func migrateDB() throws {
|
||||
let version = try run(sql: "PRAGMA user_version;") { stmt -> Int32 in
|
||||
try ifStep(stmt, SQLITE_ROW)
|
||||
return sqlite3_column_int(stmt, 0)
|
||||
}
|
||||
if version != 1 {
|
||||
// version 0 -> 1: req(domain) -> heap(fqdn, domain)
|
||||
if version == 0 {
|
||||
try tempMigrate()
|
||||
}
|
||||
try run(sql: "PRAGMA user_version = 1;")
|
||||
}
|
||||
}
|
||||
|
||||
private func tempMigrate() throws { // TODO: remove with next internal release
|
||||
do {
|
||||
try run(sql: "SELECT 1 FROM req LIMIT 1;") // fails if req doesnt exist
|
||||
createFunction("domainof") { ($0.first as! String).extractDomain() }
|
||||
try run(sql: """
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,domain,domainof(domain),nullif(logOpt,0) FROM req;
|
||||
DROP TABLE req;
|
||||
COMMIT;
|
||||
""")
|
||||
} catch { /* no need to migrate */ }
|
||||
}
|
||||
}
|
||||
|
||||
private enum TableName: String {
|
||||
case heap, cache
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
fileprivate func lastRowId(_ table: TableName) -> SQLiteRowID {
|
||||
(try? run(sql:"SELECT rowid FROM \(table.rawValue) ORDER BY rowid DESC LIMIT 1;") {
|
||||
try ifStep($0, SQLITE_ROW)
|
||||
return sqlite3_column_int64($0, 0)
|
||||
}) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
struct WhereClauseBuilder: CustomStringConvertible {
|
||||
var description: String = ""
|
||||
private let prefix: String
|
||||
private(set) var bindings: [DBBinding] = []
|
||||
init(prefix p: String = "WHERE") { prefix = "\(p) " }
|
||||
mutating func and(_ clause: String, _ bind: DBBinding ...) {
|
||||
description.append((description=="" ? prefix : " AND ") + clause)
|
||||
bindings.append(contentsOf: bind)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - DNSLog
|
||||
|
||||
extension CreateTable {
|
||||
/// `ts`: Timestamp, `fqdn`: String, `domain`: String, `opt`: Int
|
||||
static var heap: String {"""
|
||||
CREATE TABLE IF NOT EXISTS heap(
|
||||
ts INTEGER DEFAULT (strftime('%s','now')),
|
||||
fqdn TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
opt INTEGER
|
||||
);
|
||||
"""} // opt currently only used as "blocked" flag
|
||||
}
|
||||
|
||||
struct GroupedDomain {
|
||||
let domain: String, total: Int32, blocked: Int32, lastModified: Timestamp
|
||||
var options: FilterOptions? = nil
|
||||
}
|
||||
typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32)
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: write
|
||||
|
||||
/// Move newest entries from `cache` to `heap` and return range (in `heap`) of newly inserted entries.
|
||||
/// - Returns: `nil` in case no entries were transmitted.
|
||||
@discardableResult func dnsLogsPersist() -> SQLiteRowRange? {
|
||||
guard lastRowId(.cache) > 0 else { return nil }
|
||||
let before = lastRowId(.heap) + 1
|
||||
createFunction("domainof") { ($0.first as! String).extractDomain() }
|
||||
try? run(sql:"""
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO heap(ts,fqdn,domain,opt) SELECT ts,dns,domainof(dns),nullif(opt&1,0) FROM cache;
|
||||
DELETE FROM cache;
|
||||
COMMIT;
|
||||
""")
|
||||
let after = lastRowId(.heap)
|
||||
return (before > after) ? nil : (before, after)
|
||||
}
|
||||
|
||||
/// `DELETE FROM heap; DELETE FROM cache;`
|
||||
func dnsLogsDeleteAll() throws {
|
||||
try? run(sql: "DELETE FROM heap; DELETE FROM cache;")
|
||||
vacuum()
|
||||
}
|
||||
|
||||
/// Delete rows matching `ts >= ? AND domain = ?`
|
||||
/// - Parameter strict: If `true`, use `fqdn` instead of `domain` column
|
||||
/// - Returns: Number of changes aka. Number of rows deleted
|
||||
@discardableResult func dnsLogsDelete(_ domain: String, strict: Bool, since ts: Timestamp = 0) -> Int32 {
|
||||
var Where = WhereClauseBuilder()
|
||||
if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) }
|
||||
Where.and("\(strict ? "fqdn" : "domain") = ?", BindText(domain)) // (fqdn = ? OR fqdn LIKE '%.' || ?)
|
||||
return (try? run(sql: "DELETE FROM heap \(Where);", bind: Where.bindings) { stmt -> Int32 in
|
||||
try ifStep(stmt, SQLITE_DONE)
|
||||
return numberOfChanges
|
||||
}) ?? 0
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
/// Select min and max row id with given condition `ts >= ? AND ts < ?`
|
||||
/// - Returns: `nil` in case no rows are matching the condition
|
||||
func dnsLogsRowRange(between ts: Timestamp, and ts2: Timestamp) -> SQLiteRowRange? {
|
||||
try? run(sql:"SELECT min(rowid), max(rowid) FROM heap WHERE ts >= ? AND ts < ?",
|
||||
bind: [BindInt64(ts), BindInt64(ts2)]) {
|
||||
try ifStep($0, SQLITE_ROW)
|
||||
let max = sqlite3_column_int64($0, 1)
|
||||
return (max == 0) ? nil : (sqlite3_column_int64($0, 0), max)
|
||||
}
|
||||
}
|
||||
|
||||
/// Group DNS logs by domain, count occurences and number of blocked requests.
|
||||
/// - Parameters:
|
||||
/// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end `
|
||||
/// - ts: Restrict result set `ts >= ?`
|
||||
/// - ts2: Restrict result set `ts < ?`
|
||||
/// - matchingDomain: Restrict `(fqdn|domain) = ?`. Which column is used is determined by `parentDomain`.
|
||||
/// - parentDomain: If `nil` returns `domain` column. Else returns `fqdn` column with restriction on `domain == parentDomain`.
|
||||
/// - Returns: List of grouped domains with no particular sorting order.
|
||||
func dnsLogsGrouped(range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0,
|
||||
matchingDomain: String? = nil, parentDomain: String? = nil) -> [GroupedDomain]?
|
||||
{
|
||||
var Where = WhereClauseBuilder()
|
||||
if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) }
|
||||
if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) }
|
||||
if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) }
|
||||
if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) }
|
||||
let col: String // fqdn or domain
|
||||
if let parent = parentDomain { // is subdomain
|
||||
col = "fqdn"
|
||||
Where.and("domain = ?", BindText(parent))
|
||||
} else {
|
||||
col = "domain"
|
||||
}
|
||||
if let matching = matchingDomain { // (fqdn = ? OR fqdn LIKE '%.' || ?)
|
||||
Where.and("\(col) = ?", BindText(matching))
|
||||
}
|
||||
return try? run(sql: "SELECT \(col), COUNT(*), COUNT(opt), MAX(ts) FROM heap \(Where) GROUP BY \(col);", bind: Where.bindings) {
|
||||
allRows($0) {
|
||||
GroupedDomain(domain: readText($0, 0) ?? "",
|
||||
total: sqlite3_column_int($0, 1),
|
||||
blocked: sqlite3_column_int($0, 2),
|
||||
lastModified: sqlite3_column_int64($0, 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get list or individual DNS entries. Mutliple entries in the very same second are grouped.
|
||||
/// - Parameters:
|
||||
/// - fqdn: Exact match for domain name `fqdn = ?`
|
||||
/// - range: Whenever possible set range to improve SQL lookup times. `start <= rowid <= end `
|
||||
/// - ts: Restrict result set `ts >= ?`
|
||||
/// - ts2: Restrict result set `ts < ?`
|
||||
/// - Returns: List sorted by reverse timestamp order (newest first)
|
||||
func timesForDomain(_ fqdn: String, range: SQLiteRowRange? = nil, since ts: Timestamp = 0, upto ts2: Timestamp = 0) -> [GroupedTsOccurrence]? {
|
||||
var Where = WhereClauseBuilder()
|
||||
if let from = range?.start { Where.and("rowid >= ?", BindInt64(from)) }
|
||||
if let to = range?.end { Where.and("rowid <= ?", BindInt64(to)) }
|
||||
if ts != 0 { Where.and("ts >= ?", BindInt64(ts)) }
|
||||
if ts2 != 0 { Where.and("ts < ?", BindInt64(ts2)) }
|
||||
Where.and("fqdn = ?", BindText(fqdn))
|
||||
return try? run(sql: "SELECT ts, COUNT(ts), COUNT(opt) FROM heap \(Where) GROUP BY ts ORDER BY ts DESC;", bind: Where.bindings) {
|
||||
allRows($0) {
|
||||
(sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Recordings
|
||||
|
||||
extension CreateTable {
|
||||
/// `id`: Primary, `start`: Timestamp, `stop`: Timestamp, `appid`: String, `title`: String, `notes`: String
|
||||
static var rec: String {"""
|
||||
CREATE TABLE IF NOT EXISTS rec(
|
||||
id INTEGER PRIMARY KEY,
|
||||
start INTEGER DEFAULT (strftime('%s','now')),
|
||||
stop INTEGER,
|
||||
appid TEXT,
|
||||
title TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
"""}
|
||||
}
|
||||
|
||||
struct Recording {
|
||||
let id: sqlite3_int64
|
||||
let start: Timestamp
|
||||
let stop: Timestamp?
|
||||
var appId: String? = nil
|
||||
var title: String? = nil
|
||||
var notes: String? = nil
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: write
|
||||
|
||||
/// Create new recording with `stop` set to `NULL`.
|
||||
func recordingStartNew() throws -> Recording {
|
||||
try run(sql: "INSERT INTO rec (stop) VALUES (NULL);") { stmt -> Recording in
|
||||
try ifStep(stmt, SQLITE_DONE)
|
||||
return try recordingGet(withID: lastInsertedRow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update given recording by setting `stop` to current time.
|
||||
func recordingStop(_ r: inout Recording) {
|
||||
guard r.stop == nil else { return }
|
||||
let theID = r.id
|
||||
try? run(sql: "UPDATE rec SET stop = (strftime('%s','now')) WHERE id = ? LIMIT 1;",
|
||||
bind: [BindInt64(theID)]) { stmt -> Void in
|
||||
try ifStep(stmt, SQLITE_DONE)
|
||||
r = try recordingGet(withID: theID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update given recording by replacing `title`, `appid`, and `notes` with new values.
|
||||
func recordingUpdate(_ r: Recording) {
|
||||
try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;",
|
||||
bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in
|
||||
sqlite3_step(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete recording and all of its entries.
|
||||
/// - Returns: `true` on success
|
||||
func recordingDelete(_ r: Recording) throws -> Bool {
|
||||
_ = try? recordingLogsDelete(r.id)
|
||||
return try run(sql: "DELETE FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(r.id)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
return numberOfChanges > 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
private func readRecording(_ stmt: OpaquePointer) -> Recording {
|
||||
let end = sqlite3_column_int64(stmt, 2)
|
||||
return Recording(id: sqlite3_column_int64(stmt, 0),
|
||||
start: sqlite3_column_int64(stmt, 1),
|
||||
stop: end == 0 ? nil : end,
|
||||
appId: readText(stmt, 3),
|
||||
title: readText(stmt, 4),
|
||||
notes: readText(stmt, 5))
|
||||
}
|
||||
|
||||
/// `WHERE stop IS NULL`
|
||||
func recordingGetOngoing() -> Recording? {
|
||||
try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;") {
|
||||
try ifStep($0, SQLITE_ROW)
|
||||
return readRecording($0)
|
||||
}
|
||||
}
|
||||
|
||||
/// `WHERE stop IS NOT NULL`
|
||||
func recordingGetAll() -> [Recording]? {
|
||||
try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") {
|
||||
allRows($0) { readRecording($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `WHERE id = ?`
|
||||
private func recordingGet(withID: sqlite3_int64) throws -> Recording {
|
||||
try run(sql: "SELECT * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) {
|
||||
try ifStep($0, SQLITE_ROW)
|
||||
return readRecording($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - RecordingLog
|
||||
|
||||
extension CreateTable {
|
||||
/// `rid`: Reference `rec(id)`, `ts`: Timestamp, `domain`: String
|
||||
static var recLog: String {"""
|
||||
CREATE TABLE IF NOT EXISTS recLog(
|
||||
rid INTEGER REFERENCES rec(id) ON DELETE CASCADE,
|
||||
ts INTEGER,
|
||||
domain TEXT
|
||||
);
|
||||
"""}
|
||||
}
|
||||
|
||||
typealias RecordLog = (domain: String, count: Int32)
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: write
|
||||
|
||||
/// Duplicate and copy all log entries for given recording to `recLog` table
|
||||
func recordingLogsPersist(_ r: Recording) {
|
||||
guard let end = r.stop else { return }
|
||||
// TODO: make sure cache entries get copied too.
|
||||
// either by copying them directly from cache or perform sync first
|
||||
try? run(sql: """
|
||||
INSERT INTO recLog (rid, ts, domain) SELECT ?, ts, fqdn FROM heap
|
||||
WHERE heap.ts >= ? AND heap.ts <= ?
|
||||
""", bind: [BindInt64(r.id), BindInt64(r.start), BindInt64(end)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all log entries with given recording id. Optional: only delete entries for a single domain
|
||||
/// - Parameter d: If `nil` remove all entries for given recording
|
||||
/// - Returns: Number of deleted rows
|
||||
func recordingLogsDelete(_ recId: sqlite3_int64, matchingDomain d: String? = nil) throws -> Int32 {
|
||||
try run(sql: "DELETE FROM recLog WHERE rid = ? \(d==nil ? "" : "AND domain = ?");",
|
||||
bind: [BindInt64(recId), d==nil ? nil : BindText(d!)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
return numberOfChanges
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
/// List of domains and count occurences for given recording.
|
||||
func recordingLogsGetGrouped(_ r: Recording) -> [RecordLog]? {
|
||||
try? run(sql: "SELECT domain, COUNT() FROM recLog WHERE rid = ? GROUP BY domain;",
|
||||
bind: [BindInt64(r.id)]) {
|
||||
allRows($0) { (readText($0, 0) ?? "", sqlite3_column_int($0, 1)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - DBSettings
|
||||
|
||||
//extension CreateTable {
|
||||
// static var settings: String {
|
||||
// "CREATE TABLE IF NOT EXISTS settings(key TEXT UNIQUE NOT NULL, val TEXT);"
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//extension SQLiteDatabase {
|
||||
// func getSetting(for key: String) -> String? {
|
||||
// try? run(sql: "SELECT val FROM settings WHERE key = ?;",
|
||||
// bind: [BindText(key)]) { readText($0, 0) }
|
||||
// }
|
||||
// func setSetting(_ value: String?, for key: String) {
|
||||
// if let value = value {
|
||||
// try? run(sql: "INSERT OR REPLACE INTO settings (key, val) VALUES (?, ?);",
|
||||
// bind: [BindText(value), BindText(key)]) { step($0) }
|
||||
// } else {
|
||||
// try? run(sql: "DELETE FROM settings WHERE key = ?;",
|
||||
// bind: [BindText(key)]) { step($0) }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
88
main/DB/DBCommon.swift
Normal file
88
main/DB/DBCommon.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
enum CreateTable {} // used for CREATE TABLE statements
|
||||
|
||||
extension SQLiteDatabase {
|
||||
func initCommonScheme() {
|
||||
try? run(sql: CreateTable.cache)
|
||||
try? run(sql: CreateTable.filter)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - transit
|
||||
|
||||
extension CreateTable {
|
||||
/// `ts`: Timestamp, `dns`: String, `opt`: Int
|
||||
static var cache: String {"""
|
||||
CREATE TABLE IF NOT EXISTS cache(
|
||||
ts INTEGER DEFAULT (strftime('%s','now')),
|
||||
dns TEXT NOT NULL,
|
||||
opt INTEGER
|
||||
);
|
||||
"""}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
/// `INSERT INTO cache (dns, opt) VALUES (?, ?);`
|
||||
func logWritePrepare() throws -> OpaquePointer {
|
||||
try prepare(sql: "INSERT INTO cache (dns, opt) VALUES (?, ?);")
|
||||
}
|
||||
/// `prep` must exist and be initialized with `logWritePrepare()`
|
||||
func logWrite(_ pStmt: OpaquePointer!, _ domain: String, blocked: Bool = false) throws {
|
||||
guard let prep = pStmt else {
|
||||
return
|
||||
}
|
||||
try prepared(run: prep, bind: [BindText(domain), BindInt32(blocked ? 1 : 0)])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - filter
|
||||
|
||||
extension CreateTable {
|
||||
/// `domain`: String, `opt`: Int
|
||||
static var filter: String {"""
|
||||
CREATE TABLE IF NOT EXISTS filter(
|
||||
domain TEXT UNIQUE NOT NULL,
|
||||
opt INTEGER
|
||||
);
|
||||
"""}
|
||||
}
|
||||
|
||||
struct FilterOptions: OptionSet {
|
||||
let rawValue: Int32
|
||||
static let none = FilterOptions([])
|
||||
static let blocked = FilterOptions(rawValue: 1 << 0)
|
||||
static let ignored = FilterOptions(rawValue: 1 << 1)
|
||||
static let any = FilterOptions(rawValue: 0b11)
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
func loadFilters(where matching: FilterOptions? = nil) -> [String : FilterOptions]? {
|
||||
let rv = matching?.rawValue ?? 0
|
||||
return try? run(sql: "SELECT domain, opt FROM filter \(rv>0 ? "WHERE opt & ?" : "");",
|
||||
bind: rv>0 ? [BindInt32(rv)] : []) {
|
||||
allRowsKeyed($0) {
|
||||
(key: readText($0, 0) ?? "",
|
||||
value: FilterOptions(rawValue: sqlite3_column_int($0, 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
func setFilter(_ domain: String, _ value: FilterOptions?) {
|
||||
if let rv = value?.rawValue, rv > 0 {
|
||||
try? run(sql: "INSERT OR REPLACE INTO filter (domain, opt) VALUES (?, ?);",
|
||||
bind: [BindText(domain), BindInt32(rv)]) { _ = sqlite3_step($0) }
|
||||
} else {
|
||||
try? run(sql: "DELETE FROM filter WHERE domain = ? LIMIT 1;",
|
||||
bind: [BindText(domain)]) { _ = sqlite3_step($0) }
|
||||
}
|
||||
}
|
||||
// func loadFilterCount() -> (blocked: Int32, ignored: Int32)? {
|
||||
// try? run(sql: "SELECT SUM(opt&1), SUM(opt&2)/2 FROM filter;") {
|
||||
// try ifStep($0, SQLITE_ROW)
|
||||
// return (sqlite3_column_int($0, 0), sqlite3_column_int($0, 1))
|
||||
// }
|
||||
// }
|
||||
}
|
||||
244
main/DB/DBCore.swift
Normal file
244
main/DB/DBCore.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
// iOS 9.3 uses SQLite 3.8.10
|
||||
|
||||
enum SQLiteError: Error {
|
||||
case OpenDatabase(message: String)
|
||||
case Prepare(message: String)
|
||||
case Step(message: String)
|
||||
case Bind(message: String)
|
||||
}
|
||||
|
||||
/// `try? SQLiteDatabase.open()`
|
||||
var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
|
||||
typealias SQLiteRowID = sqlite3_int64
|
||||
typealias SQLiteRowRange = (start: SQLiteRowID, end: SQLiteRowID)
|
||||
|
||||
// MARK: - SQLiteDatabase
|
||||
|
||||
class SQLiteDatabase {
|
||||
fileprivate var functions = [String: [Int: Function]]()
|
||||
private let dbPointer: OpaquePointer?
|
||||
private init(dbPointer: OpaquePointer?) {
|
||||
self.dbPointer = dbPointer
|
||||
}
|
||||
|
||||
fileprivate var errorMessage: String {
|
||||
if let errorPointer = sqlite3_errmsg(dbPointer) {
|
||||
let errorMessage = String(cString: errorPointer)
|
||||
return errorMessage
|
||||
} else {
|
||||
return "No error message provided from sqlite."
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
sqlite3_close(dbPointer)
|
||||
}
|
||||
|
||||
static func destroyDatabase(path: String = URL.internalDB().relativePath) {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
do { try FileManager.default.removeItem(atPath: path) }
|
||||
catch { print("Could not destroy database file: \(path)") }
|
||||
}
|
||||
}
|
||||
|
||||
static func open(path: String = URL.internalDB().relativePath) throws -> SQLiteDatabase {
|
||||
var db: OpaquePointer?
|
||||
//sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_SHAREDCACHE, nil)
|
||||
if sqlite3_open(path, &db) == SQLITE_OK {
|
||||
return SQLiteDatabase(dbPointer: db)
|
||||
} else {
|
||||
defer {
|
||||
if db != nil {
|
||||
sqlite3_close(db)
|
||||
}
|
||||
}
|
||||
if let errorPointer = sqlite3_errmsg(db) {
|
||||
let message = String(cString: errorPointer)
|
||||
throw SQLiteError.OpenDatabase(message: message)
|
||||
} else {
|
||||
throw SQLiteError.OpenDatabase(message: "No error message provided from sqlite.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run<T>(sql: String, bind: [DBBinding?] = [], step: (OpaquePointer) throws -> T) throws -> T {
|
||||
// print("SQL run: \(sql)")
|
||||
// for x in bind where x != nil {
|
||||
// print(" -> \(x!)")
|
||||
// }
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(dbPointer, sql, -1, &statement, nil) == SQLITE_OK,
|
||||
let stmt = statement else {
|
||||
throw SQLiteError.Prepare(message: errorMessage)
|
||||
}
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
var col: Int32 = 0
|
||||
for b in bind.compactMap({$0}) {
|
||||
col += 1
|
||||
guard b.bind(stmt, col) == SQLITE_OK else {
|
||||
throw SQLiteError.Bind(message: errorMessage)
|
||||
}
|
||||
}
|
||||
return try step(stmt)
|
||||
}
|
||||
|
||||
func run(sql: String) throws {
|
||||
// print("SQL exec: \(sql)")
|
||||
var err: UnsafeMutablePointer<Int8>? = nil
|
||||
if sqlite3_exec(dbPointer, sql, nil, nil, &err) != SQLITE_OK {
|
||||
let errMsg = (err != nil) ? String(cString: err!) : "Unknown execution error"
|
||||
sqlite3_free(err);
|
||||
throw SQLiteError.Step(message: errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws {
|
||||
guard sqlite3_step(stmt) == expected else {
|
||||
throw SQLiteError.Step(message: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func vacuum() {
|
||||
try? run(sql: "VACUUM;")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Custom Functions
|
||||
|
||||
// let SQLITE_STATIC = unsafeBitCast(0, sqlite3_destructor_type.self)
|
||||
let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
||||
public struct Blob {
|
||||
public let bytes: [UInt8]
|
||||
public init(bytes: [UInt8]) { self.bytes = bytes }
|
||||
public init(bytes: UnsafeRawPointer, length: Int) {
|
||||
let i8bufptr = UnsafeBufferPointer(start: bytes.assumingMemoryBound(to: UInt8.self), count: length)
|
||||
self.init(bytes: [UInt8](i8bufptr))
|
||||
}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
fileprivate typealias Function = @convention(block) (OpaquePointer?, Int32, UnsafeMutablePointer<OpaquePointer?>?) -> Void
|
||||
|
||||
func createFunction(_ function: String, argumentCount: UInt? = nil, deterministic: Bool = false, _ block: @escaping (_ args: [Any?]) -> Any?) {
|
||||
let argc = argumentCount.map { Int($0) } ?? -1
|
||||
let box: Function = { context, argc, argv in
|
||||
let arguments: [Any?] = (0..<Int(argc)).map {
|
||||
let value = argv![$0]
|
||||
switch sqlite3_value_type(value) {
|
||||
case SQLITE_BLOB: return Blob(bytes: sqlite3_value_blob(value), length: Int(sqlite3_value_bytes(value)))
|
||||
case SQLITE_FLOAT: return sqlite3_value_double(value)
|
||||
case SQLITE_INTEGER: return sqlite3_value_int64(value)
|
||||
case SQLITE_NULL: return nil
|
||||
case SQLITE_TEXT: return String(cString: UnsafePointer(sqlite3_value_text(value)))
|
||||
case let type: fatalError("unsupported value type: \(type)")
|
||||
}
|
||||
}
|
||||
let result = block(arguments)
|
||||
if let r = result as? Blob { sqlite3_result_blob(context, r.bytes, Int32(r.bytes.count), nil) }
|
||||
else if let r = result as? Double { sqlite3_result_double(context, r) }
|
||||
else if let r = result as? Int64 { sqlite3_result_int64(context, r) }
|
||||
else if let r = result as? String { sqlite3_result_text(context, r, Int32(r.count), SQLITE_TRANSIENT) }
|
||||
else if result == nil { sqlite3_result_null(context) }
|
||||
else { fatalError("unsupported result type: \(String(describing: result))") }
|
||||
}
|
||||
var flags = SQLITE_UTF8
|
||||
if deterministic {
|
||||
flags |= SQLITE_DETERMINISTIC
|
||||
}
|
||||
sqlite3_create_function_v2(dbPointer, function, Int32(argc), flags, unsafeBitCast(box, to: UnsafeMutableRawPointer.self), { context, argc, value in
|
||||
let function = unsafeBitCast(sqlite3_user_data(context), to: Function.self)
|
||||
function(context, argc, value)
|
||||
}, nil, nil, nil)
|
||||
if functions[function] == nil { functions[function] = [:] }
|
||||
functions[function]?[argc] = box
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
protocol DBBinding {
|
||||
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32
|
||||
}
|
||||
|
||||
struct BindInt32 : DBBinding {
|
||||
let raw: Int32
|
||||
init(_ value: Int32) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_int(stmt, col, raw) }
|
||||
}
|
||||
|
||||
struct BindInt64 : DBBinding {
|
||||
let raw: sqlite3_int64
|
||||
init(_ value: sqlite3_int64) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_int64(stmt, col, raw) }
|
||||
}
|
||||
|
||||
struct BindText : DBBinding {
|
||||
let raw: String
|
||||
init(_ value: String) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw as NSString).utf8String, -1, nil) }
|
||||
}
|
||||
|
||||
struct BindTextOrNil : DBBinding {
|
||||
let raw: String?
|
||||
init(_ value: String?) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer!, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw == nil) ? nil : (raw! as NSString).utf8String, -1, nil) }
|
||||
}
|
||||
|
||||
// MARK: - Easy Access func
|
||||
|
||||
extension SQLiteDatabase {
|
||||
var numberOfChanges: Int32 { get { sqlite3_changes(dbPointer) } }
|
||||
var lastInsertedRow: SQLiteRowID { get { sqlite3_last_insert_rowid(dbPointer) } }
|
||||
|
||||
func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
let val = sqlite3_column_text(stmt, col)
|
||||
return (val != nil ? String(cString: val!) : nil)
|
||||
}
|
||||
|
||||
func allRows<T>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> T) -> [T] {
|
||||
var r: [T] = []
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) { r.append(fn(stmt)) }
|
||||
return r
|
||||
}
|
||||
|
||||
func allRowsKeyed<T,U>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> (key: T, value: U)) -> [T:U] {
|
||||
var r: [T:U] = [:]
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) { let (k,v) = fn(stmt); r[k] = v }
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Prepared Statement
|
||||
|
||||
extension SQLiteDatabase {
|
||||
func prepare(sql: String) throws -> OpaquePointer {
|
||||
var pStmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(dbPointer, sql, -1, &pStmt, nil) == SQLITE_OK, let S = pStmt else {
|
||||
throw SQLiteError.Prepare(message: errorMessage)
|
||||
}
|
||||
return S
|
||||
}
|
||||
|
||||
@discardableResult func prepared(run pStmt: OpaquePointer!, bind: [DBBinding?] = []) throws -> Int32 {
|
||||
defer { sqlite3_reset(pStmt) }
|
||||
var col: Int32 = 0
|
||||
for b in bind.compactMap({$0}) {
|
||||
col += 1
|
||||
guard b.bind(pStmt, col) == SQLITE_OK else {
|
||||
throw SQLiteError.Bind(message: errorMessage)
|
||||
}
|
||||
}
|
||||
return sqlite3_step(pStmt)
|
||||
}
|
||||
|
||||
func prepared(finalize pStmt: OpaquePointer!) {
|
||||
sqlite3_finalize(pStmt)
|
||||
}
|
||||
}
|
||||
40
main/DB/DBExtensions.swift
Normal file
40
main/DB/DBExtensions.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import UIKit
|
||||
|
||||
extension GroupedDomain {
|
||||
/// Return new `GroupedDomain` by adding `total` and `blocked` counts. Set `lastModified` to the maximum of the two.
|
||||
static func +(a: GroupedDomain, b: GroupedDomain) -> Self {
|
||||
GroupedDomain(domain: a.domain, total: a.total + b.total, blocked: a.blocked + b.blocked,
|
||||
lastModified: max(a.lastModified, b.lastModified), options: a.options ?? b.options )
|
||||
}
|
||||
/// Return new `GroupedDomain` by subtracting `total` and `blocked` counts.
|
||||
static func -(a: GroupedDomain, b: GroupedDomain) -> Self {
|
||||
GroupedDomain(domain: a.domain, total: a.total - b.total, blocked: a.blocked - b.blocked,
|
||||
lastModified: a.lastModified, options: a.options )
|
||||
}
|
||||
}
|
||||
|
||||
extension GroupedDomain {
|
||||
var detailCellText: String { get {
|
||||
return blocked > 0
|
||||
? "\(lastModified.asDateTime()) — \(blocked)/\(total) blocked"
|
||||
: "\(lastModified.asDateTime()) — \(total)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FilterOptions {
|
||||
func tableRowImage() -> UIImage? {
|
||||
let blocked = contains(.blocked)
|
||||
let ignored = contains(.ignored)
|
||||
if blocked { return UIImage(named: ignored ? "block_ignore" : "shield-x") }
|
||||
if ignored { return UIImage(named: "quicklook-not") }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension Recording {
|
||||
var fallbackTitle: String { get { "Unnamed Recording #\(id)" } }
|
||||
var duration: Timestamp? { get { stop == nil ? nil : stop! - start } }
|
||||
var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } }
|
||||
}
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
import UIKit
|
||||
|
||||
let DBWrp = DBWrapper()
|
||||
fileprivate var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } }
|
||||
|
||||
class DBWrapper {
|
||||
private var earliestEntry: Timestamp = 0
|
||||
private var latestModification: Timestamp = 0
|
||||
private var dataA: [GroupedDomain] = [] // Domains
|
||||
private var dataB: [[GroupedDomain]] = [] // Hosts
|
||||
private var dataF: [String : FilterOptions] = [:] // Filters
|
||||
private let Q = DispatchQueue(label: "de.uni-bamberg.psi.AppCheck.db-wrapper-queue", attributes: .concurrent)
|
||||
|
||||
// auto update rows callback
|
||||
var currentlyOpenParent: String?
|
||||
weak var dataA_delegate: IncrementalDataSourceUpdate?
|
||||
weak var dataB_delegate: IncrementalDataSourceUpdate?
|
||||
func dataB_delegate(_ parent: String) -> IncrementalDataSourceUpdate? {
|
||||
(currentlyOpenParent == parent) ? dataB_delegate : nil
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Data Source Getter
|
||||
|
||||
func listOfDomains() -> [GroupedDomain] {
|
||||
Q.sync() { dataA }
|
||||
}
|
||||
|
||||
func listOfHosts(_ parent: String) -> [GroupedDomain] {
|
||||
Q.sync() { dataB[ifExist: dataA_index(of: parent)] ?? [] }
|
||||
}
|
||||
|
||||
func dataF_list(_ filter: FilterOptions) -> [String] {
|
||||
Q.sync() { dataF.compactMap { $1.contains(filter) ? $0 : nil } }.sorted()
|
||||
}
|
||||
|
||||
func dataF_counts() -> (blocked: Int, ignored: Int) {
|
||||
Q.sync() { dataF.reduce((0, 0)) {
|
||||
($0.0 + ($1.1.contains(.blocked) ? 1 : 0),
|
||||
$0.1 + ($1.1.contains(.ignored) ? 1 : 0)) }}
|
||||
}
|
||||
|
||||
func listOfTimes(_ domain: String) -> [GroupedTsOccurrence] {
|
||||
return AppDB?.timesForDomain(domain, since: earliestEntry)?.reversed() ?? []
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
func initContentOfDB() {
|
||||
QLog.Debug("SQLite path: \(URL.internalDB())")
|
||||
DispatchQueue.global().async {
|
||||
#if IOS_SIMULATOR
|
||||
self.generateTestData()
|
||||
DispatchQueue.main.async {
|
||||
// dont know why main queue is needed, wont start otherwise
|
||||
Timer.repeating(2, call: #selector(self.insertRandomEntry), on: self)
|
||||
}
|
||||
#endif
|
||||
self.dataF_init()
|
||||
self.dataAB_init()
|
||||
self.autoSyncTimer_init()
|
||||
}
|
||||
}
|
||||
|
||||
func reloadAfterDateFilterHasChanged() {
|
||||
DispatchQueue.global().async {
|
||||
self.dataAB_init()
|
||||
}
|
||||
}
|
||||
|
||||
private func dataF_init() {
|
||||
let list = AppDB?.loadFilters() ?? [:]
|
||||
Q.async(flags: .barrier) {
|
||||
self.dataF = list
|
||||
NotifyDNSFilterChanged.postAsyncMain()
|
||||
}
|
||||
}
|
||||
|
||||
private func dataAB_init() {
|
||||
let earliest = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||
let list = AppDB?.domainList(since: earliest)
|
||||
Q.async(flags: .barrier) {
|
||||
self.dataA = []
|
||||
self.dataB = []
|
||||
self.earliestEntry = earliest
|
||||
self.latestModification = earliest
|
||||
if let allDomains = list {
|
||||
for (parent, parts) in self.groupBySubdomains(allDomains) {
|
||||
self.dataA.append(parent)
|
||||
self.dataB.append(parts)
|
||||
self.latestModification = max(parent.lastModified, self.latestModification)
|
||||
}
|
||||
}
|
||||
NotifyLogHistoryReset.postAsyncMain()
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto sync new logs every 7 seconds.
|
||||
private func autoSyncTimer_init() {
|
||||
Q.async() { // using Q to start timer only after init data A,B,F
|
||||
DispatchQueue.main.async {
|
||||
// dont know why main queue is needed, wont start otherwise
|
||||
Timer.repeating(7, call: #selector(self.syncNewestLogs), on: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Partial Update History
|
||||
|
||||
@objc private func syncNewestLogs() {
|
||||
dataA_mergeInsert();
|
||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp() {
|
||||
if earliestEntry < lastXFilter {
|
||||
dataA_mergeDelete(between: earliestEntry, and: lastXFilter)
|
||||
earliestEntry = lastXFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dataA_mergeInsert() {
|
||||
#if !IOS_SIMULATOR
|
||||
guard currentVPNState == .on else { return }
|
||||
#endif
|
||||
guard let res = AppDB?.domainList(since: latestModification + 1), res.count > 0 else {
|
||||
return
|
||||
}
|
||||
QLog.Info("auto sync \(res.count) new logs")
|
||||
Q.async(flags: .barrier) {
|
||||
var c = 0
|
||||
for (parent, parts) in self.groupBySubdomains(res) {
|
||||
if let i = self.dataA_index(of: parent.domain) {
|
||||
self.dataB_mergeInsert(parent.domain, at: i, newChildren: parts)
|
||||
|
||||
let merged = parent + self.dataA.remove(at: i)
|
||||
self.dataA.insert(merged, at: c)
|
||||
self.dataB.insert(self.dataB.remove(at: i), at: c)
|
||||
self.dataA_delegate?.moveRow(merged, from: i, to: c)
|
||||
} else {
|
||||
self.dataA.insert(parent, at: c)
|
||||
self.dataB.insert(parts, at: c)
|
||||
self.dataA_delegate?.insertRow(parent, at: c)
|
||||
self.dataB_delegate(parent.domain)?.replaceData(with: parts);
|
||||
}
|
||||
c += 1
|
||||
self.latestModification = max(parent.lastModified, self.latestModification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dataB_mergeInsert(_ dom: String, at index: Int, newChildren: [GroupedDomain]) {
|
||||
let tvc = dataB_delegate(dom)
|
||||
var i = 0
|
||||
for child in newChildren {
|
||||
if let u = dataB[index].firstIndex(where: { $0.domain == child.domain }) {
|
||||
let merged = child + dataB[index].remove(at: u)
|
||||
dataB[index].insert(merged, at: i)
|
||||
tvc?.moveRow(merged, from: u, to: i)
|
||||
} else {
|
||||
dataB[index].insert(child, at: i)
|
||||
tvc?.insertRow(child, at: i)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Soft Delete History
|
||||
// Will delete appearance only. DB still contains a copy.
|
||||
|
||||
/// Technically not really deleting existing logs. Rather query DB for selected range and decrement whatever count is returned.
|
||||
/// This means you should only run the delete operation once. As running multiple times will distrort the data.
|
||||
/// - Parameters:
|
||||
/// - between: Starting with and including
|
||||
/// - and: Up until, exculding
|
||||
private func dataA_mergeDelete(between: Timestamp, and: Timestamp) {
|
||||
guard let res = AppDB?.domainList(between: between, and: and), res.count > 0 else {
|
||||
return
|
||||
}
|
||||
QLog.Info("deleting \(res.count) old logs (soft delete)")
|
||||
Q.async(flags: .barrier) {
|
||||
for (parent, parts) in self.groupBySubdomains(res) {
|
||||
guard let i = self.dataA_index(of: parent.domain) else {
|
||||
continue // should never happen anyway
|
||||
}
|
||||
if parent.total < self.dataA[i].total {
|
||||
self.dataB_mergeDelete(parent.domain, at: i, oldChildren: parts)
|
||||
self.dataA[i] = self.dataA[i] - parent
|
||||
self.dataA_delegate?.replaceRow(self.dataA[i], at: i)
|
||||
} else {
|
||||
self.dataA_delete(at: i, parentDomain: parent.domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dataB_mergeDelete(_ dom: String, at index: Int, oldChildren: [GroupedDomain]) {
|
||||
let tvc = dataB_delegate(dom)
|
||||
for child in oldChildren {
|
||||
guard let u = dataB[index].firstIndex(where: { $0.domain == child.domain }) else {
|
||||
continue // should never happen anyway
|
||||
}
|
||||
if child.total < dataB[index][u].total {
|
||||
dataB[index][u] = dataB[index][u] - child
|
||||
tvc?.replaceRow(dataB[index][u], at: u)
|
||||
} else {
|
||||
dataB[index].remove(at: u)
|
||||
tvc?.deleteRow(at: u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dataA_delete(at index: Int, parentDomain: String) {
|
||||
dataA.remove(at: index)
|
||||
dataB.remove(at: index)
|
||||
dataA_delegate?.deleteRow(at: index)
|
||||
dataB_delegate(parentDomain)?.replaceData(with: [])
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Hard Delete History
|
||||
// will delete DB content. No restore.
|
||||
|
||||
func deleteHistory() {
|
||||
DispatchQueue.global().async {
|
||||
try? AppDB?.destroyContent()
|
||||
AppDB?.vacuum()
|
||||
self.dataAB_init()
|
||||
}
|
||||
}
|
||||
|
||||
func deleteHistory(domain: String, since ts: Timestamp) {
|
||||
DispatchQueue.global().async {
|
||||
let modified = (try? AppDB?.deleteRows(matching: domain, since: max(ts, self.earliestEntry))) ?? 0
|
||||
guard modified > 0 else {
|
||||
return // nothing has changed
|
||||
}
|
||||
QLog.Info("deleting \(modified) old logs (hard delete)")
|
||||
AppDB?.vacuum()
|
||||
self.Q.async(flags: .barrier) {
|
||||
guard let index = self.dataA_index(of: domain) else {
|
||||
return // nothing has changed
|
||||
}
|
||||
let parentDom = self.dataA[index].domain
|
||||
guard let list = AppDB?.domainList(matching: parentDom, since: self.earliestEntry),
|
||||
list.count > 0 else {
|
||||
self.dataA_delete(at: index, parentDomain: parentDom)
|
||||
return // nothing left, after deleting matching rows
|
||||
}
|
||||
// else: incremental update, replace whole list
|
||||
self.dataA[index] = list.merge(parentDom, options: self.dataF[parentDom])
|
||||
self.dataA_delegate?.replaceRow(self.dataA[index], at: index)
|
||||
self.dataB[index].removeAll()
|
||||
for var child in list {
|
||||
child.options = self.dataF[child.domain]
|
||||
self.dataB[index].append(child)
|
||||
}
|
||||
self.dataB_delegate(parentDom)?.replaceData(with: self.dataB[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Partial Update Filter
|
||||
|
||||
func updateFilter(_ domain: String, add: FilterOptions) {
|
||||
updateFilter(domain, set: (dataF[domain] ?? FilterOptions()).union(add))
|
||||
}
|
||||
|
||||
func updateFilter(_ domain: String, remove: FilterOptions) {
|
||||
updateFilter(domain, set: dataF[domain]?.subtracting(remove))
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - set: Remove a filter with `nil` or `.none`
|
||||
private func updateFilter(_ domain: String, set: FilterOptions?) {
|
||||
AppDB?.setFilter(domain, set)
|
||||
Q.async(flags: .barrier) {
|
||||
self.dataF[domain] = set
|
||||
if let i = self.dataA_index(of: domain) {
|
||||
if domain == self.dataA[i].domain {
|
||||
self.dataA[i].options = (set == FilterOptions.none) ? nil : set
|
||||
self.dataA_delegate?.replaceRow(self.dataA[i], at: i)
|
||||
}
|
||||
if let u = self.dataB[i].firstIndex(where: { $0.domain == domain }) {
|
||||
self.dataB[i][u].options = (set == FilterOptions.none) ? nil : set
|
||||
self.dataB_delegate(self.dataA[i].domain)?.replaceRow(self.dataB[i][u], at: u)
|
||||
}
|
||||
}
|
||||
NotifyDNSFilterChanged.postAsyncMain()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Recordings
|
||||
|
||||
func listOfRecordings() -> [Recording] { AppDB?.allRecordings() ?? [] }
|
||||
func recordingGetCurrent() -> Recording? { AppDB?.ongoingRecording() }
|
||||
func recordingStartNew() -> Recording? { try? AppDB?.startNewRecording() }
|
||||
|
||||
func recordingStop(_ r: inout Recording) { AppDB?.stopRecording(&r) }
|
||||
func recordingPersist(_ r: Recording) { AppDB?.persistRecordingLogs(r) }
|
||||
func recordingDetails(_ r: Recording) -> [RecordLog] { AppDB?.getRecordingsLogs(r) ?? [] }
|
||||
|
||||
func recordingUpdate(_ r: Recording) {
|
||||
AppDB?.updateRecording(r)
|
||||
NotifyRecordingChanged.post((r, false))
|
||||
}
|
||||
|
||||
func recordingDelete(_ r: Recording) {
|
||||
if (try? AppDB?.deleteRecording(r)) == true {
|
||||
NotifyRecordingChanged.post((r, true))
|
||||
}
|
||||
}
|
||||
|
||||
func recordingDeleteDetails(_ r: Recording, domain: String?) -> Bool {
|
||||
((try? AppDB?.deleteRecordingLogs(r.id, matchingDomain: domain)) ?? 0) > 0
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper methods
|
||||
|
||||
private func dataA_index(of domain: String) -> Int? {
|
||||
dataA.firstIndex { domain.isSubdomain(of: $0.domain) }
|
||||
}
|
||||
|
||||
private func groupBySubdomains(_ allDomains: [GroupedDomain]) -> [(parent: GroupedDomain, parts: [GroupedDomain])] {
|
||||
var i: Int = 0
|
||||
var indexOf: [String: Int] = [:]
|
||||
var res: [(domain: String, list: [GroupedDomain])] = []
|
||||
for var x in allDomains {
|
||||
let domain = x.domain.splitDomainAndHost().domain
|
||||
x.options = dataF[x.domain]
|
||||
if let y = indexOf[domain] {
|
||||
res[y].list.append(x)
|
||||
} else {
|
||||
res.append((domain, [x]))
|
||||
indexOf[domain] = i
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return res.map { ($1.merge($0, options: self.dataF[$0]), $1) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Test Data
|
||||
|
||||
extension DBWrapper {
|
||||
private func generateTestData() {
|
||||
guard let db = AppDB else { return }
|
||||
let deleted = (try? db.deleteRows(matching: "test.com")) ?? 0
|
||||
QLog.Debug("Deleting \(deleted) rows matching 'test.com'")
|
||||
|
||||
QLog.Debug("Writing 33 test logs")
|
||||
try? db.insertDNSQuery("keeptest.com", blocked: false)
|
||||
for _ in 1...4 { try? db.insertDNSQuery("test.com", blocked: false) }
|
||||
for _ in 1...7 { try? db.insertDNSQuery("i.test.com", blocked: false) }
|
||||
for i in 1...8 { try? db.insertDNSQuery("b.test.com", blocked: i>5) }
|
||||
for i in 1...13 { try? db.insertDNSQuery("bi.test.com", blocked: i%2==0) }
|
||||
|
||||
QLog.Debug("Creating 4 filters")
|
||||
db.setFilter("b.test.com", .blocked)
|
||||
db.setFilter("i.test.com", .ignored)
|
||||
db.setFilter("bi.test.com", [.blocked, .ignored])
|
||||
|
||||
QLog.Debug("Done")
|
||||
}
|
||||
|
||||
@objc private func insertRandomEntry() {
|
||||
//QLog.Debug("Inserting 1 periodic log entry")
|
||||
try? AppDB?.insertDNSQuery("\(arc4random() % 5).count.test.com", blocked: true)
|
||||
}
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
typealias Timestamp = Int64
|
||||
|
||||
struct FilterOptions: OptionSet {
|
||||
let rawValue: Int32
|
||||
static let none = FilterOptions([])
|
||||
static let blocked = FilterOptions(rawValue: 1 << 0)
|
||||
static let ignored = FilterOptions(rawValue: 1 << 1)
|
||||
static let any = FilterOptions(rawValue: 0b11)
|
||||
}
|
||||
|
||||
enum SQLiteError: Error {
|
||||
case OpenDatabase(message: String)
|
||||
case Prepare(message: String)
|
||||
case Step(message: String)
|
||||
case Bind(message: String)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQLiteDatabase
|
||||
|
||||
class SQLiteDatabase {
|
||||
private let dbPointer: OpaquePointer?
|
||||
private init(dbPointer: OpaquePointer?) {
|
||||
self.dbPointer = dbPointer
|
||||
}
|
||||
|
||||
fileprivate var errorMessage: String {
|
||||
if let errorPointer = sqlite3_errmsg(dbPointer) {
|
||||
let errorMessage = String(cString: errorPointer)
|
||||
return errorMessage
|
||||
} else {
|
||||
return "No error message provided from sqlite."
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
sqlite3_close(dbPointer)
|
||||
}
|
||||
|
||||
static func destroyDatabase(path: String = URL.internalDB().relativePath) {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
do { try FileManager.default.removeItem(atPath: path) }
|
||||
catch { print("Could not destroy database file: \(path)") }
|
||||
}
|
||||
}
|
||||
|
||||
// static func export() throws -> URL {
|
||||
// let fmt = DateFormatter()
|
||||
// fmt.dateFormat = "yyyy-MM-dd"
|
||||
// let dest = FileManager.default.exportDir().appendingPathComponent("\(fmt.string(from: Date()))-dns-log.sqlite")
|
||||
// try? FileManager.default.removeItem(at: dest)
|
||||
// try FileManager.default.copyItem(at: FileManager.default.internalDB(), to: dest)
|
||||
// return dest
|
||||
// }
|
||||
|
||||
static func open(path: String = URL.internalDB().relativePath) throws -> SQLiteDatabase {
|
||||
var db: OpaquePointer?
|
||||
//sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_SHAREDCACHE, nil)
|
||||
if sqlite3_open(path, &db) == SQLITE_OK {
|
||||
return SQLiteDatabase(dbPointer: db)
|
||||
} else {
|
||||
defer {
|
||||
if db != nil {
|
||||
sqlite3_close(db)
|
||||
}
|
||||
}
|
||||
if let errorPointer = sqlite3_errmsg(db) {
|
||||
let message = String(cString: errorPointer)
|
||||
throw SQLiteError.OpenDatabase(message: message)
|
||||
} else {
|
||||
throw SQLiteError.OpenDatabase(message: "No error message provided from sqlite.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run<T>(sql: String, bind: [DBBinding?] = [], step: (OpaquePointer) throws -> T) throws -> T {
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(dbPointer, sql, -1, &statement, nil) == SQLITE_OK,
|
||||
let stmt = statement else {
|
||||
throw SQLiteError.Prepare(message: errorMessage)
|
||||
}
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
var col: Int32 = 0
|
||||
for b in bind.compactMap({$0}) {
|
||||
col += 1
|
||||
guard b.bind(stmt, col) == SQLITE_OK else {
|
||||
throw SQLiteError.Bind(message: errorMessage)
|
||||
}
|
||||
}
|
||||
return try step(stmt)
|
||||
}
|
||||
|
||||
func ifStep(_ stmt: OpaquePointer, _ expected: Int32) throws {
|
||||
guard sqlite3_step(stmt) == expected else {
|
||||
throw SQLiteError.Step(message: errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func createTable(table: SQLTable.Type) throws {
|
||||
try run(sql: table.createStatement) { try ifStep($0, SQLITE_DONE) }
|
||||
}
|
||||
|
||||
func vacuum() {
|
||||
try? run(sql: "VACUUM;") { try ifStep($0, SQLITE_DONE) }
|
||||
}
|
||||
}
|
||||
|
||||
protocol SQLTable {
|
||||
static var createStatement: String { get }
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
protocol DBBinding {
|
||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32
|
||||
}
|
||||
|
||||
struct BindInt32 : DBBinding {
|
||||
let raw: Int32
|
||||
init(_ value: Int32) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_int(stmt, col, raw) }
|
||||
}
|
||||
|
||||
struct BindInt64 : DBBinding {
|
||||
let raw: sqlite3_int64
|
||||
init(_ value: sqlite3_int64) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_int64(stmt, col, raw) }
|
||||
}
|
||||
|
||||
struct BindText : DBBinding {
|
||||
let raw: String
|
||||
init(_ value: String) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw as NSString).utf8String, -1, nil) }
|
||||
}
|
||||
|
||||
struct BindTextOrNil : DBBinding {
|
||||
let raw: String?
|
||||
init(_ value: String?) { raw = value }
|
||||
func bind(_ stmt: OpaquePointer, _ col: Int32) -> Int32 { sqlite3_bind_text(stmt, col, (raw == nil) ? nil : (raw! as NSString).utf8String, -1, nil) }
|
||||
}
|
||||
|
||||
// MARK: - Easy Access func
|
||||
|
||||
private extension SQLiteDatabase {
|
||||
func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
let val = sqlite3_column_text(stmt, col)
|
||||
return (val != nil ? String(cString: val!) : nil)
|
||||
}
|
||||
|
||||
func allRows<T>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> T) -> [T] {
|
||||
var r: [T] = []
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) { r.append(fn(stmt)) }
|
||||
return r
|
||||
}
|
||||
|
||||
func allRowsKeyed<T,U>(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> (key: T, value: U)) -> [T:U] {
|
||||
var r: [T:U] = [:]
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) { let (k,v) = fn(stmt); r[k] = v }
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
func initScheme() {
|
||||
try? self.createTable(table: DNSQueryT.self)
|
||||
try? self.createTable(table: DNSFilterT.self)
|
||||
try? self.createTable(table: Recording.self)
|
||||
try? self.createTable(table: RecordingLog.self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - DNSQueryT
|
||||
|
||||
private struct DNSQueryT: SQLTable {
|
||||
let ts: Timestamp
|
||||
let domain: String
|
||||
let wasBlocked: Bool
|
||||
let options: FilterOptions
|
||||
static var createStatement: String {
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS req(
|
||||
ts INTEGER DEFAULT (strftime('%s','now')),
|
||||
domain TEXT NOT NULL,
|
||||
logOpt INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupedDomain {
|
||||
let domain: String, total: Int32, blocked: Int32, lastModified: Timestamp
|
||||
var options: FilterOptions? = nil
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: insert
|
||||
|
||||
func insertDNSQuery(_ domain: String, blocked: Bool) throws {
|
||||
try? run(sql: "INSERT INTO req (domain, logOpt) VALUES (?, ?);",
|
||||
bind: [BindText(domain), BindInt32(blocked ? 1 : 0)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: delete
|
||||
|
||||
func destroyContent() throws {
|
||||
try? run(sql: "DROP TABLE IF EXISTS req;") { try ifStep($0, SQLITE_DONE) }
|
||||
try? createTable(table: DNSQueryT.self)
|
||||
}
|
||||
|
||||
/// Delete rows matching `ts >= ? AND "domain" OR "*.domain"`
|
||||
@discardableResult func deleteRows(matching domain: String, since ts: Timestamp = 0) throws -> Int32 {
|
||||
try run(sql: "DELETE FROM req WHERE ts >= ? AND (domain = ? OR domain LIKE '%.' || ?);",
|
||||
bind: [BindInt64(ts), BindText(domain), BindText(domain)]) { stmt -> Int32 in
|
||||
try ifStep(stmt, SQLITE_DONE)
|
||||
return sqlite3_changes(dbPointer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
private func allDomainsGrouped(_ clause: String = "", bind: [DBBinding?] = []) -> [GroupedDomain]? {
|
||||
try? run(sql: "SELECT domain, COUNT(*), SUM(logOpt&1), MAX(ts) FROM req \(clause) GROUP BY domain ORDER BY 4 DESC;", bind: bind) {
|
||||
allRows($0) {
|
||||
GroupedDomain(domain: readText($0, 0) ?? "",
|
||||
total: sqlite3_column_int($0, 1),
|
||||
blocked: sqlite3_column_int($0, 2),
|
||||
lastModified: sqlite3_column_int64($0, 3))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func domainList(since ts: Timestamp = 0) -> [GroupedDomain]? {
|
||||
ts==0 ? allDomainsGrouped() : allDomainsGrouped("WHERE ts >= ?", bind: [BindInt64(ts)])
|
||||
}
|
||||
|
||||
/// Get grouped domains matching `ts >= ? AND "domain" OR "*.domain"`
|
||||
func domainList(matching domain: String, since ts: Timestamp = 0) -> [GroupedDomain]? {
|
||||
allDomainsGrouped("WHERE ts >= ? AND (domain = ? OR domain LIKE '%.' || ?)",
|
||||
bind: [BindInt64(ts), BindText(domain), BindText(domain)])
|
||||
}
|
||||
|
||||
/// From `ts1` (including) and up to `ts2` (excluding). `ts1 >= X < ts2`
|
||||
func domainList(between ts1: Timestamp, and ts2: Timestamp) -> [GroupedDomain]? {
|
||||
allDomainsGrouped("WHERE ts >= ? AND ts < ?", bind: [BindInt64(ts1), BindInt64(ts2)])
|
||||
}
|
||||
|
||||
func timesForDomain(_ fullDomain: String, since ts: Timestamp = 0) -> [GroupedTsOccurrence]? {
|
||||
try? run(sql: "SELECT ts, COUNT(ts), SUM(logOpt>0) FROM req WHERE ts >= ? AND domain = ? GROUP BY ts;",
|
||||
bind: [BindInt64(ts), BindText(fullDomain)]) {
|
||||
allRows($0) {
|
||||
(sqlite3_column_int64($0, 0), sqlite3_column_int($0, 1), sqlite3_column_int($0, 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias GroupedTsOccurrence = (ts: Timestamp, total: Int32, blocked: Int32)
|
||||
|
||||
|
||||
// MARK: - DNSFilterT
|
||||
|
||||
private struct DNSFilterT: SQLTable {
|
||||
let domain: String
|
||||
let options: FilterOptions
|
||||
static var createStatement: String {
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS filter(
|
||||
domain TEXT UNIQUE NOT NULL,
|
||||
opt INTEGER DEFAULT 0
|
||||
);
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: read
|
||||
|
||||
func loadFilters() -> [String : FilterOptions]? {
|
||||
try? run(sql: "SELECT domain, opt FROM filter;") {
|
||||
allRowsKeyed($0) {
|
||||
(key: readText($0, 0) ?? "",
|
||||
value: FilterOptions(rawValue: sqlite3_column_int($0, 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: write
|
||||
|
||||
func setFilter(_ domain: String, _ value: FilterOptions?) {
|
||||
func removeFilter() {
|
||||
try? run(sql: "DELETE FROM filter WHERE domain = ? LIMIT 1;",
|
||||
bind: [BindText(domain)]) { stmt -> Void in
|
||||
sqlite3_step(stmt)
|
||||
}
|
||||
}
|
||||
guard let rv = value?.rawValue, rv > 0 else {
|
||||
removeFilter()
|
||||
return
|
||||
}
|
||||
func createFilter() throws {
|
||||
try run(sql: "INSERT OR FAIL INTO filter (domain, opt) VALUES (?, ?);",
|
||||
bind: [BindText(domain), BindInt32(rv)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
}
|
||||
}
|
||||
func updateFilter() {
|
||||
try? run(sql: "UPDATE filter SET opt = ? WHERE domain = ? LIMIT 1;",
|
||||
bind: [BindInt32(rv), BindText(domain)]) { stmt -> Void in
|
||||
sqlite3_step(stmt)
|
||||
}
|
||||
}
|
||||
do { try createFilter() } catch { updateFilter() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Recordings
|
||||
|
||||
struct Recording: SQLTable {
|
||||
let id: sqlite3_int64
|
||||
let start: Timestamp
|
||||
let stop: Timestamp?
|
||||
var appId: String? = nil
|
||||
var title: String? = nil
|
||||
var notes: String? = nil
|
||||
static var createStatement: String {
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS rec(
|
||||
id INTEGER PRIMARY KEY,
|
||||
start INTEGER DEFAULT (strftime('%s','now')),
|
||||
stop INTEGER,
|
||||
appid TEXT,
|
||||
title TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: write
|
||||
|
||||
func startNewRecording() throws -> Recording {
|
||||
try run(sql: "INSERT INTO rec (stop) VALUES (NULL);") { stmt -> Recording in
|
||||
try ifStep(stmt, SQLITE_DONE)
|
||||
return try getRecording(withID: sqlite3_last_insert_rowid(dbPointer))
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording(_ r: inout Recording) {
|
||||
guard r.stop == nil else { return }
|
||||
let theID = r.id
|
||||
try? run(sql: "UPDATE rec SET stop = (strftime('%s','now')) WHERE id = ? LIMIT 1;",
|
||||
bind: [BindInt64(theID)]) { stmt -> Void in
|
||||
try ifStep(stmt, SQLITE_DONE)
|
||||
r = try getRecording(withID: theID)
|
||||
}
|
||||
}
|
||||
|
||||
func updateRecording(_ r: Recording) {
|
||||
try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;",
|
||||
bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in
|
||||
sqlite3_step(stmt)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRecording(_ r: Recording) throws -> Bool {
|
||||
_ = try? deleteRecordingLogs(r.id)
|
||||
return try run(sql: "DELETE FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(r.id)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
return sqlite3_changes(dbPointer) > 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
func readRecording(_ stmt: OpaquePointer) -> Recording {
|
||||
let end = sqlite3_column_int64(stmt, 2)
|
||||
return Recording(id: sqlite3_column_int64(stmt, 0),
|
||||
start: sqlite3_column_int64(stmt, 1),
|
||||
stop: end == 0 ? nil : end,
|
||||
appId: readText(stmt, 3),
|
||||
title: readText(stmt, 4),
|
||||
notes: readText(stmt, 5))
|
||||
}
|
||||
|
||||
func ongoingRecording() -> Recording? {
|
||||
try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;") {
|
||||
try ifStep($0, SQLITE_ROW)
|
||||
return readRecording($0)
|
||||
}
|
||||
}
|
||||
|
||||
func allRecordings() -> [Recording]? {
|
||||
try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") {
|
||||
allRows($0) { readRecording($0) }
|
||||
}
|
||||
}
|
||||
|
||||
func getRecording(withID: sqlite3_int64) throws -> Recording {
|
||||
try run(sql: "SELECT * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) {
|
||||
try ifStep($0, SQLITE_ROW)
|
||||
return readRecording($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK:
|
||||
|
||||
private struct RecordingLog: SQLTable {
|
||||
let rID: Int32
|
||||
let ts: Timestamp
|
||||
let domain: String
|
||||
static var createStatement: String {
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS recLog(
|
||||
rid INTEGER REFERENCES rec(id) ON DELETE CASCADE,
|
||||
ts INTEGER,
|
||||
domain TEXT
|
||||
);
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
extension SQLiteDatabase {
|
||||
|
||||
// MARK: write
|
||||
|
||||
func persistRecordingLogs(_ r: Recording) {
|
||||
guard let end = r.stop else {
|
||||
return
|
||||
}
|
||||
try? run(sql: """
|
||||
INSERT INTO recLog (rid, ts, domain) SELECT ?, ts, domain FROM req
|
||||
WHERE req.ts >= ? AND req.ts <= ?
|
||||
""", bind: [BindInt64(r.id), BindInt64(r.start), BindInt64(end)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteRecordingLogs(_ recId: sqlite3_int64, matchingDomain d: String? = nil) throws -> Int32 {
|
||||
try run(sql: "DELETE FROM recLog WHERE rid = ? \(d==nil ? "" : "AND domain = ?");",
|
||||
bind: [BindInt64(recId), d==nil ? nil : BindText(d!)]) {
|
||||
try ifStep($0, SQLITE_DONE)
|
||||
return sqlite3_changes(dbPointer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: read
|
||||
|
||||
func getRecordingsLogs(_ r: Recording) -> [RecordLog]? {
|
||||
try? run(sql: "SELECT domain, COUNT() FROM recLog WHERE rid = ? GROUP BY domain;",
|
||||
bind: [BindInt64(r.id)]) {
|
||||
allRows($0) { (readText($0, 0), sqlite3_column_int($0, 1)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias RecordLog = (domain: String?, count: Int32)
|
||||
Reference in New Issue
Block a user