Skip to content

Commit

Permalink
Add support for conditional prompting in bundle init (#971)
Browse files Browse the repository at this point in the history
## Changes
This PR introduces the `skip_prompt_if` extension to the jsonschema
library. If the inputs provided by the user match the JSON schema then
the prompt for that property is skipped.

Right now only constant checks are supported, but if in the future more
complicated conditionals are required, this can be extended to support
`allOf`, `oneOf`, `anyOf` etc allowing template authors to specify
conditionals of arbitary complexity.

## Tests
Unit tests and manually.
  • Loading branch information
shreyas-goenka authored Nov 30, 2023
1 parent 1f1ed6d commit bdef0f7
Show file tree
Hide file tree
Showing 7 changed files with 318 additions and 3 deletions.
4 changes: 4 additions & 0 deletions libs/jsonschema/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type Extension struct {
// schema will fail.
MinDatabricksCliVersion string `json:"min_databricks_cli_version,omitempty"`

// Skip prompting if this schema is satisfied by the configuration already present. In
// that case the default value of the property is used instead.
SkipPromptIf *Schema `json:"skip_prompt_if,omitempty"`

// Version of the schema. This is used to determine if the schema is
// compatible with the current CLI version.
Version *int `json:"version,omitempty"`
Expand Down
33 changes: 33 additions & 0 deletions libs/jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type Schema struct {
// IDE. This is manually injected here using schema.Docs
Description string `json:"description,omitempty"`

// Expected value for the JSON object. The object value must be equal to this
// field if it's specified in the schema.
Const any `json:"const,omitempty"`

// Schemas for the fields of an struct. The keys are the first json tag.
// The values are the schema for the type of the field
Properties map[string]*Schema `json:"properties,omitempty"`
Expand Down Expand Up @@ -118,6 +122,18 @@ func (schema *Schema) validateSchemaDefaultValueTypes() error {
return nil
}

func (schema *Schema) validateConstValueTypes() error {
for name, property := range schema.Properties {
if property.Const == nil {
continue
}
if err := validateType(property.Const, property.Type); err != nil {
return fmt.Errorf("type validation for const value of property %s failed: %w", name, err)
}
}
return nil
}

// Validate enum field values for properties are consistent with types.
func (schema *Schema) validateSchemaEnumValueTypes() error {
for name, property := range schema.Properties {
Expand Down Expand Up @@ -203,14 +219,25 @@ func (schema *Schema) validateSchemaMinimumCliVersion(currentVersion string) fun
}
}

func (schema *Schema) validateSchemaSkippedPropertiesHaveDefaults() error {
for name, property := range schema.Properties {
if property.SkipPromptIf != nil && property.Default == nil {
return fmt.Errorf("property %q has a skip_prompt_if clause but no default value", name)
}
}
return nil
}

func (schema *Schema) validate() error {
for _, fn := range []func() error{
schema.validateSchemaPropertyTypes,
schema.validateSchemaDefaultValueTypes,
schema.validateSchemaEnumValueTypes,
schema.validateConstValueTypes,
schema.validateSchemaDefaultValueIsInEnums,
schema.validateSchemaPattern,
schema.validateSchemaMinimumCliVersion("v" + build.GetInfo().Version),
schema.validateSchemaSkippedPropertiesHaveDefaults,
} {
err := fn()
if err != nil {
Expand Down Expand Up @@ -248,6 +275,12 @@ func Load(path string) (*Schema, error) {
return nil, fmt.Errorf("failed to parse default value for property %s: %w", name, err)
}
}
if property.Const != nil {
property.Const, err = toInteger(property.Const)
if err != nil {
return nil, fmt.Errorf("failed to parse const value for property %s: %w", name, err)
}
}
for i, enum := range property.Enum {
property.Enum[i], err = toInteger(enum)
if err != nil {
Expand Down
55 changes: 55 additions & 0 deletions libs/jsonschema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func TestSchemaLoadIntegers(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, int64(1), schema.Properties["abc"].Default)
assert.Equal(t, []any{int64(1), int64(2), int64(3)}, schema.Properties["abc"].Enum)
assert.Equal(t, int64(5), schema.Properties["def"].Const)
}

func TestSchemaLoadIntegersWithInvalidDefault(t *testing.T) {
Expand All @@ -60,6 +61,11 @@ func TestSchemaLoadIntegersWithInvalidEnums(t *testing.T) {
assert.EqualError(t, err, "failed to parse enum value 2.4 at index 1 for property abc: expected integer value, got: 2.4")
}

func TestSchemaLoadIntergersWithInvalidConst(t *testing.T) {
_, err := Load("./testdata/schema-load-int/schema-invalid-const.json")
assert.EqualError(t, err, "failed to parse const value for property def: expected integer value, got: 5.1")
}

func TestSchemaValidateDefaultType(t *testing.T) {
invalidSchema := &Schema{
Properties: map[string]*Schema{
Expand Down Expand Up @@ -250,3 +256,52 @@ func TestValidateSchemaMinimumCliVersion(t *testing.T) {
err = s.validateSchemaMinimumCliVersion("v0.0.0-dev")()
assert.NoError(t, err)
}

func TestValidateSchemaConstTypes(t *testing.T) {
s := &Schema{
Properties: map[string]*Schema{
"foo": {
Type: "string",
Const: "abc",
},
},
}
err := s.validate()
assert.NoError(t, err)

s = &Schema{
Properties: map[string]*Schema{
"foo": {
Type: "string",
Const: 123,
},
},
}
err = s.validate()
assert.EqualError(t, err, "type validation for const value of property foo failed: expected type string, but value is 123")
}

func TestValidateSchemaSkippedPropertiesHaveDefaults(t *testing.T) {
s := &Schema{
Properties: map[string]*Schema{
"foo": {
Type: "string",
Extension: Extension{SkipPromptIf: &Schema{}},
},
},
}
err := s.validate()
assert.EqualError(t, err, "property \"foo\" has a skip_prompt_if clause but no default value")

s = &Schema{
Properties: map[string]*Schema{
"foo": {
Type: "string",
Default: "abc",
Extension: Extension{SkipPromptIf: &Schema{}},
},
},
}
err = s.validate()
assert.NoError(t, err)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"def": {
"type": "integer",
"const": 5.1
}
}
}
4 changes: 4 additions & 0 deletions libs/jsonschema/testdata/schema-load-int/schema-valid.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"type": "integer",
"default": 1,
"enum": [1,2,3]
},
"def": {
"type": "integer",
"const": 5
}
}
}
47 changes: 44 additions & 3 deletions libs/template/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,61 @@ func (c *config) assignDefaultValues(r *renderer) error {
return nil
}

func (c *config) skipPrompt(p jsonschema.Property, r *renderer) (bool, error) {
// Config already has a value assigned. We don't have to prompt for a user input.
if _, ok := c.values[p.Name]; ok {
return true, nil
}

if p.Schema.SkipPromptIf == nil {
return false, nil
}

// Check if conditions specified by template author for skipping the prompt
// are satisfied. If they are not, we have to prompt for a user input.
for name, property := range p.Schema.SkipPromptIf.Properties {
if v, ok := c.values[name]; ok && v == property.Const {
continue
}
return false, nil
}

if p.Schema.Default == nil {
return false, fmt.Errorf("property %s has skip_prompt_if set but no default value", p.Name)
}

// Assign default value to property if we are skipping it.
if p.Schema.Type != jsonschema.StringType {
c.values[p.Name] = p.Schema.Default
return true, nil
}

// Execute the default value as a template and assign it to the property.
var err error
c.values[p.Name], err = r.executeTemplate(p.Schema.Default.(string))
if err != nil {
return false, err
}
return true, nil
}

// Prompts user for values for properties that do not have a value set yet
func (c *config) promptForValues(r *renderer) error {
for _, p := range c.schema.OrderedProperties() {
name := p.Name
property := p.Schema

// Config already has a value assigned
if _, ok := c.values[name]; ok {
// Skip prompting if we can.
skip, err := c.skipPrompt(p, r)
if err != nil {
return err
}
if skip {
continue
}

// Compute default value to display by converting it to a string
var defaultVal string
var err error
if property.Default != nil {
defaultValRaw, err := property.DefaultString()
if err != nil {
Expand Down
Loading

0 comments on commit bdef0f7

Please sign in to comment.