Skip to content

Commit

Permalink
feat: basic support for Ctx{Get,Set} decorators
Browse files Browse the repository at this point in the history
`Ctx` decorators are a way to share informations across
objects during the reading of the binary file definition.

In the following example, a streaming protocol that receives records
of arbitrary length is defined.
Records have two different type a 'definition' or a 'data' both use an
'id' to identify themself. The definition defines the size of the data
message they define by using `CtxSet` to store that size into the
context. The data message uses `CtxGet` to fetch its size defined
previously by the definition.

```typescript
class RecordDefinition {
    @relation(PrimitiveSymbol.u8)
    id: number

    @CtxSet(_ => `Definition.${_.id}`)
    @relation(PrimitiveSymbol.u8)
    size: number
}

class RecordMessage {
    @relation(PrimitiveSymbol.u8)
    id: number

    @CtxGet(_ => `Definition.${_.id}`)
    _size: number

    @count('_size')
    @relation(PrimitiveSymbol.u8)
    data
}

class Record {
    @relation(PrimitiveSymbol.u8)
    type: number

    @choice('type', {
        0x00: RecordDefinition,
        0x01: RecordMessage,
    })
    message: RecordDefinition | RecordMessage
}

class Protocol {
    @until(EOF)
    records: Record[]
}
```
  • Loading branch information
tperale committed Dec 12, 2024
1 parent 3061928 commit 89a5203
Show file tree
Hide file tree
Showing 6 changed files with 395 additions and 5 deletions.
30 changes: 27 additions & 3 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { EOF, PrimitiveSymbol, type InstantiableObject } from '../types'
import { binread } from '../reader'
import { withBinspectorContext } from '../context'
import { BinaryReader, BinaryCursorEndianness } from '../cursor'
import { CtxGet, CtxSet } from '../decorators/context'

function expectReadTest<Target> (buffer: Array<number>, ObjectDefinition: InstantiableObject<Target>, endian: BinaryCursorEndianness = BinaryCursorEndianness.BigEndian, ...args: any[]) {
return expect(binread(new BinaryReader(new Uint8Array(buffer).buffer, endian), ObjectDefinition, ...args))
function expectReadTest<Target> (buffer: Array<number>, ObjectDefinition: InstantiableObject<Target>, endian: BinaryCursorEndianness = BinaryCursorEndianness.BigEndian, ctx = {}, ...args: any[]) {
return expect(binread(new BinaryReader(new Uint8Array(buffer).buffer, endian), ObjectDefinition, ctx, ...args))
}

function expectReadTestToThrow<Target> (buffer: Array<number>, ObjectDefinition: InstantiableObject<Target>) {
Expand Down Expand Up @@ -536,7 +537,7 @@ describe('Reading binary definition with PrePost decorators', () => {
}
}

expectReadTest([0x01, 0x02, 0x03, 0x04], Protocol, BinaryCursorEndianness.BigEndian, 2).toMatchObject({
expectReadTest([0x01, 0x02, 0x03, 0x04], Protocol, BinaryCursorEndianness.BigEndian, {}, 2).toMatchObject({
value: 0x03,
})
})
Expand Down Expand Up @@ -652,6 +653,29 @@ describe('Reading binary definition with PrePost decorators', () => {
})
})

describe('Reading binary definition with Ctx decorators', () => {
it('should ', () => {
class Protocol {
@CtxGet('Settings.Count')
data_type: number

@CtxSet('Settings.Value')
@Count('data_type')
@Relation(PrimitiveSymbol.u8)
foo: number
}

const ctx = { Settings: { Count: 3 } }

expectReadTest([0x01, 0x02, 0x03], Protocol, BinaryCursorEndianness.LittleEndian, ctx).toMatchObject({
data_type: 3,
foo: [1, 2, 3],
})

expect(ctx).toMatchObject({ Settings: { Count: 3, Value: [1, 2, 3] } })
})
})

describe('Reading a relation to an empty definition', () => {
it('should throw an error', () => {
class Header {
Expand Down
72 changes: 72 additions & 0 deletions src/decorators/__tests__/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect } from '@jest/globals'
import { CtxSet, CtxGet, useContextGet, useContextSet } from '../context'
import Meta from '../../metadatas'

function testContextGet (TargetClass: new () => any, field: string, ctx: object, expected: any) {
const instance = new TargetClass()

const contexts = Meta.getContext(TargetClass[Symbol.metadata] as DecoratorMetadataObject, field)
expect(useContextGet(contexts, instance, ctx)).toEqual(expected)
}

function testContextSet (TargetClass: new () => any, field: string, value: any, ctx: object) {
const instance = new TargetClass()

const contexts = Meta.getContext(TargetClass[Symbol.metadata] as DecoratorMetadataObject, field)
useContextSet(contexts, value, instance, ctx)
}

describe('@Ctx: functions', () => {
it('should modify the ctx object', () => {
class TestClass {
@CtxSet('test')
data: number
}
const ctx: any = {}
const VALUE = 1
testContextSet(TestClass, 'data', VALUE, ctx)
expect(ctx.test).toEqual(VALUE)
})
it('should modify the ctx object with recursive accessors', () => {
class TestClass {
@CtxSet('foo.bar.1')
data: number

@CtxSet('foo.bar.2')
data_2: number
}
const ctx: any = {}
const VALUE = 1
testContextSet(TestClass, 'data', VALUE, ctx)
testContextSet(TestClass, 'data_2', VALUE, ctx)
expect(ctx.foo.bar[1]).toEqual(VALUE)
expect(ctx.foo.bar[2]).toEqual(VALUE)
})
it('should get value from the ctx object', () => {
class TestClass {
@CtxGet('test')
data: number
}
const VALUE = 1
const ctx = { test: VALUE }
testContextGet(TestClass, 'data', ctx, VALUE)
})
it('should get value from the ctx object with recursive accessors', () => {
class TestClass {
@CtxGet('foo.bar')
data: number
}
const VALUE = 1
const ctx = { foo: { bar: VALUE } }
testContextGet(TestClass, 'data', ctx, VALUE)
})
it('should throw when accessing non existing properties', () => {
class TestClass {
@CtxGet('test.foo')
data: number
}
const VALUE = 1
const ctx = { test: VALUE }
expect(() => testContextGet(TestClass, 'data', ctx, VALUE)).toThrow()
})
})
264 changes: 264 additions & 0 deletions src/decorators/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/**
* Module definition of {@link Context} property decorators.
*
* The {@link Context} decorators provides functions to read/write the context
* shared among the binary objects during the reading phase.
*
* @module Context
*/
import { Context, DecoratorType } from '../types'
import { createPropertyMetaDescriptor, PropertyMetaDescriptor, recursiveGet, StringFormattedRecursiveKeyOf } from './common'
import { Relation } from './primitive'
import Meta from '../metadatas'

export const CtxSymbol = Symbol('context')

export type GlobalCtx = object

export type CtxKeyFunction<This> = (targetInstance: This) => string

export enum CtxType {
CtxGetter,
CtxSetter,
}

export interface CtxOptions {
/**
* Ensures that a relation exists before defining the Transformer decorator.
*/
base_type: 'array' | undefined
}

export const CtxOptionsDefault = {
base_type: undefined,
}

/**
* Ctx metadata type definition.
*
* @extends {PropertyMetaDescriptor}
*/
export interface Ctx<This> extends PropertyMetaDescriptor<This> {
options: CtxOptions
/**
* Function that retrieve the key to access the context
*/
keyGetter: CtxKeyFunction<This>
/**
* Context type: retrieve a value or set a value
*/
func_type: CtxType
}

function ctxPropertyFunctionDecoratorFactory<This extends object, Value> (name: string, func_type: CtxType, keyGetter: string | CtxKeyFunction<This>, opt: Partial<CtxOptions> = CtxOptionsDefault): DecoratorType<This, Value> {
const options = { ...CtxOptionsDefault, ...opt }

return function (_: any, context: Context<This, Value>) {
const propertyKey = context.name as keyof This
if (!Meta.isFieldDecorated(context.metadata, propertyKey)) {
// Create an empty relation that wont be read.
Relation()(_, context)
}
const ctx: Ctx<This> = {
...createPropertyMetaDescriptor(CtxSymbol, name, context.metadata, propertyKey),
func_type,
options,
keyGetter: typeof keyGetter === 'string' ? () => keyGetter : keyGetter,
}

Meta.setContext(context.metadata, propertyKey, ctx)
}
}

/**
* `@CtxGet` decorator retrieve a value based on the key passed as argument
* from a 'context' shared during the reading phase.
*
* @example
*
* In the following example, a streaming protocol that receives records
* of arbitrary length is defined.
* Records have two different type a 'definition' or a 'data' both use an
* 'id' to identify themself. The definition defines the size of the data
* message they define by using `CtxSet` to store that size into the
* context. The data message uses `CtxGet` to fetch its size defined
* previously by the definition.
*
* ```typescript
* class RecordDefinition {
* @Relation(PrimitiveSymbol.u8)
* id: number
*
* @CtxSet(_ => `Definition.${_.id}`)
* @Relation(PrimitiveSymbol.u8)
* size: number
* }
*
* class RecordMessage {
* @Relation(PrimitiveSymbol.u8)
* id: number
*
* @CtxGet(_ => `Definition.${_.id}`)
* _size: number
*
* @Count('_size')
* @Relation(PrimitiveSymbol.u8)
* data
* }
*
* class Record {
* @Relation(PrimitiveSymbol.u8)
* type: number
*
* @Choice('type', {
* 0x00: RecordDefinition,
* 0x01: RecordMessage,
* })
* message: RecordDefinition | RecordMessage
* }
*
* class Protocol {
* @Until(EOF)
* records: Record[]
* }
* ```
*
* @param {CtxKeyFunction<This>} keyGetter Either a string formatted as
* recursive key or a function that returns that string based on the
* instance value.
* @param {Partial<CtxOptions>} [opt] Optional configuration.
* @returns {DecoratorType<This, Value>} The property decorator function.
*
* @category Decorators
*/
export function CtxGet<This extends object, Value> (keyGetter: CtxKeyFunction<This> | string, opt?: Partial<CtxOptions>): DecoratorType<This, Value> {
return ctxPropertyFunctionDecoratorFactory<This, Value> ('ctx-get', CtxType.CtxGetter, keyGetter, opt)
}

/**
* `@CtxSet` decorator set the value of the decorated property into a
* shared 'context' during the reading phase.
*
* @example
*
* In the following example, a streaming protocol that receives records
* of arbitrary length is defined.
* Records have two different type a 'definition' or a 'data' both use an
* 'id' to identify themself. The definition defines the size of the data
* message they define by using `CtxSet` to store that size into the
* context. The data message uses `CtxGet` to fetch its size defined
* previously by the definition.
*
* ```typescript
* class RecordDefinition {
* @Relation(PrimitiveSymbol.u8)
* id: number
*
* @CtxSet(_ => `Definition.${_.id}`)
* @Relation(PrimitiveSymbol.u8)
* size: number
* }
*
* class RecordMessage {
* @Relation(PrimitiveSymbol.u8)
* id: number
*
* @CtxGet(_ => `Definition.${_.id}`)
* _size: number
*
* @Count('_size')
* @Relation(PrimitiveSymbol.u8)
* data
* }
*
* class Record {
* @Relation(PrimitiveSymbol.u8)
* type: number
*
* @Choice('type', {
* 0x00: RecordDefinition,
* 0x01: RecordMessage,
* })
* message: RecordDefinition | RecordMessage
* }
*
* class Protocol {
* @Until(EOF)
* records: Record[]
* }
* ```
*
* @param {CtxKeyFunction<This>} keyGetter Either a string formatted as
* recursive key or a function that returns that string based on the
* instance value.
* @param {Partial<CtxOptions>} [opt] Optional configuration.
* @returns {DecoratorType<This, Value>} The property decorator function.
*
* @category Decorators
*/
export function CtxSet<This extends object, Value> (keyGetter: CtxKeyFunction<This> | string, opt?: Partial<CtxOptions>): DecoratorType<This, Value> {
return ctxPropertyFunctionDecoratorFactory<This, Value> ('ctx-set', CtxType.CtxSetter, keyGetter, opt)
}

/**
* useContextGet execute an array of `Ctx` decorator metadata.
*
* @typeParam This The type of the class the decorator is applied to.
*
* @param {Array<Ctx<This>>} metaCtx An array of context function to apply.
* @param {This} targetInstance The target class instance containing the property.
* @param {GlobalCtx} ctx The shared context reference.
* @returns {any} The context retrieved.
*
* @category Advanced Use
*/
export function useContextGet<This> (metaCtx: Array<Ctx<This>>, targetInstance: This, ctx: GlobalCtx): any {
const values = metaCtx.filter(x => x.func_type === CtxType.CtxGetter).map((x) => {
const key = x.keyGetter(targetInstance)

// TODO future version should pass some typing to the context
return key.split('.').reduce((acc: any, key: string) => {
if (Object.prototype.hasOwnProperty.call(acc, key) === false) {
throw new ReferenceError(`Can't retrieve key: '${key}' from ctx: ${JSON.stringify(ctx)}.`)
}
return acc[key]
}, ctx)
})

return values.length === 1 ? values[0] : values
}

/**
* useContextSet execute an array of `Ctx` decorator metadata.
*
* @typeParam This The type of the class the decorator is applied to.
*
* @param {Array<Ctx<This>>} metaCtx An array of context function to apply.
* @param {This} targetInstance The target class instance containing the property.
* @param {GlobalCtx} ctx The shared context reference.
* @returns {any} The context retrieved.
*
* @category Advanced Use
*/
export function useContextSet<This> (metaCtx: Array<Ctx<This>>, propertyValue: any, targetInstance: This, ctx: GlobalCtx): void {
metaCtx.filter(x => x.func_type === CtxType.CtxSetter).forEach((x) => {
const key = x.keyGetter(targetInstance)
const accessors = key.split('.')
const lastKey = accessors[accessors.length - 1]
const ref = accessors.slice(0, -1).reduce((acc: any, key: string) => {
if (Object.prototype.hasOwnProperty.call(acc, key) === false) {
if (x.options.base_type == 'array') {
Object.defineProperty(acc, key, {
value: []
})
} else {
Object.defineProperty(acc, key, {
value: {}
})
}
}
return acc[key]
}, ctx)
ref[lastKey] = propertyValue
})
}
1 change: 1 addition & 0 deletions src/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from './primitive'
export * from './prepost'
export * from './bitfield'
export * from './transformer'
export * from './context'
Loading

0 comments on commit 89a5203

Please sign in to comment.