Skip to content

Commit 0658153

Browse files
authored
Merge pull request #425 from Hanssen0/feat/codec-from
feat(core)!: Codec.from
2 parents 68a9a86 + 584a1eb commit 0658153

8 files changed

Lines changed: 395 additions & 107 deletions

File tree

‎.changeset/bright-chefs-divide.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ckb-ccc/core": major
3+
---
4+
5+
feat(core)!: Codec.from
6+

‎packages/core/src/codec/codec.ts‎

Lines changed: 65 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import { Bytes, bytesFrom, BytesLike } from "../bytes/index.js";
44

55
export type CodecLike<Encodable, Decoded = Encodable> = {
6-
readonly encode: (encodable: Encodable) => Bytes;
7-
readonly decode: (
8-
decodable: BytesLike,
6+
encode: (encodable: Encodable) => BytesLike;
7+
decode: (
8+
decodable: Bytes,
99
config?: { isExtraFieldIgnored?: boolean },
1010
) => Decoded;
11-
readonly byteLength?: number;
11+
from?: ((encoded: Encodable) => Decoded) | null;
12+
byteLength?: number;
1213
};
1314
export class Codec<Encodable, Decoded = Encodable> {
1415
constructor(
@@ -17,6 +18,7 @@ export class Codec<Encodable, Decoded = Encodable> {
1718
decodable: BytesLike,
1819
config?: { isExtraFieldIgnored?: boolean }, // This is equivalent to "compatible" in the Rust implementation of Molecule.
1920
) => Decoded,
21+
public readonly from: (encodable: Encodable) => Decoded,
2022
public readonly byteLength?: number, // if provided, treat codec as fixed length
2123
) {}
2224

@@ -40,53 +42,76 @@ export class Codec<Encodable, Decoded = Encodable> {
4042
}
4143
}
4244

43-
static from<Encodable, Decoded = Encodable>({
44-
encode,
45-
decode,
46-
byteLength,
47-
}: CodecLike<Encodable, Decoded>): Codec<Encodable, Decoded> {
45+
static from<Encodable, Decoded = Encodable>(
46+
codecLike: CodecLike<Encodable, Decoded>,
47+
): Codec<Encodable, Decoded> {
48+
const newEncode = function (encodable: Encodable) {
49+
const encoded = bytesFrom(codecLike.encode(encodable));
50+
if (
51+
codecLike.byteLength !== undefined &&
52+
encoded.byteLength !== codecLike.byteLength
53+
) {
54+
throw new Error(
55+
`Codec.encode: expected byte length ${codecLike.byteLength}, got ${encoded.byteLength}`,
56+
);
57+
}
58+
return encoded;
59+
};
60+
const newDecode = function (
61+
decodable: BytesLike,
62+
config?: { isExtraFieldIgnored?: boolean },
63+
) {
64+
const decodableBytes = bytesFrom(decodable);
65+
if (
66+
codecLike.byteLength !== undefined &&
67+
decodableBytes.byteLength !== codecLike.byteLength
68+
) {
69+
throw new Error(
70+
`Codec.decode: expected byte length ${codecLike.byteLength}, got ${decodableBytes.byteLength}`,
71+
);
72+
}
73+
return codecLike.decode(decodableBytes, config);
74+
};
4875
return new Codec(
49-
(encodable: Encodable) => {
50-
const encoded = encode(encodable);
51-
if (byteLength !== undefined && encoded.byteLength !== byteLength) {
52-
throw new Error(
53-
`Codec.encode: expected byte length ${byteLength}, got ${encoded.byteLength}`,
54-
);
55-
}
56-
return encoded;
57-
},
58-
(decodable, config) => {
59-
const decodableBytes = bytesFrom(decodable);
60-
if (
61-
byteLength !== undefined &&
62-
decodableBytes.byteLength !== byteLength
63-
) {
64-
throw new Error(
65-
`Codec.decode: expected byte length ${byteLength}, got ${decodableBytes.byteLength}`,
66-
);
67-
}
68-
return decode(decodable, config);
69-
},
70-
byteLength,
76+
newEncode,
77+
newDecode,
78+
codecLike.from?.bind(codecLike) ??
79+
function (encodable: Encodable) {
80+
return newDecode(newEncode(encodable));
81+
},
82+
codecLike.byteLength,
7183
);
7284
}
7385

7486
map<NewEncodable = Encodable, NewDecoded = Decoded>({
7587
inMap,
7688
outMap,
89+
from,
7790
}: {
7891
inMap?: (encodable: NewEncodable) => Encodable;
7992
outMap?: (decoded: Decoded) => NewDecoded;
93+
from?: (encodable: NewEncodable) => NewDecoded;
8094
}): Codec<NewEncodable, NewDecoded> {
81-
return new Codec(
82-
(encodable) =>
83-
this.encode((inMap ? inMap(encodable) : encodable) as Encodable),
84-
(buffer, config) =>
85-
(outMap
86-
? outMap(this.decode(buffer, config))
87-
: this.decode(buffer, config)) as NewDecoded,
88-
this.byteLength,
89-
);
95+
const encode = inMap
96+
? (encodable: NewEncodable) => this.encode(inMap(encodable))
97+
: (this.encode as unknown as (encodable: NewEncodable) => Bytes);
98+
const decode = outMap
99+
? (buffer: BytesLike, config?: { isExtraFieldIgnored?: boolean }) =>
100+
outMap(this.decode(buffer, config))
101+
: (this.decode as unknown as (
102+
buffer: BytesLike,
103+
config?: { isExtraFieldIgnored?: boolean },
104+
) => NewDecoded);
105+
const newFrom =
106+
from ??
107+
((encodable: NewEncodable): NewDecoded => {
108+
const toEncode = (inMap ? inMap(encodable) : encodable) as Encodable;
109+
return (
110+
outMap ? outMap(this.from(toEncode)) : this.from(toEncode)
111+
) as NewDecoded;
112+
});
113+
114+
return new Codec(encode, decode, newFrom, this.byteLength);
90115
}
91116

92117
mapIn<NewEncodable>(
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, test } from "vitest";
2+
import { bytesFrom } from "../bytes/index.js";
3+
import { mol } from "../molecule/index.js";
4+
import { Codec } from "./codec.js";
5+
import { codec, Entity } from "./entity.js";
6+
7+
interface FooLike {
8+
v: number;
9+
}
10+
11+
/**
12+
* A minimal entity decorated with `@codec`, used as the base class for the
13+
* dispatch tests below. `Foo.from` marks values it produces with `via: "Foo"`
14+
* so tests can assert which class' `from` actually ran.
15+
*/
16+
@codec(mol.table({ v: mol.Uint8 }))
17+
class Foo extends Entity.Base<FooLike, Foo>() {
18+
public readonly v: number;
19+
public readonly via: string;
20+
21+
constructor(v: number, via: string) {
22+
super();
23+
this.v = v;
24+
this.via = via;
25+
}
26+
27+
static override from(fooLike: FooLike | Foo): Foo {
28+
if (fooLike instanceof Foo) {
29+
return fooLike;
30+
}
31+
return new Foo(fooLike.v, "Foo");
32+
}
33+
34+
override clone(): Foo {
35+
return new Foo(this.v, this.via);
36+
}
37+
}
38+
39+
/** A subclass of `Foo` that overrides `from` with distinguishable behavior. */
40+
class Bar extends Foo {
41+
static override from(fooLike: FooLike | Foo): Foo {
42+
if (fooLike instanceof Foo) {
43+
return fooLike;
44+
}
45+
return new Foo(fooLike.v, "Bar");
46+
}
47+
}
48+
49+
describe("codec decorator static method dispatch", () => {
50+
test("Foo.encode/decode/fromBytes dispatch to Foo.from", () => {
51+
const bytes = Foo.encode({ v: 1 });
52+
expect(Foo.decode(bytes).via).toBe("Foo");
53+
expect(Foo.fromBytes(bytes).via).toBe("Foo");
54+
});
55+
56+
test("Bar.encode/decode/fromBytes dispatch to Bar.from, not the inherited Foo.from", () => {
57+
// Bar does not redefine encode/decode/fromBytes; it inherits them from the
58+
// shared Base installed by the `@codec` decorator on `Foo`. Calling them
59+
// through `Bar` must still resolve `from` against `Bar`, since `this` is
60+
// bound to the receiver of the call (`Bar`), not the class that was
61+
// originally decorated (`Foo`).
62+
const bytes = Bar.encode({ v: 1 });
63+
expect(Bar.decode(bytes).via).toBe("Bar");
64+
expect(Bar.fromBytes(bytes).via).toBe("Bar");
65+
});
66+
67+
test("Bar used as a nested field codec dispatches to Bar.from, not Foo.from", () => {
68+
// `mol.table` builds its field codec via `Codec.from(Bar)`, which reads
69+
// `Bar.encode`/`Bar.decode`/`Bar.from` off `Bar` without ever detaching
70+
// them from their receiver (no destructuring). This must preserve the
71+
// correct `this` binding so nested encode/decode still dispatch to
72+
// `Bar.from` and not to `Foo.from`.
73+
const outer = mol.table({ inner: Bar });
74+
75+
const encoded = outer.encode({ inner: { v: 1 } });
76+
const decoded = outer.decode(encoded);
77+
expect(decoded.inner.via).toBe("Bar");
78+
79+
const fromed = outer.from({ inner: { v: 1 } });
80+
expect(fromed.inner.via).toBe("Bar");
81+
});
82+
83+
test("tearing encode off a class and calling it without a receiver throws", () => {
84+
// This is expected, ordinary JavaScript behavior for static methods that
85+
// rely on `this`: once detached from their receiver, they can no longer
86+
// resolve `this.from`. The `@codec` decorator and `Codec.from` must not
87+
// paper over this by rebinding `this` at property-read time; only the
88+
// framework's own internal call sites (e.g. `Codec.from`) are responsible
89+
// for calling these methods without tearing them off their class.
90+
const { encode } = Bar;
91+
expect(() => encode({ v: 1 })).toThrow(TypeError);
92+
});
93+
94+
test("Codec.from does not tear encode/decode/from off the source object", () => {
95+
// Regression guard for `Codec.from`: it must keep a reference to the
96+
// passed-in `codecLike` and call `codecLike.encode(...)` etc. as member
97+
// calls (preserving `this`), rather than destructuring
98+
// `{ encode, decode, from }` into standalone functions, which would lose
99+
// the receiver and break subclass dispatch through nested codecs.
100+
const rebound = Codec.from(Bar);
101+
const encoded = rebound.encode({ v: 1 });
102+
expect(rebound.decode(bytesFrom(encoded)).via).toBe("Bar");
103+
expect(rebound.from({ v: 1 }).via).toBe("Bar");
104+
});
105+
});

0 commit comments

Comments
 (0)