├── README ├── TODO ├── query.js ├── test.html ├── test.js └── testglue.js /README: -------------------------------------------------------------------------------- 1 | A small query library for IndexedDB 2 | 3 | 4 | Query construction 5 | ------------------ 6 | 7 | A query consists of the name of an index, an operation, and a 8 | comparison value. This is how you construct a query: 9 | 10 | Index("make").oneof("BMW", "Volkswagen") 11 | 12 | This will return all objects whose "make" index value is 13 | either "BMW" or "Volkswagen". Available operations are: 14 | 15 | * eq 16 | * lt, lteq 17 | * gt, gteq 18 | * between, betweeq 19 | * oneof 20 | 21 | It is possible to link queries with boolean operations, e.g.: 22 | 23 | Index("make").eq("BMW") 24 | .and(Index("model").eq("325i")) 25 | .and(Index("year").lteq(1991)) 26 | 27 | 28 | Getting results 29 | --------------- 30 | 31 | Getting results from a query works very much like getting results from 32 | a single index in IndexedDB. You have the option of a cursor, e.g.: 33 | 34 | let cars = []; 35 | let store = transaction.objectStore("cars"); 36 | let request = query.openCursor(store); 37 | request.onsuccess = function (event) { 38 | let cursor = request.result; 39 | if (cursor) { 40 | cars.push(cursor.value); 41 | cursor.continue(); 42 | } 43 | } 44 | 45 | or simply getting all values at once: 46 | 47 | let cars; 48 | let request = query.getAll(store); 49 | request.onsuccess = function (event) { 50 | cars = request.result; 51 | } 52 | 53 | `query.openKeyCursor` and `query.getAllKeys` are also available if 54 | just the keys are of interest. 55 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO 2 | 3 | * error handling 4 | * limits 5 | * ordered results 6 | * test perf 7 | * investigate different strategies for filtering 8 | * investigate using (or at least supporting) workers for CPU-bound tasks 9 | * 'startswith' comparison for strings 10 | 11 | Separate/additional library: 12 | 13 | * indexing helpers (e.g. for full text searching, case folding, etc.) 14 | * query plan optimizer 15 | -------------------------------------------------------------------------------- /query.js: -------------------------------------------------------------------------------- 1 | /* This Source Code Form is subject to the terms of the Mozilla Public 2 | * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 | * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 | 5 | /** 6 | * Represents a queriable index. Call methods on the returned object 7 | * to create queries. 8 | * 9 | * Example: 10 | * 11 | * let query = Index("make").eq("BMW"); 12 | * 13 | */ 14 | function Index(name) { 15 | function queryMaker(op) { 16 | return function () { 17 | return IndexQuery(name, op, arguments); 18 | }; 19 | } 20 | return { 21 | eq: queryMaker("eq"), 22 | neq: queryMaker("neq"), 23 | gt: queryMaker("gt"), 24 | gteq: queryMaker("gteq"), 25 | lt: queryMaker("lt"), 26 | lteq: queryMaker("lteq"), 27 | between: queryMaker("between"), 28 | betweeq: queryMaker("betweeq"), 29 | oneof: function oneof() { 30 | let values = Array.slice(arguments); 31 | let query = IndexQuery(name, "eq", [values.shift()]); 32 | while (values.length) { 33 | query = query.or(IndexQuery(name, "eq", [values.shift()])); 34 | } 35 | return query; 36 | } 37 | }; 38 | } 39 | 40 | /** 41 | * Helper that notifies a 'success' event on a request, with a given 42 | * result object. This is typically either a cursor or a result array. 43 | */ 44 | function notifySuccess(request, result) { 45 | let event = {type: "success", 46 | target: request}; //TODO complete event interface 47 | request.readyState = IDBRequest.DONE; 48 | request.result = result; 49 | if (typeof request.onsuccess == "function") { 50 | request.onsuccess(event); 51 | } 52 | }; 53 | 54 | /** 55 | * Create a cursor object. 56 | */ 57 | function Cursor(store, request, keys, keyOnly) { 58 | let cursor = { 59 | continue: function continue_() { 60 | if (!keys.length) { 61 | notifySuccess(request, undefined); 62 | return; 63 | } 64 | let key = keys.shift(); 65 | if (keyOnly) { 66 | cursor.key = key; 67 | notifySuccess(request, cursor); 68 | return; 69 | } 70 | let r = store.get(key); 71 | r.onsuccess = function onsuccess() { 72 | cursor.key = key; 73 | cursor.value = r.result; 74 | notifySuccess(request, cursor); 75 | }; 76 | } 77 | //TODO complete cursor interface 78 | }; 79 | return cursor; 80 | } 81 | 82 | /** 83 | * Create a request object. 84 | */ 85 | function Request() { 86 | return { 87 | result: undefined, 88 | onsuccess: null, 89 | onerror: null, 90 | readyState: IDBRequest.LOADING 91 | // TODO complete request interface 92 | }; 93 | } 94 | 95 | /** 96 | * Create a request that will receive a cursor. 97 | * 98 | * This will also kick off the query, instantiate the Cursor when the 99 | * results are available, and notify the first 'success' event. 100 | */ 101 | function CursorRequest(store, queryFunc, keyOnly) { 102 | let request = Request(); 103 | queryFunc(store, function (keys) { 104 | let cursor = Cursor(store, request, keys, keyOnly); 105 | cursor.continue(); 106 | }); 107 | return request; 108 | } 109 | 110 | /** 111 | * Create a request that will receive a result array. 112 | * 113 | * This will also kick off the query, build up the result array, and 114 | * notify the 'success' event. 115 | */ 116 | function ResultRequest(store, queryFunc, keyOnly) { 117 | let request = Request(); 118 | queryFunc(store, function (keys) { 119 | if (keyOnly || !keys.length) { 120 | notifySuccess(request, keys); 121 | return; 122 | } 123 | let results = []; 124 | function getNext() { 125 | let r = store.get(keys.shift()); 126 | r.onsuccess = function onsuccess() { 127 | results.push(r.result); 128 | if (!keys.length) { 129 | notifySuccess(request, results); 130 | return; 131 | } 132 | getNext(); 133 | }; 134 | } 135 | getNext(); 136 | }); 137 | return request; 138 | } 139 | 140 | /** 141 | * Provide a generic way to create a query object from a query function. 142 | * Depending on the implementation of that query function, the query could 143 | * produce results from an index, combine results from other queries, etc. 144 | */ 145 | function Query(queryFunc, toString) { 146 | 147 | let query = { 148 | 149 | // Sadly we need to expose this to make Intersection and Union work :( 150 | _queryFunc: queryFunc, 151 | 152 | and: function and(query2) { 153 | return Intersection(query, query2); 154 | }, 155 | 156 | or: function or(query2) { 157 | return Union(query, query2); 158 | }, 159 | 160 | openCursor: function openCursor(store) { 161 | return CursorRequest(store, queryFunc, false); 162 | }, 163 | 164 | openKeyCursor: function openKeyCursor(store) { 165 | return CursorRequest(store, queryFunc, true); 166 | }, 167 | 168 | getAll: function getAll(store) { 169 | return ResultRequest(store, queryFunc, false); 170 | }, 171 | 172 | getAllKeys: function getAllKeys(store) { 173 | return ResultRequest(store, queryFunc, true); 174 | }, 175 | 176 | toString: toString 177 | }; 178 | return query; 179 | }; 180 | 181 | /** 182 | * Create a query object that queries an index. 183 | */ 184 | function IndexQuery(indexName, operation, values) { 185 | let negate = false; 186 | let op = operation; 187 | if (op == "neq") { 188 | op = "eq"; 189 | negate = true; 190 | } 191 | 192 | function makeRange() { 193 | let range; 194 | switch (op) { 195 | case "eq": 196 | range = IDBKeyRange.only(values[0]); 197 | break; 198 | case "lt": 199 | range = IDBKeyRange.upperBound(values[0], true); 200 | break; 201 | case "lteq": 202 | range = IDBKeyRange.upperBound(values[0]); 203 | break; 204 | case "gt": 205 | range = IDBKeyRange.lowerBound(values[0], true); 206 | break; 207 | case "gteq": 208 | range = IDBKeyRange.lowerBound(values[0]); 209 | range.upperOpen = true; 210 | break; 211 | case "between": 212 | range = IDBKeyRange.bound(values[0], values[1], true, true); 213 | break; 214 | case "betweeq": 215 | range = IDBKeyRange.bound(values[0], values[1]); 216 | break; 217 | } 218 | return range; 219 | } 220 | 221 | function queryKeys(store, callback) { 222 | let index = store.index(indexName); 223 | let range = makeRange(); 224 | let request = index.getAllKeys(range); 225 | request.onsuccess = function onsuccess(event) { 226 | let result = request.result; 227 | if (!negate) { 228 | callback(result); 229 | return; 230 | } 231 | 232 | // Deal with the negation case. This means we fetch all keys and then 233 | // subtract the original result from it. 234 | request = index.getAllKeys(); 235 | request.onsuccess = function onsuccess(event) { 236 | let all = request.result; 237 | callback(arraySub(all, result)); 238 | }; 239 | }; 240 | } 241 | 242 | let args = arguments; 243 | function toString() { 244 | return "IndexQuery(" + Array.slice(args).toSource().slice(1, -1) + ")"; 245 | } 246 | 247 | return Query(queryKeys, toString); 248 | } 249 | 250 | /** 251 | * Create a query object that performs the intersection of two given queries. 252 | */ 253 | function Intersection(query1, query2) { 254 | function queryKeys(store, callback) { 255 | query1._queryFunc(store, function (keys1) { 256 | query2._queryFunc(store, function (keys2) { 257 | callback(arrayIntersect(keys1, keys2)); 258 | }); 259 | }); 260 | } 261 | 262 | function toString() { 263 | return "Intersection(" + query1.toString() + ", " + query2.toString() + ")"; 264 | } 265 | 266 | return Query(queryKeys, toString); 267 | } 268 | 269 | /** 270 | * Create a query object that performs the union of two given queries. 271 | */ 272 | function Union(query1, query2) { 273 | function queryKeys(store, callback) { 274 | query1._queryFunc(store, function (keys1) { 275 | query2._queryFunc(store, function (keys2) { 276 | callback(arrayUnion(keys1, keys2)); 277 | }); 278 | }); 279 | } 280 | 281 | function toString() { 282 | return "Union(" + query1.toString() + ", " + query2.toString() + ")"; 283 | } 284 | 285 | return Query(queryKeys, toString); 286 | } 287 | 288 | 289 | function arraySub(minuend, subtrahend) { 290 | if (!minuend.length || !subtrahend.length) { 291 | return minuend; 292 | } 293 | return minuend.filter(function(item) { 294 | return subtrahend.indexOf(item) == -1; 295 | }); 296 | } 297 | 298 | function arrayUnion(foo, bar) { 299 | if (!foo.length) { 300 | return bar; 301 | } 302 | if (!bar.length) { 303 | return foo; 304 | } 305 | return foo.concat(arraySub(bar, foo)); 306 | } 307 | 308 | function arrayIntersect(foo, bar) { 309 | if (!foo.length) { 310 | return foo; 311 | } 312 | if (!bar.length) { 313 | return bar; 314 | } 315 | return foo.filter(function(item) { 316 | return bar.indexOf(item) != -1; 317 | }); 318 | } 319 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* Any copyright is dedicated to the Public Domain. 2 | http://creativecommons.org/publicdomain/zero/1.0/ */ 3 | 4 | function debug() { 5 | let args = Array.slice(arguments); 6 | args.unshift("DEBUG"); 7 | console.log.apply(console, args); 8 | } 9 | 10 | const DB_NAME = "testquery"; 11 | const DB_VERSION = 1; 12 | const STORE_NAME = "lemonscars"; 13 | 14 | let sampleRecords = [ 15 | {name: "ECTO-1", 16 | year: 1989, 17 | make: "BMW", 18 | model: "325i", 19 | races: 1}, 20 | {name: "ECTO-2", 21 | year: 1984, 22 | make: "BMW", 23 | model: "325e", 24 | races: 3}, 25 | {name: "Cheesy", 26 | year: 1984, 27 | make: "BMW", 28 | model: "325e", 29 | races: 9}, 30 | {name: "Pikachubaru", 31 | year: 2001, 32 | make: "Subaru", 33 | model: "Legacy Outback", 34 | races: 5}, 35 | {name: "Ferdinand the Bug", 36 | year: 1971, 37 | make: "Volkswagen", 38 | model: "Super Beetle", 39 | races: 0} 40 | ]; 41 | 42 | function openDB(callback) { 43 | let indexedDB = window.mozIndexedDB; 44 | let request = indexedDB.open(DB_NAME, DB_VERSION); 45 | request.onsuccess = function (event) { 46 | debug("Opened database:", DB_NAME, DB_VERSION); 47 | callback(request.result); 48 | }; 49 | request.onupgradeneeded = function (event) { 50 | debug("Database needs upgrade:", DB_NAME, 51 | event.oldVersion, event.newVersion); 52 | debug("Correct old database version:", event.oldVersion == 0); 53 | debug("Correct new database version:", event.newVersion == DB_VERSION); 54 | 55 | let db = request.result; 56 | let store = db.createObjectStore(STORE_NAME, {keyPath: "name"}); 57 | store.createIndex("year", "year", { unique: false }); 58 | store.createIndex("make", "make", { unique: false }); 59 | store.createIndex("model", "model", { unique: false }); 60 | store.createIndex("races", "races", { unique: false }); 61 | }; 62 | request.onerror = function (event) { 63 | debug("Failed to open database", DB_NAME); 64 | }; 65 | request.onblocked = function (event) { 66 | debug("Opening database request is blocked."); 67 | }; 68 | } 69 | 70 | let gDB; 71 | function populateDB(callback) { 72 | openDB(function (db) { 73 | gDB = db; 74 | let txn = gDB.transaction([STORE_NAME], IDBTransaction.READ_WRITE); 75 | let store = txn.objectStore(STORE_NAME); 76 | txn.oncomplete = function oncomplete() { 77 | console.debug("Populate transaction completed."); 78 | callback(); 79 | }; 80 | sampleRecords.forEach(function (record) { 81 | debug("Storing", record); 82 | store.put(record); 83 | }); 84 | }); 85 | } 86 | 87 | function openStore() { 88 | let txn = gDB.transaction([STORE_NAME], IDBTransaction.READ_ONLY); 89 | txn.oncomplete = run_next_test; 90 | txn.onabort = function () { 91 | console.error("The transaction was aborted because an error occurred."); 92 | }; 93 | let store = txn.objectStore(STORE_NAME); 94 | return store; 95 | } 96 | 97 | function compareKeys(keys, expectedKeys) { 98 | //TODO for now we don't care about order 99 | debug("Comparing", keys, expectedKeys); 100 | do_check_eq(keys.length, expectedKeys.length); 101 | do_check_eq(arrayUnion(keys, expectedKeys).length, keys.length); 102 | } 103 | 104 | function test(query, expectedKeys) { 105 | add_test(function test_openCursor() { 106 | debug("Testing " + query + ".openCursor"); 107 | let request = query.openCursor(openStore()); 108 | let keys = []; 109 | request.onsuccess = function onsuccess() { 110 | if (request.result == undefined) { 111 | compareKeys(keys, expectedKeys); 112 | return; 113 | } 114 | keys.push(request.result.name); 115 | }; 116 | }); 117 | 118 | add_test(function test_openKeyCursor() { 119 | debug("Testing " + query + ".openKeyCursor"); 120 | let request = query.openKeyCursor(openStore()); 121 | let keys = []; 122 | request.onsuccess = function onsuccess() { 123 | if (request.result == undefined) { 124 | compareKeys(keys, expectedKeys); 125 | return; 126 | } 127 | keys.push(request.result); 128 | }; 129 | }); 130 | 131 | add_test(function test_getAll() { 132 | debug("Testing " + query + ".getAll"); 133 | let request = query.getAll(openStore()); 134 | request.onsuccess = function onsuccess() { 135 | let keys = request.result.map(function (item) { return item.name; }); 136 | compareKeys(keys, expectedKeys); 137 | }; 138 | }); 139 | 140 | add_test(function test_getAllKeys() { 141 | debug("Testing " + query + ".getAllKeys"); 142 | let request = query.getAllKeys(openStore()); 143 | request.onsuccess = function onsuccess() { 144 | compareKeys(request.result, expectedKeys); 145 | }; 146 | }); 147 | 148 | } 149 | 150 | function run_tests() { 151 | populateDB(run_next_test); 152 | } 153 | 154 | 155 | /*** Tests start here ***/ 156 | 157 | // eq 158 | test( 159 | Index("make").eq("Chevrolet"), 160 | [] 161 | ); 162 | test( 163 | Index("make").eq("BMW"), 164 | ["ECTO-1", "ECTO-2", "Cheesy"] 165 | ); 166 | 167 | // neq 168 | test( 169 | Index("make").neq("BMW"), 170 | ["Pikachubaru", "Ferdinand the Bug"] 171 | ); 172 | 173 | // lt 174 | test( 175 | Index("year").lt(1971), //TODO 1971 fails on getAll?!? 176 | [] 177 | ); 178 | test( 179 | Index("year").lt(1984), //TODO 1984 fails on getAll?!? 180 | ["Ferdinand the Bug"] 181 | ); 182 | 183 | // lteq 184 | test( 185 | Index("year").lteq(1970), 186 | [] 187 | ); 188 | test( 189 | Index("year").lteq(1984), 190 | ["Ferdinand the Bug", "Cheesy", "ECTO-2"] 191 | ); 192 | 193 | // gt 194 | test( 195 | Index("year").gt(2001), 196 | [] 197 | ); 198 | test( 199 | Index("year").gt(1989), 200 | ["Pikachubaru"] 201 | ); 202 | 203 | // gteq 204 | test( 205 | Index("year").gteq(2002), 206 | [] 207 | ); 208 | test( 209 | Index("year").gteq(1989), 210 | ["Pikachubaru", "ECTO-1"] 211 | ); 212 | 213 | // oneof 214 | test( 215 | Index("make").oneof("Chevrolet", "Ford"), 216 | [] 217 | ); 218 | test( 219 | Index("make").oneof("Volkswagen", "Subaru"), 220 | ["Pikachubaru", "Ferdinand the Bug"] 221 | ); 222 | 223 | // between 224 | test( 225 | Index("year").between(1960, 1970), 226 | [] 227 | ); 228 | test( 229 | Index("year").between(1980, 1990), 230 | ["ECTO-1", "ECTO-2", "Cheesy"] 231 | ); 232 | 233 | 234 | // Composite queries 235 | test( 236 | Index("make").eq("BMW").and(Index("model").eq("325e")), 237 | ["ECTO-2", "Cheesy"] 238 | ); 239 | test( 240 | Index("make").eq("Volkswagen").or(Index("make").eq("Subaru")), 241 | ["Pikachubaru", "Ferdinand the Bug"] 242 | ); 243 | -------------------------------------------------------------------------------- /testglue.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Glue to make XPCShell test harness helpers work in a web page. 3 | */ 4 | function do_throw(text, stack) { 5 | console.error(text); 6 | throw text; 7 | } 8 | 9 | function print() { 10 | console.info.apply(console, arguments); 11 | } 12 | 13 | function do_execute_soon(func) { 14 | window.setTimeout(func, 0); 15 | } 16 | 17 | function do_test_pending() {} 18 | function do_test_finished() {} 19 | 20 | function do_check_eq(left, right) { 21 | let pass = left == right; 22 | console.log(pass ? "PASS" : "FAIL", left + " == " + right); 23 | if (!pass) { 24 | do_throw("FAIL"); 25 | } 26 | } 27 | 28 | function do_check_true(condition) { 29 | do_check_eq(condition, true); 30 | } 31 | 32 | const _TEST_FILE = "test.js"; 33 | 34 | 35 | /*** XPCShell test harness helpers ***/ 36 | 37 | /** 38 | * Add a test function to the list of tests that are to be run asynchronously. 39 | * 40 | * Each test function must call run_next_test() when it's done. Test files 41 | * should call run_next_test() in their run_test function to execute all 42 | * async tests. 43 | * 44 | * @return the test function that was passed in. 45 | */ 46 | let gTests = []; 47 | function add_test(func) { 48 | gTests.push(func); 49 | return func; 50 | } 51 | 52 | /** 53 | * Runs the next test function from the list of async tests. 54 | */ 55 | let gRunningTest = null; 56 | let gTestIndex = 0; // The index of the currently running test. 57 | function run_next_test() 58 | { 59 | function _run_next_test() 60 | { 61 | if (gTestIndex < gTests.length) { 62 | do_test_pending(); 63 | gRunningTest = gTests[gTestIndex++]; 64 | print("TEST-INFO | " + _TEST_FILE + " | Starting " + 65 | gRunningTest.name); 66 | // Exceptions do not kill asynchronous tests, so they'll time out. 67 | try { 68 | gRunningTest(); 69 | } 70 | catch (e) { 71 | do_throw(e); 72 | } 73 | } 74 | } 75 | 76 | // For sane stacks during failures, we execute this code soon, but not now. 77 | // We do this now, before we call do_test_finished(), to ensure the pending 78 | // counter (_tests_pending) never reaches 0 while we still have tests to run 79 | // (do_execute_soon bumps that counter). 80 | do_execute_soon(_run_next_test); 81 | 82 | if (gRunningTest !== null) { 83 | // Close the previous test do_test_pending call. 84 | do_test_finished(); 85 | } 86 | } 87 | --------------------------------------------------------------------------------