diff --git a/CHANGELOG.md b/CHANGELOG.md index 387b0a8044..c2f3572301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Documentation/GoodPracticesForDesigningRecordTypes.md b/Documentation/GoodPracticesForDesigningRecordTypes.md index 7930a3af43..96aee73423 100644 --- a/Documentation/GoodPracticesForDesigningRecordTypes.md +++ b/Documentation/GoodPracticesForDesigningRecordTypes.md @@ -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 } @@ -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 { - 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 { - 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 { + 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) } ``` @@ -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 diff --git a/GRDB/QueryInterface/Request/Association/Association.swift b/GRDB/QueryInterface/Request/Association/Association.swift index 2ff915f14e..27cc272fd4 100644 --- a/GRDB/QueryInterface/Request/Association/Association.swift +++ b/GRDB/QueryInterface/Request/Association/Association.swift @@ -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 - /// static let author = belongsTo(Author.self) - /// } - associatedtype RowDecoder - /// :nodoc: var sqlAssociation: SQLAssociation { get } @@ -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)) @@ -273,51 +290,6 @@ extension Association { } } -extension Association { - /// Creates an association that prefetches another one. - public func including(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(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(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(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(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: diff --git a/GRDB/QueryInterface/Request/QueryInterfaceRequest+Association.swift b/GRDB/QueryInterface/Request/QueryInterfaceRequest+Association.swift index 8ef57e1d9e..ccc0808572 100644 --- a/GRDB/QueryInterface/Request/QueryInterfaceRequest+Association.swift +++ b/GRDB/QueryInterface/Request/QueryInterfaceRequest+Association.swift @@ -1,58 +1,4 @@ extension QueryInterfaceRequest where RowDecoder: TableRecord { - // MARK: - Associations - - /// Creates a request that prefetches an association. - public func including(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(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(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(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(required association: A) -> QueryInterfaceRequest where A.OriginRowDecoder == RowDecoder { - return mapQuery { - $0.mapRelation { - $0.joining(required: association.sqlAssociation) - } - } - } // MARK: - Association Aggregates diff --git a/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift b/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift index 6c710e103e..22ae9c18e9 100644 --- a/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift +++ b/GRDB/QueryInterface/Request/QueryInterfaceRequest.swift @@ -28,7 +28,7 @@ /// } /// /// See https://github.com/groue/GRDB.swift#the-query-interface -public struct QueryInterfaceRequest { +public struct QueryInterfaceRequest: DerivableRequest { var query: SQLQuery init(query: SQLQuery) { @@ -44,7 +44,7 @@ public struct QueryInterfaceRequest { } } -extension QueryInterfaceRequest : FetchRequest { +extension QueryInterfaceRequest: FetchRequest { public typealias RowDecoder = T /// Returns a tuple that contains a prepared statement that is ready to be @@ -108,10 +108,9 @@ extension QueryInterfaceRequest : FetchRequest { } } -extension QueryInterfaceRequest : DerivableRequest, AggregatingRequest { - +extension QueryInterfaceRequest: SelectionRequest { // MARK: Request Derivation - + /// Creates a request which selects *selection*. /// /// // SELECT id, email FROM player @@ -214,20 +213,11 @@ extension QueryInterfaceRequest : DerivableRequest, AggregatingRequest { // From raw rows? From copied rows? return mapQuery { $0.annotated(with: selection) } } +} + +extension QueryInterfaceRequest: FilteredRequest { + // MARK: Request Derivation - /// Creates a request which returns distinct rows. - /// - /// // SELECT DISTINCT * FROM player - /// var request = Player.all() - /// request = request.distinct() - /// - /// // SELECT DISTINCT name FROM player - /// var request = Player.select(Column("name")) - /// request = request.distinct() - public func distinct() -> QueryInterfaceRequest { - return mapQuery { $0.distinct() } - } - /// Creates a request with the provided *predicate promise* added to the /// eventual set of already applied predicates. /// @@ -237,28 +227,11 @@ extension QueryInterfaceRequest : DerivableRequest, AggregatingRequest { public func filter(_ predicate: @escaping (Database) throws -> SQLExpressible) -> QueryInterfaceRequest { return mapQuery { $0.filter(predicate) } } - - /// Creates a request which expects a single result. - /// - /// It is unlikely you need to call this method. Its net effect is that - /// QueryInterfaceRequest does not use any `LIMIT 1` sql clause when you - /// call a `fetchOne` method. - /// - /// :nodoc: - public func expectingSingleResult() -> QueryInterfaceRequest { - return mapQuery { $0.expectingSingleResult() } - } - - /// Creates a request grouped according to *expressions promise*. - public func group(_ expressions: @escaping (Database) throws -> [SQLExpressible]) -> QueryInterfaceRequest { - return mapQuery { $0.group(expressions) } - } - - /// Creates a request with the provided *predicate* added to the - /// eventual set of already applied predicates. - public func having(_ predicate: SQLExpressible) -> QueryInterfaceRequest { - return mapQuery { $0.having(predicate) } - } + +} + +extension QueryInterfaceRequest: OrderedRequest { + // MARK: Request Derivation /// Creates a request with the provided *orderings promise*. /// @@ -300,6 +273,78 @@ extension QueryInterfaceRequest : DerivableRequest, AggregatingRequest { public func unordered() -> QueryInterfaceRequest { return mapQuery { $0.unordered() } } +} + +extension QueryInterfaceRequest: AggregatingRequest { + // MARK: Request Derivation + + /// Creates a request grouped according to *expressions promise*. + public func group(_ expressions: @escaping (Database) throws -> [SQLExpressible]) -> QueryInterfaceRequest { + return mapQuery { $0.group(expressions) } + } + + /// Creates a request with the provided *predicate* added to the + /// eventual set of already applied predicates. + public func having(_ predicate: SQLExpressible) -> QueryInterfaceRequest { + return mapQuery { $0.having(predicate) } + } +} + +extension QueryInterfaceRequest: JoinableRequest { + /// :nodoc: + public func _including(all association: SQLAssociation) -> QueryInterfaceRequest { + return mapQuery { $0._including(all: association) } + } + + /// :nodoc: + public func _including(optional association: SQLAssociation) -> QueryInterfaceRequest { + return mapQuery { $0._including(optional: association) } + } + + /// :nodoc: + public func _including(required association: SQLAssociation) -> QueryInterfaceRequest { + return mapQuery { $0._including(required: association) } + } + + /// :nodoc: + public func _joining(optional association: SQLAssociation) -> QueryInterfaceRequest { + return mapQuery { $0._joining(optional: association) } + } + + /// :nodoc: + public func _joining(required association: SQLAssociation) -> QueryInterfaceRequest { + return mapQuery { $0._joining(required: association) } + } +} + +extension QueryInterfaceRequest { + + // MARK: Request Derivation + + /// Creates a request which returns distinct rows. + /// + /// // SELECT DISTINCT * FROM player + /// var request = Player.all() + /// request = request.distinct() + /// + /// // SELECT DISTINCT name FROM player + /// var request = Player.select(Column("name")) + /// request = request.distinct() + public func distinct() -> QueryInterfaceRequest { + return mapQuery { $0.distinct() } + } + + /// Creates a request which expects a single result. + /// + /// It is unlikely you need to call this method. Its net effect is that + /// QueryInterfaceRequest does not use any `LIMIT 1` sql clause when you + /// call a `fetchOne` method. + /// + /// :nodoc: + public func expectingSingleResult() -> QueryInterfaceRequest { + return mapQuery { $0.expectingSingleResult() } + } + /// Creates a request which fetches *limit* rows, starting at *offset*. /// diff --git a/GRDB/QueryInterface/Request/RequestProtocols.swift b/GRDB/QueryInterface/Request/RequestProtocols.swift index 9d6e09ed6c..c8c467d4f6 100644 --- a/GRDB/QueryInterface/Request/RequestProtocols.swift +++ b/GRDB/QueryInterface/Request/RequestProtocols.swift @@ -384,7 +384,7 @@ extension AggregatingRequest { /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) /// -/// The protocol for all requests that be ordered. +/// The protocol for all requests that can be ordered. /// /// :nodoc: public protocol OrderedRequest { @@ -494,13 +494,97 @@ extension OrderedRequest { } } -// MARK: - DerivableRequest +/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) +/// +/// Type-unsafe support for the JoinableRequest protocol. +/// +/// :nodoc: +public protocol _JoinableRequest { + /// Creates a request that prefetches an association. + func _including(all association: SQLAssociation) -> Self + + /// 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. + func _including(optional association: SQLAssociation) -> Self + + /// 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. + func _including(required association: SQLAssociation) -> Self + + /// 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. + func _joining(optional association: SQLAssociation) -> Self + + /// 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. + func _joining(required association: SQLAssociation) -> Self +} /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) /// -/// The base protocol for all requests that can be refined. +/// The protocol for all requests that can be associated. /// /// :nodoc: -public protocol DerivableRequest: SelectionRequest, FilteredRequest, OrderedRequest { +public protocol JoinableRequest: _JoinableRequest { + /// The record type that can be associated to. + /// + /// In the request below, it is Book: + /// + /// let request = Book.all() + /// + /// In the `belongsTo` association below, it is Author: + /// + /// struct Book: TableRecord { + /// // BelongsToAssociation + /// static let author = belongsTo(Author.self) + /// } associatedtype RowDecoder } + +extension JoinableRequest { + /// Creates a request that prefetches an association. + public func including(all association: A) -> Self where A.OriginRowDecoder == RowDecoder { + return _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(optional association: A) -> Self where A.OriginRowDecoder == RowDecoder { + return _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(required association: A) -> Self where A.OriginRowDecoder == RowDecoder { + return _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(optional association: A) -> Self where A.OriginRowDecoder == RowDecoder { + return _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(required association: A) -> Self where A.OriginRowDecoder == RowDecoder { + return _joining(required: association.sqlAssociation) + } +} + +// MARK: - DerivableRequest + +/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) +/// +/// The base protocol for all requests that can be refined. +/// +/// :nodoc: +public protocol DerivableRequest: SelectionRequest, FilteredRequest, OrderedRequest, JoinableRequest { } diff --git a/GRDB/QueryInterface/SQL/SQLAssociation.swift b/GRDB/QueryInterface/SQL/SQLAssociation.swift index a918fcea54..6419373fe9 100644 --- a/GRDB/QueryInterface/SQL/SQLAssociation.swift +++ b/GRDB/QueryInterface/SQL/SQLAssociation.swift @@ -208,7 +208,7 @@ public /* TODO: internal */ struct SQLAssociation { reversedAssociation = reversedAssociation.mapDestinationRelation { _ in filteredPivotRelation.select([]).deletingChildren() } - return destination.relation.appending(reversedAssociation, kind: .oneRequired) + return destination.relation.appendingChild(for: reversedAssociation, kind: .oneRequired) } } diff --git a/GRDB/QueryInterface/SQL/SQLQuery.swift b/GRDB/QueryInterface/SQL/SQLQuery.swift index 454f4f5231..ad3e1b2fba 100644 --- a/GRDB/QueryInterface/SQL/SQLQuery.swift +++ b/GRDB/QueryInterface/SQL/SQLQuery.swift @@ -29,13 +29,12 @@ struct SQLQuery { } } -extension SQLQuery: SelectionRequest, FilteredRequest, OrderedRequest { - func select(_ selection: [SQLSelectable]) -> SQLQuery { - return mapRelation { $0.select(selection) } - } - - func annotated(with selection: [SQLSelectable]) -> SQLQuery { - return mapRelation { $0.annotated(with: selection) } +extension SQLQuery { + /// Returns a query whose relation is transformed by the given closure. + func mapRelation(_ transform: (SQLRelation) -> SQLRelation) -> SQLQuery { + var query = self + query.relation = transform(relation) + return query } func distinct() -> SQLQuery { @@ -43,29 +42,41 @@ extension SQLQuery: SelectionRequest, FilteredRequest, OrderedRequest { query.isDistinct = true return query } - + func expectingSingleResult() -> SQLQuery { var query = self query.expectsSingleResult = true return query } - func filter(_ predicate: @escaping (Database) throws -> SQLExpressible) -> SQLQuery { - return mapRelation { $0.filter(predicate) } - } - - func group(_ expressions: @escaping (Database) throws -> [SQLExpressible]) -> SQLQuery { + func limit(_ limit: Int, offset: Int? = nil) -> SQLQuery { var query = self - query.groupPromise = DatabasePromise { db in try expressions(db).map { $0.sqlExpression } } + query.limit = SQLLimit(limit: limit, offset: offset) return query } - func having(_ predicate: SQLExpressible) -> SQLQuery { - var query = self - query.havingExpressions.append(predicate.sqlExpression) - return query + func qualified(with alias: TableAlias) -> SQLQuery { + return mapRelation { $0.qualified(with: alias) } + } +} + +extension SQLQuery: SelectionRequest { + func select(_ selection: [SQLSelectable]) -> SQLQuery { + return mapRelation { $0.select(selection) } + } + + func annotated(with selection: [SQLSelectable]) -> SQLQuery { + return mapRelation { $0.annotated(with: selection) } } +} + +extension SQLQuery: FilteredRequest { + func filter(_ predicate: @escaping (Database) throws -> SQLExpressible) -> SQLQuery { + return mapRelation { $0.filter(predicate) } + } +} +extension SQLQuery: OrderedRequest { func order(_ orderings: @escaping (Database) throws -> [SQLOrderingTerm]) -> SQLQuery { return mapRelation { $0.order(orderings) } } @@ -77,25 +88,44 @@ extension SQLQuery: SelectionRequest, FilteredRequest, OrderedRequest { func unordered() -> SQLQuery { return mapRelation { $0.unordered() } } +} - func limit(_ limit: Int, offset: Int? = nil) -> SQLQuery { +extension SQLQuery: AggregatingRequest { + func group(_ expressions: @escaping (Database) throws -> [SQLExpressible]) -> SQLQuery { var query = self - query.limit = SQLLimit(limit: limit, offset: offset) + query.groupPromise = DatabasePromise { db in try expressions(db).map { $0.sqlExpression } } return query } - func qualified(with alias: TableAlias) -> SQLQuery { - return mapRelation { $0.qualified(with: alias) } - } - - /// Returns a query whose relation is transformed by the given closure. - func mapRelation(_ transform: (SQLRelation) -> SQLRelation) -> SQLQuery { + func having(_ predicate: SQLExpressible) -> SQLQuery { var query = self - query.relation = transform(relation) + query.havingExpressions.append(predicate.sqlExpression) return query } } +extension SQLQuery: _JoinableRequest { + func _including(all association: SQLAssociation) -> SQLQuery { + return mapRelation { $0._including(all: association) } + } + + func _including(optional association: SQLAssociation) -> SQLQuery { + return mapRelation { $0._including(optional: association) } + } + + func _including(required association: SQLAssociation) -> SQLQuery { + return mapRelation { $0._including(required: association) } + } + + func _joining(optional association: SQLAssociation) -> SQLQuery { + return mapRelation { $0._joining(optional: association) } + } + + func _joining(required association: SQLAssociation) -> SQLQuery { + return mapRelation { $0._joining(required: association) } + } +} + extension SQLQuery { func fetchCount(_ db: Database) throws -> Int { let (statement, adapter) = try SQLQueryGenerator(countQuery).prepare(db) diff --git a/GRDB/QueryInterface/SQL/SQLRelation.swift b/GRDB/QueryInterface/SQL/SQLRelation.swift index f29b0ec034..ec5123fa26 100644 --- a/GRDB/QueryInterface/SQL/SQLRelation.swift +++ b/GRDB/QueryInterface/SQL/SQLRelation.swift @@ -244,39 +244,6 @@ extension SQLRelation { return relation } - /// Creates a relation that prefetches another one. - func including(all association: SQLAssociation) -> SQLRelation { - return appending(association, kind: .allPrefetched) - } - - /// Creates a relation that includes another one. The columns of the - /// associated record are selected. The returned relation does not - /// require that the associated database table contains a matching row. - func including(optional association: SQLAssociation) -> SQLRelation { - return appending(association, kind: .oneOptional) - } - - /// Creates a relation that includes another one. The columns of the - /// associated record are selected. The returned relation requires - /// that the associated database table contains a matching row. - func including(required association: SQLAssociation) -> SQLRelation { - return appending(association, kind: .oneRequired) - } - - /// Creates a relation that joins another one. The columns of the - /// associated record are not selected. The returned relation does not - /// require that the associated database table contains a matching row. - func joining(optional association: SQLAssociation) -> SQLRelation { - return appending(association.mapDestinationRelation { $0.select([]) }, kind: .oneOptional) - } - - /// Creates a relation that joins another one. The columns of the - /// associated record are not selected. The returned relation requires - /// that the associated database table contains a matching row. - func joining(required association: SQLAssociation) -> SQLRelation { - return appending(association.mapDestinationRelation { $0.select([]) }, kind: .oneRequired) - } - /// Returns a relation extended with an association. /// /// This method provides support for public joining methods such @@ -309,7 +276,7 @@ extension SQLRelation { /// HasMany in the above examples, but also for indirect associations such /// as HasManyThrough, which have any number of pivot relations between the /// origin and the destination. - func appending(_ association: SQLAssociation, kind: SQLRelation.Child.Kind) -> SQLRelation { + func appendingChild(for association: SQLAssociation, kind: SQLRelation.Child.Kind) -> SQLRelation { let childCardinality = (kind == .allNotPrefetched) // preserve association cardinality in intermediate steps of including(all:) ? association.destination.cardinality @@ -359,7 +326,7 @@ extension SQLRelation { switch kind { case .oneRequired, .oneOptional, .allNotPrefetched: - return appending(reducedAssociation, kind: kind) + return appendingChild(for: reducedAssociation, kind: kind) case .allPrefetched: // Intermediate steps of indirect associations are not prefetched. // @@ -371,7 +338,7 @@ extension SQLRelation { // static let citizens = hasMany(Citizens.self, through: passports, using: Passport.citizen) // } // let request = Country.including(all: Country.citizens) - return appending(reducedAssociation, kind: .allNotPrefetched) + return appendingChild(for: reducedAssociation, kind: .allNotPrefetched) } } @@ -390,6 +357,28 @@ extension SQLRelation { } } +extension SQLRelation: _JoinableRequest { + func _including(all association: SQLAssociation) -> SQLRelation { + return appendingChild(for: association, kind: .allPrefetched) + } + + func _including(optional association: SQLAssociation) -> SQLRelation { + return appendingChild(for: association, kind: .oneOptional) + } + + func _including(required association: SQLAssociation) -> SQLRelation { + return appendingChild(for: association, kind: .oneRequired) + } + + func _joining(optional association: SQLAssociation) -> SQLRelation { + return appendingChild(for: association.mapDestinationRelation { $0.select([]) }, kind: .oneOptional) + } + + func _joining(required association: SQLAssociation) -> SQLRelation { + return appendingChild(for: association.mapDestinationRelation { $0.select([]) }, kind: .oneRequired) + } +} + // MARK: - SQLSource enum SQLSource { diff --git a/README.md b/README.md index d714bbf3aa..3b2a5a5bb6 100644 --- a/README.md +++ b/README.md @@ -2566,7 +2566,7 @@ extension Place : MutablePersistableRecord { container["longitude"] = coordinate.longitude } - // Update id upon successful insertion: + // Update auto-incremented id upon successful insertion mutating func didInsert(with rowID: Int64, for column: String?) { id = rowID } @@ -2607,6 +2607,7 @@ struct Player: Encodable, MutablePersistableRecord { var name: String var score: Int + // Update auto-incremented id upon successful insertion mutating func didInsert(with rowID: Int64, for column: String?) { id = rowID } @@ -3305,6 +3306,7 @@ When SQLite won't let you provide an explicit primary key (as in [full-text](#fu container["date"] = date } + // Update auto-incremented id upon successful insertion mutating func didInsert(with rowID: Int64, for column: String?) { id = rowID } @@ -3398,7 +3400,7 @@ extension Place: FetchableRecord { } // Persistence methods extension Place: MutablePersistableRecord { - /// Update record ID after a successful insertion + // Update auto-incremented id upon successful insertion mutating func didInsert(with rowID: Int64, for column: String?) { id = rowID } @@ -3452,7 +3454,7 @@ extension Place: MutablePersistableRecord { container[Columns.longitude] = coordinate.longitude } - /// Update record ID after a successful insertion + // Update auto-incremented id upon successful insertion mutating func didInsert(with rowID: Int64, for column: String?) { id = rowID } @@ -3511,7 +3513,7 @@ class Place: Record { container[Columns.longitude] = coordinate.longitude } - /// Update record ID after a successful insertion + // Update auto-incremented id upon successful insertion override func didInsert(with rowID: Int64, for column: String?) { id = rowID } diff --git a/Tests/GRDBTests/DerivableRequestTests.swift b/Tests/GRDBTests/DerivableRequestTests.swift index cc971e2afd..9d0f991b7a 100644 --- a/Tests/GRDBTests/DerivableRequestTests.swift +++ b/Tests/GRDBTests/DerivableRequestTests.swift @@ -60,10 +60,17 @@ private var libraryMigrator: DatabaseMigrator = { // Define DerivableRequest extensions extension DerivableRequest where RowDecoder == Author { + // SelectionRequest + func selectCountry() -> Self { + return select(Column("country")) + } + + // FilteredRequest func filter(country: String) -> Self { return filter(Column("country") == country) } + // OrderedRequest func orderByFullName() -> Self { return order( Column("lastName").collating(.localizedCaseInsensitiveCompare), @@ -72,13 +79,19 @@ extension DerivableRequest where RowDecoder == Author { } extension DerivableRequest where RowDecoder == Book { + // OrderedRequest func orderByTitle() -> Self { return order(Column("title").collating(.localizedCaseInsensitiveCompare)) } + + // JoinableRequest + func filter(authorCountry: String) -> Self { + return joining(required: Book.author.filter(country: authorCountry)) + } } class DerivableRequestTests: GRDBTestCase { - func testFilter() throws { + func testFilteredRequest() throws { let dbQueue = try makeDatabaseQueue() try libraryMigrator.migrate(dbQueue) try dbQueue.inDatabase { db in @@ -99,7 +112,7 @@ class DerivableRequestTests: GRDBTestCase { } } - func testOrder() throws { + func testOrderedRequest() throws { let dbQueue = try makeDatabaseQueue() try libraryMigrator.migrate(dbQueue) try dbQueue.inDatabase { db in @@ -184,4 +197,70 @@ class DerivableRequestTests: GRDBTestCase { """) } } + + func testSelectionRequest() throws { + let dbQueue = try makeDatabaseQueue() + try libraryMigrator.migrate(dbQueue) + try dbQueue.inDatabase { db in + do { + sqlQueries.removeAll() + let request = Author.all().selectCountry() + let authorCountries = try Set(String.fetchAll(db, request)) + XCTAssertEqual(authorCountries, ["FR", "US"]) + XCTAssertEqual(lastSQLQuery, """ + SELECT "country" FROM "author" + """) + } + + do { + sqlQueries.removeAll() + let request = Book.including(required: Book.author.selectCountry()) + _ = try Row.fetchAll(db, request) + XCTAssertEqual(lastSQLQuery, """ + SELECT "book".*, "author"."country" \ + FROM "book" \ + JOIN "author" ON "author"."id" = "book"."authorId" + """) + } + } + } + + func testJoinableRequest() throws { + let dbQueue = try makeDatabaseQueue() + try libraryMigrator.migrate(dbQueue) + try dbQueue.inDatabase { db in + do { + sqlQueries.removeAll() + let frenchBookTitles = try Book.all() + .filter(authorCountry: "FR") + .order(Column("title")) + .fetchAll(db) + .map { $0.title } + XCTAssertEqual(frenchBookTitles, ["Du côté de chez Swann"]) + XCTAssertEqual(lastSQLQuery, """ + SELECT "book".* \ + FROM "book" \ + JOIN "author" ON ("author"."id" = "book"."authorId") AND ("author"."country" = 'FR') \ + ORDER BY "book"."title" + """) + } + + do { + sqlQueries.removeAll() + let frenchAuthorFullNames = try Author + .joining(required: Author.books.filter(authorCountry: "FR")) + .order(Column("firstName")) + .fetchAll(db) + .map { $0.fullName } + XCTAssertEqual(frenchAuthorFullNames, ["Marcel Proust"]) + XCTAssertEqual(lastSQLQuery, """ + SELECT "author1".* \ + FROM "author" "author1" \ + JOIN "book" ON "book"."authorId" = "author1"."id" \ + JOIN "author" "author2" ON ("author2"."id" = "book"."authorId") AND ("author2"."country" = 'FR') \ + ORDER BY "author1"."firstName" + """) + } + } + } }