Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat deser class by schema collection #370

Merged
merged 29 commits into from
Jan 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
78567dc
feat: deserialized js class with schema info
fospring Nov 18, 2023
2fa650c
feat: WIP add test of get_record
fospring Nov 22, 2023
2d65f4d
test: test decode class get record
fospring Nov 25, 2023
4b9201c
test: test call inner class's method
fospring Nov 25, 2023
a7230bc
test: test decode class with inner array
fospring Nov 25, 2023
f3e01c9
test: test decode UnorderMap and nested collections
fospring Nov 25, 2023
8cb2bd0
test: get subtype of UnorderedMap and nested UnorderedMap
fospring Nov 25, 2023
7c2cfe6
feat: UnorderedMap remove reconstructor by add subtype and dynamic ch…
fospring Nov 25, 2023
707054e
feat: auto reconstruct for LookupMap
fospring Nov 26, 2023
95e8e0f
feat: refact set_reconstructor and modulize
fospring Nov 28, 2023
1083681
feat: vector nest other collections and test, decode and test Unorder…
fospring Nov 29, 2023
ac40cd9
feat: add lookupset and remove useless log
fospring Nov 29, 2023
482ce68
feat: mv decodeObj2class to sdk
fospring Nov 30, 2023
564440c
feat: decode subtype recursively
fospring Dec 2, 2023
203e139
test: add test of nested class of UnorderedMap
fospring Dec 3, 2023
15d5ae4
feat: update dependencies
fospring Dec 6, 2023
4ffc1a8
feat: add test bigint and date
fospring Dec 8, 2023
6e7a825
feat: decodeObj2class if contains scheme info
fospring Dec 9, 2023
1eb4e97
feat: reconstruct with decodeObj2class if contains static schema and …
fospring Dec 9, 2023
868f663
optimize: optimize data clone inner decodeObj2class
fospring Dec 10, 2023
c43ab16
chore: add description for auto reconstruct by json schema
fospring Dec 10, 2023
8e67878
fix: fix gas use out in nft contract
fospring Dec 18, 2023
feff288
fix: fix handle normal case with nested Class and add testcase
fospring Dec 21, 2023
24f4e5e
test: add test of schema fields skipped
fospring Dec 23, 2023
4a0a219
feat: Vector inherit from SubType and fix gas Exceeded in ubuntu
fospring Dec 23, 2023
1b4ec32
chore: remove unused lodash
fospring Dec 23, 2023
f4b8967
feat: add reconstructor in schema and update different collection typ…
fospring Dec 25, 2023
884cc82
feat: update check subtype logic
fospring Dec 26, 2023
c8baf76
fix: fix reconstructor undefine error
fospring Dec 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions AUTO_RECONSCTRUCT_BY_JSON_SCHEME.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
# Auto reconstruct by json schema
## Problem Solved: Could not decode contract state to class instance in early version of sdk
JS SDK decode contract as utf-8 and parse it as JSON, results in a JS Object.
One thing not intuitive is objects are recovered as Object, not class instance. For example, Assume an instance of this class is stored in contract state:
```typescript
Class Car {
name: string;
speed: number;

run() {
// ...
}
}
```
When load it back, the SDK gives us something like:
```json
{"name": "Audi", "speed": 200}
```
However this is a JS Object, not an instance of Car Class, and therefore you cannot call run method on it.
This also applies to when user passes a JSON argument to a contract method. If the contract is written in TypeScript, although it may look like:
```typescript
add_a_car(car: Car) {
car.run(); // doesn't work
this.some_collection.set(car.name, car);
}
```
But car.run() doesn't work, because SDK only know how to deserialize it as a plain object, not a Car instance.
This problem is particularly painful when class is nested, for example collection class instance LookupMap containing Car class instance. Currently SDK mitigate this problem by requires user to manually reconstruct the JS object to an instance of the original class.
## A method to decode string to class instance by json schema file
we just need to add static member in the class type.
```typescript
Class Car {
static schema = {
name: "string",
speed: "number",
};
name: string;
speed: number;

run() {
// ...
}
}
```
After we add static member in the class type in our smart contract, it will auto reconstruct smart contract and it's member to class instance recursive by sdk.
And we can call class's functions directly after it deserialized.
```js
add_a_car(car: Car) {
car.run(); // it works!
this.some_collection.set(car.name, car);
}
```
### The schema format
#### We support multiple type in schema:
* build-in non object types: `string`, `number`, `boolean`
* build-in object types: `Date`, `BigInt`. And we can skip those two build-in object types in schema info
* build-in collection types: `array`, `map`
* for `array` type, we need to declare it in the format of `{array: {value: valueType}}`
* for `map` type, we need to declare it in the format of `{map: {key: 'KeyType', value: 'valueType'}}`
* Custom Class types: `Car` or any class types
* Near collection types: `Vector`, `LookupMap`, `LookupSet`, `UnorderedMap`, `UnorderedSet`
We have a test example which contains all those types in one schema: [status-deserialize-class.js](./examples/src/status-deserialize-class.js)
```js
class StatusDeserializeClass {
static schema = {
is_inited: "boolean",
records: {map: {key: 'string', value: 'string'}},
car: Car,
messages: {array: {value: 'string'}},
efficient_recordes: {unordered_map: {value: 'string'}},
nested_efficient_recordes: {unordered_map: {value: {unordered_map: {value: 'string'}}}},
nested_lookup_recordes: {unordered_map: {value: {lookup_map: {value: 'string'}}}},
vector_nested_group: {vector: {value: {lookup_map: {value: 'string'}}}},
lookup_nest_vec: {lookup_map: {value: {vector: {value: 'string'}}}},
unordered_set: {unordered_set: {value: 'string'}},
user_car_map: {unordered_map: {value: Car}},
big_num: 'bigint',
date: 'date'
};

constructor() {
this.is_inited = false;
this.records = {};
this.car = new Car();
this.messages = [];
// account_id -> message
this.efficient_recordes = new UnorderedMap("a");
// id -> account_id -> message
this.nested_efficient_recordes = new UnorderedMap("b");
// id -> account_id -> message
this.nested_lookup_recordes = new UnorderedMap("c");
// index -> account_id -> message
this.vector_nested_group = new Vector("d");
// account_id -> index -> message
this.lookup_nest_vec = new LookupMap("e");
this.unordered_set = new UnorderedSet("f");
this.user_car_map = new UnorderedMap("g");
this.big_num = 1n;
this.date = new Date();
}
// other methods
}
```
#### Logic of auto reconstruct by json schema
The `_reconstruct` method in [near-bindgen.ts](./packages/near-sdk-js/src/near-bindgen.ts) will check whether there exit a schema in smart contract class, if there exist a static schema info, it will be decoded to class by invoking `decodeObj2class`, or it will fallback to previous behavior:
```typescript
static _reconstruct(classObject: object, plainObject: AnyObject): object {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (classObject.constructor.schema === undefined) {
for (const item in classObject) {
const reconstructor = classObject[item].constructor?.reconstruct;

classObject[item] = reconstructor
? reconstructor(plainObject[item])
: plainObject[item];
}

return classObject;
}

return decodeObj2class(classObject, plainObject);
}
```
#### no need to announce GetOptions.reconstructor in decoding nested collections
In this other hand, after we set schema for the Near collections with nested collections, we don't need to announce `reconstructor` when we need to get and decode a nested collections because the data type info in the schema will tell sdk what the nested data type.
Before we set schema if we need to get a nested collection we need to set `reconstructor` in `GetOptions`:
```typescript
@NearBindgen({})
export class Contract {
outerMap: UnorderedMap<UnorderedMap<string>>;

constructor() {
this.outerMap = new UnorderedMap("o");
}

@view({})
get({id, accountId}: { id: string; accountId: string }) {
const innerMap = this.outerMap.get(id, {
reconstructor: UnorderedMap.reconstruct, // we need to announce reconstructor explicit
});
if (innerMap === null) {
return null;
}
return innerMap.get(accountId);
}
}
```
After we set schema info we don't need to set `reconstructor` in `GetOptions`, sdk can infer which reconstructor should be took by the schema:
```typescript
@NearBindgen({})
export class Contract {
static schema = {
outerMap: {unordered_map: {value: { unordered_map: {value: 'string'}}}}
};

outerMap: UnorderedMap<UnorderedMap<string>>;

constructor() {
this.outerMap = new UnorderedMap("o");
}

@view({})
get({id, accountId}: { id: string; accountId: string }) {
const innerMap = this.outerMap.get(id, {
reconstructor: UnorderedMap.reconstruct, // we need to announce reconstructor explicit, reconstructor can be infered from static schema
});
if (innerMap === null) {
return null;
}
return innerMap.get(accountId);
}
}
```
169 changes: 169 additions & 0 deletions examples/__tests__/test-status-deserialize-class.ava.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import {Worker} from "near-workspaces";
import test from "ava";

test.before(async (t) => {
// Init the worker and start a Sandbox server
const worker = await Worker.init();

// Prepare sandbox for tests, create accounts, deploy contracts, etx.
const root = worker.rootAccount;

// Deploy the contract.
const statusMessage = await root.devDeploy("./build/status-deserialize-class.wasm");

await root.call(statusMessage, "init_contract", {});
const result = await statusMessage.view("is_contract_inited", {});
t.is(result, true);

// Create test users
const ali = await root.createSubAccount("ali");
const bob = await root.createSubAccount("bob");
const carl = await root.createSubAccount("carl");

// Save state for test runs
t.context.worker = worker;
t.context.accounts = { root, statusMessage, ali, bob, carl };
});

test.after.always(async (t) => {
await t.context.worker.tearDown().catch((error) => {
console.log("Failed to tear down the worker:", error);
});
});

test("Ali sets then gets status", async (t) => {
const { ali, statusMessage } = t.context.accounts;
await ali.call(statusMessage, "set_record", { message: "hello" });

t.is(
await statusMessage.view("get_record", { account_id: ali.accountId }),
"hello"
);
});

test("Ali set_truck_info and get_truck_info", async (t) => {
const { ali, statusMessage } = t.context.accounts;
let carName = "Mercedes-Benz";
let speed = 240;
await ali.call(statusMessage, "set_truck_info", { name: carName, speed: speed });

await ali.call(statusMessage, "add_truck_load", { name: "alice", load: "a box" });
await ali.call(statusMessage, "add_truck_load", { name: "bob", load: "a packet" });

t.is(
await statusMessage.view("get_truck_info", { }),
carName + " run with speed " + speed + " with loads length: 2"
);

t.is(
await statusMessage.view("get_user_car_info", { account_id: ali.accountId }),
carName + " run with speed " + speed
);
});

test("Ali push_message and get_messages", async (t) => {
const { ali, statusMessage } = t.context.accounts;
let message1 = 'Hello';
let message2 = 'World';
await ali.call(statusMessage, "push_message", { message: message1 });
await ali.call(statusMessage, "push_message", { message: message2 });

t.is(
await statusMessage.view("get_messages", { }),
'Hello,World'
);
});

test("Ali set_nested_efficient_recordes then get_nested_efficient_recordes text", async (t) => {
const { ali, bob, statusMessage } = t.context.accounts;
await ali.call(statusMessage, "set_nested_efficient_recordes", { id: "1", message: "hello" }, { gas: 35_000_000_000_000n });
await bob.call(statusMessage, "set_nested_efficient_recordes", { id: "1", message: "hello" }, { gas: 35_000_000_000_000n });
await bob.call(statusMessage, "set_nested_efficient_recordes", { id: "2", message: "world" }, { gas: 35_000_000_000_000n });

t.is(
await statusMessage.view("get_efficient_recordes", { account_id: ali.accountId }),
"hello"
);

t.is(
await statusMessage.view("get_nested_efficient_recordes", { id: "1", account_id: bob.accountId }),
"hello"
);

t.is(
await statusMessage.view("get_nested_efficient_recordes", { id: "2", account_id: bob.accountId }),
"world"
);

t.is(
await statusMessage.view("get_nested_lookup_recordes", { id: "1", account_id: bob.accountId }),
"hello"
);

t.is(
await statusMessage.view("get_nested_lookup_recordes", { id: "2", account_id: bob.accountId }),
"world"
);

t.is(
await statusMessage.view("get_vector_nested_group", { idx: 0, account_id: bob.accountId }),
"world"
);

t.is(
await statusMessage.view("get_lookup_nested_vec", { account_id: bob.accountId, idx: 1 }),
"world"
);

t.is(
await statusMessage.view("get_is_contains_user", { account_id: bob.accountId}),
true
);
});

test("Ali set_big_num_and_date then gets", async (t) => {
const { ali, bob, statusMessage } = t.context.accounts;
await ali.call(statusMessage, "set_big_num_and_date", { bigint_num: `${10n}`, new_date: new Date('August 19, 2023 23:15:30 GMT+00:00') });


const afterSetNum = await statusMessage.view("get_big_num", { });
t.is(afterSetNum, `${10n}`);
const afterSetDate = await statusMessage.view("get_date", { });
t.is(afterSetDate.toString(), '2023-08-19T23:15:30.000Z');
});

test("Ali set_extra_data without schema defined then gets", async (t) => {
const { ali, statusMessage } = t.context.accounts;
await ali.call(statusMessage, "set_extra_data", { message: "Hello world!", number: 100 });

const messageWithoutSchemaDefined = await statusMessage.view("get_extra_msg", { });
t.is(messageWithoutSchemaDefined, "Hello world!");
const numberWithoutSchemaDefined = await statusMessage.view("get_extra_number", { });
t.is(numberWithoutSchemaDefined, 100);
});

test("Ali set_extra_record without schema defined then gets", async (t) => {
const { ali, statusMessage } = t.context.accounts;
await ali.call(statusMessage, "set_extra_record", { message: "Hello world!"});

const recordWithoutSchemaDefined = await statusMessage.view("get_extra_record", { account_id: ali.accountId });
t.is(recordWithoutSchemaDefined, "Hello world!");
});

test("View get_subtype_of_efficient_recordes", async (t) => {
const { statusMessage } = t.context.accounts;

t.is(
await statusMessage.view("get_subtype_of_efficient_recordes", { }),
'string'
);
});

test("View get_subtype_of_nested_efficient_recordes", async (t) => {
const { statusMessage } = t.context.accounts;

t.is(
JSON.stringify(await statusMessage.view("get_subtype_of_nested_efficient_recordes", { })),
'{"collection":{"value":"string"}}'
);
});
4 changes: 3 additions & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"build:state-migration": "run-s build:state-migration:*",
"build:state-migration:original": "near-sdk-js build src/state-migration-original.ts build/state-migration-original.wasm",
"build:state-migration:new": "near-sdk-js build src/state-migration-new.ts build/state-migration-new.wasm",
"build:status-deserialize-class": "near-sdk-js build src/status-deserialize-class.js build/status-deserialize-class.wasm",
"test": "ava && pnpm test:counter-lowlevel && pnpm test:counter-ts",
"test:nft": "ava __tests__/standard-nft/*",
"test:ft": "ava __tests__/standard-ft/*",
Expand All @@ -53,7 +54,8 @@
"test:nested-collections": "ava __tests__/test-nested-collections.ava.js",
"test:status-message-borsh": "ava __tests__/test-status-message-borsh.ava.js",
"test:status-message-serialize-err": "ava __tests__/test-status-message-serialize-err.ava.js",
"test:status-message-deserialize-err": "ava __tests__/test-status-message-deserialize-err.ava.js"
"test:status-message-deserialize-err": "ava __tests__/test-status-message-deserialize-err.ava.js",
"test:status-deserialize-class": "ava __tests__/test-status-deserialize-class.ava.js"
},
"author": "Near Inc <hello@nearprotocol.com>",
"license": "Apache-2.0",
Expand Down
Loading