diff --git a/README.md b/README.md index 959c4101..32d9d2a9 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,7 @@ import { JsonDB, Config } from 'node-json-db'; // The third argument is used to ask JsonDB to save the database in a human readable format. (default false) // The fourth argument is the separator. By default it's slash (/) // The fifth argument enables sync writes (default false) -// The sixth argument controls automatic Date parsing for ISO strings (default true) -var db = new JsonDB(new Config("myDataBase", true, false, '/', false, false)); +var db = new JsonDB(new Config("myDataBase", true, false, '/', false)); // Pushing the data into the database // With the wanted DataPath @@ -368,6 +367,71 @@ await db.push("/test1","super test"); - The encryption uses AES-256-GCM for secure authenticated encryption - The encryption key must be exactly 32 bytes +#### Type Serialization + +JsonDB automatically serializes and deserializes JavaScript types that are not natively supported by JSON. The following types are supported out of the box: + +| Type | Serialized format | +|----------|-----------------------------------------------------------------------| +| `Date` | `{ "__type": "Date", "__value": "2023-01-01T00:00:00.000Z" }` | +| `Set` | `{ "__type": "Set", "__value": [1, 2, 3] }` | +| `Map` | `{ "__type": "Map", "__value": [["key", "value"]] }` | +| `RegExp` | `{ "__type": "RegExp", "__value": { "source": "^test$", "flags": "i" } }` | +| `BigInt` | `{ "__type": "BigInt", "__value": "9007199254740993" }` | + +```typescript +import { JsonDB, Config } from 'node-json-db'; + +const db = new JsonDB(new Config('myDataBase')); + +// All these types are automatically serialized and deserialized +await db.push('/data', { + tags: new Set(['typescript', 'json']), + metadata: new Map([['version', 1], ['author', 'test']]), + createdAt: new Date(), + pattern: /^hello\s+world$/i, + bigNumber: BigInt('9007199254740993'), +}); + +const data = await db.getData('/data'); +// data.tags is a Set, data.metadata is a Map, data.createdAt is a Date, etc. +``` + +#### Custom Serializers + +You can add support for additional types by implementing the `ISerializer` interface and registering them with `Config.addSerializer()`: + +```typescript +import { JsonDB, Config, ISerializer } from 'node-json-db'; + +const urlSerializer: ISerializer = { + type: "URL", + serialize: (value: URL) => value.href, + deserialize: (value: string) => new URL(value), + test: (value: any) => value instanceof URL, +}; + +const config = new Config('myDataBase'); +config.addSerializer(urlSerializer); + +const db = new JsonDB(config); +await db.push('/link', new URL('https://example.com')); +const link = await db.getData('/link'); // URL instance +``` + +You can also use `defaultSerializers` directly with `JsonAdapter` for full control: + +```typescript +import { JsonAdapter, FileAdapter, defaultSerializers, ISerializer } from 'node-json-db'; + +const mySerializer: ISerializer = { /* ... */ }; +const adapter = new JsonAdapter( + new FileAdapter('mydb.json', false), + false, + [...defaultSerializers, mySerializer] +); +``` + ### Exception/Error #### Type diff --git a/src/JsonDB.ts b/src/JsonDB.ts index 9843d716..89d93ca3 100644 --- a/src/JsonDB.ts +++ b/src/JsonDB.ts @@ -9,8 +9,10 @@ import {readLockAsync, writeLockAsync} from "./lock/Lock"; export {Config, ConfigWithAdapter} from './lib/JsonDBConfig' export {DatabaseError, DataError} from './lib/Errors' export type {IAdapter} from './adapter/IAdapter' +export type {ISerializer} from './adapter/data/ISerializer' export {JsonAdapter} from './adapter/data/JsonAdapter' export {FileAdapter} from './adapter/file/FileAdapter' +export {DateSerializer, SetSerializer, MapSerializer, RegExpSerializer, BigIntSerializer, defaultSerializers} from './adapter/data/Serializers' type DataPath = Array diff --git a/src/adapter/data/ISerializer.ts b/src/adapter/data/ISerializer.ts new file mode 100644 index 00000000..08cd7513 --- /dev/null +++ b/src/adapter/data/ISerializer.ts @@ -0,0 +1,39 @@ +/** + * Contract for custom type serialization/deserialization. + * Implement this interface to add support for custom types + * that are not natively supported by JSON. + * + * Types are serialized using a `__type` discriminator: + * ```json + * { "__type": "TypeName", "__value": } + * ``` + */ +export interface ISerializer { + /** + * Unique type identifier stored as `__type` in the serialized JSON. + */ + readonly type: string; + + /** + * Serialize a value to a JSON-compatible representation. + * The returned value will be stored under `__value` in the serialized JSON. + * @param value The value to serialize + * @returns A JSON-compatible value + */ + serialize(value: any): any; + + /** + * Deserialize a value from its JSON representation. + * The input is the `__value` from the serialized JSON. + * @param value The stored JSON value + * @returns The deserialized runtime value + */ + deserialize(value: any): any; + + /** + * Test if a runtime value should be handled by this serializer. + * @param value The value to test + * @returns true if this serializer should handle this value + */ + test(value: any): boolean; +} diff --git a/src/adapter/data/JsonAdapter.ts b/src/adapter/data/JsonAdapter.ts index fe8a896f..03935f94 100644 --- a/src/adapter/data/JsonAdapter.ts +++ b/src/adapter/data/JsonAdapter.ts @@ -1,47 +1,90 @@ -import {IAdapter} from "../IAdapter"; +import { IAdapter } from "../IAdapter"; +import { ISerializer } from "./ISerializer"; +import { defaultSerializers } from "./Serializers"; export class JsonAdapter implements IAdapter { + private readonly adapter: IAdapter; + private readonly humanReadable: boolean; + private readonly serializerMap: ReadonlyMap; - private readonly adapter: IAdapter; - private readonly humanReadable: boolean; - private readonly dateRegex = new RegExp('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}', 'm') - private readonly parseDates: boolean; + /** + * @param adapter The underlying string adapter for reading/writing raw data + * @param humanReadable Whether to pretty-print the JSON output + * @param serializers Custom serializers for complex types (default: Date, Set, Map, RegExp, BigInt). + */ + constructor( + adapter: IAdapter, + humanReadable: boolean = false, + serializers: readonly ISerializer[] = defaultSerializers, + ) { + this.adapter = adapter; + this.humanReadable = humanReadable; + this.serializerMap = new Map(serializers.map((s) => [s.type, s])); + } - - constructor(adapter: IAdapter, humanReadable: boolean = false, parseDates: boolean = true) { - this.adapter = adapter; - this.humanReadable = humanReadable; - this.parseDates = parseDates; - } - - private replacer(key: string, value: any): any { - return value; + async readAsync(): Promise { + const data = await this.adapter.readAsync(); + if (data == null || data === "") { + await this.writeAsync({}); + return {}; } - - private reviver(key: string, value: any): any { - if (this.parseDates && typeof value == "string" && this.dateRegex.test(value)) { - return new Date(value); + const serializerMap = this.serializerMap; + const reviver = function (key: string, value: any): any { + if (value !== null && typeof value === "object" && "__type" in value) { + // Un-escape user data that naturally contained __type + if ( + typeof value.__type === "object" && + value.__type !== null && + "__escaped" in value.__type + ) { + return { ...value, __type: value.__type.__escaped }; } - return value; - } - - async readAsync(): Promise { - const data = await this.adapter.readAsync(); - if (data == null || data === '') { - await this.writeAsync({}); - return {}; + // Deserialize known serialized types + if ("__value" in value) { + const serializer = serializerMap.get(value.__type); + if (serializer) { + return serializer.deserialize(value.__value); + } } - return JSON.parse(data, this.reviver.bind(this)); - } + } + return value; + }; + return JSON.parse(data, reviver); + } - writeAsync(data: any): Promise { - let stringify = ''; - if (this.humanReadable) { - stringify = JSON.stringify(data, this.replacer.bind(this), 4) - } else { - stringify = JSON.stringify(data, this.replacer.bind(this)) + writeAsync(data: any): Promise { + const serializerMap = this.serializerMap; + const replacer = function (this: any, key: string, value: any): any { + const rawValue = this[key]; + for (const serializer of serializerMap.values()) { + if ( + rawValue !== null && + rawValue !== undefined && + serializer.test(rawValue) + ) { + return { + __type: serializer.type, + __value: serializer.serialize(rawValue), + }; } - return this.adapter.writeAsync(stringify); + } + // Escape plain objects that naturally contain __type to prevent false deserialization + if ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "__type" in value + ) { + return { ...value, __type: { __escaped: value.__type } }; + } + return value; + }; + let stringify = ""; + if (this.humanReadable) { + stringify = JSON.stringify(data, replacer, 4); + } else { + stringify = JSON.stringify(data, replacer); } - -} \ No newline at end of file + return this.adapter.writeAsync(stringify); + } +} diff --git a/src/adapter/data/Serializers.ts b/src/adapter/data/Serializers.ts new file mode 100644 index 00000000..7ddeb28b --- /dev/null +++ b/src/adapter/data/Serializers.ts @@ -0,0 +1,154 @@ +import {ISerializer} from "./ISerializer"; + +/** + * Serializer for JavaScript Date objects. + * Serializes a Date as an ISO 8601 string. + * + * @example + * ```json + * { "__type": "Date", "__value": "2023-01-01T00:00:00.000Z" } + * ``` + */ +export class DateSerializer implements ISerializer { + readonly type = "Date"; + + serialize(value: Date): string { + return value.toISOString(); + } + + deserialize(value: string): Date { + return new Date(value); + } + + test(value: any): boolean { + return value instanceof Date; + } +} + +/** + * Serializer for JavaScript Set objects. + * Serializes a Set as an array of its values. + * + * @example + * ```json + * { "__type": "Set", "__value": [1, 2, 3] } + * ``` + */ +export class SetSerializer implements ISerializer { + readonly type = "Set"; + + serialize(value: Set): any[] { + return [...value]; + } + + deserialize(value: any[]): Set { + return new Set(value); + } + + test(value: any): boolean { + return value instanceof Set; + } +} + +/** + * Serializer for JavaScript Map objects. + * Serializes a Map as an array of key-value pairs. + * + * @example + * ```json + * { "__type": "Map", "__value": [["key1", "value1"], ["key2", "value2"]] } + * ``` + */ +export class MapSerializer implements ISerializer { + readonly type = "Map"; + + serialize(value: Map): [any, any][] { + return [...value]; + } + + deserialize(value: [any, any][]): Map { + return new Map(value); + } + + test(value: any): boolean { + return value instanceof Map; + } +} + +/** + * Serializer for JavaScript RegExp objects. + * Serializes a RegExp as an object with source and flags. + * + * @example + * ```json + * { "__type": "RegExp", "__value": { "source": "hello\\s+world", "flags": "gi" } } + * ``` + */ +export class RegExpSerializer implements ISerializer { + readonly type = "RegExp"; + + serialize(value: RegExp): { source: string; flags: string } { + return {source: value.source, flags: value.flags}; + } + + deserialize(value: { source: string; flags: string }): RegExp { + return new RegExp(value.source, value.flags); + } + + test(value: any): boolean { + return value instanceof RegExp; + } +} + +/** + * Serializer for JavaScript BigInt values. + * Serializes a BigInt as a string representation. + * + * @example + * ```json + * { "__type": "BigInt", "__value": "9007199254740993" } + * ``` + */ +export class BigIntSerializer implements ISerializer { + readonly type = "BigInt"; + + serialize(value: bigint): string { + return value.toString(); + } + + deserialize(value: string): bigint { + return BigInt(value); + } + + test(value: any): boolean { + return typeof value === 'bigint'; + } +} + +/** + * Default serializers included with the JsonAdapter. + * Provides built-in support for Date, Set, Map, RegExp, and BigInt types. + * + * This array is frozen (immutable). To extend it with your own serializers, + * spread it into a new array: + * ```typescript + * import { defaultSerializers, ISerializer } from 'node-json-db'; + * + * const mySerializer: ISerializer = { + * type: "MyType", + * serialize: (value) => value.toJSON(), + * deserialize: (value) => MyType.fromJSON(value), + * test: (value) => value instanceof MyType, + * }; + * + * // Use with Config.addSerializer() or spread into a custom list: + * const serializers = [...defaultSerializers, mySerializer]; + * ``` + */ +export const defaultSerializers: readonly ISerializer[] = Object.freeze([ + new DateSerializer(), + new SetSerializer(), + new MapSerializer(), + new RegExpSerializer(), + new BigIntSerializer(), +]); diff --git a/src/lib/JsonDBConfig.ts b/src/lib/JsonDBConfig.ts index 36d52116..b1e42859 100644 --- a/src/lib/JsonDBConfig.ts +++ b/src/lib/JsonDBConfig.ts @@ -1,6 +1,8 @@ import * as path from "path"; import {IAdapter} from "../adapter/IAdapter"; +import {ISerializer} from "../adapter/data/ISerializer"; import {JsonAdapter} from "../adapter/data/JsonAdapter"; +import {defaultSerializers} from "../adapter/data/Serializers"; import {FileAdapter} from "../adapter/file/FileAdapter"; import { CipheredFileAdapter } from "../adapter/file/CipheredFileAdapter"; import { CipherKey, KeyObject } from 'crypto'; @@ -18,13 +20,14 @@ export class Config implements JsonDBConfig { separator: string syncOnSave: boolean humanReadable: boolean - parseDates: boolean + private _serializers: ISerializer[] + private _cipherKey?: CipherKey get filename(): string { return this._filename; } - constructor(filename: string, saveOnPush: boolean = true, humanReadable: boolean = false, separator: string = '/', syncOnSave: boolean = false, parseDates: boolean = true) { + constructor(filename: string, saveOnPush: boolean = true, humanReadable: boolean = false, separator: string = '/', syncOnSave: boolean = false) { this._filename = filename // Force json if no extension @@ -36,8 +39,36 @@ export class Config implements JsonDBConfig { this.separator = separator this.syncOnSave = syncOnSave this.humanReadable = humanReadable - this.parseDates = parseDates - this.adapter = new JsonAdapter(new FileAdapter(this._filename, syncOnSave), humanReadable, parseDates); + this._serializers = [...defaultSerializers] + this.adapter = new JsonAdapter(new FileAdapter(this._filename, syncOnSave), humanReadable, this._serializers); + } + + /** + * Add a custom serializer for handling additional types during JSON serialization. + * + * Custom serializers allow you to extend the built-in type support (Date, Set, Map, RegExp, BigInt) + * with your own types. Each serializer uses a `__type`/`__value` envelope pattern in the stored JSON. + * + * @param serializer - The serializer to add. Must implement the {@link ISerializer} interface. + * + * @example + * ```typescript + * import { Config, ISerializer } from 'node-json-db'; + * + * const urlSerializer: ISerializer = { + * type: "URL", + * serialize: (value: URL) => value.href, + * deserialize: (value: string) => new URL(value), + * test: (value: any) => value instanceof URL, + * }; + * + * const config = new Config('mydb'); + * config.addSerializer(urlSerializer); + * ``` + */ + addSerializer(serializer: ISerializer): void { + this._serializers = [...this._serializers, serializer]; + this._rebuildAdapter(); } /** @@ -90,7 +121,16 @@ export class Config implements JsonDBConfig { this._filename = baseName + '.enc.json'; } - this.adapter = new JsonAdapter(new CipheredFileAdapter(cipherKey, this._filename, this.syncOnSave), this.humanReadable, this.parseDates); + this._cipherKey = cipherKey; + this._rebuildAdapter(); + } + + private _rebuildAdapter(): void { + if (this._cipherKey) { + this.adapter = new JsonAdapter(new CipheredFileAdapter(this._cipherKey, this._filename, this.syncOnSave), this.humanReadable, this._serializers); + } else { + this.adapter = new JsonAdapter(new FileAdapter(this._filename, this.syncOnSave), this.humanReadable, this._serializers); + } } } diff --git a/test/adapter/Serializers.test.ts b/test/adapter/Serializers.test.ts new file mode 100644 index 00000000..bcea2993 --- /dev/null +++ b/test/adapter/Serializers.test.ts @@ -0,0 +1,203 @@ +import {DateSerializer, SetSerializer, MapSerializer, RegExpSerializer, BigIntSerializer, defaultSerializers} from "../../src/adapter/data/Serializers"; + +describe('Serializers', () => { + describe('DateSerializer', () => { + const serializer = new DateSerializer(); + + test('should have type "Date"', () => { + expect(serializer.type).toBe("Date"); + }) + + test('should test Date instances', () => { + expect(serializer.test(new Date())).toBe(true); + expect(serializer.test(new Date("2023-01-01"))).toBe(true); + expect(serializer.test("2023-01-01")).toBe(false); + expect(serializer.test(Date.now())).toBe(false); + expect(serializer.test(null)).toBe(false); + expect(serializer.test(undefined)).toBe(false); + }) + + test('should serialize Date to ISO string', () => { + const date = new Date("2023-06-15T12:00:00.000Z"); + const result = serializer.serialize(date); + expect(result).toBe("2023-06-15T12:00:00.000Z"); + }) + + test('should deserialize ISO string to Date', () => { + const result = serializer.deserialize("2023-06-15T12:00:00.000Z"); + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toBe("2023-06-15T12:00:00.000Z"); + }) + }) + + describe('SetSerializer', () => { + const serializer = new SetSerializer(); + + test('should have type "Set"', () => { + expect(serializer.type).toBe("Set"); + }) + + test('should test Set instances', () => { + expect(serializer.test(new Set())).toBe(true); + expect(serializer.test(new Set([1, 2]))).toBe(true); + expect(serializer.test([])).toBe(false); + expect(serializer.test({})).toBe(false); + expect(serializer.test("set")).toBe(false); + expect(serializer.test(null)).toBe(false); + expect(serializer.test(undefined)).toBe(false); + }) + + test('should serialize Set to array', () => { + const result = serializer.serialize(new Set([1, 2, 3])); + expect(result).toEqual([1, 2, 3]); + }) + + test('should serialize empty Set to empty array', () => { + const result = serializer.serialize(new Set()); + expect(result).toEqual([]); + }) + + test('should deserialize array to Set', () => { + const result = serializer.deserialize([1, 2, 3]); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(3); + expect(result.has(1)).toBe(true); + }) + + test('should deserialize empty array to empty Set', () => { + const result = serializer.deserialize([]); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(0); + }) + }) + + describe('MapSerializer', () => { + const serializer = new MapSerializer(); + + test('should have type "Map"', () => { + expect(serializer.type).toBe("Map"); + }) + + test('should test Map instances', () => { + expect(serializer.test(new Map())).toBe(true); + expect(serializer.test(new Map([["a", 1]]))).toBe(true); + expect(serializer.test([])).toBe(false); + expect(serializer.test({})).toBe(false); + expect(serializer.test("map")).toBe(false); + expect(serializer.test(null)).toBe(false); + expect(serializer.test(undefined)).toBe(false); + }) + + test('should serialize Map to array of entries', () => { + const result = serializer.serialize(new Map([["a", 1], ["b", 2]])); + expect(result).toEqual([["a", 1], ["b", 2]]); + }) + + test('should serialize empty Map to empty array', () => { + const result = serializer.serialize(new Map()); + expect(result).toEqual([]); + }) + + test('should deserialize array of entries to Map', () => { + const result = serializer.deserialize([["a", 1], ["b", 2]]); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.get("a")).toBe(1); + expect(result.get("b")).toBe(2); + }) + + test('should deserialize empty array to empty Map', () => { + const result = serializer.deserialize([]); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }) + }) + + describe('RegExpSerializer', () => { + const serializer = new RegExpSerializer(); + + test('should have type "RegExp"', () => { + expect(serializer.type).toBe("RegExp"); + }) + + test('should test RegExp instances', () => { + expect(serializer.test(/abc/)).toBe(true); + expect(serializer.test(new RegExp("abc"))).toBe(true); + expect(serializer.test("abc")).toBe(false); + expect(serializer.test({})).toBe(false); + expect(serializer.test(null)).toBe(false); + expect(serializer.test(undefined)).toBe(false); + }) + + test('should serialize RegExp to source and flags', () => { + const result = serializer.serialize(/hello\s+world/gi); + expect(result).toEqual({source: "hello\\s+world", flags: "gi"}); + }) + + test('should serialize RegExp with no flags', () => { + const result = serializer.serialize(/^test$/); + expect(result).toEqual({source: "^test$", flags: ""}); + }) + + test('should deserialize to RegExp', () => { + const result = serializer.deserialize({source: "hello\\s+world", flags: "gi"}); + expect(result).toBeInstanceOf(RegExp); + expect(result.source).toBe("hello\\s+world"); + expect(result.flags).toBe("gi"); + expect(result.test("Hello World")).toBe(true); + }) + }) + + describe('BigIntSerializer', () => { + const serializer = new BigIntSerializer(); + + test('should have type "BigInt"', () => { + expect(serializer.type).toBe("BigInt"); + }) + + test('should test bigint values', () => { + expect(serializer.test(BigInt(42))).toBe(true); + expect(serializer.test(BigInt("9007199254740993"))).toBe(true); + expect(serializer.test(42)).toBe(false); + expect(serializer.test("42")).toBe(false); + expect(serializer.test(null)).toBe(false); + expect(serializer.test(undefined)).toBe(false); + }) + + test('should serialize BigInt to string', () => { + const result = serializer.serialize(BigInt("9007199254740993")); + expect(result).toBe("9007199254740993"); + }) + + test('should deserialize string to BigInt', () => { + const result = serializer.deserialize("9007199254740993"); + expect(typeof result).toBe("bigint"); + expect(result).toBe(BigInt("9007199254740993")); + }) + }) + + describe('defaultSerializers', () => { + test('should contain all built-in serializers', () => { + expect(defaultSerializers).toHaveLength(5); + expect(defaultSerializers[0]).toBeInstanceOf(DateSerializer); + expect(defaultSerializers[1]).toBeInstanceOf(SetSerializer); + expect(defaultSerializers[2]).toBeInstanceOf(MapSerializer); + expect(defaultSerializers[3]).toBeInstanceOf(RegExpSerializer); + expect(defaultSerializers[4]).toBeInstanceOf(BigIntSerializer); + }) + + test('all serializers should implement ISerializer', () => { + for (const serializer of defaultSerializers) { + expect(typeof serializer.type).toBe("string"); + expect(typeof serializer.serialize).toBe("function"); + expect(typeof serializer.deserialize).toBe("function"); + expect(typeof serializer.test).toBe("function"); + } + }) + + test('all serializers should have unique type identifiers', () => { + const types = defaultSerializers.map(s => s.type); + expect(new Set(types).size).toBe(types.length); + }) + }) +}) diff --git a/test/adapter/adapters.test.ts b/test/adapter/adapters.test.ts index 34904951..2470ffe5 100644 --- a/test/adapter/adapters.test.ts +++ b/test/adapter/adapters.test.ts @@ -5,6 +5,7 @@ import {JsonAdapter} from "../../src/adapter/data/JsonAdapter"; import {IAdapter} from "../../src/adapter/IAdapter"; import {ConfigWithAdapter} from "../../src/lib/JsonDBConfig"; import {DataError} from "../../src/lib/Errors"; +import {defaultSerializers} from "../../src/adapter/data/Serializers"; function checkFileExists(file: string): Promise { return fs.promises.access(file, fs.constants.F_OK) @@ -150,16 +151,234 @@ describe('Adapter', () => { expect(readObject.test).toBe(data.test); }) - test('should not parse ISO date strings when parseDates is false', async () => { - const adapter = new JsonAdapter(new MemoryAdapter(), false, false); + test('should serialize and deserialize a Set', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + mySet: new Set([1, 2, 3]) + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.mySet).toBeInstanceOf(Set); + expect(readObject.mySet.size).toBe(3); + expect(readObject.mySet.has(1)).toBe(true); + expect(readObject.mySet.has(2)).toBe(true); + expect(readObject.mySet.has(3)).toBe(true); + }) + + test('should serialize and deserialize a Map', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + myMap: new Map([["a", 1], ["b", 2]]) + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.myMap).toBeInstanceOf(Map); + expect(readObject.myMap.size).toBe(2); + expect(readObject.myMap.get("a")).toBe(1); + expect(readObject.myMap.get("b")).toBe(2); + }) + + test('should serialize and deserialize a RegExp', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + pattern: /hello\s+world/gi + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.pattern).toBeInstanceOf(RegExp); + expect(readObject.pattern.source).toBe("hello\\s+world"); + expect(readObject.pattern.flags).toBe("gi"); + }) + + test('should serialize and deserialize a BigInt', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); const data = { - myDate: new Date().toISOString(), + big: BigInt("9007199254740993") } await adapter.writeAsync(data); const readObject = await adapter.readAsync(); - expect(readObject.myDate).toBe(data.myDate); - expect(typeof readObject.myDate).toBe("string"); + expect(typeof readObject.big).toBe("bigint"); + expect(readObject.big).toBe(BigInt("9007199254740993")); + }) + + test('should serialize and deserialize an empty Set', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + emptySet: new Set() + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.emptySet).toBeInstanceOf(Set); + expect(readObject.emptySet.size).toBe(0); + }) + + test('should serialize and deserialize an empty Map', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + emptyMap: new Map() + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.emptyMap).toBeInstanceOf(Map); + expect(readObject.emptyMap.size).toBe(0); + }) + + test('should serialize and deserialize nested complex types', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + mySet: new Set(["a", "b"]), + myMap: new Map([ + ["key1", new Set([10, 20])], + ["key2", "plain value"] + ]), + myDate: new Date(), + hello: "world" + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.mySet).toBeInstanceOf(Set); + expect(readObject.mySet.has("a")).toBe(true); + expect(readObject.myMap).toBeInstanceOf(Map); + expect(readObject.myMap.get("key1")).toBeInstanceOf(Set); + expect(readObject.myMap.get("key1").has(10)).toBe(true); + expect(readObject.myMap.get("key2")).toBe("plain value"); + expect(readObject.myDate).toBeInstanceOf(Date); + expect(readObject.hello).toBe("world"); + }) + + test('should preserve objects with __type that do not match any serializer', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + custom: {__type: "UnknownType", __value: "some data"} + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.custom.__type).toBe("UnknownType"); + expect(readObject.custom.__value).toBe("some data"); + }) + + test('should support custom serializers alongside defaults', async () => { + class Url { + constructor(public href: string) {} + toString() { return this.href; } + } + const urlSerializer = { + type: "URL", + serialize: (value: Url) => value.href, + deserialize: (value: string) => new Url(value), + test: (value: any) => value instanceof Url, + }; + const adapter = new JsonAdapter(new MemoryAdapter(), false, [...defaultSerializers, urlSerializer]); + const data = { + link: new Url("https://example.com"), + tags: new Set(["a", "b"]), + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.link).toBeInstanceOf(Url); + expect(readObject.link.href).toBe("https://example.com"); + expect(readObject.tags).toBeInstanceOf(Set); + expect(readObject.tags.has("a")).toBe(true); + }) + + test('should serialize Set with string values in human-readable format', async () => { + const memAdapter = new MemoryAdapter(); + const adapter = new JsonAdapter(memAdapter, true); + const data = { + tags: new Set(["typescript", "json"]) + } + + await adapter.writeAsync(data); + const raw = await memAdapter.readAsync(); + expect(raw).toContain('"__type": "Set"'); + expect(raw).toContain('"__value"'); + + const readObject = await adapter.readAsync(); + expect(readObject.tags).toBeInstanceOf(Set); + expect(readObject.tags.has("typescript")).toBe(true); + }) + + test('should serialize and deserialize all built-in types together', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + date: new Date("2023-06-15T12:00:00.000Z"), + set: new Set([1, "two", 3]), + map: new Map([["x", 10], ["y", new Date("2020-01-01")]]), + regex: /^test$/i, + bigint: BigInt("12345678901234567890"), + plain: "hello", + num: 42, + bool: true, + nil: null, + } + + await adapter.writeAsync(data); + const result = await adapter.readAsync(); + expect(result.date).toBeInstanceOf(Date); + expect(result.date.toISOString()).toBe("2023-06-15T12:00:00.000Z"); + expect(result.set).toBeInstanceOf(Set); + expect(result.set.size).toBe(3); + expect(result.map).toBeInstanceOf(Map); + expect(result.map.get("y")).toBeInstanceOf(Date); + expect(result.regex).toBeInstanceOf(RegExp); + expect(result.regex.test("TEST")).toBe(true); + expect(typeof result.bigint).toBe("bigint"); + expect(result.bigint).toBe(BigInt("12345678901234567890")); + expect(result.plain).toBe("hello"); + expect(result.num).toBe(42); + expect(result.bool).toBe(true); + expect(result.nil).toBeNull(); + }) + + test('should escape user data that has __type matching a serializer name', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + custom: {__type: "Set", __value: [1, 2, 3]} + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.custom.__type).toBe("Set"); + expect(readObject.custom.__value).toEqual([1, 2, 3]); + expect(readObject.custom).not.toBeInstanceOf(Set); + }) + + test('should escape nested user data with __type properties', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + outer: { + inner: {__type: "Map", __value: "not really a map"}, + real: new Set([1, 2]) + } + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.outer.inner.__type).toBe("Map"); + expect(readObject.outer.inner.__value).toBe("not really a map"); + expect(readObject.outer.real).toBeInstanceOf(Set); + expect(readObject.outer.real.has(1)).toBe(true); + }) + + test('should escape user data with __type that is a non-string value', async () => { + const adapter = new JsonAdapter(new MemoryAdapter(), false); + const data = { + custom: {__type: 42, extra: "data"} + } + + await adapter.writeAsync(data); + const readObject = await adapter.readAsync(); + expect(readObject.custom.__type).toBe(42); + expect(readObject.custom.extra).toBe("data"); }) }) }); @@ -170,5 +389,26 @@ describe('Adapter', () => { const result = await config.adapter.readAsync(); expect(result.test).toBe("test"); }); + + test('should add custom serializer via addSerializer', async () => { + const {Config} = require("../../src/lib/JsonDBConfig"); + class Url { + constructor(public href: string) {} + } + const urlSerializer = { + type: "URL", + serialize: (value: Url) => value.href, + deserialize: (value: string) => new Url(value), + test: (value: any) => value instanceof Url, + }; + const config = new Config('/tmp/test-serializer'); + config.addSerializer(urlSerializer); + await config.adapter.writeAsync({link: new Url("https://example.com"), tags: new Set(["a"])}); + const result = await config.adapter.readAsync(); + expect(result.link).toBeInstanceOf(Url); + expect(result.link.href).toBe("https://example.com"); + expect(result.tags).toBeInstanceOf(Set); + try { fs.rmSync("/tmp/test-serializer.json"); } catch {} + }); }); });