Repository: honeycombio/terraform-provider-honeycombio
Pull request #663 (2025-04-24 12:45:29)

feat(d/query_specification): temporary calculated field support
Adds Temporary Calculated Field support.


Patch:
diff --git a/client/query_spec.go b/client/query_spec.go
index 6797ca5..1f7dc39 100644
--- a/client/query_spec.go
+++ b/client/query_spec.go
@@ -21,6 +21,11 @@ type QuerySpec struct {
 	// The calculations to return as a time series and summary table. If no
 	// calculations are provided, COUNT is applied.
 	Calculations []CalculationSpec `json:"calculations,omitempty"`
+	// CalculatedFields are temporary Calculated Fields that are
+	// created for the query.
+	// They are not saved in the dataset and are not available in the dataset
+	// after the query is run.
+	CalculatedFields []CalculatedFieldSpec `json:"calculated_fields,omitempty"`
 	// The filters with which to restrict the considered events.
 	Filters []FilterSpec `json:"filters,omitempty"`
 	// If multiple filters are specified, filter_combination determines how
@@ -156,6 +161,12 @@ type CalculationSpec struct {
 	Column *string `json:"column,omitempty"`
 }
 
+// CalculatedFieldSpec represents a Temporary Calculated Field within a query.
+type CalculatedFieldSpec struct {
+	Name       string `json:"name"`
+	Expression string `json:"expression"`
+}
+
 // CalculationOp represents the operator of a calculation.
 type CalculationOp string
 
diff --git a/docs/data-sources/query_specification.md b/docs/data-sources/query_specification.md
index 842772f..99ddc3f 100644
--- a/docs/data-sources/query_specification.md
+++ b/docs/data-sources/query_specification.md
@@ -15,7 +15,11 @@ data "honeycombio_query_specification" "example" {
     column = "duration_ms"
   }
 
-  # zero or more filter blocks
+  calculated_field {
+    name       = "fast_enough"
+    expression = "LTE($response.duration_ms, 200)"
+  }
+
   filter {
     column = "trace.parent_id"
     op     = "does-not-exist"
@@ -27,6 +31,12 @@ data "honeycombio_query_specification" "example" {
     value  = "ThatSpecialTenant" 
   }
 
+  filter {
+    column = "fast_enough"
+    op     = "="
+    value  = false
+  }
+
   filter_combination = "AND"
 
   breakdowns = ["app.tenant"]
@@ -44,6 +54,7 @@ output "json_query" {
 The following arguments are supported:
 
 * `calculation` - (Optional) Zero or more configuration blocks (described below) with the calculations that should be displayed. If no calculations are specified, `COUNT` will be used.
+* `calculated_field` - (Optional) Zero or more configuration blocks (described below) of inline Calculated Fields.
 * `filter` - (Optional) Zero or more configuration blocks (described below) with the filters that should be applied.
 * `filter_combination` - (Optional) How to combine multiple filters, either `AND` (default) or `OR`.
 * `breakdowns` - (Optional) A list of fields to group by.
@@ -62,6 +73,11 @@ Each query configuration may have zero or more `calculation` blocks, which each
 * `op` - (Required) The operator to apply, see the supported list of calculation operators at [Calculation Operators](https://docs.honeycomb.io/api/query-specification/#calculation-operators).
 * `column` - (Optional) The column to apply the operator to, not needed with `COUNT` or `CONCURRENCY`.
 
+Each query configuration may have zero or more `calculated_field` blocks, which each accept the following arguments:
+
+* `name` - (Required) The name of this Temporary Calculated Field.
+* `expression` - (Required) The formula for your Calculated Field. To learn more about syntax and available functions, and to explore some example formulas, visit the [Calculated Field Formula Reference](https://docs.honeycomb.io/reference/derived-column-formula/).
+
 Each query configuration may have zero or more `filter` blocks, which each accept the following arguments:
 
 * `column` - (Required) The column to apply the filter to.
diff --git a/internal/helper/validation/valid_calculated_field.go b/internal/helper/validation/valid_calculated_field.go
new file mode 100644
index 0000000..204263c
--- /dev/null
+++ b/internal/helper/validation/valid_calculated_field.go
@@ -0,0 +1,47 @@
+package validation
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+
+	dcparser "github.com/honeycombio/honeycomb-derived-column-validator/pkg/parser"
+)
+
+var _ validator.String = isValidCalculatedFieldValidator{}
+
+type isValidCalculatedFieldValidator struct{}
+
+func (v isValidCalculatedFieldValidator) Description(_ context.Context) string {
+	return "expression must be a valid calculated field"
+}
+
+func (v isValidCalculatedFieldValidator) MarkdownDescription(ctx context.Context) string {
+	return v.Description(ctx)
+}
+
+func (v isValidCalculatedFieldValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) {
+	if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
+		return
+	}
+
+	if _, err := dcparser.ANTLRParse(request.ConfigValue.ValueString(), false); err != nil {
+		response.Diagnostics.Append(
+			validatordiag.InvalidAttributeValueDiagnostic(
+				request.Path,
+				v.Description(ctx),
+				fmt.Sprintf("%q: %s", request.ConfigValue.ValueString(), err),
+			),
+		)
+	}
+}
+
+// IsValidCalculatedField returns an AttributeValidator which ensures that any configured
+// attribute value is a valid calculated field expression.
+//
+// Null (unconfigured) and unknown (known after apply) values are skipped.
+func IsValidCalculatedField() validator.String {
+	return isValidCalculatedFieldValidator{}
+}
diff --git a/internal/models/query.go b/internal/models/query.go
index 9f95e3f..a5d70c4 100644
--- a/internal/models/query.go
+++ b/internal/models/query.go
@@ -9,19 +9,20 @@ type QueryResourceModel struct {
 }
 
 type QuerySpecificationModel struct {
-	ID                types.String                         `tfsdk:"id"`
-	FilterCombination types.String                         `tfsdk:"filter_combination"`
-	Breakdowns        []types.String                       `tfsdk:"breakdowns"`
-	Limit             types.Int64                          `tfsdk:"limit"`
-	TimeRange         types.Int64                          `tfsdk:"time_range"`
-	StartTime         types.Int64                          `tfsdk:"start_time"`
-	EndTime           types.Int64                          `tfsdk:"end_time"`
-	Granularity       types.Int64                          `tfsdk:"granularity"`
-	Calculations      []QuerySpecificationCalculationModel `tfsdk:"calculation"`
-	Filters           []QuerySpecificationFilterModel      `tfsdk:"filter"`
-	Havings           []QuerySpecificationHavingModel      `tfsdk:"having"`
-	Orders            []QuerySpecificationOrderModel       `tfsdk:"order"`
-	Json              types.String                         `tfsdk:"json"` // Computed JSON query specification output
+	ID                types.String                             `tfsdk:"id"`
+	FilterCombination types.String                             `tfsdk:"filter_combination"`
+	Breakdowns        []types.String                           `tfsdk:"breakdowns"`
+	Limit             types.Int64                              `tfsdk:"limit"`
+	TimeRange         types.Int64                              `tfsdk:"time_range"`
+	StartTime         types.Int64                              `tfsdk:"start_time"`
+	EndTime           types.Int64                              `tfsdk:"end_time"`
+	Granularity       types.Int64                              `tfsdk:"granularity"`
+	Calculations      []QuerySpecificationCalculationModel     `tfsdk:"calculation"`
+	CalculatedFields  []QuerySpecificationCalculatedFieldModel `tfsdk:"calculated_field"`
+	Filters           []QuerySpecificationFilterModel          `tfsdk:"filter"`
+	Havings           []QuerySpecificationHavingModel          `tfsdk:"having"`
+	Orders            []QuerySpecificationOrderModel           `tfsdk:"order"`
+	Json              types.String                             `tfsdk:"json"` // Computed JSON query specification output
 }
 
 type QuerySpecificationCalculationModel struct {
@@ -29,6 +30,11 @@ type QuerySpecificationCalculationModel struct {
 	Op     types.String `tfsdk:"op"`
 }
 
+type QuerySpecificationCalculatedFieldModel struct {
+	Name       types.String `tfsdk:"name"`
+	Expression types.String `tfsdk:"expression"`
+}
+
 type QuerySpecificationFilterModel struct {
 	Column types.String `tfsdk:"column"`
 	Op     types.String `tfsdk:"op"`
diff --git a/internal/provider/query_specification_data_source.go b/internal/provider/query_specification_data_source.go
index b39104f..e7a5d79 100644
--- a/internal/provider/query_specification_data_source.go
+++ b/internal/provider/query_specification_data_source.go
@@ -17,6 +17,7 @@ import (
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/hashcode"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
 )
 
@@ -115,6 +116,24 @@ func (d *querySpecDataSource) Schema(_ context.Context, _ datasource.SchemaReque
 					},
 				},
 			},
+			"calculated_field": schema.ListNestedBlock{
+				Description: "Zero or more configuration blocks describing the Calculated Fields to create for use in this query.",
+				NestedObject: schema.NestedBlockObject{
+					Attributes: map[string]schema.Attribute{
+						"name": schema.StringAttribute{
+							Description: "The name of the Calculated Field.",
+							Required:    true,
+						},
+						"expression": schema.StringAttribute{
+							Description: "The formula to use for the Calculated Field.",
+							Required:    true,
+							Validators: []validator.String{
+								validation.IsValidCalculatedField(),
+							},
+						},
+					},
+				},
+			},
 			"filter": schema.ListNestedBlock{
 				Description: "Zero or more configuration blocks describing the filters to apply to the query results.",
 				NestedObject: schema.NestedBlockObject{
@@ -233,6 +252,14 @@ func (d *querySpecDataSource) Read(ctx context.Context, req datasource.ReadReque
 		calculations = []client.CalculationSpec{{Op: client.CalculationOpCount}}
 	}
 
+	calculatedFields := make([]client.CalculatedFieldSpec, len(data.CalculatedFields))
+	for i, f := range data.CalculatedFields {
+		calculatedFields[i] = client.CalculatedFieldSpec{
+			Name:       f.Name.ValueString(),
+			Expression: f.Expression.ValueString(),
+		}
+	}
+
 	filters := make([]client.FilterSpec, 0, len(data.Filters))
 	for i, f := range data.Filters {
 		filter := client.FilterSpec{
@@ -349,7 +376,7 @@ func (d *querySpecDataSource) Read(ctx context.Context, req datasource.ReadReque
 
 		orders[i] = order
 	}
-	// ensure all orders have a matching calculation or breakdown
+	// ensure all orders have a matching calculation, calculated_field or breakdown
 	for i, order := range orders {
 		if order.Op != nil && *order.Op == client.CalculationOpHeatmap {
 			resp.Diagnostics.AddAttributeError(
@@ -366,6 +393,14 @@ func (d *querySpecDataSource) Read(ctx context.Context, req datasource.ReadReque
 				break
 			}
 		}
+		if !found {
+			for _, calc := range calculatedFields {
+				if reflect.DeepEqual(order.Column, calc.Name) {
+					found = true
+					break
+				}
+			}
+		}
 		if !found {
 			for _, breakdown := range breakdowns {
 				if order.Column != nil && *order.Column == breakdown {
@@ -385,6 +420,7 @@ func (d *querySpecDataSource) Read(ctx context.Context, req datasource.ReadReque
 
 	querySpec := &client.QuerySpec{
 		Calculations:      calculations,
+		CalculatedFields:  calculatedFields,
 		Filters:           filters,
 		Havings:           havings,
 		FilterCombination: client.FilterCombination(data.FilterCombination.ValueString()),


Test patch:
diff --git a/client/query_spec_test.go b/client/query_spec_test.go
index c035645..d1fcf9a 100644
--- a/client/query_spec_test.go
+++ b/client/query_spec_test.go
@@ -33,6 +33,12 @@ func TestQuerySpec(t *testing.T) {
 				Column: client.ToPtr("duration_ms"),
 			},
 		},
+		CalculatedFields: []client.CalculatedFieldSpec{
+			{
+				Name:       "fast_enough",
+				Expression: "LTE($duration_ms, 200)",
+			},
+		},
 		Filters: []client.FilterSpec{
 			{
 				Column: "column_1",
@@ -48,6 +54,11 @@ func TestQuerySpec(t *testing.T) {
 				Op:     client.FilterOpNotEquals,
 				Value:  "",
 			},
+			{
+				Column: "fast_enough",
+				Op:     client.FilterOpEquals,
+				Value:  true,
+			},
 		},
 		FilterCombination: client.FilterCombinationOr,
 		Breakdowns:        []string{"column_1", "column_2"},
diff --git a/internal/helper/validation/valid_calculated_field_test.go b/internal/helper/validation/valid_calculated_field_test.go
new file mode 100644
index 0000000..141c8e4
--- /dev/null
+++ b/internal/helper/validation/valid_calculated_field_test.go
@@ -0,0 +1,66 @@
+package validation_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-framework/path"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/stretchr/testify/assert"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
+)
+
+func Test_IsValidCalculatedFieldValidator(t *testing.T) {
+	t.Parallel()
+
+	testCases := map[string]struct {
+		val         types.String
+		expectError bool
+	}{
+		"unknown": {
+			val: types.StringUnknown(),
+		},
+		"null": {
+			val: types.StringNull(),
+		},
+		"valid": {
+			val: types.StringValue(`IF(AND(NOT(EXISTS($trace.parent_id)),EXISTS($duration_ms)),LTE($duration_ms,300))`),
+		},
+		"mismatched input": {
+			val:         types.StringValue(`BOOL(1`),
+			expectError: true,
+		},
+		"invalid function": {
+			val:         types.StringValue(`FOOBAR(1)`),
+			expectError: true,
+		},
+		"extraneous input": {
+			val:         types.StringValue(`IF(AND(NOT(EXISTS($trace.parent_id)),EXISTS($duration_ms)),LTE($duration_ms,300)),`),
+			expectError: true,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			request := validator.StringRequest{
+				Path:           path.Root("test"),
+				PathExpression: path.MatchRoot("test"),
+				ConfigValue:    tc.val,
+			}
+			response := validator.StringResponse{}
+
+			validation.IsValidCalculatedField().
+				ValidateString(context.Background(), request, &response)
+
+			assert.Equal(t,
+				tc.expectError,
+				response.Diagnostics.HasError(),
+				"unexpected error: %s", response.Diagnostics,
+			)
+		})
+	}
+}
diff --git a/internal/provider/query_specification_data_source_test.go b/internal/provider/query_specification_data_source_test.go
index 463bb78..9392394 100644
--- a/internal/provider/query_specification_data_source_test.go
+++ b/internal/provider/query_specification_data_source_test.go
@@ -45,6 +45,12 @@ func TestAcc_QuerySpecificationDataSource_basic(t *testing.T) {
       "column": "duration_ms"
     }
   ],
+  "calculated_fields": [
+    {
+      "name": "adhoc_test",
+      "expression": "BOOL(1)"
+    }
+  ],
   "filters": [
     {
       "column": "trace.parent_id",
@@ -120,6 +126,11 @@ data "honeycombio_query_specification" "test" {
         column = "duration_ms"
     }
 
+    calculated_field {
+      name       = "adhoc_test"
+      expression = "BOOL(1)"
+    }
+
     filter {
         column = "trace.parent_id"
         op     = "does-not-exist"