Skip to content

Commit e9003b1

Browse files
chrisradekChristopher Radek
andauthored
Fix typeChangedFrom of template/union/tuple to catch invalid versioning (microsoft#5262)
Fixes microsoft#4752 and replaces microsoft#5191 microsoft#5191 was the original attempt to fix this issue. However the implementation only worked if the current type of a `@typeChangedFrom` decorated property wasn't itself a template/union. This PR gets away from using `validateReference` like the previous PR did, and instead will recursively crawl the target type such that it visits any template args/union variants/tuple members, checking if each sub-type available for the version being validated. Edit: Here is an example illustrating the issue introduced in microsoft#5191: [playground](https://cadlplayground.z22.web.core.windows.net/prs/5191/?c=aW1wb3J0ICJAdHlwZXNwZWMvdmVyc2lvbmluZyI7DQoNCnVzaW5nIFR5cGVTcGVjLlbJH8UeQMcvZWQoxxpzKQ0KQHNlcnZpY2UNCm5hbWVzcGFjZSBEZW1vU8YXxTplbnVtIMg0IHsNCiAgdjEsxQcyLA0KfcVeYWRky1oudjEpDQptb2RlbCBPcmlnaW5hbCB71ioyySpVcGRhdGVkxynGFEJhcsZz5QD4Q2hhbmdlZEZyb23MQizJZVtdKcQtZmFpbHM6yFFbXeUAyN9E0ERwYXNzZcpFO%2BcA6g%3D%3D&e=%40typespec%2Fopenapi3&options=%7B%7D) --------- Co-authored-by: Christopher Radek <[email protected]>
1 parent 74a7abb commit e9003b1

File tree

5 files changed

+350
-13
lines changed

5 files changed

+350
-13
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
changeKind: internal
3+
packages:
4+
- "@typespec/versioning"
5+
---
6+
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: fix
3+
packages:
4+
- "@typespec/versioning"
5+
---
6+
7+
Fixes diagnostics for @typeChangedFrom to properly detect when an incompatible version is referenced inside of a template, union, or tuple.

packages/versioning/src/validate.ts

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -223,21 +223,64 @@ function validateMultiTypeReference(program: Program, source: Type, options?: Ty
223223
if (versionTypeMap === undefined) return;
224224
for (const [version, type] of versionTypeMap!) {
225225
if (type === undefined) continue;
226+
validateTypeAvailability(program, version, type, source, options);
227+
}
228+
}
229+
230+
/**
231+
* Ensures that a type is available in a given version.
232+
* For types that may wrap other types, e.g. unions, tuples, or template instances,
233+
* this function will recursively check the wrapped types.
234+
*/
235+
function validateTypeAvailability(
236+
program: Program,
237+
version: Version,
238+
targetType: Type,
239+
source: Type,
240+
options?: TypeNameOptions,
241+
) {
242+
const typesToCheck: Type[] = [targetType];
243+
while (typesToCheck.length) {
244+
const type = typesToCheck.pop()!;
226245
const availMap = getAvailabilityMap(program, type);
227-
const availability = availMap?.get(version.name) ?? Availability.Available;
228-
if ([Availability.Added, Availability.Available].includes(availability)) {
229-
continue;
246+
const availability = availMap?.get(version?.name) ?? Availability.Available;
247+
if (![Availability.Added, Availability.Available].includes(availability)) {
248+
reportDiagnostic(program, {
249+
code: "incompatible-versioned-reference",
250+
messageId: "doesNotExist",
251+
format: {
252+
sourceName: getTypeName(source, options),
253+
targetName: getTypeName(type, options),
254+
version: prettyVersion(version),
255+
},
256+
target: source,
257+
});
258+
}
259+
260+
if (isTemplateInstance(type)) {
261+
for (const arg of type.templateMapper.args) {
262+
if (isType(arg)) {
263+
typesToCheck.push(arg);
264+
}
265+
}
266+
} else if (type.kind === "Union") {
267+
for (const variant of type.variants.values()) {
268+
if (type.expression) {
269+
// Union expressions don't have decorators applied,
270+
// so we need to check the type directly.
271+
typesToCheck.push(variant.type);
272+
} else {
273+
// Named unions can have decorators applied,
274+
// so we need to check that the variant type is valid
275+
// for whatever decoration the variant has.
276+
validateTargetVersionCompatible(program, variant, variant.type);
277+
}
278+
}
279+
} else if (type.kind === "Tuple") {
280+
for (const value of type.values) {
281+
typesToCheck.push(value);
282+
}
230283
}
231-
reportDiagnostic(program, {
232-
code: "incompatible-versioned-reference",
233-
messageId: "doesNotExist",
234-
format: {
235-
sourceName: getTypeName(source, options),
236-
targetName: getTypeName(type, options),
237-
version: prettyVersion(version),
238-
},
239-
target: source,
240-
});
241284
}
242285
}
243286

packages/versioning/test/incompatible-versioning.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,160 @@ describe("versioning: validate incompatible references", () => {
387387
});
388388
});
389389

390+
it("emit diagnostic when using @typeChangedFrom with a type parameter that does not yet exist in arrays", async () => {
391+
const diagnostics = await runner.diagnose(`
392+
@test
393+
@added(Versions.v2)
394+
model Original {}
395+
396+
@test
397+
@added(Versions.v2)
398+
model Updated {}
399+
400+
@test
401+
model Test {
402+
@typeChangedFrom(Versions.v2, Original[])
403+
prop: Updated[];
404+
}
405+
`);
406+
expectDiagnostics(diagnostics, {
407+
code: "@typespec/versioning/incompatible-versioned-reference",
408+
severity: "error",
409+
message:
410+
"'TestService.Test.prop' is referencing type 'TestService.Original' which does not exist in version 'v1'.",
411+
});
412+
});
413+
414+
it("emit diagnostic when using @typeChangedFrom with a type parameter that does not yet exist in records", async () => {
415+
const diagnostics = await runner.diagnose(`
416+
@test
417+
@added(Versions.v2)
418+
model Original {}
419+
420+
@test
421+
@added(Versions.v2)
422+
model Updated {}
423+
424+
@test
425+
model Test {
426+
@typeChangedFrom(Versions.v2, Record<Original>)
427+
prop: Record<Updated>;
428+
}
429+
`);
430+
expectDiagnostics(diagnostics, {
431+
code: "@typespec/versioning/incompatible-versioned-reference",
432+
severity: "error",
433+
message:
434+
"'TestService.Test.prop' is referencing type 'TestService.Original' which does not exist in version 'v1'.",
435+
});
436+
});
437+
438+
it("emit diagnostic when using @typeChangedFrom with a type parameter that does not yet exist in unions", async () => {
439+
const diagnostics = await runner.diagnose(`
440+
@test
441+
@added(Versions.v2)
442+
model Original {}
443+
444+
@test
445+
@added(Versions.v2)
446+
model Updated {}
447+
448+
@test
449+
model Test {
450+
@typeChangedFrom(Versions.v2, Original | string)
451+
prop: Updated;
452+
}
453+
`);
454+
expectDiagnostics(diagnostics, {
455+
code: "@typespec/versioning/incompatible-versioned-reference",
456+
severity: "error",
457+
message:
458+
"'TestService.Test.prop' is referencing type 'TestService.Original' which does not exist in version 'v1'.",
459+
});
460+
});
461+
462+
it("emit diagnostic when using @typeChangedFrom with a type parameter that does not yet exist in named unions", async () => {
463+
const diagnostics = await runner.diagnose(`
464+
@test
465+
@added(Versions.v2)
466+
model Original {}
467+
468+
@test
469+
@added(Versions.v2)
470+
model Updated {}
471+
472+
@test
473+
union InvalidUnion {
474+
string,
475+
Updated,
476+
}
477+
478+
@test
479+
model Test {
480+
@typeChangedFrom(Versions.v2, InvalidUnion)
481+
prop: string;
482+
}
483+
`);
484+
expectDiagnostics(diagnostics, {
485+
code: "@typespec/versioning/incompatible-versioned-reference",
486+
severity: "error",
487+
message:
488+
"'TestService.Updated' is referencing versioned type 'TestService.Updated' but is not versioned itself.",
489+
});
490+
});
491+
492+
it("emit diagnostic when using @typeChangedFrom with a type parameter that does not yet exist in tuples", async () => {
493+
const diagnostics = await runner.diagnose(`
494+
@test
495+
@added(Versions.v2)
496+
model Original {}
497+
498+
@test
499+
@added(Versions.v2)
500+
model Updated {}
501+
502+
@test
503+
model Test {
504+
@typeChangedFrom(Versions.v2, [Original, string])
505+
prop: [Updated, string];
506+
}
507+
`);
508+
expectDiagnostics(diagnostics, {
509+
code: "@typespec/versioning/incompatible-versioned-reference",
510+
severity: "error",
511+
message:
512+
"'TestService.Test.prop' is referencing type 'TestService.Original' which does not exist in version 'v1'.",
513+
});
514+
});
515+
516+
it("emit diagnostic when using @typeChangedFrom with a type parameter that does not yet exist in template", async () => {
517+
const diagnostics = await runner.diagnose(`
518+
@test
519+
@added(Versions.v2)
520+
model Original {}
521+
522+
@test
523+
@added(Versions.v2)
524+
model Updated {}
525+
526+
model Template<T> {
527+
prop: T;
528+
}
529+
530+
@test
531+
model Test {
532+
@typeChangedFrom(Versions.v2, Template<Original>)
533+
prop: Template<Updated>;
534+
}
535+
`);
536+
expectDiagnostics(diagnostics, {
537+
code: "@typespec/versioning/incompatible-versioned-reference",
538+
severity: "error",
539+
message:
540+
"'TestService.Test.prop' is referencing type 'TestService.Original' which does not exist in version 'v1'.",
541+
});
542+
});
543+
390544
it("succeed if version are compatible in model", async () => {
391545
const diagnostics = await runner.diagnose(`
392546
@added(Versions.v2)

packages/versioning/test/versioning.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type Program,
1010
type ProjectionApplication,
1111
type Scalar,
12+
type Tuple,
1213
type Type,
1314
type Union,
1415
} from "@typespec/compiler";
@@ -553,6 +554,132 @@ describe("versioning: logic", () => {
553554
ok((v3.properties.get("prop")!.type as Model).name === "Updated");
554555
});
555556

557+
it("can change template arg types to versioned models", async () => {
558+
const {
559+
projections: [v1, v2, v3],
560+
} = await versionedModel(
561+
["v1", "v2", "v3"],
562+
`
563+
@test
564+
model Original {}
565+
566+
@test
567+
@added(Versions.v2)
568+
model Updated {}
569+
570+
@test
571+
model Test {
572+
@added(Versions.v2)
573+
@typeChangedFrom(Versions.v3, Original[])
574+
prop: Updated[];
575+
}
576+
`,
577+
);
578+
579+
ok(v1.properties.get("prop") === undefined);
580+
581+
const propV2 = v2.properties.get("prop")!.type as Model;
582+
const propV3 = v3.properties.get("prop")!.type as Model;
583+
ok(propV2.name === "Array");
584+
ok((propV2.indexer!.value as Model).name === "Original");
585+
ok(propV3.name === "Array");
586+
ok((propV3.indexer!.value as Model).name === "Updated");
587+
});
588+
589+
it("can change types to versioned unions", async () => {
590+
const {
591+
projections: [v1, v2, v3],
592+
} = await versionedModel(
593+
["v1", "v2", "v3"],
594+
`
595+
@test
596+
model Original {}
597+
598+
@test
599+
@added(Versions.v2)
600+
model Updated {}
601+
602+
@test
603+
union TemporaryUnion {
604+
string,
605+
606+
@added(Versions.v2)
607+
Updated,
608+
}
609+
610+
@test
611+
model Test {
612+
@typeChangedFrom(Versions.v3, TemporaryUnion)
613+
prop: string | Updated;
614+
}
615+
`,
616+
);
617+
618+
const propV1 = v1.properties.get("prop")!.type as Union;
619+
const propV2 = v2.properties.get("prop")!.type as Union;
620+
const propV3 = v3.properties.get("prop")!.type as Union;
621+
ok(propV1.name === "TemporaryUnion");
622+
ok(propV2.name === "TemporaryUnion");
623+
ok(propV3.expression);
624+
ok([...propV3.variants.values()].find((v) => (v.type as Model)?.name === "Updated"));
625+
});
626+
627+
it("can change types to versioned tuples", async () => {
628+
const {
629+
projections: [v1, v2],
630+
} = await versionedModel(
631+
["v1", "v2"],
632+
`
633+
@test
634+
model Original {}
635+
636+
@test
637+
@added(Versions.v2)
638+
model Updated {}
639+
640+
@test
641+
model Test {
642+
@typeChangedFrom(Versions.v2, [Original])
643+
prop: [Updated];
644+
}
645+
`,
646+
);
647+
648+
const propV1 = v1.properties.get("prop")!.type as Tuple;
649+
const propV2 = v2.properties.get("prop")!.type as Tuple;
650+
ok((propV1.values[0] as Model).name === "Original");
651+
ok((propV2.values[0] as Model).name === "Updated");
652+
});
653+
654+
it("can change types to versioned union expressions", async () => {
655+
const {
656+
projections: [v1, v2],
657+
} = await versionedModel(
658+
["v1", "v2"],
659+
`
660+
@test
661+
model Original {}
662+
663+
@test
664+
@added(Versions.v2)
665+
model Updated {}
666+
667+
@test
668+
model Test {
669+
@typeChangedFrom(Versions.v2, string | Original)
670+
prop: string | Updated;
671+
}
672+
`,
673+
);
674+
675+
const propV1 = v1.properties.get("prop")!.type as Union;
676+
const propV2 = v2.properties.get("prop")!.type as Union;
677+
ok(propV1.expression);
678+
ok(propV2.expression);
679+
ok([...propV1.variants.values()].find((v) => (v.type as Model)?.name === "Original"));
680+
ok([...propV2.variants.values()].find((v) => (v.type as Model)?.name === "Updated"));
681+
});
682+
556683
it("can change type over multiple versions", async () => {
557684
const {
558685
projections: [v1, v2, v3],

0 commit comments

Comments
 (0)