├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── DSFFullTextSearchIndex.md ├── DSFFullTextSearchIndex.podspec ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── DSFFullTextSearchIndex │ └── DSFFullTextSearchIndex.swift └── Tests ├── DSFFullTextSearchIndexTests ├── DSFFullTextSearchIndexTests.swift ├── Filemanager+temporary.swift ├── Resources │ └── the_school_short_story.txt ├── StopWords.swift └── XCTestManifests.swift └── LinuxMain.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | docs 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /DSFFullTextSearchIndex.md: -------------------------------------------------------------------------------- 1 | # DSFFullTextSearchIndex 2 | 3 | A full text search class using sqlite FTS5 as the text indexer 4 | 5 | ``` swift 6 | @objc public class DSFFullTextSearchIndex: NSObject 7 | ``` 8 | 9 | ## Inheritance 10 | 11 | `NSObject` 12 | 13 | ## Methods 14 | 15 | ## add(document:useNativeEnumerator:stopWords:) 16 | 17 | ``` swift 18 | @objc public func add(document: Document, useNativeEnumerator: Bool = false, stopWords: Set? = nil) -> Status 19 | ``` 20 | 21 | ## add(url:text:canReplace:useNativeEnumerator:stopWords:) 22 | 23 | Add a new document to the search index 24 | 25 | ``` swift 26 | @objc public func add(url: URL, text: String, canReplace: Bool = true, useNativeEnumerator: Bool = false, stopWords: Set? = nil) -> Status 27 | ``` 28 | 29 | ### Parameters 30 | 31 | - url: The unique URL identifying the document 32 | - text: The document text 33 | - canReplace: Allow or disallow replacing a document with an identical URL 34 | - useNativeEnumerator: If true, uses the native text enumerator methods to split into words before adding to index. Can improve word searching for CJK texts 35 | - stopWords: If set, removes any words in the set from the document before adding to the index 36 | 37 | ### Returns 38 | 39 | true if the document was successfully added to the index, false otherwise 40 | 41 | ## add(documents:canReplace:useNativeEnumerator:stopWords:) 42 | 43 | ``` swift 44 | @objc public func add(documents: [Document], canReplace _: Bool = true, useNativeEnumerator: Bool = false, stopWords: Set? = nil) -> Status 45 | ``` 46 | 47 | ## inTransaction(block:) 48 | 49 | Run the provided block within a search index transaction. 50 | 51 | ``` swift 52 | @objc public func inTransaction(block: () -> Status) -> Status 53 | ``` 54 | 55 | ## remove(url:) 56 | 57 | Remove the specified document from the search index 58 | 59 | ``` swift 60 | @objc public func remove(url: URL) -> Status 61 | ``` 62 | 63 | ## remove(urls:) 64 | 65 | Remove the specified documents from the search index 66 | 67 | ``` swift 68 | @objc public func remove(urls: [URL]) -> Status 69 | ``` 70 | 71 | ## removeAll() 72 | 73 | Remove all documents in the search index 74 | 75 | ``` swift 76 | @objc public func removeAll() -> Status 77 | ``` 78 | 79 | ## exists(url:) 80 | 81 | Returns true if the specified document url exists in the search index, false otherwise 82 | 83 | ``` swift 84 | @objc public func exists(url: URL) -> Bool 85 | ``` 86 | 87 | ## allURLs() 88 | 89 | Returns all the document URLs stored in the index 90 | 91 | ``` swift 92 | @objc public func allURLs() -> [URL] 93 | ``` 94 | 95 | ## count() 96 | 97 | Returns the number of documents in the search index 98 | 99 | ``` swift 100 | @objc public func count() -> Int32 101 | ``` 102 | 103 | ## search(text:) 104 | 105 | Perform a text search using the current index 106 | 107 | ``` swift 108 | @objc public func search(text: String) -> [URL]? 109 | ``` 110 | 111 | ### Parameters 112 | 113 | - text: The text to search for 114 | 115 | ### Returns 116 | 117 | An array of document URLs matching the text query 118 | -------------------------------------------------------------------------------- /DSFFullTextSearchIndex.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = "DSFFullTextSearchIndex" 3 | s.version = "1.1" 4 | s.summary = "A simple full text search indexer for macOS/iOS/tvOS" 5 | s.description = <<-DESC 6 | A simple full text search (FTS) class using SQLite FTS5 with a similar API as SKSearchKit 7 | DESC 8 | s.homepage = "https://github.com/dagronf" 9 | s.license = { :type => "MIT", :file => "LICENSE" } 10 | s.author = { "Darren Ford" => "dford_au-reg@yahoo.com" } 11 | s.social_media_url = "" 12 | s.osx.deployment_target = "10.11" 13 | s.ios.deployment_target = "11.4" 14 | s.tvos.deployment_target = "11.4" 15 | s.source = { :git => ".git", :tag => s.version.to_s } 16 | s.subspec "Core" do |ss| 17 | ss.source_files = "Sources/DSFFullTextSearchIndex/**/*.swift" 18 | end 19 | 20 | s.ios.framework = 'UIKit' 21 | s.osx.framework = 'AppKit' 22 | 23 | s.swift_version = "5.0" 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Darren Ford 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DSFFullTextSearchIndex", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "DSFFullTextSearchIndex", 12 | targets: ["DSFFullTextSearchIndex"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 21 | .target( 22 | name: "DSFFullTextSearchIndex", 23 | dependencies: []), 24 | .testTarget( 25 | name: "DSFFullTextSearchIndexTests", 26 | dependencies: ["DSFFullTextSearchIndex"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DSFFullTextSearchIndex 2 | 3 | A simple iOS/macOS/tvOS full text search (FTS) class using SQLite FTS5 using a similar API as SKSearchKit with no external dependencides 4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 |

14 | 15 | 16 | 17 | Swift Package Manager 18 | 19 |

20 | 21 | ## Why 22 | 23 | I wanted to add a full text search index to my macOS/iOS application and realized that SKSearchKit (and thus [DFSearchKit](https://github.com/dagronf/DFSearchKit) is macOS only. 24 | 25 | SQLite has solid FTS capabilities via and I wanted to be able to use these in a similar way as SKSearchKit. I also wanted a simple wrapper that **didn't have any dependencies**. As much as I love [GRDB](https://github.com/groue/GRDB.swift) I certainly didn't need everything that it provides. 26 | 27 | I also wanted something that can both :- 28 | 29 | * work independently in an app that doesn't have a traditional database, and 30 | * work in an app with an existing SQLite database. 31 | * be able to be shared between applications on iOS, macOS, macOS (Catalyst) and tvOS. 32 | 33 | ## Simple example 34 | 35 | ```swift 36 | 37 | // Create an index 38 | 39 | let index = DSFFullTextSearchIndex() 40 | index.create(filePath: /* some file path */) 41 | 42 | // 43 | // Add some documents 44 | // 45 | let url1 = URL(string: "demo://maintext/1") 46 | index.add(url: url1, text: "Sphinx of black quartz judge my vow") 47 | 48 | let url2 = URL(string: "demo://maintext/2") 49 | index.add(url: url2, text: "Quick brown fox jumps over the lazy dog") 50 | 51 | let url3 = URL(string: "demo://maintext/3") 52 | index.add(url: url3, text: "The dog didn't like the bird sitting on the fence and left quietly") 53 | 54 | // 55 | // Search 56 | // 57 | let urls1 = index.search(text: "quartz") // single match - url1 58 | let urls2 = index.search(text: "quick") // single match - url2 59 | let urls3 = index.search(text: "dog") // two matches - url1 and url3 60 | 61 | // Search with a wildcard 62 | let urls4 = index.search(text: "qu*") // three matches = url1 (quartz), url2 (quick) and url3 (quietly) 63 | 64 | ``` 65 | 66 | ## API documentation 67 | 68 | - [DSFFullTextSearchIndex](DSFFullTextSearchIndex.md) 69 | 70 | Generated using [swift-doc](https://github.com/SwiftDocOrg/swift-doc). 71 | 72 | 73 | ## To do 74 | 75 | * Add a custom tokenizer to more accurately handle stop words and CJK 76 | * Character folding etc. 77 | * A ton of more stuff too. 78 | 79 | ## License 80 | 81 | ``` 82 | MIT License 83 | 84 | Copyright (c) 2020 Darren Ford 85 | 86 | Permission is hereby granted, free of charge, to any person obtaining a copy 87 | of this software and associated documentation files (the "Software"), to deal 88 | in the Software without restriction, including without limitation the rights 89 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 90 | copies of the Software, and to permit persons to whom the Software is 91 | furnished to do so, subject to the following conditions: 92 | 93 | The above copyright notice and this permission notice shall be included in all 94 | copies or substantial portions of the Software. 95 | 96 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 97 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 98 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 99 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 100 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 101 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 102 | SOFTWARE. 103 | ``` 104 | -------------------------------------------------------------------------------- /Sources/DSFFullTextSearchIndex/DSFFullTextSearchIndex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DSFFullTextSearchIndex.swift 3 | // DSFFullTextSearchIndex 4 | // 5 | // Copyright © 2020 Darren Ford. All rights reserved. 6 | // 7 | // MIT license 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 10 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 11 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all copies or substantial 15 | // portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 18 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 19 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | import SQLite3 25 | 26 | /// A full text search class using sqlite FTS5 as the text indexer 27 | @objc public class DSFFullTextSearchIndex: NSObject { 28 | private static var TableDef = "DSFFullTextIndexFTS" 29 | private var db: OpaquePointer? 30 | 31 | @objc(DSFFullTextSearchIndexDocument) 32 | public class Document: NSObject { 33 | let url: URL 34 | let text: String 35 | init(url: URL, text: String) { 36 | self.url = url 37 | self.text = text 38 | super.init() 39 | } 40 | } 41 | 42 | /// Error states 43 | @objc(DSFSearchIndexStatus) public enum Status: Int { 44 | case success = 0 45 | 46 | case fileAlreadyExists = -1 47 | case documentUrlAlreadyExists = -2 48 | 49 | case sqliteUnableToOpen = -100 50 | case sqliteUnableToPrepare = -101 51 | case sqliteUnableToBind = -102 52 | case sqliteUnableToStep = -103 53 | case sqliteUnableToCreateTransaction = -104 54 | case sqliteUnableToCommitTransaction = -105 55 | case sqliteUnableToRollbackTransaction = -106 56 | 57 | case sqliteUnableToVacuum = -200 58 | } 59 | 60 | /// Create a search index to a file on disk 61 | /// - Parameter fileURL: the file URL specifying the index file to create 62 | /// - Returns: true if created successfully, false otherwise 63 | @objc public func create(fileURL: URL) -> Status { 64 | return self.create(filePath: fileURL.path) 65 | } 66 | 67 | /// Create a search index to a file on disk 68 | /// - Parameter path: the file path specifying the index file to create 69 | /// - Returns: true if created successfully, false otherwise 70 | @objc public func create(filePath: String) -> Status { 71 | self.close() 72 | return self.createDatabase(filePath: filePath) 73 | } 74 | 75 | /// Open a search index from a file on disk 76 | /// - Parameter fileURL: the file URL specifying the index to open 77 | /// - Returns: true if opened successfully, false otherwise 78 | @objc public func open(fileURL: URL) -> Status { 79 | return self.open(filePath: fileURL.path) 80 | } 81 | 82 | /// Open a search index from a file on disk 83 | /// - Parameter path: the file path specifying the index to open 84 | /// - Returns: true if opened successfully, false otherwise 85 | @objc public func open(filePath: String) -> Status { 86 | self.close() 87 | if sqlite3_open(filePath, &db) != SQLITE_OK { 88 | print("Error opening database") 89 | return .sqliteUnableToOpen 90 | } 91 | return createTables() 92 | } 93 | 94 | /// Rebuilds the database file, repacking it into a minimal amount of disk space 95 | /// 96 | /// See [Sqlite VACUUM command](https://www.sqlite.org/lang_vacuum.html) 97 | @objc public func vacuum() -> Status { 98 | guard sqlite3_exec(self.db, "VACUUM", nil, nil, nil) == SQLITE_OK else { 99 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 100 | Swift.print("failure starting transaction: \(errmsg)") 101 | return .sqliteUnableToVacuum 102 | } 103 | return .success 104 | } 105 | 106 | /// Close the search index 107 | @objc public func close() { 108 | if let d = db { 109 | sqlite3_close(d) 110 | db = nil 111 | } 112 | } 113 | } 114 | 115 | // MARK: - Adding documents 116 | 117 | extension DSFFullTextSearchIndex { 118 | @objc public func add(document: Document, useNativeEnumerator: Bool = false, stopWords: Set? = nil) -> Status { 119 | return self.add(url: document.url, text: document.text, useNativeEnumerator: useNativeEnumerator, stopWords: stopWords) 120 | } 121 | 122 | /// Add a new document to the search index 123 | /// - Parameters: 124 | /// - url: The unique URL identifying the document 125 | /// - text: The document text 126 | /// - canReplace: Allow or disallow replacing a document with an identical URL 127 | /// - useNativeEnumerator: If true, uses the native text enumerator methods to split into words before adding to index. Can improve word searching for CJK texts 128 | /// - stopWords: If set, removes any words in the set from the document before adding to the index 129 | /// - Returns: true if the document was successfully added to the index, false otherwise 130 | @objc public func add(url: URL, text: String, canReplace: Bool = true, useNativeEnumerator: Bool = false, stopWords: Set? = nil) -> Status { 131 | // If we're not allowed to replace, check whether the url exists first 132 | if !canReplace, self.exists(url: url) { 133 | return .documentUrlAlreadyExists 134 | } 135 | 136 | let urlString: NSString = url.absoluteString as NSString 137 | 138 | let textString = NSMutableString(capacity: text.count) 139 | 140 | // We can use the built-in string enumerator in macOS/iOS/tvOS to split words before adding to index 141 | // This is useful when indexing something like CJK text, where words aren't necessarily separated by spaces 142 | // The built-in fts tokenisers in sqlite doesn't seem to handle these cases well. 143 | if useNativeEnumerator || stopWords != nil { 144 | let nsText = text as NSString 145 | let stops = stopWords ?? Set() 146 | nsText.enumerateSubstrings(in: NSRange(location: 0, length: nsText.length), options: [.byWords]) { str, _, _, _ in 147 | if let str = str?.lowercased(), !stops.contains(str) { 148 | textString.append("\(str) ") 149 | } 150 | } 151 | } else { 152 | textString.append(text) 153 | } 154 | 155 | let status = self.inTransaction { () -> Status in 156 | 157 | // Delete the url if it already exists 158 | let status = self.remove(url: url) 159 | guard status == .success else { 160 | return status 161 | } 162 | 163 | let insertStatement = "INSERT INTO \(DSFFullTextSearchIndex.TableDef) (url, content) VALUES (?,?);" 164 | 165 | var stmt: OpaquePointer? 166 | defer { 167 | sqlite3_finalize(stmt) 168 | } 169 | 170 | guard sqlite3_prepare(self.db, insertStatement, -1, &stmt, nil) == SQLITE_OK else { 171 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 172 | Swift.print("error preparing insert: \(errmsg)") 173 | return .sqliteUnableToPrepare 174 | } 175 | 176 | guard sqlite3_bind_text(stmt, 1, urlString.utf8String, -1, nil) == SQLITE_OK else { 177 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 178 | Swift.print("failure binding name: \(errmsg)") 179 | return .sqliteUnableToBind 180 | } 181 | 182 | guard sqlite3_bind_text(stmt, 2, textString.utf8String, -1, nil) == SQLITE_OK else { 183 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 184 | Swift.print("failure binding name: \(errmsg)") 185 | return .sqliteUnableToBind 186 | } 187 | 188 | guard sqlite3_step(stmt) == SQLITE_DONE else { 189 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 190 | Swift.print("failure inserting hero: \(errmsg)") 191 | return .sqliteUnableToStep 192 | } 193 | 194 | return .success 195 | } 196 | 197 | return status 198 | } 199 | 200 | @objc public func add(documents: [Document], canReplace _: Bool = true, useNativeEnumerator: Bool = false, stopWords: Set? = nil) -> Status { 201 | let status = beginTransaction() 202 | if status != .success { 203 | return status 204 | } 205 | 206 | for document in documents { 207 | let status = self.add(document: document, useNativeEnumerator: useNativeEnumerator, stopWords: stopWords) 208 | if status != .success { 209 | Swift.print("unable to add url \(document.url), rolling back") 210 | _ = self.rollbackTransaction() 211 | return status 212 | } 213 | } 214 | 215 | return commitTransaction() 216 | } 217 | } 218 | 219 | // MARK: - Transaction support 220 | 221 | extension DSFFullTextSearchIndex { 222 | /// Run the provided block within a search index transaction. 223 | @objc public func inTransaction(block: () -> Status) -> Status { 224 | var status = self.beginTransaction() 225 | guard status == .success else { 226 | return status 227 | } 228 | 229 | status = block() 230 | guard status == .success else { 231 | _ = self.rollbackTransaction() 232 | return status 233 | } 234 | 235 | return self.commitTransaction() 236 | } 237 | 238 | func beginTransaction() -> Status { 239 | guard sqlite3_exec(self.db, "BEGIN TRANSACTION", nil, nil, nil) == SQLITE_OK else { 240 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 241 | Swift.print("failure starting transaction: \(errmsg)") 242 | return .sqliteUnableToCreateTransaction 243 | } 244 | return .success 245 | } 246 | 247 | func commitTransaction() -> Status { 248 | guard sqlite3_exec(self.db, "COMMIT TRANSACTION", nil, nil, nil) == SQLITE_OK else { 249 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 250 | Swift.print("failure committing transaction: \(errmsg)") 251 | return .sqliteUnableToCommitTransaction 252 | } 253 | return .success 254 | } 255 | 256 | func rollbackTransaction() -> Status { 257 | guard sqlite3_exec(self.db, "ROLLBACK TRANSACTION", nil, nil, nil) == SQLITE_OK else { 258 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 259 | Swift.print("failure rolling transaction: \(errmsg)") 260 | return .sqliteUnableToRollbackTransaction 261 | } 262 | return .success 263 | } 264 | } 265 | 266 | // MARK: - Removing documents 267 | 268 | extension DSFFullTextSearchIndex { 269 | /// Remove the specified document from the search index 270 | @objc public func remove(url: URL) -> Status { 271 | return self.remove(urls: [url]) 272 | } 273 | 274 | /// Remove the specified documents from the search index 275 | @objc public func remove(urls: [URL]) -> Status { 276 | let urlsPlaceholder = urls.map { _ in "?" }.joined(separator: ",") 277 | let deleteStatement = "DELETE FROM \(DSFFullTextSearchIndex.TableDef) where url IN (\(urlsPlaceholder));" 278 | 279 | var stmt: OpaquePointer? 280 | defer { 281 | sqlite3_finalize(stmt) 282 | } 283 | 284 | guard sqlite3_prepare(self.db, deleteStatement, -1, &stmt, nil) == SQLITE_OK else { 285 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 286 | Swift.print("error preparing delete: \(errmsg)") 287 | return .sqliteUnableToPrepare 288 | } 289 | 290 | for count in 1 ... urls.count { 291 | let urlString = urls[count - 1].absoluteString as NSString 292 | guard sqlite3_bind_text(stmt, Int32(count), urlString.utf8String, -1, nil) == SQLITE_OK else { 293 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 294 | Swift.print("failure binding name: \(errmsg)") 295 | return .sqliteUnableToBind 296 | } 297 | } 298 | 299 | guard sqlite3_step(stmt) == SQLITE_DONE else { 300 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 301 | Swift.print("failure deleting url \(urls): \(errmsg)") 302 | return .sqliteUnableToStep 303 | } 304 | 305 | return .success 306 | } 307 | 308 | /// Remove all documents in the search index 309 | @objc public func removeAll() -> Status { 310 | let deleteStatement = "DELETE FROM \(DSFFullTextSearchIndex.TableDef)" 311 | var stmt: OpaquePointer? 312 | defer { 313 | sqlite3_finalize(stmt) 314 | } 315 | guard sqlite3_prepare(self.db, deleteStatement, -1, &stmt, nil) == SQLITE_OK else { 316 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 317 | Swift.print("error preparing delete: \(errmsg)") 318 | return .sqliteUnableToPrepare 319 | } 320 | guard sqlite3_step(stmt) == SQLITE_DONE else { 321 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 322 | Swift.print("failure removing all urls: \(errmsg)") 323 | return .sqliteUnableToStep 324 | } 325 | return .success 326 | } 327 | } 328 | 329 | // MARK: - Content information 330 | 331 | extension DSFFullTextSearchIndex { 332 | /// Returns true if the specified document url exists in the search index, false otherwise 333 | @objc public func exists(url: URL) -> Bool { 334 | let query = "SELECT url FROM \(DSFFullTextSearchIndex.TableDef) where url = ?" 335 | 336 | var statement: OpaquePointer? 337 | defer { 338 | sqlite3_finalize(statement) 339 | } 340 | 341 | guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { 342 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 343 | fatalError("Error preparing select: \(errmsg)") 344 | } 345 | 346 | let urlString: NSString = url.absoluteString as NSString 347 | guard sqlite3_bind_text(statement, 1, urlString.utf8String, -1, nil) == SQLITE_OK else { 348 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 349 | fatalError("failure binding name: \(errmsg)") 350 | } 351 | 352 | return sqlite3_step(statement) == SQLITE_ROW 353 | } 354 | 355 | /// Returns all the document URLs stored in the index 356 | @objc public func allURLs() -> [URL] { 357 | let query = "SELECT url FROM \(DSFFullTextSearchIndex.TableDef)" 358 | 359 | var statement: OpaquePointer? 360 | defer { 361 | sqlite3_finalize(statement) 362 | } 363 | 364 | guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { 365 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 366 | fatalError("Error preparing select: \(errmsg)") 367 | } 368 | 369 | var results = [URL]() 370 | while sqlite3_step(statement) == SQLITE_ROW { 371 | guard let cURL = sqlite3_column_text(statement, 0) else { 372 | continue 373 | } 374 | let urlString = String(cString: cURL) 375 | guard let url = URL(string: urlString) else { 376 | continue 377 | } 378 | results.append(url) 379 | } 380 | return results 381 | } 382 | 383 | /// Returns the number of documents in the search index 384 | @objc public func count() -> Int32 { 385 | let query = "SELECT COUNT(*) FROM \(DSFFullTextSearchIndex.TableDef)" 386 | 387 | var statement: OpaquePointer? 388 | defer { 389 | sqlite3_finalize(statement) 390 | } 391 | 392 | guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { 393 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 394 | fatalError("Error preparing select: \(errmsg)") 395 | } 396 | 397 | guard sqlite3_step(statement) == SQLITE_ROW else { 398 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 399 | fatalError("Error preparing select: \(errmsg)") 400 | } 401 | 402 | return sqlite3_column_int(statement, 0) 403 | } 404 | } 405 | 406 | // MARK: - Search 407 | 408 | extension DSFFullTextSearchIndex { 409 | /// Perform a text search using the current index 410 | /// - Parameter text: The text to search for 411 | /// - Returns: An array of document URLs matching the text query 412 | @objc public func search(text: String) -> [URL]? { 413 | let query = """ 414 | SELECT url FROM \(DSFFullTextSearchIndex.TableDef) 415 | WHERE \(DSFFullTextSearchIndex.TableDef) MATCH ? ORDER BY bm25(\(DSFFullTextSearchIndex.TableDef)) 416 | """ 417 | 418 | var statement: OpaquePointer? 419 | defer { 420 | sqlite3_finalize(statement) 421 | } 422 | 423 | if sqlite3_prepare_v2(db, query, -1, &statement, nil) != SQLITE_OK { 424 | print("Error preparing select: \(String(cString: sqlite3_errmsg(db)!))") 425 | return nil 426 | } 427 | 428 | let textString = text as NSString 429 | guard sqlite3_bind_text(statement, 1, textString.utf8String, -1, nil) == SQLITE_OK else { 430 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 431 | Swift.print("failure binding name: \(errmsg)") 432 | return nil 433 | } 434 | 435 | var results = [URL]() 436 | while sqlite3_step(statement) == SQLITE_ROW { 437 | guard let cURL = sqlite3_column_text(statement, 0) else { 438 | continue 439 | } 440 | let urlString = String(cString: cURL) 441 | guard let url = URL(string: urlString) else { 442 | continue 443 | } 444 | results.append(url) 445 | } 446 | 447 | return results 448 | } 449 | } 450 | 451 | private extension DSFFullTextSearchIndex { 452 | func createDatabase(filePath: String) -> Status { 453 | if FileManager.default.fileExists(atPath: filePath) { 454 | return .fileAlreadyExists 455 | } 456 | 457 | if sqlite3_open(filePath, &db) != SQLITE_OK { 458 | print("Error opening database") 459 | return .sqliteUnableToOpen 460 | } 461 | return createTables() 462 | } 463 | 464 | func createTables() -> Status { 465 | let createTableString = 466 | """ 467 | CREATE VIRTUAL TABLE IF NOT EXISTS \(DSFFullTextSearchIndex.TableDef) 468 | USING FTS5(url UNINDEXED, content); 469 | """ 470 | 471 | var createTableStatement: OpaquePointer? 472 | defer { 473 | sqlite3_finalize(createTableStatement) 474 | } 475 | 476 | guard sqlite3_prepare_v2(db, createTableString, -1, &createTableStatement, nil) == SQLITE_OK else { 477 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 478 | Swift.print("failure creating table prepare: \(errmsg)") 479 | return .sqliteUnableToPrepare 480 | } 481 | 482 | guard sqlite3_step(createTableStatement) == SQLITE_DONE else { 483 | let errmsg = String(cString: sqlite3_errmsg(self.db)!) 484 | Swift.print("failure creating table: \(errmsg)") 485 | return .sqliteUnableToStep 486 | } 487 | 488 | return .success 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /Tests/DSFFullTextSearchIndexTests/DSFFullTextSearchIndexTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DSFFullTextSearchIndexTests.swift 3 | // DSFFullTextSearchIndex 4 | // 5 | // Copyright © 2020 Darren Ford. All rights reserved. 6 | // 7 | // MIT license 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 10 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 11 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all copies or substantial 15 | // portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 18 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 19 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | // 22 | 23 | @testable import DSFFullTextSearchIndex 24 | import XCTest 25 | 26 | fileprivate func bundleResourceURL(forResource name: String, withExtension ext: String) -> URL { 27 | let thisSourceFile = URL(fileURLWithPath: #file) 28 | var thisDirectory = thisSourceFile.deletingLastPathComponent() 29 | thisDirectory = thisDirectory.appendingPathComponent("Resources") 30 | thisDirectory = thisDirectory.appendingPathComponent(name + "." + ext) 31 | return thisDirectory 32 | } 33 | 34 | final class DSFFullTextSearchIndexTests: XCTestCase { 35 | func testBasic() { 36 | let temp = DSFTemporaryFile() 37 | Swift.print(temp.tempFile.path) 38 | 39 | let index = DSFFullTextSearchIndex() 40 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 41 | 42 | let url1 = URL(fileURLWithPath: "/tmp/blah") 43 | let url2 = URL(fileURLWithPath: "/tmp/blah2") 44 | let url3 = URL(fileURLWithPath: "/tmp/blah3") 45 | 46 | XCTAssertEqual(.success, index.add(url: url1, text: "This is a test bark")) 47 | XCTAssertEqual(1, index.count()) 48 | 49 | XCTAssertEqual(.success, index.add(url: url2, text: "This is a caterpillar")) 50 | XCTAssertEqual(2, index.count()) 51 | 52 | XCTAssertEqual(.success, index.add(url: url3, text: "bark bark bark")) 53 | XCTAssertEqual(3, index.count()) 54 | 55 | let r = index.search(text: "test")! 56 | XCTAssertEqual(1, r.count) 57 | 58 | let r2 = index.search(text: "caterpillar")! 59 | XCTAssertEqual(1, r2.count) 60 | 61 | let r3 = index.search(text: "This")! 62 | XCTAssertEqual(2, r3.count) 63 | 64 | let r4 = index.search(text: "cat")! 65 | XCTAssertEqual(0, r4.count) 66 | 67 | let r5 = index.search(text: "cat*")! 68 | XCTAssertEqual(1, r5.count) 69 | 70 | let r6 = index.search(text: "bark")! 71 | XCTAssertEqual(2, r6.count) 72 | XCTAssertEqual(url3, r6[0]) 73 | XCTAssertEqual(url1, r6[1]) 74 | 75 | var urls = index.allURLs() 76 | XCTAssertEqual(3, urls.count) 77 | XCTAssertEqual(3, index.count()) 78 | 79 | XCTAssertEqual(.success, index.remove(url: url3)) 80 | let r7 = index.search(text: "bark")! 81 | XCTAssertEqual(1, r7.count) 82 | XCTAssertEqual(url1, r7[0]) 83 | 84 | urls = index.allURLs() 85 | XCTAssertEqual(urls.count, 2) 86 | } 87 | 88 | func testDelete() { 89 | let temp = DSFTemporaryFile() 90 | Swift.print(temp.tempFile.path) 91 | 92 | let index = DSFFullTextSearchIndex() 93 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 94 | 95 | let url1 = URL(fileURLWithPath: "/tmp/blah1") 96 | let url2 = URL(fileURLWithPath: "/tmp/blah2") 97 | let url3 = URL(fileURLWithPath: "/tmp/blah3") 98 | 99 | XCTAssertEqual(.success, index.add(url: url1, text: "This is a test bark")) 100 | XCTAssertEqual(.success, index.add(url: url2, text: "This is a caterpillar")) 101 | 102 | let r = index.search(text: "test")! 103 | XCTAssertEqual(1, r.count) 104 | 105 | let r2 = index.search(text: "caterpillar")! 106 | XCTAssertEqual(1, r2.count) 107 | 108 | XCTAssertTrue(index.exists(url: url2)) 109 | XCTAssertFalse(index.exists(url: url3)) 110 | 111 | XCTAssertEqual(.success, index.remove(url: url2)) 112 | let r3 = index.search(text: "caterpillar")! 113 | XCTAssertEqual(0, r3.count) 114 | 115 | XCTAssertEqual(.success, index.remove(urls: [url2, url1])) 116 | let r4 = index.search(text: "caterpillar")! 117 | XCTAssertEqual(0, r4.count) 118 | let r5 = index.search(text: "test")! 119 | XCTAssertEqual(0, r5.count) 120 | 121 | //// 122 | 123 | XCTAssertEqual(.success, index.add(url: url1, text: "This is a test bark")) 124 | XCTAssertEqual(.success, index.add(url: url2, text: "This is a caterpillar")) 125 | let r10 = index.search(text: "test")! 126 | XCTAssertEqual(1, r10.count) 127 | 128 | let r11 = index.search(text: "caterpillar")! 129 | XCTAssertEqual(1, r11.count) 130 | 131 | XCTAssertEqual(.success, index.removeAll()) 132 | let r101 = index.search(text: "test")! 133 | XCTAssertEqual(0, r101.count) 134 | 135 | let r111 = index.search(text: "caterpillar")! 136 | XCTAssertEqual(0, r111.count) 137 | } 138 | 139 | func testChinese() { 140 | let temp = DSFTemporaryFile() 141 | Swift.print(temp.tempFile.path) 142 | 143 | let index = DSFFullTextSearchIndex() 144 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 145 | 146 | let url1 = URL(fileURLWithPath: "/tmp/blah") 147 | let str1 = "为什么不支持中文 fts5 does not seem to work for chinese" 148 | 149 | XCTAssertEqual(.success, index.add(url: url1, text: str1)) 150 | var r = index.search(text: "中文")! 151 | XCTAssertEqual(0, r.count) 152 | 153 | XCTAssertEqual(.success, index.add(url: url1, text: str1, useNativeEnumerator: true)) 154 | r = index.search(text: "中文")! 155 | XCTAssertEqual(1, r.count) 156 | XCTAssertEqual(url1, r[0]) 157 | 158 | let url2 = URL(fileURLWithPath: "/tmp/blah2") 159 | let str2 = "مرحبا العالم" 160 | XCTAssertEqual(.success, index.add(url: url2, text: str2)) 161 | r = index.search(text: "العالم")! 162 | XCTAssertEqual(1, r.count) 163 | XCTAssertEqual(url2, r[0]) 164 | } 165 | 166 | func testStopWords() { 167 | let temp = DSFTemporaryFile() 168 | Swift.print(temp.tempFile.path) 169 | 170 | let index = DSFFullTextSearchIndex() 171 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 172 | 173 | let url1 = URL(fileURLWithPath: "/tmp/blah") 174 | XCTAssertEqual(.success, index.add(url: url1, text: "This is aren’t a caterpillar", stopWords: gStopWords)) 175 | 176 | var r = index.search(text: "This")! 177 | XCTAssertEqual(0, r.count) 178 | r = index.search(text: "is")! 179 | XCTAssertEqual(0, r.count) 180 | r = index.search(text: "aren’t")! 181 | XCTAssertEqual(0, r.count) 182 | r = index.search(text: "are*")! 183 | XCTAssertEqual(0, r.count) 184 | r = index.search(text: "a")! 185 | XCTAssertEqual(0, r.count) 186 | r = index.search(text: "cater*")! 187 | XCTAssertEqual(1, r.count) 188 | } 189 | 190 | func testPhrasesAndNear() { 191 | let temp = DSFTemporaryFile() 192 | Swift.print(temp.tempFile.path) 193 | 194 | let index = DSFFullTextSearchIndex() 195 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 196 | 197 | let url1 = URL(fileURLWithPath: "/tmp/blah") 198 | XCTAssertEqual(.success, index.add(url: url1, text: "Sphinx of black quartz judge my vow")) 199 | 200 | var r = index.search(text: "QUARTZ")! 201 | XCTAssertEqual(1, r.count) 202 | 203 | r = index.search(text: "black + quartz")! 204 | XCTAssertEqual(1, r.count) 205 | 206 | r = index.search(text: "Sphinx + quartz")! 207 | XCTAssertEqual(0, r.count) 208 | 209 | r = index.search(text: "Sphinx of")! 210 | XCTAssertEqual(1, r.count) 211 | 212 | r = index.search(text: "NEAR(sphinx quartz)")! 213 | XCTAssertEqual(1, r.count) 214 | 215 | r = index.search(text: "NEAR(sphinx judge, 2)")! 216 | XCTAssertEqual(0, r.count) 217 | 218 | r = index.search(text: "NEAR(sphinx judge, 3)")! 219 | XCTAssertEqual(1, r.count) 220 | } 221 | 222 | func testChinese2() { 223 | let str1 = """ 224 |   盖闻天地之数,有十二万九千六百岁为一元。将一元分为十二会,乃子、丑 225 | 、寅、卯、辰、巳、午、未、申、酉、戌、亥之十二支也。每会该一万八百岁。 226 | 且就一日而论:子时得阳气,而丑则鸡鸣;寅不通光,而卯则日出;辰时食后, 227 | 而巳则挨排;日午天中,而未则西蹉;申时晡而日落酉;戌黄昏而人定亥。譬于 228 | 大数,若到戌会之终,则天地昏蒙而万物否矣。再去五千四百岁,交亥会之初, 229 | 则当黑暗,而两间人物俱无矣,故曰混沌。又五千四百岁,亥会将终,贞下起元 230 | ,近子之会,而复逐渐开明。邵康节曰:“冬至子之半,天心无改移。一阳初动 231 | 处,万物未生时。”到此,天始有根。再五千四百岁,正当子会,轻清上腾,有 232 | 日,有月,有星,有辰。日、月、星、辰,谓之四象。故曰,天开于子。又经五 233 | 千四百岁,子会将终,近丑之会,而逐渐坚实。易曰:“大哉乾元!至哉坤元! 234 | 万物资生,乃顺承天。”至此,地始凝结。再五千四百岁,正当丑会,重浊下凝 235 | ,有水,有火,有山,有石,有土。水、火、山、石、土谓之五形。故曰,地辟 236 | 于丑。又经五千四百岁,丑会终而寅会之初,发生万物。历曰:“天气下降,地 237 | 气上升;天地交合,群物皆生。”至此,天清地爽,阴阳交合。再五千四百岁, 238 | 正当寅会,生人,生兽,生禽,正谓天地人,三才定位。故曰,人生于寅。 239 | """ 240 | 241 | let temp = DSFTemporaryFile() 242 | Swift.print(temp.tempFile.path) 243 | 244 | let index = DSFFullTextSearchIndex() 245 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 246 | 247 | let url1 = URL(fileURLWithPath: "/tmp/blah") 248 | XCTAssertEqual(.success, index.add(url: url1, text: str1, useNativeEnumerator: true)) 249 | 250 | let r = index.search(text: "天地")! 251 | XCTAssertEqual(1, r.count) 252 | XCTAssertEqual(url1, r[0]) 253 | } 254 | 255 | 256 | func testCreateOpen() { 257 | let temp = DSFTemporaryFile() 258 | Swift.print(temp.tempFile.path) 259 | 260 | let index = DSFFullTextSearchIndex() 261 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 262 | 263 | let url1 = URL(string: "demo://maintext/1")! 264 | XCTAssertEqual(.success, index.add(url: url1, text: "Sphinx of black quartz judge my vow")) 265 | 266 | let url2 = URL(string: "demo://maintext/2")! 267 | XCTAssertEqual(.success, index.add(url: url2, text: "Quick brown fox jumps over the lazy dog")) 268 | 269 | let url3 = URL(string: "demo://maintext/3")! 270 | XCTAssertEqual(.success, index.add(url: url3, text: "The dog didn't like the bird sitting on the fence and left quietly")) 271 | 272 | let urls1 = index.search(text: "quartz")! // single match - url1 273 | XCTAssertEqual(1, urls1.count) 274 | let urls2 = index.search(text: "quick")! // single match - url2 275 | XCTAssertEqual(1, urls2.count) 276 | let urls3 = index.search(text: "dog")! // two matches - url1 and url3 277 | XCTAssertEqual(2, urls3.count) 278 | 279 | index.close() 280 | 281 | let index2 = DSFFullTextSearchIndex() 282 | 283 | XCTAssertEqual(.success, index2.open(filePath: temp.tempFile.path)) 284 | let urls11 = index2.search(text: "quartz")! // single match - url1 285 | XCTAssertEqual(1, urls11.count) 286 | let urls12 = index2.search(text: "quick")! // single match - url2 287 | XCTAssertEqual(1, urls12.count) 288 | let urls13 = index2.search(text: "dog")! // two matches - url1 and url3 289 | XCTAssertEqual(2, urls13.count) 290 | 291 | index.close() 292 | } 293 | 294 | private func loadText(fileURL: URL) -> Data { 295 | do { 296 | return try Data(contentsOf: fileURL) 297 | } 298 | catch { 299 | fatalError("couldn't load resource '\(fileURL)") 300 | } 301 | } 302 | 303 | func indexContains(_ index: DSFFullTextSearchIndex, search: String, expectedCount: Int) -> [URL] { 304 | let urls1 = index.search(text: search) 305 | XCTAssertNotNil(urls1) 306 | XCTAssertEqual(expectedCount, urls1!.count) 307 | return urls1! 308 | } 309 | 310 | func testBasicLarger() { 311 | 312 | let temp = DSFTemporaryFile() 313 | Swift.print(temp.tempFile.path) 314 | 315 | let index = DSFFullTextSearchIndex() 316 | XCTAssertEqual(.success, index.create(filePath: temp.tempFile.path)) 317 | 318 | // Load in stored text document. As the extension is specified, we can infer the mime type 319 | let fileURL = bundleResourceURL(forResource: "the_school_short_story", withExtension: "txt") 320 | let data = loadText(fileURL: fileURL) 321 | let str = String(data: data, encoding: .utf8)! 322 | XCTAssertEqual(.success, index.add(url: fileURL, text: str)) 323 | 324 | let urls1 = indexContains(index, search: "puppy", expectedCount: 1) 325 | XCTAssertEqual(urls1[0], fileURL) 326 | _ = indexContains(index, search: "noodles", expectedCount: 0) 327 | _ = indexContains(index, search: "salam*", expectedCount: 1) // salamander 328 | _ = indexContains(index, search: "poppas + and + mommas", expectedCount: 1) 329 | 330 | // … the salamander, the tropical fish, Edgar, the … 331 | _ = indexContains(index, search: "salamander edgar", expectedCount: 1) // salamander AND edgar in the doc 332 | _ = indexContains(index, search: "\"salamander edgar\"", expectedCount: 0) // phrase 333 | _ = indexContains(index, search: "salamander + edgar", expectedCount: 0) // phrase 334 | _ = indexContains(index, search: "\"the tropical fish\"", expectedCount: 1) // phrase 335 | _ = indexContains(index, search: "the + tropical + fish + edgar", expectedCount: 1) // phrase 336 | 337 | _ = indexContains(index, search: "NEAR(salamander Edgar, 1)", expectedCount: 0) 338 | _ = indexContains(index, search: "NEAR(salamander Edgar, 2)", expectedCount: 0) 339 | _ = indexContains(index, search: "NEAR(salamander Edgar, 3)", expectedCount: 1) 340 | } 341 | 342 | static var allTests = [ 343 | ("testBasic", testBasic), 344 | ("testDelete", testDelete), 345 | ("testChinese", testChinese), 346 | ("testStopWords", testStopWords), 347 | ("testPhrasesAndNear", testPhrasesAndNear), 348 | ("testChinese2", testChinese2), 349 | ("testCreateOpen", testCreateOpen), 350 | ("testBasicLarger", testBasicLarger) 351 | ] 352 | } 353 | -------------------------------------------------------------------------------- /Tests/DSFFullTextSearchIndexTests/Filemanager+temporary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+temporary.swift 3 | // DSFFullTextSearchIndex 4 | // 5 | // Copyright © 2020 Darren Ford. All rights reserved. 6 | // 7 | // MIT license 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 10 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 11 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 12 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 13 | // 14 | // The above copyright notice and this permission notice shall be included in all copies or substantial 15 | // portions of the Software. 16 | // 17 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 18 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 19 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 20 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | 25 | public extension FileManager { 26 | /// Create a new uniquely-named temporary folder. 27 | /// - Parameter prefix: (optional) prefix to add the temporary file name 28 | /// - Parameter fileExtension: (optional) file extension (without the `.`) to use for the created file 29 | /// - Parameter contents: (optional) the data to write to the file 30 | func createTemporaryFile(prefix: String? = nil, fileExtension: String? = nil, contents: Data? = nil) throws -> URL { 31 | 32 | var tempFilename = NSTemporaryDirectory() 33 | 34 | if let prefix = prefix { 35 | tempFilename += prefix + "_" 36 | } 37 | 38 | tempFilename += ProcessInfo.processInfo.globallyUniqueString 39 | 40 | if let fileExtension = fileExtension { 41 | tempFilename += "." + fileExtension 42 | } 43 | 44 | let tempURL = URL(fileURLWithPath: tempFilename) 45 | 46 | if let c = contents { 47 | try c.write(to: tempURL, options: .atomicWrite) 48 | } 49 | 50 | return tempURL 51 | } 52 | 53 | /// Create a new uniquely-named folder within this folder. 54 | /// - Parameter prefix: (optional) prefix to add the temporary folder name 55 | /// - Parameter shouldCreate: (optional) should the folder be created (defaults to true) 56 | func createTemporaryFolder(prefix: String? = nil, shouldCreate: Bool = true) throws -> URL { 57 | var tempFolderName = NSTemporaryDirectory() 58 | if let prefix = prefix { 59 | tempFolderName += prefix + "_" 60 | } 61 | 62 | tempFolderName += ProcessInfo.processInfo.globallyUniqueString 63 | 64 | let tempURL = URL(fileURLWithPath: tempFolderName) 65 | 66 | if shouldCreate { 67 | try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) 68 | } 69 | return tempURL 70 | } 71 | } 72 | 73 | 74 | @objc public class DSFTemporaryFile: NSObject { 75 | private let fileManager: FileManager 76 | @objc public let tempFile: URL 77 | 78 | public override init() { 79 | self.fileManager = FileManager.default 80 | guard let file = try? self.fileManager.createTemporaryFile() else { 81 | assert(false) 82 | } 83 | self.tempFile = file 84 | super.init() 85 | } 86 | @objc public init(_ filemanager: FileManager = FileManager.default) throws { 87 | self.fileManager = filemanager 88 | self.tempFile = try filemanager.createTemporaryFile() 89 | super.init() 90 | } 91 | deinit { 92 | try? self.fileManager.removeItem(at: self.tempFile) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Tests/DSFFullTextSearchIndexTests/Resources/the_school_short_story.txt: -------------------------------------------------------------------------------- 1 | The School 2 | from 3 | Sixty Stories 4 | by Donald Barthelme 5 | 6 | Well, we had all these children out planting trees, see, because we figured that ... that was part of their education, to see how, you know, the root systems ... and also the sense of responsibility, taking care of things, being individually responsible. You know what I mean. And the trees all died. They were orange trees. I don’t know why they died, they just died. Something wrong with the soil possibly or maybe the stuff we got from the nursery wasn’t the best. We complained about it. So we’ve got thirty kids there, each kid had his or her own little tree to plant and we’ve got these thirty dead trees. All these kids looking at these little brown sticks, it was depressing. 7 | 8 | It wouldn’t have been so bad except that just a couple of weeks before the thing with the trees, the snakes all died. But I think that the snakes – well, the reason that the snakes kicked off was that ... you remember, the boiler was shut off for four days because of the strike, and that was explicable. It was something you could explain to the kids because of the strike. I mean, none of their parents would let them cross the picket line and they knew there was a strike going on and what it meant. So when things got started up again and we found the snakes they weren’t too disturbed. 9 | 10 | With the herb gardens it was probably a case of overwatering, and at least now they know not to overwater. The children were very conscientious with the herb gardens and some of them probably ... you know, slipped them a little extra water when we weren’t looking. Or maybe ... well, I don’t like to think about sabotage, although it did occur to us. I mean, it was something that crossed our minds. We were thinking that way probably because before that the gerbils had died, and the white mice had died, and the salamander ... well, now they know not to carry them around in plastic bags. 11 | 12 | Of course we expected the tropical fish to die, that was no surprise. Those numbers, you look at them crooked and they’re belly-up on the surface. But the lesson plan called for a tropical fish input at that point, there was nothing we could do, it happens every year, you just have to hurry past it. 13 | 14 | We weren’t even supposed to have a puppy. 15 | 16 | We weren’t even supposed to have one, it was just a puppy the Murdoch girl found under a Gristede’s truck one day and she was afraid the truck would run over it when the driver had finished making his delivery, so she stuck it in her knapsack and brought it to the school with her. So we had this puppy. As soon as I saw the puppy I thought, Oh Christ, I bet it will live for about two weeks and then... And that’s what it did. It wasn’t supposed to be in the classroom at all, there’s some kind of regulation about it, but you can’t tell them they can’t have a puppy when the puppy is already there, right in front of them, running around on the floor and yap yap yapping. They named it Edgar – that is, they named it after me. They had a lot of fun running after it and yelling, “Here, Edgar! Nice Edgar!” Then they’d laugh like hell. They enjoyed the ambiguity. I enjoyed it myself. I don’t mind being kidded. They made a little house for it in the supply closet and all that. I don’t know what it died of. Distemper, I guess. It probably hadn’t had any shots. I got it out of there before the kids got to school. I checked the supply closet each morning, routinely, because I knew what was going to happen. I gave it to the custodian. 17 | 18 | And then there was this Korean orphan that the class adopted through the Help the Children program, all the kids brought in a quarter a month, that was the idea. It was an unfortunate thing, the kid’s name was Kim and maybe we adopted him too late or something. The cause of death was not stated in the letter we got, they suggested we adopt another child instead and sent us some interesting case histories, but we didn’t have the heart. The class took it pretty hard, they began (I think, nobody ever said anything to me directly) to feel that maybe there was something wrong with the school. But I don’t think there’s anything wrong with the school, particularly, I’ve seen better and I’ve seen worse. It was just a run of bad luck. We had an extraordinary number of parents passing away, for instance. There were I think two heart attacks and two suicides, one drowning, and four killed together in a car accident. One stroke. And we had the usual heavy mortality rate among the grandparents, or maybe it was heavier this year, it seemed so. And finally the tragedy. 19 | 20 | The tragedy occurred when Matthew Wein and Tony Mavrogordo were playing over where they’re excavating for the new federal office building. There were all these big wooden beams stacked, you know, at the edge of the excavation. There’s a court case coming out of that, the parents are claiming that the beams were poorly stacked. I don’t know what’s true and what’s not. It’s been a strange year. 21 | 22 | I forgot to mention Billy Brandt’s father who was knifed fatally when he grappled with a masked intruder in his home. 23 | 24 | One day, we had a discussion in class. They asked me, where did they go? The trees, the salamander, the tropical fish, Edgar, the poppas and mommas, Matthew and Tony, where did they go? And I said, I don’t know, I don’t know. And they said, who knows? and I said, nobody knows. And they said, is death that which gives meaning to life? And I said no, life is that which gives meaning to life. Then they said, but isn’t death, considered as a fundamental datum, the means by which the taken-for-granted mundanity of the everyday may be transcended in the direction of – 25 | 26 | I said, yes, maybe. 27 | 28 | They said, we don’t like it. 29 | 30 | I said, that’s sound. 31 | 32 | They said, it’s a bloody shame! 33 | 34 | I said, it is. 35 | 36 | They said, will you make love now with Helen (our teaching assistant) so that we can see how it is done? We know you like Helen. 37 | 38 | I do like Helen but I said that I would not. 39 | 40 | We’ve heard so much about it, they said, but we’ve never seen it. 41 | 42 | I said I would be fired and that it was never, or almost never, done as a demonstration. Helen looked out the window. 43 | 44 | They said, please, please make love with Helen, we require an assertion of value, we are frightened. 45 | 46 | I said that they shouldn’t be frightened (although I am often frightened) and that there was value everywhere. Helen came and embraced me. I kissed her a few times on the brow. We held each other. The children were excited. Then there was a knock on the door, I opened the door, and the new gerbil walked in. The children cheered wildly. 47 | -------------------------------------------------------------------------------- /Tests/DSFFullTextSearchIndexTests/StopWords.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StopWords.swift 3 | // DFSearchKitTests 4 | // 5 | // Created by Darren Ford on 26/5/18. 6 | // Copyright © 2019 Darren Ford. All rights reserved. 7 | // 8 | // MIT license 9 | // 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 11 | // documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 12 | // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 13 | // permit persons to whom the Software is furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in all copies or substantial 16 | // portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 19 | // WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 20 | // OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 21 | // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | // 23 | 24 | import Foundation 25 | 26 | let gStopWords: Set = [ 27 | "a", 28 | "about", 29 | "above", 30 | "after", 31 | "again", 32 | "against", 33 | "all", 34 | "am", 35 | "an", 36 | "and", 37 | "any", 38 | "are", 39 | "aren't", 40 | "aren’t", 41 | "as", 42 | "at", 43 | "be", 44 | "because", 45 | "been", 46 | "before", 47 | "being", 48 | "below", 49 | "between", 50 | "both", 51 | "but", 52 | "by", 53 | "can't", 54 | "can’t", 55 | "can", 56 | "cannot", 57 | "could", 58 | "couldn't", 59 | "couldn’t", 60 | "did", 61 | "didn't", 62 | "didn’t", 63 | "do", 64 | "does", 65 | "doesn't", 66 | "doesn’t", 67 | "doing", 68 | "don't", 69 | "don’t", 70 | "down", 71 | "during", 72 | "each", 73 | "few", 74 | "for", 75 | "from", 76 | "further", 77 | "had", 78 | "hadn't", 79 | "hadn’t", 80 | "has", 81 | "hasn't", 82 | "hasn’t", 83 | "have", 84 | "haven't", 85 | "haven’t", 86 | "having", 87 | "he'd", 88 | "he'll", 89 | "he's", 90 | "he’d", 91 | "he’ll", 92 | "he’s", 93 | "he", 94 | "her", 95 | "here's", 96 | "here’s", 97 | "here", 98 | "hers", 99 | "herself", 100 | "him", 101 | "himself", 102 | "his", 103 | "how's", 104 | "how’s", 105 | "how", 106 | "i'd", 107 | "i'll", 108 | "i'm", 109 | "i've", 110 | "i’d", 111 | "i’ll", 112 | "i’m", 113 | "i’ve", 114 | "i", 115 | "if", 116 | "in", 117 | "into", 118 | "is", 119 | "isn't", 120 | "isn’t", 121 | "it's", 122 | "it’s", 123 | "it", 124 | "its", 125 | "itself", 126 | "let's", 127 | "let’s", 128 | "me", 129 | "more", 130 | "most", 131 | "mustn't", 132 | "mustn’t", 133 | "my", 134 | "myself", 135 | "no", 136 | "nor", 137 | "not", 138 | "of", 139 | "off", 140 | "on", 141 | "once", 142 | "only", 143 | "or", 144 | "other", 145 | "ought", 146 | "our", 147 | "ours", 148 | "ourselves", 149 | "out", 150 | "over", 151 | "own", 152 | "said", 153 | "same", 154 | "say", 155 | "says", 156 | "shall", 157 | "shan't", 158 | "shan’t", 159 | "she'd", 160 | "she'll", 161 | "she's", 162 | "she’d", 163 | "she’ll", 164 | "she’s", 165 | "she", 166 | "should", 167 | "shouldn't", 168 | "shouldn’t", 169 | "so", 170 | "some", 171 | "such", 172 | "than", 173 | "that's", 174 | "that’s", 175 | "that", 176 | "the", 177 | "their", 178 | "theirs", 179 | "them", 180 | "themselves", 181 | "then", 182 | "there's", 183 | "there’s", 184 | "there", 185 | "these", 186 | "they'd", 187 | "they'll", 188 | "they're", 189 | "they've", 190 | "they’d", 191 | "they’ll", 192 | "they’re", 193 | "they’ve", 194 | "they", 195 | "this", 196 | "those", 197 | "through", 198 | "to", 199 | "too", 200 | "under", 201 | "until", 202 | "up", 203 | "upon", 204 | "us", 205 | "very", 206 | "was", 207 | "wasn't", 208 | "wasn’t", 209 | "we'd", 210 | "we'll", 211 | "we're", 212 | "we've", 213 | "we’d", 214 | "we’ll", 215 | "we’re", 216 | "we’ve", 217 | "we", 218 | "were", 219 | "weren't", 220 | "weren’t", 221 | "what's", 222 | "what’s", 223 | "what", 224 | "when's", 225 | "when’s", 226 | "when", 227 | "where's", 228 | "where’s", 229 | "where", 230 | "which", 231 | "while", 232 | "who's", 233 | "who’s", 234 | "who", 235 | "whom", 236 | "whose", 237 | "why's", 238 | "why’s", 239 | "why", 240 | "will", 241 | "with", 242 | "won't", 243 | "won’t", 244 | "would", 245 | "wouldn't", 246 | "wouldn’t", 247 | "you'd", 248 | "you'll", 249 | "you're", 250 | "you've", 251 | "you’d", 252 | "you’ll", 253 | "you’re", 254 | "you’ve", 255 | "you", 256 | "your", 257 | "yours", 258 | "yourself", 259 | "yourselves" ] 260 | -------------------------------------------------------------------------------- /Tests/DSFFullTextSearchIndexTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(DSFSearchIndexTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import DSFFullTextSearchIndexTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += DSFFullTextSearchIndexTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------