HealthProbe / HealthProbeTests / SQLiteHealthArchiveStoreTests.swift
Newer Older
999 lines | 47.43kb
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

            
Bogdan Timofte authored 4 days ago
44
    func testTypeCaptureStateRoundTripsAnchorAndSummary() async throws {
45
        let url = databaseURL()
46
        let store = SQLiteHealthArchiveStore(databaseURL: url)
47
        let observationID = try await store.beginObservation(
48
            observedAt: Date(timeIntervalSince1970: 2_000),
49
            triggerReason: "manual",
50
            selectedTypeSetHash: "selected-types"
51
        )
52
        let expected = HealthArchiveTypeCaptureState(
53
            sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue,
54
            count: 42,
55
            contentHash: "content-hash",
56
            earliestDate: Date(timeIntervalSince1970: 1_000),
57
            latestDate: Date(timeIntervalSince1970: 1_500),
58
            yearlyCounts: [2026: 42],
59
            anchorData: Data([0xde, 0xad, 0xbe, 0xef])
60
        )
61

            
62
        try await store.upsertTypeCaptureState(expected, observationID: observationID)
63
        let actual = try await store.typeCaptureState(sampleTypeIdentifier: HKQuantityTypeIdentifier.stepCount.rawValue)
64
        let report = try await store.checkIntegrity()
65

            
66
        XCTAssertEqual(actual, HealthArchiveTypeCaptureState(
67
            sampleTypeIdentifier: expected.sampleTypeIdentifier,
68
            observationID: observationID,
69
            count: expected.count,
70
            contentHash: expected.contentHash,
71
            earliestDate: expected.earliestDate,
72
            latestDate: expected.latestDate,
73
            yearlyCounts: expected.yearlyCounts,
74
            anchorData: expected.anchorData
75
        ))
76
        XCTAssertTrue(report.passed)
77
        XCTAssertEqual(try countRows(in: "type_capture_states", at: url), 1)
78
    }
79

            
Bogdan Timofte authored 2 weeks ago
80
    func testPrototypeArchiveIsResetAndReinitializedAsV2() async throws {
81
        let url = databaseURL()
82
        try createPrototypeDatabase(at: url)
83
        let store = SQLiteHealthArchiveStore(databaseURL: url)
84

            
85
        let report = try await store.checkIntegrity()
86

            
87
        XCTAssertTrue(report.passed)
88
        XCTAssertEqual(report.schemaVersion, 2)
89
        XCTAssertTrue(report.missingTableNames.isEmpty)
90
    }
91

            
92
    func testRepeatedSamplePageDoesNotDuplicateIdentityOrVersion() async throws {
93
        let url = databaseURL()
94
        let store = SQLiteHealthArchiveStore(databaseURL: url)
95
        let sample = makeStepCountSample()
96

            
97
        let firstWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
98
        let secondWrite = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_060))
99
        let records = try await store.records(for: HealthArchiveRecordRequest(
100
            sampleTypeIdentifier: sample.sampleType.identifier
101
        ))
102
        let report = try await store.checkIntegrity()
103
        let versionDebugRows = try sampleVersionDebugRows(at: url)
Bogdan Timofte authored a week ago
104
        let visibilityDebugRows = try visibilityRangeDebugRows(at: url)
Bogdan Timofte authored 2 weeks ago
105

            
106
        XCTAssertEqual(firstWrite.insertedCount, 1)
107
        XCTAssertEqual(firstWrite.updatedCount, 0)
108
        XCTAssertEqual(firstWrite.unchangedCount, 0)
109
        XCTAssertEqual(try countRows(in: "samples", at: url), 1)
110
        XCTAssertEqual(try countRows(in: "sample_versions", at: url), 1, versionDebugRows)
Bogdan Timofte authored 5 days ago
111
        XCTAssertEqual(try countRows(in: "sample_observation_events", at: url), 1)
112
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE event_kind = 'appeared'", at: url), 1)
113
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE event_kind = 'verified'", at: url), 0)
Bogdan Timofte authored a week ago
114
        XCTAssertEqual(try countRows(in: "sample_visibility_ranges", at: url), 1, visibilityDebugRows)
Bogdan Timofte authored 2 weeks ago
115
        XCTAssertEqual(try countRows(in: "source_revisions", at: url), 1)
Bogdan Timofte authored 2 weeks ago
116
        XCTAssertFalse(try tableExists("archive_samples", at: url))
Bogdan Timofte authored 2 weeks ago
117
        XCTAssertEqual(secondWrite.insertedCount, 0)
118
        XCTAssertEqual(secondWrite.updatedCount, 0)
119
        XCTAssertEqual(secondWrite.unchangedCount, 1)
120
        XCTAssertEqual(records.count, 1)
121
        XCTAssertEqual(records.first?.displayValue, "42.0 count")
122
        XCTAssertTrue(report.passed)
123
    }
124

            
Bogdan Timofte authored 2 weeks ago
125
    func testVerificationUsesArchiveV2TablesWithoutLegacyMirror() async throws {
126
        let url = databaseURL()
127
        let store = SQLiteHealthArchiveStore(databaseURL: url)
128
        let sample = makeStepCountSample()
129

            
130
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
131
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
132
        let observationIDs = try observationIDs(at: url)
133

            
134
        XCTAssertEqual(observationIDs.count, 2)
Bogdan Timofte authored 2 weeks ago
135
        XCTAssertEqual(try countRows(in: "observation_type_runs", at: url), 2)
136
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationIDs[0]) AND inserted_event_count = 1", at: url), 1)
Bogdan Timofte authored 2 weeks ago
137
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1)
Bogdan Timofte authored 5 days ago
138
        XCTAssertEqual(try countRows(in: "daily_type_aggregates WHERE observation_id = \(observationIDs[1]) AND visible_record_count = 1", at: url), 1)
Bogdan Timofte authored 2 weeks ago
139
        XCTAssertFalse(try tableExists("archive_samples", at: url))
140
    }
141

            
Bogdan Timofte authored 4 days ago
142
    func testUnchangedVerificationCopiesPreviousSummaryWithoutNewEvents() async throws {
143
        let url = databaseURL()
144
        let store = SQLiteHealthArchiveStore(databaseURL: url)
145
        let sample = makeStepCountSample()
146

            
147
        _ = try await store.upsertSamples([sample], observedAt: Date(timeIntervalSince1970: 2_000))
148
        try await store.markVerification(sampleType: sample.sampleType, verifiedAt: Date(timeIntervalSince1970: 2_060))
149
        let unchangedObservationID = try await store.beginObservation(
150
            observedAt: Date(timeIntervalSince1970: 2_120),
151
            triggerReason: "manual",
152
            selectedTypeSetHash: "selected-types"
153
        )
154
        try await store.markUnchangedVerification(
155
            sampleType: sample.sampleType,
156
            verifiedAt: Date(timeIntervalSince1970: 2_120),
157
            observationID: unchangedObservationID
158
        )
159
        try await store.finishObservation(
160
            observationID: unchangedObservationID,
161
            status: "completed",
162
            endedAt: Date(timeIntervalSince1970: 2_121)
163
        )
164

            
165
        XCTAssertEqual(try countRows(in: "sample_observation_events", at: url), 1)
166
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(unchangedObservationID) AND inserted_event_count = 0 AND deleted_event_count = 0 AND verified_visible_count = 1", at: url), 1)
167
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(unchangedObservationID) AND visible_record_count = 1 AND appeared_count = 0 AND disappeared_count = 0 AND representation_changed_count = 0", at: url), 1)
168
        XCTAssertEqual(try countRows(in: "daily_type_aggregates WHERE observation_id = \(unchangedObservationID) AND visible_record_count = 1", at: url), 1)
169
        XCTAssertFalse(try tableExists("archive_samples", at: url))
170
    }
171

            
Bogdan Timofte authored 3 days ago
172
    func testChangedVerificationReplacesOnlyAffectedDailyAggregateBuckets() async throws {
173
        let url = databaseURL()
174
        let store = SQLiteHealthArchiveStore(databaseURL: url)
175
        let firstDaySample = makeStepCountSample(value: 10, start: 1_000)
176
        let secondDaySample = makeStepCountSample(value: 20, start: 90_000)
177
        let secondDayDeltaSample = makeStepCountSample(value: 30, start: 91_000)
178

            
179
        _ = try await store.upsertSamples(
180
            [firstDaySample, secondDaySample],
181
            observedAt: Date(timeIntervalSince1970: 95_000)
182
        )
183
        try await store.markVerification(
184
            sampleType: firstDaySample.sampleType,
185
            verifiedAt: Date(timeIntervalSince1970: 95_060)
186
        )
187
        let changedObservationID = try await store.beginObservation(
188
            observedAt: Date(timeIntervalSince1970: 96_000),
189
            triggerReason: "manual",
190
            selectedTypeSetHash: "selected-types"
191
        )
192

            
193
        _ = try await store.upsertSamples(
194
            [secondDayDeltaSample],
195
            observedAt: Date(timeIntervalSince1970: 96_000),
196
            observationID: changedObservationID
197
        )
198
        try await store.markVerification(
199
            sampleType: firstDaySample.sampleType,
200
            verifiedAt: Date(timeIntervalSince1970: 96_000),
201
            observationID: changedObservationID
202
        )
203
        try await store.finishObservation(
204
            observationID: changedObservationID,
205
            status: "completed",
206
            endedAt: Date(timeIntervalSince1970: 96_010)
207
        )
208

            
209
        XCTAssertEqual(
210
            try countRows(in: "daily_type_aggregates WHERE observation_id = \(changedObservationID)", at: url),
211
            2
212
        )
213
        XCTAssertEqual(
214
            try countRows(in: "daily_type_aggregates WHERE observation_id = \(changedObservationID) AND visible_record_count = 1", at: url),
215
            1
216
        )
217
        XCTAssertEqual(
218
            try countRows(in: "daily_type_aggregates WHERE observation_id = \(changedObservationID) AND visible_record_count = 2", at: url),
219
            1
220
        )
221
        XCTAssertEqual(
222
            try countRows(in: "observation_type_summaries WHERE observation_id = \(changedObservationID) AND visible_record_count = 3 AND appeared_count = 1", at: url),
223
            1
224
        )
225
    }
226

            
Bogdan Timofte authored 3 days ago
227
    func testChangedVerificationKeepsSummaryCorrectWhenLatestAndMaxAreReplaced() async throws {
228
        let url = databaseURL()
229
        let store = SQLiteHealthArchiveStore(databaseURL: url)
230
        let oldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_300)
231
        let oldLatestMaxSample = makeStepCountSample(value: 20, start: 4_000, end: 4_300)
232
        let newLatestMaxSample = makeStepCountSample(value: 25, start: 5_000, end: 5_300)
233
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
234

            
235
        _ = try await store.upsertSamples(
236
            [oldSample, oldLatestMaxSample],
237
            observedAt: Date(timeIntervalSince1970: 6_000)
238
        )
239
        try await store.markVerification(
240
            sampleType: oldSample.sampleType,
241
            verifiedAt: Date(timeIntervalSince1970: 6_060)
242
        )
243
        let changedObservationID = try await store.beginObservation(
244
            observedAt: Date(timeIntervalSince1970: 6_120),
245
            triggerReason: "manual",
246
            selectedTypeSetHash: "selected-types"
247
        )
248

            
249
        _ = try await store.upsertSamples(
250
            [newLatestMaxSample],
251
            observedAt: Date(timeIntervalSince1970: 6_120),
252
            observationID: changedObservationID
253
        )
254
        try await store.recordDisappearance(
255
            sampleUUIDHash: HashService.sampleUUIDHash(oldLatestMaxSample.uuid.uuidString),
256
            sampleTypeIdentifier: typeIdentifier,
257
            observedMissingAt: Date(timeIntervalSince1970: 6_120),
258
            observationID: changedObservationID
259
        )
260
        try await store.markVerification(
261
            sampleType: oldSample.sampleType,
262
            verifiedAt: Date(timeIntervalSince1970: 6_120),
263
            observationID: changedObservationID
264
        )
265

            
266
        let summary = try typeSummary(observationID: changedObservationID, at: url)
267

            
268
        XCTAssertEqual(summary.visibleRecordCount, 2)
269
        XCTAssertEqual(summary.appearedCount, 1)
270
        XCTAssertEqual(summary.disappearedCount, 1)
271
        XCTAssertEqual(summary.earliestStartDate, oldSample.startDate.timeIntervalSince1970, accuracy: 0.000_001)
272
        XCTAssertEqual(summary.latestEndDate, newLatestMaxSample.endDate.timeIntervalSince1970, accuracy: 0.000_001)
273
        XCTAssertEqual(summary.valueSum, 35, accuracy: 0.000_001)
274
        XCTAssertEqual(summary.valueMax, 25, accuracy: 0.000_001)
275
    }
276

            
277
    func testChangedVerificationKeepsSummaryCorrectWhenLatestIsRemovedWithoutReplacement() async throws {
278
        let url = databaseURL()
279
        let store = SQLiteHealthArchiveStore(databaseURL: url)
280
        let remainingSample = makeStepCountSample(value: 10, start: 1_000, end: 1_300)
281
        let removedLatestSample = makeStepCountSample(value: 20, start: 4_000, end: 4_300)
282
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
283

            
284
        _ = try await store.upsertSamples(
285
            [remainingSample, removedLatestSample],
286
            observedAt: Date(timeIntervalSince1970: 6_000)
287
        )
288
        try await store.markVerification(
289
            sampleType: remainingSample.sampleType,
290
            verifiedAt: Date(timeIntervalSince1970: 6_060)
291
        )
292
        let changedObservationID = try await store.beginObservation(
293
            observedAt: Date(timeIntervalSince1970: 6_120),
294
            triggerReason: "manual",
295
            selectedTypeSetHash: "selected-types"
296
        )
297

            
298
        try await store.recordDisappearance(
299
            sampleUUIDHash: HashService.sampleUUIDHash(removedLatestSample.uuid.uuidString),
300
            sampleTypeIdentifier: typeIdentifier,
301
            observedMissingAt: Date(timeIntervalSince1970: 6_120),
302
            observationID: changedObservationID
303
        )
304
        try await store.markVerification(
305
            sampleType: remainingSample.sampleType,
306
            verifiedAt: Date(timeIntervalSince1970: 6_120),
307
            observationID: changedObservationID
308
        )
309

            
310
        let summary = try typeSummary(observationID: changedObservationID, at: url)
311

            
312
        XCTAssertEqual(summary.visibleRecordCount, 1)
313
        XCTAssertEqual(summary.appearedCount, 0)
314
        XCTAssertEqual(summary.disappearedCount, 1)
315
        XCTAssertEqual(summary.earliestStartDate, remainingSample.startDate.timeIntervalSince1970, accuracy: 0.000_001)
316
        XCTAssertEqual(summary.latestEndDate, remainingSample.endDate.timeIntervalSince1970, accuracy: 0.000_001)
317
        XCTAssertEqual(summary.valueSum, 10, accuracy: 0.000_001)
318
        XCTAssertEqual(summary.valueMax, 10, accuracy: 0.000_001)
319
    }
320

            
Bogdan Timofte authored 2 weeks ago
321
    func testDiffSummaryAndRecordsBetweenObservationsUseSQLVisibility() async throws {
322
        let url = databaseURL()
323
        let store = SQLiteHealthArchiveStore(databaseURL: url)
324
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
325
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
326
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
327

            
328
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
329
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
330
        try await store.recordDisappearance(
331
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
332
            sampleTypeIdentifier: typeIdentifier,
333
            observedMissingAt: Date(timeIntervalSince1970: 3_120)
334
        )
335
        let observationIDs = try observationIDs(at: url)
336
        XCTAssertEqual(observationIDs.count, 3)
337

            
338
        let appearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
339
            fromObservationID: observationIDs[0],
340
            toObservationID: observationIDs[1],
341
            sampleTypeIdentifier: typeIdentifier
342
        ))
343
        let appearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
344
            fromObservationID: observationIDs[0],
345
            toObservationID: observationIDs[1],
346
            sampleTypeIdentifier: typeIdentifier,
347
            kind: .appeared,
348
            limit: 10
349
        ))
350
        let disappearedSummary = try await store.diffSummary(HealthArchiveDiffRequest(
351
            fromObservationID: observationIDs[1],
352
            toObservationID: observationIDs[2],
353
            sampleTypeIdentifier: typeIdentifier
354
        ))
355
        let disappearedRecords = try await store.diffRecords(HealthArchiveDiffRecordRequest(
356
            fromObservationID: observationIDs[1],
357
            toObservationID: observationIDs[2],
358
            sampleTypeIdentifier: typeIdentifier,
359
            kind: .disappeared,
360
            limit: 10
361
        ))
362

            
363
        XCTAssertEqual(appearedSummary.appearedCount, 1)
364
        XCTAssertEqual(appearedSummary.disappearedCount, 0)
365
        XCTAssertEqual(appearedSummary.representationChangedCount, 0)
366
        XCTAssertEqual(appearedRecords.map(\.displayValue), ["7.0 count"])
367
        XCTAssertEqual(disappearedSummary.appearedCount, 0)
368
        XCTAssertEqual(disappearedSummary.disappearedCount, 1)
369
        XCTAssertEqual(disappearedSummary.representationChangedCount, 0)
370
        XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"])
371
        XCTAssertNotNil(disappearedRecords.first?.disappearedAt)
Bogdan Timofte authored a week ago
372

            
373
        let exportRequest = HealthArchiveReportRequest(
374
            reportID: UUID(),
375
            title: "Disappeared Step Count Export",
376
            typeIdentifierFilter: typeIdentifier,
377
            diffFromObservationID: observationIDs[1],
378
            diffToObservationID: observationIDs[2],
379
            diffKind: .disappeared
380
        )
381
        let exportPreview = try await store.exportPreview(exportRequest)
382
        let exportURL = try await store.exportReport(exportRequest)
383

            
384
        XCTAssertEqual(exportPreview.estimatedRecordCount, 1)
385
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
386
        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 1 AND from_observation_id = \(observationIDs[1]) AND to_observation_id = \(observationIDs[2])", at: url), 1)
387
    }
388

            
389
    func testExportPreviewAndReportUseSQLiteManifest() async throws {
390
        let url = databaseURL()
391
        let store = SQLiteHealthArchiveStore(databaseURL: url)
392
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
393
        let samples = [
394
            makeStepCountSample(value: 42, start: 1_000),
395
            makeStepCountSample(value: 7, start: 2_000)
396
        ]
397

            
398
        _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 3_000))
399

            
400
        let request = HealthArchiveReportRequest(
401
            reportID: UUID(),
402
            title: "Step Count Export",
403
            typeIdentifierFilter: typeIdentifier
404
        )
405
        let preview = try await store.exportPreview(request)
406
        let exportURL = try await store.exportReport(request)
407

            
408
        XCTAssertEqual(preview.estimatedRecordCount, 2)
409
        XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path))
410
        XCTAssertEqual(try countRows(in: "export_manifests", at: url), 1)
411
        XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 2", at: url), 1)
412

            
413
        let cache = try CoreDataArchiveCacheStore(inMemory: true)
414
        let rebuild = try cache.rebuild(fromArchiveAt: url)
415
        XCTAssertEqual(rebuild.exportManifestRows, 1)
Bogdan Timofte authored 2 weeks ago
416
    }
417

            
Bogdan Timofte authored 2 weeks ago
418
    func testGroupedObservationKeepsPageWritesDeletesAndVerificationTogether() async throws {
419
        let url = databaseURL()
420
        let store = SQLiteHealthArchiveStore(databaseURL: url)
421
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
422
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
423
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
424

            
425
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
426
        let observationID = try await store.beginObservation(
427
            observedAt: Date(timeIntervalSince1970: 3_060),
428
            triggerReason: "manual",
429
            selectedTypeSetHash: "selected-types"
430
        )
431
        _ = try await store.upsertSamples(
432
            [secondSample],
433
            observedAt: Date(timeIntervalSince1970: 3_060),
434
            observationID: observationID
435
        )
436
        try await store.recordDisappearance(
437
            sampleUUIDHash: HashService.sampleUUIDHash(firstSample.uuid.uuidString),
438
            sampleTypeIdentifier: typeIdentifier,
439
            observedMissingAt: Date(timeIntervalSince1970: 3_060),
440
            observationID: observationID
441
        )
Bogdan Timofte authored a week ago
442
        XCTAssertEqual(
443
            try countRows(
444
                in: "observation_type_summaries WHERE observation_id = \(observationID)",
445
                at: url
446
            ),
447
            0
448
        )
449
        XCTAssertEqual(
450
            try countRows(
451
                in: "daily_type_aggregates WHERE observation_id = \(observationID)",
452
                at: url
453
            ),
454
            0
455
        )
Bogdan Timofte authored 2 weeks ago
456
        try await store.markVerification(
457
            sampleType: secondSample.sampleType,
458
            verifiedAt: Date(timeIntervalSince1970: 3_060),
459
            observationID: observationID
460
        )
461
        try await store.finishObservation(
462
            observationID: observationID,
463
            status: "completed",
464
            endedAt: Date(timeIntervalSince1970: 3_070)
465
        )
466

            
467
        let observationIDs = try observationIDs(at: url)
468
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
469
            fromObservationID: observationIDs[0],
470
            toObservationID: observationID,
471
            sampleTypeIdentifier: typeIdentifier
472
        ))
473

            
474
        XCTAssertEqual(observationIDs.count, 2)
475
        XCTAssertEqual(summary.appearedCount, 1)
476
        XCTAssertEqual(summary.disappearedCount, 1)
477
        XCTAssertEqual(try countRows(in: "observations WHERE id = \(observationID) AND status = 'completed' AND selected_type_set_hash = 'selected-types'", at: url), 1)
478
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID)", at: url), 2)
479
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationID) AND inserted_event_count = 1 AND deleted_event_count = 1 AND verified_visible_count = 1 AND status = 'completed'", at: url), 1)
Bogdan Timofte authored a week ago
480
        XCTAssertEqual(try countRows(in: "observation_type_summaries WHERE observation_id = \(observationID) AND visible_record_count = 1", at: url), 1)
481
        XCTAssertGreaterThan(try countRows(in: "daily_type_aggregates WHERE observation_id = \(observationID)", at: url), 0)
Bogdan Timofte authored 2 weeks ago
482
    }
483

            
Bogdan Timofte authored a week ago
484
    func testGroupedObservationCanBatchDeletedObjectsInSingleRun() async throws {
485
        let url = databaseURL()
486
        let store = SQLiteHealthArchiveStore(databaseURL: url)
487
        let samples = [
488
            makeStepCountSample(value: 42, start: 1_000),
489
            makeStepCountSample(value: 7, start: 2_000),
490
            makeStepCountSample(value: 9, start: 3_000)
491
        ]
492
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
493

            
494
        _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 4_000))
495
        let observationID = try await store.beginObservation(
496
            observedAt: Date(timeIntervalSince1970: 4_060),
497
            triggerReason: "manual",
498
            selectedTypeSetHash: "selected-types"
499
        )
500
        let deletedCount = try await store.recordDisappearances(
501
            sampleUUIDHashes: samples.map { HashService.sampleUUIDHash($0.uuid.uuidString) },
502
            sampleTypeIdentifier: typeIdentifier,
503
            observedMissingAt: Date(timeIntervalSince1970: 4_060),
504
            observationID: observationID
505
        )
506
        try await store.markVerification(
507
            sampleType: samples[0].sampleType,
508
            verifiedAt: Date(timeIntervalSince1970: 4_060),
509
            observationID: observationID
510
        )
511

            
512
        XCTAssertEqual(deletedCount, 3)
513
        XCTAssertEqual(try countRows(in: "sample_observation_events WHERE observation_id = \(observationID) AND event_kind = 'disappeared'", at: url), 3)
514
        XCTAssertEqual(try countRows(in: "observation_type_runs WHERE observation_id = \(observationID) AND deleted_event_count = 3 AND status = 'completed'", at: url), 1)
515
    }
516

            
Bogdan Timofte authored 2 weeks ago
517
    func testLargeSyntheticDiffReturnsCountsAndBoundedPages() async throws {
518
        let url = databaseURL()
519
        let store = SQLiteHealthArchiveStore(databaseURL: url)
520
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
521
        let initialCount = 1_200
522
        let appearedCount = 180
523
        let disappearedCount = 160
524
        let pageSize = 25
525
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
526
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
527

            
528
        _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_000_000))
529
        _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_000_060))
530
        for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
531
            try await store.recordDisappearance(
532
                sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
533
                sampleTypeIdentifier: typeIdentifier,
534
                observedMissingAt: Date(timeIntervalSince1970: 1_000_120 + Double(offset))
535
            )
536
        }
537

            
538
        let observationIDs = try observationIDs(at: url)
539
        let firstObservationID = try XCTUnwrap(observationIDs.first)
540
        let lastObservationID = try XCTUnwrap(observationIDs.last)
541
        let queryStartedAt = Date()
542
        let summary = try await store.diffSummary(HealthArchiveDiffRequest(
543
            fromObservationID: firstObservationID,
544
            toObservationID: lastObservationID,
545
            sampleTypeIdentifier: typeIdentifier
546
        ))
547
        let firstPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
548
            fromObservationID: firstObservationID,
549
            toObservationID: lastObservationID,
550
            sampleTypeIdentifier: typeIdentifier,
551
            kind: .appeared,
552
            limit: pageSize
553
        ))
554
        let firstPageLastRecord = try XCTUnwrap(firstPage.last)
555
        let secondPage = try await store.diffRecords(HealthArchiveDiffRecordRequest(
556
            fromObservationID: firstObservationID,
557
            toObservationID: lastObservationID,
558
            sampleTypeIdentifier: typeIdentifier,
559
            kind: .appeared,
560
            afterCursor: RecordCursor(
561
                startDate: firstPageLastRecord.startDate,
562
                strictFingerprint: firstPageLastRecord.strictFingerprint
563
            ),
564
            limit: pageSize
565
        ))
566
        let queryElapsedSeconds = Date().timeIntervalSince(queryStartedAt)
567

            
568
        XCTAssertEqual(summary.appearedCount, appearedCount)
569
        XCTAssertEqual(summary.disappearedCount, disappearedCount)
570
        XCTAssertEqual(summary.representationChangedCount, 0)
571
        XCTAssertEqual(firstPage.count, pageSize)
572
        XCTAssertEqual(secondPage.count, pageSize)
573
        XCTAssertTrue(Set(firstPage.map(\.id)).isDisjoint(with: Set(secondPage.map(\.id))))
574
        XCTAssertLessThan(queryElapsedSeconds, 10)
575
    }
576

            
Bogdan Timofte authored 2 weeks ago
577
    func testLargeSyntheticDiffQueryMetrics() throws {
578
        let url = databaseURL()
579
        let store = SQLiteHealthArchiveStore(databaseURL: url)
580
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
581
        let initialCount = 900
582
        let appearedCount = 120
583
        let disappearedCount = 100
584
        let pageSize = 25
585
        let initialSamples = makeStepCountSamples(count: initialCount, startIndex: 0)
586
        let appearedSamples = makeStepCountSamples(count: appearedCount, startIndex: initialCount)
587

            
588
        try waitForArchiveOperation {
589
            _ = try await store.upsertSamples(initialSamples, observedAt: Date(timeIntervalSince1970: 1_200_000))
590
            _ = try await store.upsertSamples(initialSamples + appearedSamples, observedAt: Date(timeIntervalSince1970: 1_200_060))
591
            for (offset, sample) in initialSamples.prefix(disappearedCount).enumerated() {
592
                try await store.recordDisappearance(
593
                    sampleUUIDHash: HashService.sampleUUIDHash(sample.uuid.uuidString),
594
                    sampleTypeIdentifier: typeIdentifier,
595
                    observedMissingAt: Date(timeIntervalSince1970: 1_200_120 + Double(offset))
596
                )
597
            }
598
        }
599

            
600
        let observationIDs = try observationIDs(at: url)
601
        let firstObservationID = try XCTUnwrap(observationIDs.first)
602
        let lastObservationID = try XCTUnwrap(observationIDs.last)
603
        let options = XCTMeasureOptions()
604
        options.iterationCount = 3
605

            
606
        measure(metrics: [XCTClockMetric(), XCTMemoryMetric()], options: options) {
607
            do {
608
                let result = try waitForArchiveOperation {
609
                    let summary = try await store.diffSummary(HealthArchiveDiffRequest(
610
                        fromObservationID: firstObservationID,
611
                        toObservationID: lastObservationID,
612
                        sampleTypeIdentifier: typeIdentifier
613
                    ))
614
                    let records = try await store.diffRecords(HealthArchiveDiffRecordRequest(
615
                        fromObservationID: firstObservationID,
616
                        toObservationID: lastObservationID,
617
                        sampleTypeIdentifier: typeIdentifier,
618
                        kind: .appeared,
619
                        limit: pageSize
620
                    ))
621
                    return (summary, records)
622
                }
623
                XCTAssertEqual(result.0.appearedCount, appearedCount)
624
                XCTAssertEqual(result.0.disappearedCount, disappearedCount)
625
                XCTAssertEqual(result.1.count, pageSize)
626
            } catch {
627
                XCTFail("Measured archive query failed: \(error)")
628
            }
629
        }
630
    }
631

            
Bogdan Timofte authored 2 weeks ago
632
    func testAggregateComparisonUsesMaterializedDailyAggregates() async throws {
633
        let url = databaseURL()
634
        let store = SQLiteHealthArchiveStore(databaseURL: url)
635
        let firstSample = makeStepCountSample(value: 42, start: 1_000)
636
        let secondSample = makeStepCountSample(value: 7, start: 2_000)
637
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
638

            
639
        _ = try await store.upsertSamples([firstSample], observedAt: Date(timeIntervalSince1970: 3_000))
640
        _ = try await store.upsertSamples([firstSample, secondSample], observedAt: Date(timeIntervalSince1970: 3_060))
641
        let observationIDs = try observationIDs(at: url)
642
        XCTAssertEqual(observationIDs.count, 2)
643

            
644
        let rows = try await store.aggregateComparison(HealthArchiveAggregateComparisonRequest(
645
            fromObservationID: observationIDs[0],
646
            toObservationID: observationIDs[1],
647
            sampleTypeIdentifier: typeIdentifier,
648
            limit: 10
649
        ))
650

            
651
        XCTAssertEqual(rows.count, 1)
652
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
653
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 1)
654
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 2)
655
        XCTAssertEqual(rows.first?.visibleRecordDelta, 1)
656
        XCTAssertEqual(rows.first?.fromValueSum, 42)
657
        XCTAssertEqual(rows.first?.toValueSum, 49)
658
        XCTAssertEqual(rows.first?.valueSumDelta, 7)
659
    }
660

            
Bogdan Timofte authored 2 weeks ago
661
    func testSourceProvenanceBreakdownReturnsVisibleSourceRows() async throws {
662
        let url = databaseURL()
663
        let store = SQLiteHealthArchiveStore(databaseURL: url)
664
        let sample = makeStepCountSample(value: 42, start: 1_000)
665
        let typeIdentifier = sample.sampleType.identifier
666

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

            
669
        let rows = try await store.sourceProvenanceBreakdown(HealthArchiveSourceProvenanceRequest(
670
            visibleAtObservationID: nil,
671
            sampleTypeIdentifier: typeIdentifier,
672
            limit: 10
673
        ))
674

            
675
        XCTAssertEqual(rows.count, 1)
676
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
677
        XCTAssertEqual(rows.first?.visibleRecordCount, 1)
678
        XCTAssertEqual(rows.first?.valueSum, 42)
679
        XCTAssertEqual(rows.first?.sourceBundleIdentifier, sample.sourceRevision.source.bundleIdentifier)
680
    }
681

            
682
    func testConsolidationEvidenceClassifiesStableCountDropAsConsolidationLikely() async throws {
683
        let url = databaseURL()
684
        let store = SQLiteHealthArchiveStore(databaseURL: url)
685
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
686

            
687
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
688
        let secondOldSample = makeStepCountSample(value: 20, start: 1_600, end: 2_000)
689
        let consolidatedSample = makeStepCountSample(value: 30, start: 1_000, end: 2_500)
690

            
691
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
692
        _ = try await store.upsertSamples([consolidatedSample], observedAt: Date(timeIntervalSince1970: 3_060))
693
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
694
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
695

            
696
        let observationIDs = try observationIDs(at: url)
697
        XCTAssertEqual(observationIDs.count, 4)
698

            
699
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
700
            fromObservationID: observationIDs[0],
701
            toObservationID: observationIDs[observationIDs.count - 1],
702
            sampleTypeIdentifier: typeIdentifier
703
        ))
704

            
705
        XCTAssertEqual(rows.count, 1)
706
        XCTAssertEqual(rows.first?.sampleTypeIdentifier, typeIdentifier)
707
        XCTAssertEqual(rows.first?.disappearedCount, 2)
708
        XCTAssertEqual(rows.first?.appearedCount, 1)
709
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
710
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
711
        XCTAssertEqual(rows.first?.fromValueSum, 30)
712
        XCTAssertEqual(rows.first?.toValueSum, 30)
713
        XCTAssertEqual(rows.first?.label, "consolidation_likely")
714
        XCTAssertTrue(rows.first?.sourceCompatible == true)
715
    }
716

            
717
    func testConsolidationEvidenceClassifiesDenseStableShiftAsAggregateChanged() async throws {
718
        let url = databaseURL()
719
        let store = SQLiteHealthArchiveStore(databaseURL: url)
720
        let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue
721

            
722
        let firstOldSample = makeStepCountSample(value: 10, start: 1_000, end: 1_500)
723
        let secondOldSample = makeStepCountSample(value: 20, start: 5_000, end: 6_000)
724
        let denseSample = makeStepCountSample(value: 30, start: 1_000, end: 1_200)
725

            
726
        _ = try await store.upsertSamples([firstOldSample, secondOldSample], observedAt: Date(timeIntervalSince1970: 3_000))
727
        _ = try await store.upsertSamples([denseSample], observedAt: Date(timeIntervalSince1970: 3_060))
728
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(firstOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_120))
729
        try await store.recordDisappearance(sampleUUIDHash: HashService.sampleUUIDHash(secondOldSample.uuid.uuidString), sampleTypeIdentifier: typeIdentifier, observedMissingAt: Date(timeIntervalSince1970: 3_180))
730

            
731
        let observationIDs = try observationIDs(at: url)
732
        XCTAssertEqual(observationIDs.count, 4)
733

            
734
        let rows = try await store.consolidationEvidence(HealthArchiveConsolidationEvidenceRequest(
735
            fromObservationID: observationIDs[0],
736
            toObservationID: observationIDs[observationIDs.count - 1],
737
            sampleTypeIdentifier: typeIdentifier
738
        ))
739

            
740
        XCTAssertEqual(rows.count, 1)
741
        XCTAssertEqual(rows.first?.label, "aggregate_changed")
742
        XCTAssertEqual(rows.first?.fromVisibleRecordCount, 2)
743
        XCTAssertEqual(rows.first?.toVisibleRecordCount, 1)
744
        XCTAssertEqual(rows.first?.fromValueSum, 30)
745
        XCTAssertEqual(rows.first?.toValueSum, 30)
746
    }
747

            
Bogdan Timofte authored 2 weeks ago
748
    private func databaseURL() -> URL {
749
        temporaryDirectory.appending(path: "Archive.sqlite")
750
    }
751

            
752
    private func createPrototypeDatabase(at url: URL) throws {
753
        var db: OpaquePointer?
754
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
755
            sqlite3_close(db)
756
            XCTFail("Could not create prototype database")
757
            return
758
        }
759
        defer { sqlite3_close(db) }
760
        let status = sqlite3_exec(db, "CREATE TABLE archive_samples (id TEXT PRIMARY KEY)", nil, nil, nil)
761
        XCTAssertEqual(status, SQLITE_OK)
762
    }
763

            
764
    private func makeStepCountSample() -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
765
        makeStepCountSample(value: 42, start: 1_000)
766
    }
767

            
Bogdan Timofte authored 2 weeks ago
768
    private func makeStepCountSample(value: Double, start: TimeInterval, end: TimeInterval? = nil) -> HKQuantitySample {
Bogdan Timofte authored 2 weeks ago
769
        let quantityType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
Bogdan Timofte authored 2 weeks ago
770
        let quantity = HKQuantity(unit: .count(), doubleValue: value)
771
        let startDate = Date(timeIntervalSince1970: start)
Bogdan Timofte authored 2 weeks ago
772
        let endDate = Date(timeIntervalSince1970: end ?? (start + 300))
Bogdan Timofte authored 2 weeks ago
773
        return HKQuantitySample(type: quantityType, quantity: quantity, start: startDate, end: endDate)
Bogdan Timofte authored 2 weeks ago
774
    }
775

            
Bogdan Timofte authored 2 weeks ago
776
    private func makeStepCountSamples(count: Int, startIndex: Int) -> [HKQuantitySample] {
777
        (0..<count).map { offset in
778
            let index = startIndex + offset
779
            return makeStepCountSample(
780
                value: Double((index % 97) + 1),
781
                start: 10_000 + Double(index * 600)
782
            )
783
        }
784
    }
785

            
Bogdan Timofte authored 2 weeks ago
786
    private func waitForArchiveOperation<T>(_ operation: @escaping () async throws -> T) throws -> T {
787
        let expectation = expectation(description: "archive operation")
788
        let box = AsyncResultBox<T>()
789

            
790
        Task {
791
            do {
792
                box.result = .success(try await operation())
793
            } catch {
794
                box.result = .failure(error)
795
            }
796
            expectation.fulfill()
797
        }
798

            
799
        wait(for: [expectation], timeout: 20)
800
        return try XCTUnwrap(box.result).get()
801
    }
802

            
Bogdan Timofte authored 2 weeks ago
803
    private func countRows(in tableName: String, at url: URL) throws -> Int {
804
        var db: OpaquePointer?
805
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
806
            sqlite3_close(db)
807
            XCTFail("Could not open test database")
808
            return 0
809
        }
810
        defer { sqlite3_close(db) }
811

            
812
        var statement: OpaquePointer?
813
        guard sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM \(tableName)", -1, &statement, nil) == SQLITE_OK else {
814
            sqlite3_finalize(statement)
815
            XCTFail("Could not prepare count query")
816
            return 0
817
        }
818
        defer { sqlite3_finalize(statement) }
819

            
820
        guard sqlite3_step(statement) == SQLITE_ROW else {
821
            return 0
822
        }
823
        return Int(sqlite3_column_int(statement, 0))
824
    }
825

            
Bogdan Timofte authored 2 weeks ago
826
    private func tableExists(_ tableName: String, at url: URL) throws -> Bool {
827
        var db: OpaquePointer?
828
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
829
            sqlite3_close(db)
830
            XCTFail("Could not open test database")
831
            return false
832
        }
833
        defer { sqlite3_close(db) }
834

            
835
        let sql = """
836
        SELECT 1
837
        FROM sqlite_master
838
        WHERE type = 'table' AND name = ?
839
        LIMIT 1
840
        """
841
        var statement: OpaquePointer?
842
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
843
            sqlite3_finalize(statement)
844
            XCTFail("Could not prepare table existence query")
845
            return false
846
        }
847
        defer { sqlite3_finalize(statement) }
848

            
849
        sqlite3_bind_text(statement, 1, tableName, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
850
        return sqlite3_step(statement) == SQLITE_ROW
851
    }
852

            
Bogdan Timofte authored 2 weeks ago
853
    private func observationIDs(at url: URL) throws -> [Int64] {
854
        var db: OpaquePointer?
855
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
856
            sqlite3_close(db)
857
            XCTFail("Could not open test database")
858
            return []
859
        }
860
        defer { sqlite3_close(db) }
861

            
862
        var statement: OpaquePointer?
863
        guard sqlite3_prepare_v2(db, "SELECT id FROM observations ORDER BY id", -1, &statement, nil) == SQLITE_OK else {
864
            sqlite3_finalize(statement)
865
            XCTFail("Could not prepare observation query")
866
            return []
867
        }
868
        defer { sqlite3_finalize(statement) }
869

            
870
        var ids: [Int64] = []
871
        while sqlite3_step(statement) == SQLITE_ROW {
872
            ids.append(sqlite3_column_int64(statement, 0))
873
        }
874
        return ids
875
    }
876

            
Bogdan Timofte authored 3 days ago
877
    private func typeSummary(observationID: Int64, at url: URL) throws -> (
878
        visibleRecordCount: Int,
879
        appearedCount: Int,
880
        disappearedCount: Int,
881
        earliestStartDate: Double,
882
        latestEndDate: Double,
883
        valueSum: Double,
884
        valueMax: Double
885
    ) {
886
        var db: OpaquePointer?
887
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
888
            sqlite3_close(db)
889
            XCTFail("Could not open test database")
890
            return (0, 0, 0, 0, 0, 0, 0)
891
        }
892
        defer { sqlite3_close(db) }
893

            
894
        let sql = """
895
        SELECT visible_record_count, appeared_count, disappeared_count,
896
               earliest_start_date, latest_end_date, value_sum, value_max
897
        FROM observation_type_summaries
898
        WHERE observation_id = ?
899
        LIMIT 1
900
        """
901
        var statement: OpaquePointer?
902
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
903
            sqlite3_finalize(statement)
904
            XCTFail("Could not prepare type summary query")
905
            return (0, 0, 0, 0, 0, 0, 0)
906
        }
907
        defer { sqlite3_finalize(statement) }
908

            
909
        sqlite3_bind_int64(statement, 1, observationID)
910
        guard sqlite3_step(statement) == SQLITE_ROW else {
911
            XCTFail("Missing type summary row")
912
            return (0, 0, 0, 0, 0, 0, 0)
913
        }
914

            
915
        return (
916
            visibleRecordCount: Int(sqlite3_column_int(statement, 0)),
917
            appearedCount: Int(sqlite3_column_int(statement, 1)),
918
            disappearedCount: Int(sqlite3_column_int(statement, 2)),
919
            earliestStartDate: sqlite3_column_double(statement, 3),
920
            latestEndDate: sqlite3_column_double(statement, 4),
921
            valueSum: sqlite3_column_double(statement, 5),
922
            valueMax: sqlite3_column_double(statement, 6)
923
        )
924
    }
925

            
Bogdan Timofte authored 2 weeks ago
926
    private func sampleVersionDebugRows(at url: URL) throws -> String {
927
        var db: OpaquePointer?
928
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
929
            sqlite3_close(db)
930
            return "could not open database"
931
        }
932
        defer { sqlite3_close(db) }
933

            
934
        let sql = """
935
        SELECT v.payload_hash, v.start_date, v.end_date, v.value_kind, v.numeric_value, v.unit,
936
               v.source_revision_id, src.bundle_identifier, sr.product_type, sr.version,
937
               sr.operating_system_version, v.hk_device_id, v.metadata_id
938
        FROM sample_versions v
939
        LEFT JOIN source_revisions sr ON sr.id = v.source_revision_id
940
        LEFT JOIN sources src ON src.id = sr.source_id
941
        ORDER BY v.id
942
        """
943
        var statement: OpaquePointer?
944
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
945
            sqlite3_finalize(statement)
946
            return "could not prepare version debug query"
947
        }
948
        defer { sqlite3_finalize(statement) }
949

            
950
        var rows: [String] = []
951
        while sqlite3_step(statement) == SQLITE_ROW {
952
            rows.append((0..<13).map { index in
953
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
954
                    return "null"
955
                }
956
                if let text = sqlite3_column_text(statement, Int32(index)) {
957
                    return String(cString: text)
958
                }
959
                return "\(sqlite3_column_double(statement, Int32(index)))"
960
            }.joined(separator: "|"))
961
        }
962
        return rows.joined(separator: "\n")
963
    }
Bogdan Timofte authored a week ago
964

            
965
    private func visibilityRangeDebugRows(at url: URL) throws -> String {
966
        var db: OpaquePointer?
967
        guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
968
            sqlite3_close(db)
969
            return "could not open database"
970
        }
971
        defer { sqlite3_close(db) }
972

            
973
        let sql = """
974
        SELECT sample_id, version_id, first_observation_id, last_observation_id, first_seen_at, last_seen_at
975
        FROM sample_visibility_ranges
976
        ORDER BY sample_id, version_id, first_observation_id
977
        """
978
        var statement: OpaquePointer?
979
        guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else {
980
            sqlite3_finalize(statement)
981
            return "could not prepare visibility debug query"
982
        }
983
        defer { sqlite3_finalize(statement) }
984

            
985
        var rows: [String] = []
986
        while sqlite3_step(statement) == SQLITE_ROW {
987
            rows.append((0..<6).map { index in
988
                if sqlite3_column_type(statement, Int32(index)) == SQLITE_NULL {
989
                    return "null"
990
                }
991
                if let text = sqlite3_column_text(statement, Int32(index)) {
992
                    return String(cString: text)
993
                }
994
                return "\(sqlite3_column_double(statement, Int32(index)))"
995
            }.joined(separator: "|"))
996
        }
997
        return rows.joined(separator: "\n")
998
    }
Bogdan Timofte authored 2 weeks ago
999
}