HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
509 lines | 24.139kb
Bogdan Timofte authored 2 weeks ago
1
import HealthKit
2
import SQLite3
3
import XCTest
4
@testable import HealthProbe
5

            
6
final class SQLiteHealthArchiveStoreTests: XCTestCase {
Bogdan Timofte authored 2 weeks ago
7
    private final class AsyncResultBox<T>: @unchecked Sendable {
8
        var result: Result<T, Error>?
9
    }
10

            
Bogdan Timofte authored 2 weeks ago
11
    private var temporaryDirectory: URL!
12

            
13
    override func setUpWithError() throws {
14
        temporaryDirectory = FileManager.default.temporaryDirectory
15
            .appending(path: "HealthProbeTests-\(UUID().uuidString)", directoryHint: .isDirectory)
16
        try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true)
17
    }
18

            
19
    override func tearDownWithError() throws {
20
        if let temporaryDirectory {
21
            try? FileManager.default.removeItem(at: temporaryDirectory)
22
        }
23
        temporaryDirectory = nil
24
    }
25

            
26
    func testFreshArchiveInitializesSchemaAndPassesIntegrity() async throws {
27
        let store = SQLiteHealthArchiveStore(databaseURL: databaseURL())
28

            
29
        let report = try await store.checkIntegrity()
30
        let records = try await store.records(for: HealthArchiveRecordRequest(
31
            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue
32
        ))
33

            
34
        XCTAssertTrue(report.passed)
35
        XCTAssertEqual(report.schemaVersion, 2)
36
        XCTAssertEqual(report.sqliteIntegrityStatus, "ok")
37
        XCTAssertEqual(report.foreignKeyIssueCount, 0)
38
        XCTAssertTrue(report.missingTableNames.isEmpty)
39
        XCTAssertTrue(report.requiredTableNames.contains("sample_visibility_ranges"))
40
        XCTAssertTrue(report.requiredTableNames.contains("daily_type_aggregates"))
41
        XCTAssertTrue(records.isEmpty)
42
    }
43

            
44
    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
45
        let url = databaseURL()
46
        try createPrototypeDatabase(at: url)
47
        let store = SQLiteHealthArchiveStore(databaseURL: url)
48

            
49
        let report = try await store.checkIntegrity()
50

            
51
        XCTAssertTrue(report.passed)
52
        XCTAssertEqual(report.schemaVersion, 2)
53
        XCTAssertTrue(report.missingTableNames.isEmpty)
54
    }
55

            
56
    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
57
        let url = databaseURL()
58
        let store = SQLiteHealthArchiveStore(databaseURL: url)
59
        let sample = makeStepCountSample()
60

            
61
        let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
62
        let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060))
63
        let records = try await store.records(for: HealthArchiveRecordRequest(
64
            sampleTypeIdentifier: sample.sampleType.identifier
65
        ))
66
        let report = try await store.checkIntegrity()
67
        let versionDebugRows = try sampleVersionDebugRows(at: url)
68

            
69
        XCTAssertEqual(firstWrite.insertedCount, 1)
70
        XCTAssertEqual(firstWrite.updatedCount, 0)
71
        XCTAssertEqual(firstWrite.unchangedCount, 0)
72
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
73
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
74
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1)
75
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
Bogdan Timofte authored 2 weeks ago
76
        XCTAssertEqual(try countRows(in: "archive_samples", at: url), 0)
Bogdan Timofte authored 2 weeks ago
77
        XCTAssertEqual(secondWrite.insertedCount, 0)
78
        XCTAssertEqual(secondWrite.updatedCount, 0)
79
        XCTAssertEqual(secondWrite.unchangedCount, 1)
80
        XCTAssertEqual(records.count, 1)
81
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
82
        XCTAssertTrue(report.passed)
83
    }
84

            
Bogdan Timofte authored 2 weeks ago
85
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
86
        let url = databaseURL()
87
        let store = SQLiteHealthArchiveStore(databaseURL: url)
88
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
89
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
90
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
91

            
92
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
93
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
94
        try await store.recordDisappearance(
95
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
96
            sampleTypeIdentifier: typeIdentifier,
97
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
98
        )
99
        let observationIDs = try observationIDs(at: url)
100
        XCTAssertEqual(observationIDs.count, 3)
101

            
102
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
103
            fromObservationID: observationIDs[0],
104
            toObservationID: observationIDs[1],
105
            sampleTypeIdentifier: typeIdentifier
106
        ))
107
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
108
            fromObservationID: observationIDs[0],
109
            toObservationID: observationIDs[1],
110
            sampleTypeIdentifier: typeIdentifier,
111
            kind: .appeared,
112
            limit: 10
113
        ))
114
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
115
            fromObservationID: observationIDs[1],
116
            toObservationID: observationIDs[2],
117
            sampleTypeIdentifier: typeIdentifier
118
        ))
119
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
120
            fromObservationID: observationIDs[1],
121
            toObservationID: observationIDs[2],
122
            sampleTypeIdentifier: typeIdentifier,
123
            kind: .disappeared,
124
            limit: 10
125
        ))
126

            
127
        XCTAssertEqual(appearedSummary.appearedCount, 1)
128
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
129
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
130
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
131
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
132
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
133
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
134
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
135
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
136
    }
137

            
Bogdan Timofte authored 2 weeks ago
138
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
139
        let url = databaseURL()
140
        let store = SQLiteHealthArchiveStore(databaseURL: url)
141
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
142
        let initialCount = 1_200
143
        let appearedCount = 180
144
        let disappearedCount = 160
145
        let pageSize = 25
146
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
147
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
148

            
149
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
150
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
151
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
152
            try await store.recordDisappearance(
153
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
154
                sampleTypeIdentifier: typeIdentifier,
155
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
156
            )
157
        }
158

            
159
        let observationIDs = try observationIDs(at: url)
160
        let firstObservationID = try XCTUnwrap(observationIDs.first)
161
        let lastObservationID = try XCTUnwrap(observationIDs.last)
162
        let queryStartedAt = Date()
163
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
164
            fromObservationID: firstObservationID,
165
            toObservationID: lastObservationID,
166
            sampleTypeIdentifier: typeIdentifier
167
        ))
168
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
169
            fromObservationID: firstObservationID,
170
            toObservationID: lastObservationID,
171
            sampleTypeIdentifier: typeIdentifier,
172
            kind: .appeared,
173
            limit: pageSize
174
        ))
175
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
176
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
177
            fromObservationID: firstObservationID,
178
            toObservationID: lastObservationID,
179
            sampleTypeIdentifier: typeIdentifier,
180
            kind: .appeared,
181
            afterCursor: RecordCursor(
182
                startDate: firstPageLastRecord.startDate,
183
                strictFingerprint: firstPageLastRecord.strictFingerprint
184
            ),
185
            limit: pageSize
186
        ))
187
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
188

            
189
        XCTAssertEqual(summary.appearedCount, appearedCount)
190
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
191
        XCTAssertEqual(summary.representationChangedCount, 0)
192
        XCTAssertEqual(firstPage.count, pageSize)
193
        XCTAssertEqual(secondPage.count, pageSize)
194
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
195
        XCTAssertLessThan(queryElapsedSeconds, 10)
196
    }
197

            
Bogdan Timofte authored 2 weeks ago
198
    func testLargeSyntheticDiffQueryMetrics() throws {
199
        let url = databaseURL()
200
        let store = SQLiteHealthArchiveStore(databaseURL: url)
201
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
202
        let initialCount = 900
203
        let appearedCount = 120
204
        let disappearedCount = 100
205
        let pageSize = 25
206
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
207
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
208

            
209
        try waitForArchiveOperation {
210
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
211
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
212
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
213
                try await store.recordDisappearance(
214
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
215
                    sampleTypeIdentifier: typeIdentifier,
216
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
217
                )
218
            }
219
        }
220

            
221
        let observationIDs = try observationIDs(at: url)
222
        let firstObservationID = try XCTUnwrap(observationIDs.first)
223
        let lastObservationID = try XCTUnwrap(observationIDs.last)
224
        let options = XCTMeasureOptions()
225
        options.iterationCount = 3
226

            
227
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
228
            do {
229
                let result = try waitForArchiveOperation {
230
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
231
                        fromObservationID: firstObservationID,
232
                        toObservationID: lastObservationID,
233
                        sampleTypeIdentifier: typeIdentifier
234
                    ))
235
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
236
                        fromObservationID: firstObservationID,
237
                        toObservationID: lastObservationID,
238
                        sampleTypeIdentifier: typeIdentifier,
239
                        kind: .appeared,
240
                        limit: pageSize
241
                    ))
242
                    return (summary, records)
243
                }
244
                XCTAssertEqual(result.0.appearedCount, appearedCount)
245
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
246
                XCTAssertEqual(result.1.count, pageSize)
247
            } catch {
248
                XCTFail("Measured archive query failed: \(error)")
249
            }
250
        }
251
    }
252

            
Bogdan Timofte authored 2 weeks ago
253
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
254
        let url = databaseURL()
255
        let store = SQLiteHealthArchiveStore(databaseURL: url)
256
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
257
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
258
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
259

            
260
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
261
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
262
        let observationIDs = try observationIDs(at: url)
263
        XCTAssertEqual(observationIDs.count, 2)
264

            
265
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
266
            fromObservationID: observationIDs[0],
267
            toObservationID: observationIDs[1],
268
            sampleTypeIdentifier: typeIdentifier,
269
            limit: 10
270
        ))
271

            
272
        XCTAssertEqual(rows.count, 1)
273
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
274
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
275
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
276
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
277
        XCTAssertEqual(rows.first?.fromValueSum, 42)
278
        XCTAssertEqual(rows.first?.toValueSum, 49)
279
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
280
    }
281

            
Bogdan Timofte authored 2 weeks ago
282
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
283
        let url = databaseURL()
284
        let store = SQLiteHealthArchiveStore(databaseURL: url)
285
        let sample = makeStepCountSample(value: 42, start: 1_000)
286
        let typeIdentifier = sample.sampleType.identifier
287

            
288
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 3_000))
289

            
290
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
291
            visibleAtObservationID: nil,
292
            sampleTypeIdentifier: typeIdentifier,
293
            limit: 10
294
        ))
295

            
296
        XCTAssertEqual(rows.count, 1)
297
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
298
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
299
        XCTAssertEqual(rows.first?.valueSum, 42)
300
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
301
    }
302

            
303
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
304
        let url = databaseURL()
305
        let store = SQLiteHealthArchiveStore(databaseURL: url)
306
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
307

            
308
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
309
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
310
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
311

            
312
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
313
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
314
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
315
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
316

            
317
        let observationIDs = try observationIDs(at: url)
318
        XCTAssertEqual(observationIDs.count, 4)
319

            
320
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
321
            fromObservationID: observationIDs[0],
322
            toObservationID: observationIDs[observationIDs.count - 1],
323
            sampleTypeIdentifier: typeIdentifier
324
        ))
325

            
326
        XCTAssertEqual(rows.count, 1)
327
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
328
        XCTAssertEqual(rows.first?.disappearedCount, 2)
329
        XCTAssertEqual(rows.first?.appearedCount, 1)
330
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
331
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
332
        XCTAssertEqual(rows.first?.fromValueSum, 30)
333
        XCTAssertEqual(rows.first?.toValueSum, 30)
334
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
335
        XCTAssertTrue(rows.first?.sourceCompatible == true)
336
    }
337

            
338
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
339
        let url = databaseURL()
340
        let store = SQLiteHealthArchiveStore(databaseURL: url)
341
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
342

            
343
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
344
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
345
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
346

            
347
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
348
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
349
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
350
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
351

            
352
        let observationIDs = try observationIDs(at: url)
353
        XCTAssertEqual(observationIDs.count, 4)
354

            
355
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
356
            fromObservationID: observationIDs[0],
357
            toObservationID: observationIDs[observationIDs.count - 1],
358
            sampleTypeIdentifier: typeIdentifier
359
        ))
360

            
361
        XCTAssertEqual(rows.count, 1)
362
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
363
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
364
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
365
        XCTAssertEqual(rows.first?.fromValueSum, 30)
366
        XCTAssertEqual(rows.first?.toValueSum, 30)
367
    }
368

            
Bogdan Timofte authored 2 weeks ago
369
    private func databaseURL() -> URL {
370
        temporaryDirectory.appending(path: "Archive.sqlite")
371
    }
372

            
373
    private func createPrototypeDatabase(at url: URL) throws {
374
        var db: OpaquePointer?
375
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
376
            sqlite3_close(db)
377
            XCTFail("Could not create prototype database")
378
            return
379
        }
380
        defer { sqlite3_close(db) }
381
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
382
        XCTAssertEqual(status, SQLITE_OK)
383
    }
384

            
385
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
386
        makeStepCountSample(value: 42, start: 1_000)
387
    }
388

            
Bogdan Timofte authored 2 weeks ago
389
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
390
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
391
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
392
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
393
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
394
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
395
    }
396

            
Bogdan Timofte authored 2 weeks ago
397
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
398
        (0..<count).map { offset in
399
            let index = startIndex + offset
400
            return makeStepCountSample(
401
                value: Double((index % 97) + 1),
402
                start: 10_000 + Double(index * 600)
403
            )
404
        }
405
    }
406

            
Bogdan Timofte authored 2 weeks ago
407
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
408
        let expectation = expectation(description: "archive operation")
409
        let box = AsyncResultBox<T>()
410

            
411
        Task {
412
            do {
413
                box.result = .success(try await operation())
414
            } catch {
415
                box.result = .failure(error)
416
            }
417
            expectation.fulfill()
418
        }
419

            
420
        wait(for: [expectation], timeout: 20)
421
        return try XCTUnwrap(box.result).get()
422
    }
423

            
Bogdan Timofte authored 2 weeks ago
424
    private func countRows(in tableName: String, at url: URL) throws -> Int {
425
        var db: OpaquePointer?
426
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
427
            sqlite3_close(db)
428
            XCTFail("Could not open test database")
429
            return 0
430
        }
431
        defer { sqlite3_close(db) }
432

            
433
        var statement: OpaquePointer?
434
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
435
            sqlite3_finalize(statement)
436
            XCTFail("Could not prepare count query")
437
            return 0
438
        }
439
        defer { sqlite3_finalize(statement) }
440

            
441
        guard sqlite3_step(statement) == SQLITE_ROW else {
442
            return 0
443
        }
444
        return Int(sqlite3_column_int(statement, 0))
445
    }
446

            
Bogdan Timofte authored 2 weeks ago
447
    private func observationIDs(at url: URL) throws -> [Int64] {
448
        var db: OpaquePointer?
449
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
450
            sqlite3_close(db)
451
            XCTFail("Could not open test database")
452
            return []
453
        }
454
        defer { sqlite3_close(db) }
455

            
456
        var statement: OpaquePointer?
457
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
458
            sqlite3_finalize(statement)
459
            XCTFail("Could not prepare observation query")
460
            return []
461
        }
462
        defer { sqlite3_finalize(statement) }
463

            
464
        var ids: [Int64] = []
465
        while sqlite3_step(statement) == SQLITE_ROW {
466
            ids.append(sqlite3_column_int64(statement, 0))
467
        }
468
        return ids
469
    }
470

            
Bogdan Timofte authored 2 weeks ago
471
    private func sampleVersionDebugRows(at url: URL) throws -> String {
472
        var db: OpaquePointer?
473
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
474
            sqlite3_close(db)
475
            return "could not open database"
476
        }
477
        defer { sqlite3_close(db) }
478

            
479
        let sql = """
480
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
481
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
482
               sr.operating_system_version, v.hk_device_id, v.metadata_id
483
        FROM sample_versions v
484
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
485
        LEFT JOIN sources src ON src.id = sr.source_id
486
        ORDER BY v.id
487
        """
488
        var statement: OpaquePointer?
489
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
490
            sqlite3_finalize(statement)
491
            return "could not prepare version debug query"
492
        }
493
        defer { sqlite3_finalize(statement) }
494

            
495
        var rows: [String] = []
496
        while sqlite3_step(statement) == SQLITE_ROW {
497
            rows.append((0..<13).map { index in
498
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
499
                    return "null"
500
                }
501
                if let text = sqlite3_column_text(statement, Int32(index)) {
502
                    return String(cString: text)
503
                }
504
                return "\(sqlite3_column_double(statement, Int32(index)))"
505
            }.joined(separator: "|"))
506
        }
507
        return rows.joined(separator: "\n")
508
    }
509
}