feat(core): merge more target options from targetDefaults (#12435)

Fixes https://github.com/nrwl/nx/issues/12433
This commit is contained in:
Craigory Coppola 2022-12-29 18:13:53 -05:00 committed by GitHub
parent 66a26583da
commit c783ac5e9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 603 additions and 51 deletions

View File

@ -113,6 +113,13 @@ In this case Nx will use the right `production` input for each project.
### Target Defaults
Target defaults provide ways to set common options for a particular target in your workspace. When building your project's configuration, we merge it with up to 1 default from this map. For a given target, we look at its name and its executor. We then check target defaults for any of the following combinations:
- `` `${executor}` ``
- `` `${targetName}` ``
Whichever of these we find first, we use as the base for that target's configuration. Some common scenarios for this follow.
Targets can depend on other targets. A common scenario is having to build dependencies of a project first before
building the project. The `dependsOn` property in `project.json` can be used to define the list of dependencies of an
individual target.
@ -149,6 +156,35 @@ Another target default you can configure is `outputs`:
}
```
When defining any options or configurations inside of a target default, you may use the `{workspaceRoot}` and `{projectRoot}` tokens. This is useful for defining things like the outputPath or tsconfig for many build targets.
```json {% fileName="nx.json" %}
{
"targetDefaults": {
"@nrwl/js:tsc": {
"options": {
"main": "{projectRoot}/src/index.ts"
},
"configurations": {
"prod": {
"tsconfig": "{projectRoot}/tsconfig.prod.json"
}
},
"inputs": ["prod"],
"outputs": ["{workspaceRoot}/{projectRoot}"]
},
"build": {
"inputs": ["prod"],
"outputs": ["{workspaceRoot}/{projectRoot}"]
}
}
}
```
{% callout type="note" title="Target Default Priority" %}
Note that the inputs and outputs are respecified on the @nrwl/js:tsc default configuration. This is **required**, as when reading target defaults Nx will only ever look at one key. If there is a default configuration based on the executor used, it will be read first. If not, Nx will fall back to looking at the configuration based on target name. For instance, running `nx build project` will read the options from `targetDefaults[@nrwl/js:tsc]` if the target configuration for build uses the @nrwl/js:tsc executor. It **would not** read any of the configuration from the `build` target default configuration unless the executor does not match.
{% /callout %}
### Generators
Default generator options are configured in `nx.json` as well. For instance, the following tells Nx to always
@ -174,7 +210,7 @@ named "default" is used by default. Specify a different one like this `nx run-ma
Tasks runners can accept different options. The following are the options supported
by `"nx/tasks-runners/default"` and `"@nrwl/nx-cloud"`.
| Property | Descrtipion |
| Property | Description |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| cacheableOperations | defines the list of targets/operations that are cached by Nx |
| parallel | defines the max number of targets ran in parallel (in older versions of Nx you had to pass `--parallel --maxParallel=3` instead of `--parallel=3`) |

View File

@ -254,6 +254,69 @@ describe('Nx Running Tests', () => {
);
}, 10000);
describe('target defaults + executor specifications', () => {
it('should be able to run targets with unspecified executor given an appropriate targetDefaults entry', () => {
const target = uniq('target');
const lib = uniq('lib');
updateJson('nx.json', (nxJson) => {
nxJson.targetDefaults ??= {};
nxJson.targetDefaults[target] = {
executor: 'nx:run-commands',
options: {
command: `echo Hello from ${target}`,
},
};
return nxJson;
});
updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
[target]: {},
},
})
);
expect(runCLI(`${target} ${lib} --verbose`)).toContain(
`Hello from ${target}`
);
});
it('should be able to pull options from targetDefaults based on executor', () => {
const target = uniq('target');
const lib = uniq('lib');
updateJson('nx.json', (nxJson) => {
nxJson.targetDefaults ??= {};
nxJson.targetDefaults[`nx:run-commands`] = {
options: {
command: `echo Hello from ${target}`,
},
};
return nxJson;
});
updateFile(
`libs/${lib}/project.json`,
JSON.stringify({
name: lib,
targets: {
[target]: {
executor: 'nx:run-commands',
},
},
})
);
expect(runCLI(`${target} ${lib} --verbose`)).toContain(
`Hello from ${target}`
);
});
});
describe('target dependencies', () => {
let myapp;
let mylib1;

View File

@ -209,6 +209,26 @@
"type": "object",
"description": "Target defaults",
"properties": {
"executor": {
"description": "The function that Nx will invoke when you run this target",
"type": "string"
},
"options": {
"type": "object"
},
"outputs": {
"type": "array",
"items": {
"type": "string"
}
},
"configurations": {
"type": "object",
"description": "provides extra sets of values that will be merged into the options map",
"additionalProperties": {
"type": "object"
}
},
"inputs": {
"$ref": "#/definitions/inputs"
},
@ -243,12 +263,6 @@
}
]
}
},
"outputs": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false

View File

@ -1,6 +1,7 @@
import { PackageManager } from '../utils/package-manager';
import {
InputDefinition,
TargetConfiguration,
TargetDependencyConfig,
} from './workspace-json-project-json';
@ -19,14 +20,7 @@ export interface NxAffectedConfig {
defaultBase?: string;
}
export type TargetDefaults = Record<
string,
{
outputs?: string[];
dependsOn?: (TargetDependencyConfig | string)[];
inputs?: (InputDefinition | string)[];
}
>;
export type TargetDefaults = Record<string, Partial<TargetConfiguration>>;
export type TargetDependencies = Record<
string,

View File

@ -1,8 +1,14 @@
import { toProjectName, Workspaces } from './workspaces';
import {
mergeTargetConfigurations,
readTargetDefaultsForTarget,
toProjectName,
Workspaces,
} from './workspaces';
import { NxJsonConfiguration } from './nx-json';
import { vol } from 'memfs';
import * as fastGlob from 'fast-glob';
import { TargetConfiguration } from './workspace-json-project-json';
jest.mock('fs', () => require('memfs').fs);
@ -138,4 +144,316 @@ describe('Workspaces', () => {
});
});
});
describe('target defaults', () => {
const targetDefaults = {
'nx:run-commands': {
options: {
key: 'default-value-for-executor',
},
},
build: {
options: {
key: 'default-value-for-targetname',
},
},
};
it('should prefer executor key', () => {
expect(
readTargetDefaultsForTarget(
'other-target',
targetDefaults,
'nx:run-commands'
).options['key']
).toEqual('default-value-for-executor');
});
it('should fallback to target key', () => {
expect(
readTargetDefaultsForTarget('build', targetDefaults, 'other-executor')
.options['key']
).toEqual('default-value-for-targetname');
});
it('should return undefined if not found', () => {
expect(
readTargetDefaultsForTarget(
'other-target',
targetDefaults,
'other-executor'
)
).toBeNull();
});
describe('options', () => {
it('should merge if executor matches', () => {
expect(
mergeTargetConfigurations(
{
root: '.',
targets: {
build: {
executor: 'target',
options: {
a: 'project-value-a',
},
},
},
},
'build',
{
executor: 'target',
options: {
a: 'default-value-a',
b: 'default-value-b',
},
}
).options
).toEqual({ a: 'project-value-a', b: 'default-value-b' });
});
it('should merge if executor is only provided on the project', () => {
expect(
mergeTargetConfigurations(
{
root: '.',
targets: {
build: {
executor: 'target',
options: {
a: 'project-value',
},
},
},
},
'build',
{
options: {
a: 'default-value',
b: 'default-value',
},
}
).options
).toEqual({ a: 'project-value', b: 'default-value' });
});
it('should merge if executor is only provided in the defaults', () => {
expect(
mergeTargetConfigurations(
{
root: '.',
targets: {
build: {
options: {
a: 'project-value',
},
},
},
},
'build',
{
executor: 'target',
options: {
a: 'default-value',
b: 'default-value',
},
}
).options
).toEqual({ a: 'project-value', b: 'default-value' });
});
it('should not merge if executor is different', () => {
expect(
mergeTargetConfigurations(
{
root: '',
targets: {
build: {
executor: 'other',
options: {
a: 'project-value',
},
},
},
},
'build',
{
executor: 'default-executor',
options: {
b: 'default-value',
},
}
).options
).toEqual({ a: 'project-value' });
});
it('should resolve workspaceRoot and projectRoot tokens', () => {
expect(
mergeTargetConfigurations(
{
root: 'my/project',
targets: {
build: {
options: {
a: '{workspaceRoot}',
},
},
},
},
'build',
{
executor: 'target',
options: {
b: '{workspaceRoot}/dist/{projectRoot}',
},
}
).options
).toEqual({ a: '{workspaceRoot}', b: 'dist/my/project' });
});
});
describe('configurations', () => {
const projectConfigurations: TargetConfiguration['configurations'] = {
dev: {
foo: 'project-value-foo',
},
prod: {
bar: 'project-value-bar',
},
};
const defaultConfigurations: TargetConfiguration['configurations'] = {
dev: {
foo: 'default-value-foo',
other: 'default-value-other',
},
baz: {
x: 'default-value-x',
},
};
const merged: TargetConfiguration['configurations'] = {
dev: {
foo: projectConfigurations.dev.foo,
other: defaultConfigurations.dev.other,
},
prod: { bar: projectConfigurations.prod.bar },
baz: { x: defaultConfigurations.baz.x },
};
it('should merge configurations if executor matches', () => {
expect(
mergeTargetConfigurations(
{
root: '.',
targets: {
build: {
executor: 'target',
configurations: projectConfigurations,
},
},
},
'build',
{
executor: 'target',
configurations: defaultConfigurations,
}
).configurations
).toEqual(merged);
});
it('should merge if executor is only provided on the project', () => {
expect(
mergeTargetConfigurations(
{
root: '.',
targets: {
build: {
executor: 'target',
configurations: projectConfigurations,
},
},
},
'build',
{
configurations: defaultConfigurations,
}
).configurations
).toEqual(merged);
});
it('should merge if executor is only provided in the defaults', () => {
expect(
mergeTargetConfigurations(
{
root: '.',
targets: {
build: {
configurations: projectConfigurations,
},
},
},
'build',
{
executor: 'target',
configurations: defaultConfigurations,
}
).configurations
).toEqual(merged);
});
it('should not merge if executor doesnt match', () => {
expect(
mergeTargetConfigurations(
{
root: '',
targets: {
build: {
executor: 'other',
configurations: projectConfigurations,
},
},
},
'build',
{
executor: 'target',
configurations: defaultConfigurations,
}
).configurations
).toEqual(projectConfigurations);
});
it('should resolve workspaceRoot and projectRoot tokens', () => {
expect(
mergeTargetConfigurations(
{
root: 'my/project',
targets: {
build: {
configurations: {
dev: {
a: '{workspaceRoot}',
},
},
},
},
},
'build',
{
executor: 'target',
configurations: {
prod: {
a: '{workspaceRoot}/dist/{projectRoot}',
},
},
}
).configurations
).toEqual({
dev: { a: '{workspaceRoot}' },
prod: { a: 'dist/my/project' },
});
});
});
});
});

View File

@ -7,14 +7,15 @@ import { performance } from 'perf_hooks';
import { workspaceRoot } from '../utils/workspace-root';
import { readJsonFile } from '../utils/fileutils';
import { logger, NX_PREFIX } from '../utils/logger';
import { logger, NX_PREFIX, stripIndent } from '../utils/logger';
import { loadNxPlugins, readPluginPackageJson } from '../utils/nx-plugin';
import * as yaml from 'js-yaml';
import type { NxJsonConfiguration } from './nx-json';
import type { NxJsonConfiguration, TargetDefaults } from './nx-json';
import {
ProjectConfiguration,
ProjectsConfigurations,
TargetConfiguration,
} from './workspace-json-project-json';
import {
CustomHasher,
@ -140,16 +141,19 @@ export class Workspaces {
for (const proj of Object.values(config.projects)) {
if (proj.targets) {
for (const targetName of Object.keys(proj.targets)) {
if (nxJson.targetDefaults[targetName]) {
const projectTargetDefinition = proj.targets[targetName];
if (!projectTargetDefinition.outputs) {
projectTargetDefinition.outputs =
nxJson.targetDefaults[targetName].outputs;
}
if (!projectTargetDefinition.dependsOn) {
projectTargetDefinition.dependsOn =
nxJson.targetDefaults[targetName].dependsOn;
}
const defaults = readTargetDefaultsForTarget(
targetName,
nxJson.targetDefaults,
projectTargetDefinition.executor
);
if (defaults) {
proj.targets[targetName] = mergeTargetConfigurations(
proj,
targetName,
defaults
);
}
}
}
@ -928,6 +932,125 @@ export function buildWorkspaceConfigurationFromGlobs(
};
}
export function mergeTargetConfigurations(
projectConfiguration: ProjectConfiguration,
target: string,
targetDefaults: TargetDefaults[string]
): TargetConfiguration {
const { targets, root } = projectConfiguration;
const targetConfiguration = targets?.[target];
if (!targetConfiguration) {
throw new Error(
`Attempted to merge targetDefaults for ${projectConfiguration.name}.${target}, which doesn't exist.`
);
}
const {
configurations: defaultConfigurations,
options: defaultOptions,
...defaults
} = targetDefaults;
const result = {
...defaults,
...targetConfiguration,
};
// Target is "compatible", e.g. executor is defined only once or is the same
// in both places. This means that it is likely safe to merge options
if (
!targetDefaults.executor ||
!targetConfiguration.executor ||
targetDefaults.executor === targetConfiguration.executor
) {
result.options = mergeOptions(
defaultOptions,
targetConfiguration.options ?? {},
root,
target
);
result.configurations = mergeConfigurations(
defaultConfigurations,
targetConfiguration.configurations,
root,
target
);
}
return result as TargetConfiguration;
}
function mergeOptions<T extends Object>(
defaults: T,
options: T,
projectRoot: string,
key: string
): T {
return {
...resolvePathTokensInOptions(defaults, projectRoot, key),
...options,
};
}
function mergeConfigurations<T extends Object>(
defaultConfigurations: Record<string, T>,
projectDefinedConfigurations: Record<string, T>,
projectRoot: string,
targetName: string
): Record<string, T> {
const configurations: Record<string, T> = { ...projectDefinedConfigurations };
for (const configuration in defaultConfigurations) {
configurations[configuration] = mergeOptions(
defaultConfigurations[configuration],
configurations[configuration],
projectRoot,
`${targetName}.${configuration}`
);
}
return configurations;
}
function resolvePathTokensInOptions<T extends Object>(
object: T,
projectRoot: string,
key: string
): T {
for (let [opt, value] of Object.entries(object ?? {})) {
if (typeof value === 'string') {
if (value.startsWith('{workspaceRoot}/')) {
value = value.replace(/^\{workspaceRoot\}\//, '');
}
if (value.includes('{workspaceRoot}')) {
throw new Error(
`${NX_PREFIX} The {workspaceRoot} token is only valid at the beginning of an option. (${key})`
);
}
object[opt] = value.replace('{projectRoot}', projectRoot);
} else if (typeof value === 'object' && value) {
resolvePathTokensInOptions(value, projectRoot, [key, opt].join('.'));
}
}
return object;
}
export function readTargetDefaultsForTarget(
targetName: string,
targetDefaults: TargetDefaults,
executor?: string
): TargetDefaults[string] {
if (executor) {
// If an executor is defined in project.json, defaults should be read
// from the most specific key that matches that executor.
// e.g. If executor === run-commands, and the target is named build:
// Use, use nx:run-commands if it is present
// If not, use build if it is present.
const key = [executor, targetName].find((x) => targetDefaults?.[x]);
return key ? targetDefaults?.[key] : null;
} else {
// If the executor is not defined, the only key we have is the target name.
return targetDefaults?.[targetName];
}
}
// we have to do it this way to preserve the order of properties
// not to screw up the formatting
export function renamePropertyWithStableKeys(

View File

@ -11,12 +11,13 @@ import { ProjectGraphBuilder } from '../project-graph-builder';
import { PackageJson } from '../../utils/package-json';
import { readJsonFile } from '../../utils/fileutils';
import { NxJsonConfiguration } from '../../config/nx-json';
import {
ProjectConfiguration,
TargetConfiguration,
} from '../../config/workspace-json-project-json';
import { ProjectConfiguration } from '../../config/workspace-json-project-json';
import { findMatchingProjects } from '../../utils/find-matching-projects';
import { NX_PREFIX } from '../../utils/logger';
import {
mergeTargetConfigurations,
readTargetDefaultsForTarget,
} from '../../config/workspaces';
export function buildWorkspaceProjectNodes(
ctx: ProjectGraphProcessorContext,
@ -55,7 +56,6 @@ export function buildWorkspaceProjectNodes(
}
}
p.targets = normalizeProjectTargets(p.targets, nxJson.targetDefaults, key);
p.implicitDependencies = normalizeImplicitDependencies(
key,
p.implicitDependencies,
@ -69,6 +69,8 @@ export function buildWorkspaceProjectNodes(
loadNxPlugins(ctx.workspace.plugins)
);
p.targets = normalizeProjectTargets(p, nxJson.targetDefaults, key);
// TODO: remove in v16
const projectType =
p.projectType === 'application'
@ -109,26 +111,27 @@ export function buildWorkspaceProjectNodes(
* Apply target defaults and normalization
*/
function normalizeProjectTargets(
targets: Record<string, TargetConfiguration>,
defaultTargets: NxJsonConfiguration['targetDefaults'],
project: ProjectConfiguration,
targetDefaults: NxJsonConfiguration['targetDefaults'],
projectName: string
) {
for (const targetName in defaultTargets) {
const target = targets?.[targetName];
if (!target) {
continue;
}
if (defaultTargets[targetName].inputs && !target.inputs) {
target.inputs = defaultTargets[targetName].inputs;
}
if (defaultTargets[targetName].dependsOn && !target.dependsOn) {
target.dependsOn = defaultTargets[targetName].dependsOn;
}
if (defaultTargets[targetName].outputs && !target.outputs) {
target.outputs = defaultTargets[targetName].outputs;
}
}
const targets = project.targets;
for (const target in targets) {
const executor =
targets[target].executor ?? targets[target].command
? 'nx:run-commands'
: null;
const defaults = readTargetDefaultsForTarget(
target,
targetDefaults,
executor
);
if (defaults) {
targets[target] = mergeTargetConfigurations(project, target, defaults);
}
const config = targets[target];
if (config.command) {
if (config.executor) {
@ -138,12 +141,13 @@ function normalizeProjectTargets(
} else {
targets[target] = {
...targets[target],
executor: 'nx:run-commands',
executor,
options: {
...config.options,
command: config.command,
},
};
delete config.command;
}
}
}