HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
508 lines | 24.066kb
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)
76
        XCTAssertEqual(secondWrite.insertedCount, 0)
77
        XCTAssertEqual(secondWrite.updatedCount, 0)
78
        XCTAssertEqual(secondWrite.unchangedCount, 1)
79
        XCTAssertEqual(records.count, 1)
80
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
81
        XCTAssertTrue(report.passed)
82
    }
83

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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