Skip to content

Commit

Permalink
Switch to JS implementation of indexedDB.cmp(). (#1412)
Browse files Browse the repository at this point in the history
* Switch to JS implementation of indexedDB.cmp().
This one is about 30 times faster than indexedDB.cmp()
+ it removes the global dependency of indexedDB for RangeSet,
used by liveQuery().

* Bugfixed new unit test for cmp

* Removed alway-false condition.

* Added tests to comply to IDB spec:

Comparing two arrays with different length:

https://www.w3.org/TR/IndexedDB-3/#compare-two-keys

* Bugfixes spec compliancy:
comparing arrays and typed arrays of different sizes.

* Bugfix: treated NaN array items as equals.

* Removing another domDeps dependent code part.
Ran unit tests on IE after doing this. All still pass.
Though one test on Dexie.Observable failed so I without digging to deep into the reason, I disabled that test for IE and legacy edge.
  • Loading branch information
dfahlander committed Oct 14, 2021
1 parent 6a227cf commit 46a430f
Show file tree
Hide file tree
Showing 10 changed files with 247 additions and 26 deletions.
2 changes: 1 addition & 1 deletion src/classes/collection/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { hangsOnDeleteLargeKeyRange } from "../../globals/constants";
import { ThenShortcut } from "../../public/types/then-shortcut";
import { Transaction } from '../transaction';
import { DBCoreCursor, DBCoreTransaction, DBCoreRangeType, DBCoreMutateResponse, DBCoreKeyRange } from '../../public/types/dbcore';
import { cmp } from "../../functions/cmp";

/** class Collection
*
Expand Down Expand Up @@ -485,7 +486,6 @@ export class Collection implements ICollection {
const coreTable = ctx.table.core;
const {outbound, extractKey} = coreTable.schema.primaryKey;
const limit = this.db._options.modifyChunkSize || 200;
const {cmp} = this.db.core;
const totalFailures = [];
let successCount = 0;
const failedKeys: IndexableType[] = [];
Expand Down
2 changes: 2 additions & 0 deletions src/classes/dexie/dexie-static-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { globalEvents } from '../../globals/global-events';
import { liveQuery } from '../../live-query/live-query';
import { extendObservabilitySet } from '../../live-query/extend-observability-set';
import { domDeps } from './dexie-dom-dependencies';
import { cmp } from '../../functions/cmp';

/* (Dexie) is an instance of DexieConstructor, as defined in public/types/dexie-constructor.d.ts
* (new Dexie()) is an instance of Dexie, as defined in public/types/dexie.d.ts
Expand Down Expand Up @@ -186,6 +187,7 @@ props(Dexie, {
shallowClone: shallowClone,
deepClone: deepClone,
getObjectDiff: getObjectDiff,
cmp,
asap: asap,
//maxKey: new Dexie('',{addons:[]})._maxKey,
minKey: minKey,
Expand Down
2 changes: 1 addition & 1 deletion src/classes/dexie/generate-middleware-stacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function createMiddlewareStacks(
tmpTrans: IDBTransaction): {[StackName in keyof DexieStacks]?: DexieStacks[StackName]}
{
const dbcore = createMiddlewareStack<DBCore>(
createDBCore(idbdb, indexedDB, IDBKeyRange, tmpTrans),
createDBCore(idbdb, IDBKeyRange, tmpTrans),
middlewares.dbcore);

// TODO: Create other stacks the same way as above. They might be dependant on the result
Expand Down
5 changes: 0 additions & 5 deletions src/dbcore/dbcore-indexeddb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,9 @@ export function getKeyPathAlias(keyPath: null | string | string[]) {

export function createDBCore (
db: IDBDatabase,
indexedDB: IDBFactory,
IdbKeyRange: typeof IDBKeyRange,
tmpTrans: IDBTransaction) : DBCore
{
const cmp = indexedDB.cmp.bind(indexedDB);

function extractSchema(db: IDBDatabase, trans: IDBTransaction) : {schema: DBCoreSchema, hasGetAll: boolean} {
const tables = arrayify(db.objectStoreNames);
return {
Expand Down Expand Up @@ -404,8 +401,6 @@ export function createDBCore (
return tableMap[name];
},

cmp,

MIN_KEY: -Infinity,

MAX_KEY: getMaxKey(IdbKeyRange),
Expand Down
4 changes: 1 addition & 3 deletions src/dbcore/keyrange.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { domDeps } from '../classes/dexie/dexie-dom-dependencies';
import { getMaxKey } from '../functions/quirks';
import { DBCoreKeyRange, DBCoreRangeType } from '../public/types/dbcore';

export const AnyRange: DBCoreKeyRange = {
type: DBCoreRangeType.Any,
lower: -Infinity,
lowerOpen: false,
get upper() { return getMaxKey(domDeps.IDBKeyRange) },
upper: [[]],
upperOpen: false
}

Expand Down
99 changes: 85 additions & 14 deletions src/functions/cmp.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,89 @@
import { domDeps } from '../classes/dexie/dexie-dom-dependencies';
import { exceptions } from '../errors';
import { IndexableType } from '../public';
// Implementation of https://www.w3.org/TR/IndexedDB-3/#compare-two-keys

let _cmp: (a: any, b: any) => number;
export function cmp(a: IndexableType, b: IndexableType): number {
if (_cmp) return _cmp(a, b);
const {indexedDB} = domDeps;
if (!indexedDB) throw new exceptions.MissingAPI();
_cmp = (a, b) => {
try {
return a == null || b == null ? NaN : indexedDB.cmp(a, b);
} catch {
return NaN;
import { toStringTag } from './utils';

// ... with the adjustment to return NaN instead of throwing.
export function cmp(a: any, b: any): number {
try {
const ta = type(a);
const tb = type(b);
if (ta !== tb) {
if (ta === 'Array') return 1;
if (tb === 'Array') return -1;
if (ta === 'binary') return 1;
if (tb === 'binary') return -1;
if (ta === 'string') return 1;
if (tb === 'string') return -1;
if (ta === 'Date') return 1;
if (tb !== 'Date') return NaN;
return -1;
}
switch (ta) {
case 'number':
case 'Date':
case 'string':
return a > b ? 1 : a < b ? -1 : 0;
case 'binary': {
return compareUint8Arrays(getUint8Array(a), getUint8Array(b));
}
case 'Array':
return compareArrays(a, b);
}
} catch {}
return NaN; // Return value if any given args are valid keys.
}

export function compareArrays(a: any[], b: any[]): number {
const al = a.length;
const bl = b.length;
const l = al < bl ? al : bl;
for (let i = 0; i < l; ++i) {
const res = cmp(a[i], b[i]);
if (res !== 0) return res;
}
return al === bl ? 0 : al < bl ? -1 : 1;
}

export function compareUint8Arrays(
a: Uint8Array,
b: Uint8Array
) {
const al = a.length;
const bl = b.length;
const l = al < bl ? al : bl;
for (let i = 0; i < l; ++i) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return _cmp(a, b);
return al === bl ? 0 : al < bl ? -1 : 1;
}

// Implementation of https://www.w3.org/TR/IndexedDB-3/#key-type
function type(x: any) {
const t = typeof x;
if (t !== 'object') return t;
if (ArrayBuffer.isView(x)) return 'binary';
const tsTag = toStringTag(x); // Cannot use instanceof in Safari
return tsTag === 'ArrayBuffer' ? 'binary' : (tsTag as 'Array' | 'Date');
}

type BinaryType =
| ArrayBuffer
| DataView
| Uint8ClampedArray
| ArrayBufferView
| Uint8Array
| Int8Array
| Uint16Array
| Int16Array
| Uint32Array
| Int32Array
| Float32Array
| Float64Array;

function getUint8Array(a: BinaryType): Uint8Array {
if (a instanceof Uint8Array) return a;
if (ArrayBuffer.isView(a))
// TypedArray or DataView
return new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
return new Uint8Array(a); // ArrayBuffer
}
1 change: 0 additions & 1 deletion src/public/types/dbcore.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ export interface DBCore {
transaction(stores: string[], mode: 'readonly' | 'readwrite', options?: DbCoreTransactionOptions): DBCoreTransaction;

// Utility methods
cmp(a: any, b: any) : number;
readonly MIN_KEY: any;
readonly MAX_KEY: any;
readonly schema: DBCoreSchema;
Expand Down
1 change: 1 addition & 0 deletions test/tests-all.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Dexie from 'dexie';
Dexie.test = true; // Improve code coverage
import "./tests-cmp.js";
import "./tests-table.js";
import "./tests-chrome-transaction-durability.js";
import "./tests-collection.js";
Expand Down
150 changes: 150 additions & 0 deletions test/tests-cmp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import Dexie from 'dexie';
import { module, test, equal, ok } from 'QUnit';

function fillArrayBuffer(ab, val) {
const view = new Uint8Array(ab);
for (let i = 0; i < view.byteLength; ++i) {
view[i] = val;
}
}

module('cmp');

const { cmp } = Dexie;

test('it should support indexable types', () => {
// numbers
ok(cmp(1, 1) === 0, 'Equal numbers should return 0');
ok(cmp(1, 2) === -1, 'Less than numbers should return -1');
ok(cmp(-1, -2000) === 1, 'Greater than numbers should return 1');
// strings
ok(cmp('A', 'A') === 0, 'Equal strings should return 0');
ok(cmp('A', 'B') === -1, 'Less than strings should return -1');
ok(cmp('C', 'A') === 1, 'Greater than strings should return 1');
// Dates
ok(cmp(new Date(1), new Date(1)) === 0, 'Equal dates should return 0');
ok(cmp(new Date(1), new Date(2)) === -1, 'Less than dates should return -1');
ok(
cmp(new Date(1000), new Date(500)) === 1,
'Greater than dates should return 1'
);
// Arrays
ok(cmp([1, 2, '3'], [1, 2, '3']) === 0, 'Equal arrays should return 0');
ok(cmp([-1], [1]) === -1, 'Less than arrays should return -1');
ok(cmp([1], [-1]) === 1, 'Greater than arrays should return 1');
ok(cmp([1], [1, 0]) === -1, 'If second array is longer with same leading entries, return -1');
ok(cmp([1, 0], [1]) === 1, 'If first array is longer with same leading entries, return 1');
ok(cmp([1], [0,0]) === 1, 'If first array is shorter but has greater leading entries, return 1');
ok(cmp([0,0], [1]) === -1, 'If second array is shorter but has greater leading entries, return -1');

/* Binary types
| DataView
| Uint8ClampedArray
| Uint8Array
| Int8Array
| Uint16Array
| Int16Array
| Uint32Array
| Int32Array
| Float32Array
| Float64Array;
*/
const viewTypes = [
'DataView',
'Uint8ClampedArray',
'Uint8Array',
'Int8Array',
'Uint16Array',
'Uint32Array',
'Int32Array',
'Float32Array',
'Float64Array',
]
.map((typeName) => [typeName, self[typeName]])
.filter(([_, ctor]) => !!ctor); // Don't try to test types not supported by the browser

const zeroes1 = new ArrayBuffer(16);
const zeroes2 = new ArrayBuffer(16);
const ones = new ArrayBuffer(16);
fillArrayBuffer(zeroes1, 0);
fillArrayBuffer(zeroes2, 0);
fillArrayBuffer(ones, 1);

for (const [typeName, ArrayBufferView] of viewTypes) {
// Equals
let v1 = new ArrayBufferView(zeroes1);
let v2 = new ArrayBufferView(zeroes2);
ok(cmp(v1, v2) === 0, `Equal ${typeName}s should return 0`);
// Less than
v1 = new ArrayBufferView(zeroes1);
v2 = new ArrayBufferView(ones);
ok(cmp(v1, v2) === -1, `Less than ${typeName}s should return -1`);
// Less than
v1 = new ArrayBufferView(ones);
v2 = new ArrayBufferView(zeroes1);
ok(cmp(v1, v2) === 1, `Greater than ${typeName}s should return 1`);
}
});
test("it should respect IndexedDB's type order", () => {
const zoo = [
'meow',
1,
new Date(),
Infinity,
-Infinity,
new ArrayBuffer(1),
[[]],
];
const [minusInfinity, num, infinity, date, string, binary, array] =
zoo.sort(cmp);
equal(minusInfinity, -Infinity, 'Minus infinity is sorted first');
equal(num, 1, 'Numbers are sorted second');
equal(infinity, Infinity, 'Infinity is sorted third');
ok(date instanceof Date, 'Date is sorted fourth');
ok(typeof string === 'string', 'strings are sorted fifth');
ok(binary instanceof ArrayBuffer, 'binaries are sorted sixth');
ok(Array.isArray(array), 'Arrays are sorted seventh');
});

test('it should return NaN on invalid types', () => {
ok(
isNaN(cmp(1, { foo: 'bar' })),
'Comparing a number against an object returns NaN (would throw in indexedDB)'
);
ok(
isNaN(cmp({ foo: 'bar' }, 1)),
'Comparing an object against a number returns NaN also'
);
});

test('it should treat different binary types as if they were equal', () => {
const viewTypes = [
'DataView',
'Uint8ClampedArray',
'Uint8Array',
'Int8Array',
'Uint16Array',
'Uint32Array',
'Int32Array',
'Float32Array',
'Float64Array',
]
.map((typeName) => [typeName, self[typeName]])
.filter(([_, ctor]) => !!ctor); // Don't try to test types not supported by the browser

const zeroes1 = new ArrayBuffer(16);
const zeroes2 = new ArrayBuffer(16);
fillArrayBuffer(zeroes1, 0);
fillArrayBuffer(zeroes2, 0);

for (const [typeName, ArrayBufferView] of viewTypes) {
let v1 = new ArrayBufferView(zeroes1);
ok(cmp(v1, zeroes1) === 0, `Comparing ${typeName} with ArrayBuffer should return 0 if they have identical data`);
}
});

test('it should return NaN if comparing arrays where any item or sub array item includes an invalid key', ()=> {
ok(cmp([1, [[2, "3"]]], [1,[[2, "3"]]]) === 0, "It can deep compare arrays with valid keys (equals)");
ok(cmp([1, [[2, "3"]]], [1,[[2, 3]]]) === 1, "It can deep compare arrays with valid keys (greater than)");
ok(isNaN(cmp([1, [[2, 3]]], [1,[[{foo: "bar"}, 3]]])), "It returns NaN when any item in the any of the arrays are invalid keys");
});
7 changes: 6 additions & 1 deletion test/tests-misc.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Dexie from 'dexie';
import {module, stop, start, asyncTest, equal, deepEqual, ok} from 'QUnit';
import {resetDatabase, spawnedTest, promisedTest, supports} from './dexie-unittest-utils';
import {resetDatabase, spawnedTest, promisedTest, supports, isIE, isEdge} from './dexie-unittest-utils';

const async = Dexie.async;

Expand Down Expand Up @@ -321,6 +321,11 @@ asyncTest ("#1079 mapToClass", function(){
});

asyncTest("PR #1108", async ()=>{
if (isIE || isEdge) {
ok(true, "Disabling this test for IE and legacy Edge");
start();
return;
}
const origConsoleWarn = console.warn;
const warnings = [];
console.warn = function(msg){warnings.push(msg); return origConsoleWarn.apply(this, arguments)};
Expand Down

0 comments on commit 46a430f

Please sign in to comment.