Skip to content

Commit 10fce20

Browse files
devversionalxhub
authored andcommitted
refactor(migrations): initial migration logic for converting to signal queries (#57556)
Adds initial migration logic to convert decorator query declarations to signal queries. We will re-use more part of the signal input migration in follow-ups, to properly migrate, and e.g. even handle control flow PR Close #57556
1 parent eba3a0a commit 10fce20

File tree

11 files changed

+647
-6
lines changed

11 files changed

+647
-6
lines changed

packages/core/schematics/migrations/signal-migration/src/BUILD.bazel

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ ts_library(
77
exclude = ["test/**"],
88
),
99
visibility = [
10-
"//packages/core/schematics/migrations/signal-migration/src/batch:__pkg__",
10+
"//packages/core/schematics/migrations/signal-queries-migration:__pkg__",
1111
"//packages/language-service:__subpackages__",
1212
],
1313
deps = [

packages/core/schematics/migrations/signal-migration/src/utils/remove_from_union.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,14 @@ import ts from 'typescript';
1111
export function removeFromUnionIfPossible(
1212
union: ts.UnionTypeNode,
1313
filter: (v: ts.TypeNode) => boolean,
14-
): ts.UnionTypeNode {
14+
): ts.TypeNode {
1515
const filtered = union.types.filter(filter);
1616
if (filtered.length === union.types.length) {
1717
return union;
1818
}
19+
// If there is only item at this point, avoid the union structure.
20+
if (filtered.length === 1) {
21+
return filtered[0];
22+
}
1923
return ts.factory.updateUnionTypeNode(union, ts.factory.createNodeArray(filtered));
2024
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
2+
3+
ts_library(
4+
name = "migration",
5+
srcs = glob(
6+
["**/*.ts"],
7+
exclude = ["*.spec.ts"],
8+
),
9+
deps = [
10+
"//packages/compiler",
11+
"//packages/compiler-cli",
12+
"//packages/compiler-cli/private",
13+
"//packages/compiler-cli/src/ngtsc/annotations",
14+
"//packages/compiler-cli/src/ngtsc/annotations/directive",
15+
"//packages/compiler-cli/src/ngtsc/reflection",
16+
"//packages/core/schematics/migrations/signal-migration/src",
17+
"//packages/core/schematics/utils/tsurge",
18+
"@npm//@types/node",
19+
"@npm//typescript",
20+
],
21+
)
22+
23+
ts_library(
24+
name = "test_lib",
25+
testonly = True,
26+
srcs = glob(
27+
["**/*.spec.ts"],
28+
),
29+
deps = [
30+
":migration",
31+
"//packages/compiler-cli",
32+
"//packages/compiler-cli/src/ngtsc/file_system/testing",
33+
"//packages/core/schematics/utils/tsurge",
34+
],
35+
)
36+
37+
jasmine_node_test(
38+
name = "test",
39+
srcs = [":test_lib"],
40+
env = {"FORCE_COLOR": "3"},
41+
)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import ts from 'typescript';
10+
import {ExtractedQuery} from './identify_queries';
11+
import {Replacement, TextUpdate} from '../../utils/tsurge';
12+
import {absoluteFromSourceFile} from '../../../../compiler-cli';
13+
import {ImportManager} from '../../../../compiler-cli/private/migrations';
14+
import assert from 'assert';
15+
import {WrappedNodeExpr} from '@angular/compiler';
16+
import {removeFromUnionIfPossible} from '../signal-migration/src/utils/remove_from_union';
17+
18+
const printer = ts.createPrinter();
19+
20+
/**
21+
* A few notes on changes:
22+
*
23+
* @ViewChild()
24+
* --> static is gone!
25+
* --> read stays
26+
*
27+
* @ViewChildren()
28+
* --> emitDistinctChangesOnly is gone!
29+
* --> read stays
30+
*
31+
* @ContentChild()
32+
* --> descendants stays
33+
* --> read stays
34+
* --> static is gone!
35+
*
36+
* @ContentChildren()
37+
* --> descendants stays
38+
* --> read stays
39+
* --> emitDistinctChangesOnly is gone!
40+
*/
41+
export function computeReplacementsToMigrateQuery(
42+
node: ts.PropertyDeclaration,
43+
metadata: ExtractedQuery,
44+
importManager: ImportManager,
45+
): Replacement[] {
46+
const sf = node.getSourceFile();
47+
let newQueryFn = importManager.addImport({
48+
requestedFile: sf,
49+
exportModuleSpecifier: '@angular/core',
50+
exportSymbolName: metadata.kind,
51+
});
52+
53+
// The default value for descendants is `true`, except for `ContentChildren`.
54+
const defaultDescendants = metadata.kind !== 'contentChildren';
55+
const optionProperties: ts.PropertyAssignment[] = [];
56+
const args: ts.Expression[] = [
57+
metadata.args[0], // Locator.
58+
];
59+
let type = node.type;
60+
61+
if (metadata.queryInfo.read !== null) {
62+
assert(metadata.queryInfo.read instanceof WrappedNodeExpr);
63+
optionProperties.push(
64+
ts.factory.createPropertyAssignment('read', metadata.queryInfo.read.node),
65+
);
66+
}
67+
if (metadata.queryInfo.descendants !== defaultDescendants) {
68+
optionProperties.push(
69+
ts.factory.createPropertyAssignment(
70+
'descendants',
71+
metadata.queryInfo.descendants ? ts.factory.createTrue() : ts.factory.createFalse(),
72+
),
73+
);
74+
}
75+
76+
if (optionProperties.length > 0) {
77+
args.push(ts.factory.createObjectLiteralExpression(optionProperties));
78+
}
79+
80+
// TODO: Can we consult, based on references and non-null assertions?
81+
const isIndicatedAsRequired = node.exclamationToken !== undefined;
82+
83+
// If the query is required already via some indicators, and this is a "single"
84+
// query, use the available `.required` method.
85+
if (isIndicatedAsRequired && metadata.queryInfo.first) {
86+
newQueryFn = ts.factory.createPropertyAccessExpression(newQueryFn, 'required');
87+
}
88+
89+
// If this query is still nullable (i.e. not required), attempt to remove
90+
// explicit `undefined` types if possible.
91+
if (!isIndicatedAsRequired && type !== undefined && ts.isUnionTypeNode(type)) {
92+
type = removeFromUnionIfPossible(type, (v) => v.kind !== ts.SyntaxKind.UndefinedKeyword);
93+
}
94+
95+
const locatorType = Array.isArray(metadata.queryInfo.predicate)
96+
? null
97+
: metadata.queryInfo.predicate.expression;
98+
const readType = metadata.queryInfo.read ?? locatorType;
99+
100+
// If the type and the read type are matching, we can rely on the TS generic
101+
// signature rather than repeating e.g. `viewChild<Button>(Button)`.
102+
if (
103+
type !== undefined &&
104+
readType instanceof WrappedNodeExpr &&
105+
ts.isIdentifier(readType.node) &&
106+
ts.isTypeReferenceNode(type) &&
107+
ts.isIdentifier(type.typeName) &&
108+
type.typeName.text === readType.node.text
109+
) {
110+
type = undefined;
111+
}
112+
113+
const call = ts.factory.createCallExpression(newQueryFn, type ? [type] : undefined, args);
114+
const updated = ts.factory.updatePropertyDeclaration(
115+
node,
116+
[ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)],
117+
node.name,
118+
undefined,
119+
undefined,
120+
call,
121+
);
122+
123+
return [
124+
new Replacement(
125+
absoluteFromSourceFile(node.getSourceFile()),
126+
new TextUpdate({
127+
position: node.getStart(),
128+
end: node.getEnd(),
129+
toInsert: printer.printNode(ts.EmitHint.Unspecified, updated, sf),
130+
}),
131+
),
132+
];
133+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import ts from 'typescript';
10+
import {
11+
getAngularDecorators,
12+
queryDecoratorNames,
13+
} from '../../../../compiler-cli/src/ngtsc/annotations';
14+
import {ReflectionHost} from '../../../../compiler-cli/src/ngtsc/reflection';
15+
import {UniqueID} from '../../utils/tsurge';
16+
import path from 'path';
17+
import {extractDecoratorQueryMetadata} from '../../../../compiler-cli/src/ngtsc/annotations/directive';
18+
import {PartialEvaluator} from '../../../../compiler-cli/private/migrations';
19+
import {R3QueryMetadata} from '../../../../compiler';
20+
21+
/** Branded type to uniquely identify class properties in a project. */
22+
export type ClassPropertyID = UniqueID<'ClassPropertyID; Potentially a query'>;
23+
24+
/** Type describing an extracted decorator query that can be migrated. */
25+
export interface ExtractedQuery {
26+
id: ClassPropertyID;
27+
kind: 'viewChild' | 'viewChildren' | 'contentChild' | 'contentChildren';
28+
args: ts.Expression[];
29+
queryInfo: R3QueryMetadata;
30+
}
31+
32+
/**
33+
* Determines if the given node refers to a decorator-based query, and
34+
* returns its resolved metadata if possible.
35+
*/
36+
export function extractSourceQueryDefinition(
37+
node: ts.Node,
38+
reflector: ReflectionHost,
39+
evaluator: PartialEvaluator,
40+
projectDirAbsPath: string,
41+
): ExtractedQuery | null {
42+
if (
43+
!ts.isPropertyDeclaration(node) ||
44+
!ts.isClassDeclaration(node.parent) ||
45+
node.parent.name === undefined ||
46+
!ts.isIdentifier(node.name)
47+
) {
48+
return null;
49+
}
50+
51+
const decorators = reflector.getDecoratorsOfDeclaration(node) ?? [];
52+
const ngDecorators = getAngularDecorators(decorators, queryDecoratorNames, /* isCore */ false);
53+
if (ngDecorators.length === 0) {
54+
return null;
55+
}
56+
const decorator = ngDecorators[0];
57+
58+
const id = getUniqueIDForClassProperty(node, projectDirAbsPath);
59+
if (id === null) {
60+
return null;
61+
}
62+
63+
let kind: ExtractedQuery['kind'];
64+
if (decorator.name === 'ViewChild') {
65+
kind = 'viewChild';
66+
} else if (decorator.name === 'ViewChildren') {
67+
kind = 'viewChildren';
68+
} else if (decorator.name === 'ContentChild') {
69+
kind = 'contentChild';
70+
} else if (decorator.name === 'ContentChildren') {
71+
kind = 'contentChildren';
72+
} else {
73+
throw new Error('Unexpected query decorator detected.');
74+
}
75+
76+
const queryInfo = extractDecoratorQueryMetadata(
77+
node,
78+
decorator.name,
79+
decorator.args ?? [],
80+
node.name.text,
81+
reflector,
82+
evaluator,
83+
);
84+
85+
return {
86+
id,
87+
kind,
88+
args: decorator.args ?? [],
89+
queryInfo,
90+
};
91+
}
92+
93+
/**
94+
* Gets a unique ID for the given class property.
95+
*
96+
* This is useful for matching class fields across compilation units.
97+
* E.g. a reference may point to the field via `.d.ts`, while the other
98+
* may reference it via actual `.ts` sources. IDs for the same fields
99+
* would then match identity.
100+
*/
101+
export function getUniqueIDForClassProperty(
102+
property: ts.PropertyDeclaration,
103+
projectDirAbsPath: string,
104+
): ClassPropertyID | null {
105+
if (!ts.isClassDeclaration(property.parent) || property.parent.name === undefined) {
106+
return null;
107+
}
108+
const filePath = path
109+
.relative(projectDirAbsPath, property.getSourceFile().fileName)
110+
.replace(/\.d\.ts$/, '.ts');
111+
112+
// Note: If a class is nested, there could be an ID clash.
113+
// This is highly unlikely though, and this is not a problem because
114+
// in such cases, there is even less chance there are any references to
115+
// a non-exported classes; in which case, cross-compilation unit references
116+
// likely can't exist anyway.
117+
118+
return `${filePath}-${property.parent.name.text}-${property.name.getText()}` as ClassPropertyID;
119+
}

0 commit comments

Comments
 (0)