Design complex search queries using GRDB syntax

I am trying to design a simple search engine in my translation-dictionary app that uses GRDB to handle my database.
I have 4 models that correspond each to a table in my DB: Card, SubCard, WordBox and Translation.

They are linked together like so:
WordBox --> hasMany --> Translation
SubCard --> hasMany --> WordBox
Card --> hasOne --> WordBox (I call it header in my code)
Card --> hasMany --> SubCard
As you can see, WordBox have polymorphic relationship with Card and SubCard

This schema helps me to represent a word and its related derived words. For example, in French the verb "manger" (to eat). I can create subcards for each of the tenses (there are dozens of tenses in French), and as the conjugation differs for each pronouns, each wordbox would correspond to a pronoun. This way I can nicely organize my data

I can fetch all of a card's data through this query:

struct CardInfo: FetchableRecord, Decodable {
    var card: Card
    var header: WordBoxInfo
    var subcards: [SubCardInfo]
}

let query = Card
    .including(required: Card.header
        .including(required: WordBox.translation)
    )
    .including(all: Card.subcards
        .including(all: SubCard.content
            .including(required: WordBox.translation)
        )
    )
    .asRequest(of: CardInfo.self)

But I prefer to fetch only a lighter version (without subcards) for listing displays, as I do not need to display the details. I do it through this query:

struct CardInfoCompressed: FetchableRecord, Decodable {
    var card: Card
    var header: WordBoxInfo
}

let query = Card
    .including(required: Card.header
        .including(required: WordBox.translation)
    )
    .asRequest(of: CardInfoCompressed.self)

Question
My goal is to build search queries based on these requirements:

  1. given a $searchText, I want to look for it in all WordBox.menukad fields, all WordBox.transcription fields and all Translation.body fields for Translation entries that verify Translation.language == LanguageManager.userLanguage
  2. If there are any match, i want to fetch the associated CardInfoCompressed objects and return them.

So there is another degree of complexity, as a WordBox can be connected to a Card or a SubCard. This way if it matches, I then need to fetch the correct cardID by checking the wordBoxableType field, due to polymorphism. If wordBoxableType == card, then i can just return the wordboxableID, but if wordBoxableType == subcard then i need to return the cardID field of the SubCard for which id == wordboxableID. I hope this is not too intricate...
If the Translation.body matches, then i need to look at its WordBox (hasOne) and here we go as above.

I tried several things by defining filters for both my wordbox and translations requirements, then adding them to my query above but it did not work. I am a bit lost on this as I do not find a lot of documentation for complex queries as such. I could use a raw SQL query but I would prefer to achieve this through GRDB.

Below are my structures:

struct Card: Codable {
    var id: Int64?
    var headerID: Int64?
    var shoresh: String? = nil
    var group: CardGroup? = nil
    var tags: [String] = []
    var type: CardType
    var metadata: [String: String] = [:]
    
    var wrappedShoresh: String {
        return shoresh ?? ""
    }
    
    var displayShoresh: String {
        guard let shoresh = shoresh else { return "" }
        return shoresh.map { String($0) }.joined(separator: " - ")
    }

    var wrappedGroupDescription: String {
        return group?.description ?? ""
    }
    
    var wrappedGroupHebrew: String {
        return group?.hebrew ?? ""
    }
}

extension Card: FetchableRecord, MutablePersistableRecord {
    static var databaseTableName: String {
        return "card"
    }
    
    enum Columns: String, ColumnExpression {
        case id, headerID, shoresh, group, tags, type, metadata
    }
    
    static let subcards = hasMany(SubCard.self).forKey("subcards")
    var subcards: QueryInterfaceRequest<SubCard> {
        return request(for: Card.subcards)
    }
    
    static let header = hasOne(WordBox.self, using: ForeignKey(["id"], to: ["headerId"])).forKey("header")
    var header: QueryInterfaceRequest<WordBox> {
        return request(for: Card.header)
            .filter(Column("wordBoxableType") == WordBoxableType.card.rawValue)
    }

    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

struct CardInfo: FetchableRecord, Decodable {
    var card: Card
    var header: WordBoxInfo
    var subcards: [SubCardInfo]
}

struct CardInfoCompressed: FetchableRecord, Decodable {
    var card: Card
    var header: WordBoxInfo
}
struct WordBox: Codable {
    var id: Int64?
    var wordBoxableID: Int64
    var wordBoxableType: WordBoxableType
    var pronouns: [Pronoun]
    var menukad: String
    var transcription: String
    
    var wrappedPronounsHebrew: [String] {
        return pronouns.map { $0.description }
    }
    
    var wrappedPronounsTranscription: [String] {
        return pronouns.map { $0.caseName }
    }
}

extension WordBox: FetchableRecord, MutablePersistableRecord {
    static var databaseTableName: String {
        return "wordBox"
    }
    
    enum Columns: String, ColumnExpression {
        case id, wordBoxableID, wordBoxableType, pronouns, menukad, transcription
    }
    
    static let translations = hasMany(Translation.self, using: ForeignKey(["wordBoxID"], to: ["id"]))
    var translations: QueryInterfaceRequest<Translation> {
        return request(for: WordBox.translations)
    }
    
    static let translation = hasOne(Translation.self, using: ForeignKey(["wordBoxID"], to: ["id"]))
    var translation: QueryInterfaceRequest<Translation> {
        return request(for: WordBox.translations)
            .filter(Column("language") == LanguageManager.shared.currentLanguage.description)
    }
    
    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
    
    func fetchAssociatedCard(db: Database) throws -> Card? {
        switch wordBoxableType {
        case .card:
            return try Card.fetchOne(db, key: wordBoxableID)
        case .subcard:
            if let subCard: SubCard = try SubCard.fetchOne(db, key: wordBoxableID) {
                return try subCard.fetchCard(db: db)
            }
        }
        return nil
    }
}

struct WordBoxInfo: FetchableRecord, Decodable {
    var pronouns: [String]
    var menukad: String
    var transcription: String
    var translation: Translation
}

enum WordBoxableType: String, Codable {
    case card
    case subcard
}
struct SubCard: Codable {
    var id: Int64?
    var cardID: Int64?
    var title: String
}

extension SubCard: FetchableRecord, MutablePersistableRecord {
    static var databaseTableName: String {
        return "subCard"
    }
    
    enum Columns: String, ColumnExpression {
        case id, cardID, title
    }
    
    static let card = belongsTo(Card.self)
    var card: QueryInterfaceRequest<Card> {
        return request(for: SubCard.card)
    }
    
    static let content = hasMany(WordBox.self, using: ForeignKey(["wordBoxableID"], to: ["id"])).forKey("content")
    var content: QueryInterfaceRequest<WordBox> {
        return request(for: SubCard.content)
            .filter(Column("wordBoxableType") == WordBoxableType.subcard.rawValue)
    }
    
    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
    
    func fetchCard(db: Database) throws -> Card? {
        return try card.fetchOne(db)
    }
}

struct SubCardInfo: FetchableRecord, Decodable {
    var title: String
    var content: [WordBoxInfo]
}
struct Translation: Codable {
    var id: Int64?
    var wordBoxID: Int64
    var language: Language
    var body: String
}

extension Translation: FetchableRecord, MutablePersistableRecord {
    static var databaseTableName: String {
        return "translation"
    }
    
    enum Columns: String, ColumnExpression {
        case id, wordBoxID, language, text
    }
    
    static let wordBox = belongsTo(WordBox.self)
    var wordBox: QueryInterfaceRequest<WordBox> {
        return request(for: Translation.wordBox)
    }
    
    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

First I want to clarify a point. I want to do this using GRDB requests and not raw SQL because GRDB request directly map and init my entities which is very convenient. I do not know how I would do the same with raw SQL.

Now that being said, I went through the GRDB files SQLInterpolation+QueryInterface.swift and TableRecord+QueryInterfaceRequest.swift but I could not find a solution.

At first to simplify my problem, I tried to make it work with a simplified request.
So just try to fetch the CardInfoCompressed entities that have a header.transcription that starts with the string 'bok'.
I naively tried with this:

let myFilter = WordBox
    .filter(
        Column("transcription").like("bok%")
    ).exists()

let query = Card
    .filter(myFilter)
    .including(required: Card.header
        .including(required: WordBox.translation)
    )
    .asRequest(of: CardInfoCompressed.self)

But this did not work. It returns every cards, undistinctly.
To understand why, we need to look at the request that was produced:

SELECT "card".*, "wordBox".*, "translation".* 
    FROM "card" 
    JOIN "wordBox" ON "wordBox"."id" = "card"."headerId" 
    JOIN "translation" ON "translation"."wordBoxID" = "wordBox"."id" 
    WHERE EXISTS (
        SELECT * FROM "wordBox" 
        WHERE "transcription" LIKE 'bok%'
    )

The problem lies in the WHERE EXISTS clause. It only checks if a wordBox entity that has a transcription that starts with 'bok' exists. It does not take into account any card object, so this clause is verified for any card, and hence, it returns all the cards undistinctly.

The corrected request would be something like:

SELECT "card".*, "wordBox".*, "translation".*
FROM "card"
JOIN "wordBox" ON "wordBox"."id" = "card"."headerId"
JOIN "translation" ON "translation"."wordBoxID" = "wordBox"."id"
WHERE EXISTS (
    SELECT 1 FROM "wordBox" as "wb"
    WHERE "wb"."transcription" LIKE 'bok%'
      AND "wb"."id" = "card"."headerId"
)

Inside the WHERE clause, we take into account that the wordBox that should verify the filter, should also be associated with the selected card.

Is this feasible to do using GRDB ?
I will be looking and will post if I find a solution.

After getting into more depths in the GRDB doc and a lot of tweaks, I solved my question ! What I missed was the TableAlias notion.

So at first, I tried to create a query that would only parse through my WordBoxes. I discovered the TableAlias and this is what I needed. I wrote this small query:

static func wordBoxQuery(_ searchText: String) -> QueryInterfaceRequest<WordBox> {
    let translationAlias = TableAlias()
    
    let query = WordBox.joining(optional: WordBox.translation.aliased(translationAlias))))
        .filter(
            Column("transcription").like("\(searchText)%")
            || Column("menukad").like("\(searchText)%")
            || translationAlias[Column("body")].like("\(searchText)%")
        )
}

Thanks to the translationAlias, I was able to move the trnaslation condition i was looking for into my filter over the wordboxes.
Thanks to this, I am able to fetch all wordboxes thattranscription or menukad fields start with searchText or that have a translation child which body field starts with searchText.

With trial and errors, I was then able to write the a bigger query that would fetch the cards that own wordboxes that verify these conditions:

static func searchByIds(_ searchText: String) -> QueryInterfaceRequest<Card> {
    let subcardAlias = TableAlias()
    let wordboxAlias = TableAlias()
    let translationAlias = TableAlias()
    
    let headerAlias = TableAlias()
    let headerTranslationAlias = TableAlias()
    
    let query = Card
        .joining(optional: Card.subcards.aliased(subcardAlias)
            .joining(optional: SubCard.content.aliased(wordboxAlias)
                .joining(optional: WordBox.translation.aliased(translationAlias))))
        .joining(optional: Card.header.aliased(headerAlias)
            .joining(optional: WordBox.translation.aliased(headerTranslationAlias)))
        .filter(
            wordboxAlias[Column("transcription")].like("\(searchText)%")
            || wordboxAlias[Column("menukad")].like("\(searchText)%")
            || translationAlias[Column("body")].like("\(searchText)%")
            || headerAlias[Column("transcription")].like("\(searchText)%")
            || headerAlias[Column("menukad")].like("\(searchText)%")
            || headerTranslationAlias[Column("body")].like("\(searchText)%")
        )
        .distinct()

    return query
}

This is good, but I needed to fetch these into a CardInfoPartial structure of such type:

struct CardInfoPartial: FetchableRecord, Decodable {
    struct WordBoxInfo: FetchableRecord, Decodable {
        var pronouns: [String]
        var menukad: String
        var transcription: String
        var translation: Translation
    }
    var card: Card
    var header: WordBoxInfo
}

So I was good with the card but not with the header. I tried to add includings but the query time grew exponentially, probably because it was trying to join allll the headers data and all their translations to my cards.
But as the request qithout the including was fast, I thought I could split this into 2 requests: one that would fetch card ids, and the other that would fetch some cards based on a cardId array. In the end, I ended up with this:

struct CardInfoPartial: FetchableRecord, Decodable {
    var card: Card
    var header: WordBoxInfo
}

extension CardInfoPartial {
    static func byIds(_ ids: [Int64], _ limit: Int? = 20) -> QueryInterfaceRequest<CardInfoPartial> {
        var query = Card
            .filter(ids.contains(Column("id")))
            .including(required: Card.header
                .including(required: WordBox.translation))
            .asRequest(of: CardInfoPartial.self)
        
        if let limit = limit, limit > 0 {
            query = query.limit(limit)
        }
        
        return query
    }

    static func searchByIds(_ searchText: String) -> QueryInterfaceRequest<Int64> {
        let subcardAlias = TableAlias()
        let wordboxAlias = TableAlias()
        let translationAlias = TableAlias()
        
        let headerAlias = TableAlias()
        let headerTranslationAlias = TableAlias()
        
        let query = Card
            .joining(optional: Card.subcards.aliased(subcardAlias)
                .joining(optional: SubCard.content.aliased(wordboxAlias)
                    .joining(optional: WordBox.translation.aliased(translationAlias))))
            .joining(optional: Card.header.aliased(headerAlias)
                .joining(optional: WordBox.translation.aliased(headerTranslationAlias)))
            .filter(
                wordboxAlias[Column("transcription")].like("\(searchText)%")
                || wordboxAlias[Column("menukad")].like("\(searchText)%")
                || translationAlias[Column("body")].like("\(searchText)%")
                || headerAlias[Column("transcription")].like("\(searchText)%")
                || headerAlias[Column("menukad")].like("\(searchText)%")
                || headerTranslationAlias[Column("body")].like("\(searchText)%")
            )
            .distinct()
            .select(Column("id"))
            .asRequest(of: Int64.self)

        return query
    }
}

I wonder if it would have been possible to achieve the same or better efficiency within only one query but this works well so I will have it for now
(Actually I still have an issue but my question from this thread is solved, so Ill open a new one)

1 Like