diff --git a/builder/codegen.js b/builder/codegen.js index 6cf3627..f28ca18 100644 --- a/builder/codegen.js +++ b/builder/codegen.js @@ -84,6 +84,16 @@ module.exports = function generateCode(hyperdb, { directory = '.', esm = false } } if (addedHelper) str += '\n' + const keyEnums = new Map() + for (const type of hyperdb.orderedTypes) { + if (!type.isCollection && !type.isIndex) continue + for (let i = 0; i < type.keyEncoding.length; i++) { + const id = enumId(type, i) + if (id) keyEnums.set(id, keyEnum(type, i)) + } + } + for (const [id, e] of keyEnums) str += generateEnumKeyEncoding(e, id) + const collections = [] const indexes = [] @@ -169,7 +179,7 @@ function generateCommonPrefix(type) { str += ' return []\n' } else if (len === 1) { str += ` const a = ${getKeyPath(type.fullKey[0], 'record', true)}\n` - str += ' return a === undefined ? [] : [a]\n' + str += ` return a === undefined ? [] : [${wrapKeyEncode(type, 0, 'a')}]\n` } else { str += ' const arr = []\n' str += '\n' @@ -178,7 +188,7 @@ function generateCommonPrefix(type) { const key = type.fullKey[i] str += ` const a${i} = ${getKeyPath(key, 'record', true)}\n` str += ` if (a${i} === undefined) return arr\n` - str += ` arr.push(a${i})\n` + str += ` arr.push(${wrapKeyEncode(type, i, 'a' + i)})\n` str += '\n' } str += ' return arr\n' @@ -224,7 +234,7 @@ function generateCollectionDefinition(collection) { for (let i = 0; i < collection.key.length; i++) { const key = collection.key[i] - str += ` ${getKeyPath(key, 'record', false)} = key[${i}]\n` + str += ` ${getKeyPath(key, 'record', false)} = ${wrapKeyDecode(collection, i, `key[${i}]`)}\n` } if (versionedUserland) { @@ -238,7 +248,7 @@ function generateCollectionDefinition(collection) { str += `function ${id}_reconstruct_key (keyBuf) {\n` if (collection.key.length) str += ` const key = ${id}_key.decode(keyBuf)\n` - str += generateKeyReconstruct(' ', collection.key, 'key') + '\n' + str += generateKeyReconstruct(' ', collection, 'key') + '\n' str += '}\n' str += '\n' @@ -348,7 +358,9 @@ function generateEncodeCollectionValue(collection, sep) { function generateEncodeCollectionKey(collection, sep) { const id = getId(collection) - const accessors = toProps('record', collection.fullKey) + const accessors = toProps('record', collection.fullKey).map((a, i) => + wrapKeyEncode(collection, i, a) + ) let str = '' str += ' encodeKey (record) {\n' str += ` const key = [${accessors.join(', ')}]\n` @@ -365,7 +377,9 @@ function generateEncodeIndexKeys(index, sep) { if (index.isMapped) { const indexAccessors = toProps('mappedRecord', index.indexKey) const recordAccessors = toProps('record', index.fullKey.slice(index.indexKey.length)) - const accessors = indexAccessors.concat(recordAccessors) + const accessors = indexAccessors + .concat(recordAccessors) + .map((a, i) => wrapKeyEncode(index, i, a)) str += ` const mapped = ${id}_map(record, context)\n` str += ' const keys = new Array(mapped.length)\n' str += ' for (let i = 0; i < mapped.length; i++) {\n' @@ -374,7 +388,7 @@ function generateEncodeIndexKeys(index, sep) { str += ' }\n' str += ' return keys\n' } else { - const accessors = toProps('record', index.fullKey) + const accessors = toProps('record', index.fullKey).map((a, i) => wrapKeyEncode(index, i, a)) str += ` return [${id + '_key'}.encode([${accessors.join(', ')}])]\n` } str += ` }${sep}\n` @@ -395,7 +409,8 @@ function toProps(name, keys) { return keys.map((c) => (c === null ? name : c.split('.').reduce(gen, name))) } -function generateKeyReconstruct(indent, keys, key) { +function generateKeyReconstruct(indent, type, key) { + const keys = type.key if (keys.length === 0) return indent + 'return {}' const grouped = new Map() @@ -430,7 +445,7 @@ function generateKeyReconstruct(indent, keys, key) { const [k, v] = all[i] s += indent + ` ${gen.property(k)}: ` if (v.index !== -1) { - s += `${key}[${v.index}]` + s += wrapKeyDecode(type, v.index, `${key}[${v.index}]`) } else { s += generate(indent + ' ', v.map) } @@ -441,14 +456,55 @@ function generateKeyReconstruct(indent, keys, key) { } } +function keyEnum(type, i) { + const keyType = type.builder.schema.types.get(`@${type.namespace}/${type.keyEncoding[i]}`) + return keyType?.isEnum ? keyType : null +} + +function enumId(type, i) { + const e = keyEnum(type, i) + return e?.strings ? 'enum' + type.builder.schema.positionsByType.get(e.fqn) : null +} + +function wrapKeyEncode(type, i, expr) { + const id = enumId(type, i) + return id ? `${id}_encode(${expr})` : expr +} + +function wrapKeyDecode(type, i, expr) { + const id = enumId(type, i) + return id ? `${id}_decode(${expr})` : expr +} + +function generateEnumKeyEncoding(enumType, id) { + let str = '' + str += `// ${s(enumType.fqn)} enum key encoding\n` + str += `function ${id}_encode (v) {\n` + str += ' switch (v) {\n' + for (let i = 0; i < enumType.enum.length; i++) { + str += ` case ${s(enumType.enum[i].key)}: return ${i + enumType.offset}\n` + } + str += ' default: return v\n' + str += ' }\n' + str += '}\n' + str += '\n' + str += `function ${id}_decode (v) {\n` + str += ' switch (v) {\n' + for (let i = 0; i < enumType.enum.length; i++) { + str += ` case ${i + enumType.offset}: return ${s(enumType.enum[i].key)}\n` + } + str += ' default: return null\n' + str += ' }\n' + str += '}\n' + str += '\n' + return str +} + function generateIndexKeyEncoding(type) { let str = 'new IndexEncoder([\n' for (let i = 0; i < type.keyEncoding.length; i++) { - const component = type.keyEncoding[i] - - const keyType = type.builder.schema.types.get(`@${type.namespace}/${component}`) - if (keyType?.isEnum) str += ' IndexEncoder.UINT' - else str += ' ' + IndexTypeMap.get(component) + if (keyEnum(type, i)) str += ' IndexEncoder.UINT' + else str += ' ' + IndexTypeMap.get(type.keyEncoding[i]) if (i !== type.keyEncoding.length - 1) str += ',\n' else str += '\n' diff --git a/test/basic.js b/test/basic.js index a7b6e8c..a99f1a8 100644 --- a/test/basic.js +++ b/test/basic.js @@ -710,6 +710,44 @@ test('enum as key type', async function ({ create, bee }, t) { await db.close() }) +test('string enum as key type', async function ({ create, bee }, t) { + const db = await create({ fixture: 8 }) + const builder = require('./fixtures/generated/8/hyperschema') + const { NotSpecified, Male, Female } = builder.getEnum('@db/gender') + + await db.insert('@db/members', { name: 'Doe', gender: NotSpecified }) + await db.insert('@db/members', { name: 'John', gender: Male }) + await db.insert('@db/members', { name: 'Jane', gender: Female }) + await db.flush() + + { + const john = await db.get('@db/members', { name: 'John', gender: Male }) + t.alike(john, { name: 'John', gender: Male }) + } + { + const females = await db + .find('@db/members-by-gender', { lte: { gender: Female }, gte: { gender: Female } }) + .toArray() + t.alike(females, [{ name: 'Jane', gender: Female }]) + } + + { + const females = await db + .find('@db/members-by-gender', { lte: { gender: 'Female' }, gte: { gender: 'Female' } }) + .toArray() + t.alike(females, [{ name: 'Jane', gender: 'Female' }]) + } + + { + const females = await db + .find('@db/members-by-gender', { lte: { gender: 3 }, gte: { gender: 3 } }) + .toArray() + t.alike(females, [{ name: 'Jane', gender: Female }]) + } + + await db.close() +}) + test('flushing with pending insert/delete throws', async ({ create }, t) => { const db = await create(definition) diff --git a/test/fixtures/builders/8.js b/test/fixtures/builders/8.js new file mode 100644 index 0000000..54aa1b2 --- /dev/null +++ b/test/fixtures/builders/8.js @@ -0,0 +1,51 @@ +const HyperDB = require('../../../builder') +const Hyperschema = require('hyperschema') +const path = require('path') + +const SCHEMA_DIR = path.join(__dirname, '../generated/8/hyperschema') +const DB_DIR = path.join(__dirname, '../generated/8/hyperdb') + +const schema = Hyperschema.from(SCHEMA_DIR) + +const dbSchema = schema.namespace('db') + +dbSchema.register({ + name: 'gender', + strings: true, + enum: ['NotSpecified', 'Male', 'Female'] +}) + +dbSchema.register({ + name: 'member', + fields: [ + { + name: 'name', + type: 'string', + required: true + }, + { + name: 'gender', + type: '@db/gender', + required: true + } + ] +}) + +Hyperschema.toDisk(schema) + +const db = HyperDB.from(SCHEMA_DIR, DB_DIR) +const testDb = db.namespace('db') + +testDb.collections.register({ + name: 'members', + schema: '@db/member', + key: ['name', 'gender'] +}) + +testDb.indexes.register({ + name: 'members-by-gender', + collection: '@db/members', + key: ['gender'] +}) + +HyperDB.toDisk(db) diff --git a/test/fixtures/generate.js b/test/fixtures/generate.js index fece4cd..50006a6 100644 --- a/test/fixtures/generate.js +++ b/test/fixtures/generate.js @@ -5,3 +5,4 @@ require('./builders/4.js') require('./builders/5.js') require('./builders/6.js') require('./builders/7.js') +require('./builders/8.js') diff --git a/test/fixtures/generated/8/hyperdb/db.json b/test/fixtures/generated/8/hyperdb/db.json new file mode 100644 index 0000000..e1d4cf8 --- /dev/null +++ b/test/fixtures/generated/8/hyperdb/db.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "offset": 0, + "schema": [ + { + "name": "members", + "namespace": "db", + "id": 0, + "type": 1, + "version": 1, + "versionField": null, + "indexes": [ + "@db/members-by-gender" + ], + "schema": "@db/member", + "derived": false, + "key": [ + "name", + "gender" + ], + "trigger": null + }, + { + "name": "members-by-gender", + "namespace": "db", + "id": 1, + "type": 2, + "version": 1, + "collection": "@db/members", + "unique": false, + "deprecated": false, + "key": [ + "gender" + ] + } + ] +} \ No newline at end of file diff --git a/test/fixtures/generated/8/hyperdb/index.js b/test/fixtures/generated/8/hyperdb/index.js new file mode 100644 index 0000000..ffc905c --- /dev/null +++ b/test/fixtures/generated/8/hyperdb/index.js @@ -0,0 +1,180 @@ +// This file is autogenerated by the hyperdb compiler +/* eslint-disable camelcase */ + +const { IndexEncoder, c, b4a } = require('hyperdb/runtime') +const { version, getEncoding, setVersion } = require('./messages.js') + +const versions = { schema: version, db: 1 } + +// '@db/gender' enum key encoding +function enum0_encode (v) { + switch (v) { + case 'NotSpecified': return 1 + case 'Male': return 2 + case 'Female': return 3 + default: return v + } +} + +function enum0_decode (v) { + switch (v) { + case 1: return 'NotSpecified' + case 2: return 'Male' + case 3: return 'Female' + default: return null + } +} + +// '@db/members' collection key +const collection0_key = new IndexEncoder([ + IndexEncoder.STRING, + IndexEncoder.UINT +], { prefix: 0 }) + +function collection0_indexify (record) { + const arr = [] + + const a0 = record.name + if (a0 === undefined) return arr + arr.push(a0) + + const a1 = record.gender + if (a1 === undefined) return arr + arr.push(enum0_encode(a1)) + + return arr +} + +// '@db/members' value encoding +const collection0_enc = getEncoding('@db/member/hyperdb#0') + +// '@db/members' reconstruction function +function collection0_reconstruct (schemaVersion, keyBuf, valueBuf) { + const key = collection0_key.decode(keyBuf) + setVersion(schemaVersion) + const state = { start: 0, end: valueBuf.byteLength, buffer: valueBuf } + const type = c.uint.decode(state) + if (type !== 0) throw new Error('Unknown collection type: ' + type) + collection0.decodedVersion = c.uint.decode(state) + const record = collection0_enc.decode(state) + record.name = key[0] + record.gender = enum0_decode(key[1]) + return record +} +// '@db/members' key reconstruction function +function collection0_reconstruct_key (keyBuf) { + const key = collection0_key.decode(keyBuf) + return { + name: key[0], + gender: enum0_decode(key[1]) + } +} + +// '@db/members' +const collection0 = { + name: '@db/members', + id: 0, + version: 1, + encodeKey (record) { + const key = [record.name, enum0_encode(record.gender)] + return collection0_key.encode(key) + }, + encodeKeyRange ({ gt, lt, gte, lte } = {}) { + return collection0_key.encodeRange({ + gt: gt ? collection0_indexify(gt) : null, + lt: lt ? collection0_indexify(lt) : null, + gte: gte ? collection0_indexify(gte) : null, + lte: lte ? collection0_indexify(lte) : null + }) + }, + encodeValue (schemaVersion, collectionVersion, record) { + setVersion(schemaVersion) + const state = { start: 0, end: 2, buffer: null } + collection0_enc.preencode(state, record) + state.buffer = b4a.allocUnsafe(state.end) + state.buffer[state.start++] = 0 + state.buffer[state.start++] = collectionVersion + collection0_enc.encode(state, record) + return state.buffer + }, + trigger: null, + reconstruct: collection0_reconstruct, + reconstructKey: collection0_reconstruct_key, + indexes: [], + decodedVersion: 0 +} + +// '@db/members-by-gender' collection key +const index1_key = new IndexEncoder([ + IndexEncoder.UINT, + IndexEncoder.STRING, + IndexEncoder.UINT +], { prefix: 1 }) + +function index1_indexify (record) { + const arr = [] + + const a0 = record.gender + if (a0 === undefined) return arr + arr.push(enum0_encode(a0)) + + const a1 = record.name + if (a1 === undefined) return arr + arr.push(a1) + + const a2 = record.gender + if (a2 === undefined) return arr + arr.push(enum0_encode(a2)) + + return arr +} + +// '@db/members-by-gender' +const index1 = { + name: '@db/members-by-gender', + version: 1, + id: 1, + encodeKey (record) { + return index1_key.encode(index1_indexify(record)) + }, + encodeKeyRange ({ gt, lt, gte, lte } = {}) { + return index1_key.encodeRange({ + gt: gt ? index1_indexify(gt) : null, + lt: lt ? index1_indexify(lt) : null, + gte: gte ? index1_indexify(gte) : null, + lte: lte ? index1_indexify(lte) : null + }) + }, + encodeValue: (record) => index1.collection.encodeKey(record), + encodeIndexKeys (record, context) { + return [index1_key.encode([enum0_encode(record.gender), record.name, enum0_encode(record.gender)])] + }, + reconstruct: (keyBuf, valueBuf) => valueBuf, + offset: collection0.indexes.length, + collection: collection0 +} +collection0.indexes.push(index1) + +const collections = [ + collection0 +] + +const indexes = [ + index1 +] + +module.exports = { versions, collections, indexes, resolveCollection, resolveIndex } + +function resolveCollection (name) { + switch (name) { + case '@db/members': return collection0 + default: return null + } +} + +function resolveIndex (name) { + switch (name) { + case '@db/members-by-gender': return index1 + default: return null + } +} diff --git a/test/fixtures/generated/8/hyperdb/messages.js b/test/fixtures/generated/8/hyperdb/messages.js new file mode 100644 index 0000000..fe7ed31 --- /dev/null +++ b/test/fixtures/generated/8/hyperdb/messages.js @@ -0,0 +1,155 @@ +// This file is autogenerated by the hyperschema compiler +// Schema Version: 1 +/* eslint-disable camelcase */ +/* eslint-disable quotes */ +/* eslint-disable space-before-function-paren */ + +const { c } = require('hyperschema/runtime') + +const VERSION = 1 + +// eslint-disable-next-line no-unused-vars +let version = VERSION + +const encoding0_enum = { + NotSpecified: 'NotSpecified', + Male: 'Male', + Female: 'Female' +} + +// @db/gender enum +const encoding0 = { + preencode (state, m) { + state.end++ // max enum is 3 so always one byte + }, + encode (state, m) { + switch (m) { + case 'NotSpecified': + c.uint.encode(state, 1) + break + case 'Male': + c.uint.encode(state, 2) + break + case 'Female': + c.uint.encode(state, 3) + break + default: + throw new Error('Unknown enum') + } + }, + decode (state) { + switch (c.uint.decode(state)) { + case 1: + return 'NotSpecified' + case 2: + return 'Male' + case 3: + return 'Female' + default: return null + } + } +} + +// @db/member +const encoding1 = { + preencode(state, m) { + c.string.preencode(state, m.name) + encoding0.preencode(state, m.gender) + }, + encode(state, m) { + c.string.encode(state, m.name) + encoding0.encode(state, m.gender) + }, + decode(state) { + const r0 = c.string.decode(state) + const r1 = encoding0.decode(state) + + return { + name: r0, + gender: r1 + } + } +} + +// @db/member/hyperdb#0 +const encoding2 = { + preencode(state, m) { + + }, + encode(state, m) { + + }, + decode(state) { + return { + name: null, + gender: null + } + } +} + +function setVersion(v) { + version = v +} + +function encode(name, value, v = VERSION) { + version = v + return c.encode(getEncoding(name), value) +} + +function decode(name, buffer, v = VERSION) { + version = v + return c.decode(getEncoding(name), buffer) +} + +function getEnum(name) { + switch (name) { + case '@db/gender': + return encoding0_enum + default: + throw new Error('Enum not found ' + name) + } +} + +function getEncoding(name) { + switch (name) { + case '@db/gender': + return encoding0 + case '@db/member': + return encoding1 + case '@db/member/hyperdb#0': + return encoding2 + default: + throw new Error('Encoder not found ' + name) + } +} + +function getStruct(name, v = VERSION) { + const enc = getEncoding(name) + return { + preencode(state, m) { + version = v + enc.preencode(state, m) + }, + encode(state, m) { + version = v + enc.encode(state, m) + }, + decode(state) { + version = v + return enc.decode(state) + } + } +} + +const resolveStruct = getStruct // compat + +module.exports = { + resolveStruct, + getStruct, + getEnum, + getEncoding, + encode, + decode, + setVersion, + version +} diff --git a/test/fixtures/generated/8/hyperschema/index.js b/test/fixtures/generated/8/hyperschema/index.js new file mode 100644 index 0000000..f9e23c9 --- /dev/null +++ b/test/fixtures/generated/8/hyperschema/index.js @@ -0,0 +1,137 @@ +// This file is autogenerated by the hyperschema compiler +// Schema Version: 1 +/* eslint-disable camelcase */ +/* eslint-disable quotes */ +/* eslint-disable space-before-function-paren */ + +const { c } = require('hyperschema/runtime') + +const VERSION = 1 + +// eslint-disable-next-line no-unused-vars +let version = VERSION + +const encoding0_enum = { + NotSpecified: 'NotSpecified', + Male: 'Male', + Female: 'Female' +} + +// @db/gender enum +const encoding0 = { + preencode (state, m) { + state.end++ // max enum is 3 so always one byte + }, + encode (state, m) { + switch (m) { + case 'NotSpecified': + c.uint.encode(state, 1) + break + case 'Male': + c.uint.encode(state, 2) + break + case 'Female': + c.uint.encode(state, 3) + break + default: + throw new Error('Unknown enum') + } + }, + decode (state) { + switch (c.uint.decode(state)) { + case 1: + return 'NotSpecified' + case 2: + return 'Male' + case 3: + return 'Female' + default: return null + } + } +} + +// @db/member +const encoding1 = { + preencode(state, m) { + c.string.preencode(state, m.name) + encoding0.preencode(state, m.gender) + }, + encode(state, m) { + c.string.encode(state, m.name) + encoding0.encode(state, m.gender) + }, + decode(state) { + const r0 = c.string.decode(state) + const r1 = encoding0.decode(state) + + return { + name: r0, + gender: r1 + } + } +} + +function setVersion(v) { + version = v +} + +function encode(name, value, v = VERSION) { + version = v + return c.encode(getEncoding(name), value) +} + +function decode(name, buffer, v = VERSION) { + version = v + return c.decode(getEncoding(name), buffer) +} + +function getEnum(name) { + switch (name) { + case '@db/gender': + return encoding0_enum + default: + throw new Error('Enum not found ' + name) + } +} + +function getEncoding(name) { + switch (name) { + case '@db/gender': + return encoding0 + case '@db/member': + return encoding1 + default: + throw new Error('Encoder not found ' + name) + } +} + +function getStruct(name, v = VERSION) { + const enc = getEncoding(name) + return { + preencode(state, m) { + version = v + enc.preencode(state, m) + }, + encode(state, m) { + version = v + enc.encode(state, m) + }, + decode(state) { + version = v + return enc.decode(state) + } + } +} + +const resolveStruct = getStruct // compat + +module.exports = { + resolveStruct, + getStruct, + getEnum, + getEncoding, + encode, + decode, + setVersion, + version +} diff --git a/test/fixtures/generated/8/hyperschema/schema.json b/test/fixtures/generated/8/hyperschema/schema.json new file mode 100644 index 0000000..c33a13a --- /dev/null +++ b/test/fixtures/generated/8/hyperschema/schema.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "schema": [ + { + "name": "gender", + "namespace": "db", + "offset": 1, + "enum": [ + { + "key": "NotSpecified", + "version": 1 + }, + { + "key": "Male", + "version": 1 + }, + { + "key": "Female", + "version": 1 + } + ], + "strings": true + }, + { + "name": "member", + "namespace": "db", + "compact": false, + "flagsPosition": -1, + "fields": [ + { + "name": "name", + "required": true, + "type": "string", + "version": 1 + }, + { + "name": "gender", + "required": true, + "type": "@db/gender", + "version": 1 + } + ] + } + ] +}