diff --git a/libs/jsonschema/extension.go b/libs/jsonschema/extension.go index 9127a0d61b..3e32caf1ab 100644 --- a/libs/jsonschema/extension.go +++ b/libs/jsonschema/extension.go @@ -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"` diff --git a/libs/jsonschema/schema.go b/libs/jsonschema/schema.go index 83213791ec..443e7af6e6 100644 --- a/libs/jsonschema/schema.go +++ b/libs/jsonschema/schema.go @@ -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"` @@ -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 { @@ -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 { @@ -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 { diff --git a/libs/jsonschema/schema_test.go b/libs/jsonschema/schema_test.go index a750f44af1..cf1f127672 100644 --- a/libs/jsonschema/schema_test.go +++ b/libs/jsonschema/schema_test.go @@ -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) { @@ -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{ @@ -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) +} diff --git a/libs/jsonschema/testdata/schema-load-int/schema-invalid-const.json b/libs/jsonschema/testdata/schema-load-int/schema-invalid-const.json new file mode 100644 index 0000000000..9c1b3c0d3a --- /dev/null +++ b/libs/jsonschema/testdata/schema-load-int/schema-invalid-const.json @@ -0,0 +1,9 @@ +{ + "type": "object", + "properties": { + "def": { + "type": "integer", + "const": 5.1 + } + } +} diff --git a/libs/jsonschema/testdata/schema-load-int/schema-valid.json b/libs/jsonschema/testdata/schema-load-int/schema-valid.json index a1167a6c98..425d7c5a97 100644 --- a/libs/jsonschema/testdata/schema-load-int/schema-valid.json +++ b/libs/jsonschema/testdata/schema-load-int/schema-valid.json @@ -5,6 +5,10 @@ "type": "integer", "default": 1, "enum": [1,2,3] + }, + "def": { + "type": "integer", + "const": 5 } } } diff --git a/libs/template/config.go b/libs/template/config.go index 508e773678..2b4d19d14a 100644 --- a/libs/template/config.go +++ b/libs/template/config.go @@ -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 { diff --git a/libs/template/config_test.go b/libs/template/config_test.go index d76952dc84..c4968ee1a1 100644 --- a/libs/template/config_test.go +++ b/libs/template/config_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "testing" + "text/template" "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/jsonschema" @@ -229,3 +230,171 @@ func TestTemplateSchemaErrorsWithEmptyDescription(t *testing.T) { _, err := newConfig(context.Background(), "./testdata/config-test-schema/invalid-test-schema.json") assert.EqualError(t, err, "template property property-without-description is missing a description") } + +func testRenderer() *renderer { + return &renderer{ + config: map[string]any{ + "fruit": "apples", + }, + baseTemplate: template.New(""), + } +} + +func TestPromptIsSkippedWhenEmpty(t *testing.T) { + c := config{ + ctx: context.Background(), + values: make(map[string]any), + schema: &jsonschema.Schema{ + Properties: map[string]*jsonschema.Schema{ + "always-skip": { + Type: "string", + Default: "I like {{.fruit}}", + Extension: jsonschema.Extension{ + SkipPromptIf: &jsonschema.Schema{}, + }, + }, + }, + }, + } + + // We should always skip the prompt here. An empty JSON schema by definition + // matches all possible configurations. + skip, err := c.skipPrompt(jsonschema.Property{ + Name: "always-skip", + Schema: c.schema.Properties["always-skip"], + }, testRenderer()) + assert.NoError(t, err) + assert.True(t, skip) + assert.Equal(t, "I like apples", c.values["always-skip"]) +} + +func TestPromptSkipErrorsWithEmptyDefault(t *testing.T) { + c := config{ + ctx: context.Background(), + values: make(map[string]any), + schema: &jsonschema.Schema{ + Properties: map[string]*jsonschema.Schema{ + "no-default": { + Type: "string", + Extension: jsonschema.Extension{ + SkipPromptIf: &jsonschema.Schema{}, + }, + }, + }, + }, + } + + _, err := c.skipPrompt(jsonschema.Property{ + Name: "no-default", + Schema: c.schema.Properties["no-default"], + }, testRenderer()) + assert.EqualError(t, err, "property no-default has skip_prompt_if set but no default value") +} + +func TestPromptIsSkippedIfValueIsAssigned(t *testing.T) { + c := config{ + ctx: context.Background(), + values: make(map[string]any), + schema: &jsonschema.Schema{ + Properties: map[string]*jsonschema.Schema{ + "already-assigned": { + Type: "string", + Default: "some-default-value", + }, + }, + }, + } + + c.values["already-assigned"] = "some-value" + skip, err := c.skipPrompt(jsonschema.Property{ + Name: "already-assigned", + Schema: c.schema.Properties["already-assigned"], + }, testRenderer()) + assert.NoError(t, err) + assert.True(t, skip) + assert.Equal(t, "some-value", c.values["already-assigned"]) +} + +func TestPromptIsSkipped(t *testing.T) { + c := config{ + ctx: context.Background(), + values: make(map[string]any), + schema: &jsonschema.Schema{ + Properties: map[string]*jsonschema.Schema{ + "abc": { + Type: "string", + }, + "def": { + Type: "integer", + }, + "xyz": { + Type: "string", + Default: "hello-world", + Extension: jsonschema.Extension{ + SkipPromptIf: &jsonschema.Schema{ + Properties: map[string]*jsonschema.Schema{ + "abc": { + Const: "foobar", + }, + "def": { + Const: 123, + }, + }, + }, + }, + }, + }, + }, + } + + // No skip condition defined. Prompt should not be skipped. + skip, err := c.skipPrompt(jsonschema.Property{ + Name: "abc", + Schema: c.schema.Properties["abc"], + }, testRenderer()) + assert.NoError(t, err) + assert.False(t, skip) + + // No values assigned to config. Prompt should not be skipped. + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.False(t, skip) + assert.NotContains(t, c.values, "xyz") + + // Values do not match skip condition. Prompt should not be skipped. + c.values["abc"] = "foo" + c.values["def"] = 123 + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.False(t, skip) + assert.NotContains(t, c.values, "xyz") + + // Values do not match skip condition. Prompt should not be skipped. + c.values["abc"] = "foobar" + c.values["def"] = 1234 + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.False(t, skip) + assert.NotContains(t, c.values, "xyz") + + // Values match skip condition. Prompt should be skipped. Default value should + // be assigned to "xyz". + c.values["abc"] = "foobar" + c.values["def"] = 123 + skip, err = c.skipPrompt(jsonschema.Property{ + Name: "xyz", + Schema: c.schema.Properties["xyz"], + }, testRenderer()) + assert.NoError(t, err) + assert.True(t, skip) + assert.Equal(t, "hello-world", c.values["xyz"]) +}