diff --git a/src/json-schema/types.ts b/src/json-schema/types.ts index 98b43946e6..a19bef7db2 100644 --- a/src/json-schema/types.ts +++ b/src/json-schema/types.ts @@ -36,6 +36,9 @@ export interface JsonSchemaObject extends JsonSchemaGenericKeywords { }; required?: string[]; additionalProperties?: boolean | JsonSchemaNode; + patternProperties?: { + [key: string]: JsonSchemaNode; + }; const?: object; } diff --git a/src/json-type/codegen/binary/__tests__/testBinaryCodegen.ts b/src/json-type/codegen/binary/__tests__/testBinaryCodegen.ts index cb2a6eb241..ce33ba0030 100644 --- a/src/json-type/codegen/binary/__tests__/testBinaryCodegen.ts +++ b/src/json-type/codegen/binary/__tests__/testBinaryCodegen.ts @@ -369,6 +369,40 @@ export const testBinaryCodegen = (transcode: (system: TypeSystem, type: Type, va }); }); + describe('"map" type', () => { + test('can encode empty map', () => { + const system = new TypeSystem(); + const t = system.t; + const type = t.map; + const value: {} = {}; + expect(transcode(system, type, value)).toStrictEqual(value); + }); + + test('can encode empty map with one key', () => { + const system = new TypeSystem(); + const t = system.t; + const type = t.map; + const value: {} = {a: 'asdf'}; + expect(transcode(system, type, value)).toStrictEqual(value); + }); + + test('can encode typed map with two keys', () => { + const system = new TypeSystem(); + const t = system.t; + const type = t.Map(t.bool); + const value: {} = {x: true, y: false}; + expect(transcode(system, type, value)).toStrictEqual(value); + }); + + test('can encode nested maps', () => { + const system = new TypeSystem(); + const t = system.t; + const type = t.Map(t.Map(t.bool)); + const value: {} = {a: {x: true, y: false}}; + expect(transcode(system, type, value)).toStrictEqual(value); + }); + }); + describe('"ref" type', () => { test('can encode a simple reference', () => { const system = new TypeSystem(); diff --git a/src/json-type/codegen/capacity/__tests__/CapacityEstimatorCodegenContext.spec.ts b/src/json-type/codegen/capacity/__tests__/CapacityEstimatorCodegenContext.spec.ts index 2632740548..ed7bfb5487 100644 --- a/src/json-type/codegen/capacity/__tests__/CapacityEstimatorCodegenContext.spec.ts +++ b/src/json-type/codegen/capacity/__tests__/CapacityEstimatorCodegenContext.spec.ts @@ -155,6 +155,38 @@ describe('object', () => { }); }); +describe('map', () => { + test('empty', () => { + const system = new TypeSystem(); + const type = system.t.map; + const estimator = type.compileCapacityEstimator({}); + expect(estimator(123)).toBe(maxEncodingCapacity({})); + }); + + test('with one field', () => { + const system = new TypeSystem(); + const type = system.t.Map(system.t.bool); + const estimator = type.compileCapacityEstimator({}); + expect(estimator({foo: true})).toBe(maxEncodingCapacity({foo: true})); + }); + + test('three number fields', () => { + const system = new TypeSystem(); + const type = system.t.Map(system.t.num); + const estimator = type.compileCapacityEstimator({}); + const data = {foo: 1, bar: 2, baz: 3}; + expect(estimator(data)).toBe(maxEncodingCapacity(data)); + }); + + test('nested maps', () => { + const system = new TypeSystem(); + const type = system.t.Map(system.t.Map(system.t.str)); + const estimator = type.compileCapacityEstimator({}); + const data = {foo: {bar: 'baz'}, baz: {bar: 'foo'}}; + expect(estimator(data)).toBe(maxEncodingCapacity(data)); + }); +}); + describe('ref', () => { test('two hops', () => { const system = new TypeSystem(); diff --git a/src/json-type/codegen/json/__tests__/json.spec.ts b/src/json-type/codegen/json/__tests__/json.spec.ts index 90ada7e863..25474f9e12 100644 --- a/src/json-type/codegen/json/__tests__/json.spec.ts +++ b/src/json-type/codegen/json/__tests__/json.spec.ts @@ -121,6 +121,28 @@ describe('"obj" type', () => { }); }); +describe('"map" type', () => { + test('serializes a map', () => { + const type = s.Map(s.num); + exec(type, {a: 1, b: 2, c: 3}); + }); + + test('serializes empty map', () => { + const type = s.Map(s.num); + exec(type, {}); + }); + + test('serializes a map with a single key', () => { + const type = s.Map(s.num); + exec(type, {'0': 0}); + }); + + test('serializes a map in a map', () => { + const type = s.Map(s.Map(s.bool)); + exec(type, {a: {b: true}}); + }); +}); + describe('general', () => { test('serializes according to schema a POJO object', () => { const type = s.Object({ diff --git a/src/json-type/constants.ts b/src/json-type/constants.ts index f86d62e04e..49af3008b6 100644 --- a/src/json-type/constants.ts +++ b/src/json-type/constants.ts @@ -6,6 +6,7 @@ export enum ValidationError { ARR, TUP, OBJ, + MAP, KEY, KEYS, BIN, @@ -32,6 +33,7 @@ export const ValidationErrorMessage = { [ValidationError.ARR]: 'Not an array.', [ValidationError.TUP]: 'Not a tuple.', [ValidationError.OBJ]: 'Not an object.', + [ValidationError.MAP]: 'Not a map.', [ValidationError.KEY]: 'Missing key.', [ValidationError.KEYS]: 'Too many or missing object keys.', [ValidationError.BIN]: 'Not a binary.', diff --git a/src/json-type/schema/__tests__/type.spec.ts b/src/json-type/schema/__tests__/type.spec.ts index 6cd149b9d9..4698e7de27 100644 --- a/src/json-type/schema/__tests__/type.spec.ts +++ b/src/json-type/schema/__tests__/type.spec.ts @@ -14,7 +14,7 @@ test('can generate any type', () => { s.prop('address', address), s.prop('timeCreated', s.Number()), s.prop('tags', s.Array(s.Or(s.Number(), s.String()))), - s.prop('elements', s.Map(s.str)) + s.prop('elements', s.Map(s.str)), ); expect(userType).toMatchObject({ diff --git a/src/json-type/type/TypeBuilder.ts b/src/json-type/type/TypeBuilder.ts index 7d4e8bfd35..4490a9532c 100644 --- a/src/json-type/type/TypeBuilder.ts +++ b/src/json-type/type/TypeBuilder.ts @@ -46,6 +46,10 @@ export class TypeBuilder { return this.Object(); } + get map() { + return this.Map(this.any); + } + get fn() { return this.Function(this.any, this.any); } @@ -129,6 +133,12 @@ export class TypeBuilder { return field; } + public Map(type: T, options?: schema.Optional) { + const map = new classes.MapType(type, options); + map.system = this.system; + return map; + } + public Or(...types: F) { const or = new classes.OrType(types); or.system = this.system; @@ -176,6 +186,8 @@ export class TypeBuilder { ), ).options(node); } + case 'map': + return this.Map(this.import(node.type), node); case 'const': return this.Const(node.value).options(node); case 'or': diff --git a/src/json-type/type/__tests__/fixtures.ts b/src/json-type/type/__tests__/fixtures.ts index d6b00e3ec8..5f821be0e5 100644 --- a/src/json-type/type/__tests__/fixtures.ts +++ b/src/json-type/type/__tests__/fixtures.ts @@ -17,6 +17,9 @@ export const everyType = t.Object( t.prop('emptyArray', t.arr.options({max: 0})), t.prop('oneItemArray', t.arr.options({min: 1, max: 1})), t.prop('objWithArray', t.Object(t.propOpt('arr', t.arr), t.propOpt('arr2', t.arr))), + t.prop('emptyMap', t.map), + t.prop('mapWithOneNumField', t.Map(t.num)), + t.prop('mapOfStr', t.Map(t.str)), ); export const everyTypeValue: TypeOf> = { @@ -37,4 +40,12 @@ export const everyTypeValue: TypeOf> = { objWithArray: { arr: [1, 2, 3], }, + emptyMap: {}, + mapWithOneNumField: { + a: 1, + }, + mapOfStr: { + a: 'a', + b: 'b', + }, }; diff --git a/src/json-type/type/__tests__/getJsonSchema.spec.ts b/src/json-type/type/__tests__/getJsonSchema.spec.ts index 7b47810633..1fd7789e29 100644 --- a/src/json-type/type/__tests__/getJsonSchema.spec.ts +++ b/src/json-type/type/__tests__/getJsonSchema.spec.ts @@ -36,6 +36,7 @@ test('can print a type', () => { ) .options({format: 'cbor'}), ), + t.prop('map', t.Map(t.str)), ) .options({unknownFields: true}); // console.log(JSON.stringify(type.toJsonSchema(), null, 2)); @@ -83,6 +84,14 @@ test('can print a type', () => { "id": { "type": "string", }, + "map": { + "patternProperties": { + ".*": { + "type": "string", + }, + }, + "type": "object", + }, "numberProperty": { "exclusiveMinimum": 3.14, "type": "number", @@ -178,6 +187,7 @@ test('can print a type', () => { "unionProperty", "operation", "binaryOperation", + "map", ], "type": "object", } diff --git a/src/json-type/type/__tests__/random.spec.ts b/src/json-type/type/__tests__/random.spec.ts index 7bd32ec814..dbd672eda8 100644 --- a/src/json-type/type/__tests__/random.spec.ts +++ b/src/json-type/type/__tests__/random.spec.ts @@ -13,12 +13,23 @@ test('generates random JSON', () => { t.prop('name', t.str), t.prop('tags', t.Array(t.str)), t.propOpt('scores', t.Array(t.num)), + t.prop('refs', t.Map(t.str)), ); const json = type.random(); expect(json).toMatchInlineSnapshot(` { "id": "", "name": "1", + "refs": { + "259<@CGK": "UY\\\`c", + ";>BEILPT": "^beimp", + "HKORVY]\`": "korvy}#", + "LOSWZ^ae": "pswz #'*", + "_cfjmqtx": "гггггг诶诶诶诶", + "nquy|"%)": "4", + "w{ $'+/2": "=@", + "гггг诶诶诶诶": "MQTX", + }, "tags": [ "@CG", "QUY\\\`", diff --git a/src/json-type/type/__tests__/toString.spec.ts b/src/json-type/type/__tests__/toString.spec.ts index 134f04e388..7d7fdf6597 100644 --- a/src/json-type/type/__tests__/toString.spec.ts +++ b/src/json-type/type/__tests__/toString.spec.ts @@ -35,6 +35,7 @@ test('can print a type', () => { ) .options({format: 'cbor'}), ), + t.prop('map', t.Map(t.num)), ) .options({unknownFields: true}); // console.log(type + ''); @@ -83,11 +84,14 @@ test('can print a type', () => { │ │ └─ str │ └─ "value": │ └─ any - └─ "binaryOperation": - └─ bin { format = "cbor" } - └─ tup { description = "Should always have 3 elements" } - ├─ const { description = "7 is the magic number" } → 7 - ├─ str - └─ any" + ├─ "binaryOperation": + │ └─ bin { format = "cbor" } + │ └─ tup { description = "Should always have 3 elements" } + │ ├─ const { description = "7 is the magic number" } → 7 + │ ├─ str + │ └─ any + └─ "map": + └─ map + └─ num" `); }); diff --git a/src/json-type/type/__tests__/toTypeScriptAst.spec.ts b/src/json-type/type/__tests__/toTypeScriptAst.spec.ts index 396e631dfd..9a567e4e54 100644 --- a/src/json-type/type/__tests__/toTypeScriptAst.spec.ts +++ b/src/json-type/type/__tests__/toTypeScriptAst.spec.ts @@ -207,6 +207,31 @@ describe('obj', () => { }); }); +describe('map', () => { + test('can emit tuple AST', () => { + const system = new TypeSystem(); + const {t} = system; + const type = system.t.Map(t.num).options({ + title: 'title', + description: 'description', + }); + expect(type.toTypeScriptAst()).toMatchInlineSnapshot(` + { + "node": "TypeReference", + "typeArguments": [ + { + "node": "StringKeyword", + }, + { + "node": "NumberKeyword", + }, + ], + "typeName": "Record", + } + `); + }); +}); + describe('ref', () => { test('can emit reference AST', () => { const system = new TypeSystem(); diff --git a/src/json-type/type/__tests__/validateTestSuite.ts b/src/json-type/type/__tests__/validateTestSuite.ts index b772183acd..eeadfc0f13 100644 --- a/src/json-type/type/__tests__/validateTestSuite.ts +++ b/src/json-type/type/__tests__/validateTestSuite.ts @@ -417,6 +417,37 @@ export const validateTestSuite = (validate: (type: Type, value: unknown) => void }); }); + describe('map', () => { + test('accepts empty object as input', () => { + const type = t.map; + validate(type, {}); + }); + + test('does not accept empty array as input', () => { + const type = t.map; + expect(() => validate(type, [])).toThrow(); + }); + + test('validates "any" map', () => { + const type = t.map; + validate(type, { + a: 'str', + b: 123, + c: true, + }); + }); + + test('validates contained type', () => { + const type = t.Map(t.str); + validate(type, {}); + validate(type, {a: ''}); + validate(type, {b: 'asdf'}); + expect(() => validate(type, {c: 123})).toThrowErrorMatchingInlineSnapshot(`"STR"`); + expect(() => validate(type, {c: false})).toThrowErrorMatchingInlineSnapshot(`"STR"`); + expect(() => validate(type, [])).toThrowErrorMatchingInlineSnapshot(`"MAP"`); + }); + }); + describe('ref', () => { test('validates after recursively resolving', () => { const t = system.t; diff --git a/src/json-type/type/classes.ts b/src/json-type/type/classes.ts index 15e4e09ec3..d85fcb17a1 100644 --- a/src/json-type/type/classes.ts +++ b/src/json-type/type/classes.ts @@ -1142,12 +1142,7 @@ export class ArrayType extends AbstractType extends AbstractType>> { + protected schema: schema.MapSchema; + + constructor(protected type: T, options?: schema.Optional) { + super(); + this.schema = schema.s.Map(schema.s.any, options); + } + + public getSchema(ctx?: TypeExportContext): schema.MapSchema> { + return { + ...this.schema, + type: this.type.getSchema(ctx) as any, + }; + } + + public toJsonSchema(): jsonSchema.JsonSchemaObject { + const jsonSchema = { + type: 'object', + patternProperties: { + '.*': this.type.toJsonSchema(), + }, + }; + return jsonSchema; + } + + public getOptions(): schema.Optional>> { + const {__t, type, ...options} = this.schema; + return options as any; + } + + public validateSchema(): void { + const schema = this.getSchema(); + validateTType(schema, 'map'); + this.type.validateSchema(); + } + + public codegenValidator(ctx: ValidatorCodegenContext, path: ValidationPath, r: string): void { + const err = ctx.err(ValidationError.MAP, path); + ctx.js(`if (!${r} || (typeof ${r} !== 'object') || (${r}.constructor !== Object)) return ${err};`); + const rKeys = ctx.codegen.var(`Object.keys(${r});`); + const rLength = ctx.codegen.var(`${rKeys}.length`); + const rKey = ctx.codegen.r(); + const rValue = ctx.codegen.r(); + ctx.js(`for (var ${rKey}, ${rValue}, i = 0; i < ${rLength}; i++) {`); + ctx.js(`${rKey} = ${rKeys}[i];`); + ctx.js(`${rValue} = ${r}[${rKey}];`); + this.type.codegenValidator(ctx, [...path, {r: rKey}], rValue); + ctx.js(`}`); + ctx.emitCustomValidators(this, path, r); + } + + public codegenJsonTextEncoder(ctx: JsonTextEncoderCodegenContext, value: JsExpression): void { + ctx.writeText('{'); + const r = ctx.codegen.var(value.use()); + const rKeys = ctx.codegen.var(`Object.keys(${r})`); + const rLength = ctx.codegen.var(`${rKeys}.length`); + const rKey = ctx.codegen.var(); + ctx.codegen.if(`${rLength}`, () => { + ctx.js(`${rKey} = ${rKeys}[0];`); + ctx.js(`s += asString(${rKey}) + ':';`); + this.type.codegenJsonTextEncoder(ctx, new JsExpression(() => `${r}[${rKey}]`)); + }); + ctx.js(`for (var i = 1; i < ${rLength}; i++) {`); + ctx.js(`${rKey} = ${rKeys}[i];`); + ctx.js(`s += ',' + asString(${rKey}) + ':';`); + this.type.codegenJsonTextEncoder(ctx, new JsExpression(() => `${r}[${rKey}]`)); + ctx.js(`}`); + ctx.writeText('}'); + } + + private codegenBinaryEncoder(ctx: BinaryEncoderCodegenContext, value: JsExpression): void { + const type = this.type; + const codegen = ctx.codegen; + const r = codegen.var(value.use()); + const rKeys = codegen.var(`Object.keys(${r})`); + const rKey = codegen.var(); + const rLength = codegen.var(`${rKeys}.length`); + const ri = codegen.var('0'); + ctx.js(`encoder.writeObjHdr(${rLength});`); + ctx.js(`for(; ${ri} < ${rLength}; ${ri}++){`); + ctx.js(`${rKey} = ${rKeys}[${ri}];`); + ctx.js(`encoder.writeStr(${rKey});`); + const expr = new JsExpression(() => `${r}[${rKey}]`); + if (ctx instanceof CborEncoderCodegenContext) type.codegenCborEncoder(ctx, expr); + else if (ctx instanceof MessagePackEncoderCodegenContext) type.codegenMessagePackEncoder(ctx, expr); + else throw new Error('Unknown encoder'); + ctx.js(`}`); + } + + public codegenCborEncoder(ctx: CborEncoderCodegenContext, value: JsExpression): void { + this.codegenBinaryEncoder(ctx, value); + } + + public codegenMessagePackEncoder(ctx: MessagePackEncoderCodegenContext, value: JsExpression): void { + this.codegenBinaryEncoder(ctx, value); + } + + public codegenJsonEncoder(ctx: JsonEncoderCodegenContext, value: JsExpression): void { + const type = this.type; + const objStartBlob = ctx.gen((encoder) => encoder.writeStartObj()); + const objEndBlob = ctx.gen((encoder) => encoder.writeEndObj()); + const separatorBlob = ctx.gen((encoder) => encoder.writeObjSeparator()); + const keySeparatorBlob = ctx.gen((encoder) => encoder.writeObjKeySeparator()); + const codegen = ctx.codegen; + const r = codegen.var(value.use()); + const rKeys = codegen.var(`Object.keys(${r})`); + const rKey = codegen.var(); + const rLength = codegen.var(`${rKeys}.length`); + ctx.blob(objStartBlob); + ctx.codegen.if(`${rLength}`, () => { + ctx.js(`${rKey} = ${rKeys}[0];`); + codegen.js(`encoder.writeStr(${rKey});`); + ctx.blob(keySeparatorBlob); + type.codegenJsonEncoder(ctx, new JsExpression(() => `${r}[${rKey}]`)); + }); + ctx.js(`for (var i = 1; i < ${rLength}; i++) {`); + ctx.js(`${rKey} = ${rKeys}[i];`); + ctx.blob(separatorBlob); + codegen.js(`encoder.writeStr(${rKey});`); + ctx.blob(keySeparatorBlob); + type.codegenJsonEncoder(ctx, new JsExpression(() => `${r}[${rKey}]`)); + ctx.js(`}`); + ctx.blob(objEndBlob); + } + + public codegenCapacityEstimator(ctx: CapacityEstimatorCodegenContext, value: JsExpression): void { + const codegen = ctx.codegen; + ctx.inc(MaxEncodingOverhead.Object); + const r = codegen.var(value.use()); + const rKeys = codegen.var(`Object.keys(${r})`); + const rKey = codegen.var(); + const rLen = codegen.var(`${rKeys}.length`); + codegen.js(`size += ${MaxEncodingOverhead.ObjectElement} * ${rLen}`); + const type = this.type; + const fn = type.compileCapacityEstimator({ + system: ctx.options.system, + name: ctx.options.name, + }); + const rFn = codegen.linkDependency(fn); + const ri = codegen.var('0'); + codegen.js(`for (; ${ri} < ${rLen}; ${ri}++) {`); + codegen.js(`${rKey} = ${rKeys}[${ri}];`); + codegen.js( + `size += ${MaxEncodingOverhead.String} + ${MaxEncodingOverhead.StringLengthMultiplier} * ${rKey}.length;`, + ); + codegen.js(`size += ${rFn}(${r}[${rKey}]);`); + codegen.js(`}`); + } + + public random(): Record { + const length = Math.round(Math.random() * 10); + const res: Record = {}; + for (let i = 0; i < length; i++) res[RandomJson.genString(length)] = this.type.random(); + return res; + } + + public toTypeScriptAst(): ts.TsTypeReference { + const node: ts.TsTypeReference = { + node: 'TypeReference', + typeName: 'Record', + typeArguments: [{node: 'StringKeyword'}, this.type.toTypeScriptAst()], + }; + // augmentWithComment(this.schema, node); + return node; + } + + public toJson(value: unknown, system: TypeSystem | undefined = this.system): json_string { + const map = value as Record; + const keys = Object.keys(map); + const length = keys.length; + if (!length) return '{}' as json_string; + const last = length - 1; + const type = this.type; + let str = '{'; + for (let i = 0; i < last; i++) { + const key = keys[i]; + const val = (value as any)[key]; + if (val === undefined) continue; + str += asString(key) + ':' + type.toJson(val as any, system) + ','; + } + const key = keys[last]; + const val = (value as any)[key]; + if (val !== undefined) { + str += asString(key) + ':' + type.toJson(val as any, system); + } else if (str.length > 1) str = str.slice(0, -1); + return (str + '}') as json_string; + } + + public toString(tab: string = ''): string { + return super.toString(tab) + printTree(tab, [(tab) => this.type.toString(tab)]); + } +} + export class RefType extends AbstractType>> { protected schema: schema.RefSchema>; diff --git a/src/json-type/type/types.ts b/src/json-type/type/types.ts index 804493ad66..c93485d917 100644 --- a/src/json-type/type/types.ts +++ b/src/json-type/type/types.ts @@ -15,6 +15,7 @@ export type Type = | classes.ArrayType | classes.TupleType | classes.ObjectType + | classes.MapType | classes.RefType | classes.OrType | classes.FunctionType