diff --git a/.changeset/rare-carpets-tell.md b/.changeset/rare-carpets-tell.md new file mode 100644 index 000000000..37eb11d9a --- /dev/null +++ b/.changeset/rare-carpets-tell.md @@ -0,0 +1,5 @@ +--- +'@graphprotocol/graph-ts': minor +--- + +feat: add yaml parsing support to mappings diff --git a/packages/ts/common/yaml.ts b/packages/ts/common/yaml.ts new file mode 100644 index 000000000..d3a7ecce7 --- /dev/null +++ b/packages/ts/common/yaml.ts @@ -0,0 +1,299 @@ +import { Bytes, Result, TypedMap } from './collections'; +import { BigInt } from './numbers'; + +/** + * Host YAML interface. + */ +export declare namespace yaml { + /** + * Parses a YAML document from UTF-8 encoded bytes. + * Aborts mapping execution if the bytes cannot be parsed. + */ + function fromBytes(data: Bytes): YAMLValue; + + /** + * Parses a YAML document from UTF-8 encoded bytes. + * Returns `Result.error == true` if the bytes cannot be parsed. + */ + function try_fromBytes(data: Bytes): Result; +} + +export namespace yaml { + /** + * Parses a YAML document from a UTF-8 encoded string. + * Aborts mapping execution if the string cannot be parsed. + */ + export function fromString(data: string): YAMLValue { + const bytes = Bytes.fromUTF8(data); + + return yaml.fromBytes(bytes); + } + + /** + * Parses a YAML document from a UTF-8 encoded string. + * Returns `Result.error == true` if the string cannot be parsed. + */ + export function try_fromString(data: string): Result { + const bytes = Bytes.fromUTF8(data); + + return yaml.try_fromBytes(bytes); + } +} + +/** + * All possible YAML value types. + */ +export enum YAMLValueKind { + NULL = 0, + BOOL = 1, + NUMBER = 2, + STRING = 3, + ARRAY = 4, + OBJECT = 5, + TAGGED = 6, +} + +/** + * Pointer type for `YAMLValue` data. + * + * Big enough to fit any pointer or native `YAMLValue.data`. + */ +export type YAMLValuePayload = u64; + +export class YAMLValue { + kind: YAMLValueKind; + data: YAMLValuePayload; + + constructor(kind: YAMLValueKind, data: YAMLValuePayload) { + this.kind = kind; + this.data = data; + } + + static newNull(): YAMLValue { + return new YAMLValue(YAMLValueKind.NULL, 0); + } + + static newBool(data: bool): YAMLValue { + return new YAMLValue(YAMLValueKind.BOOL, data ? 1 : 0); + } + + static newI64(data: i64): YAMLValue { + return new YAMLValue(YAMLValueKind.NUMBER, changetype(data.toString())); + } + + static newU64(data: u64): YAMLValue { + return new YAMLValue(YAMLValueKind.NUMBER, changetype(data.toString())); + } + + static newF64(data: f64): YAMLValue { + return new YAMLValue(YAMLValueKind.NUMBER, changetype(data.toString())); + } + + static newBigInt(data: BigInt): YAMLValue { + return new YAMLValue(YAMLValueKind.STRING, changetype(data.toString())); + } + + static newString(data: string): YAMLValue { + return new YAMLValue(YAMLValueKind.STRING, changetype(data)); + } + + static newArray(data: Array): YAMLValue { + return new YAMLValue(YAMLValueKind.ARRAY, changetype(data)); + } + + static newObject(data: TypedMap): YAMLValue { + return new YAMLValue(YAMLValueKind.OBJECT, changetype(data)); + } + + static newTagged(tag: string, value: YAMLValue): YAMLValue { + const tagged = new YAMLTaggedValue(tag, value); + return new YAMLValue(YAMLValueKind.TAGGED, changetype(tagged)); + } + + isNull(): bool { + return this.kind == YAMLValueKind.NULL; + } + + isBool(): bool { + return this.kind == YAMLValueKind.BOOL; + } + + isNumber(): bool { + return this.kind == YAMLValueKind.NUMBER; + } + + isString(): bool { + return this.kind == YAMLValueKind.STRING; + } + + isArray(): bool { + return this.kind == YAMLValueKind.ARRAY; + } + + isObject(): bool { + return this.kind == YAMLValueKind.OBJECT; + } + + isTagged(): bool { + return this.kind == YAMLValueKind.TAGGED; + } + + toBool(): bool { + assert(this.isBool(), 'YAML value is not a boolean'); + return this.data != 0; + } + + toNumber(): string { + assert(this.isNumber(), 'YAML value is not a number'); + return changetype(this.data as usize); + } + + toI64(): i64 { + return I64.parseInt(this.toNumber()); + } + + toU64(): u64 { + return U64.parseInt(this.toNumber()); + } + + toF64(): f64 { + return F64.parseFloat(this.toNumber()); + } + + toBigInt(): BigInt { + assert(this.isNumber() || this.isString(), 'YAML value is not numeric'); + return BigInt.fromString(changetype(this.data as usize)); + } + + toString(): string { + assert(this.isString(), 'YAML value is not a string'); + return changetype(this.data as usize); + } + + toArray(): Array { + assert(this.isArray(), 'YAML value is not an array'); + return changetype>(this.data as usize); + } + + toObject(): TypedMap { + assert(this.isObject(), 'YAML value is not an object'); + return changetype>(this.data as usize); + } + + toTagged(): YAMLTaggedValue { + assert(this.isTagged(), 'YAML value is not tagged'); + return changetype(this.data as usize); + } + + // Allows access to YAML values from within an object. + @operator('==') + static eq(a: YAMLValue, b: YAMLValue): bool { + if (a.isBool() && b.isBool()) { + return a.toBool() == b.toBool(); + } + + if (a.isNumber() && b.isNumber()) { + return a.toNumber() == b.toNumber(); + } + + if (a.isString() && b.isString()) { + return a.toString() == b.toString(); + } + + if (a.isArray() && b.isArray()) { + const arrA = a.toArray(); + const arrB = b.toArray(); + + if (arrA.length == arrB.length) { + for (let i = 0; i < arrA.length; i++) { + if (arrA[i] != arrB[i]) { + return false; + } + } + + return true; + } + + return false; + } + + if (a.isObject() && b.isObject()) { + const objA = a.toObject(); + const objB = b.toObject(); + + if (objA.entries.length == objB.entries.length) { + for (let i = 0; i < objA.entries.length; i++) { + const valB = objB.get(objA.entries[i].key); + + if (!valB || objA.entries[i].value != valB) { + return false; + } + } + + return true; + } + + return false; + } + + if (a.isTagged() && b.isTagged()) { + return a.toTagged() == b.toTagged(); + } + + return false; + } + + // Allows access to YAML values from within an object. + @operator('!=') + static ne(a: YAMLValue | null, b: YAMLValue | null): bool { + if (!a || !b) { + return true; + } + + return !(a! == b!); + } + + // Makes it easier to access a specific index in a YAML array or a string key in a YAML object. + // + // Examples: + // Usage in YAML objects: `yaml.fromString(subgraphManifest)['specVersion']`; + // Nesting is also supported: `yaml.fromString(subgraphManifest)['schema']['file']`; + // Usage in YAML arrays: `yaml.fromString(subgraphManifest)['dataSources']['0']`; + // YAML arrays and objects: `yaml.fromString(subgraphManifest)['dataSources']['0']['source']['address']`; + @operator('[]') + get(index: string): YAMLValue { + assert(this.isArray() || this.isObject(), 'YAML value can not be accessed by index'); + + if (this.isArray()) { + return this.toArray()[I32.parseInt(index)]; + } + + return this.toObject().mustGet(YAMLValue.newString(index)); + } +} + +export class YAMLTaggedValue { + tag: string; + value: YAMLValue; + + constructor(tag: string, value: YAMLValue) { + this.tag = tag; + this.value = value; + } + + // Allows access to YAML values from within an object. + @operator('==') + static eq(a: YAMLTaggedValue, b: YAMLTaggedValue): bool { + return a.tag == b.tag && a.value == b.value; + } + + // Allows access to YAML values from within an object. + @operator('!=') + static ne(a: YAMLTaggedValue | null, b: YAMLTaggedValue | null): bool { + if (!a || !b) { + return true; + } + + return !(a! == b!); + } +} diff --git a/packages/ts/global/global.ts b/packages/ts/global/global.ts index ec7e4bad3..f01673006 100644 --- a/packages/ts/global/global.ts +++ b/packages/ts/global/global.ts @@ -6,6 +6,7 @@ import { starknet } from '../chain/starknet'; import { Bytes, Entity, Result, TypedMap, TypedMapEntry, Wrapped } from '../common/collections'; import { BigDecimal } from '../common/numbers'; import { JSONValue, Value } from '../common/value'; +import { YAMLTaggedValue, YAMLValue } from '../common/yaml'; /** * Contains type IDs and their discriminants for every blockchain supported by Graph-Node. @@ -247,7 +248,17 @@ export enum TypeId { ``` */ - // Reserved discriminant space for a future blockchain type IDs: [4,500, 5,499] + // Reserved discriminant space for YAML type IDs: [5,500, 6,499] + YamlValue = 5500, + YamlTaggedValue = 5501, + YamlTypedMapEntryValueValue = 5502, + YamlTypedMapValueValue = 5503, + YamlArrayValue = 5504, + YamlArrayTypedMapEntryValueValue = 5505, + YamlWrappedValue = 5506, + YamlResultValueBool = 5507, + + // Reserved discriminant space for a future blockchain type IDs: [6,500, 7,499] } export function id_of_type(typeId: TypeId): usize { @@ -593,6 +604,25 @@ export function id_of_type(typeId: TypeId): usize { return idof(); case TypeId.StarknetArrayBytes: return idof>(); + /** + * YAML type IDs. + */ + case TypeId.YamlValue: + return idof(); + case TypeId.YamlTaggedValue: + return idof(); + case TypeId.YamlTypedMapEntryValueValue: + return idof>(); + case TypeId.YamlTypedMapValueValue: + return idof>(); + case TypeId.YamlArrayValue: + return idof>(); + case TypeId.YamlArrayTypedMapEntryValueValue: + return idof>>(); + case TypeId.YamlWrappedValue: + return idof>(); + case TypeId.YamlResultValueBool: + return idof>(); default: return 0; } diff --git a/packages/ts/index.ts b/packages/ts/index.ts index de81fefee..d0ac26249 100644 --- a/packages/ts/index.ts +++ b/packages/ts/index.ts @@ -19,6 +19,7 @@ export * from './common/datasource'; export * from './common/json'; export * from './common/numbers'; export * from './common/value'; +export * from './common/yaml'; /** * Host store interface. diff --git a/packages/ts/test/test.mjs b/packages/ts/test/test.mjs index 459db3d78..dbf4b3dd6 100644 --- a/packages/ts/test/test.mjs +++ b/packages/ts/test/test.mjs @@ -1,7 +1,8 @@ // TODO: disabling eslint for now // We need to re-do this and use TS instead of JS import fs from 'fs'; -import path from 'path'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { StringDecoder } from 'string_decoder'; import asc from 'assemblyscript/asc'; @@ -30,6 +31,7 @@ async function main() { fs.copyFileSync('common/json.ts', 'test/temp_lib/common/json.ts'); fs.copyFileSync('common/numbers.ts', 'test/temp_lib/common/numbers.ts'); fs.copyFileSync('common/value.ts', 'test/temp_lib/common/value.ts'); + fs.copyFileSync('common/yaml.ts', 'test/temp_lib/common/yaml.ts'); fs.copyFileSync('chain/arweave.ts', 'test/temp_lib/chain/arweave.ts'); fs.copyFileSync('chain/ethereum.ts', 'test/temp_lib/chain/ethereum.ts'); fs.copyFileSync('chain/near.ts', 'test/temp_lib/chain/near.ts'); @@ -40,7 +42,7 @@ async function main() { try { const outputWasmPath = 'test/temp_out/test.wasm'; - for (const file of ['test/bigInt.ts', 'test/bytes.ts', 'test/entity.ts']) + for (const file of ['test/bigInt.ts', 'test/bytes.ts', 'test/entity.ts', 'test/yaml.ts']) await testFile(file, outputWasmPath); } catch (e) { console.error(e); @@ -51,6 +53,7 @@ async function main() { fs.unlinkSync('test/temp_lib/common/json.ts'); fs.unlinkSync('test/temp_lib/common/numbers.ts'); fs.unlinkSync('test/temp_lib/common/value.ts'); + fs.unlinkSync('test/temp_lib/common/yaml.ts'); fs.rmdirSync('test/temp_lib/common'); fs.unlinkSync('test/temp_lib/chain/arweave.ts'); fs.unlinkSync('test/temp_lib/chain/ethereum.ts'); @@ -87,7 +90,8 @@ async function testFile(sourceFile, outputWasmPath) { env: { memory, abort(messagePtr, fileNamePtr, lineNumber, columnNumber) { - const fileSource = path.join(__dirname, '..', sourceFile); + const __filename = fileURLToPath(import.meta.url); + const fileSource = path.join(path.dirname(__filename), '..', sourceFile); let message = 'assertion failure'; if (messagePtr !== 0) { message += `: ${getString(memory, messagePtr)}`; diff --git a/packages/ts/test/yaml.ts b/packages/ts/test/yaml.ts new file mode 100644 index 000000000..49502225e --- /dev/null +++ b/packages/ts/test/yaml.ts @@ -0,0 +1,207 @@ +import { TypedMap, YAMLValue } from './temp_lib/index'; + +export function testYAMLMethods(): void { + let val: YAMLValue; + + val = YAMLValue.newNull(); + assert(val.isNull()); + + val = YAMLValue.newBool(true); + assert(val.isBool()); + assert(val.toBool() == true); + + val = YAMLValue.newI64(1); + assert(val.isNumber()); + assert(val.toNumber() == '1'); + assert(val.toI64() == 1); + + val = YAMLValue.newU64(1); + assert(val.isNumber()); + assert(val.toNumber() == '1'); + assert(val.toU64() == 1); + + val = YAMLValue.newF64(1.2); + assert(val.isNumber()); + assert(val.toNumber() == '1.2'); + assert(val.toF64() == 1.2); + + val = YAMLValue.newString('a'); + assert(val.isString()); + assert(val.toString() == 'a'); + + val = YAMLValue.newArray([YAMLValue.newU64(1)]); + assert(val.isArray()); + assert(val.toArray().length == 1); + assert(val.toArray()[0].toNumber() == '1'); + + let obj: TypedMap = new TypedMap(); + obj.set(YAMLValue.newString('a'), YAMLValue.newString('b')); + + val = YAMLValue.newObject(obj); + assert(val.isObject()); + assert(val.toObject().entries.length == 1); + assert(val.toObject().mustGet(YAMLValue.newString('a')).toString() == 'b'); + + val = YAMLValue.newTagged('a', YAMLValue.newString('b')); + assert(val.isTagged()); + assert((val.toTagged().tag = 'a')); + assert(val.toTagged().value.toString() == 'b'); +} + +export function testYAMLEqNeOverloads(): void { + assert(YAMLValue.newBool(true) == YAMLValue.newBool(true)); + assert(!(YAMLValue.newBool(true) != YAMLValue.newBool(true))); + assert(YAMLValue.newBool(true) != YAMLValue.newBool(false)); + + assert(YAMLValue.newU64(1) == YAMLValue.newU64(1)); + assert(!(YAMLValue.newU64(1) != YAMLValue.newU64(1))); + assert(YAMLValue.newU64(1) != YAMLValue.newU64(2)); + + assert(YAMLValue.newString('a') == YAMLValue.newString('a')); + assert(!(YAMLValue.newString('a') != YAMLValue.newString('a'))); + assert(YAMLValue.newString('a') != YAMLValue.newString('b')); + + assert(YAMLValue.newArray([YAMLValue.newU64(1)]) == YAMLValue.newArray([YAMLValue.newU64(1)])); + assert(!(YAMLValue.newArray([YAMLValue.newU64(1)]) != YAMLValue.newArray([YAMLValue.newU64(1)]))); + assert( + YAMLValue.newArray([YAMLValue.newU64(1)]) != + YAMLValue.newArray([YAMLValue.newU64(1), YAMLValue.newU64(2)]), + ); + assert(YAMLValue.newArray([YAMLValue.newU64(1)]) != YAMLValue.newArray([YAMLValue.newU64(2)])); + + const objA: TypedMap = new TypedMap(); + objA.set(YAMLValue.newString('a'), YAMLValue.newString('b')); + + const objB: TypedMap = new TypedMap(); + objB.set(YAMLValue.newString('a'), YAMLValue.newString('b')); + + assert(YAMLValue.newObject(objA) == YAMLValue.newObject(objB)); + assert(!(YAMLValue.newObject(objA) != YAMLValue.newObject(objB))); + + objA.set(YAMLValue.newString('c'), YAMLValue.newString('d')); + assert(YAMLValue.newObject(objA) != YAMLValue.newObject(objB)); + + objB.set(YAMLValue.newString('c'), YAMLValue.newString('e')); + assert(YAMLValue.newObject(objA) != YAMLValue.newObject(objB)); + + assert( + YAMLValue.newTagged('a', YAMLValue.newString('b')) == + YAMLValue.newTagged('a', YAMLValue.newString('b')), + ); + assert( + !( + YAMLValue.newTagged('a', YAMLValue.newString('b')) != + YAMLValue.newTagged('a', YAMLValue.newString('b')) + ), + ); + assert( + YAMLValue.newTagged('a', YAMLValue.newString('b')) != + YAMLValue.newTagged('c', YAMLValue.newString('d')), + ); +} + +export function testYAMLIndexOverload(): void { + const objA: TypedMap = new TypedMap(); + const objB: TypedMap = new TypedMap(); + + objB.set(YAMLValue.newString('b'), YAMLValue.newString('c')); + objA.set(YAMLValue.newString('a'), YAMLValue.newArray([YAMLValue.newObject(objB)])); + + assert(YAMLValue.newObject(objA)['a']['0']['b'].toString() == 'c'); +} + +export function testComplexYAMLParsing(): void { + const metadata = new TypedMap(); + metadata.set( + YAMLValue.newString('created_at'), + YAMLValue.newTagged('timestamp', YAMLValue.newString('2025-01-01')), + ); + metadata.set(YAMLValue.newString('author'), YAMLValue.newNull()); + + const limits = new TypedMap(); + limits.set(YAMLValue.newString('max_retry'), YAMLValue.newI64(3)); + limits.set(YAMLValue.newString('timeout'), YAMLValue.newI64(1000)); + + const settings = new TypedMap(); + settings.set(YAMLValue.newString('debug'), YAMLValue.newBool(true)); + settings.set(YAMLValue.newString('mode'), YAMLValue.newString('production')); + settings.set(YAMLValue.newString('limits'), YAMLValue.newObject(limits)); + + const tags = [ + YAMLValue.newString('important'), + YAMLValue.newString('test'), + YAMLValue.newString('v1'), + ]; + + const item1 = new TypedMap(); + item1.set(YAMLValue.newString('name'), YAMLValue.newString('item1')); + item1.set(YAMLValue.newString('value'), YAMLValue.newI64(100)); + + const item2 = new TypedMap(); + item2.set(YAMLValue.newString('name'), YAMLValue.newString('item2')); + item2.set(YAMLValue.newString('value'), YAMLValue.newI64(200)); + + const nestedArray = [YAMLValue.newObject(item1), YAMLValue.newObject(item2)]; + + // Create the root object + const root = new TypedMap(); + root.set(YAMLValue.newString('name'), YAMLValue.newString('Test Config')); + root.set(YAMLValue.newString('version'), YAMLValue.newF64(1.0)); + root.set(YAMLValue.newString('enabled'), YAMLValue.newBool(true)); + root.set(YAMLValue.newString('count'), YAMLValue.newI64(42)); + root.set(YAMLValue.newString('pi'), YAMLValue.newF64(3.14159)); + root.set(YAMLValue.newString('settings'), YAMLValue.newObject(settings)); + root.set(YAMLValue.newString('tags'), YAMLValue.newArray(tags)); + root.set(YAMLValue.newString('nested_array'), YAMLValue.newArray(nestedArray)); + root.set(YAMLValue.newString('metadata'), YAMLValue.newObject(metadata)); + + const obj = YAMLValue.newObject(root); + + // Now validate the structure + assert(obj.isObject()); + const parsed = obj.toObject(); + + // Validate simple fields + assert(parsed.mustGet(YAMLValue.newString('name')).toString() == 'Test Config'); + assert(parsed.mustGet(YAMLValue.newString('version')).toF64() == 1.0); + assert(parsed.mustGet(YAMLValue.newString('enabled')).toBool() == true); + assert(parsed.mustGet(YAMLValue.newString('count')).toI64() == 42); + assert(parsed.mustGet(YAMLValue.newString('pi')).toF64() == 3.14159); + + // Validate nested object (settings) + const parsedSettings = parsed.mustGet(YAMLValue.newString('settings')).toObject(); + assert(parsedSettings.mustGet(YAMLValue.newString('debug')).toBool() == true); + assert(parsedSettings.mustGet(YAMLValue.newString('mode')).toString() == 'production'); + + // Validate deeply nested object (settings.limits) + const parsedLimits = parsedSettings.mustGet(YAMLValue.newString('limits')).toObject(); + assert(parsedLimits.mustGet(YAMLValue.newString('max_retry')).toI64() == 3); + assert(parsedLimits.mustGet(YAMLValue.newString('timeout')).toI64() == 1000); + + // Validate array of strings (tags) + const parsedTags = parsed.mustGet(YAMLValue.newString('tags')).toArray(); + assert(parsedTags.length == 3); + assert(parsedTags[0].toString() == 'important'); + assert(parsedTags[1].toString() == 'test'); + assert(parsedTags[2].toString() == 'v1'); + + // Validate array of objects (nested_array) + const parsedNestedArray = parsed.mustGet(YAMLValue.newString('nested_array')).toArray(); + assert(parsedNestedArray.length == 2); + + const parsedItem1 = parsedNestedArray[0].toObject(); + assert(parsedItem1.mustGet(YAMLValue.newString('name')).toString() == 'item1'); + assert(parsedItem1.mustGet(YAMLValue.newString('value')).toI64() == 100); + + const parsedItem2 = parsedNestedArray[1].toObject(); + assert(parsedItem2.mustGet(YAMLValue.newString('name')).toString() == 'item2'); + assert(parsedItem2.mustGet(YAMLValue.newString('value')).toI64() == 200); + + // Validate tagged value and null + const parsedMetadata = parsed.mustGet(YAMLValue.newString('metadata')).toObject(); + assert(parsedMetadata.mustGet(YAMLValue.newString('created_at')).isTagged()); + const parsedCreatedAt = parsedMetadata.mustGet(YAMLValue.newString('created_at')).toTagged(); + assert(parsedCreatedAt.tag == 'timestamp'); + assert(parsedCreatedAt.value.toString() == '2025-01-01'); + assert(parsedMetadata.mustGet(YAMLValue.newString('author')).isNull()); +}