? = 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 |
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 |
--------------------------------------------------------------------------------