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

feat: support complex flag relationships #468

Merged
merged 12 commits into from Aug 24, 2022

Conversation

mdonnalley
Copy link
Contributor

@mdonnalley mdonnalley commented Aug 10, 2022

Lay groundwork to support more advanced flag relationship validation

Example usage

export default class MyCommand extends Command {
  static flags = {
    one: Flags.string(),
    two: Flags.string(),
    three: Flags.string(),
    four: Flags.string(),
    five: Flags.string(),
    six: Flags.string(),
    foo: Flags.string({
      relationships: [
         // always require the 'one' flag    
         { type: 'all', flags: ['one']},

         // require either of the 'two' or 'three' flags
         { type: 'some', flags: ['two', 'three']},

         // never allow the 'four' flag
         { type: 'none', flags: ['four']},

         // never allow the 'five' flag but only when it is equal to 'foo'
         { type: 'none', flags: [{name: 'five', when: (flags) => flags.five === 'foo'}]},

         // always require the 'six' flag but only when 'one' is equal to 'foo'
         { type: 'all', flags: [{name: 'six', when: (flags) => flags.one === 'foo'}]},
      ]
    }),
  }
}

Closes oclif/oclif#323

@mdonnalley mdonnalley self-assigned this Aug 10, 2022
@@ -112,6 +112,16 @@ export type FlagProps = {
hidden?: boolean;
required?: boolean;
dependsOn?: string[];
relationships?: {
dependsOn?: {
type: 'all' | 'atLeastOne';
Copy link
Member

Choose a reason for hiding this comment

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

none could allow exclusive to go away?

relationships?: {
dependsOn?: {
type: 'all' | 'atLeastOne';
flags: string[];
Copy link
Member

Choose a reason for hiding this comment

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

flags: string[] | Array<{flag: string, value: any}> would allow you to say things like:

flag A can't be used if flag B has value X (exclusive)
or
you can't use flag A unless flag B has value Y (dependsOn)

or even (allow or disallow multiple values from a flag)

[{ flag: 'a', value: 'x'}, {flag: 'a', value: 'y'}]


function validateExclusive(name: string, exclusive: string[]) {
for (const also of exclusive) {
// do not enforce exclusivity for flags that were defaulted
Copy link
Member

Choose a reason for hiding this comment

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

consider making this configurable. Flag B exists, but can't be used unless you make Flag A something other than its default

Comment on lines 123 to 127
let foundAtLeastOne = false
for (const flag of dependsOnFlags) {
if (parse.output.flags[flag]) {
foundAtLeastOne = true
break
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let foundAtLeastOne = false
for (const flag of dependsOnFlags) {
if (parse.output.flags[flag]) {
foundAtLeastOne = true
break
const foundAtLeastOne = dependsOnFlags.some((flag) => parse.output.flags[flag])

flags: string[];
};
exclusive?: {
type: 'all' | 'atLeastOne';
Copy link
Member

Choose a reason for hiding this comment

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

checking my understanding:

'all' => it's an error if you use all the flags listed?
'atLeastOne' => it's an error if you use any of the flags listed?

@@ -751,6 +751,7 @@ export async function toCached(c: Command.Class, plugin?: IPlugin): Promise<Comm
helpGroup: flag.helpGroup,
allowNo: flag.allowNo,
dependsOn: flag.dependsOn,
relationships: flag.relationships,
Copy link
Member

Choose a reason for hiding this comment

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

would we want to use union types to make it not possible to use both relationships and any of the simple ones (dependsOn, exclusive, exactlyOne) so that it doesn't get really nasty?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I feel comfortable not putting any guard rails up for that. People can make a mess of their flags if they want

For the sf side, we could always add some linting rules to guide people away from doing too much mixing and matching

@@ -86,6 +86,12 @@ export type DefaultContext<T> = {
export type Default<T> = T | ((context: DefaultContext<T>) => Promise<T>)
export type DefaultHelp<T> = T | ((context: DefaultContext<T>) => Promise<string | undefined>)

export type FlagRelationship = string | {name: string; when: (flags: Record<string, unknown>) => Promise<boolean>};
export type Relationship = {
type: 'all' | 'some' | 'none';
Copy link
Member

Choose a reason for hiding this comment

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

consider one or 1 since we've already got an exactlyOne since it happened often enough.


async function validateRelationships(name: string, flag: CompletableFlag<any>) {
if (!flag.relationships) return
for (const relationship of flag.relationships) {
Copy link
Member

Choose a reason for hiding this comment

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

worth parallelizing?

Copy link
Member

Choose a reason for hiding this comment

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

ux idea: as written, you'll get a failure on the first validation to throw. For the user, they'll fix one error and then hit the next.

You could run this with Promise.allSettled and maybe display all the errors in the output.

mshanemc
mshanemc previously approved these changes Aug 18, 2022
Copy link
Member

@mshanemc mshanemc left a comment

Choose a reason for hiding this comment

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

approved with some ideas/suggestions

})
})

describe('type: some', () => {
Copy link
Member

Choose a reason for hiding this comment

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

testing ideas:

it looks like you've always got length = 1 for relationships. I'd be interested in maybe a "big test" that

  1. tests an array of several relationships
  2. includes relationships of different types

{
type: 'all',
flags: [
'cookies',
Copy link
Member

Choose a reason for hiding this comment

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

nice!

Comment on lines 97 to 98
parse.output.metadata.flags[flag] &&
parse.output.metadata.flags[flag].setFromDefault
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
parse.output.metadata.flags[flag] &&
parse.output.metadata.flags[flag].setFromDefault
parse.output.metadata.flags[flag]?.setFromDefault

etc on the others.

Comment on lines 114 to 115
function validateExactlyOne(name: string, exactlyOne: FlagRelationship[]) {
for (const flag of exactlyOne || []) {
Copy link
Member

Choose a reason for hiding this comment

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

defaults in the signature

Suggested change
function validateExactlyOne(name: string, exactlyOne: FlagRelationship[]) {
for (const flag of exactlyOne || []) {
function validateExactlyOne(name: string, exactlyOne: FlagRelationship[] = []) {
for (const flag of exactlyOne) {


async function validateRelationships(name: string, flag: CompletableFlag<any>) {
if (!flag.relationships) return
for (const relationship of flag.relationships) {
Copy link
Member

Choose a reason for hiding this comment

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

ux idea: as written, you'll get a failure on the first validation to throw. For the user, they'll fix one error and then hit the next.

You could run this with Promise.allSettled and maybe display all the errors in the output.

@@ -54,43 +56,13 @@ export function validate(parse: {
}
}

function validateFlags() {
async function validateFlags() {
for (const [name, flag] of Object.entries(parse.input.flags)) {
Copy link
Member

Choose a reason for hiding this comment

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

I'd love to see if parallelizing this would help with perf (both the various validations for each flag, AND doing all the flags together)

Comment on lines 89 to 92
const keys = Object.keys(resolved).reduce((acc, key) => {
if (resolved[key]) acc.push(key)
return acc
}, [] as string[])
Copy link
Member

Choose a reason for hiding this comment

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

not sure why this reduce needs to be here. Another design would be

Object.entries(resolved).forEach(

and use 1 && conditional to check what you're checking to get to an error

  1. the value is truthy AND
    2.parse.output.metadata.flags[flag]?.setFromDefault is falsy AND
  2. parse.output.metadata.flags[name]?.setFromDefault is falsy AND
  3. parse.output.flags[flag] is truthy
    --> throw!

that way it's only 1 iteration, the conditionals can be in whatever order is most likely to resolve soonest and it'll fail on the first error

@mdonnalley mdonnalley merged commit 222d1f6 into main Aug 24, 2022
@mdonnalley mdonnalley deleted the mdonnalley/flag-relationships branch August 24, 2022 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

conditional dependsOn
2 participants