From e43331210f6dca1b357445e111f912d5375bdaa7 Mon Sep 17 00:00:00 2001 From: rowan Date: Sun, 18 May 2025 16:28:37 -0500 Subject: [PATCH] wip class serde --- package-lock.json | 8 - src/de/impl.ts | 26 +++ src/de/mixin.ts | 25 +-- src/decorator.ts | 4 + src/index.ts | 1 - src/json.ts | 481 ------------------------------------------- src/ser/impl.ts | 31 +-- src/ser/interface.ts | 7 +- test.ts | 45 ++++ 9 files changed, 96 insertions(+), 532 deletions(-) create mode 100644 src/de/impl.ts delete mode 100644 src/json.ts create mode 100644 test.ts diff --git a/package-lock.json b/package-lock.json index 08e30e5..f38c4fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "license": "ISC", "devDependencies": { - "@types/text-encoding": "^0.0.40", "esbuild": "^0.25.4", "typescript": "^5.8.3" } @@ -439,13 +438,6 @@ "node": ">=18" } }, - "node_modules/@types/text-encoding": { - "version": "0.0.40", - "resolved": "https://registry.npmjs.org/@types/text-encoding/-/text-encoding-0.0.40.tgz", - "integrity": "sha512-dHzoIdwBfY7jcSTTt6XBkaeiuFQAQD7r/7aJySKDdHkYBCDOvs9jPVt4NYXuwBMn89PP6gSd29WubIS19wTiXg==", - "dev": true, - "license": "MIT" - }, "node_modules/esbuild": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", diff --git a/src/de/impl.ts b/src/de/impl.ts new file mode 100644 index 0000000..27ae2a4 --- /dev/null +++ b/src/de/impl.ts @@ -0,0 +1,26 @@ +import { SerdeOptions, Stage } from '../options' +import { GenericVisitor } from './generic' +import { Deserialize, Deserializer } from './interface' + +type DeserializeConstructor = Deserialize & { new(): Deserialize } + +export function deserializeWith>(deserializer: D, into: E, options: SerdeOptions): T { + const visitor = new GenericVisitor() + const obj = deserializer.deserializeObject(visitor) as any + const target = new into() + const newObject = {} as any + + for (const property in target) { + const name = options.getPropertyName(property, Stage.Deserialize) + const value = obj[name] || options.getDefault(property) + newObject[property] = value + delete obj[name] + } + + if (options.options.denyUnknownFields && Object.keys(obj).length > 0) { + throw new TypeError(`Unexpected fields: ${Object.keys(obj).join(', ')}`) + } + + return Object.assign(target, newObject) +} + diff --git a/src/de/mixin.ts b/src/de/mixin.ts index 0a4b844..019d338 100644 --- a/src/de/mixin.ts +++ b/src/de/mixin.ts @@ -1,31 +1,8 @@ import { getMetadata, Serde } from '../decorator' -import { SerdeOptions, Stage } from '../options' import { Constructor, staticImplements } from '../utils' -import { GenericVisitor } from './generic' +import { deserializeWith } from './impl' import { Deserialize, Deserializer } from './interface' -type DeserializeConstructor = Deserialize & { new(): Deserialize } - -function deserializeWith>(deserializer: D, into: E, options: SerdeOptions): T { - const visitor = new GenericVisitor() - const obj = deserializer.deserializeObject(visitor) as any - const target = new into() - const newObject = {} as any - - for (const property in target) { - const name = options.getPropertyName(property, Stage.Deserialize) - const value = obj[name] || options.getDefault(property) - newObject[property] = value - delete obj[name] - } - - if (options.options.denyUnknownFields && Object.keys(obj).length > 0) { - throw new TypeError(`Unexpected fields: ${Object.keys(obj).join(', ')}`) - } - - return Object.assign(target, newObject) -} - export function deserialize(constructor: C) { @staticImplements>() class Deserializable extends constructor { diff --git a/src/decorator.ts b/src/decorator.ts index dc652c1..a45eb77 100644 --- a/src/decorator.ts +++ b/src/decorator.ts @@ -46,6 +46,10 @@ export function getMetadata(value: any) { export function register(registry: Registry = GlobalRegistry) { return function(target: any) { + if (target[Serde] == null) { + target[Serde] = SerdeOptions.from(target) + } + registry.add(target) return target } diff --git a/src/index.ts b/src/index.ts index 1ce5f3e..2385a75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ export * as ser from './ser' export * as de from './de' -export * as json from './json' export * from './decorator' export * from './options' diff --git a/src/json.ts b/src/json.ts deleted file mode 100644 index 3159992..0000000 --- a/src/json.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { DefaultIterableAccessImpl, DefaultMapAccessImpl, Deserialize, Deserializer, IterableAccess, MapAccess, Visitor } from './de' -import { GlobalRegistry, Registry } from './registry' -import { IterableSerializer, ObjectSerializer, Serializable, Serializer, serializeWith } from './ser' -import { mixin, Nullable } from './utils' - -export function toString(value: any): string { - const serializer = new JSONSerializer() - serializeWith(serializer, value) - return serializer.output -} - -export function fromString>(value: string, into: D): T { - const deserializer = JSONDeserializer.fromString(value) - return into.deserialize(deserializer) -} - -type Byte = number - -const clamp = (value: number, min: number, max: number): number => { - return Math.min(Math.max(value, min), max) -} - -const isNumeric = (value: any): value is number => { - return !isNaN(value) -} - -const isNumericToken = (value: Byte) => { - return value === Token.Period || value === Token.Hyphen || Token.Digit.includes(value) -} - -interface Predicate { - (value: T): boolean -} - -const encoder = new TextEncoder() -const b = (strings: TemplateStringsArray) => encoder.encode(strings[0]) -const char = (strings: TemplateStringsArray) => b(strings)[0] - -const Literal = Object.freeze({ - True: b`true`, - False: b`false` -} as const) - -const Token = Object.freeze({ - Space: char` `, - LeftCurly: char`{`, - RightCurly: char`}`, - LeftSquare: char`[`, - RightSquare: char`]`, - Quote: char`"`, - ForwardSlash: char`\\`, - Digit: b`0123456789`, - Hyphen: char`-`, - Period: char`.`, - Comma: char`,`, - Colon: char`:` -} as const) - - -export interface CommaSeparated extends MapAccess, IterableAccess { } -@mixin(DefaultMapAccessImpl) -@mixin(DefaultIterableAccessImpl) -export class CommaSeparated implements MapAccess, IterableAccess { - private readonly de: JSONDeserializer - private first: boolean = true - - constructor(deserializer: JSONDeserializer) { - this.de = deserializer - } - - nextKeySeed>(seed: K): Nullable { - if (this.de.buffer.peek().next() === Token.RightCurly) { - return - } - - if (!this.first) { - const take = this.de.buffer.take() - if (take.next() !== Token.Comma) { - throw unexpected(',', take.toString(), this.de.buffer.position) - } - } - - this.first = false - return seed.deserialize(this.de) - } - - nextValueSeed>(seed: V): Nullable { - const next = this.de.buffer.next() - if (next !== Token.Colon) { - throw unexpected(':', next.toString(), this.de.buffer.position) - } - - return seed.deserialize(this.de) - } - - nextElementSeed>(seed: I): Nullable { - if (this.de.buffer.peek().next() === Token.RightSquare) { - return - } - - if (!this.first) { - const take = this.de.buffer.take() - if (take.next() !== Token.Comma) { - throw unexpected(',', take.toString(), this.de.buffer.position) - } - } - - this.first = false - return seed.deserialize(this.de) - } -} - -class StringBuffer { - private readonly view: Uint8Array - private index: number = 0 - private readonly encoder: TextEncoder - private readonly decoder: TextDecoder - - get position() { - return this.index - } - - get length() { - return this.view.byteLength - } - - constructor(view: Uint8Array, encoder: TextEncoder = new TextEncoder(), decoder: TextDecoder = new TextDecoder()) { - this.view = view - this.encoder = encoder - this.decoder = decoder - } - - static fromArrayBuffer(value: ArrayBuffer, encoder?: TextEncoder, decoder?: TextDecoder): StringBuffer { - return new this(new Uint8Array(value), encoder, decoder) - } - - static fromString(value: string, encoder: TextEncoder = new TextEncoder(), decoder?: TextDecoder): StringBuffer { - return this.fromArrayBuffer( - encoder.encode(value), - encoder, - decoder - ) - } - - next() { - const value = this.view[this.index] - this.index += 1 - return value - } - - nextChar() { - return this.take().toString() - } - - done(): boolean { - return this.index >= this.view.byteLength - } - - toBytes() { - return this.view.slice(this.index) - } - - toString() { - return this.decoder.decode(this.toBytes()) - } - - take(limit: number = 1): StringBuffer { - const bytes = this.peek(limit) - this.index += limit - return bytes - } - - at(index: number) { - return this.view[this.index + index] - } - - takeWhile(fn: Predicate): StringBuffer { - let index = 0 - - while (!this.done() && fn(this.at(index))) { - index += 1 - } - - return this.take(index) - } - - drop(limit: number) { - this.index += limit - return this - } - - peek(limit: number = 1): StringBuffer { - const index = this.index - return this.slice(index, index + limit) - } - - startsWith(value: string | ArrayBufferLike): boolean { - if (typeof value === 'string') { - return this.startsWith(this.encoder.encode(value)) - } - - const length = value.byteLength - const bytes = new Uint8Array(value) - - return this.peek(length).toBytes().every((v, i) => v === bytes[i]) - } - - slice(start?: number, end?: number) { - return new StringBuffer( - this.view.subarray(start, end), - this.encoder, - this.decoder - ) - } - - indexOf(value: number | ArrayBufferLike, start: number = 0) { - const search = new Uint8Array(isNumeric(value) ? [value] : value) - start = clamp(start, this.index, this.length) - const bytes = this.slice(start) - - for (let i = 0, len = bytes.length; i < len; i++) { - if (bytes.at(i) === search[0] && bytes.slice(i).startsWith(search)) { - return i - } - } - - return -1 - } -} - -class JSONObjectSerializer implements ObjectSerializer { - private ser: JSONSerializer - private first: boolean = true - - constructor(serializer: JSONSerializer) { - this.ser = serializer - serializer.write('{') - } - - serializeKey(key: U): void { - if (!this.first) { - this.ser.write(',') - } else { - this.first = false - } - - serializeWith(this.ser, key) - this.ser.write(':') - } - - serializeValue(value: U): void { - serializeWith(this.ser, value) - } - - end(): void { - this.ser.write('}') - } -} - -class JSONIterableSerializer implements IterableSerializer { - private ser: JSONSerializer - private first: boolean = true - - constructor(serializer: JSONSerializer) { - this.ser = serializer - serializer.write('[') - } - - serializeElement(element: U): void { - if (!this.first) { - this.ser.write(',') - } else { - this.first = false - } - - serializeWith(this.ser, element) - } - - end(): void { - this.ser.write(']') - } -} - -export class JSONSerializer implements Serializer { - output: string = '' - - write(value: string) { - this.output += value - } - - serializeString(value: string) { - this.write(`"${value}"`) - } - - serializeBoolean(value: boolean): void { - this.write(value.toString()) - } - - serializeSymbol(value: symbol): void { - const key = Symbol.keyFor(value) - if (key) { - this.write(key) - } else { - return this.serializeString(value.toString()) - } - } - - serializeObject(): ObjectSerializer { - return new JSONObjectSerializer(this) - } - - serializeNumber(value: number) { - this.write(value.toString()) - } - - serializeBigInt(value: bigint) { - this.write(value.toString()) - } - - serializeIterable(): IterableSerializer { - return new JSONIterableSerializer(this) - } - - serializeNull() { - return this.write('null') - } -} - -const unexpected = (expected: string, actual: string, position: number) => new SyntaxError(`Expected ${expected} at position ${position} (got '${actual}')`) - -export class JSONDeserializer implements Deserializer { - private readonly registry: Registry - readonly buffer: StringBuffer - - constructor(buffer: StringBuffer, registry: Registry = GlobalRegistry) { - this.buffer = buffer - this.registry = registry - } - - static fromString(value: string): JSONDeserializer { - return new this(StringBuffer.fromString(value)) - } - - deserializeAny>(visitor: V): T { - const peek = this.buffer.peek() - const nextByte = peek.take() - const byte = nextByte.next() - - switch (true) { - case b`n`.includes(byte): - return this.deserializeNull(visitor) - case b`tf`.includes(byte): - return this.deserializeBoolean(visitor) - case b`-0123456789`.includes(byte): - return this.deserializeNumber(visitor) - case Token.Quote === byte: - return this.deserializeString(visitor) - case Token.LeftSquare === byte: - return this.deserializeIterable(visitor) - case Token.LeftCurly === byte: - return this.deserializeObject(visitor) - default: - throw new SyntaxError(`Invalid syntax at position ${this.buffer.position}: "${nextByte.toString()}"`) - } - } - - deserializeNull>(visitor: V): T { - if (this.buffer.startsWith('null')) { - this.buffer.take(4) - } - - return visitor.visitNull() - } - - deserializeObject>(visitor: V): T { - let next = this.buffer.take() - if (next.next() === Token.LeftCurly) { - - const value = visitor.visitObject(new CommaSeparated(this)) - - next = this.buffer.take() - if (next.next() === Token.RightCurly) { - return value - } else { - throw unexpected('}', next.toString(), this.buffer.position) - } - } else { - throw unexpected('{', next.toString(), this.buffer.position) - } - } - - - deserializeClass>(name: string, fields: string[], visitor: V): T { - const cls = this.registry.get(name) - // TODO: deserialize name representing class name, use the existing deserializeWith logic - // to deserialize the class here. likely, deserialize name and then return a CommaSeparated - // object - } - - deserializeString>(visitor: V): T { - const next = this.buffer.take() - if (next.next() === Token.Quote) { - let index = -1 - - do { - index = this.buffer.indexOf(Token.Quote, index) - } while (index > -1 && this.buffer.at(index - 1) === Token.ForwardSlash) - - if (index === -1) { - throw new SyntaxError('Unterminated string literal') - } - - const bytes = this.buffer.take(index) - this.buffer.take() - return visitor.visitString(bytes.toString()) - } else { - throw unexpected('"', next.toString(), this.buffer.position) - } - } - - deserializeNumber>(visitor: V): T { - const next = this.buffer.peek().next() - - if (isNumericToken(next)) { - const digits = this.buffer.takeWhile(isNumericToken).toString() - if (digits.length >= 16) { - const number = BigInt(digits) - return visitor.visitBigInt(number) - } else if (digits.length > 0) { - let number = parseInt(digits.toString(), 10) - return visitor.visitNumber(number) - } - } - - throw unexpected('"-", ".", or 0..=9', next.toString(), this.buffer.position) - } - - deserializeBigInt>(visitor: V): T { - return this.deserializeNumber(visitor) - } - - deserializeBoolean>(visitor: V): T { - const next = this.buffer.next() - let length = 3 - - switch (next) { - case Literal.False[0]: - length = 4 - case Literal.True[0]: - break - default: - throw unexpected('"true" or "false"', this.buffer.next().toString(), this.buffer.position) - } - - this.buffer.take(length) - return visitor.visitBoolean(length === 3) - } - - deserializeSymbol>(_visitor: V): T { - throw new Error('Method not implemented.') - } - - deserializeIterable>(visitor: V): T { - let next = this.buffer.take() - if (next.next() === Token.LeftSquare) { - - const value = visitor.visitIterable!(new CommaSeparated(this)) - - next = this.buffer.take() - if (next.next() === Token.RightSquare) { - return value - } else { - throw unexpected(']', next.toString(), this.buffer.position) - } - } else { - throw unexpected('[', next.toString(), this.buffer.position) - } - } -} - - diff --git a/src/ser/impl.ts b/src/ser/impl.ts index cbac447..9eee79a 100644 --- a/src/ser/impl.ts +++ b/src/ser/impl.ts @@ -1,33 +1,37 @@ +import { IterableSerializer, ObjectSerializer, Serializable, Serializer } from './interface' import { Serde } from '../decorator' import { SerdeOptions, Stage } from '../options' -import { ifNull, isFunction, isIterable, Nullable, orElse } from '../utils' -import { IterableSerializer, Serializable, Serializer } from './interface' +import { ifNull, isFunction, isIterable, isPlainObject, Nullable, orElse } from '../utils' const unhandledType = (serializer: any, value: any) => new TypeError(`'${serializer.constructor.name}' has no method for value type '${typeof value}'`) -function serializeEntries>(serializer: Serializer, value: E, options?: SerdeOptions) { +function serializeEntries>(serializer: ObjectSerializer, value: E, options?: SerdeOptions) { let state - const objectSerializer = serializer.serializeObject!() - for (const [key, val] of value) { if (options?.shouldSkipSerializing(key, val)) { continue } const name = options?.getPropertyName(key as string, Stage.Serialize) ?? key - state = objectSerializer.serializeKey(name) - state = objectSerializer.serializeValue(val) + console.log('prop name for', key, name) + state = serializer.serializeKey(name) + state = serializer.serializeValue(val) } - return objectSerializer.end() + return serializer.end() +} + +function serializeClass>(serializer: Serializer, value: R, options?: SerdeOptions) { + const classSerializer = serializer.serializeClass!(value.constructor.name) + return serializeEntries(classSerializer, Object.entries(value) as Iterable<[K, V]>, options) } function serializeObject>(serializer: Serializer, value: R, options?: SerdeOptions) { - return serializeEntries(serializer, Object.entries(value) as Iterable<[K, V]>, options) + return serializeEntries(serializer.serializeObject!(), Object.entries(value) as Iterable<[K, V]>, options) } -function serializeIter>(serializer: IterableSerializer, value: V) { +function serializeIter>(serializer: IterableSerializer, value: V, options?: SerdeOptions) { let state for (const val of value) { @@ -61,10 +65,13 @@ export function serializeWith(serializer: Serializer, value: Serializable, case 'undefined': return serialize(serializer.serializeNull) case 'object': + const options = optionsGetter(value) if (isIterable(value) && isFunction(serializer.serializeIterable)) { - return serializeIter(serializer.serializeIterable(), value) + return serializeIter(serializer.serializeIterable(), value, options) + } if (!isPlainObject(value)) { + return serializeClass(serializer, value as Record, options) } else if (isFunction(serializer.serializeObject)) { - return serializeObject(serializer, value as Record, optionsGetter(value)) + return serializeObject(serializer, value as Record, options) } // deliberate fallthrough when the above fail default: return serializeAny(value) diff --git a/src/ser/interface.ts b/src/ser/interface.ts index 70874fb..fd1c3f2 100644 --- a/src/ser/interface.ts +++ b/src/ser/interface.ts @@ -11,11 +11,6 @@ export interface IterableSerializer { end(): T } -export interface ClassSerializer { - serializeField(name: PropertyKey, value: U): T - end(): T -} - const TypeSerializerMethods = [ 'serializeString', 'serializeNumber', @@ -39,7 +34,7 @@ interface TypeSerializer { serializeObject(): ObjectSerializer // serializeMap?(): ObjectSerializer serializeIterable?(): IterableSerializer - serializeClass(name: PropertyKey): ClassSerializer + serializeClass(name: PropertyKey): ObjectSerializer } const AnySerializerMethods = ['serializeAny'] diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..796e02b --- /dev/null +++ b/test.ts @@ -0,0 +1,45 @@ +//import { register, serde } from './src' +//import { CaseConvention } from './src/case' +//import { deserialize } from './src/de' +//import { fromString, toString } from './src/json' +//import { serialize } from './src/ser' +//import { Nullable } from './src/utils' +// +//@serialize +//@deserialize +//@register() +//@serde({ renameAll: CaseConvention.PascalCase }) +//class InnerStruct { +// private value: string +// +// @serde({ skip: true }) +// private metadata: any +// +// constructor(v: string) { +// this.value = v +// } +//} +// +//@serialize +//@deserialize +//@register() +//@serde({ renameAll: CaseConvention.SnakeCase }) +//class TestStruct { +// aNumber: number = 69 +// aString: Nullable +// +// @serde({ skip: { serializing: { if: (v: boolean) => v } } }) +// aBoolean: boolean = false +// +// innerStruct: InnerStruct +// +// constructor(str: string) { +// this.innerStruct = new InnerStruct(str) +// } +//} +// +//const test = new TestStruct('hi :3') +//const ser = toString(test) +//console.log(ser) +//const de = fromString(ser, TestStruct) +//console.log(de)