From 3b43fad74d426804a8a1e6f9d4fe5b2ff19b1585 Mon Sep 17 00:00:00 2001 From: Vincent Weevers Date: Fri, 27 Dec 2024 19:30:54 +0100 Subject: [PATCH] Implement `has()` and `hasMany()` Adds two methods: ```js await db.put('love', 'u') await db.has('love') // true await db.hasMany(['love', 'hate']) // [true, false] ``` Ref: https://github.com/Level/community/issues/142 Category: addition --- README.md | 35 +++++++++ abstract-level.js | 111 +++++++++++++++++++++++++++++ lib/abstract-sublevel.js | 8 +++ test/has-many-test.js | 144 ++++++++++++++++++++++++++++++++++++++ test/has-test.js | 81 +++++++++++++++++++++ test/index.js | 5 ++ test/util.js | 11 +++ types/abstract-level.d.ts | 32 +++++++++ 8 files changed, 427 insertions(+) create mode 100644 test/has-many-test.js create mode 100644 test/has-test.js diff --git a/README.md b/README.md index e563c4f..9ac4649 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,41 @@ Get multiple values from the database by an array of `keys`. The optional `optio Returns a promise for an array of values with the same order as `keys`. If a key was not found, the relevant value will be `undefined`. +### `db.has(key[, options])` + +Check if the given `key` exists in the database. Returns a promise for a boolean. The optional `options` object may contain: + +- `keyEncoding`: custom key encoding for this operation, used to encode the `key`. +- `snapshot`: explicit [snapshot](#snapshot--dbsnapshotoptions) to read from. If no `snapshot` is provided and `db.supports.implicitSnapshots` is true, the database will create its own internal snapshot for this operation. + +Use `has()` wisely and avoid the following pattern which has a race condition: + +```js +if (await db.has('example')) { + const value = await db.get('example') + console.log(value) +} +``` + +Instead do: + +```js +const value = await db.get('example') + +if (value !== undefined) { + console.log(value) +} +``` + +### `db.hasMany(keys[, options])` + +Check the existence of multiple `keys` given as an array. The optional `options` object may contain: + +- `keyEncoding`: custom key encoding for this operation, used to encode the `keys`. +- `snapshot`: explicit [snapshot](#snapshot--dbsnapshotoptions) to read from. If no `snapshot` is provided and `db.supports.implicitSnapshots` is true, the database will create its own internal snapshot for this operation. + +Returns a promise for an array of booleans with the same order as `keys`. + ### `db.put(key, value[, options])` Add a new entry or overwrite an existing entry. The optional `options` object may contain: diff --git a/abstract-level.js b/abstract-level.js index f219440..455613b 100644 --- a/abstract-level.js +++ b/abstract-level.js @@ -449,6 +449,117 @@ class AbstractLevel extends EventEmitter { return new Array(keys.length).fill(undefined) } + async has (key, options) { + options = getOptions(options, this[kDefaultOptions].key) + + if (this[kStatus] === 'opening') { + return this.deferAsync(() => this.has(key, options)) + } + + assertOpen(this) + + // TODO (next major): change this to an assert + const err = this._checkKey(key) + if (err) throw err + + const snapshot = options.snapshot != null ? options.snapshot : null + const keyEncoding = this.keyEncoding(options.keyEncoding) + const keyFormat = keyEncoding.format + + // Forward encoding options to the underlying store + if (options === this[kDefaultOptions].key) { + // Avoid Object.assign() for default options + options = this[kDefaultOptions].keyFormat + } else if (options.keyEncoding !== keyFormat) { + // Avoid spread operator because of https://bugs.chromium.org/p/chromium/issues/detail?id=1204540 + options = Object.assign({}, options, { keyEncoding: keyFormat }) + } + + const encodedKey = keyEncoding.encode(key) + const mappedKey = this.prefixKey(encodedKey, keyFormat, true) + + // Keep snapshot open during operation + if (snapshot !== null) { + snapshot.ref() + } + + try { + return this._has(mappedKey, options) + } finally { + // Release snapshot + if (snapshot !== null) { + snapshot.unref() + } + } + } + + async _has (key, options) { + throw new ModuleError('Database does not support has()', { + code: 'LEVEL_NOT_SUPPORTED' + }) + } + + async hasMany (keys, options) { + options = getOptions(options, this[kDefaultOptions].entry) + + if (this[kStatus] === 'opening') { + return this.deferAsync(() => this.hasMany(keys, options)) + } + + assertOpen(this) + + if (!Array.isArray(keys)) { + throw new TypeError("The first argument 'keys' must be an array") + } + + if (keys.length === 0) { + return [] + } + + const snapshot = options.snapshot != null ? options.snapshot : null + const keyEncoding = this.keyEncoding(options.keyEncoding) + const keyFormat = keyEncoding.format + + // Forward encoding options to the underlying store + if (options === this[kDefaultOptions].key) { + // Avoid Object.assign() for default options + options = this[kDefaultOptions].keyFormat + } else if (options.keyEncoding !== keyFormat) { + // Avoid spread operator because of https://bugs.chromium.org/p/chromium/issues/detail?id=1204540 + options = Object.assign({}, options, { keyEncoding: keyFormat }) + } + + const mappedKeys = new Array(keys.length) + + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const err = this._checkKey(key) + if (err) throw err + + mappedKeys[i] = this.prefixKey(keyEncoding.encode(key), keyFormat, true) + } + + // Keep snapshot open during operation + if (snapshot !== null) { + snapshot.ref() + } + + try { + return this._hasMany(mappedKeys, options) + } finally { + // Release snapshot + if (snapshot !== null) { + snapshot.unref() + } + } + } + + async _hasMany (keys, options) { + throw new ModuleError('Database does not support hasMany()', { + code: 'LEVEL_NOT_SUPPORTED' + }) + } + async put (key, value, options) { if (!this.hooks.prewrite.noop) { // Forward to batch() which will run the hook diff --git a/lib/abstract-sublevel.js b/lib/abstract-sublevel.js index 4b40893..cc91711 100644 --- a/lib/abstract-sublevel.js +++ b/lib/abstract-sublevel.js @@ -146,6 +146,14 @@ module.exports = function ({ AbstractLevel }) { return this[kParent].getMany(keys, options) } + async _has (key, options) { + return this[kParent].has(key, options) + } + + async _hasMany (keys, options) { + return this[kParent].hasMany(keys, options) + } + async _del (key, options) { return this[kParent].del(key, options) } diff --git a/test/has-many-test.js b/test/has-many-test.js new file mode 100644 index 0000000..488c18f --- /dev/null +++ b/test/has-many-test.js @@ -0,0 +1,144 @@ +'use strict' + +const { illegalKeys } = require('./util') +const traits = require('./traits') + +let db + +/** + * @param {import('tape')} test + */ +exports.setUp = function (test, testCommon) { + test('hasMany() setup', async function (t) { + db = testCommon.factory() + return db.open() + }) +} + +/** + * @param {import('tape')} test + */ +exports.args = function (test, testCommon) { + test('hasMany() requires an array argument', function (t) { + t.plan(6) + + db.hasMany().catch(function (err) { + t.is(err && err.name, 'TypeError') + t.is(err && err.message, "The first argument 'keys' must be an array") + }) + + db.hasMany('foo').catch(function (err) { + t.is(err && err.name, 'TypeError') + t.is(err && err.message, "The first argument 'keys' must be an array") + }) + + db.hasMany('foo', {}).catch(function (err) { + t.is(err && err.name, 'TypeError') + t.is(err && err.message, "The first argument 'keys' must be an array") + }) + }) + + test('hasMany() with illegal keys', function (t) { + t.plan(illegalKeys.length * 4) + + for (const { name, key } of illegalKeys) { + db.hasMany([key]).catch(function (err) { + t.ok(err instanceof Error, name + ' - is Error') + t.is(err.code, 'LEVEL_INVALID_KEY', name + ' - correct error code') + }) + + db.hasMany(['valid', key]).catch(function (err) { + t.ok(err instanceof Error, name + ' - is Error (second key)') + t.is(err.code, 'LEVEL_INVALID_KEY', name + ' - correct error code (second key)') + }) + } + }) +} + +/** + * @param {import('tape')} test + */ +exports.hasMany = function (test, testCommon) { + test('simple hasMany()', async function (t) { + await db.put('foo', 'bar') + + t.same(await db.hasMany(['foo']), [true]) + t.same(await db.hasMany(['foo'], {}), [true]) // same but with {} + t.same(await db.hasMany(['beep']), [false]) + + await db.put('beep', 'boop') + + t.same(await db.hasMany(['beep']), [true]) + t.same(await db.hasMany(['foo', 'beep']), [true, true]) + t.same(await db.hasMany(['aaa', 'beep']), [false, true]) + t.same(await db.hasMany(['beep', 'aaa']), [true, false], 'maintains order of input keys') + }) + + test('empty hasMany()', async function (t) { + t.same(await db.hasMany([]), []) + + const encodings = Object.keys(db.supports.encodings) + .filter(k => db.supports.encodings[k]) + + for (const valueEncoding of encodings) { + t.same(await db.hasMany([], { valueEncoding }), []) + } + }) + + test('simultaneous hasMany()', async function (t) { + t.plan(20) + + await db.put('hello', 'world') + const promises = [] + + for (let i = 0; i < 10; ++i) { + promises.push(db.hasMany(['hello']).then(function (values) { + t.same(values, [true]) + })) + } + + for (let i = 0; i < 10; ++i) { + promises.push(db.hasMany(['non-existent']).then(function (values) { + t.same(values, [false]) + })) + } + + return Promise.all(promises) + }) + + traits.open('hasMany()', testCommon, async function (t, db) { + t.same(await db.hasMany(['foo']), [false]) + }) + + traits.closed('hasMany()', testCommon, async function (t, db) { + return db.hasMany(['foo']) + }) + + // Also test empty array because it has a fast-path + traits.open('hasMany() with empty array', testCommon, async function (t, db) { + t.same(await db.hasMany([]), []) + }) + + traits.closed('hasMany() with empty array', testCommon, async function (t, db) { + return db.hasMany([]) + }) +} + +/** + * @param {import('tape')} test + */ +exports.tearDown = function (test, testCommon) { + test('hasMany() teardown', async function (t) { + return db.close() + }) +} + +/** + * @param {import('tape')} test + */ +exports.all = function (test, testCommon) { + exports.setUp(test, testCommon) + exports.args(test, testCommon) + exports.hasMany(test, testCommon) + exports.tearDown(test, testCommon) +} diff --git a/test/has-test.js b/test/has-test.js new file mode 100644 index 0000000..c17b7c7 --- /dev/null +++ b/test/has-test.js @@ -0,0 +1,81 @@ +'use strict' + +const { illegalKeys } = require('./util') +const traits = require('./traits') + +let db + +exports.setUp = function (test, testCommon) { + test('has() setup', async function (t) { + db = testCommon.factory() + return db.open() + }) +} + +exports.args = function (test, testCommon) { + test('has() with illegal keys', function (t) { + t.plan(illegalKeys.length * 2) + + for (const { name, key } of illegalKeys) { + db.has(key).catch(function (err) { + t.ok(err instanceof Error, name + ' - is Error') + t.is(err.code, 'LEVEL_INVALID_KEY', name + ' - correct error code') + }) + } + }) +} + +exports.has = function (test, testCommon) { + test('simple has()', async function (t) { + await db.put('foo', 'bar') + + t.is(await db.has('foo'), true) + t.is(await db.has('foo', {}), true) // same but with {} + + for (const key of ['non-existent', Math.random()]) { + t.is(await db.has(key), false, 'not found') + } + }) + + test('simultaneous has()', async function (t) { + t.plan(20) + + await db.put('hello', 'world') + const promises = [] + + for (let i = 0; i < 10; ++i) { + promises.push(db.has('hello').then((value) => { + t.is(value, true, 'found') + })) + } + + for (let i = 0; i < 10; ++i) { + promises.push(db.has('non-existent').then((value) => { + t.is(value, false, 'not found') + })) + } + + return Promise.all(promises) + }) + + traits.open('has()', testCommon, async function (t, db) { + t.is(await db.has('foo'), false) + }) + + traits.closed('has()', testCommon, async function (t, db) { + return db.has('foo') + }) +} + +exports.tearDown = function (test, testCommon) { + test('has() teardown', async function (t) { + return db.close() + }) +} + +exports.all = function (test, testCommon) { + exports.setUp(test, testCommon) + exports.args(test, testCommon) + exports.has(test, testCommon) + exports.tearDown(test, testCommon) +} diff --git a/test/index.js b/test/index.js index f3256a2..e6191e6 100644 --- a/test/index.js +++ b/test/index.js @@ -25,6 +25,11 @@ function suite (options) { require('./put-get-del-test').all(test, testCommon) require('./get-many-test').all(test, testCommon) + if (testCommon.supports.has) { + require('./has-test').all(test, testCommon) + require('./has-many-test').all(test, testCommon) + } + require('./batch-test').all(test, testCommon) require('./chained-batch-test').all(test, testCommon) diff --git a/test/util.js b/test/util.js index d2df9e7..a78d774 100644 --- a/test/util.js +++ b/test/util.js @@ -91,6 +91,7 @@ class MinimalLevel extends AbstractLevel { super({ encodings: { utf8: true }, seek: true, + has: true, explicitSnapshots: true }, options) @@ -113,6 +114,16 @@ class MinimalLevel extends AbstractLevel { return keys.map(k => entries.get(k)) } + async _has (key, options) { + const entries = (options.snapshot || this)[kEntries] + return entries.has(key) + } + + async _hasMany (keys, options) { + const entries = (options.snapshot || this)[kEntries] + return keys.map(k => entries.has(k)) + } + async _del (key, options) { this[kEntries].delete(key) } diff --git a/types/abstract-level.d.ts b/types/abstract-level.d.ts index d3232e5..9a205e3 100644 --- a/types/abstract-level.d.ts +++ b/types/abstract-level.d.ts @@ -89,6 +89,18 @@ declare class AbstractLevel options: AbstractGetManyOptions ): Promise<(V | undefined)[]> + /** + * Check if the given {@link key} exists in the database. + */ + has (key: KDefault): Promise + has (key: K, options: AbstractHasOptions): Promise + + /** + * Check the existence of multiple {@link keys} given as an array. + */ + hasMany (keys: KDefault[]): Promise + hasMany (keys: K[], options: AbstractHasManyOptions): Promise + /** * Add a new entry or overwrite an existing entry. */ @@ -344,6 +356,26 @@ export interface AbstractGetManyOptions { valueEncoding?: string | Transcoder.PartialDecoder | undefined } +/** + * Options for the {@link AbstractLevel.has} method. + */ +export interface AbstractHasOptions { + /** + * Custom key encoding for this operation, used to encode the `key`. + */ + keyEncoding?: string | Transcoder.PartialEncoder | undefined +} + +/** + * Options for the {@link AbstractLevel.hasMany} method. + */ +export interface AbstractHasManyOptions { + /** + * Custom key encoding for this operation, used to encode the `keys`. + */ + keyEncoding?: string | Transcoder.PartialEncoder | undefined +} + /** * Options for the {@link AbstractLevel.put} method. */