From 3ac724c250ee4b80407f58508d49edd33d44669b Mon Sep 17 00:00:00 2001 From: Naoki Ikeguchi Date: Sun, 12 Jan 2025 20:54:17 +0900 Subject: [PATCH 1/5] feat(compiler): Bring back the `Map` type Signed-off-by: Naoki Ikeguchi --- packages/compiler/lib/intrinsics.tsp | 10 +- packages/compiler/test/checker/model.test.ts | 57 ++++++++++++ .../compiler/test/checker/relation.test.ts | 93 +++++++++++++++++++ packages/compiler/test/stdlib.test.ts | 1 + 4 files changed, 160 insertions(+), 1 deletion(-) diff --git a/packages/compiler/lib/intrinsics.tsp b/packages/compiler/lib/intrinsics.tsp index d233fd7936..783e3a0a3a 100644 --- a/packages/compiler/lib/intrinsics.tsp +++ b/packages/compiler/lib/intrinsics.tsp @@ -184,8 +184,16 @@ scalar boolean; model Array {} /** - * @dev Model with string properties where all the properties have type `Property` + * @dev Model with string properties where all the properties have type `Element` * @template Element The type of the properties */ @indexer(string, Element) model Record {} + +/** + * @dev Model with string properties where all the keys have type `Key` and all the properties have type `Element` + * @template Key The type of the keys + * @template Element The type of the properties + */ +@indexer(Key, Element) +model Map {} diff --git a/packages/compiler/test/checker/model.test.ts b/packages/compiler/test/checker/model.test.ts index 020d97f078..6fd7cf47c3 100644 --- a/packages/compiler/test/checker/model.test.ts +++ b/packages/compiler/test/checker/model.test.ts @@ -1183,6 +1183,63 @@ describe("compiler: models", () => { strictEqual(options[1].name, "string"); }); + it("can spread a Map", async () => { + testHost.addTypeSpecFile("main.tsp", `@test model Test {...Map;}`); + const { Test } = (await testHost.compile("main.tsp")) as { + Test: Model; + }; + ok(isRecordModelType(testHost.program, Test)); + strictEqual(Test.indexer?.key.name, "string"); + strictEqual(Test.indexer?.value.kind, "Scalar"); + strictEqual(Test.indexer?.value.name, "int32"); + }); + + it("can spread a Map with different value than existing props", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + @test model Test { + name: string; + ...Map; + } + `, + ); + const { Test } = (await testHost.compile("main.tsp")) as { + Test: Model; + }; + ok(isRecordModelType(testHost.program, Test)); + const nameProp = Test.properties.get("name"); + strictEqual(nameProp?.type.kind, "Scalar"); + strictEqual(nameProp?.type.name, "string"); + strictEqual(Test.indexer?.key.name, "string"); + strictEqual(Test.indexer?.value.kind, "Scalar"); + strictEqual(Test.indexer?.value.name, "int32"); + }); + + it("can spread different maps", async () => { + testHost.addTypeSpecFile( + "main.tsp", + ` + @test model Test { + ...Map; + ...Map; + } + `, + ); + const { Test } = (await testHost.compile("main.tsp")) as { + Test: Model; + }; + ok(isRecordModelType(testHost.program, Test)); + strictEqual(Test.indexer?.key.name, "string"); + const indexerValue = Test.indexer?.value; + strictEqual(indexerValue.kind, "Union"); + const options = [...indexerValue.variants.values()].map((x) => x.type); + strictEqual(options[0].kind, "Scalar"); + strictEqual(options[0].name, "int32"); + strictEqual(options[1].kind, "Scalar"); + strictEqual(options[1].name, "string"); + }); + it("emit diagnostic if spreading an T[]", async () => { testHost.addTypeSpecFile( "main.tsp", diff --git a/packages/compiler/test/checker/relation.test.ts b/packages/compiler/test/checker/relation.test.ts index 7f1ba4a3aa..6ef0477395 100644 --- a/packages/compiler/test/checker/relation.test.ts +++ b/packages/compiler/test/checker/relation.test.ts @@ -254,6 +254,7 @@ describe("compiler: checker: type relations", () => { "numeric", "float", "Record", + "Map", "bytes", "duration", "plainDate", @@ -290,6 +291,7 @@ describe("compiler: checker: type relations", () => { "numeric", "float", "Record", + "Map", "bytes", "duration", "plainDate", @@ -859,6 +861,97 @@ describe("compiler: checker: type relations", () => { }); }); + describe("Map target", () => { + ["Map"].forEach((x) => { + it(`can assign ${x}`, async () => { + await expectTypeAssignable({ source: x, target: "Map" }); + }); + }); + + it("can assign empty object", async () => { + await expectTypeAssignable({ source: "{}", target: "Map" }); + }); + + it("can assign object with property being the same type", async () => { + await expectTypeAssignable({ source: "{foo: string}", target: "Map" }); + await expectTypeAssignable({ + source: "{foo: string, bar: string}", + target: "Map", + }); + }); + + it("can assign object with property being the of subtype type", async () => { + await expectTypeAssignable({ source: "{foo: int32}", target: "Map" }); + await expectTypeAssignable({ + source: "{foo: float, bar: int64}", + target: "Map", + }); + }); + + it("can assign a map of subtypes", async () => { + await expectTypeAssignable({ source: "Map", target: "Map" }); + }); + + it("can assign object that implement the same indexer", async () => { + await expectTypeAssignable({ + source: "Foo", + target: "Map", + commonCode: ` + model Foo is Map { + prop1: string; + prop2: string; + } + `, + }); + }); + + it("type with spread indexer allow other properties to no match index", async () => { + await expectTypeAssignable({ + source: "{age: int32, other: string}", + target: "Foo", + commonCode: ` + model Foo { + age: int32; + ...Map; + } + `, + }); + }); + + it("emit diagnostic assigning other type", async () => { + await expectTypeNotAssignable( + { source: `string`, target: "Map" }, + { + code: "unassignable", + message: "Type 'string' is not assignable to type 'Map'", + }, + ); + }); + + it("emit diagnostic assigning Map of incompatible type", async () => { + await expectTypeNotAssignable( + { source: `Map`, target: "Map" }, + { + code: "unassignable", + message: [ + `Type 'Map' is not assignable to type 'Map'`, + " Type 'int32' is not assignable to type 'string'", + ].join("\n"), + }, + ); + }); + + it("emit diagnostic if some properties are different type", async () => { + await expectTypeNotAssignable( + { source: `{foo: string, bar: int32}`, target: "Map" }, + { + code: "unassignable", + message: "Type 'int32' is not assignable to type 'string'", + }, + ); + }); + }); + describe("models", () => { it("can assign empty object", async () => { await expectTypeAssignable({ source: "{}", target: "{}" }); diff --git a/packages/compiler/test/stdlib.test.ts b/packages/compiler/test/stdlib.test.ts index 10dad9ae14..ed8b491bb1 100644 --- a/packages/compiler/test/stdlib.test.ts +++ b/packages/compiler/test/stdlib.test.ts @@ -20,6 +20,7 @@ const intrinsicTypes = [ "decimal", "Array", "Record", + "Map", "string[]", ]; From 7abf3eef29ad23bd43eb51fddbf0f3a14d1d606f Mon Sep 17 00:00:00 2001 From: Naoki Ikeguchi Date: Sun, 12 Jan 2025 21:08:37 +0900 Subject: [PATCH 2/5] feat(compiler): Allow enums and unions in model indexers Signed-off-by: Naoki Ikeguchi --- packages/compiler/src/core/types.ts | 2 +- packages/compiler/src/lib/intrinsic/decorators.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index ec31817968..e030c40706 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -280,7 +280,7 @@ export type NeverIndexer = { }; export type ModelIndexer = { - readonly key: Scalar; + readonly key: Enum | Scalar | Union; readonly value: Type; }; diff --git a/packages/compiler/src/lib/intrinsic/decorators.ts b/packages/compiler/src/lib/intrinsic/decorators.ts index da86fd3c69..9971b0f8d2 100644 --- a/packages/compiler/src/lib/intrinsic/decorators.ts +++ b/packages/compiler/src/lib/intrinsic/decorators.ts @@ -1,12 +1,19 @@ import { DocTarget, setDocData } from "../../core/intrinsic-type-state.js"; import type { Program } from "../../core/program.js"; -import type { DecoratorContext, ModelIndexer, Scalar, Type } from "../../core/types.js"; +import type { + DecoratorContext, + Enum, + ModelIndexer, + Scalar, + Type, + Union, +} from "../../core/types.js"; const indexTypeKey = Symbol.for(`TypeSpec.index`); export const indexerDecorator = ( context: DecoratorContext, target: Type, - key: Scalar, + key: Enum | Scalar | Union, value: Type, ) => { const indexer: ModelIndexer = { key, value }; From d22db35ddca7e0fa49dba53adb41bc63ced6b19e Mon Sep 17 00:00:00 2001 From: Naoki Ikeguchi Date: Sun, 12 Jan 2025 20:54:37 +0900 Subject: [PATCH 3/5] test(openapi3): Add tests for `Map` to be additionalProperties Signed-off-by: Naoki Ikeguchi --- .../test/additional-properties.test.ts | 177 +++++++++++++----- 1 file changed, 130 insertions(+), 47 deletions(-) diff --git a/packages/openapi3/test/additional-properties.test.ts b/packages/openapi3/test/additional-properties.test.ts index 0fd065f5d9..341aa91585 100644 --- a/packages/openapi3/test/additional-properties.test.ts +++ b/packages/openapi3/test/additional-properties.test.ts @@ -3,78 +3,161 @@ import { describe, it } from "vitest"; import { oapiForModel } from "./test-host.js"; describe("openapi3: Additional properties", () => { - describe("extends Record", () => { - it("doesn't set additionalProperties on model itself", async () => { - const res = await oapiForModel("Pet", `model Pet extends Record {};`); - deepStrictEqual(res.schemas.Pet.additionalProperties, undefined); + describe("Record", () => { + describe("extends Record", () => { + it("doesn't set additionalProperties on model itself", async () => { + const res = await oapiForModel("Pet", `model Pet extends Record {};`); + deepStrictEqual(res.schemas.Pet.additionalProperties, undefined); + }); + + it("links to an allOf of the Record schema", async () => { + const res = await oapiForModel("Pet", `model Pet extends Record {};`); + deepStrictEqual(res.schemas.Pet.allOf, [{ type: "object", additionalProperties: {} }]); + }); + + it("include model properties", async () => { + const res = await oapiForModel( + "Pet", + `model Pet extends Record { name: string };`, + ); + deepStrictEqual(res.schemas.Pet.properties, { + name: { type: "string" }, + }); + }); }); - it("links to an allOf of the Record schema", async () => { - const res = await oapiForModel("Pet", `model Pet extends Record {};`); - deepStrictEqual(res.schemas.Pet.allOf, [{ type: "object", additionalProperties: {} }]); + describe("is Record", () => { + it("set additionalProperties on model itself", async () => { + const res = await oapiForModel("Pet", `model Pet is Record {};`); + deepStrictEqual(res.schemas.Pet.additionalProperties, {}); + }); + + it("set additional properties type", async () => { + const res = await oapiForModel("Pet", `model Pet is Record {};`); + deepStrictEqual(res.schemas.Pet.additionalProperties, { + type: "string", + }); + }); + + it("include model properties", async () => { + const res = await oapiForModel("Pet", `model Pet is Record { name: string };`); + deepStrictEqual(res.schemas.Pet.properties, { + name: { type: "string" }, + }); + }); }); - it("include model properties", async () => { - const res = await oapiForModel("Pet", `model Pet extends Record { name: string };`); - deepStrictEqual(res.schemas.Pet.properties, { - name: { type: "string" }, + describe("referencing Record", () => { + it("add additionalProperties inline for property of type Record", async () => { + const res = await oapiForModel( + "Pet", + ` + model Pet { details: Record }; + `, + ); + + ok(res.isRef); + ok(res.schemas.Pet, "expected definition named Pet"); + deepStrictEqual(res.schemas.Pet.properties.details, { + type: "object", + additionalProperties: {}, + }); + }); + }); + + it("set additionalProperties if model extends Record with leaf type", async () => { + const res = await oapiForModel( + "Pet", + ` + @doc("value") + scalar Value; + model Pet is Record {}; + `, + ); + + ok(res.isRef); + ok(res.schemas.Pet, "expected definition named Pet"); + deepStrictEqual(res.schemas.Pet.additionalProperties, { + $ref: "#/components/schemas/Value", }); }); }); - describe("is Record", () => { - it("set additionalProperties on model itself", async () => { - const res = await oapiForModel("Pet", `model Pet is Record {};`); - deepStrictEqual(res.schemas.Pet.additionalProperties, {}); + describe("Map", () => { + describe("extends Map", () => { + it("doesn't set additionalProperties on model itself", async () => { + const res = await oapiForModel("Pet", `model Pet extends Map {};`); + deepStrictEqual(res.schemas.Pet.additionalProperties, undefined); + }); + + it("links to an allOf of the Map schema", async () => { + const res = await oapiForModel("Pet", `model Pet extends Map {};`); + deepStrictEqual(res.schemas.Pet.allOf, [{ type: "object", additionalProperties: {} }]); + }); + + it("include model properties", async () => { + const res = await oapiForModel( + "Pet", + `model Pet extends Map { name: string };`, + ); + deepStrictEqual(res.schemas.Pet.properties, { + name: { type: "string" }, + }); + }); }); - it("set additional properties type", async () => { - const res = await oapiForModel("Pet", `model Pet is Record {};`); - deepStrictEqual(res.schemas.Pet.additionalProperties, { - type: "string", + describe("is Map", () => { + it("set additionalProperties on model itself", async () => { + const res = await oapiForModel("Pet", `model Pet is Map {};`); + deepStrictEqual(res.schemas.Pet.additionalProperties, {}); + }); + + it("set additional properties type", async () => { + const res = await oapiForModel("Pet", `model Pet is Map {};`); + deepStrictEqual(res.schemas.Pet.additionalProperties, { + type: "string", + }); + }); + + it("include model properties", async () => { + const res = await oapiForModel( + "Pet", + `model Pet is Map { name: string };`, + ); + deepStrictEqual(res.schemas.Pet.properties, { + name: { type: "string" }, + }); }); }); - it("include model properties", async () => { - const res = await oapiForModel("Pet", `model Pet is Record { name: string };`); - deepStrictEqual(res.schemas.Pet.properties, { - name: { type: "string" }, + describe("referencing Map", () => { + it("add additionalProperties inline for property of type Map", async () => { + const res = await oapiForModel("Pet", `model Pet { details: Map };`); + + ok(res.isRef); + ok(res.schemas.Pet, "expected definition named Pet"); + deepStrictEqual(res.schemas.Pet.properties.details, { + type: "object", + additionalProperties: {}, + }); }); }); - }); - describe("referencing Record", () => { - it("add additionalProperties inline for property of type Record", async () => { + it("set additionalProperties if model extends Map with leaf type", async () => { const res = await oapiForModel( "Pet", ` - model Pet { details: Record }; + @doc("value") + scalar Value; + model Pet is Map {}; `, ); ok(res.isRef); ok(res.schemas.Pet, "expected definition named Pet"); - deepStrictEqual(res.schemas.Pet.properties.details, { - type: "object", - additionalProperties: {}, + deepStrictEqual(res.schemas.Pet.additionalProperties, { + $ref: "#/components/schemas/Value", }); }); }); - - it("set additionalProperties if model extends Record with leaf type", async () => { - const res = await oapiForModel( - "Pet", - ` - @doc("value") - scalar Value; - model Pet is Record {}; - `, - ); - - ok(res.isRef); - ok(res.schemas.Pet, "expected definition named Pet"); - deepStrictEqual(res.schemas.Pet.additionalProperties, { - $ref: "#/components/schemas/Value", - }); - }); }); From 605e033ac7c1684d714ab4adfbf069624cad3de6 Mon Sep 17 00:00:00 2001 From: Naoki Ikeguchi Date: Sun, 12 Jan 2025 21:28:10 +0900 Subject: [PATCH 4/5] chore: Add chronus changelog --- .../changes/feat-bring-back-map-type-2025-0-12-21-27-56.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/feat-bring-back-map-type-2025-0-12-21-27-56.md diff --git a/.chronus/changes/feat-bring-back-map-type-2025-0-12-21-27-56.md b/.chronus/changes/feat-bring-back-map-type-2025-0-12-21-27-56.md new file mode 100644 index 0000000000..3345ee7532 --- /dev/null +++ b/.chronus/changes/feat-bring-back-map-type-2025-0-12-21-27-56.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Bring back the `Map` type to intrinsics \ No newline at end of file From 93a796ae439a0d1af2af7aab8ee27c90d03f2bce Mon Sep 17 00:00:00 2001 From: Naoki Ikeguchi Date: Sun, 12 Jan 2025 21:11:42 +0900 Subject: [PATCH 5/5] chore(website): Update generated files Signed-off-by: Naoki Ikeguchi --- .../standard-library/built-in-data-types.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/website/src/content/docs/docs/standard-library/built-in-data-types.md b/website/src/content/docs/docs/standard-library/built-in-data-types.md index 80aba54b41..c323f815bf 100644 --- a/website/src/content/docs/docs/standard-library/built-in-data-types.md +++ b/website/src/content/docs/docs/standard-library/built-in-data-types.md @@ -122,6 +122,24 @@ model ExampleOptions | title? | [`string`](#string) | The title of the example | | description? | [`string`](#string) | Description of the example | +### `Map` {#Map} + + + +```typespec +model Map +``` + +#### Template Parameters +| Name | Description | +|------|-------------| +| Key | The type of the keys | +| Element | The type of the properties | + + +#### Properties +None + ### `object` {#object} :::caution **Deprecated**: object is deprecated. Please use {} for an empty model, `Record` for a record with unknown property types, `unknown[]` for an array.