-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.swift
More file actions
493 lines (410 loc) · 16.6 KB
/
main.swift
File metadata and controls
493 lines (410 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
//
// main.swift
// SQLiteWireClientDemo
//
// Created by Darron Schall on 5/13/21.
//
import Foundation
import SQLite3
// MARK: Post-Processed Data Structures
// Use classes instead of structs for reference semantics, to make it easier to construct
// our circular object graph with partial objects that later get fleshed out as more
// data becomes available.
protocol HasUUIDIdentifier {
var id: UUID{ get set }
}
class Genre: HasUUIDIdentifier {
var id: UUID
var title: String?
var description: String?
var books: [Book]?
init(id: UUID) {
self.id = id
}
}
class Book: HasUUIDIdentifier {
var id: UUID
var title: String?
var description: String?
var genre: Genre?
var authors: [Author]?
var publishedAt: Date?
init(id: UUID) {
self.id = id
}
}
class Author: HasUUIDIdentifier {
var id: UUID
var firstName: String?
var lastName: String?
var books: [Book]?
init(id: UUID) {
self.id = id
}
}
// MARK: Target data structures to populate from the server API response
// Using top-level variables like this isn't a real-world scenario. But, for comparison sake,
// we'll parse the server response into these top-level arrays and build a circular nested
// object graph that represents the data we get back from the server.
//
// We time how long each approach takes to build this in-memory data representation in
// an attempt to compare apples-to-apples across JSON and SQLite data transfer formats.
var genres: [Genre] = []
var books: [Book] = []
var authors: [Author] = []
// Helper for JSON:API parsing to handle partial objects due to relationships/includes
// in the data transfer format. Find an existing that we can link to and/or flesh out.
func findById<T: HasUUIDIdentifier>(array: [T], id: String) -> T? {
return findById(array: array, id: UUID(uuidString: id)!)
}
func findById<T: HasUUIDIdentifier>(array: [T], id: UUID) -> T? {
for element in array {
if element.id == id {
return element
}
}
return nil
}
// MARK: JSON-API decoding
struct JSONAPIResponse: Decodable {
var data: [JSONAPIResource]
var included: [JSONAPIResource]?
private enum CodingKeys : String, CodingKey { case data, included }
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// data is either an array of JSONAPIResource or a single JSONAPIResource
do {
data = try container.decode([JSONAPIResource].self, forKey: .data)
} catch DecodingError.typeMismatch {
data = [try container.decode(JSONAPIResource.self, forKey: .data)]
}
included = try container.decodeIfPresent([JSONAPIResource].self, forKey: .included)
}
}
struct JSONAPIResource: Decodable {
var type: String
var id: String
var attributes: [String: JSONValue]?
var relationships: [String: JSONAPIResponse]?
}
enum JSONValue: Decodable {
case number(Double)
case integer(Int)
case string(String)
case bool(Bool)
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let int = try? container.decode(Int.self) {
self = .integer(int)
} else if let double = try? container.decode(Double.self) {
self = .number(double)
} else if let string = try? container.decode(String.self) {
// TODO: Add a .date case and auto-convert if string matches date format
self = .string(string)
} else if let bool = try? container.decode(Bool.self) {
self = .bool(bool)
} else if container.decodeNil() {
self = .null
} else {
throw DecodingError.typeMismatch(JSONValue.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unrecognized type"))
}
}
}
// MARK: Date format helper
// We'll use this to convert strings to Date instances for both JSON and SQLite
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss Z"
// MARK: Fetch and process JSON API response
func getBooksAsJSON(completionHandler: @escaping () -> Void) {
let url = URL(string: "http://127.0.0.1:3000/api/v1/books")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "GET"
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
let session = URLSession(configuration: URLSessionConfiguration.default)
let task = session.dataTask(with: urlRequest) { data, response, error in
let start = DispatchTime.now()
guard let data = data, error == nil else {
fatalError ("error: \(error!)")
}
let response = try! JSONDecoder().decode(JSONAPIResponse.self, from: data)
response.data.forEach { resource in
_ = processResource(resource: resource)
}
response.included?.forEach { resource in
_ = processResource(resource: resource)
}
let end = DispatchTime.now()
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
let timeInterval = Double(nanoTime) / 1_000_000_000
print("json parsing time: \(timeInterval)")
completionHandler()
}
task.resume()
}
func processResource(resource: JSONAPIResource) -> HasUUIDIdentifier {
switch resource.type {
case "genre":
return processGenre(resource: resource)
case "book":
return processBook(resource: resource)
case "author":
return processAuthor(resource: resource)
default:
fatalError("Unexpected type: \(resource.type)")
}
}
func processGenre(resource: JSONAPIResource) -> Genre {
// Check to see if our top-level object graph already has this genre
var genre: Genre? = findById(array: genres, id: resource.id)
if genre == nil {
genre = Genre(id: UUID(uuidString: resource.id)!)
genres.append(genre!)
}
// Optionally set attributes because they might not be present if
// we're processing an included or nested relationship resource
if case .string(let value) = resource.attributes?["title"] {
genre!.title = value
}
if case .string(let value) = resource.attributes?["description"] {
genre!.description = value
}
if let relationships = resource.relationships {
if let books = relationships["books"] {
books.data.forEach { resource in
let book = processResource(resource: resource) as! Book
if genre!.books == nil {
genre?.books = [book]
} else {
// Only add book if the relationship is not already present
if findById(array: genre!.books!, id: book.id) == nil {
genre!.books!.append(book)
}
}
}
}
}
return genre!
}
func processBook(resource: JSONAPIResource) -> Book {
// Check to see if our top-level object graph already has this book
var book: Book? = findById(array: books, id: resource.id)
if book == nil {
book = Book(id: UUID(uuidString: resource.id)!)
books.append(book!)
}
// Optionally set attributes because they might not be present if
// we're processing an included or nested relationship resource
if case .string(let value) = resource.attributes?["title"] {
book!.title = value
}
if case .string(let value) = resource.attributes?["description"] {
book!.description = value
}
if case .string(let value) = resource.attributes?["published_at"] {
book!.publishedAt = dateFormatter.date(from: value)
}
if let relationships = resource.relationships {
if let authors = relationships["authors"] {
authors.data.forEach { resource in
let author = processResource(resource: resource) as! Author
if book!.authors == nil {
book?.authors = [author]
} else {
// Only add author if the relationship is not already present
if findById(array: book!.authors!, id: author.id) == nil {
book!.authors!.append(author)
}
}
}
}
if let genre = relationships["genre"] {
genre.data.forEach { resource in
let genre = processResource(resource: resource) as! Genre
book!.genre = genre
}
}
}
return book!
}
func processAuthor(resource: JSONAPIResource) -> Author {
// Check to see if our top-level object graph already has this author
var author: Author? = findById(array: authors, id: resource.id)
if author == nil {
author = Author(id: UUID(uuidString: resource.id)!)
authors.append(author!)
}
// Optionally set attributes because they might not be present if
// we're processing an included or nested relationship resource
if case .string(let value) = resource.attributes?["first_name"] {
author!.firstName = value
}
if case .string(let value) = resource.attributes?["last_name"] {
author!.lastName = value
}
if let relationships = resource.relationships {
if let books = relationships["books"] {
books.data.forEach { resource in
let book = processResource(resource: resource) as! Book
if author!.books == nil {
author?.books = [book]
} else {
// Only add book if the relationship is not already present
if findById(array: author!.books!, id: book.id) == nil {
author!.books!.append(book)
}
}
}
}
}
return author!
}
// MARK: Fetch and process SQLite3 API response
func getBooksAsSQLite3(completionHandler: @escaping () -> Void) {
let url = URL(string: "http://127.0.0.1:3000/api/v1/books")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "GET"
urlRequest.setValue("application/x-sqlite3", forHTTPHeaderField: "Accept")
let session = URLSession(configuration: URLSessionConfiguration.default)
let task = session.dataTask(with: urlRequest) { data, response, error in
let start = DispatchTime.now()
guard let data = data, error == nil else {
fatalError ("error: \(error!)")
}
let path = FileManager.default.currentDirectoryPath
let tempDatabaseUrl = URL(string: "file://\(path)")!.appendingPathComponent("temp_data.sqlite")
if FileManager.default.fileExists(atPath: tempDatabaseUrl.path) {
try! FileManager.default.removeItem(at: tempDatabaseUrl)
}
try! data.write(to: tempDatabaseUrl)
var db: OpaquePointer?
if sqlite3_open(tempDatabaseUrl.path, &db) != SQLITE_OK { // error mostly because of corrupt database
fatalError("error opening database \(tempDatabaseUrl.absoluteString)")
}
populateGenres(from: db!)
populateBooks(from: db!)
populateAuthors(from: db!)
populateAuthorships(from: db!)
if sqlite3_close(db) != SQLITE_OK {
fatalError("error closing database \(tempDatabaseUrl.absoluteString)")
}
try! FileManager.default.removeItem(at: tempDatabaseUrl)
let end = DispatchTime.now()
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
let timeInterval = Double(nanoTime) / 1_000_000_000
print("sqlite parsing time: \(timeInterval)")
completionHandler()
}
task.resume()
}
func populateGenres(from db: OpaquePointer) {
let genresQuerySql = "SELECT * FROM genres;"
var queryStatement: OpaquePointer?
if sqlite3_prepare_v2(db, genresQuerySql, -1, &queryStatement, nil) == SQLITE_OK {
while (sqlite3_step(queryStatement) == SQLITE_ROW) {
let id = String(cString: sqlite3_column_text(queryStatement, 0)!)
let title = String(cString: sqlite3_column_text(queryStatement, 1)!)
let description = String(cString: sqlite3_column_text(queryStatement, 2)!)
let genre = Genre(id: UUID(uuidString: id)!)
genre.title = title
genre.description = description
genre.books = [] // We'll populate this whe we process Books
genres.append(genre)
}
} else {
let errorMessage = String(cString: sqlite3_errmsg(db))
print("\nError populating genres: query is not prepared \(errorMessage)")
}
sqlite3_finalize(queryStatement)
}
func populateBooks(from db: OpaquePointer) {
let booksQuerySql = "SELECT * FROM books;"
var queryStatement: OpaquePointer?
if sqlite3_prepare_v2(db, booksQuerySql, -1, &queryStatement, nil) == SQLITE_OK {
while (sqlite3_step(queryStatement) == SQLITE_ROW) {
let id = String(cString: sqlite3_column_text(queryStatement, 0)!)
let title = String(cString: sqlite3_column_text(queryStatement, 1)!)
let description = String(cString: sqlite3_column_text(queryStatement, 2)!)
let publishedAt = String(cString: sqlite3_column_text(queryStatement, 3)!)
let genreId = String(cString: sqlite3_column_text(queryStatement, 4)!)
let book = Book(id: UUID(uuidString: id)!)
book.title = title
book.description = description
book.publishedAt = dateFormatter.date(from: publishedAt)
// We know that all Genres are already loaded at this point
book.genre = findById(array: genres, id: genreId)
book.authors = [] // We'll populate this later via authorships
books.append(book)
// Create the circular reference while we're here
book.genre!.books!.append(book)
}
} else {
let errorMessage = String(cString: sqlite3_errmsg(db))
print("\nError populating books: query is not prepared \(errorMessage)")
}
sqlite3_finalize(queryStatement)
}
func populateAuthors(from db: OpaquePointer) {
let authorsQuerySql = "SELECT * FROM authors;"
var queryStatement: OpaquePointer?
if sqlite3_prepare_v2(db, authorsQuerySql, -1, &queryStatement, nil) == SQLITE_OK {
while (sqlite3_step(queryStatement) == SQLITE_ROW) {
let id = String(cString: sqlite3_column_text(queryStatement, 0)!)
let firstName = String(cString: sqlite3_column_text(queryStatement, 1)!)
let lastName = String(cString: sqlite3_column_text(queryStatement, 2)!)
let author = Author(id: UUID(uuidString: id)!)
author.firstName = firstName
author.lastName = lastName
author.books = [] // We'll populate this later via authorships
authors.append(author)
}
} else {
let errorMessage = String(cString: sqlite3_errmsg(db))
print("\nError populating authors: query is not prepared \(errorMessage)")
}
sqlite3_finalize(queryStatement)
}
func populateAuthorships(from db: OpaquePointer) {
let authorshipsQuerySql = "SELECT * FROM authorships;"
var queryStatement: OpaquePointer?
if sqlite3_prepare_v2(db, authorshipsQuerySql, -1, &queryStatement, nil) == SQLITE_OK {
while (sqlite3_step(queryStatement) == SQLITE_ROW) {
let authorId = String(cString: sqlite3_column_text(queryStatement, 0)!)
let bookId = String(cString: sqlite3_column_text(queryStatement, 1)!)
let author = findById(array: authors, id: authorId)!
let book = findById(array: books, id: bookId)!
author.books!.append(book)
book.authors!.append(author)
}
} else {
let errorMessage = String(cString: sqlite3_errmsg(db))
print("\nError populating authorships: query is not prepared \(errorMessage)")
}
sqlite3_finalize(queryStatement)
}
// MARK: Timer helper
func measureElapsedTime(_ closure: (@escaping () -> Void) -> Void) {
// Re-set to pristine post-processing internal object graph
genres = []
books = []
authors = []
// Keep alive to wait for the results of the processing
let semaphore = DispatchSemaphore(value: 0)
let start = DispatchTime.now()
closure {
semaphore.signal()
}
semaphore.wait()
let end = DispatchTime.now()
let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
let timeInterval = Double(nanoTime) / 1_000_000_000
print("elapsed time: \(timeInterval)")
}
// MARK: Main
for _ in 0..<10 {
measureElapsedTime(getBooksAsJSON)
}
print("----------")
for _ in 0..<10 {
measureElapsedTime(getBooksAsSQLite3)
}