Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose joining methods on both requests and associations #539

Merged
merged 13 commits into from
May 30, 2019
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one expection:

- [#537](https://github.com/groue/GRDB.swift/pull/537): Remove useless parenthesis from generated SQL
- [#538](https://github.com/groue/GRDB.swift/pull/538) by [@Timac](https://github.com/Timac): Add FAQ to clarify "Wrong number of statement arguments" error with "like '%?%'"
- [#539](https://github.com/groue/GRDB.swift/pull/539): Expose joining methods of both requests and associations

### Documentation Diff

The [Define Record Requests](Documentation/GoodPracticesForDesigningRecordTypes.md#define-record-requests) chapter of the The [Good Practices for Designing Record Types](Documentation/GoodPracticesForDesigningRecordTypes.md) has been rewritten. Yes, good practices evolve.


## 4.0.1
Expand Down
130 changes: 83 additions & 47 deletions Documentation/GoodPracticesForDesigningRecordTypes.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,14 @@ struct Book: Codable {
// Add Database access

extension Author: FetchableRecord, MutablePersistableRecord {
// Update auto-incremented id upon successful insertion
mutating func didInsert(with rowID: Int64, for column: String?) {
id = rowID
}
}

extension Book: FetchableRecord, MutablePersistableRecord {
// Update auto-incremented id upon successful insertion
mutating func didInsert(with rowID: Int64, for column: String?) {
id = rowID
}
Expand Down Expand Up @@ -148,103 +150,134 @@ Applying the **[Single Responsibility Principle]** has a consequence: don't even

Now that we have record types that are able to read and write in the database, we'd like to put them to good use.

> :bulb: **Tip**: Define requests which make sense for your application in your record types.
> :bulb: **Tip**: Define an enumeration of columns that you will use in order to filter, sort, etc.

This may give:
When your record type is a [Codable Record], derive columns from the [CodingKeys] enum:

```swift
// For a codable record
extension Author {
// Define database columns from CodingKeys
private enum Columns {
fileprivate enum Columns {
static let id = Column(CodingKeys.id)
static let name = Column(CodingKeys.name)
static let country = Column(CodingKeys.country)
}

/// Returns a request for all authors ordered by name, in a localized
/// case-insensitive fashion.
static func orderByName() -> QueryInterfaceRequest<Author> {
let name = Author.Columns.name
return Author.order(name.collating(.localizedCaseInsensitiveCompare))
}

/// Returns a request for all authors from a country.
static func filter(country: String) -> QueryInterfaceRequest<Author> {
return Author.filter(Author.Columns.country == country)
}
}
```

Those requests will hide intimate database details like database columns inside the record types, and make your application code crystal clear:
Otherwise, declare a plain String enum that conforms to the ColumnExpression protocol:

```swift
let sortedAuthors = try dbQueue.read { db in
try Author.orderByName().fetchAll(db)
// For a non-codable record
extension Author {
// Define database columns as an enum
fileprivate enum Columns: String, ColumnExpression {
case id, name, country
}
}
```

You can also use those requests to [observe database changes] in order to, say, reload a table view:
Note that those Columns enum are declared `fileprivate`, because we prefer hiding them as much as possible from the rest of the application.

```swift
try ValueObservation
.trackingAll(Author.orderByName())
.start(in: dbQueue) { (authors: [Author]) in
print("fresh authors: \(authors)")
}
```
> :bulb: **Tip**: Define commonly used requests in a constrained extension of the `DerivableRequest` protocol.

### Make Requests Able to Compose Together
The `DerivableRequest` protocol generally lets you filter, sort, and join or include associations (we'll talk about associations in the [Compose Records] chapter below).

When requests should be composed together, don't define them as static methods of your record type. Instead, define them in a constrained extension of the `DerivableRequest` protocol:
Here is how you define those requests:

```swift
extension Author {
// Define database columns from CodingKeys
fileprivate enum Columns {
static let id = Column(CodingKeys.id)
static let name = Column(CodingKeys.name)
static let country = Column(CodingKeys.country)
}
}

// Some requests of Author --------------------v
extension DerivableRequest where RowDecoder == Author {

/// Returns a request for all authors ordered by name, in a localized
/// case-insensitive fashion.
/// case-insensitive fashion
func orderByName() -> Self {
let name = Author.Columns.name
return order(name.collating(.localizedCaseInsensitiveCompare))
}

/// Returns a request for all authors from a country.
/// Returns a request for all authors from a country
func filter(country: String) -> Self {
return filter(Author.Columns.country == country)
}
}
```

This extension lets you compose complex requests from small building blocks:
Those methods defined on the `DerivableRequest` protocol hide intimate database details. They allow you to compose database requests in a fluent style:

```swift
try dbQueue.read { db in
let sortedAuthors = try Author.all()
let sortedAuthors: [Author] = try Author.all()
.orderByName()
.fetchAll(db)
let frenchAuthors = try Author.all()

let frenchAuthors: [Author] = try Author.all()
.filter(country: "France")
.fetchAll(db)
let sortedSpanishAuthors = try Author.all()

let sortedSpanishAuthors: [Author] = try Author.all()
.filter(country: "Spain")
.orderByName()
.fetchAll(db)
}
```

Extensions on `DerivableRequest` also play nicely with record [associations]:
Those methods are also available on record associations, because associations also conform to the `DerivableRequest` protocol:

```swift
// Some requests of Book
extension DerivableRequest where RowDecoder == Book {
/// Returns a request for all books from a country
func filter(authorCountry: String) -> Self {
// A book is from a country if it can be
// joined with an author from that country:
// ---------------------------------v
return joining(required: Book.author.filter(country: authorCountry))
}
}

try dbQueue.read { db in
let italianBooks = try Book.all()
.filter(authorCountry: "Italy")
.fetchAll(db)
}
```

Not *every requests* can be expressed on `DerivableRequest`. For example, [Association Aggregates] are out of scope. When this happens, define your requests in a constrained extension to `QueryInterfaceRequest`:

```swift
// More requests of Author -------------------------v
extension QueryInterfaceRequest where RowDecoder == Author {
/// Returns a request for all authors with at least one book
func havingBooks() -> QueryInterfaceRequest<Author> {
return having(Author.books.isEmpty == false)
}
}
```

Those requests defined on `QueryInterfaceRequest` still compose fluently:

```swift
try dbQueue.read { db in
let sortedFrenchAuthorsHavingBooks = try Author.all()
.filter(country: "France")
.havingBooks() // <-
.orderByName()
.fetchAll(db)
}
```

Finally, when it happens that a request only makes sense when defined on the Record type itself, just go ahead and define a static method on your Record type:

```swift
let englishBooks = try dbQueue.read { db in
// Only keep books that can be joined to an English author
try Book.joining(required: Book.author.filter(country: "United Kingdom"))
extension MySingletonRecord {
/// The one any only record stored in the database
static let shared = all().limit(1)
}

let singleton = try dbQueue.read { db
try MySingletonRecord.shared.fetchOne(db)
}
```

Expand Down Expand Up @@ -697,3 +730,6 @@ Instead, have a look at [Database Observation]:
[Embrace Errors]: #embrace-errors
[Thread-Safety is also an Application Concern]: #thread-safety-is-also-an-application-concern
[recommended convention]: AssociationsBasics.md#associations-and-the-database-schema
[Association Aggregates]: AssociationsBasics.md#association-aggregates
[Codable Record]: ../README.md#codable-records
[CodingKeys]: https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types
82 changes: 27 additions & 55 deletions GRDB/QueryInterface/Request/Association/Association.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,6 @@ public protocol Association: DerivableRequest {
/// }
associatedtype OriginRowDecoder

/// The associated record type.
///
/// In the `belongsTo` association below, it is Author:
///
/// struct Book: TableRecord {
/// // BelongsToAssociation<Book, Author>
/// static let author = belongsTo(Author.self)
/// }
associatedtype RowDecoder

/// :nodoc:
var sqlAssociation: SQLAssociation { get }

Expand All @@ -53,6 +43,33 @@ public protocol Association: DerivableRequest {
init(sqlAssociation: SQLAssociation)
}

extension Association {
/// :nodoc:
public func _including(all association: SQLAssociation) -> Self {
return mapDestinationRelation { $0._including(all: association) }
}

/// :nodoc:
public func _including(optional association: SQLAssociation) -> Self {
return mapDestinationRelation { $0._including(optional: association) }
}

/// :nodoc:
public func _including(required association: SQLAssociation) -> Self {
return mapDestinationRelation { $0._including(required: association) }
}

/// :nodoc:
public func _joining(optional association: SQLAssociation) -> Self {
return mapDestinationRelation { $0._joining(optional: association) }
}

/// :nodoc:
public func _joining(required association: SQLAssociation) -> Self {
return mapDestinationRelation { $0._joining(required: association) }
}
}

extension Association {
private func mapDestinationRelation(_ transform: (SQLRelation) -> SQLRelation) -> Self {
return Self.init(sqlAssociation: sqlAssociation.mapDestinationRelation(transform))
Expand Down Expand Up @@ -273,51 +290,6 @@ extension Association {
}
}

extension Association {
/// Creates an association that prefetches another one.
public func including<A: AssociationToMany>(all association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapDestinationRelation {
$0.including(all: association.sqlAssociation)
}
}

/// Creates an association that includes another one. The columns of the
/// associated record are selected. The returned association does not
/// require that the associated database table contains a matching row.
public func including<A: Association>(optional association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapDestinationRelation {
$0.including(optional: association.sqlAssociation)
}
}

/// Creates an association that includes another one. The columns of the
/// associated record are selected. The returned association requires
/// that the associated database table contains a matching row.
public func including<A: Association>(required association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapDestinationRelation {
$0.including(required: association.sqlAssociation)
}
}

/// Creates an association that joins another one. The columns of the
/// associated record are not selected. The returned association does not
/// require that the associated database table contains a matching row.
public func joining<A: Association>(optional association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapDestinationRelation {
$0.joining(optional: association.sqlAssociation)
}
}

/// Creates an association that joins another one. The columns of the
/// associated record are not selected. The returned association requires
/// that the associated database table contains a matching row.
public func joining<A: Association>(required association: A) -> Self where A.OriginRowDecoder == RowDecoder {
return mapDestinationRelation {
$0.joining(required: association.sqlAssociation)
}
}
}

// Allow association.filter(key: ...)
extension Association where Self: TableRequest, RowDecoder: TableRecord {
/// :nodoc:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,58 +1,4 @@
extension QueryInterfaceRequest where RowDecoder: TableRecord {
// MARK: - Associations

/// Creates a request that prefetches an association.
public func including<A: AssociationToMany>(all association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
$0.including(all: association.sqlAssociation)
}
}
}

/// Creates a request that includes an association. The columns of the
/// associated record are selected. The returned request does not
/// require that the associated database table contains a matching row.
public func including<A: Association>(optional association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
$0.including(optional: association.sqlAssociation)
}
}
}

/// Creates a request that includes an association. The columns of the
/// associated record are selected. The returned request requires
/// that the associated database table contains a matching row.
public func including<A: Association>(required association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
$0.including(required: association.sqlAssociation)
}
}
}

/// Creates a request that joins an association. The columns of the
/// associated record are not selected. The returned request does not
/// require that the associated database table contains a matching row.
public func joining<A: Association>(optional association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
$0.joining(optional: association.sqlAssociation)
}
}
}

/// Creates a request that joins an association. The columns of the
/// associated record are not selected. The returned request requires
/// that the associated database table contains a matching row.
public func joining<A: Association>(required association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder {
return mapQuery {
$0.mapRelation {
$0.joining(required: association.sqlAssociation)
}
}
}

// MARK: - Association Aggregates

Expand Down
Loading