diff --git a/packages/compiler/core/checker.ts b/packages/compiler/core/checker.ts index c4cd1a6d59..99021bb686 100644 --- a/packages/compiler/core/checker.ts +++ b/packages/compiler/core/checker.ts @@ -139,6 +139,11 @@ export interface Checker { value: string | number | boolean, node?: StringLiteralNode | NumericLiteralNode | BooleanLiteralNode ): StringLiteralType | NumericLiteralType | BooleanLiteralType; + getEffectiveModelType(model: ModelType): ModelType; + filterModelProperties( + model: ModelType, + filter: (property: ModelTypeProperty) => boolean + ): ModelType; errorType: ErrorType; voidType: VoidType; @@ -298,6 +303,8 @@ export function createChecker(program: Program): Checker { createFunctionType, createLiteralType, finishType, + getEffectiveModelType, + filterModelProperties, }; const projectionMembers = createProjectionMembers(checker); @@ -873,7 +880,10 @@ export function createChecker(program: Program): Checker { continue; } - const newPropType = cloneType(prop, { sourceProperty: prop, model: intersection }); + const newPropType = cloneType(prop, { + sourceProperty: prop, + model: intersection, + }); properties.set(prop.name, newPropType); } } @@ -1701,7 +1711,10 @@ export function createChecker(program: Program): Checker { // copy each property for (const prop of walkPropertiesInherited(targetType)) { - const newProp = cloneType(prop, { sourceProperty: prop, model: parentModel }); + const newProp = cloneType(prop, { + sourceProperty: prop, + model: parentModel, + }); props.push(newProp); } } @@ -1718,6 +1731,18 @@ export function createChecker(program: Program): Checker { } } + function countPropertiesInherited(model: ModelType) { + let current: ModelType | undefined = model; + let count = 0; + + while (current) { + count += current.properties.size; + current = current.baseModel; + } + + return count; + } + function checkModelProperty(prop: ModelPropertyNode, parentModel?: ModelType): ModelTypeProperty { const decorators = checkDecorators(prop); const valueType = getTypeForNode(prop.value); @@ -3116,6 +3141,102 @@ export function createChecker(program: Program): Checker { return parts.reverse().join("."); } + + function getEffectiveModelType(model: ModelType): ModelType { + while (true) { + if (model.name) { + // named model + return model; + } + + // We would need to change the algorithm if this doesn't hold. We + // assume model has no inherited properties below. + compilerAssert(!model.baseModel, "Anonymous model with base model."); + + if (model.properties.size === 0) { + // empty model + return model; + } + + let source: ModelType | undefined; + + for (const property of model.properties.values()) { + const propertySource = getRootSourceModel(property); + if (!propertySource) { + // unsourced property + return model; + } + + if (!source) { + // initialize common source from first sourced property. + source = propertySource; + continue; + } + + if (isDerivedFrom(source, propertySource)) { + // OK + } else if (isDerivedFrom(propertySource, source)) { + // OK, but refine common source to derived type. + source = propertySource; + } else { + // different source + return model; + } + } + + compilerAssert(source, "Should have found a common source to reach here."); + + if (model.properties.size !== countPropertiesInherited(source)) { + // source has additional properties. + return model; + } + + // keep going until we reach a model that cannot be further reduced. + model = source; + } + } + + function filterModelProperties( + model: ModelType, + filter: (property: ModelTypeProperty) => boolean + ): ModelType { + if (model.name === "LinkerResource") { + debugger; + } + let filtered = false; + for (const property of walkPropertiesInherited(model)) { + if (!filter(property)) { + filtered = true; + break; + } + } + + if (!filtered) { + return model; + } + + const properties = new Map(); + const newModel: ModelType = createType({ + kind: "Model", + node: undefined, + name: "", + properties, + decorators: [], + derivedModels: [], + }); + + for (const property of walkPropertiesInherited(model)) { + if (filter(property)) { + const newProperty = cloneType(property, { + sourceProperty: property, + model: newModel, + }); + properties.set(property.name, newProperty); + } + } + + return finishType(newModel); + } } function isErrorType(type: Type): type is ErrorType { @@ -3125,3 +3246,17 @@ function isErrorType(type: Type): type is ErrorType { function createUsingSymbol(symbolSource: Sym): Sym { return { flags: SymbolFlags.Using, declarations: [], name: symbolSource.name, symbolSource }; } + +function isDerivedFrom(derived: ModelType, base: ModelType) { + while (derived !== base && derived.baseModel) { + derived = derived.baseModel; + } + return derived === base; +} + +function getRootSourceModel(property: ModelTypeProperty): ModelType | undefined { + while (property.sourceProperty) { + property = property.sourceProperty; + } + return property?.model; +} diff --git a/packages/compiler/core/projector.ts b/packages/compiler/core/projector.ts index 8398139850..d86654f4a4 100644 --- a/packages/compiler/core/projector.ts +++ b/packages/compiler/core/projector.ts @@ -267,8 +267,8 @@ export function createProjector( */ function shouldFinishType(type: ModelType | InterfaceType | UnionType) { if ( - type.node.kind !== SyntaxKind.ModelStatement && - type.node.kind !== SyntaxKind.InterfaceStatement + type.node?.kind !== SyntaxKind.ModelStatement && + type.node?.kind !== SyntaxKind.InterfaceStatement ) { return true; } diff --git a/packages/compiler/core/semantic-walker.ts b/packages/compiler/core/semantic-walker.ts index cd1142ad94..3fcc210bfe 100644 --- a/packages/compiler/core/semantic-walker.ts +++ b/packages/compiler/core/semantic-walker.ts @@ -258,7 +258,7 @@ function navigateType( */ export function isTemplate(model: ModelType): boolean { return ( - model.node.kind === SyntaxKind.ModelStatement && + model.node?.kind === SyntaxKind.ModelStatement && model.node.templateParameters.length > 0 && !model.templateArguments?.length ); diff --git a/packages/compiler/core/types.ts b/packages/compiler/core/types.ts index 152a72614f..a6917e4ba7 100644 --- a/packages/compiler/core/types.ts +++ b/packages/compiler/core/types.ts @@ -144,7 +144,7 @@ export type IntrinsicModel = export interface ModelType extends BaseType, DecoratedType, TemplatedType { kind: "Model"; name: IntrinsicModelName | string; - node: + node?: | ModelStatementNode | ModelExpressionNode | IntersectionExpressionNode diff --git a/packages/compiler/test/checker/effective-type.ts b/packages/compiler/test/checker/effective-type.ts new file mode 100644 index 0000000000..59cc57b777 --- /dev/null +++ b/packages/compiler/test/checker/effective-type.ts @@ -0,0 +1,367 @@ +import { ok, strictEqual } from "assert"; +import { DecoratorContext, ModelType, ModelTypeProperty, Type } from "../../core/types.js"; +import { createTestHost, TestHost } from "../../testing/index.js"; + +describe("compiler: effective type", () => { + let testHost: TestHost; + let removeFilter: (model: ModelTypeProperty) => boolean; + + beforeEach(async () => { + const removeSymbol = Symbol("remove"); + testHost = await createTestHost(); + testHost.addJsFile("remove.js", { + $remove: function ({ program }: DecoratorContext, entity: Type) { + program.stateSet(removeSymbol).add(entity); + }, + }); + removeFilter = function (property: ModelTypeProperty) { + return !testHost.program.stateSet(removeSymbol).has(property); + }; + }); + + it("spread", async () => { + testHost.addCadlFile( + "main.cadl", + ` + @test model Source { + prop: string; + } + + @test model Test { + prop: { ...Source }; + } + ` + ); + const { Source, Test } = await testHost.compile("./"); + ok(Source.kind === "Model" && Test.kind === "Model", "expected models"); + + const propType = Test.properties.get("prop")?.type as ModelType; + const effective = testHost.program.checker.getEffectiveModelType(propType); + strictEqual(effective, Source); + }); + + it("indirect spread", async () => { + testHost.addCadlFile( + "main.cadl", + ` + @test model Source { + prop: string; + } + + // Alias here as a named model is its own effective type. + alias Spread = { + ...Source + }; + + @test model Test { + test: {...Source} + } + ` + ); + const { Source, Test } = await testHost.compile("./"); + ok(Source.kind === "Model" && Test.kind === "Model", "expected models"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const effective = testHost.program.checker.getEffectiveModelType(propType); + strictEqual(effective, Source); + }); + + it("intersect", async () => { + testHost.addCadlFile( + "main.cadl", + ` + @test model Source { + prop: string; + } + + @test model Test { + test: Source & {} + } + ` + ); + const { Source, Test } = await testHost.compile("./"); + ok(Source.kind === "Model" && Test.kind === "Model", "expected models"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const effective = testHost.program.checker.getEffectiveModelType(propType); + strictEqual(effective, Source); + }); + + it("extends", async () => { + testHost.addCadlFile( + "main.cadl", + ` + model Base { + propBase: string; + } + + @test model Derived extends Base { + propDerived: string; + } + + @test model Test { + test: { ...Derived }; + } + ` + ); + const { Test, Derived } = await testHost.compile("./"); + ok(Test.kind === "Model" && Derived.kind === "Model", "expected models"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + const effective = testHost.program.checker.getEffectiveModelType(propType); + strictEqual(effective, Derived); + }); + + it("intersect and filter", async () => { + testHost.addCadlFile( + "main.cadl", + ` + import "./remove.js"; + + @test model Source { + prop: string; + } + + @test model Test { + test: Source & { @remove something: string; }; + } + ` + ); + const { Source, Test } = await testHost.compile("./"); + ok(Source.kind === "Model" && Test.kind === "Model", "expected models"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const filtered = testHost.program.checker.filterModelProperties(propType, removeFilter); + const effective = testHost.program.checker.getEffectiveModelType(filtered); + strictEqual(effective, Source); + }); + + it("extend and filter", async () => { + testHost.addCadlFile( + "main.cadl", + ` + import "./remove.js"; + + @test model Base { + prop: string; + } + + @test model Derived extends Base { + @remove test: string; + } + ` + ); + const { Base, Derived } = await testHost.compile("./"); + ok(Base.kind === "Model" && Derived.kind === "Model", "expected models"); + + const propType = Derived.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const filtered = testHost.program.checker.filterModelProperties(Derived, removeFilter); + const effective = testHost.program.checker.getEffectiveModelType(filtered); + strictEqual(effective, Base); + }); + + it("extend, intersect, and filter", async () => { + testHost.addCadlFile( + "main.cadl", + ` + import "./remove.js"; + + model Base { + prop: string; + } + + @test model Derived extends Base { + propDerived: string; + } + + @test model Test { + test: Derived & { @remove something: string; }; + } + ` + ); + const { Derived, Test } = await testHost.compile("./"); + ok(Derived.kind === "Model" && Test.kind === "Model", "expected models"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const filtered = testHost.program.checker.filterModelProperties(propType, removeFilter); + const effective = testHost.program.checker.getEffectiveModelType(filtered); + strictEqual(effective, Derived); + }); + + it("does not depend on property order", async () => { + testHost.addCadlFile( + "main.cadl", + ` + import "./remove.js"; + + model Base { + prop: string; + } + + @test model Derived extends Base { + propDerived: string; + } + + @test model Test { + test: Derived & { @remove something: string; }; + } + ` + ); + const { Derived, Test } = await testHost.compile("./"); + ok(Derived.kind === "Model" && Test.kind === "Model", "expected models"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + // There's a code path that's hard to hit with the way properties are + // ordered between base and derived, reverse it to make sure we don't + // depend on this order. + propType.properties = new Map(Array.from(propType.properties).reverse()); + + const filtered = testHost.program.checker.filterModelProperties(propType, removeFilter); + const effective = testHost.program.checker.getEffectiveModelType(filtered); + strictEqual(effective, Derived); + }); + + it("extend templated base with spread and filter", async () => { + testHost.addCadlFile( + "main.cadl", + ` + import "./remove.js"; + + model Base { + @remove prop: string; + ...T; + } + + @test model Thing { + name: string; + } + + @test model Test { + test: Base; + } + ` + ); + const { Thing, Test } = await testHost.compile("./"); + ok(Thing.kind === "Model" && Test.kind === "Model", "expected models"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const filtered = testHost.program.checker.filterModelProperties(propType, removeFilter); + const effective = testHost.program.checker.getEffectiveModelType(filtered); + strictEqual(effective, Thing); + }); + + it("empty model", async () => { + testHost.addCadlFile( + "main.cadl", + ` + @test model Test { + test: {}; + } + ` + ); + const { Test } = await testHost.compile("./"); + ok(Test.kind === "Model", "expected model"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const effective = testHost.program.checker.getEffectiveModelType(propType); + strictEqual(effective, propType); + }); + + it("unsourced property", async () => { + testHost.addCadlFile( + "main.cadl", + ` + model Source { + prop: string; + } + + @test model Test { + test: { notRemoved: string, ...Source }; + } + ` + ); + const { Test } = await testHost.compile("./"); + ok(Test.kind === "Model", "expected model"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const effective = testHost.program.checker.getEffectiveModelType(propType); + strictEqual(effective, propType); + }); + + it("different sources", async () => { + testHost.addCadlFile( + "main.cadl", + ` + model SourceOne { + one: string; + } + + model SourceTwo { + two: string + + } + + @test model Test { + test: SourceOne & SourceTwo; + } + ` + ); + + const { Test } = await testHost.compile("./"); + ok(Test.kind === "Model", "expected model"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const effective = testHost.program.checker.getEffectiveModelType(propType); + strictEqual(effective, propType); + }); + + it("only part of source", async () => { + testHost.addCadlFile( + "main.cadl", + ` + import "./remove.js"; + + model Source { + propA: string; + @remove + propB: string; + } + + @test model Test { + test: Source; + } + ` + ); + + const { Test } = await testHost.compile("./"); + ok(Test.kind === "Model", "expected model"); + + const propType = Test.properties.get("test")?.type; + ok(propType?.kind === "Model", "expected model"); + + const filtered = testHost.program.checker.filterModelProperties(propType, removeFilter); + const effective = testHost.program.checker.getEffectiveModelType(filtered); + strictEqual(effective, filtered); + }); +}); diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index c36575d9c1..44cc569e50 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -433,6 +433,8 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { // For literal types, we just want to emit them directly as well. return mapCadlTypeToOpenAPI(type); } + + type = getEffectiveType(type); const name = getTypeName(program, type, typeNameOptions); if (shouldInline(program, type)) { @@ -458,6 +460,19 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } } + function getEffectiveType(type: Type): Type { + if (type.kind === "Model" && !type.name) { + const filtered = program.checker.filterModelProperties(type, isSchemaProperty); + if (filtered !== type) { + const effective = program.checker.getEffectiveModelType(filtered); + if (effective.name) { + return effective; + } + } + } + return type; + } + function getParamPlaceholder(property: ModelTypeProperty) { let spreadParam = false;