Add CI/CD configuration and API documentation
@@ -0,0 +1,4 @@
|
||||
Sharing a Database in an App Group Container
|
||||
============================================
|
||||
|
||||
This guide [has moved](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasesharing).
|
||||
@@ -0,0 +1,404 @@
|
||||
GRDB ❤️ Combine
|
||||
===============
|
||||
|
||||
**On systems supporting the Combine framework, GRDB offers the ability to publish database values and events using Combine's publishers.**
|
||||
|
||||
- [Usage]
|
||||
- [Demo Application]
|
||||
- [Asynchronous Database Access]
|
||||
- [Database Observation]
|
||||
- [Combine and Data Consistency]: take care when you combine database publishers together
|
||||
|
||||
## Usage
|
||||
|
||||
To connect to the database, please refer to [Database Connections].
|
||||
|
||||
<details>
|
||||
<summary><strong>Asynchronously read from the database</strong></summary>
|
||||
|
||||
This publisher reads a single value and delivers it.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Read<[Player]>
|
||||
let players = dbQueue.readPublisher { db in
|
||||
try Player.fetchAll(db)
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Asynchronously write in the database</strong></summary>
|
||||
|
||||
This publisher updates the database and delivers a single value.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Void>
|
||||
let write = dbQueue.writePublisher { db in
|
||||
try Player(...).insert(db)
|
||||
}
|
||||
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher { db -> Int in
|
||||
try Player(...).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Asynchronously migrate the database</strong></summary>
|
||||
|
||||
This publisher migrates a database:
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Migrate
|
||||
let migrator: DatabaseMigrator = ...
|
||||
let publisher = migrator.migratePublisher(dbQueue)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Observe changes in database values</strong></summary>
|
||||
|
||||
This publisher delivers fresh values whenever the database changes:
|
||||
|
||||
```swift
|
||||
// A publisher with output [Player] and failure Error
|
||||
let publisher = ValueObservation
|
||||
.tracking { db in try Player.fetchAll(db) }
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
// A publisher with output Int? and failure Error
|
||||
let publisher = ValueObservation
|
||||
.tracking { db in try Int.fetchOne(db, sql: "SELECT MAX(score) FROM player") }
|
||||
.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Observe database transactions</strong></summary>
|
||||
|
||||
This publisher delivers database connections whenever a database transaction has impacted an observed region:
|
||||
|
||||
```swift
|
||||
// A publisher with output Database and failure Error
|
||||
let publisher = DatabaseRegionObservation
|
||||
.tracking(Player.all())
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
let cancellable = publisher.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (db: Database) in
|
||||
print("Exclusive write access to the database after players have been impacted")
|
||||
})
|
||||
|
||||
// A publisher with output Database and failure Error
|
||||
let publisher = DatabaseRegionObservation
|
||||
.tracking(SQLRequest<Int>(sql: "SELECT MAX(score) FROM player"))
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
let cancellable = publisher.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (db: Database) in
|
||||
print("Exclusive write access to the database after maximum score has been impacted")
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
# Asynchronous Database Access
|
||||
|
||||
GRDB provide publishers that perform asynchronous database accesses:
|
||||
|
||||
- [`readPublisher(receiveOn:value:)`]
|
||||
- [`writePublisher(receiveOn:updates:)`]
|
||||
- [`writePublisher(receiveOn:updates:thenRead:)`]
|
||||
- [`migratePublisher(_:receiveOn:)`]
|
||||
|
||||
|
||||
#### `DatabaseReader.readPublisher(receiveOn:value:)`
|
||||
|
||||
This methods returns a publisher that completes after database values have been asynchronously fetched.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Read<[Player]>
|
||||
let players = dbQueue.readPublisher { db in
|
||||
try Player.fetchAll(db)
|
||||
}
|
||||
```
|
||||
|
||||
Any attempt at modifying the database completes subscriptions with an error.
|
||||
|
||||
When you use a [database queue] or a [database snapshot], the read has to wait for any eventual concurrent database access performed by this queue or snapshot to complete.
|
||||
|
||||
When you use a [database pool], reads are generally non-blocking, unless the maximum number of concurrent reads has been reached. In this case, a read has to wait for another read to complete. That maximum number can be [configured].
|
||||
|
||||
This publisher can be subscribed from any thread. A new database access starts on every subscription.
|
||||
|
||||
The fetched value is published on the main queue, unless you provide a specific [scheduler] to the `receiveOn` argument.
|
||||
|
||||
|
||||
#### `DatabaseWriter.writePublisher(receiveOn:updates:)`
|
||||
|
||||
This method returns a publisher that completes after database updates have been successfully executed inside a database transaction.
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Void>
|
||||
let write = dbQueue.writePublisher { db in
|
||||
try Player(...).insert(db)
|
||||
}
|
||||
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher { db -> Int in
|
||||
try Player(...).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
}
|
||||
```
|
||||
|
||||
This publisher can be subscribed from any thread. A new database access starts on every subscription.
|
||||
|
||||
It completes on the main queue, unless you provide a specific [scheduler] to the `receiveOn` argument.
|
||||
|
||||
When you use a [database pool], and your app executes some database updates followed by some slow fetches, you may profit from optimized scheduling with [`writePublisher(receiveOn:updates:thenRead:)`]. See below.
|
||||
|
||||
|
||||
#### `DatabaseWriter.writePublisher(receiveOn:updates:thenRead:)`
|
||||
|
||||
This method returns a publisher that completes after database updates have been successfully executed inside a database transaction, and values have been subsequently fetched:
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher(
|
||||
updates: { db in try Player(...).insert(db) }
|
||||
thenRead: { db, _ in try Player.fetchCount(db) })
|
||||
}
|
||||
```
|
||||
|
||||
It publishes exactly the same values as [`writePublisher(receiveOn:updates:)`]:
|
||||
|
||||
```swift
|
||||
// DatabasePublishers.Write<Int>
|
||||
let newPlayerCount = dbQueue.writePublisher { db -> Int in
|
||||
try Player(...).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
}
|
||||
```
|
||||
|
||||
The difference is that the last fetches are performed in the `thenRead` function. This function accepts two arguments: a readonly database connection, and the result of the `updates` function. This allows you to pass information from a function to the other (it is ignored in the sample code above).
|
||||
|
||||
When you use a [database pool], this method applies a scheduling optimization: the `thenRead` function sees the database in the state left by the `updates` function, and yet does not block any concurrent writes. This can reduce database write contention.
|
||||
|
||||
When you use a [database queue], the results are guaranteed to be identical, but no scheduling optimization is applied.
|
||||
|
||||
This publisher can be subscribed from any thread. A new database access starts on every subscription.
|
||||
|
||||
It completes on the main queue, unless you provide a specific [scheduler] to the `receiveOn` argument.
|
||||
|
||||
|
||||
# Database Observation
|
||||
|
||||
Database Observation publishers are based on [ValueObservation] and [DatabaseRegionObservation]. Please refer to their documentation for more information. If your application needs change notifications that are not built as Combine publishers, check the general [Database Changes Observation] chapter.
|
||||
|
||||
- [`ValueObservation.publisher(in:scheduling:)`]
|
||||
- [`SharedValueObservation.publisher()`]
|
||||
- [`DatabaseRegionObservation.publisher(in:)`]
|
||||
|
||||
|
||||
#### `ValueObservation.publisher(in:scheduling:)`
|
||||
|
||||
[ValueObservation] tracks changes in database values. You can turn it into a Combine publisher:
|
||||
|
||||
```swift
|
||||
let observation = ValueObservation.tracking { db in
|
||||
try Player.fetchAll(db)
|
||||
}
|
||||
|
||||
// A publisher with output [Player] and failure Error
|
||||
let publisher = observation.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
This publisher has the same behavior as ValueObservation:
|
||||
|
||||
- It notifies an initial value before the eventual changes.
|
||||
- It may coalesce subsequent changes into a single notification.
|
||||
- It may notify consecutive identical values. You can filter out the undesired duplicates with the `removeDuplicates()` Combine operator, but we suggest you have a look at the [removeDuplicates()](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation/removeduplicates()) GRDB operator also.
|
||||
- It only completes when it is cancelled.
|
||||
- By default, it notifies the initial value, as well as eventual changes and errors, on the main thread, asynchronously.
|
||||
|
||||
This can be configured with the `scheduling` argument. It does not accept a Combine scheduler, but a [ValueObservationScheduler](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservationscheduler).
|
||||
|
||||
For example, the `.immediate` scheduler makes sure the initial value is notified immediately when the publisher is subscribed. It can help your application update the user interface without having to wait for any asynchronous notifications:
|
||||
|
||||
```swift
|
||||
// Immediate notification of the initial value
|
||||
let cancellable = observation
|
||||
.publisher(
|
||||
in: dbQueue,
|
||||
scheduling: .immediate) // <-
|
||||
.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (players: [Player]) in print("Fresh players: \(players)") })
|
||||
// <- here "fresh players" is already printed.
|
||||
```
|
||||
|
||||
Note that the `.immediate` scheduler requires that the publisher is subscribed from the main thread. It raises a fatal error otherwise.
|
||||
|
||||
|
||||
#### `SharedValueObservation.publisher()`
|
||||
|
||||
[SharedValueObservation] tracks changes in database values. You can turn it into a Combine publisher:
|
||||
|
||||
```swift
|
||||
let sharedObservation = ValueObservation
|
||||
.tracking { db in try Player.fetchAll(db) }
|
||||
.shared(in: dbQueue)
|
||||
|
||||
// A publisher with output [Player] and failure Error
|
||||
let publisher = sharedObservation.publisher()
|
||||
```
|
||||
|
||||
This publisher has the same behavior as SharedValueObservation.
|
||||
|
||||
|
||||
#### `DatabaseRegionObservation.publisher(in:)`
|
||||
|
||||
[DatabaseRegionObservation] notifies all transactions that impact a tracked database region. You can turn it into a Combine publisher:
|
||||
|
||||
```swift
|
||||
let request = Player.all()
|
||||
let observation = DatabaseRegionObservation.tracking(request)
|
||||
|
||||
// A publisher with output Database and failure Error
|
||||
let publisher = observation.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
This publisher can be created and subscribed from any thread. It delivers database connections in a "protected dispatch queue", serialized with all database updates. It only completes when a database error happens.
|
||||
|
||||
```swift
|
||||
let request = Player.all()
|
||||
let cancellable = DatabaseRegionObservation
|
||||
.tracking(request)
|
||||
.publisher(in: dbQueue)
|
||||
.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { (db: Database) in
|
||||
print("Players have changed.")
|
||||
})
|
||||
|
||||
try dbQueue.write { db in
|
||||
try Player(name: "Arthur").insert(db)
|
||||
try Player(name: "Barbara").insert(db)
|
||||
}
|
||||
// Prints "Players have changed."
|
||||
|
||||
try dbQueue.write { db in
|
||||
try Player.deleteAll(db)
|
||||
}
|
||||
// Prints "Players have changed."
|
||||
```
|
||||
|
||||
See [DatabaseRegionObservation] for more information.
|
||||
|
||||
|
||||
## Combine and Data Consistency
|
||||
|
||||
When you compose database publishers together with Combine operators such as `combineLatest` or `zip`, you lose all guarantees of [data consistency](https://en.wikipedia.org/wiki/Consistency_(database_systems)).
|
||||
|
||||
This is because each database publisher is isolated from others: each one of them sees its own state of the database. Whenever some database change is interleaved between publisher operations, publishers will process or publish values that may not fit well together.
|
||||
|
||||
In other words, whenever you need to perform some database access or observation that depends on some database invariant, make sure you define one and only one database publisher instead of combining several publishers. This is how you will prevent eventual concurrent database writes from messing with your app, and introduce bugs.
|
||||
|
||||
To this end, remember that *all database publishers can perform several requests*.
|
||||
|
||||
In the example below, we are totally sure that the published `HallOfFame` values will never contain inconsistent values, because it is produced by one and only one publisher:
|
||||
|
||||
```swift
|
||||
struct HallOfFame {
|
||||
// Invariant: bestPlayers.count <= totalPlayerCount
|
||||
var totalPlayerCount: Int
|
||||
var bestPlayers: [Player]
|
||||
}
|
||||
|
||||
// CORRECT: DATA CONSISTENCY GUARANTEED
|
||||
let hallOfFamePublisher = ValueObservation
|
||||
.tracking { db -> HallOfFame in
|
||||
// 1st request
|
||||
let totalPlayerCount = try Player.fetchCount(db)
|
||||
|
||||
// 2nd request
|
||||
let bestPlayers = try Player
|
||||
.order(Column("score").desc)
|
||||
.limit(10)
|
||||
.fetchAll(db)
|
||||
|
||||
// 100% guaranteed
|
||||
assert(bestPlayers.count <= totalPlayerCount)
|
||||
|
||||
// Merge results together
|
||||
return HallOfFame(
|
||||
totalPlayerCount: totalPlayerCount,
|
||||
bestPlayers: bestPlayers)
|
||||
}
|
||||
.publisher(in: dbQueue)
|
||||
```
|
||||
|
||||
Compare with the incorrect version that combines two database publishers together:
|
||||
|
||||
```swift
|
||||
// OK
|
||||
let totalPlayerCountPublisher = ValueObservation
|
||||
.tracking(Player.fetchCount)
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
// OK
|
||||
let bestPlayerPublisher = ValueObservation
|
||||
.tracking(Player
|
||||
.order(Column("score").desc)
|
||||
.limit(10)
|
||||
.fetchAll)
|
||||
.publisher(in: dbQueue)
|
||||
|
||||
// NOT OK: DATA CONSISTENCY NOT GUARANTEED
|
||||
let hallOfFamePublisher = totalPlayerCountPublisher
|
||||
.combineLatest(bestPlayerPublisher)
|
||||
.map(HallOfFame.init(totalPlayerCount:bestPlayers))
|
||||
|
||||
let cancellable = hallOfFamePublisher.sink(
|
||||
receiveCompletion: { completion in ... },
|
||||
receiveValue: { hallOfFame in
|
||||
// ASSERTION MAY FAIL if some players are deleted
|
||||
// at the wrong time
|
||||
assert(hallOfFame.bestPlayers.count <= hallOfFame.totalPlayerCount)
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
[Database Connections]: ../README.md#database-connections
|
||||
[Usage]: #usage
|
||||
[Asynchronous Database Access]: #asynchronous-database-access
|
||||
[Combine]: https://developer.apple.com/documentation/combine
|
||||
[Database Changes Observation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseobservation
|
||||
[Database Observation]: #database-observation
|
||||
[Combine and Data Consistency]: #combine-and-data-consistency
|
||||
[DatabaseRegionObservation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseregionobservation
|
||||
[Demo Application]: DemoApps/GRDBCombineDemo/README.md
|
||||
[SQLite]: http://sqlite.org
|
||||
[ValueObservation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation
|
||||
[SharedValueObservation]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/sharedvalueobservation
|
||||
[`DatabaseRegionObservation.publisher(in:)`]: #databaseregionobservationpublisherin
|
||||
[`ValueObservation.publisher(in:scheduling:)`]: #valueobservationpublisherinscheduling
|
||||
[`SharedValueObservation.publisher()`]: #sharedvalueobservationpublisher
|
||||
[`readPublisher(receiveOn:value:)`]: #databasereaderreadpublisherreceiveonvalue
|
||||
[`writePublisher(receiveOn:updates:)`]: #databasewriterwritepublisherreceiveonupdates
|
||||
[`writePublisher(receiveOn:updates:thenRead:)`]: #databasewriterwritepublisherreceiveonupdatesthenread
|
||||
[`migratePublisher(_:receiveOn:)`]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasemigrator/migratepublisher(_:receiveon:)
|
||||
[configured]: ../README.md#databasepool-configuration
|
||||
[database pool]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasepool
|
||||
[database queue]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasequeue
|
||||
[database snapshot]: https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasesnapshot
|
||||
[scheduler]: https://developer.apple.com/documentation/combine/scheduler
|
||||
@@ -0,0 +1,414 @@
|
||||
Common Table Expressions
|
||||
========================
|
||||
|
||||
[**:fire: EXPERIMENTAL**](../README.md#what-are-experimental-features)
|
||||
|
||||
---
|
||||
|
||||
**Common table expressions** (CTEs) can generally be seen as *SQL views that you define on the fly*.
|
||||
|
||||
A certain level of familiarity with SQL databases is helpful before you dive into this guide. The starting point is obviously the [SQLite documentation](https://sqlite.org/lang_with.html). Many CTE tutorials exist online as well, including [this good one](https://blog.expensify.com/2015/09/25/the-simplest-sqlite-common-table-expression-tutorial/).
|
||||
|
||||
In this guide, you will learn how to:
|
||||
|
||||
- [Define Common Table Expressions]
|
||||
- [Embed Common Table Expressions in Requests]
|
||||
- [Fetch Values From Common Table Expressions]
|
||||
- Join CTEs with [Associations to Common Table Expressions]
|
||||
|
||||
> **Note**: most code examples will be trivial, and not very "useful". This is because the goal of this guide is to stay focused on the GRDB support for CTEs. Rich setup would just be distracting. So bring your own good ideas with you!
|
||||
|
||||
|
||||
## Define Common Table Expressions
|
||||
|
||||
You will create a `CommonTableExpression` definition first. Choose a **name**, and a **request** that provides the content of the common table expression.
|
||||
|
||||
The CTE name is like a regular table name: pick one that does not conflict with the names of existing tables.
|
||||
|
||||
The CTE request can be provided as a [query interface request]:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT name FROM player) ...
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
request: Player.select(Column("name")))
|
||||
```
|
||||
|
||||
You can feed a CTE with raw SQL as well (second and third examples use [SQL Interpolation]):
|
||||
|
||||
```swift
|
||||
let name = "O'Brien"
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien') ...
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
sql: "SELECT ?", arguments: [name])
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien') ...
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
literal: "SELECT \(name)")
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien') ...
|
||||
let request = SQLRequest("SELECT \(name)")
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
request: request)
|
||||
```
|
||||
|
||||
All CTEs can be provided with explicit column names:
|
||||
|
||||
```swift
|
||||
// WITH pair(a, b) AS (SELECT 1, 2) ...
|
||||
let pairCTE = CommonTableExpression(
|
||||
named: "pair",
|
||||
columns: ["a", "b"],
|
||||
sql: "SELECT 1, 2")
|
||||
```
|
||||
|
||||
Recursive CTEs need the `recursive` flag. The example below selects all integers between 1 and 1000:
|
||||
|
||||
```swift
|
||||
// WITH RECURSIVE counter(x) AS
|
||||
// (VALUES(1) UNION ALL SELECT x+1 FROM counter WHERE x<1000)
|
||||
let counterCTE = CommonTableExpression(
|
||||
recursive: true,
|
||||
named: "counter",
|
||||
columns: ["x"],
|
||||
sql: """
|
||||
VALUES(1)
|
||||
UNION ALL
|
||||
SELECT x+1 FROM counter WHERE x<1000
|
||||
""")
|
||||
```
|
||||
|
||||
> **Note**: many recursive CTEs use the `UNION ALL` SQL operator. The query interface does not provide any Swift support for it, so you'll generally have to write SQL in your definitions of recursive CTEs.
|
||||
|
||||
|
||||
## Embed Common Table Expressions in Requests
|
||||
|
||||
A typical SQLite query that uses a common table expression first *defines* the CTE and then *uses* the CTE by mentioning its table name. We'll see below Swift apis that match those two steps.
|
||||
|
||||
We will use the (simple) query below as a target. It is the query we'll want to generate in this chapter. It defines a CTE, and uses it in a subquery:
|
||||
|
||||
```sql
|
||||
WITH playerName AS (SELECT 'O''Brien')
|
||||
SELECT * FROM player
|
||||
WHERE name = (SELECT * FROM playerName)
|
||||
```
|
||||
|
||||
We first build a `CommonTableExpression`:
|
||||
|
||||
```swift
|
||||
let name = "O'Brien"
|
||||
let playerNameCTE = CommonTableExpression(
|
||||
named: "playerName",
|
||||
literal: "SELECT \(name)")
|
||||
```
|
||||
|
||||
We can then embed the definition of the CTE in a [query interface request] by calling the `with(_:)` method:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player ...
|
||||
let request = Player
|
||||
.with(playerNameCTE)...
|
||||
```
|
||||
|
||||
And we can then filter the `player` table with a subquery:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player
|
||||
// WHERE name = (SELECT * FROM playerName)
|
||||
let request = Player
|
||||
.with(playerNameCTE)
|
||||
.filter(Column("name") == playerNameCTE.all())
|
||||
```
|
||||
|
||||
> **Note**: the `with(_:)` method can be called as many times as a there are common table expressions in your request.
|
||||
>
|
||||
> **Note**: the `with(_:)` method can be called at any time, as all request methods: `Player.with(...).filter(...).with(...)`.
|
||||
>
|
||||
> **Note**: the `with(_:)` method replaces any previously embedded CTE that has the same table name. This allows you to embed the same CTE several times if you feel like it.
|
||||
>
|
||||
> **Note**: the `CommonTableExpression.all()` method builds a regular [query interface request] for the content of the CTE (like `SELECT * FROM <cte name>`, not to be mismatched with the request that was used to define the CTE). You can filter this request, sort it, etc, like all query interface requests:
|
||||
>
|
||||
> ```swift
|
||||
> cte.all().select(...).filter(...).group(...).order(...)
|
||||
> ```
|
||||
|
||||
Common table expressions can also be embedded in [SQLRequest] with [SQL Interpolation]:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player
|
||||
// WHERE name = (SELECT * FROM playerName)
|
||||
let request: SQLRequest<Player> = """
|
||||
WITH \(definitionFor: playerNameCTE)
|
||||
SELECT * FROM player
|
||||
WHERE name = (SELECT * FROM \(playerNameCTE))
|
||||
"""
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// SELECT * FROM player
|
||||
// WHERE name = (SELECT * FROM playerName)
|
||||
let request: SQLRequest<Player> = """
|
||||
WITH \(definitionFor: playerNameCTE)
|
||||
SELECT * FROM player
|
||||
WHERE name = (\(playerNameCTE.all()))
|
||||
"""
|
||||
```
|
||||
|
||||
Common table expressions can also be used as subqueries, when you update or delete rows in the database:
|
||||
|
||||
```swift
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// UPDATE player SET name = (SELECT * FROM playerName)
|
||||
try Player
|
||||
.with(playerNameCTE)
|
||||
.updateAll(db, Column("name").set(to: playerNameCTE.all()))
|
||||
|
||||
// WITH playerName AS (SELECT 'O''Brien')
|
||||
// DELETE FROM player WHERE name = (SELECT * FROM playerName)
|
||||
try Player
|
||||
.with(playerNameCTE)
|
||||
.filter(Column("name") == playerNameCTE.all())
|
||||
.deleteAll(db)
|
||||
```
|
||||
|
||||
|
||||
## Fetch Values From Common Table Expressions
|
||||
|
||||
In the previous chapter, a common table expression was embedded as a subquery, with the `CommonTableExpression.all()` method.
|
||||
|
||||
`cte.all()` builds a regular [query interface request] that you can filter, sort, etc, like all query interface requests.
|
||||
|
||||
You can also fetch from `cte.all()`, as long as the request is given the definition of the CTE: `cte.all().with(cte)`. In SQL, this would give: `WITH cte AS (...) SELECT * FROM cte`:
|
||||
|
||||
This request, of type `QueryInterfaceRequest<Row>`, can fetch raw database [rows](../README.md#row-queries):
|
||||
|
||||
```swift
|
||||
let cte = CommonTableExpression(...)
|
||||
let request = cte.all().with(cte)
|
||||
let rows = try request.fetchAll(db) // [Row]
|
||||
```
|
||||
|
||||
In order to fetch something else, such as simple [values](../README.md#value-queries), or custom [records](../README.md#records), you have two possible options:
|
||||
|
||||
1. Use the `asRequest(of:)` method:
|
||||
|
||||
```swift
|
||||
let cte = CommonTableExpression(...)
|
||||
let request = cte.all().with(cte).asRequest(of: Player.self)
|
||||
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
let players = try request.fetchAll(db) // [Player]
|
||||
```
|
||||
|
||||
2. Provide the fetched type to the cte itself:
|
||||
|
||||
```swift
|
||||
let cte = CommonTableExpression<Player>(...)
|
||||
// ~~~~~~~~
|
||||
let request = cte.all().with(cte)
|
||||
let players = try request.fetchAll(db) // [Player]
|
||||
```
|
||||
|
||||
## Associations to Common Table Expressions
|
||||
|
||||
GRDB [associations] define "to-one" and "to-many" relationships between two database tables. Here we will define associations between regular tables and common table expressions.
|
||||
|
||||
We recommend familiarity with the "joining methods", described in [Joining And Prefetching Associated Records]:
|
||||
|
||||
```swift
|
||||
// SELECT parent.* FROM parent LEFT JOIN child ON ...
|
||||
Parent.joining(optional: childAssociation)
|
||||
|
||||
// SELECT parent.* FROM parent JOIN child ON ...
|
||||
Parent.joining(required: childAssociation)
|
||||
|
||||
// SELECT parent.*, child.* FROM parent LEFT JOIN child ON ...
|
||||
Parent.including(optional: childAssociation)
|
||||
|
||||
// SELECT parent.*, child.* FROM parent JOIN child ON ...
|
||||
Parent.including(required: childAssociation)
|
||||
```
|
||||
|
||||
> **Note**: common table expressions currently only define "to-one" associations, so the `including(all:)` joining method is unavailable.
|
||||
|
||||
CTE associations are generally built with the `association(to:on:)` method, which needs:
|
||||
|
||||
- The two sides of the association: a `CommonTableExpression` instance, and another CTE or a type that conforms to the [TableRecord] protocol.
|
||||
- A function that returns the condition that joins the two sides of the association.
|
||||
|
||||
The condition function plays the same role as the **foreign key** that defines regular table [associations] such as **BelongsTo** or **HasMany**. It accepts two [TableAlias], from which you can build a joining expression:
|
||||
|
||||
For example:
|
||||
|
||||
```swift
|
||||
// An association from LeftRecord to rightCTE
|
||||
let rightCTE = ...
|
||||
let association = LeftRecord.association(
|
||||
to: rightCTE,
|
||||
on: { left, right in
|
||||
left[Column("x")] == right[Column("y")]
|
||||
})
|
||||
```
|
||||
|
||||
Now this association can be used with a joining method:
|
||||
|
||||
```swift
|
||||
// WITH rightCTE AS (...)
|
||||
// SELECT leftRecord.*, rightCTE.*
|
||||
// FROM leftRecord
|
||||
// JOIN rightCTE ON leftRecord.x = rightCTE.y
|
||||
LeftRecord
|
||||
.with(rightCTE)
|
||||
.including(required: association)
|
||||
```
|
||||
|
||||
|
||||
### CTE Association Example: a Chat App
|
||||
|
||||
As an example, let's build the classical main screen of a chat application: a list of all latest messages from all conversations.
|
||||
|
||||
The database schema of the chat app contains a `chat` and a `message` table. The application defines the following records:
|
||||
|
||||
```swift
|
||||
struct Chat: Codable, FetchableRecord, PersistableRecord {
|
||||
var id: Int64
|
||||
...
|
||||
}
|
||||
|
||||
struct Message: Codable, FetchableRecord, PersistableRecord {
|
||||
var chatID: Int64
|
||||
var date: Date
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
To feed the main app screen, we want to fetch a list of `ChatInfo` records:
|
||||
|
||||
```swift
|
||||
struct ChatInfo: Decodable, FetchableRecord {
|
||||
/// The chat
|
||||
var chat: Chat
|
||||
|
||||
/// The latest chat message, if any
|
||||
var latestMessage: Message?
|
||||
}
|
||||
```
|
||||
|
||||
The SQL request that we want to run is below. It uses an SQLite-specific [special processing](https://sqlite.org/lang_select.html) of `MAX()` that helps the selection of latest messages from all chats:
|
||||
|
||||
```sql
|
||||
WITH latestMessage AS
|
||||
(SELECT *, MAX(date) FROM message GROUP BY chatID)
|
||||
SELECT chat.*, latestMessage.*
|
||||
FROM chat
|
||||
LEFT JOIN latestMessage ON chat.id = latestMessage.chatID
|
||||
ORDER BY latestMessage.date DESC
|
||||
```
|
||||
|
||||
We start by defining the CTE request, which loads the latest messages of all chats:
|
||||
|
||||
```swift
|
||||
// SELECT *, MAX(date) FROM message GROUP BY chatID
|
||||
let latestMessageRequest = Message
|
||||
.annotated(with: max(Column("date")))
|
||||
.group(Column("chatID"))
|
||||
```
|
||||
|
||||
We can now define the CTE for the latest messages:
|
||||
|
||||
```swift
|
||||
// WITH latestMessage AS
|
||||
// (SELECT *, MAX(date) FROM message GROUP BY chatID)
|
||||
let latestMessageCTE = CommonTableExpression(
|
||||
named: "latestMessage",
|
||||
request: latestMessageRequest)
|
||||
```
|
||||
|
||||
The association from a chat to its latest message follows:
|
||||
|
||||
```swift
|
||||
|
||||
// ... JOIN latestMessage ON chat.id = latestMessage.chatID
|
||||
let latestMessage = Chat.association(
|
||||
to: latestMessageCTE,
|
||||
on: { chat, latestMessage in
|
||||
chat[Column("id")] == latestMessage[Column("chatID")]
|
||||
})
|
||||
.order(Column("date").desc)
|
||||
```
|
||||
|
||||
The final request can now be defined:
|
||||
|
||||
```swift
|
||||
|
||||
// WITH latestMessage AS
|
||||
// (SELECT *, MAX(date) FROM message GROUP BY chatID)
|
||||
// SELECT chat.*, latestMessage.*
|
||||
// FROM chat
|
||||
// LEFT JOIN latestMessage ON chat.id = latestMessage.chatID
|
||||
// ORDER BY latestMessage.date DESC
|
||||
let request = Chat
|
||||
.with(latestMessageCTE)
|
||||
.including(optional: latestMessage)
|
||||
.asRequest(of: ChatInfo.self)
|
||||
```
|
||||
|
||||
And we can fetch the data that feeds our application screen:
|
||||
|
||||
```swift
|
||||
|
||||
let chatInfos: [ChatInfos] = try dbQueue.read { db in
|
||||
try request.fetchAll(db)
|
||||
}
|
||||
```
|
||||
|
||||
> :bulb: **Tip**: the joining methods are generally type-safe: they won't allow you to join apples to oranges. This works when associations have a *precise* type. In this context, anonymous `CommonTableExpression` CTEs can work against type safety. When you want to define associations between several CTEs, and make sure the compiler will notice wrong uses of those associations, tag your common table expressions with an explicit type: `CommonTableExpression<SomeType>`.
|
||||
>
|
||||
> To do so, you can use an existing record type, or an ad-hoc enum. For example:
|
||||
>
|
||||
> ```swift
|
||||
> enum CTE1 { }
|
||||
> let cte1 = CommonTableExpression<CTE1>(...)
|
||||
>
|
||||
> enum CTE2 { }
|
||||
> let cte2 = CommonTableExpression<CTE2>(...)
|
||||
>
|
||||
> let assoc1 = BaseRecord.association(to: cte1, on: ...) // from BaseRecord to CTE1
|
||||
> let assoc2 = cte1.association(to: cte2, on: ...) // from CTE1 to CTE2
|
||||
> let assoc3 = cte2.association(to: FarRecord.self, on: ...) // from CTE2 to FarRecord
|
||||
>
|
||||
> // WITH ...
|
||||
> // SELECT base.* FROM base
|
||||
> // JOIN cte1 ON ...
|
||||
> // JOIN cte2 ON ...
|
||||
> // JOIN far ON ...
|
||||
> let request = BaseRecord
|
||||
> .with(cte1).with(cte2)
|
||||
> .joining(required: assoc1. // OK
|
||||
> .joining(required: assoc2. // OK
|
||||
> .joining(required: assoc3))) // OK
|
||||
>
|
||||
> // Compiler error
|
||||
> let request = BaseRecord
|
||||
> .joining(required: assoc2) // Not OK
|
||||
> .joining(required: assoc3) // Not OK
|
||||
> ```
|
||||
|
||||
[query interface request]: ../README.md#requests
|
||||
[query interface requests]: ../README.md#requests
|
||||
[SQLRequest]: ../README.md#custom-requests
|
||||
[SQLiteral]: SQLInterpolation.md
|
||||
[SQL Interpolation]: SQLInterpolation.md
|
||||
[associations]: AssociationsBasics.md
|
||||
[Joining And Prefetching Associated Records]: AssociationsBasics.md#joining-and-prefetching-associated-records
|
||||
[Define Common Table Expressions]: #define-common-table-expressions
|
||||
[Embed Common Table Expressions in Requests]: #embed-common-table-expressions-in-requests
|
||||
[Fetch Values From Common Table Expressions]: #fetch-values-from-common-table-expressions
|
||||
[Associations to Common Table Expressions]: #associations-to-common-table-expressions
|
||||
[TableRecord]: ../README.md#tablerecord-protocol
|
||||
[TableAlias]: AssociationsBasics.md#table-aliases
|
||||
@@ -0,0 +1,4 @@
|
||||
:twisted_rightwards_arrows: Concurrency
|
||||
=======================================
|
||||
|
||||
This guide [has moved](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/concurrency).
|
||||
@@ -0,0 +1,136 @@
|
||||
Custom SQLite Builds
|
||||
====================
|
||||
|
||||
By default, GRDB uses the version of SQLite that ships with the target operating system.
|
||||
|
||||
**You can build GRDB with a custom build of [SQLite 3.44.0](https://www.sqlite.org/changes.html).**
|
||||
|
||||
A custom SQLite build can activate extra SQLite features, and extra GRDB features as well, such as support for the [FTS5 full-text search engine](../../../#full-text-search), and [SQLite Pre-Update Hooks](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/transactionobserver).
|
||||
|
||||
GRDB builds SQLite with [swiftlyfalling/SQLiteLib](https://github.com/swiftlyfalling/SQLiteLib), which uses the same SQLite configuration as the one used by Apple in its operating systems, and lets you add extra compilation options that leverage the features you need.
|
||||
|
||||
**To install GRDB with a custom SQLite build:**
|
||||
|
||||
1. Clone the GRDB git repository, checkout the latest tagged version:
|
||||
|
||||
```sh
|
||||
cd [GRDB directory]
|
||||
git checkout [latest tag]
|
||||
git submodule update --init SQLiteCustom/src
|
||||
```
|
||||
|
||||
2. Choose your [extra compilation options](https://www.sqlite.org/compile.html). For example, `SQLITE_ENABLE_FTS5`, `SQLITE_ENABLE_PREUPDATE_HOOK`.
|
||||
|
||||
It is recommended that you enable the `SQLITE_ENABLE_SNAPSHOT` option. It allows GRDB to optimize [ValueObservation](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation) when you use a [Database Pool](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasepool).
|
||||
|
||||
3. Create a folder named `GRDBCustomSQLite` somewhere in your project directory.
|
||||
|
||||
4. Create four files in the `GRDBCustomSQLite` folder:
|
||||
|
||||
- `SQLiteLib-USER.xcconfig`: this file sets the extra SQLite compilation flags.
|
||||
|
||||
```xcconfig
|
||||
// As many -D options as there are custom SQLite compilation options
|
||||
// Note: there is no space between -D and the option name.
|
||||
CUSTOM_SQLLIBRARY_CFLAGS = -DSQLITE_ENABLE_SNAPSHOT -DSQLITE_ENABLE_FTS5
|
||||
```
|
||||
|
||||
- `GRDBCustomSQLite-USER.xcconfig`: this file lets GRDB know about extra compilation flags, and enables extra GRDB APIs.
|
||||
|
||||
```xcconfig
|
||||
// As many -D options as there are custom SQLite compilation options
|
||||
// Note: there is one space between -D and the option name.
|
||||
CUSTOM_OTHER_SWIFT_FLAGS = -D SQLITE_ENABLE_SNAPSHOT -D SQLITE_ENABLE_FTS5
|
||||
```
|
||||
|
||||
- `GRDBCustomSQLite-USER.h`: this file lets your application know about extra compilation flags.
|
||||
|
||||
```c
|
||||
// As many #define as there are custom SQLite compilation options
|
||||
#define SQLITE_ENABLE_SNAPSHOT
|
||||
#define SQLITE_ENABLE_FTS5
|
||||
```
|
||||
|
||||
- `GRDBCustomSQLite-INSTALL.sh`: this file installs the three other files.
|
||||
|
||||
```sh
|
||||
# License: MIT License
|
||||
# https://github.com/swiftlyfalling/SQLiteLib/blob/master/LICENSE
|
||||
#
|
||||
#######################################################
|
||||
# PROJECT PATHS
|
||||
# !! MODIFY THESE TO MATCH YOUR PROJECT HIERARCHY !!
|
||||
#######################################################
|
||||
|
||||
# The path to the folder containing GRDBCustom.xcodeproj:
|
||||
GRDB_SOURCE_PATH="${PROJECT_DIR}/GRDB"
|
||||
|
||||
# The path to your custom "SQLiteLib-USER.xcconfig":
|
||||
SQLITELIB_XCCONFIG_USER_PATH="${PROJECT_DIR}/GRDBCustomSQLite/SQLiteLib-USER.xcconfig"
|
||||
|
||||
# The path to your custom "GRDBCustomSQLite-USER.xcconfig":
|
||||
CUSTOMSQLITE_XCCONFIG_USER_PATH="${PROJECT_DIR}/GRDBCustomSQLite/GRDBCustomSQLite-USER.xcconfig"
|
||||
|
||||
# The path to your custom "GRDBCustomSQLite-USER.h":
|
||||
CUSTOMSQLITE_H_USER_PATH="${PROJECT_DIR}/GRDBCustomSQLite/GRDBCustomSQLite-USER.h"
|
||||
|
||||
#######################################################
|
||||
#
|
||||
#######################################################
|
||||
|
||||
|
||||
if [ ! -d "$GRDB_SOURCE_PATH" ];
|
||||
then
|
||||
echo "error: Path to GRDB source (GRDB_SOURCE_PATH) missing/incorrect: $GRDB_SOURCE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SyncFileChanges () {
|
||||
SOURCE=$1
|
||||
DESTINATIONPATH=$2
|
||||
DESTINATIONFILENAME=$3
|
||||
DESTINATION="${DESTINATIONPATH}/${DESTINATIONFILENAME}"
|
||||
|
||||
if [ ! -f "$SOURCE" ];
|
||||
then
|
||||
echo "error: Source file missing: $SOURCE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rsync -a "$SOURCE" "$DESTINATION"
|
||||
}
|
||||
|
||||
SyncFileChanges $SQLITELIB_XCCONFIG_USER_PATH "${GRDB_SOURCE_PATH}/SQLiteCustom/src" "SQLiteLib-USER.xcconfig"
|
||||
SyncFileChanges $CUSTOMSQLITE_XCCONFIG_USER_PATH "${GRDB_SOURCE_PATH}/SQLiteCustom" "GRDBCustomSQLite-USER.xcconfig"
|
||||
SyncFileChanges $CUSTOMSQLITE_H_USER_PATH "${GRDB_SOURCE_PATH}/SQLiteCustom" "GRDBCustomSQLite-USER.h"
|
||||
|
||||
echo "Finished syncing"
|
||||
```
|
||||
|
||||
Modify the top of `GRDBCustomSQLite-INSTALL.sh` file so that it contains correct paths.
|
||||
|
||||
5. Embed the `GRDBCustom.xcodeproj` project in your own project.
|
||||
|
||||
6. Add the `GRDBCustom` target in the **Target Dependencies** section of the **Build Phases** tab of your **application target**.
|
||||
|
||||
7. Add the `GRDBCustom.framework` from the targeted platform to the **Embedded Binaries** section of the **General** tab of your **application target**.
|
||||
|
||||
8. Add a Run Script phase for your target in the **Pre-actions** section of the **Build** tab of your **application scheme**:
|
||||
|
||||
```sh
|
||||
source "${PROJECT_DIR}/GRDBCustomSQLite/GRDBCustomSQLite-INSTALL.sh"
|
||||
```
|
||||
|
||||
The path should be the path to your `GRDBCustomSQLite-INSTALL.sh` file.
|
||||
|
||||
Select your application target in the "Provide build settings from" menu.
|
||||
|
||||
9. Check the "Shared" checkbox of your application scheme (this lets you commit the pre-action in your Version Control System).
|
||||
|
||||
Now you can use GRDB with your custom SQLite build:
|
||||
|
||||
```swift
|
||||
import GRDB
|
||||
|
||||
let dbQueue = try DatabaseQueue(...)
|
||||
```
|
||||
@@ -0,0 +1,611 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */; };
|
||||
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */; };
|
||||
56519DCA274FC8E900ED16D8 /* GRDBQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 56519DC9274FC8E900ED16D8 /* GRDBQuery */; };
|
||||
5671723A261B23C800423B6F /* PlayerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717239261B23C800423B6F /* PlayerList.swift */; };
|
||||
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717251261B334D00423B6F /* PlayerRequestTests.swift */; };
|
||||
567C3E1A2520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E192520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift */; };
|
||||
567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */; };
|
||||
567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */; };
|
||||
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E532520B75C0011F6E9 /* Player.swift */; };
|
||||
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E542520B75C0011F6E9 /* Persistence.swift */; };
|
||||
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */; };
|
||||
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5A2520B75C0011F6E9 /* AppView.swift */; };
|
||||
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */; };
|
||||
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */; };
|
||||
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E652520B7880011F6E9 /* AppDatabase.swift */; };
|
||||
567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E752520BB650011F6E9 /* Localizable.stringsdict */; };
|
||||
567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */; };
|
||||
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */; };
|
||||
56F8A13527359A5A0011ACBE /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 56F8A13427359A5A0011ACBE /* GRDB */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
56026C9D25B8A7D000D1DF3F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 567C3E0E2520B6DE0011F6E9 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 567C3E152520B6DE0011F6E9;
|
||||
remoteInfo = GRDBAsyncDemo;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
567C3E502520B70E0011F6E9 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
56026C9825B8A7D000D1DF3F /* GRDBAsyncDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GRDBAsyncDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
56026C9C25B8A7D000D1DF3F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTests.swift; sourceTree = "<group>"; };
|
||||
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabaseTests.swift; sourceTree = "<group>"; };
|
||||
56717239261B23C800423B6F /* PlayerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = "<group>"; };
|
||||
56717251261B334D00423B6F /* PlayerRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequestTests.swift; sourceTree = "<group>"; };
|
||||
567C3E162520B6DE0011F6E9 /* GRDBAsyncDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GRDBAsyncDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
567C3E192520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBAsyncDemoApp.swift; sourceTree = "<group>"; };
|
||||
567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
567C3E222520B6DF0011F6E9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
567C3E532520B75C0011F6E9 /* Player.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
|
||||
567C3E542520B75C0011F6E9 /* Persistence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; };
|
||||
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormView.swift; sourceTree = "<group>"; };
|
||||
567C3E5A2520B75C0011F6E9 /* AppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = "<group>"; };
|
||||
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationView.swift; sourceTree = "<group>"; };
|
||||
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerEditionView.swift; sourceTree = "<group>"; };
|
||||
567C3E652520B7880011F6E9 /* AppDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = "<group>"; };
|
||||
567C3E762520BB650011F6E9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
567C3E782520BB650011F6E9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequest.swift; sourceTree = "<group>"; };
|
||||
56F8A12E27359A350011ACBE /* GRDB.swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = GRDB.swift; path = ../../..; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
56026C9525B8A7D000D1DF3F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E132520B6DE0011F6E9 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
56519DCA274FC8E900ED16D8 /* GRDBQuery in Frameworks */,
|
||||
56F8A13527359A5A0011ACBE /* GRDB in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
56026C9925B8A7D000D1DF3F /* GRDBAsyncDemoTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
56026C9C25B8A7D000D1DF3F /* Info.plist */,
|
||||
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */,
|
||||
56717251261B334D00423B6F /* PlayerRequestTests.swift */,
|
||||
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */,
|
||||
);
|
||||
path = GRDBAsyncDemoTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
56185BC125B8047D00B9C30F /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */,
|
||||
567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */,
|
||||
567C3E752520BB650011F6E9 /* Localizable.stringsdict */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E0D2520B6DE0011F6E9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E182520B6DE0011F6E9 /* GRDBAsyncDemo */,
|
||||
56026C9925B8A7D000D1DF3F /* GRDBAsyncDemoTests */,
|
||||
567C3E172520B6DE0011F6E9 /* Products */,
|
||||
567C3E4D2520B70E0011F6E9 /* Frameworks */,
|
||||
56F8A12E27359A350011ACBE /* GRDB.swift */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E172520B6DE0011F6E9 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E162520B6DE0011F6E9 /* GRDBAsyncDemo.app */,
|
||||
56026C9825B8A7D000D1DF3F /* GRDBAsyncDemoTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E182520B6DE0011F6E9 /* GRDBAsyncDemo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E222520B6DF0011F6E9 /* Info.plist */,
|
||||
567C3E652520B7880011F6E9 /* AppDatabase.swift */,
|
||||
567C3E192520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift */,
|
||||
567C3E542520B75C0011F6E9 /* Persistence.swift */,
|
||||
567C3E532520B75C0011F6E9 /* Player.swift */,
|
||||
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */,
|
||||
567C3E1F2520B6DF0011F6E9 /* Preview Content */,
|
||||
56185BC125B8047D00B9C30F /* Resources */,
|
||||
567C3E582520B75C0011F6E9 /* Views */,
|
||||
);
|
||||
path = GRDBAsyncDemo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E1F2520B6DF0011F6E9 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E4D2520B70E0011F6E9 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E582520B75C0011F6E9 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E5A2520B75C0011F6E9 /* AppView.swift */,
|
||||
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */,
|
||||
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */,
|
||||
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */,
|
||||
56717239261B23C800423B6F /* PlayerList.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
56026C9725B8A7D000D1DF3F /* GRDBAsyncDemoTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 56026CA825B8A7D000D1DF3F /* Build configuration list for PBXNativeTarget "GRDBAsyncDemoTests" */;
|
||||
buildPhases = (
|
||||
56026C9425B8A7D000D1DF3F /* Sources */,
|
||||
56026C9525B8A7D000D1DF3F /* Frameworks */,
|
||||
56026C9625B8A7D000D1DF3F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
56026C9E25B8A7D000D1DF3F /* PBXTargetDependency */,
|
||||
);
|
||||
name = GRDBAsyncDemoTests;
|
||||
productName = GRDBAsyncDemoTests;
|
||||
productReference = 56026C9825B8A7D000D1DF3F /* GRDBAsyncDemoTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
567C3E152520B6DE0011F6E9 /* GRDBAsyncDemo */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 567C3E252520B6DF0011F6E9 /* Build configuration list for PBXNativeTarget "GRDBAsyncDemo" */;
|
||||
buildPhases = (
|
||||
567C3E122520B6DE0011F6E9 /* Sources */,
|
||||
567C3E132520B6DE0011F6E9 /* Frameworks */,
|
||||
567C3E142520B6DE0011F6E9 /* Resources */,
|
||||
567C3E502520B70E0011F6E9 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
56F8A13127359A540011ACBE /* PBXTargetDependency */,
|
||||
);
|
||||
name = GRDBAsyncDemo;
|
||||
packageProductDependencies = (
|
||||
56F8A13427359A5A0011ACBE /* GRDB */,
|
||||
56519DC9274FC8E900ED16D8 /* GRDBQuery */,
|
||||
);
|
||||
productName = GRBCombineDemo;
|
||||
productReference = 567C3E162520B6DE0011F6E9 /* GRDBAsyncDemo.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
567C3E0E2520B6DE0011F6E9 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1250;
|
||||
LastUpgradeCheck = 1200;
|
||||
TargetAttributes = {
|
||||
56026C9725B8A7D000D1DF3F = {
|
||||
CreatedOnToolsVersion = 12.3;
|
||||
TestTargetID = 567C3E152520B6DE0011F6E9;
|
||||
};
|
||||
567C3E152520B6DE0011F6E9 = {
|
||||
CreatedOnToolsVersion = 12.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 567C3E112520B6DE0011F6E9 /* Build configuration list for PBXProject "GRDBAsyncDemo" */;
|
||||
compatibilityVersion = "Xcode 12.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 567C3E0D2520B6DE0011F6E9;
|
||||
packageReferences = (
|
||||
56519DC8274FC8E900ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */,
|
||||
);
|
||||
productRefGroup = 567C3E172520B6DE0011F6E9 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
567C3E152520B6DE0011F6E9 /* GRDBAsyncDemo */,
|
||||
56026C9725B8A7D000D1DF3F /* GRDBAsyncDemoTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
56026C9625B8A7D000D1DF3F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E142520B6DE0011F6E9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */,
|
||||
567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */,
|
||||
567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */,
|
||||
567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
56026C9425B8A7D000D1DF3F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */,
|
||||
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */,
|
||||
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E122520B6DE0011F6E9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */,
|
||||
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */,
|
||||
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */,
|
||||
5671723A261B23C800423B6F /* PlayerList.swift in Sources */,
|
||||
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */,
|
||||
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */,
|
||||
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */,
|
||||
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */,
|
||||
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */,
|
||||
567C3E1A2520B6DE0011F6E9 /* GRDBAsyncDemoApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
56026C9E25B8A7D000D1DF3F /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 567C3E152520B6DE0011F6E9 /* GRDBAsyncDemo */;
|
||||
targetProxy = 56026C9D25B8A7D000D1DF3F /* PBXContainerItemProxy */;
|
||||
};
|
||||
56F8A13127359A540011ACBE /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 56F8A13027359A540011ACBE /* GRDB */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
567C3E752520BB650011F6E9 /* Localizable.stringsdict */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
567C3E762520BB650011F6E9 /* en */,
|
||||
);
|
||||
name = Localizable.stringsdict;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
567C3E782520BB650011F6E9 /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
56026C9F25B8A7D000D1DF3F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = GRDBAsyncDemoTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemoTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDBAsyncDemo.app/GRDBAsyncDemo";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
56026CA025B8A7D000D1DF3F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = GRDBAsyncDemoTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemoTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDBAsyncDemo.app/GRDBAsyncDemo";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
567C3E232520B6DF0011F6E9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
567C3E242520B6DF0011F6E9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
567C3E262520B6DF0011F6E9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"GRDBAsyncDemo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = GRDBAsyncDemo/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
567C3E272520B6DF0011F6E9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"GRDBAsyncDemo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = GRDBAsyncDemo/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBAsyncDemo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
56026CA825B8A7D000D1DF3F /* Build configuration list for PBXNativeTarget "GRDBAsyncDemoTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
56026C9F25B8A7D000D1DF3F /* Debug */,
|
||||
56026CA025B8A7D000D1DF3F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
567C3E112520B6DE0011F6E9 /* Build configuration list for PBXProject "GRDBAsyncDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
567C3E232520B6DF0011F6E9 /* Debug */,
|
||||
567C3E242520B6DF0011F6E9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
567C3E252520B6DF0011F6E9 /* Build configuration list for PBXNativeTarget "GRDBAsyncDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
567C3E262520B6DF0011F6E9 /* Debug */,
|
||||
567C3E272520B6DF0011F6E9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
56519DC8274FC8E900ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/groue/GRDBQuery";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.6.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
56519DC9274FC8E900ED16D8 /* GRDBQuery */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 56519DC8274FC8E900ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */;
|
||||
productName = GRDBQuery;
|
||||
};
|
||||
56F8A13027359A540011ACBE /* GRDB */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = GRDB;
|
||||
};
|
||||
56F8A13427359A5A0011ACBE /* GRDB */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = GRDB;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 567C3E0E2520B6DE0011F6E9 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "grdbquery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/groue/GRDBQuery",
|
||||
"state" : {
|
||||
"revision" : "a6c46dd38ecf11a5c37732870dc03a384d582fba",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBAsyncDemo.app"
|
||||
BlueprintName = "GRDBAsyncDemo"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "56026C9725B8A7D000D1DF3F"
|
||||
BuildableName = "GRDBAsyncDemoTests.xctest"
|
||||
BlueprintName = "GRDBAsyncDemoTests"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBAsyncDemo.app"
|
||||
BlueprintName = "GRDBAsyncDemo"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "SQL_TRACE"
|
||||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBAsyncDemo.app"
|
||||
BlueprintName = "GRDBAsyncDemo"
|
||||
ReferencedContainer = "container:GRDBAsyncDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,235 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
import os.log
|
||||
|
||||
/// A database of players.
|
||||
///
|
||||
/// You create an `AppDatabase` with a connection to an SQLite database
|
||||
/// (see <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>).
|
||||
///
|
||||
/// Create those connections with a configuration returned from
|
||||
/// `AppDatabase/makeConfiguration(_:)`.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Create an in-memory AppDatabase
|
||||
/// let config = AppDatabase.makeConfiguration()
|
||||
/// let dbQueue = try DatabaseQueue(configuration: config)
|
||||
/// let appDatabase = try AppDatabase(dbQueue)
|
||||
/// ```
|
||||
struct AppDatabase {
|
||||
/// Creates an `AppDatabase`, and makes sure the database schema
|
||||
/// is ready.
|
||||
///
|
||||
/// - important: Create the `DatabaseWriter` with a configuration
|
||||
/// returned by ``makeConfiguration(_:)``.
|
||||
init(_ dbWriter: any DatabaseWriter) throws {
|
||||
self.dbWriter = dbWriter
|
||||
try migrator.migrate(dbWriter)
|
||||
}
|
||||
|
||||
/// Provides access to the database.
|
||||
///
|
||||
/// Application can use a `DatabasePool`, while SwiftUI previews and tests
|
||||
/// can use a fast in-memory `DatabaseQueue`.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
|
||||
private let dbWriter: any DatabaseWriter
|
||||
}
|
||||
|
||||
// MARK: - Database Configuration
|
||||
|
||||
extension AppDatabase {
|
||||
private static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL")
|
||||
|
||||
/// Returns a database configuration suited for `PlayerRepository`.
|
||||
///
|
||||
/// SQL statements are logged if the `SQL_TRACE` environment variable
|
||||
/// is set.
|
||||
///
|
||||
/// - parameter base: A base configuration.
|
||||
public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration {
|
||||
var config = base
|
||||
|
||||
// An opportunity to add required custom SQL functions or
|
||||
// collations, if needed:
|
||||
// config.prepareDatabase { db in
|
||||
// db.add(function: ...)
|
||||
// }
|
||||
|
||||
// Log SQL statements if the `SQL_TRACE` environment variable is set.
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/database/trace(options:_:)>
|
||||
if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil {
|
||||
config.prepareDatabase { db in
|
||||
db.trace {
|
||||
// It's ok to log statements publicly. Sensitive
|
||||
// information (statement arguments) are not logged
|
||||
// unless config.publicStatementArguments is set
|
||||
// (see below).
|
||||
os_log("%{public}@", log: sqlLogger, type: .debug, String(describing: $0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Protect sensitive information by enabling verbose debugging in
|
||||
// DEBUG builds only.
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/configuration/publicstatementarguments>
|
||||
config.publicStatementArguments = true
|
||||
#endif
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Migrations
|
||||
|
||||
extension AppDatabase {
|
||||
/// The DatabaseMigrator that defines the database schema.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
|
||||
private var migrator: DatabaseMigrator {
|
||||
var migrator = DatabaseMigrator()
|
||||
|
||||
#if DEBUG
|
||||
// Speed up development by nuking the database when migrations change
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
|
||||
migrator.eraseDatabaseOnSchemaChange = true
|
||||
#endif
|
||||
|
||||
migrator.registerMigration("createPlayer") { db in
|
||||
// Create a table
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseschema>
|
||||
try db.create(table: "player") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("score", .integer).notNull()
|
||||
}
|
||||
}
|
||||
|
||||
// Migrations for future application versions will be inserted here:
|
||||
// migrator.registerMigration(...) { db in
|
||||
// ...
|
||||
// }
|
||||
|
||||
return migrator
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Access: Writes
|
||||
// The write methods execute invariant-preserving database transactions.
|
||||
|
||||
extension AppDatabase {
|
||||
/// A validation error that prevents some players from being saved into
|
||||
/// the database.
|
||||
enum ValidationError: LocalizedError {
|
||||
case missingName
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingName:
|
||||
return "Please provide a name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves (inserts or updates) a player. When the method returns, the
|
||||
/// player is present in the database, and its id is not nil.
|
||||
func savePlayer(_ player: inout Player) async throws {
|
||||
if player.name.isEmpty {
|
||||
throw ValidationError.missingName
|
||||
}
|
||||
player = try await dbWriter.write { [player] db in
|
||||
try player.saved(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the specified players
|
||||
func deletePlayers(ids: [Int64]) async throws {
|
||||
try await dbWriter.write { db in
|
||||
_ = try Player.deleteAll(db, ids: ids)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all players
|
||||
func deleteAllPlayers() async throws {
|
||||
try await dbWriter.write { db in
|
||||
_ = try Player.deleteAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh all players (by performing some random changes, for demo purpose).
|
||||
func refreshPlayers() async throws {
|
||||
try await dbWriter.write { db in
|
||||
if try Player.all().isEmpty(db) {
|
||||
// When database is empty, insert new random players
|
||||
try createRandomPlayers(db)
|
||||
} else {
|
||||
// Insert a player
|
||||
if Bool.random() {
|
||||
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
|
||||
// Delete a random player
|
||||
if Bool.random() {
|
||||
try Player.order(sql: "RANDOM()").limit(1).deleteAll(db)
|
||||
}
|
||||
|
||||
// Update some players
|
||||
for var player in try Player.fetchAll(db) where Bool.random() {
|
||||
try player.updateChanges(db) {
|
||||
$0.score = Player.randomScore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create random players if the database is empty.
|
||||
func createRandomPlayersIfEmpty() throws {
|
||||
try dbWriter.write { db in
|
||||
if try Player.all().isEmpty(db) {
|
||||
try createRandomPlayers(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let uiTestPlayers = [
|
||||
Player(id: nil, name: "Arthur", score: 5),
|
||||
Player(id: nil, name: "Barbara", score: 6),
|
||||
Player(id: nil, name: "Craig", score: 8),
|
||||
Player(id: nil, name: "David", score: 4),
|
||||
Player(id: nil, name: "Elena", score: 1),
|
||||
Player(id: nil, name: "Frederik", score: 2),
|
||||
Player(id: nil, name: "Gilbert", score: 7),
|
||||
Player(id: nil, name: "Henriette", score: 3)]
|
||||
|
||||
func createPlayersForUITests() throws {
|
||||
try dbWriter.write { db in
|
||||
try AppDatabase.uiTestPlayers.forEach { player in
|
||||
_ = try player.inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Support for `createRandomPlayersIfEmpty()` and `refreshPlayers()`.
|
||||
private func createRandomPlayers(_ db: Database) throws {
|
||||
for _ in 0..<8 {
|
||||
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Access: Reads
|
||||
|
||||
// This demo app does not provide any specific reading method, and instead
|
||||
// gives an unrestricted read-only access to the rest of the application.
|
||||
// In your app, you are free to choose another path, and define focused
|
||||
// reading methods.
|
||||
extension AppDatabase {
|
||||
/// Provides a read-only access to the database
|
||||
var reader: DatabaseReader {
|
||||
dbWriter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import GRDBQuery
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct GRDBAsyncDemoApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AppView().appDatabase(.shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Give SwiftUI access to the database
|
||||
|
||||
private struct AppDatabaseKey: EnvironmentKey {
|
||||
static var defaultValue: AppDatabase { .empty() }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var appDatabase: AppDatabase {
|
||||
get { self[AppDatabaseKey.self] }
|
||||
set { self[AppDatabaseKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appDatabase(_ appDatabase: AppDatabase) -> some View {
|
||||
self
|
||||
.environment(\.appDatabase, appDatabase)
|
||||
.databaseContext(.readOnly { appDatabase.reader })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
extension AppDatabase {
|
||||
/// The database for the application
|
||||
static let shared = makeShared()
|
||||
|
||||
private static func makeShared() -> AppDatabase {
|
||||
do {
|
||||
// Apply recommendations from
|
||||
// <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
|
||||
//
|
||||
// Create the "Application Support/Database" directory if needed
|
||||
let fileManager = FileManager.default
|
||||
let appSupportURL = try fileManager.url(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask,
|
||||
appropriateFor: nil, create: true)
|
||||
let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true)
|
||||
|
||||
// Support for tests: delete the database if requested
|
||||
if CommandLine.arguments.contains("-reset") {
|
||||
try? fileManager.removeItem(at: directoryURL)
|
||||
}
|
||||
|
||||
// Create the database folder if needed
|
||||
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
|
||||
// Open or create the database
|
||||
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
|
||||
NSLog("Database stored at \(databaseURL.path)")
|
||||
let dbPool = try DatabasePool(
|
||||
path: databaseURL.path,
|
||||
// Use default AppDatabase configuration
|
||||
configuration: AppDatabase.makeConfiguration())
|
||||
|
||||
// Create the AppDatabase
|
||||
let appDatabase = try AppDatabase(dbPool)
|
||||
|
||||
// Prepare the database with test fixtures if requested
|
||||
if CommandLine.arguments.contains("-fixedTestData") {
|
||||
try appDatabase.createPlayersForUITests()
|
||||
} else {
|
||||
// Otherwise, populate the database if it is empty, for better
|
||||
// demo purpose.
|
||||
try appDatabase.createRandomPlayersIfEmpty()
|
||||
}
|
||||
|
||||
return appDatabase
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate.
|
||||
//
|
||||
// Typical reasons for an error here include:
|
||||
// * The parent directory cannot be created, or disallows writing.
|
||||
// * The database is not accessible, due to permissions or data protection when the device is locked.
|
||||
// * The device is out of space.
|
||||
// * The database could not be migrated to its latest schema version.
|
||||
// Check the error message to determine what the actual problem was.
|
||||
fatalError("Unresolved error \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty database for SwiftUI previews
|
||||
static func empty() -> AppDatabase {
|
||||
// Connect to an in-memory database
|
||||
// See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections
|
||||
let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
return try! AppDatabase(dbQueue)
|
||||
}
|
||||
|
||||
/// Creates a database full of random players for SwiftUI previews
|
||||
static func random() -> AppDatabase {
|
||||
let appDatabase = empty()
|
||||
try! appDatabase.createRandomPlayersIfEmpty()
|
||||
return appDatabase
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import GRDB
|
||||
|
||||
/// The Player struct.
|
||||
///
|
||||
/// Identifiable conformance supports SwiftUI list animations, and type-safe
|
||||
/// GRDB primary key methods.
|
||||
/// Equatable conformance supports tests.
|
||||
struct Player: Identifiable, Equatable {
|
||||
/// The player id.
|
||||
///
|
||||
/// Int64 is the recommended type for auto-incremented database ids.
|
||||
/// Use nil for players that are not inserted yet in the database.
|
||||
var id: Int64?
|
||||
var name: String
|
||||
var score: Int
|
||||
}
|
||||
|
||||
extension Player {
|
||||
private static let names = [
|
||||
"Arthur", "Anita", "Barbara", "Bernard", "Craig", "Chiara", "David",
|
||||
"Dean", "Éric", "Elena", "Fatima", "Frederik", "Gilbert", "Georgette",
|
||||
"Henriette", "Hassan", "Ignacio", "Irene", "Julie", "Jack", "Karl",
|
||||
"Kristel", "Louis", "Liz", "Masashi", "Mary", "Noam", "Nicole",
|
||||
"Ophelie", "Oleg", "Pascal", "Patricia", "Quentin", "Quinn", "Raoul",
|
||||
"Rachel", "Stephan", "Susie", "Tristan", "Tatiana", "Ursule", "Urbain",
|
||||
"Victor", "Violette", "Wilfried", "Wilhelmina", "Yvon", "Yann",
|
||||
"Zazie", "Zoé"]
|
||||
|
||||
/// Creates a new player with empty name and zero score
|
||||
static func new() -> Player {
|
||||
Player(id: nil, name: "", score: 0)
|
||||
}
|
||||
|
||||
/// Creates a new player with random name and random score
|
||||
static func makeRandom() -> Player {
|
||||
Player(id: nil, name: randomName(), score: randomScore())
|
||||
}
|
||||
|
||||
/// Returns a random name
|
||||
static func randomName() -> String {
|
||||
names.randomElement()!
|
||||
}
|
||||
|
||||
/// Returns a random score
|
||||
static func randomScore() -> Int {
|
||||
10 * Int.random(in: 0...100)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Make Player a Codable Record.
|
||||
///
|
||||
/// See <https://github.com/groue/GRDB.swift/blob/master/README.md#records>
|
||||
extension Player: Codable, FetchableRecord, MutablePersistableRecord {
|
||||
// Define database columns from CodingKeys
|
||||
fileprivate enum Columns {
|
||||
static let name = Column(CodingKeys.name)
|
||||
static let score = Column(CodingKeys.score)
|
||||
}
|
||||
|
||||
/// Updates a player id after it has been inserted in the database.
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Database Requests
|
||||
|
||||
/// Define some player requests used by the application.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/recordrecommendedpractices>
|
||||
extension DerivableRequest<Player> {
|
||||
/// A request of players ordered by name.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// let players: [Player] = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByName().fetchAll(db)
|
||||
/// }
|
||||
func orderedByName() -> Self {
|
||||
// Sort by name in a localized case insensitive fashion
|
||||
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
|
||||
order(Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
|
||||
}
|
||||
|
||||
/// A request of players ordered by score.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// let players: [Player] = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByScore().fetchAll(db)
|
||||
/// }
|
||||
/// let bestPlayer: Player? = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByScore().fetchOne(db)
|
||||
/// }
|
||||
func orderedByScore() -> Self {
|
||||
// Sort by descending score, and then by name, in a
|
||||
// localized case insensitive fashion
|
||||
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
|
||||
order(
|
||||
Player.Columns.score.desc,
|
||||
Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import GRDB
|
||||
import GRDBQuery
|
||||
|
||||
/// A player request can be used with the `@Query` property wrapper in order to
|
||||
/// feed a view with a list of players.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// struct MyView: View {
|
||||
/// @Query(PlayerRequest(ordering: .byName)) private var players: [Player]
|
||||
///
|
||||
/// var body: some View {
|
||||
/// List(players) { player in ... )
|
||||
/// }
|
||||
/// }
|
||||
struct PlayerRequest: ValueObservationQueryable {
|
||||
enum Ordering {
|
||||
case byScore
|
||||
case byName
|
||||
}
|
||||
|
||||
static var defaultValue: [Player] { [] }
|
||||
|
||||
/// The ordering used by the player request.
|
||||
var ordering: Ordering
|
||||
|
||||
func fetch(_ db: Database) throws -> [Player] {
|
||||
switch ordering {
|
||||
case .byScore:
|
||||
return try Player.all().orderedByScore().fetchAll(db)
|
||||
case .byName:
|
||||
return try Player.all().orderedByName().fetchAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_20pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_20pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_29pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_29pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_40pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_40pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_60pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_60pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_20pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_20pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_29pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_29pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_40pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_40pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_76pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_76pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "LaunchIcon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17156" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17125"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LaunchIcon" translatesAutoresizingMaskIntoConstraints="NO" id="Sgi-te-PKu">
|
||||
<rect key="frame" x="123.5" y="334.5" width="167" height="237"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Sgi-te-PKu" firstAttribute="centerY" secondItem="6Tk-OE-BBY" secondAttribute="centerY" id="KT7-xd-gV4"/>
|
||||
<constraint firstItem="Sgi-te-PKu" firstAttribute="centerX" secondItem="6Tk-OE-BBY" secondAttribute="centerX" id="feL-Vs-SeN"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchIcon" width="167" height="237"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>%lld Players</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@VARIABLE@</string>
|
||||
<key>VARIABLE</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>lld</string>
|
||||
<key>zero</key>
|
||||
<string>No Player</string>
|
||||
<key>one</key>
|
||||
<string>1 Player</string>
|
||||
<key>other</key>
|
||||
<string>%lld Players</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>%lld points</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@VARIABLE@</string>
|
||||
<key>VARIABLE</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>lld</string>
|
||||
<key>zero</key>
|
||||
<string>0 point</string>
|
||||
<key>one</key>
|
||||
<string>1 point</string>
|
||||
<key>other</key>
|
||||
<string>%lld points</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,154 @@
|
||||
import GRDBQuery
|
||||
import SwiftUI
|
||||
|
||||
/// The main application view
|
||||
struct AppView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
|
||||
/// The `players` property is automatically updated when the database changes
|
||||
@Query(PlayerRequest(ordering: .byScore)) private var players: [Player]
|
||||
|
||||
/// We'll need to leave edit mode in several occasions.
|
||||
@State private var editMode = EditMode.inactive
|
||||
|
||||
/// Tracks the presentation of the player creation sheet.
|
||||
@State private var newPlayerIsPresented = false
|
||||
|
||||
// If you want to define the query on initialization, you will prefer:
|
||||
//
|
||||
// @Query<PlayerRequest> private var players: [Player]
|
||||
//
|
||||
// init(initialOrdering: PlayerRequest.Ordering) {
|
||||
// _players = Query(PlayerRequest(ordering: initialOrdering))
|
||||
// }
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
PlayerList(players: players)
|
||||
.navigationBarTitle(Text("\(players.count) Players"))
|
||||
.navigationBarItems(
|
||||
leading: HStack {
|
||||
EditButton()
|
||||
newPlayerButton
|
||||
},
|
||||
trailing: ToggleOrderingButton(
|
||||
ordering: $players.ordering,
|
||||
willChange: {
|
||||
// onChange(of: $players.wrappedValue.ordering)
|
||||
// is not able to leave the editing mode during
|
||||
// the animation of the list content.
|
||||
// Workaround: stop editing before the ordering
|
||||
// is changed, and the list content is updated.
|
||||
stopEditing()
|
||||
}))
|
||||
.toolbar { toolbarContent }
|
||||
.onChange(of: players) {
|
||||
if players.isEmpty {
|
||||
stopEditing()
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Button {
|
||||
// Don't stopEditing() here because this is
|
||||
// performed `onChange(of: players)`
|
||||
Task {
|
||||
try? await appDatabase.deleteAllPlayers()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash").imageScale(.large)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
stopEditing()
|
||||
Task {
|
||||
try? await appDatabase.refreshPlayers()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise").imageScale(.large)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
stopEditing()
|
||||
// Perform 50 refreshes in parallel
|
||||
Task {
|
||||
try? await withThrowingTaskGroup(of: Void.self) { group in
|
||||
for _ in 0..<50 {
|
||||
_ = group.addTaskUnlessCancelled {
|
||||
try await appDatabase.refreshPlayers()
|
||||
}
|
||||
}
|
||||
try await group.waitForAll()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "tornado").imageScale(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The button that presents the player creation sheet.
|
||||
private var newPlayerButton: some View {
|
||||
Button {
|
||||
stopEditing()
|
||||
newPlayerIsPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibility(label: Text("New Player"))
|
||||
.sheet(isPresented: $newPlayerIsPresented) {
|
||||
PlayerCreationView()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEditing() {
|
||||
withAnimation {
|
||||
editMode = .inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleOrderingButton: View {
|
||||
@Binding var ordering: PlayerRequest.Ordering
|
||||
let willChange: () -> Void
|
||||
|
||||
var body: some View {
|
||||
switch ordering {
|
||||
case .byName:
|
||||
Button {
|
||||
willChange()
|
||||
ordering = .byScore
|
||||
} label: {
|
||||
Label("Name", systemImage: "arrowtriangle.up.fill").labelStyle(.titleAndIcon)
|
||||
}
|
||||
case .byScore:
|
||||
Button {
|
||||
willChange()
|
||||
ordering = .byName
|
||||
} label: {
|
||||
Label("Score", systemImage: "arrowtriangle.down.fill").labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty") {
|
||||
// Preview the default, empty database
|
||||
AppView()
|
||||
}
|
||||
|
||||
#Preview("Populated") {
|
||||
// Preview a database of random players
|
||||
AppView().appDatabase(.random())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The view that creates a new player.
|
||||
struct PlayerCreationView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var form = PlayerForm(name: "", score: "")
|
||||
@State private var errorAlertIsPresented = false
|
||||
@State private var errorAlertTitle = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
PlayerFormView(form: $form)
|
||||
.alert(
|
||||
isPresented: $errorAlertIsPresented,
|
||||
content: { Alert(title: Text(errorAlertTitle)) })
|
||||
.navigationBarTitle("New Player")
|
||||
.navigationBarItems(
|
||||
leading: Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
},
|
||||
trailing: Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
Text("Save")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
do {
|
||||
var player = Player(id: nil, name: "", score: 0)
|
||||
form.apply(to: &player)
|
||||
try await appDatabase.savePlayer(&player)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
|
||||
errorAlertIsPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
PlayerCreationView()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The view that edits an existing player.
|
||||
struct PlayerEditionView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
@Environment(\.isPresented) private var isPresented
|
||||
private let player: Player
|
||||
@State private var form: PlayerForm
|
||||
|
||||
init(player: Player) {
|
||||
self.player = player
|
||||
self.form = PlayerForm(player)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerFormView(form: $form)
|
||||
.onChange(of: isPresented) {
|
||||
// Save when back button is pressed
|
||||
if !isPresented {
|
||||
Task {
|
||||
var savedPlayer = player
|
||||
form.apply(to: &savedPlayer)
|
||||
// Ignore error because I don't know how to cancel the
|
||||
// back button and present the error
|
||||
try? await appDatabase.savePlayer(&savedPlayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
PlayerEditionView(player: Player.makeRandom())
|
||||
.navigationBarTitle("Player Edition")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The Player editing form, embedded in both
|
||||
/// `PlayerCreationView` and `PlayerEditionView`.
|
||||
struct PlayerFormView: View {
|
||||
@Binding var form: PlayerForm
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
TextField("Name", text: $form.name)
|
||||
.accessibility(label: Text("Player Name"))
|
||||
TextField("Score", text: $form.score).keyboardType(.numberPad)
|
||||
.accessibility(label: Text("Player Score"))
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerForm {
|
||||
var name: String
|
||||
var score: String
|
||||
}
|
||||
|
||||
extension PlayerForm {
|
||||
init(_ player: Player) {
|
||||
self.name = player.name
|
||||
self.score = "\(player.score)"
|
||||
}
|
||||
|
||||
func apply(to player: inout Player) {
|
||||
player.name = name
|
||||
player.score = Int(score) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty") {
|
||||
PlayerFormView(form: .constant(PlayerForm(
|
||||
name: "",
|
||||
score: "")))
|
||||
}
|
||||
|
||||
#Preview("Prefilled") {
|
||||
PlayerFormView(form: .constant(PlayerForm(
|
||||
name: Player.randomName(),
|
||||
score: "\(Player.randomScore())")))
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerList: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
|
||||
/// The players in the list
|
||||
var players: [Player]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(players) { player in
|
||||
NavigationLink(destination: editionView(for: player)) {
|
||||
PlayerRow(player: player)
|
||||
// Don't animate player update
|
||||
.animation(nil, value: player)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
let playerIds = offsets.compactMap { players[$0].id }
|
||||
Task {
|
||||
try? await appDatabase.deletePlayers(ids: playerIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Animate list updates
|
||||
.animation(.default, value: players)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
/// The view that edits a player in the list.
|
||||
private func editionView(for player: Player) -> some View {
|
||||
PlayerEditionView(player: player).navigationBarTitle(player.name)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlayerRow: View {
|
||||
var player: Player
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(player.name)
|
||||
Spacer()
|
||||
Text("\(player.score) points").foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
PlayerList(players: [
|
||||
Player(id: 1, name: "Arthur", score: 100),
|
||||
Player(id: 2, name: "Barbara", score: 1000),
|
||||
])
|
||||
.navigationTitle("Preview")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import XCTest
|
||||
import GRDB
|
||||
@testable import GRDBAsyncDemo
|
||||
|
||||
class AppDatabaseTests: XCTestCase {
|
||||
func test_database_schema() throws {
|
||||
// Given an empty database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
|
||||
// When we instantiate an AppDatabase
|
||||
_ = try AppDatabase(dbQueue)
|
||||
|
||||
// Then the player table exists, with id, name & score columns
|
||||
try dbQueue.read { db in
|
||||
try XCTAssert(db.tableExists("player"))
|
||||
let columns = try db.columns(in: "player")
|
||||
let columnNames = Set(columns.map { $0.name })
|
||||
XCTAssertEqual(columnNames, ["id", "name", "score"])
|
||||
}
|
||||
}
|
||||
|
||||
func test_savePlayer_inserts() async throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
|
||||
// When we save a new player
|
||||
var player = Player(id: nil, name: "Arthur", score: 100)
|
||||
try await appDatabase.savePlayer(&player)
|
||||
|
||||
// Then the player exists in the database
|
||||
let playerExists = try await dbQueue.read { [player] in try player.exists($0) }
|
||||
XCTAssertTrue(playerExists)
|
||||
}
|
||||
|
||||
func test_savePlayer_updates() async throws {
|
||||
// Given a players database that contains a player
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
var player = try await dbQueue.write { db in
|
||||
try Player(id: nil, name: "Arthur", score: 100).inserted(db)
|
||||
}
|
||||
|
||||
// When we modify and save the player
|
||||
player.name = "Barbara"
|
||||
player.score = 1000
|
||||
try await appDatabase.savePlayer(&player)
|
||||
|
||||
// Then the player has been updated in the database
|
||||
let fetchedPlayer = try await dbQueue.read { [player] db in
|
||||
try XCTUnwrap(Player.fetchOne(db, key: player.id))
|
||||
}
|
||||
XCTAssertEqual(fetchedPlayer, player)
|
||||
}
|
||||
|
||||
func test_deletePlayers() async throws {
|
||||
// Given a players database that contains four players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
let playerIds: [Int64] = try await dbQueue.write { db in
|
||||
_ = try Player(id: nil, name: "Arthur", score: 100).inserted(db)
|
||||
_ = try Player(id: nil, name: "Barbara", score: 200).inserted(db)
|
||||
_ = try Player(id: nil, name: "Craig", score: 150).inserted(db)
|
||||
_ = try Player(id: nil, name: "David", score: 120).inserted(db)
|
||||
return try Player.selectPrimaryKey().fetchAll(db)
|
||||
}
|
||||
|
||||
// When we delete two players
|
||||
let deletedId1 = playerIds[0]
|
||||
let deletedId2 = playerIds[2]
|
||||
try await appDatabase.deletePlayers(ids: [deletedId1, deletedId2])
|
||||
|
||||
// Then the deleted players no longer exist
|
||||
try await dbQueue.read { db in
|
||||
try XCTAssertFalse(Player.exists(db, id: deletedId1))
|
||||
try XCTAssertFalse(Player.exists(db, id: deletedId2))
|
||||
}
|
||||
|
||||
// Then the database still contains two players
|
||||
let count = try await dbQueue.read { try Player.fetchCount($0) }
|
||||
XCTAssertEqual(count, 2)
|
||||
}
|
||||
|
||||
func test_deleteAllPlayers() async throws {
|
||||
// Given a players database that contains players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
try await dbQueue.write { db in
|
||||
_ = try Player(id: nil, name: "Arthur", score: 100).inserted(db)
|
||||
_ = try Player(id: nil, name: "Barbara", score: 200).inserted(db)
|
||||
_ = try Player(id: nil, name: "Craig", score: 150).inserted(db)
|
||||
_ = try Player(id: nil, name: "David", score: 120).inserted(db)
|
||||
}
|
||||
|
||||
// When we delete all players
|
||||
try await appDatabase.deleteAllPlayers()
|
||||
|
||||
// Then the database does not contain any player
|
||||
let count = try await dbQueue.read { try Player.fetchCount($0) }
|
||||
XCTAssertEqual(count, 0)
|
||||
}
|
||||
|
||||
func test_refreshPlayers_populates_an_empty_database() async throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
|
||||
// When we refresh players
|
||||
try await appDatabase.refreshPlayers()
|
||||
|
||||
// Then the database is not empty
|
||||
let count = try await dbQueue.read { try Player.fetchCount($0) }
|
||||
XCTAssert(count > 0)
|
||||
}
|
||||
|
||||
func test_createRandomPlayersIfEmpty_populates_an_empty_database() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
|
||||
// When we create random players
|
||||
try appDatabase.createRandomPlayersIfEmpty()
|
||||
|
||||
// Then the database is not empty
|
||||
try XCTAssert(dbQueue.read(Player.fetchCount) > 0)
|
||||
}
|
||||
|
||||
func test_createRandomPlayersIfEmpty_does_not_modify_a_non_empty_database() throws {
|
||||
// Given a players database that contains one player
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
var player = Player(id: nil, name: "Arthur", score: 100)
|
||||
try dbQueue.write { db in
|
||||
try player.insert(db)
|
||||
}
|
||||
|
||||
// When we create random players
|
||||
try appDatabase.createRandomPlayersIfEmpty()
|
||||
|
||||
// Then the database still only contains the original player
|
||||
let players = try dbQueue.read(Player.fetchAll)
|
||||
XCTAssertEqual(players, [player])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,43 @@
|
||||
import XCTest
|
||||
import GRDB
|
||||
@testable import GRDBAsyncDemo
|
||||
|
||||
class PlayerRequestTests: XCTestCase {
|
||||
func test_PlayerRequest_byName_fetches_well_ordered_players() throws {
|
||||
// Given a players database that contains two players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: nil, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: nil, name: "Barbara", score: 1000)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by name
|
||||
let playerRequest = PlayerRequest(ordering: .byName)
|
||||
let players = try dbQueue.read(playerRequest.fetch)
|
||||
|
||||
// Then the players are the two players ordered by name
|
||||
XCTAssertEqual(players, [player1, player2])
|
||||
}
|
||||
|
||||
func test_PlayerRequest_byScore_fetches_well_ordered_players() throws {
|
||||
// Given a players database that contains two players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: nil, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: nil, name: "Barbara", score: 1000)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by score
|
||||
let playerRequest = PlayerRequest(ordering: .byScore)
|
||||
let players = try dbQueue.read(playerRequest.fetch)
|
||||
|
||||
// Then the players are the two players ordered by score descending
|
||||
XCTAssertEqual(players, [player2, player1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import XCTest
|
||||
import GRDB
|
||||
@testable import GRDBAsyncDemo
|
||||
|
||||
class PlayerTests: XCTestCase {
|
||||
// MARK: - CRUD
|
||||
// Test that our Player type properly talks to GRDB.
|
||||
|
||||
func testInsert() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
|
||||
// When we insert a player
|
||||
var player = Player(id: nil, name: "Arthur", score: 100)
|
||||
try dbQueue.write { db in
|
||||
try player.insert(db)
|
||||
}
|
||||
|
||||
// Then the player gets a non-nil id
|
||||
XCTAssertNotNil(player.id)
|
||||
}
|
||||
|
||||
func testRoundtrip() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
|
||||
// When we insert a player and fetch the player with the same id
|
||||
var insertedPlayer = Player(id: nil, name: "Arthur", score: 100)
|
||||
let fetchedPlayer: Player? = try dbQueue.write { db in
|
||||
try insertedPlayer.insert(db)
|
||||
return try Player.fetchOne(db, key: insertedPlayer.id)
|
||||
}
|
||||
|
||||
// Then the fetched player is equal to the inserted player
|
||||
XCTAssertEqual(insertedPlayer, fetchedPlayer)
|
||||
}
|
||||
|
||||
// MARK: - Requests
|
||||
// Test that requests defined on the Player type behave as expected.
|
||||
|
||||
func testOrderedByScore() throws {
|
||||
// Given a players database that contains players with distinct scores
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: 1, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: 2, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: 3, name: "Craig", score: 150)
|
||||
var player4 = Player(id: 4, name: "David", score: 120)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by score
|
||||
let players = try dbQueue.read(Player.all().orderedByScore().fetchAll)
|
||||
|
||||
// Then fetched players are ordered by score descending
|
||||
XCTAssertEqual(players, [player2, player3, player4, player1])
|
||||
}
|
||||
|
||||
func testOrderedByScoreSortsIdenticalScoresByName() throws {
|
||||
// Given a players database that contains players with common scores
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: 1, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: 2, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: 3, name: "Craig", score: 200)
|
||||
var player4 = Player(id: 4, name: "David", score: 200)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by score
|
||||
let players = try dbQueue.read(Player.all().orderedByScore().fetchAll)
|
||||
|
||||
// Then fetched players are ordered by score descending and by name
|
||||
XCTAssertEqual(players, [player2, player3, player4, player1])
|
||||
}
|
||||
|
||||
func testOrderedByName() throws {
|
||||
// Given a players database that contains players with distinct names
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: 1, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: 2, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: 3, name: "Craig", score: 150)
|
||||
var player4 = Player(id: 4, name: "David", score: 120)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by name
|
||||
let players = try dbQueue.read(Player.all().orderedByName().fetchAll)
|
||||
|
||||
// Then fetched players are ordered by name
|
||||
XCTAssertEqual(players, [player1, player2, player3, player4])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
Async/Await + SwiftUI Demo Application
|
||||
======================================
|
||||
|
||||
<img align="right" src="https://github.com/groue/GRDB.swift/raw/master/Documentation/DemoApps/GRDBCombineDemo/Screenshot.png" width="50%">
|
||||
|
||||
**This demo application is an Async/Await + SwiftUI application.** For a demo application that uses UIKit, see [GRDBDemoiOS](../GRDBDemoiOS/README.md), and for Combine + SwiftUI, see [GRDBCombineDemo](../GRDBCombineDemo/README.md).
|
||||
|
||||
**Requirements**: iOS 15.0+ / Xcode 13.1+
|
||||
|
||||
> **Note**: This demo app is not a project template. Do not copy it as a starting point for your application. Instead, create a new project, choose a GRDB [installation method](../../../README.md#installation), and use the demo as an inspiration.
|
||||
|
||||
The topics covered in this demo are:
|
||||
|
||||
- How to setup a database in an iOS app.
|
||||
- How to define a simple [Codable Record](../../../README.md#codable-records).
|
||||
- How to track database changes and animate a SwiftUI List with [ValueObservation](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/valueobservation) Combine publishers.
|
||||
- How to apply the recommendations of [Recommended Practices for Designing Record Types](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/recordrecommendedpractices).
|
||||
- How to perform `async` database accesses.
|
||||
- How to feed SwiftUI previews with a transient database.
|
||||
|
||||
**Files of interest:**
|
||||
|
||||
- [GRDBAsyncDemoApp.swift](GRDBAsyncDemo/GRDBAsyncDemoApp.swift)
|
||||
|
||||
`GRDBAsyncDemoApp` feeds the app views with a database, through the SwiftUI environment.
|
||||
|
||||
- [AppDatabase.swift](GRDBAsyncDemo/AppDatabase.swift)
|
||||
|
||||
`AppDatabase` is the type that grants database access. It uses [DatabaseMigrator](https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databasemigrator) in order to setup the database schema.
|
||||
|
||||
- [Persistence.swift](GRDBAsyncDemo/Persistence.swift)
|
||||
|
||||
This file instantiates various `AppDatabase` for the various projects needs: one database on disk for the application, and in-memory databases for SwiftUI previews.
|
||||
|
||||
- [Player.swift](GRDBAsyncDemo/Player.swift)
|
||||
|
||||
`Player` is a [Record](../../../README.md#records) type, able to read and write in the database. It conforms to the standard Codable protocol in order to gain all advantages of [Codable Records](../../../README.md#codable-records).
|
||||
|
||||
- [PlayerRequest.swift](GRDBAsyncDemo/PlayerRequest.swift), [AppView.swift](GRDBAsyncDemo/Views/AppView.swift)
|
||||
|
||||
`PlayerRequest` defines the player requests used by the app (sorted by score, or by name).
|
||||
|
||||
`PlayerRequest` feeds the `@Query` property wrapper (`@Query`, defined in [GRDBQuery](https://github.com/groue/GRDBQuery), allows SwiftUI views to display up-to-date database content).
|
||||
|
||||
`AppView` is the SwiftUI view that uses `@Query` in order to feed its player list.
|
||||
|
||||
- [GRDBAsyncDemoTests](GRDBAsyncDemoTests)
|
||||
|
||||
- Test the database schema
|
||||
- Test the `Player` record and its requests
|
||||
- Test the `PlayerRequest` methods that feed the list of players.
|
||||
- Test the `AppDatabase` methods that let the app access the database.
|
||||
|
After Width: | Height: | Size: 799 KiB |
@@ -0,0 +1,611 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */; };
|
||||
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */; };
|
||||
56519DC7274FC85600ED16D8 /* GRDBQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 56519DC6274FC85600ED16D8 /* GRDBQuery */; };
|
||||
5671723A261B23C800423B6F /* PlayerList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717239261B23C800423B6F /* PlayerList.swift */; };
|
||||
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56717251261B334D00423B6F /* PlayerRequestTests.swift */; };
|
||||
567C3E1A2520B6DE0011F6E9 /* GRDBCombineDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */; };
|
||||
567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */; };
|
||||
567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */; };
|
||||
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E532520B75C0011F6E9 /* Player.swift */; };
|
||||
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E542520B75C0011F6E9 /* Persistence.swift */; };
|
||||
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E592520B75C0011F6E9 /* PlayerFormView.swift */; };
|
||||
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5A2520B75C0011F6E9 /* AppView.swift */; };
|
||||
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */; };
|
||||
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */; };
|
||||
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567C3E652520B7880011F6E9 /* AppDatabase.swift */; };
|
||||
567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E752520BB650011F6E9 /* Localizable.stringsdict */; };
|
||||
567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */; };
|
||||
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56B6D1082619EC1B003CC455 /* PlayerRequest.swift */; };
|
||||
56F8A1202735989D0011ACBE /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 56F8A11F2735989D0011ACBE /* GRDB */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
56026C9D25B8A7D000D1DF3F /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 567C3E0E2520B6DE0011F6E9 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 567C3E152520B6DE0011F6E9;
|
||||
remoteInfo = GRDBCombineDemo;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
567C3E502520B70E0011F6E9 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
56026C9825B8A7D000D1DF3F /* GRDBCombineDemoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GRDBCombineDemoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
56026C9C25B8A7D000D1DF3F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTests.swift; sourceTree = "<group>"; };
|
||||
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabaseTests.swift; sourceTree = "<group>"; };
|
||||
56717239261B23C800423B6F /* PlayerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerList.swift; sourceTree = "<group>"; };
|
||||
56717251261B334D00423B6F /* PlayerRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequestTests.swift; sourceTree = "<group>"; };
|
||||
567C3E162520B6DE0011F6E9 /* GRDBCombineDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GRDBCombineDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRDBCombineDemoApp.swift; sourceTree = "<group>"; };
|
||||
567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
567C3E222520B6DF0011F6E9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
567C3E532520B75C0011F6E9 /* Player.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Player.swift; sourceTree = "<group>"; };
|
||||
567C3E542520B75C0011F6E9 /* Persistence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = "<group>"; };
|
||||
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerFormView.swift; sourceTree = "<group>"; };
|
||||
567C3E5A2520B75C0011F6E9 /* AppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = "<group>"; };
|
||||
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerCreationView.swift; sourceTree = "<group>"; };
|
||||
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerEditionView.swift; sourceTree = "<group>"; };
|
||||
567C3E652520B7880011F6E9 /* AppDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDatabase.swift; sourceTree = "<group>"; };
|
||||
567C3E762520BB650011F6E9 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
|
||||
567C3E782520BB650011F6E9 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRequest.swift; sourceTree = "<group>"; };
|
||||
56F8A11C2735988F0011ACBE /* GRDB.swift */ = {isa = PBXFileReference; lastKnownFileType = folder; name = GRDB.swift; path = ../../..; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
56026C9525B8A7D000D1DF3F /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E132520B6DE0011F6E9 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
56519DC7274FC85600ED16D8 /* GRDBQuery in Frameworks */,
|
||||
56F8A1202735989D0011ACBE /* GRDB in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
56026C9925B8A7D000D1DF3F /* GRDBCombineDemoTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
56026C9C25B8A7D000D1DF3F /* Info.plist */,
|
||||
56026CAB25B8A7EF00D1DF3F /* AppDatabaseTests.swift */,
|
||||
56717251261B334D00423B6F /* PlayerRequestTests.swift */,
|
||||
56026CAA25B8A7EF00D1DF3F /* PlayerTests.swift */,
|
||||
);
|
||||
path = GRDBCombineDemoTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
56185BC125B8047D00B9C30F /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E1D2520B6DF0011F6E9 /* Assets.xcassets */,
|
||||
567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */,
|
||||
567C3E752520BB650011F6E9 /* Localizable.stringsdict */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E0D2520B6DE0011F6E9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E182520B6DE0011F6E9 /* GRDBCombineDemo */,
|
||||
56026C9925B8A7D000D1DF3F /* GRDBCombineDemoTests */,
|
||||
567C3E172520B6DE0011F6E9 /* Products */,
|
||||
567C3E4D2520B70E0011F6E9 /* Frameworks */,
|
||||
56F8A11C2735988F0011ACBE /* GRDB.swift */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E172520B6DE0011F6E9 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E162520B6DE0011F6E9 /* GRDBCombineDemo.app */,
|
||||
56026C9825B8A7D000D1DF3F /* GRDBCombineDemoTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E182520B6DE0011F6E9 /* GRDBCombineDemo */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E222520B6DF0011F6E9 /* Info.plist */,
|
||||
567C3E652520B7880011F6E9 /* AppDatabase.swift */,
|
||||
567C3E192520B6DE0011F6E9 /* GRDBCombineDemoApp.swift */,
|
||||
567C3E542520B75C0011F6E9 /* Persistence.swift */,
|
||||
567C3E532520B75C0011F6E9 /* Player.swift */,
|
||||
56B6D1082619EC1B003CC455 /* PlayerRequest.swift */,
|
||||
567C3E1F2520B6DF0011F6E9 /* Preview Content */,
|
||||
56185BC125B8047D00B9C30F /* Resources */,
|
||||
567C3E582520B75C0011F6E9 /* Views */,
|
||||
);
|
||||
path = GRDBCombineDemo;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E1F2520B6DF0011F6E9 /* Preview Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E202520B6DF0011F6E9 /* Preview Assets.xcassets */,
|
||||
);
|
||||
path = "Preview Content";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E4D2520B70E0011F6E9 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E582520B75C0011F6E9 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
567C3E5A2520B75C0011F6E9 /* AppView.swift */,
|
||||
567C3E5B2520B75C0011F6E9 /* PlayerCreationView.swift */,
|
||||
567C3E5C2520B75C0011F6E9 /* PlayerEditionView.swift */,
|
||||
567C3E592520B75C0011F6E9 /* PlayerFormView.swift */,
|
||||
56717239261B23C800423B6F /* PlayerList.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
56026C9725B8A7D000D1DF3F /* GRDBCombineDemoTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 56026CA825B8A7D000D1DF3F /* Build configuration list for PBXNativeTarget "GRDBCombineDemoTests" */;
|
||||
buildPhases = (
|
||||
56026C9425B8A7D000D1DF3F /* Sources */,
|
||||
56026C9525B8A7D000D1DF3F /* Frameworks */,
|
||||
56026C9625B8A7D000D1DF3F /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
56026C9E25B8A7D000D1DF3F /* PBXTargetDependency */,
|
||||
);
|
||||
name = GRDBCombineDemoTests;
|
||||
productName = GRDBCombineDemoTests;
|
||||
productReference = 56026C9825B8A7D000D1DF3F /* GRDBCombineDemoTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
567C3E152520B6DE0011F6E9 /* GRDBCombineDemo */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 567C3E252520B6DF0011F6E9 /* Build configuration list for PBXNativeTarget "GRDBCombineDemo" */;
|
||||
buildPhases = (
|
||||
567C3E122520B6DE0011F6E9 /* Sources */,
|
||||
567C3E132520B6DE0011F6E9 /* Frameworks */,
|
||||
567C3E142520B6DE0011F6E9 /* Resources */,
|
||||
567C3E502520B70E0011F6E9 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
56F8A11E273598960011ACBE /* PBXTargetDependency */,
|
||||
);
|
||||
name = GRDBCombineDemo;
|
||||
packageProductDependencies = (
|
||||
56F8A11F2735989D0011ACBE /* GRDB */,
|
||||
56519DC6274FC85600ED16D8 /* GRDBQuery */,
|
||||
);
|
||||
productName = GRBCombineDemo;
|
||||
productReference = 567C3E162520B6DE0011F6E9 /* GRDBCombineDemo.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
567C3E0E2520B6DE0011F6E9 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastSwiftUpdateCheck = 1250;
|
||||
LastUpgradeCheck = 1200;
|
||||
TargetAttributes = {
|
||||
56026C9725B8A7D000D1DF3F = {
|
||||
CreatedOnToolsVersion = 12.3;
|
||||
TestTargetID = 567C3E152520B6DE0011F6E9;
|
||||
};
|
||||
567C3E152520B6DE0011F6E9 = {
|
||||
CreatedOnToolsVersion = 12.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 567C3E112520B6DE0011F6E9 /* Build configuration list for PBXProject "GRDBCombineDemo" */;
|
||||
compatibilityVersion = "Xcode 12.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 567C3E0D2520B6DE0011F6E9;
|
||||
packageReferences = (
|
||||
56519DC5274FC85600ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */,
|
||||
);
|
||||
productRefGroup = 567C3E172520B6DE0011F6E9 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
567C3E152520B6DE0011F6E9 /* GRDBCombineDemo */,
|
||||
56026C9725B8A7D000D1DF3F /* GRDBCombineDemoTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
56026C9625B8A7D000D1DF3F /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E142520B6DE0011F6E9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
567C3E212520B6DF0011F6E9 /* Preview Assets.xcassets in Resources */,
|
||||
567C3E7A2520BB650011F6E9 /* LaunchScreen.storyboard in Resources */,
|
||||
567C3E1E2520B6DF0011F6E9 /* Assets.xcassets in Resources */,
|
||||
567C3E792520BB650011F6E9 /* Localizable.stringsdict in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
56026C9425B8A7D000D1DF3F /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
56026CAC25B8A7EF00D1DF3F /* PlayerTests.swift in Sources */,
|
||||
56026CAD25B8A7EF00D1DF3F /* AppDatabaseTests.swift in Sources */,
|
||||
56717252261B334D00423B6F /* PlayerRequestTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
567C3E122520B6DE0011F6E9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
567C3E5E2520B75C0011F6E9 /* Persistence.swift in Sources */,
|
||||
567C3E5D2520B75C0011F6E9 /* Player.swift in Sources */,
|
||||
56B6D1092619EC1B003CC455 /* PlayerRequest.swift in Sources */,
|
||||
5671723A261B23C800423B6F /* PlayerList.swift in Sources */,
|
||||
567C3E612520B75D0011F6E9 /* PlayerFormView.swift in Sources */,
|
||||
567C3E632520B75D0011F6E9 /* PlayerCreationView.swift in Sources */,
|
||||
567C3E662520B7880011F6E9 /* AppDatabase.swift in Sources */,
|
||||
567C3E622520B75D0011F6E9 /* AppView.swift in Sources */,
|
||||
567C3E642520B75D0011F6E9 /* PlayerEditionView.swift in Sources */,
|
||||
567C3E1A2520B6DE0011F6E9 /* GRDBCombineDemoApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
56026C9E25B8A7D000D1DF3F /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 567C3E152520B6DE0011F6E9 /* GRDBCombineDemo */;
|
||||
targetProxy = 56026C9D25B8A7D000D1DF3F /* PBXContainerItemProxy */;
|
||||
};
|
||||
56F8A11E273598960011ACBE /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
productRef = 56F8A11D273598960011ACBE /* GRDB */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
567C3E752520BB650011F6E9 /* Localizable.stringsdict */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
567C3E762520BB650011F6E9 /* en */,
|
||||
);
|
||||
name = Localizable.stringsdict;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
567C3E772520BB650011F6E9 /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
567C3E782520BB650011F6E9 /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
56026C9F25B8A7D000D1DF3F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = GRDBCombineDemoTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBCombineDemoTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDBCombineDemo.app/GRDBCombineDemo";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
56026CA025B8A7D000D1DF3F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
INFOPLIST_FILE = GRDBCombineDemoTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBCombineDemoTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/GRDBCombineDemo.app/GRDBCombineDemo";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
567C3E232520B6DF0011F6E9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
567C3E242520B6DF0011F6E9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
567C3E262520B6DF0011F6E9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"GRDBCombineDemo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = GRDBCombineDemo/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBCombineDemo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
567C3E272520B6DF0011F6E9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"GRDBCombineDemo/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = GRDBCombineDemo/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.github.groue.GRDBCombineDemo;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
56026CA825B8A7D000D1DF3F /* Build configuration list for PBXNativeTarget "GRDBCombineDemoTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
56026C9F25B8A7D000D1DF3F /* Debug */,
|
||||
56026CA025B8A7D000D1DF3F /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
567C3E112520B6DE0011F6E9 /* Build configuration list for PBXProject "GRDBCombineDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
567C3E232520B6DF0011F6E9 /* Debug */,
|
||||
567C3E242520B6DF0011F6E9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
567C3E252520B6DF0011F6E9 /* Build configuration list for PBXNativeTarget "GRDBCombineDemo" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
567C3E262520B6DF0011F6E9 /* Debug */,
|
||||
567C3E272520B6DF0011F6E9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
56519DC5274FC85600ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/groue/GRDBQuery";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.6.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
56519DC6274FC85600ED16D8 /* GRDBQuery */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 56519DC5274FC85600ED16D8 /* XCRemoteSwiftPackageReference "GRDBQuery" */;
|
||||
productName = GRDBQuery;
|
||||
};
|
||||
56F8A11D273598960011ACBE /* GRDB */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = GRDB;
|
||||
};
|
||||
56F8A11F2735989D0011ACBE /* GRDB */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = GRDB;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 567C3E0E2520B6DE0011F6E9 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "grdbquery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/groue/GRDBQuery",
|
||||
"state" : {
|
||||
"revision" : "a6c46dd38ecf11a5c37732870dc03a384d582fba",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBCombineDemo.app"
|
||||
BlueprintName = "GRDBCombineDemo"
|
||||
ReferencedContainer = "container:GRDBCombineDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "56026C9725B8A7D000D1DF3F"
|
||||
BuildableName = "GRDBCombineDemoTests.xctest"
|
||||
BlueprintName = "GRDBCombineDemoTests"
|
||||
ReferencedContainer = "container:GRDBCombineDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBCombineDemo.app"
|
||||
BlueprintName = "GRDBCombineDemo"
|
||||
ReferencedContainer = "container:GRDBCombineDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "SQL_TRACE"
|
||||
value = "1"
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "567C3E152520B6DE0011F6E9"
|
||||
BuildableName = "GRDBCombineDemo.app"
|
||||
BlueprintName = "GRDBCombineDemo"
|
||||
ReferencedContainer = "container:GRDBCombineDemo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,235 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
import os.log
|
||||
|
||||
/// A database of players.
|
||||
///
|
||||
/// You create an `AppDatabase` with a connection to an SQLite database
|
||||
/// (see <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>).
|
||||
///
|
||||
/// Create those connections with a configuration returned from
|
||||
/// `AppDatabase/makeConfiguration(_:)`.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// ```swift
|
||||
/// // Create an in-memory AppDatabase
|
||||
/// let config = AppDatabase.makeConfiguration()
|
||||
/// let dbQueue = try DatabaseQueue(configuration: config)
|
||||
/// let appDatabase = try AppDatabase(dbQueue)
|
||||
/// ```
|
||||
struct AppDatabase {
|
||||
/// Creates an `AppDatabase`, and makes sure the database schema
|
||||
/// is ready.
|
||||
///
|
||||
/// - important: Create the `DatabaseWriter` with a configuration
|
||||
/// returned by ``makeConfiguration(_:)``.
|
||||
init(_ dbWriter: any DatabaseWriter) throws {
|
||||
self.dbWriter = dbWriter
|
||||
try migrator.migrate(dbWriter)
|
||||
}
|
||||
|
||||
/// Provides access to the database.
|
||||
///
|
||||
/// Application can use a `DatabasePool`, while SwiftUI previews and tests
|
||||
/// can use a fast in-memory `DatabaseQueue`.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
|
||||
private let dbWriter: any DatabaseWriter
|
||||
}
|
||||
|
||||
// MARK: - Database Configuration
|
||||
|
||||
extension AppDatabase {
|
||||
private static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL")
|
||||
|
||||
/// Returns a database configuration suited for `PlayerRepository`.
|
||||
///
|
||||
/// SQL statements are logged if the `SQL_TRACE` environment variable
|
||||
/// is set.
|
||||
///
|
||||
/// - parameter base: A base configuration.
|
||||
public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration {
|
||||
var config = base
|
||||
|
||||
// An opportunity to add required custom SQL functions or
|
||||
// collations, if needed:
|
||||
// config.prepareDatabase { db in
|
||||
// db.add(function: ...)
|
||||
// }
|
||||
|
||||
// Log SQL statements if the `SQL_TRACE` environment variable is set.
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/database/trace(options:_:)>
|
||||
if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil {
|
||||
config.prepareDatabase { db in
|
||||
db.trace {
|
||||
// It's ok to log statements publicly. Sensitive
|
||||
// information (statement arguments) are not logged
|
||||
// unless config.publicStatementArguments is set
|
||||
// (see below).
|
||||
os_log("%{public}@", log: sqlLogger, type: .debug, String(describing: $0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Protect sensitive information by enabling verbose debugging in
|
||||
// DEBUG builds only.
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/configuration/publicstatementarguments>
|
||||
config.publicStatementArguments = true
|
||||
#endif
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Migrations
|
||||
|
||||
extension AppDatabase {
|
||||
/// The DatabaseMigrator that defines the database schema.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
|
||||
private var migrator: DatabaseMigrator {
|
||||
var migrator = DatabaseMigrator()
|
||||
|
||||
#if DEBUG
|
||||
// Speed up development by nuking the database when migrations change
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/migrations>
|
||||
migrator.eraseDatabaseOnSchemaChange = true
|
||||
#endif
|
||||
|
||||
migrator.registerMigration("createPlayer") { db in
|
||||
// Create a table
|
||||
// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseschema>
|
||||
try db.create(table: "player") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("score", .integer).notNull()
|
||||
}
|
||||
}
|
||||
|
||||
// Migrations for future application versions will be inserted here:
|
||||
// migrator.registerMigration(...) { db in
|
||||
// ...
|
||||
// }
|
||||
|
||||
return migrator
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Access: Writes
|
||||
// The write methods execute invariant-preserving database transactions.
|
||||
|
||||
extension AppDatabase {
|
||||
/// A validation error that prevents some players from being saved into
|
||||
/// the database.
|
||||
enum ValidationError: LocalizedError {
|
||||
case missingName
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingName:
|
||||
return "Please provide a name"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves (inserts or updates) a player. When the method returns, the
|
||||
/// player is present in the database, and its id is not nil.
|
||||
func savePlayer(_ player: inout Player) throws {
|
||||
if player.name.isEmpty {
|
||||
throw ValidationError.missingName
|
||||
}
|
||||
try dbWriter.write { db in
|
||||
try player.save(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the specified players
|
||||
func deletePlayers(ids: [Int64]) throws {
|
||||
try dbWriter.write { db in
|
||||
_ = try Player.deleteAll(db, ids: ids)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all players
|
||||
func deleteAllPlayers() throws {
|
||||
try dbWriter.write { db in
|
||||
_ = try Player.deleteAll(db)
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh all players (by performing some random changes, for demo purpose).
|
||||
func refreshPlayers() throws {
|
||||
try dbWriter.write { db in
|
||||
if try Player.all().isEmpty(db) {
|
||||
// When database is empty, insert new random players
|
||||
try createRandomPlayers(db)
|
||||
} else {
|
||||
// Insert a player
|
||||
if Bool.random() {
|
||||
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
|
||||
// Delete a random player
|
||||
if Bool.random() {
|
||||
try Player.order(sql: "RANDOM()").limit(1).deleteAll(db)
|
||||
}
|
||||
|
||||
// Update some players
|
||||
for var player in try Player.fetchAll(db) where Bool.random() {
|
||||
try player.updateChanges(db) {
|
||||
$0.score = Player.randomScore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create random players if the database is empty.
|
||||
func createRandomPlayersIfEmpty() throws {
|
||||
try dbWriter.write { db in
|
||||
if try Player.all().isEmpty(db) {
|
||||
try createRandomPlayers(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let uiTestPlayers = [
|
||||
Player(id: nil, name: "Arthur", score: 5),
|
||||
Player(id: nil, name: "Barbara", score: 6),
|
||||
Player(id: nil, name: "Craig", score: 8),
|
||||
Player(id: nil, name: "David", score: 4),
|
||||
Player(id: nil, name: "Elena", score: 1),
|
||||
Player(id: nil, name: "Frederik", score: 2),
|
||||
Player(id: nil, name: "Gilbert", score: 7),
|
||||
Player(id: nil, name: "Henriette", score: 3)]
|
||||
|
||||
func createPlayersForUITests() throws {
|
||||
try dbWriter.write { db in
|
||||
try AppDatabase.uiTestPlayers.forEach { player in
|
||||
_ = try player.inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Support for `createRandomPlayersIfEmpty()` and `refreshPlayers()`.
|
||||
private func createRandomPlayers(_ db: Database) throws {
|
||||
for _ in 0..<8 {
|
||||
_ = try Player.makeRandom().inserted(db) // insert but ignore inserted id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Database Access: Reads
|
||||
|
||||
// This demo app does not provide any specific reading method, and instead
|
||||
// gives an unrestricted read-only access to the rest of the application.
|
||||
// In your app, you are free to choose another path, and define focused
|
||||
// reading methods.
|
||||
extension AppDatabase {
|
||||
/// Provides a read-only access to the database
|
||||
var reader: DatabaseReader {
|
||||
dbWriter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import GRDBQuery
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct GRDBCombineDemoApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AppView().appDatabase(.shared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Give SwiftUI access to the database
|
||||
|
||||
private struct AppDatabaseKey: EnvironmentKey {
|
||||
static var defaultValue: AppDatabase { .empty() }
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var appDatabase: AppDatabase {
|
||||
get { self[AppDatabaseKey.self] }
|
||||
set { self[AppDatabaseKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func appDatabase(_ appDatabase: AppDatabase) -> some View {
|
||||
self
|
||||
.environment(\.appDatabase, appDatabase)
|
||||
.databaseContext(.readOnly { appDatabase.reader })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>armv7</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import GRDB
|
||||
|
||||
extension AppDatabase {
|
||||
/// The database for the application
|
||||
static let shared = makeShared()
|
||||
|
||||
private static func makeShared() -> AppDatabase {
|
||||
do {
|
||||
// Apply recommendations from
|
||||
// <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections>
|
||||
//
|
||||
// Create the "Application Support/Database" directory if needed
|
||||
let fileManager = FileManager.default
|
||||
let appSupportURL = try fileManager.url(
|
||||
for: .applicationSupportDirectory, in: .userDomainMask,
|
||||
appropriateFor: nil, create: true)
|
||||
let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true)
|
||||
|
||||
// Support for tests: delete the database if requested
|
||||
if CommandLine.arguments.contains("-reset") {
|
||||
try? fileManager.removeItem(at: directoryURL)
|
||||
}
|
||||
|
||||
// Create the database folder if needed
|
||||
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
|
||||
// Open or create the database
|
||||
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
|
||||
NSLog("Database stored at \(databaseURL.path)")
|
||||
let dbPool = try DatabasePool(
|
||||
path: databaseURL.path,
|
||||
// Use default AppDatabase configuration
|
||||
configuration: AppDatabase.makeConfiguration())
|
||||
|
||||
// Create the AppDatabase
|
||||
let appDatabase = try AppDatabase(dbPool)
|
||||
|
||||
// Prepare the database with test fixtures if requested
|
||||
if CommandLine.arguments.contains("-fixedTestData") {
|
||||
try appDatabase.createPlayersForUITests()
|
||||
} else {
|
||||
// Otherwise, populate the database if it is empty, for better
|
||||
// demo purpose.
|
||||
try appDatabase.createRandomPlayersIfEmpty()
|
||||
}
|
||||
|
||||
return appDatabase
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate.
|
||||
//
|
||||
// Typical reasons for an error here include:
|
||||
// * The parent directory cannot be created, or disallows writing.
|
||||
// * The database is not accessible, due to permissions or data protection when the device is locked.
|
||||
// * The device is out of space.
|
||||
// * The database could not be migrated to its latest schema version.
|
||||
// Check the error message to determine what the actual problem was.
|
||||
fatalError("Unresolved error \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an empty database for SwiftUI previews
|
||||
static func empty() -> AppDatabase {
|
||||
// Connect to an in-memory database
|
||||
// See https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections
|
||||
let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
return try! AppDatabase(dbQueue)
|
||||
}
|
||||
|
||||
/// Creates a database full of random players for SwiftUI previews
|
||||
static func random() -> AppDatabase {
|
||||
let appDatabase = empty()
|
||||
try! appDatabase.createRandomPlayersIfEmpty()
|
||||
return appDatabase
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import GRDB
|
||||
|
||||
/// The Player struct.
|
||||
///
|
||||
/// Identifiable conformance supports SwiftUI list animations, and type-safe
|
||||
/// GRDB primary key methods.
|
||||
/// Equatable conformance supports tests.
|
||||
struct Player: Identifiable, Equatable {
|
||||
/// The player id.
|
||||
///
|
||||
/// Int64 is the recommended type for auto-incremented database ids.
|
||||
/// Use nil for players that are not inserted yet in the database.
|
||||
var id: Int64?
|
||||
var name: String
|
||||
var score: Int
|
||||
}
|
||||
|
||||
extension Player {
|
||||
private static let names = [
|
||||
"Arthur", "Anita", "Barbara", "Bernard", "Craig", "Chiara", "David",
|
||||
"Dean", "Éric", "Elena", "Fatima", "Frederik", "Gilbert", "Georgette",
|
||||
"Henriette", "Hassan", "Ignacio", "Irene", "Julie", "Jack", "Karl",
|
||||
"Kristel", "Louis", "Liz", "Masashi", "Mary", "Noam", "Nicole",
|
||||
"Ophelie", "Oleg", "Pascal", "Patricia", "Quentin", "Quinn", "Raoul",
|
||||
"Rachel", "Stephan", "Susie", "Tristan", "Tatiana", "Ursule", "Urbain",
|
||||
"Victor", "Violette", "Wilfried", "Wilhelmina", "Yvon", "Yann",
|
||||
"Zazie", "Zoé"]
|
||||
|
||||
/// Creates a new player with empty name and zero score
|
||||
static func new() -> Player {
|
||||
Player(id: nil, name: "", score: 0)
|
||||
}
|
||||
|
||||
/// Creates a new player with random name and random score
|
||||
static func makeRandom() -> Player {
|
||||
Player(id: nil, name: randomName(), score: randomScore())
|
||||
}
|
||||
|
||||
/// Returns a random name
|
||||
static func randomName() -> String {
|
||||
names.randomElement()!
|
||||
}
|
||||
|
||||
/// Returns a random score
|
||||
static func randomScore() -> Int {
|
||||
10 * Int.random(in: 0...100)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Make Player a Codable Record.
|
||||
///
|
||||
/// See <https://github.com/groue/GRDB.swift/blob/master/README.md#records>
|
||||
extension Player: Codable, FetchableRecord, MutablePersistableRecord {
|
||||
// Define database columns from CodingKeys
|
||||
fileprivate enum Columns {
|
||||
static let name = Column(CodingKeys.name)
|
||||
static let score = Column(CodingKeys.score)
|
||||
}
|
||||
|
||||
/// Updates a player id after it has been inserted in the database.
|
||||
mutating func didInsert(_ inserted: InsertionSuccess) {
|
||||
id = inserted.rowID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Player Database Requests
|
||||
|
||||
/// Define some player requests used by the application.
|
||||
///
|
||||
/// See <https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/recordrecommendedpractices>
|
||||
extension DerivableRequest<Player> {
|
||||
/// A request of players ordered by name.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// let players: [Player] = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByName().fetchAll(db)
|
||||
/// }
|
||||
func orderedByName() -> Self {
|
||||
// Sort by name in a localized case insensitive fashion
|
||||
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
|
||||
order(Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
|
||||
}
|
||||
|
||||
/// A request of players ordered by score.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// let players: [Player] = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByScore().fetchAll(db)
|
||||
/// }
|
||||
/// let bestPlayer: Player? = try dbWriter.read { db in
|
||||
/// try Player.all().orderedByScore().fetchOne(db)
|
||||
/// }
|
||||
func orderedByScore() -> Self {
|
||||
// Sort by descending score, and then by name, in a
|
||||
// localized case insensitive fashion
|
||||
// See https://github.com/groue/GRDB.swift/blob/master/README.md#string-comparison
|
||||
order(
|
||||
Player.Columns.score.desc,
|
||||
Player.Columns.name.collating(.localizedCaseInsensitiveCompare))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import GRDB
|
||||
import GRDBQuery
|
||||
|
||||
/// A player request can be used with the `@Query` property wrapper in order to
|
||||
/// feed a view with a list of players.
|
||||
///
|
||||
/// For example:
|
||||
///
|
||||
/// struct MyView: View {
|
||||
/// @Query(PlayerRequest(ordering: .byName)) private var players: [Player]
|
||||
///
|
||||
/// var body: some View {
|
||||
/// List(players) { player in ... )
|
||||
/// }
|
||||
/// }
|
||||
struct PlayerRequest: ValueObservationQueryable {
|
||||
enum Ordering {
|
||||
case byScore
|
||||
case byName
|
||||
}
|
||||
|
||||
static var defaultValue: [Player] { [] }
|
||||
|
||||
/// The ordering used by the player request.
|
||||
var ordering: Ordering
|
||||
|
||||
func fetch(_ db: Database) throws -> [Player] {
|
||||
switch ordering {
|
||||
case .byScore:
|
||||
return try Player.all().orderedByScore().fetchAll(db)
|
||||
case .byName:
|
||||
return try Player.all().orderedByName().fetchAll(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_20pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_20pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_29pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_29pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_40pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_40pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_60pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "icon_60pt@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_20pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_20pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_29pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_29pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_40pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_40pt@2x-1.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_76pt.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_76pt@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "icon_83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon.png",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "LaunchIcon.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17156" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17125"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--View Controller-->
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="LaunchIcon" translatesAutoresizingMaskIntoConstraints="NO" id="Sgi-te-PKu">
|
||||
<rect key="frame" x="123.5" y="334.5" width="167" height="237"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Sgi-te-PKu" firstAttribute="centerY" secondItem="6Tk-OE-BBY" secondAttribute="centerY" id="KT7-xd-gV4"/>
|
||||
<constraint firstItem="Sgi-te-PKu" firstAttribute="centerX" secondItem="6Tk-OE-BBY" secondAttribute="centerX" id="feL-Vs-SeN"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchIcon" width="167" height="237"/>
|
||||
<systemColor name="systemBackgroundColor">
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>%lld Players</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@VARIABLE@</string>
|
||||
<key>VARIABLE</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>lld</string>
|
||||
<key>zero</key>
|
||||
<string>No Player</string>
|
||||
<key>one</key>
|
||||
<string>1 Player</string>
|
||||
<key>other</key>
|
||||
<string>%lld Players</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>%lld points</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@VARIABLE@</string>
|
||||
<key>VARIABLE</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>lld</string>
|
||||
<key>zero</key>
|
||||
<string>0 point</string>
|
||||
<key>one</key>
|
||||
<string>1 point</string>
|
||||
<key>other</key>
|
||||
<string>%lld points</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,145 @@
|
||||
import GRDBQuery
|
||||
import SwiftUI
|
||||
|
||||
/// The main application view
|
||||
struct AppView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
|
||||
/// The `players` property is automatically updated when the database changes
|
||||
@Query(PlayerRequest(ordering: .byScore)) private var players: [Player]
|
||||
|
||||
/// We'll need to leave edit mode in several occasions.
|
||||
@State private var editMode = EditMode.inactive
|
||||
|
||||
/// Tracks the presentation of the player creation sheet.
|
||||
@State private var newPlayerIsPresented = false
|
||||
|
||||
// If you want to define the query on initialization, you will prefer:
|
||||
//
|
||||
// @Query<PlayerRequest> private var players: [Player]
|
||||
//
|
||||
// init(initialOrdering: PlayerRequest.Ordering) {
|
||||
// _players = Query(PlayerRequest(ordering: initialOrdering))
|
||||
// }
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
PlayerList(players: players)
|
||||
.navigationBarTitle(Text("\(players.count) Players"))
|
||||
.navigationBarItems(
|
||||
leading: HStack {
|
||||
EditButton()
|
||||
newPlayerButton
|
||||
},
|
||||
trailing: ToggleOrderingButton(
|
||||
ordering: $players.ordering,
|
||||
willChange: {
|
||||
// onChange(of: $players.wrappedValue.ordering)
|
||||
// is not able to leave the editing mode during
|
||||
// the animation of the list content.
|
||||
// Workaround: stop editing before the ordering
|
||||
// is changed, and the list content is updated.
|
||||
stopEditing()
|
||||
}))
|
||||
.toolbar { toolbarContent }
|
||||
.onChange(of: players) {
|
||||
if players.isEmpty {
|
||||
stopEditing()
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .bottomBar) {
|
||||
Button {
|
||||
// Don't stopEditing() here because this is
|
||||
// performed `onChange(of: players)`
|
||||
try! appDatabase.deleteAllPlayers()
|
||||
} label: {
|
||||
Image(systemName: "trash").imageScale(.large)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
stopEditing()
|
||||
try! appDatabase.refreshPlayers()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise").imageScale(.large)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
stopEditing()
|
||||
// Perform 50 refreshes in parallel
|
||||
for _ in 0..<50 {
|
||||
DispatchQueue.global().async {
|
||||
try! AppDatabase.shared.refreshPlayers()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "tornado").imageScale(.large)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The button that presents the player creation sheet.
|
||||
private var newPlayerButton: some View {
|
||||
Button {
|
||||
stopEditing()
|
||||
newPlayerIsPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibility(label: Text("New Player"))
|
||||
.sheet(isPresented: $newPlayerIsPresented) {
|
||||
PlayerCreationView()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopEditing() {
|
||||
withAnimation {
|
||||
editMode = .inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleOrderingButton: View {
|
||||
@Binding var ordering: PlayerRequest.Ordering
|
||||
let willChange: () -> Void
|
||||
|
||||
var body: some View {
|
||||
switch ordering {
|
||||
case .byName:
|
||||
Button {
|
||||
willChange()
|
||||
ordering = .byScore
|
||||
} label: {
|
||||
Label("Name", systemImage: "arrowtriangle.up.fill").labelStyle(.titleAndIcon)
|
||||
}
|
||||
case .byScore:
|
||||
Button {
|
||||
willChange()
|
||||
ordering = .byName
|
||||
} label: {
|
||||
Label("Score", systemImage: "arrowtriangle.down.fill").labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty") {
|
||||
// Preview the default, empty database
|
||||
AppView()
|
||||
}
|
||||
|
||||
#Preview("Populated") {
|
||||
// Preview a database of random players
|
||||
AppView().appDatabase(.random())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The view that creates a new player.
|
||||
struct PlayerCreationView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var form = PlayerForm(name: "", score: "")
|
||||
@State private var errorAlertIsPresented = false
|
||||
@State private var errorAlertTitle = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
PlayerFormView(form: $form)
|
||||
.alert(
|
||||
isPresented: $errorAlertIsPresented,
|
||||
content: { Alert(title: Text(errorAlertTitle)) })
|
||||
.navigationBarTitle("New Player")
|
||||
.navigationBarItems(
|
||||
leading: Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
},
|
||||
trailing: Button {
|
||||
save()
|
||||
} label: {
|
||||
Text("Save")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
do {
|
||||
var player = Player(id: nil, name: "", score: 0)
|
||||
form.apply(to: &player)
|
||||
try appDatabase.savePlayer(&player)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorAlertTitle = (error as? LocalizedError)?.errorDescription ?? "An error occurred"
|
||||
errorAlertIsPresented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
PlayerCreationView()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The view that edits an existing player.
|
||||
struct PlayerEditionView: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
@Environment(\.isPresented) private var isPresented
|
||||
private let player: Player
|
||||
@State private var form: PlayerForm
|
||||
|
||||
init(player: Player) {
|
||||
self.player = player
|
||||
self.form = PlayerForm(player)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
PlayerFormView(form: $form)
|
||||
.onChange(of: isPresented) {
|
||||
// Save when back button is pressed
|
||||
if !isPresented {
|
||||
var savedPlayer = player
|
||||
form.apply(to: &savedPlayer)
|
||||
// Ignore error because I don't know how to cancel the
|
||||
// back button and present the error
|
||||
try? appDatabase.savePlayer(&savedPlayer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
PlayerEditionView(player: Player.makeRandom())
|
||||
.navigationBarTitle("Player Edition")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The Player editing form, embedded in both
|
||||
/// `PlayerCreationView` and `PlayerEditionView`.
|
||||
struct PlayerFormView: View {
|
||||
@Binding var form: PlayerForm
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
TextField("Name", text: $form.name)
|
||||
.accessibility(label: Text("Player Name"))
|
||||
TextField("Score", text: $form.score).keyboardType(.numberPad)
|
||||
.accessibility(label: Text("Player Score"))
|
||||
}
|
||||
.listStyle(InsetGroupedListStyle())
|
||||
}
|
||||
}
|
||||
|
||||
struct PlayerForm {
|
||||
var name: String
|
||||
var score: String
|
||||
}
|
||||
|
||||
extension PlayerForm {
|
||||
init(_ player: Player) {
|
||||
self.name = player.name
|
||||
self.score = "\(player.score)"
|
||||
}
|
||||
|
||||
func apply(to player: inout Player) {
|
||||
player.name = name
|
||||
player.score = Int(score) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Empty") {
|
||||
PlayerFormView(form: .constant(PlayerForm(
|
||||
name: "",
|
||||
score: "")))
|
||||
}
|
||||
|
||||
#Preview("Prefilled") {
|
||||
PlayerFormView(form: .constant(PlayerForm(
|
||||
name: Player.randomName(),
|
||||
score: "\(Player.randomScore())")))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlayerList: View {
|
||||
/// Write access to the database
|
||||
@Environment(\.appDatabase) private var appDatabase
|
||||
|
||||
/// The players in the list
|
||||
var players: [Player]
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(players) { player in
|
||||
NavigationLink(destination: editionView(for: player)) {
|
||||
PlayerRow(player: player)
|
||||
// Don't animate player update
|
||||
.animation(nil, value: player)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
let playerIds = offsets.compactMap { players[$0].id }
|
||||
try? appDatabase.deletePlayers(ids: playerIds)
|
||||
}
|
||||
}
|
||||
// Animate list updates
|
||||
.animation(.default, value: players)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
/// The view that edits a player in the list.
|
||||
private func editionView(for player: Player) -> some View {
|
||||
PlayerEditionView(player: player).navigationBarTitle(player.name)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlayerRow: View {
|
||||
var player: Player
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(player.name)
|
||||
Spacer()
|
||||
Text("\(player.score) points").foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
PlayerList(players: [
|
||||
Player(id: 1, name: "Arthur", score: 100),
|
||||
Player(id: 2, name: "Barbara", score: 1000),
|
||||
])
|
||||
.navigationTitle("Preview")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import XCTest
|
||||
import GRDB
|
||||
@testable import GRDBCombineDemo
|
||||
|
||||
class AppDatabaseTests: XCTestCase {
|
||||
func test_database_schema() throws {
|
||||
// Given an empty database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
|
||||
// When we instantiate an AppDatabase
|
||||
_ = try AppDatabase(dbQueue)
|
||||
|
||||
// Then the player table exists, with id, name & score columns
|
||||
try dbQueue.read { db in
|
||||
try XCTAssert(db.tableExists("player"))
|
||||
let columns = try db.columns(in: "player")
|
||||
let columnNames = Set(columns.map { $0.name })
|
||||
XCTAssertEqual(columnNames, ["id", "name", "score"])
|
||||
}
|
||||
}
|
||||
|
||||
func test_savePlayer_inserts() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
|
||||
// When we save a new player
|
||||
var player = Player(id: nil, name: "Arthur", score: 100)
|
||||
try appDatabase.savePlayer(&player)
|
||||
|
||||
// Then the player exists in the database
|
||||
try XCTAssertTrue(dbQueue.read(player.exists))
|
||||
}
|
||||
|
||||
func test_savePlayer_updates() throws {
|
||||
// Given a players database that contains a player
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
var player = Player(id: nil, name: "Arthur", score: 100)
|
||||
try dbQueue.write { db in
|
||||
try player.insert(db)
|
||||
}
|
||||
|
||||
// When we modify and save the player
|
||||
player.name = "Barbara"
|
||||
player.score = 1000
|
||||
try appDatabase.savePlayer(&player)
|
||||
|
||||
// Then the player has been updated in the database
|
||||
let fetchedPlayer = try dbQueue.read { db in
|
||||
try XCTUnwrap(Player.fetchOne(db, key: player.id))
|
||||
}
|
||||
XCTAssertEqual(fetchedPlayer, player)
|
||||
}
|
||||
|
||||
func test_deletePlayers() throws {
|
||||
// Given a players database that contains four players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: nil, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: nil, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: nil, name: "Craig", score: 150)
|
||||
var player4 = Player(id: nil, name: "David", score: 120)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we delete two players
|
||||
try appDatabase.deletePlayers(ids: [player1.id!, player3.id!])
|
||||
|
||||
// Then the deleted players no longer exist
|
||||
try dbQueue.read { db in
|
||||
try XCTAssertFalse(player1.exists(db))
|
||||
try XCTAssertFalse(player3.exists(db))
|
||||
}
|
||||
|
||||
// Then the database still contains two players
|
||||
try XCTAssertEqual(dbQueue.read(Player.fetchCount), 2)
|
||||
}
|
||||
|
||||
func test_deleteAllPlayers() throws {
|
||||
// Given a players database that contains players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: nil, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: nil, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: nil, name: "Craig", score: 150)
|
||||
var player4 = Player(id: nil, name: "David", score: 120)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we delete all players
|
||||
try appDatabase.deleteAllPlayers()
|
||||
|
||||
// Then the database does not contain any player
|
||||
try XCTAssertEqual(dbQueue.read(Player.fetchCount), 0)
|
||||
}
|
||||
|
||||
func test_refreshPlayers_populates_an_empty_database() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
|
||||
// When we refresh players
|
||||
try appDatabase.refreshPlayers()
|
||||
|
||||
// Then the database is not empty
|
||||
try XCTAssert(dbQueue.read(Player.fetchCount) > 0)
|
||||
}
|
||||
|
||||
func test_createRandomPlayersIfEmpty_populates_an_empty_database() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
|
||||
// When we create random players
|
||||
try appDatabase.createRandomPlayersIfEmpty()
|
||||
|
||||
// Then the database is not empty
|
||||
try XCTAssert(dbQueue.read(Player.fetchCount) > 0)
|
||||
}
|
||||
|
||||
func test_createRandomPlayersIfEmpty_does_not_modify_a_non_empty_database() throws {
|
||||
// Given a players database that contains one player
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
let appDatabase = try AppDatabase(dbQueue)
|
||||
var player = Player(id: nil, name: "Arthur", score: 100)
|
||||
try dbQueue.write { db in
|
||||
try player.insert(db)
|
||||
}
|
||||
|
||||
// When we create random players
|
||||
try appDatabase.createRandomPlayersIfEmpty()
|
||||
|
||||
// Then the database still only contains the original player
|
||||
let players = try dbQueue.read(Player.fetchAll)
|
||||
XCTAssertEqual(players, [player])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,43 @@
|
||||
import XCTest
|
||||
import GRDB
|
||||
@testable import GRDBCombineDemo
|
||||
|
||||
class PlayerRequestTests: XCTestCase {
|
||||
func test_PlayerRequest_byName_fetches_well_ordered_players() throws {
|
||||
// Given a players database that contains two players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: nil, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: nil, name: "Barbara", score: 1000)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by name
|
||||
let playerRequest = PlayerRequest(ordering: .byName)
|
||||
let players = try dbQueue.read(playerRequest.fetch)
|
||||
|
||||
// Then the players are the two players ordered by name
|
||||
XCTAssertEqual(players, [player1, player2])
|
||||
}
|
||||
|
||||
func test_PlayerRequest_byScore_fetches_well_ordered_players() throws {
|
||||
// Given a players database that contains two players
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: nil, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: nil, name: "Barbara", score: 1000)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by score
|
||||
let playerRequest = PlayerRequest(ordering: .byScore)
|
||||
let players = try dbQueue.read(playerRequest.fetch)
|
||||
|
||||
// Then the players are the two players ordered by score descending
|
||||
XCTAssertEqual(players, [player2, player1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import XCTest
|
||||
import GRDB
|
||||
@testable import GRDBCombineDemo
|
||||
|
||||
class PlayerTests: XCTestCase {
|
||||
// MARK: - CRUD
|
||||
// Test that our Player type properly talks to GRDB.
|
||||
|
||||
func testInsert() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
|
||||
// When we insert a player
|
||||
var player = Player(id: nil, name: "Arthur", score: 100)
|
||||
try dbQueue.write { db in
|
||||
try player.insert(db)
|
||||
}
|
||||
|
||||
// Then the player gets a non-nil id
|
||||
XCTAssertNotNil(player.id)
|
||||
}
|
||||
|
||||
func testRoundtrip() throws {
|
||||
// Given an empty players database
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
|
||||
// When we insert a player and fetch the player with the same id
|
||||
var insertedPlayer = Player(id: nil, name: "Arthur", score: 100)
|
||||
let fetchedPlayer: Player? = try dbQueue.write { db in
|
||||
try insertedPlayer.insert(db)
|
||||
return try Player.fetchOne(db, key: insertedPlayer.id)
|
||||
}
|
||||
|
||||
// Then the fetched player is equal to the inserted player
|
||||
XCTAssertEqual(insertedPlayer, fetchedPlayer)
|
||||
}
|
||||
|
||||
// MARK: - Requests
|
||||
// Test that requests defined on the Player type behave as expected.
|
||||
|
||||
func testOrderedByScore() throws {
|
||||
// Given a players database that contains players with distinct scores
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: 1, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: 2, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: 3, name: "Craig", score: 150)
|
||||
var player4 = Player(id: 4, name: "David", score: 120)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by score
|
||||
let players = try dbQueue.read(Player.all().orderedByScore().fetchAll)
|
||||
|
||||
// Then fetched players are ordered by score descending
|
||||
XCTAssertEqual(players, [player2, player3, player4, player1])
|
||||
}
|
||||
|
||||
func testOrderedByScoreSortsIdenticalScoresByName() throws {
|
||||
// Given a players database that contains players with common scores
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: 1, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: 2, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: 3, name: "Craig", score: 200)
|
||||
var player4 = Player(id: 4, name: "David", score: 200)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by score
|
||||
let players = try dbQueue.read(Player.all().orderedByScore().fetchAll)
|
||||
|
||||
// Then fetched players are ordered by score descending and by name
|
||||
XCTAssertEqual(players, [player2, player3, player4, player1])
|
||||
}
|
||||
|
||||
func testOrderedByName() throws {
|
||||
// Given a players database that contains players with distinct names
|
||||
let dbQueue = try DatabaseQueue(configuration: AppDatabase.makeConfiguration())
|
||||
_ = try AppDatabase(dbQueue)
|
||||
var player1 = Player(id: 1, name: "Arthur", score: 100)
|
||||
var player2 = Player(id: 2, name: "Barbara", score: 200)
|
||||
var player3 = Player(id: 3, name: "Craig", score: 150)
|
||||
var player4 = Player(id: 4, name: "David", score: 120)
|
||||
try dbQueue.write { db in
|
||||
try player1.insert(db)
|
||||
try player2.insert(db)
|
||||
try player3.insert(db)
|
||||
try player4.insert(db)
|
||||
}
|
||||
|
||||
// When we fetch players ordered by name
|
||||
let players = try dbQueue.read(Player.all().orderedByName().fetchAll)
|
||||
|
||||
// Then fetched players are ordered by name
|
||||
XCTAssertEqual(players, [player1, player2, player3, player4])
|
||||
}
|
||||
}
|
||||