Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[rush] JSON Selector Expressions (with "json:" selector parser) #4271

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Add selector expressions to Rush CLI",
"type": "none"
}
],
"packageName": "@microsoft/rush"
}
15 changes: 15 additions & 0 deletions libraries/rush-lib/src/api/IRushProjectSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { RushConfigurationProject } from './RushConfigurationProject';
import { SelectorExpression } from './SelectorExpressions';

/**
* This interface allows a previously constructed RushProjectSelector to be passed around
* and used by other lower-level objects. (For example, the "json:" selector reads and
* parses an entire new selector expression, which might in turn load another selector
* expression and parse it.)
*/
export interface IRushProjectSelector {
selectExpression(expr: SelectorExpression, context: string): Promise<RushConfigurationProject[]>;
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
}
183 changes: 183 additions & 0 deletions libraries/rush-lib/src/api/RushProjectSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { RushConfiguration } from './RushConfiguration';
import { RushConfigurationProject } from './RushConfigurationProject';
import { Selection } from '../logic/Selection';
import type { ISelectorParser } from '../logic/selectors/ISelectorParser';
import type { ITerminal } from '@rushstack/node-core-library';
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
import {
GitChangedProjectSelectorParser,
IGitSelectorParserOptions
} from '../logic/selectors/GitChangedProjectSelectorParser';
import { NamedProjectSelectorParser } from '../logic/selectors/NamedProjectSelectorParser';
import { TagProjectSelectorParser } from '../logic/selectors/TagProjectSelectorParser';
import { VersionPolicyProjectSelectorParser } from '../logic/selectors/VersionPolicyProjectSelectorParser';
import { JsonFileSelectorParser } from '../logic/selectors/JsonFileSelectorParser';
import { SelectorError } from '../logic/selectors/SelectorError';
import {
SelectorExpression,
IExpressionDetailedSelector,
ExpressionParameter,
IExpressionOperatorAnd,
IExpressionOperatorOr,
IExpressionOperatorNot,
isDetailedSelector,
isParameter,
isAnd,
isOr,
isNot
} from './SelectorExpressions';

/**
* When preparing to select projects in a Rush monorepo, some selector scopes
* require additional configuration in order to control their behavior. This
* options interface allows the caller to provide these properties.
*/
export interface IProjectSelectionOptions {
/**
* Options required for configuring the git selector scope.
*/
gitSelectorParserOptions: IGitSelectorParserOptions;
}

/**
* A central interface for selecting a subset of Rush projects from a given monorepo,
* using standardized selector expressions. Note that the types of selectors available
* in a monorepo may be influenced in the future by plugins, so project selection
* is always done in the context of a particular Rush configuration.
*/
export class RushProjectSelector {
private _rushConfig: RushConfiguration;
private _scopes: Map<string, ISelectorParser<RushConfigurationProject>> = new Map();
private _options: IProjectSelectionOptions;
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved

public constructor(rushConfig: RushConfiguration, options: IProjectSelectionOptions) {
this._rushConfig = rushConfig;
this._options = options;

this._scopes.set('name', new NamedProjectSelectorParser(this._rushConfig));
this._scopes.set(
'git',
new GitChangedProjectSelectorParser(this._rushConfig, this._options.gitSelectorParserOptions)
);
this._scopes.set('tag', new TagProjectSelectorParser(this._rushConfig));
this._scopes.set('version-policy', new VersionPolicyProjectSelectorParser(this._rushConfig));
this._scopes.set('json', new JsonFileSelectorParser(this._rushConfig, this));
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Select a set of projects using the passed selector expression.
*
* The passed context string is used only when constructing error messages, in the event of
* an error in user input. The default string "expression" is used if no context is provided.
*/
public async selectExpression(
expr: SelectorExpression,
context: string = 'expression'
): Promise<RushConfigurationProject[]> {
if (isAnd(expr)) {
return this._evaluateAnd(expr, context);
} else if (isOr(expr)) {
return this._evaluateOr(expr, context);
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
} else if (isNot(expr)) {
return this._evaluateNot(expr, context);
} else if (isParameter(expr)) {
return this._evaluateParameter(expr, context);
} else if (isDetailedSelector(expr)) {
return this._evaluateDetailedSelector(expr, context);
} else if (typeof expr === 'string') {
return this._evaluateSimpleSelector(expr, context);
} else {
// Fail-safe... in general, this shouldn't be possible, as user script type checking
// or JSON schema validation should catch it before this point.
throw new SelectorError(`Invalid object encountered in selector expression in ${context}.`);
}
}

private async _evaluateAnd(
expr: IExpressionOperatorAnd,
context: string
): Promise<RushConfigurationProject[]> {
const result: Array<RushConfigurationProject>[] = [];
for (const operand of expr.and) {
result.push(await this.selectExpression(operand, context));
}
return [...Selection.intersection(new Set(result[0]), ...result.slice(1).map((x) => new Set(x)))];
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
}

private async _evaluateOr(
expr: IExpressionOperatorOr,
context: string
): Promise<RushConfigurationProject[]> {
const result: Array<RushConfigurationProject>[] = [];
for (const operand of expr.or) {
result.push(await this.selectExpression(operand, context));
}
return [...Selection.union(new Set(result[0]), ...result.slice(1).map((x) => new Set(x)))];
}

private async _evaluateNot(
expr: IExpressionOperatorNot,
context: string
): Promise<RushConfigurationProject[]> {
const result: RushConfigurationProject[] = await this.selectExpression(expr.not, context);
return this._rushConfig.projects.filter((p) => !result.includes(p));
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
}

private async _evaluateParameter(
expr: ExpressionParameter,
context: string
): Promise<RushConfigurationProject[]> {
const key: string = Object.keys(expr)[0];
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved

if (key === '--to') {
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved
const arg: RushConfigurationProject[] = await this.selectExpression(expr[key], context);
return [...Selection.expandAllDependencies(arg)];
} else if (key === '--from') {
const arg: RushConfigurationProject[] = await this.selectExpression(expr[key], context);
return [...Selection.expandAllDependencies(Selection.expandAllConsumers(arg))];
} else if (key === '--only') {
// "only" is a no-op in a generic selector expression
const arg: RushConfigurationProject[] = await this.selectExpression(expr[key], context);
return arg;
} else {
throw new SelectorError(`Unknown parameter '${key}' encountered in selector expression in ${context}.`);
}
}

private async _evaluateDetailedSelector(
expr: IExpressionDetailedSelector,
context: string
): Promise<RushConfigurationProject[]> {
const parser: ISelectorParser<RushConfigurationProject> | undefined = this._scopes.get(expr.scope);
if (!parser) {
throw new SelectorError(
`Unknown selector scope '${expr.scope}' for value '${expr.value}' in ${context}.`
);
}
return [
...(await parser.evaluateSelectorAsync({
unscopedSelector: expr.value,
terminal: undefined as unknown as ITerminal,
context: context
}))
];
}

private async _evaluateSimpleSelector(expr: string, context: string): Promise<RushConfigurationProject[]> {
const index: number = expr.indexOf(':');

if (index === -1) {
return this._evaluateDetailedSelector({ scope: 'name', value: expr }, context);
}

return this._evaluateDetailedSelector(
{
scope: expr.slice(0, index),
value: expr.slice(index + 1)
},
context
);
}
}
39 changes: 39 additions & 0 deletions libraries/rush-lib/src/api/SelectorExpressionJsonFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { JsonFile, JsonSchema, FileSystem } from '@rushstack/node-core-library';

import { SelectorExpression } from './SelectorExpressions';
import schemaJson from '../schemas/selector-expression.schema.json';

/**
* A utility class for saving and loading selector expression JSON files.
*/
export class SelectorExpressionJsonFile {
private static _jsonSchema: JsonSchema = JsonSchema.fromLoadedObject(schemaJson);

public static async loadAsync(jsonFilePath: string): Promise<SelectorExpression> {
const expr: SelectorExpression = await JsonFile.loadAndValidateAsync(
jsonFilePath,
SelectorExpressionJsonFile._jsonSchema
);
return expr;
}

public static async tryLoadAsync(jsonFilePath: string): Promise<SelectorExpression | undefined> {
try {
return await this.loadAsync(jsonFilePath);
} catch (error) {
if (FileSystem.isNotExistError(error as Error)) {
return undefined;
}
throw error;
}
}

public static loadFromString(jsonString: string): SelectorExpression {
const expr: SelectorExpression = JsonFile.parseString(jsonString);
SelectorExpressionJsonFile._jsonSchema.validateObject(expr, 'stdin');
return expr;
}
}
59 changes: 59 additions & 0 deletions libraries/rush-lib/src/api/SelectorExpressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

/**
* A "Selector Expression" is a JSON description of a complex selection of
* projects, using concepts familiar to users of the Rush CLI.
*/
export type SelectorExpression = ExpressionSelector | ExpressionParameter | ExpressionOperator;

export type ExpressionSelector = string | IExpressionDetailedSelector;

export interface IExpressionDetailedSelector {
scope: string;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is scope? Give example strings

value: string;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is value? Give example strings


// Reserved for future use
filters?: Record<string, string>;
}

export type ExpressionParameter = {
[K in `--${string}`]: string;
};
elliot-nelson marked this conversation as resolved.
Show resolved Hide resolved

export type ExpressionOperator = IExpressionOperatorAnd | IExpressionOperatorOr | IExpressionOperatorNot;

export interface IExpressionOperatorAnd {
and: SelectorExpression[];
}

export interface IExpressionOperatorOr {
or: SelectorExpression[];
}

export interface IExpressionOperatorNot {
not: SelectorExpression;
}

// A collection of type guards useful for interacting with selector expressions.

export function isDetailedSelector(expr: SelectorExpression): expr is IExpressionDetailedSelector {
return !!(expr && (expr as IExpressionDetailedSelector).scope);
}

export function isParameter(expr: SelectorExpression): expr is ExpressionParameter {
const keys: string[] = Object.keys(expr);
return keys.length === 1 && keys[0].startsWith('--');
}

export function isAnd(expr: SelectorExpression): expr is IExpressionOperatorAnd {
return !!(expr && (expr as IExpressionOperatorAnd).and);
}

export function isOr(expr: SelectorExpression): expr is IExpressionOperatorOr {
return !!(expr && (expr as IExpressionOperatorOr).or);
}

export function isNot(expr: SelectorExpression): expr is IExpressionOperatorNot {
return !!(expr && (expr as IExpressionOperatorNot).not);
}
69 changes: 69 additions & 0 deletions libraries/rush-lib/src/api/test/RushProjectSelector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { RushConfiguration } from '../RushConfiguration';
import { RushConfigurationProject } from '../RushConfigurationProject';
import { RushProjectSelector } from '../RushProjectSelector';

function createProjectSelector(): RushProjectSelector {
const rushJsonFile: string = `${__dirname}/repo/rush-pnpm.json`;
const rushConfig: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile);
const projectSelector: RushProjectSelector = new RushProjectSelector(rushConfig, {
gitSelectorParserOptions: {
includeExternalDependencies: true,
enableFiltering: false
}
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2023-09-13 at 11 22 56 PM

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a "selector"? Looking at these docs, a "selector" is an engine that chooses a list of Rush projects, kind of like how a CSS selector chooses DOM elements.

You have created a class called RushProjectSelector whose state is a rush.json configuration plus some options controlling how the Git selector behaves. If we use SQL terminology, this class is like a SQL table plus some global options that affect query behavior. It has the ability to execute queries, but the class is not a query -- instead the actual query is passed into projectSelector.selectExpression().

Is a "selector" that query? Well, the query's type is SelectorExpression (not to be confused with ExpressionSelector, let's ignore that for now).

What is a "selector expression"? Well apparently it can be a selector:

const projects: ReadonlySet<RushConfigurationProject> = await projectSelector.selectExpression({
  '--to': 'project1'
});

Is it then the case that --to X and X are both selectors? Is everything a "selector"? Is a "selector" just any AST node for this query language?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Constructive suggestions:

  1. Per my other comment, let's return to the idea that a "selector" is a leaf node like git:origin/main or tag:release.
  2. An expression like to(tag:release and git:origin/main) should get some other name, like selection expression. to() itself is really a "selection function" but its JSON notation is an expression involving that function.

--to is the CLI parameter representation of to(). I feel like the JSON design went off the rails a bit by putting --to in a JSON key. JSON keys are JavaScript variable names. We don't write let --to: string;. Of course we do want the JSON syntax to be concise to write, but it also needs to be an easy API to use. The API scenarios are things like:

  • a program that generates a selection expression
  • a program that optimizes a selection expression by reducing it to a more efficient form

Such programs aren't very easy to write if we can't even do:

switch (node.kind) {
  case 'union': . . .
  case 'intersect': . . .
  . . .
}

and instead must do:

    if (isUnion(expr)) {
      return this._evaluateUnion(expr, context);
    } else if (isIntersect(expr)) {
      return this._evaluateIntersect(expr, context);
    } else if (isSubtract(expr)) {
      return this._evaluateSubtract(expr, context);
    } else if (isParameter(expr)) {
      return this._evaluateParameter(expr, context);
    } else if (isDetailedSelector(expr)) {
  1. So I recommend going back to a more conventional AST design.
  2. Rename RushProjectSelector.selectExpression to something like ProjectQueryContext.select()

return projectSelector;
}

describe(RushProjectSelector.name, () => {
describe(RushProjectSelector.prototype.selectExpression.name, () => {
it('treats a string as a project name', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression('project2');
expect(projects.map((project) => project.packageName)).toEqual(['project2']);
});

it('selects a project using a detailed scope', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
scope: 'name',
value: 'project2'
});
expect(projects.map((project) => project.packageName)).toEqual(['project2']);
});

it('selects several projects with an OR operator', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
or: ['project1', 'project2', 'project3']
});
expect(projects.map((project) => project.packageName)).toEqual(['project1', 'project2', 'project3']);
});

it('restricts a selection with an AND operator', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
and: [{ or: ['project1', 'project2', 'project3'] }, 'project2']
});
expect(projects.map((project) => project.packageName)).toEqual(['project2']);
});

it('restricts a selection with a NOT operator', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
not: 'project3'
});
expect(projects.map((project) => project.packageName)).toEqual(['project1', 'project2']);
});

it('applies a parameter to a project', async () => {
const projectSelector: RushProjectSelector = createProjectSelector();
const projects: RushConfigurationProject[] = await projectSelector.selectExpression({
'--to': 'project1'
});
expect(projects.map((project) => project.packageName)).toEqual(['project1']);
});
});
});