Skip to content

Using FTS as a subquery of an Association & FTS Query Question #1427

@eytanschulman

Description

@eytanschulman

Question

First of all thank you @groue for all of your work on GRDB, it's a fantastic interface into SQLite for Apple platforms.

I've built an app that hopefully will be finishing off soon and putting on the store that uses GRDB to power its core, and am adding search functionality with FTS5. I decided on using FTS as opposed to a simple LIKE query as I want the user to be able to search multiple fields simultaneously. I've encountered a syntax-and-maybe comprehending issue that after a few days of diving deep into docs and existing issues, decided to ask here.

Currently working - Before Changes

I have two tables, FormulaSectionPersistable, and FormulaPersistable, with a foreignKey and associations between them.

The front screen of the app shows sections and their formulas within them, in order to do that I created a combined struct to query the association, called FormulaSectionInfo.

Below I attach the definitions of all of these structs for context.

When querying the association without search, everything works fine:

let request = FormulaSectionPersistable.including(all: FormulaSectionPersistable.formulas)
                    .asRequest(of: FormulaSectionInfo.self)
                    .order(FormulaSectionPersistable.Columns.sectionName)

return try request.fetchAll(db)

Attempt to Add FTS

When I attempt to query using FTS and add .matching(pattern) to the association FormulaSectionPersistable.formulas, I get no results.

Now I believe I understand why, due to the fact that FormulaPersistable is not a virtual table with FTS enabled, it is where the content itself is stored, and therefore I had to create an "External Content Full Text Table", which I am doing like this:

try db.create(virtualTable: "\(FormulaPersistable.databaseTableName)_ft", using: FTS5()) { t in
        // Porter tokenizer provides English stemming
       t.tokenizer = .porter()
                
       // Index the content of the sessions table
       // See https://github.com/groue/GRDB.swift#external-content-full-text-tables
       t.synchronize(withTable: FormulaPersistable.databaseTableName)
                
       // The indexed columns
       t.column("name")
       t.column("description")
      t.column("formula")
   }

I managed to get FTS to partially work by creating the following query:

let pattern = FTS5Pattern(matchingAllTokensIn: "\(formulaNameQuery)*")
let formulaPersistableSQL = """
                     SELECT \(FormulaPersistable.databaseTableName).*
                     FROM \(FormulaPersistable.databaseTableName)
                     JOIN \(FormulaPersistable.databaseTableName)_ft
                         ON \(FormulaPersistable.databaseTableName)_ft.rowid = \(FormulaPersistable.databaseTableName).rowid
                         AND \(FormulaPersistable.databaseTableName)_ft MATCH ?
                    """
return try FormulaPersistable.fetchAll(db, sql: formulaPersistableSQL, arguments: [pattern])

Two Questions

FTS Query

This query does return results, although not exactly what I expected, I am trying to understand between the different queries + syntax how to produce my expected result -- The user inputs "met" and any results with the word "metal" will appear. At the moment only once the word is completed do results appear.

I came across this answer #908 (comment) from 2021, but am having difficulty understanding how after splitting the user's input by a space into an array of strings I would then recombine it into a query using Database.makeFTS5Pattern(rawPattern:forTable:)

Combining Association with FTS matching query.

My end goal is to fix the FTS query and inject it as a part of my query for FormulaSectionPersistable, as a part of my association of formulas.
This would mean taking this query:

let request = FormulaSectionPersistable.including(all: FormulaSectionPersistable.formulas)
                    .asRequest(of: FormulaSectionInfo.self)
                    .order(FormulaSectionPersistable.Columns.sectionName)

and filtering the FormulaSectionPersistable.formulas part of it with the FTS query which at the moment I'm doing with Raw SQL.
Is this currently possible with the existing syntax of Associations and Queries?

Would greatly appreciate help on these issues, thank you!

Added Context - Struct definitions

Here's FormulaSectionInfo:

public struct FormulaSectionInfo: Decodable, FetchableRecord, Identifiable {
    public var section: FormulaSectionPersistable
    public var formulas: [FormulaPersistable]
    public var id: Int64? { section.id }
    
    public init(section: FormulaSectionPersistable, formulas: [FormulaPersistable], name: String) {
        self.section = section
        self.formulas = formulas
    }
}

Here's FormulaSectionPersistable

public struct FormulaSectionPersistable: Codable, Hashable, Identifiable {
    public var sectionName: String
    public var hash: String
    public var uuid: String
    public var id: Int64?
    
    enum Columns {
        static let sectionName = Column(CodingKeys.sectionName)
        static let hash = Column(CodingKeys.hash)
        static let uuid = Column(CodingKeys.uuid)
        static let id = Column(CodingKeys.id)
    }
}

// Add Database access

extension FormulaSectionPersistable: FetchableRecord, MutablePersistableRecord {
    public static var databaseTableName: String = "formulaSection"
    // Update auto-incremented id upon successful insertion
    public mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

extension FormulaSectionPersistable {
    static let formulas = hasMany(FormulaPersistable.self, using: ForeignKey([FormulaPersistable.Columns.formulaSectionId]))
    /// The request for the formulaSection's formulas
    var formulas: QueryInterfaceRequest<FormulaPersistable> {
        request(for: FormulaSectionPersistable.formulas)
    }
}

Here is FormulaPersistable:

public struct FormulaPersistable: Codable, Hashable, Identifiable {
    public var name: String
    public var formula: String
    public var unit: String?
    public var description: String
    public var variables: [Variable]
    var formulaSectionId: Int64
    public var hash: String
    public var uuid: String
    public var id: Int64?
    
    enum Columns {
        static let name = Column(CodingKeys.name)
        static let formula = Column(CodingKeys.formula)
        static let unit = Column(CodingKeys.unit)
        static let description = Column(CodingKeys.description)
        static let variables = Column(CodingKeys.variables)
        static let formulaSectionId = Column(CodingKeys.formulaSectionId)
        static let hash = Column(CodingKeys.hash)
        static let uuid = Column(CodingKeys.uuid)
        static let id = Column(CodingKeys.id)
    }
}
extension FormulaPersistable: FetchableRecord, MutablePersistableRecord {
    public static var databaseTableName: String = "formula"
    // Update auto-incremented id upon successful insertion
    mutating public func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

extension FormulaPersistable {
    static let section = belongsTo(FormulaSectionPersistable.self)
    static let results = hasMany(FormulaResult.self, using: ForeignKey([FormulaResult.Columns.formulaId]))
    var results: QueryInterfaceRequest<FormulaResult> {
        request(for: FormulaPersistable.results)
    }
}

Environment

GRDB flavor(s): GRDB
GRDB version: 6.18.0
Installation method: SPM
Xcode version: 14.3.1
Swift version: Swift 5
Platform(s) running GRDB: iOS
macOS version running Xcode: 13.4.1

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions