Repository: honeycombio/terraform-provider-honeycombio
Pull request #454 (2024-03-18 19:14:56)

feat: migrate r/query to Framework
This migrates `r/query` from the Plugin SDKv2 to the Plugin Framework and introduces import support for queries to support [generating configuration on import](https://developer.hashicorp.com/terraform/language/import/generating-configuration).

As a bonus, found a bug in query equivalency with the default `limit` of `1000`.


Patch:
diff --git a/client/query_spec.go b/client/query_spec.go
index 0471a29..67d93e2 100644
--- a/client/query_spec.go
+++ b/client/query_spec.go
@@ -1,6 +1,14 @@
 package client
 
-import "reflect"
+import (
+	"encoding/json"
+	"reflect"
+)
+
+const (
+	DefaultQueryTimeRange = 2 * 60 * 60
+	DefaultQueryLimit     = 1000
+)
 
 // QuerySpec represents a Honeycomb query.
 //
@@ -50,7 +58,14 @@ type QuerySpec struct {
 	Granularity *int `json:"granularity,omitempty"`
 }
 
-const DefaultQueryTimeRange = 2 * 60 * 60
+// Encode returns the JSON string representation of the QuerySpec.
+func (qs *QuerySpec) Encode() (string, error) {
+	b, err := json.MarshalIndent(qs, "", "  ")
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}
 
 // Determines if two QuerySpecs are equivalent
 func (qs *QuerySpec) EquivalentTo(other QuerySpec) bool {
@@ -117,7 +132,7 @@ func (qs *QuerySpec) EquivalentTo(other QuerySpec) bool {
 	if !Equivalent(qs.Havings, other.Havings) {
 		return false
 	}
-	if !reflect.DeepEqual(qs.Limit, other.Limit) {
+	if PtrValueOrDefault(qs.Limit, DefaultQueryLimit) != PtrValueOrDefault(other.Limit, DefaultQueryLimit) {
 		return false
 	}
 	if PtrValueOrDefault(qs.TimeRange, DefaultQueryTimeRange) != PtrValueOrDefault(other.TimeRange, DefaultQueryTimeRange) {
diff --git a/docs/resources/query.md b/docs/resources/query.md
index 7d03881..1eff185 100644
--- a/docs/resources/query.md
+++ b/docs/resources/query.md
@@ -47,4 +47,8 @@ In addition to all arguments above, the following attributes are exported:
 
 ## Import
 
-Queries cannot be imported.
+Querys can be imported using a combination of the dataset name and their ID, e.g.
+
+```
+$ terraform import honeycombio_query.my_query my-dataset/bj8BwOa1uRz
+```
diff --git a/honeycombio/data_source_query_result.go b/honeycombio/data_source_query_result.go
index 4e2e108..7d2b424 100644
--- a/honeycombio/data_source_query_result.go
+++ b/honeycombio/data_source_query_result.go
@@ -88,7 +88,7 @@ func dataSourceHoneycombioQueryResultRead(ctx context.Context, d *schema.Resourc
 	}
 
 	d.SetId(queryResult.ID)
-	queryJSON, err := encodeQuery(query)
+	queryJSON, err := query.Encode()
 	if err != nil {
 		return diag.FromErr(err)
 	}
diff --git a/honeycombio/data_source_query_specification.go b/honeycombio/data_source_query_specification.go
index 3eb7fbc..1c448d7 100644
--- a/honeycombio/data_source_query_specification.go
+++ b/honeycombio/data_source_query_specification.go
@@ -251,7 +251,7 @@ func dataSourceHoneycombioQuerySpecRead(ctx context.Context, d *schema.ResourceD
 		}
 	}
 
-	jsonQuery, err := encodeQuery(query)
+	jsonQuery, err := query.Encode()
 	if err != nil {
 		return diag.FromErr(err)
 	}
diff --git a/honeycombio/provider.go b/honeycombio/provider.go
index 7707aa1..a234927 100644
--- a/honeycombio/provider.go
+++ b/honeycombio/provider.go
@@ -55,7 +55,6 @@ func Provider(version string) *schema.Provider {
 			"honeycombio_derived_column":      newDerivedColumn(),
 			"honeycombio_marker":              newMarker(),
 			"honeycombio_marker_setting":      newMarkerSetting(),
-			"honeycombio_query":               newQuery(),
 			"honeycombio_query_annotation":    newQueryAnnotation(),
 			"honeycombio_email_recipient":     newEmailRecipient(),
 			"honeycombio_pagerduty_recipient": newPDRecipient(),
diff --git a/honeycombio/query_spec.go b/honeycombio/query_spec.go
index 40512b6..de8bc56 100644
--- a/honeycombio/query_spec.go
+++ b/honeycombio/query_spec.go
@@ -1,6 +1,7 @@
 package honeycombio
 
 import (
+	"bytes"
 	"encoding/json"
 
 	"github.com/hashicorp/go-cty/cty"
@@ -10,12 +11,6 @@ import (
 	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
 )
 
-// encodeQuery in a JSON string.
-func encodeQuery(q *honeycombio.QuerySpec) (string, error) {
-	jsonQueryBytes, err := json.MarshalIndent(q, "", "  ")
-	return string(jsonQueryBytes), err
-}
-
 type querySpecValidateDiagFunc func(q *honeycombio.QuerySpec) diag.Diagnostics
 
 // validateQueryJSON checks that the input can be deserialized as a QuerySpec
@@ -23,9 +18,9 @@ type querySpecValidateDiagFunc func(q *honeycombio.QuerySpec) diag.Diagnostics
 func validateQueryJSON(validators ...querySpecValidateDiagFunc) schema.SchemaValidateDiagFunc {
 	return func(i interface{}, path cty.Path) diag.Diagnostics {
 		var q honeycombio.QuerySpec
-
-		err := json.Unmarshal([]byte(i.(string)), &q)
-		if err != nil {
+		dec := json.NewDecoder(bytes.NewReader([]byte(i.(string))))
+		dec.DisallowUnknownFields()
+		if err := dec.Decode(&q); err != nil {
 			return diag.Errorf("value of query_json is not a valid query specification")
 		}
 
diff --git a/honeycombio/resource_query.go b/honeycombio/resource_query.go
deleted file mode 100644
index b55b69f..0000000
--- a/honeycombio/resource_query.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package honeycombio
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-
-	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
-
-	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
-	"github.com/honeycombio/terraform-provider-honeycombio/honeycombio/internal/verify"
-)
-
-func newQuery() *schema.Resource {
-	return &schema.Resource{
-		CreateContext: resourceQueryCreate,
-		ReadContext:   resourceQueryRead,
-		UpdateContext: nil,
-		DeleteContext: schema.NoopContext,
-		Importer:      nil,
-
-		Schema: map[string]*schema.Schema{
-			"query_json": {
-				Type:             schema.TypeString,
-				Required:         true,
-				ForceNew:         true,
-				ValidateDiagFunc: validateQueryJSON(),
-				DiffSuppressFunc: verify.SupressEquivQuerySpecDiff,
-			},
-			"dataset": {
-				Type:     schema.TypeString,
-				Required: true,
-				ForceNew: true,
-			},
-		},
-	}
-}
-
-func resourceQueryCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	client := meta.(*honeycombio.Client)
-
-	dataset := d.Get("dataset").(string)
-	var querySpec honeycombio.QuerySpec
-	err := json.Unmarshal([]byte(d.Get("query_json").(string)), &querySpec)
-	if err != nil {
-		return diag.FromErr(err)
-	}
-
-	query, err := client.Queries.Create(ctx, dataset, &querySpec)
-	if err != nil {
-		return diagFromErr(err)
-	}
-
-	d.SetId(*query.ID)
-	return resourceQueryRead(ctx, d, meta)
-}
-
-func resourceQueryRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	client := meta.(*honeycombio.Client)
-
-	dataset := d.Get("dataset").(string)
-
-	var detailedErr honeycombio.DetailedError
-	query, err := client.Queries.Get(ctx, dataset, d.Id())
-	if errors.As(err, &detailedErr) {
-		if detailedErr.IsNotFound() {
-			d.SetId("")
-			return nil
-		} else {
-			return diagFromDetailedErr(detailedErr)
-		}
-	} else if err != nil {
-		return diag.FromErr(err)
-	}
-
-	// Track ID at the Resource level
-	d.SetId(*query.ID)
-	query.ID = nil
-
-	queryJSON, err := encodeQuery(query)
-	if err != nil {
-		return diag.FromErr(err)
-	}
-	d.Set("query_json", queryJSON)
-	return nil
-}
diff --git a/internal/helper/modifiers/equivalent_query_spec.go b/internal/helper/modifiers/equivalent_query_spec.go
new file mode 100644
index 0000000..9570065
--- /dev/null
+++ b/internal/helper/modifiers/equivalent_query_spec.go
@@ -0,0 +1,56 @@
+package modifiers
+
+import (
+	"context"
+	"encoding/json"
+
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
+)
+
+type equivalentQuerySpec struct{}
+
+var _ planmodifier.String = &equivalentQuerySpec{}
+
+func (m equivalentQuerySpec) Description(_ context.Context) string {
+	return "Avoids unnecessary plans if two query specifications are equivalent."
+}
+
+func (m equivalentQuerySpec) MarkdownDescription(ctx context.Context) string {
+	return m.Description(ctx)
+}
+
+func (m equivalentQuerySpec) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
+	// Do nothing on resource destroy.
+	if req.Plan.Raw.IsNull() {
+		return
+	}
+	// Do nothing if the plan or state is not yet known.
+	if req.PlanValue.IsUnknown() || req.PlanValue.IsNull() || req.StateValue.IsNull() || req.StateValue.IsUnknown() {
+		return
+	}
+
+	var configQs, stateQs client.QuerySpec
+	config := req.ConfigValue.ValueString()
+	if err := json.Unmarshal([]byte(config), &configQs); err != nil {
+		resp.Diagnostics.AddError("Error unmarshaling config JSON", err.Error())
+		return
+	}
+	state := req.StateValue.ValueString()
+	if err := json.Unmarshal([]byte(state), &stateQs); err != nil {
+		resp.Diagnostics.AddError("Error unmarshaling state JSON", err.Error())
+		return
+	}
+
+	if stateQs.EquivalentTo(configQs) {
+		// If the query specifications are equivalent, suppress the diff
+		// by setting the plan value to value already in state.
+		resp.PlanValue = req.StateValue
+	}
+}
+
+// EquivalentQuerySpec avoids unnecessary plans if two query specifications are equivalent.
+func EquivalentQuerySpec() planmodifier.String {
+	return equivalentQuerySpec{}
+}
diff --git a/internal/helper/validation/query_spec.go b/internal/helper/validation/query_spec.go
new file mode 100644
index 0000000..e3652b3
--- /dev/null
+++ b/internal/helper/validation/query_spec.go
@@ -0,0 +1,47 @@
+package validation
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
+)
+
+var _ validator.String = querySpecValidator{}
+
+type querySpecValidator struct{}
+
+func (v querySpecValidator) Description(_ context.Context) string {
+	return "value must be a valid Honeycomb Query Specification"
+}
+
+func (v querySpecValidator) MarkdownDescription(ctx context.Context) string {
+	return v.Description(ctx)
+}
+
+func (v querySpecValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) {
+	if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() {
+		return
+	}
+
+	var q client.QuerySpec
+	dec := json.NewDecoder(bytes.NewReader([]byte(request.ConfigValue.ValueString())))
+	dec.DisallowUnknownFields()
+	if err := dec.Decode(&q); err != nil {
+		response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
+			request.Path,
+			v.Description(ctx),
+			fmt.Sprintf("%q: %s", request.ConfigValue.ValueString(), err.Error()),
+		))
+	}
+}
+
+// ValidQuerySpec determines if the provided JSON is a valid Honeycomb Query Specification
+func ValidQuerySpec() validator.String {
+	return querySpecValidator{}
+}
diff --git a/internal/models/query.go b/internal/models/query.go
new file mode 100644
index 0000000..6f7f03c
--- /dev/null
+++ b/internal/models/query.go
@@ -0,0 +1,9 @@
+package models
+
+import "github.com/hashicorp/terraform-plugin-framework/types"
+
+type QueryResourceModel struct {
+	ID        types.String `tfsdk:"id"`
+	Dataset   types.String `tfsdk:"dataset"`
+	QueryJson types.String `tfsdk:"query_json"`
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 8554380..fa9619d 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -63,6 +63,7 @@ func (p *HoneycombioProvider) Resources(ctx context.Context) []func() resource.R
 	return []func() resource.Resource{
 		NewBurnAlertResource,
 		NewTriggerResource,
+		NewQueryResource,
 	}
 }
 
diff --git a/internal/provider/query_resource.go b/internal/provider/query_resource.go
new file mode 100644
index 0000000..d861f3b
--- /dev/null
+++ b/internal/provider/query_resource.go
@@ -0,0 +1,185 @@
+package provider
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"strings"
+
+	"github.com/hashicorp/terraform-plugin-framework/path"
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/modifiers"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
+)
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+	_ resource.Resource                = &queryResource{}
+	_ resource.ResourceWithConfigure   = &queryResource{}
+	_ resource.ResourceWithImportState = &queryResource{}
+)
+
+type queryResource struct {
+	client *client.Client
+}
+
+func NewQueryResource() resource.Resource {
+	return &queryResource{}
+}
+
+func (*queryResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_query"
+}
+
+func (r *queryResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+	r.client = getClientFromResourceRequest(&req)
+}
+
+func (*queryResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		Description: "Queries can be used by Triggers and Boards, or be executed via the Query Data API.",
+		Attributes: map[string]schema.Attribute{
+			"id": schema.StringAttribute{
+				Description: "The unique identifier for this Query.",
+				Computed:    true,
+				Required:    false,
+				Optional:    false,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+			},
+			"dataset": schema.StringAttribute{
+				Description: "The dataset this Query is associated with. Use `__all__` for envionment-wide queries.",
+				Required:    true,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+			"query_json": schema.StringAttribute{
+				Description: "A JSON object describing the query according to the Query Specification." +
+					" While the JSON can be constructed manually, it is easiest to use the `honeycombio_query_specification` data source.",
+				Required: true,
+				PlanModifiers: []planmodifier.String{
+					modifiers.EquivalentQuerySpec(),
+					stringplanmodifier.RequiresReplace(),
+				},
+				Validators: []validator.String{
+					validation.ValidQuerySpec(),
+				},
+			},
+		},
+	}
+}
+
+func (r *queryResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	// import ID is of the format <dataset>/<query ID>
+	dataset, id, found := strings.Cut(req.ID, "/")
+	if !found {
+		resp.Diagnostics.AddError(
+			"Invalid Import ID",
+			"The supplied ID must be wrtten as <dataset>/<query ID>.",
+		)
+		return
+	}
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, &models.QueryResourceModel{
+		ID:      types.StringValue(id),
+		Dataset: types.StringValue(dataset),
+	})...)
+}
+
+func (r *queryResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var plan models.QueryResourceModel
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	var querySpec client.QuerySpec
+	if err := json.Unmarshal([]byte(plan.QueryJson.ValueString()), &querySpec); err != nil {
+		resp.Diagnostics.AddAttributeError(path.Root("query_json"), "Failed to unmarshal JSON", err.Error())
+		return
+	}
+	query, err := r.client.Queries.Create(ctx, plan.Dataset.ValueString(), &querySpec)
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Creating Honeycomb Query", err) {
+		return
+	}
+
+	var state models.QueryResourceModel
+	state.ID = types.StringValue(*query.ID)
+	state.Dataset = plan.Dataset
+	// store the plan's query JSON in state so it matches the config and rely on the plan modifier
+	// to handle the rest when we read it back
+	state.QueryJson = plan.QueryJson
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *queryResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var state models.QueryResourceModel
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	var detailedErr client.DetailedError
+	query, err := r.client.Queries.Get(ctx, state.Dataset.ValueString(), state.ID.ValueString())
+	if errors.As(err, &detailedErr) {
+		if detailedErr.IsNotFound() {
+			// if not found consider it deleted -- so just remove it from state
+			resp.State.RemoveResource(ctx)
+			return
+		} else {
+			resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic(
+				"Error Reading Honeycomb Query",
+				&detailedErr,
+			))
+		}
+	} else if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Reading Honeycomb Query",
+			"Could not read Query ID "+state.ID.ValueString()+": "+err.Error(),
+		)
+	}
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	state.ID = types.StringValue(*query.ID)
+	// we don't encode the ID in the JSON
+	query.ID = nil
+	queryJson, err := query.Encode()
+	if helper.AddDiagnosticOnError(&resp.Diagnostics, "Encoding Query", err) {
+		return
+	}
+	state.QueryJson = types.StringValue(queryJson)
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *queryResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var plan models.QueryResourceModel
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	// Queries are immutable so just write the request's plan into the state's response
+	// as described in the Migration Guide:
+	//  https://developer.hashicorp.com/terraform/plugin/framework/migrating/resources/crud#migration-notes
+	resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
+}
+
+func (r *queryResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) {
+	// Queries are immutable so we don't do anything but need to implement the method
+	// to satisfy the interface
+}


Test patch:
diff --git a/client/query_spec_test.go b/client/query_spec_test.go
index 156a9ea..c035645 100644
--- a/client/query_spec_test.go
+++ b/client/query_spec_test.go
@@ -100,6 +100,7 @@ func TestQuerySpec_EquivalentTo(t *testing.T) {
 				Granularity: client.ToPtr(0),
 				Breakdowns:  []string{},
 				Orders:      []client.OrderSpec{},
+				Limit:       client.ToPtr(client.DefaultQueryLimit),
 			},
 			client.QuerySpec{},
 			true,
diff --git a/honeycombio/data_source_column_test.go b/honeycombio/data_source_column_test.go
index 68bae3d..543310a 100644
--- a/honeycombio/data_source_column_test.go
+++ b/honeycombio/data_source_column_test.go
@@ -44,8 +44,8 @@ func TestAccDataSourceHoneycombioColumn_basic(t *testing.T) {
 	})
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			// match by name and return a single column with the right type
 			{
diff --git a/honeycombio/data_source_columns_test.go b/honeycombio/data_source_columns_test.go
index f1b3cf0..2397efb 100644
--- a/honeycombio/data_source_columns_test.go
+++ b/honeycombio/data_source_columns_test.go
@@ -47,8 +47,8 @@ func TestAccDataSourceHoneycombioColumns_basic(t *testing.T) {
 	})
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDataSourceColumnsConfig([]string{"dataset = \"" + testAccDataset() + "\""}),
diff --git a/honeycombio/data_source_datasets_test.go b/honeycombio/data_source_datasets_test.go
index 83e6dcd..3fcca3d 100644
--- a/honeycombio/data_source_datasets_test.go
+++ b/honeycombio/data_source_datasets_test.go
@@ -12,8 +12,8 @@ func TestAccDataSourceHoneycombioDataset_basic(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDataSourceDatasetConfig(nil),
diff --git a/honeycombio/data_source_query_result_test.go b/honeycombio/data_source_query_result_test.go
index 9461c2b..b2f6be1 100644
--- a/honeycombio/data_source_query_result_test.go
+++ b/honeycombio/data_source_query_result_test.go
@@ -11,8 +11,8 @@ func TestAccDataSourceHoneycombioQueryResult_basic(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDataSourceQueryResultConfig(dataset),
diff --git a/honeycombio/data_source_query_specification_test.go b/honeycombio/data_source_query_specification_test.go
index 3b65cee..ae227b8 100644
--- a/honeycombio/data_source_query_specification_test.go
+++ b/honeycombio/data_source_query_specification_test.go
@@ -10,8 +10,8 @@ import (
 
 func TestAccDataSourceHoneycombioQuery_EmptyDefaults(t *testing.T) {
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: `
@@ -38,8 +38,8 @@ output "query_json" {
 
 func TestAccDataSourceHoneycombioQuery_basic(t *testing.T) {
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccQueryConfig,
@@ -200,8 +200,8 @@ const expectedJSON string = `{
 
 func TestAccDataSourceHoneycombioQuery_validationChecks(t *testing.T) {
 	resource.ParallelTest(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: appendAllTestSteps(
 			testStepsQueryValidationChecks_calculation,
 			testStepsQueryValidationChecks_filter,
@@ -403,8 +403,8 @@ func appendAllTestSteps(steps ...[]resource.TestStep) []resource.TestStep {
 
 func TestAccDataSourceHoneycombioQuery_filterOpInAndNotIn(t *testing.T) {
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: `
@@ -448,8 +448,8 @@ func TestAccDataSourceHoneycombioQuery_zerovalue(t *testing.T) {
 }`
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: `
diff --git a/honeycombio/data_source_recipient_test.go b/honeycombio/data_source_recipient_test.go
index 5b1e469..9b31aa0 100644
--- a/honeycombio/data_source_recipient_test.go
+++ b/honeycombio/data_source_recipient_test.go
@@ -88,8 +88,8 @@ func TestAccDataSourceHoneycombioRecipient_basic(t *testing.T) {
 	})
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccRecipientWithDeprecatedTarget("email", "acctest@example.org"),
diff --git a/honeycombio/data_source_recipients_test.go b/honeycombio/data_source_recipients_test.go
index 3d3367e..0cd76ea 100644
--- a/honeycombio/data_source_recipients_test.go
+++ b/honeycombio/data_source_recipients_test.go
@@ -58,8 +58,8 @@ func TestAccDataSourceHoneycombioRecipients_basic(t *testing.T) {
 	})
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: `data "honeycombio_recipients" "all" {}`,
diff --git a/honeycombio/data_source_trigger_recipient_test.go b/honeycombio/data_source_trigger_recipient_test.go
index c039903..41cda1d 100644
--- a/honeycombio/data_source_trigger_recipient_test.go
+++ b/honeycombio/data_source_trigger_recipient_test.go
@@ -43,8 +43,8 @@ func TestAccDataSourceHoneycombioTriggerRecipient_basic(t *testing.T) {
 	})
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccTriggerRecipient(dataset, "email", "acctest@example.com"),
diff --git a/honeycombio/provider_test.go b/honeycombio/provider_test.go
index 4da799a..9e3e6cc 100644
--- a/honeycombio/provider_test.go
+++ b/honeycombio/provider_test.go
@@ -1,13 +1,17 @@
 package honeycombio
 
 import (
+	"context"
 	"os"
 	"testing"
 
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-framework/providerserver"
+	"github.com/hashicorp/terraform-plugin-go/tfprotov5"
+	"github.com/hashicorp/terraform-plugin-mux/tf5muxserver"
 	"github.com/joho/godotenv"
 
 	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
+	frameworkProvider "github.com/honeycombio/terraform-provider-honeycombio/internal/provider"
 )
 
 func init() {
@@ -26,10 +30,25 @@ func testAccPreCheck(t *testing.T) func() {
 	}
 }
 
-var testAccProviderFactories = map[string]func() (*schema.Provider, error){
-	//nolint:unparam
-	"honeycombio": func() (*schema.Provider, error) {
-		return Provider("test"), nil
+// used by tests which use a mix of Framework and SDK-based datasources or resources
+//
+// n.b. will continue to be used until the SDK-based provider is removed
+var testAccProtoV5ProviderFactory = map[string]func() (tfprotov5.ProviderServer, error){
+	"honeycombio": func() (tfprotov5.ProviderServer, error) {
+		ctx := context.Background()
+		providers := []func() tfprotov5.ProviderServer{
+			// modern terraform-plugin-framework provider
+			providerserver.NewProtocol5(frameworkProvider.New("test")),
+			// legacy terraform-plugin-sdk provider
+			Provider("test").GRPCProvider,
+		}
+
+		muxServer, err := tf5muxserver.NewMuxServer(ctx, providers...)
+		if err != nil {
+			return nil, err
+		}
+
+		return muxServer.ProviderServer(), nil
 	},
 }
 
diff --git a/honeycombio/resource_board_test.go b/honeycombio/resource_board_test.go
index 59e499a..5d0ffc7 100644
--- a/honeycombio/resource_board_test.go
+++ b/honeycombio/resource_board_test.go
@@ -13,8 +13,8 @@ func TestAccHoneycombioBoard_basic(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccBoardConfig(dataset),
@@ -49,8 +49,8 @@ func TestAccHoneycombioBoard_updateGraphSettings(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			// setup a board with a single query with no graph settings
 			{
diff --git a/honeycombio/resource_column_test.go b/honeycombio/resource_column_test.go
index 553c19d..c5a74ea 100644
--- a/honeycombio/resource_column_test.go
+++ b/honeycombio/resource_column_test.go
@@ -15,9 +15,9 @@ func TestAccHoneycombioColumn_basic(t *testing.T) {
 	keyName := acctest.RandomWithPrefix("duration_ms_test")
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		IDRefreshName:     "honeycombio_column.test",
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+		IDRefreshName:            "honeycombio_column.test",
 		Steps: []resource.TestStep{
 			{
 				Config: testAccColumnConfig(keyName, dataset),
diff --git a/honeycombio/resource_dataset_definition_test.go b/honeycombio/resource_dataset_definition_test.go
index 878371e..68c77f0 100644
--- a/honeycombio/resource_dataset_definition_test.go
+++ b/honeycombio/resource_dataset_definition_test.go
@@ -21,8 +21,8 @@ func TestAccHoneycombioDatasetDefinition_basic(t *testing.T) {
 	col2Name := test.RandomStringWithPrefix("test.", 8)
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: fmt.Sprintf(`
diff --git a/honeycombio/resource_dataset_test.go b/honeycombio/resource_dataset_test.go
index 54b6e4b..ef70757 100644
--- a/honeycombio/resource_dataset_test.go
+++ b/honeycombio/resource_dataset_test.go
@@ -13,8 +13,8 @@ func TestAccHoneycombioDataset_basic(t *testing.T) {
 	testDatasetName := "testacc-test"
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDatasetConfig(testDatasetName, "a nice description", 3),
diff --git a/honeycombio/resource_derived_column_test.go b/honeycombio/resource_derived_column_test.go
index 3f9ef93..ba81095 100644
--- a/honeycombio/resource_derived_column_test.go
+++ b/honeycombio/resource_derived_column_test.go
@@ -12,9 +12,9 @@ func TestAccHoneycombioDerivedColumn_basic(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		IDRefreshName:     "honeycombio_derived_column.test",
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+		IDRefreshName:            "honeycombio_derived_column.test",
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDerivedColumnConfig(dataset, "duration_ms_log10"),
@@ -35,9 +35,9 @@ func TestAccHoneycombioDerivedColumn_basic(t *testing.T) {
 
 	// validate 'pretty' alias'
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		IDRefreshName:     "honeycombio_derived_column.test",
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+		IDRefreshName:            "honeycombio_derived_column.test",
 		Steps: []resource.TestStep{
 			{
 				Config: testAccDerivedColumnConfig(dataset, "LOG(10) duration_ms"),
@@ -55,9 +55,9 @@ func TestAccHoneycombioDerivedColumn_error(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		IDRefreshName:     "honeycombio_derived_column.test",
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
+		IDRefreshName:            "honeycombio_derived_column.test",
 		Steps: []resource.TestStep{
 			{
 				Config: fmt.Sprintf(`
diff --git a/honeycombio/resource_marker_setting_test.go b/honeycombio/resource_marker_setting_test.go
index 2c5af74..1dc2b73 100644
--- a/honeycombio/resource_marker_setting_test.go
+++ b/honeycombio/resource_marker_setting_test.go
@@ -14,8 +14,8 @@ func TestAccHoneycombioMarkerSetting_basic(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccMarkerSettingConfig(dataset),
diff --git a/honeycombio/resource_marker_test.go b/honeycombio/resource_marker_test.go
index 502c96a..4f8d322 100644
--- a/honeycombio/resource_marker_test.go
+++ b/honeycombio/resource_marker_test.go
@@ -14,8 +14,8 @@ func TestAccHoneycombioMarker_basic(t *testing.T) {
 	dataset := testAccDataset()
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccMarkerConfig(dataset),
diff --git a/honeycombio/resource_query_annotation_test.go b/honeycombio/resource_query_annotation_test.go
index 3b9b41d..aecbfad 100644
--- a/honeycombio/resource_query_annotation_test.go
+++ b/honeycombio/resource_query_annotation_test.go
@@ -15,8 +15,8 @@ func TestAccHoneycombioQueryAnnotation_update(t *testing.T) {
 	secondName := "second annotation name"
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccResourceQueryAnnotationConfig(dataset, firstName),
diff --git a/honeycombio/resource_query_test.go b/honeycombio/resource_query_test.go
deleted file mode 100644
index 20f0527..0000000
--- a/honeycombio/resource_query_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package honeycombio
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"testing"
-
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
-	"github.com/stretchr/testify/assert"
-
-	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
-)
-
-func TestAccHoneycombioQuery_update(t *testing.T) {
-	dataset := testAccDataset()
-	zeroDuration := 0
-	firstDuration := 20
-	secondDuration := 40
-
-	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		Steps: []resource.TestStep{
-			{
-				Config: testAccResourceQueryConfig(dataset, zeroDuration),
-				Check: resource.ComposeTestCheckFunc(
-					testAccCheckQueryExists(t, dataset, "honeycombio_query.test", zeroDuration),
-				),
-			},
-			{
-				Config: testAccResourceQueryConfig(dataset, firstDuration),
-				Check: resource.ComposeTestCheckFunc(
-					testAccCheckQueryExists(t, dataset, "honeycombio_query.test", firstDuration),
-				),
-			},
-			{
-				Config: testAccResourceQueryConfig(dataset, secondDuration),
-				Check: resource.ComposeTestCheckFunc(
-					testAccCheckQueryExists(t, dataset, "honeycombio_query.test", secondDuration),
-				),
-			},
-		},
-	})
-}
-
-func testAccResourceQueryConfig(dataset string, duration int) string {
-	return fmt.Sprintf(`
-data "honeycombio_query_specification" "test" {
-  calculation {
-    op     = "AVG"
-    column = "duration_ms"
-  }
-
-  filter {
-    column = "duration_ms"
-    op     = ">"
-    value  = %d
-  }
-}
-
-resource "honeycombio_query" "test" {
-  dataset = "%s"
-  query_json = data.honeycombio_query_specification.test.json
-}
-`, duration, dataset)
-}
-
-func testAccCheckQueryExists(t *testing.T, dataset string, name string, duration int) resource.TestCheckFunc {
-	return func(s *terraform.State) error {
-		resourceState, ok := s.RootModule().Resources[name]
-		if !ok {
-			return fmt.Errorf("not found: %s", name)
-		}
-
-		client := testAccClient(t)
-		createdQuery, err := client.Queries.Get(context.Background(), dataset, resourceState.Primary.ID)
-		if err != nil {
-			return fmt.Errorf("could not find created query: %w", err)
-		}
-
-		expectedQuery := &honeycombio.QuerySpec{
-			ID: &resourceState.Primary.ID,
-			Calculations: []honeycombio.CalculationSpec{
-				{
-					Op:     honeycombio.CalculationOpAvg,
-					Column: honeycombio.ToPtr("duration_ms"),
-				},
-			},
-			Filters: []honeycombio.FilterSpec{
-				{
-					Column: "duration_ms",
-					Op:     ">",
-					Value:  float64(duration),
-				},
-			},
-			TimeRange: honeycombio.ToPtr(honeycombio.DefaultQueryTimeRange),
-		}
-
-		ok = assert.Equal(t, expectedQuery, createdQuery)
-		if !ok {
-			return errors.New("created query did not match expected query")
-		}
-		return nil
-	}
-}
diff --git a/honeycombio/resource_recipients_test.go b/honeycombio/resource_recipients_test.go
index b94fb1a..88a4201 100644
--- a/honeycombio/resource_recipients_test.go
+++ b/honeycombio/resource_recipients_test.go
@@ -11,8 +11,8 @@ import (
 
 func TestAccHoneycombioRecipients_basic(t *testing.T) {
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: `
diff --git a/honeycombio/resource_slo_test.go b/honeycombio/resource_slo_test.go
index 106e48a..2cf5a2c 100644
--- a/honeycombio/resource_slo_test.go
+++ b/honeycombio/resource_slo_test.go
@@ -19,8 +19,8 @@ func TestAccHoneycombioSLO_basic(t *testing.T) {
 	slo := &honeycombio.SLO{}
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccConfigSLO_basic(dataset, sliAlias),
@@ -44,8 +44,8 @@ func TestAccHoneycombioSLO_RecreateOnNotFound(t *testing.T) {
 	slo := &honeycombio.SLO{}
 
 	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
 				Config: testAccConfigSLO_basic(dataset, sliAlias),
diff --git a/internal/helper/validation/query_spec_test.go b/internal/helper/validation/query_spec_test.go
new file mode 100644
index 0000000..3720b42
--- /dev/null
+++ b/internal/helper/validation/query_spec_test.go
@@ -0,0 +1,61 @@
+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/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
+)
+
+func Test_QuerySpecValidator(t *testing.T) {
+	t.Parallel()
+
+	type testCase struct {
+		val         types.String
+		expectError bool
+	}
+	tests := map[string]testCase{
+		"unknown": {
+			val: types.StringUnknown(),
+		},
+		"null": {
+			val: types.StringNull(),
+		},
+		"valid query spec": {
+			val: types.StringValue(`{"calculations": [{"op": "COUNT"}]}`),
+		},
+		"invalid json": {
+			val:         types.StringValue("whoop"),
+			expectError: true,
+		},
+		"invalid query spec": {
+			val:         types.StringValue(`{"foo": "bar"}`),
+			expectError: true,
+		},
+	}
+
+	for name, test := range tests {
+		name, test := name, test
+		t.Run(name, func(t *testing.T) {
+			request := validator.StringRequest{
+				Path:           path.Root("test"),
+				PathExpression: path.MatchRoot("test"),
+				ConfigValue:    test.val,
+			}
+			response := validator.StringResponse{}
+			validation.ValidQuerySpec().ValidateString(context.TODO(), request, &response)
+
+			if !response.Diagnostics.HasError() && test.expectError {
+				t.Fatal("expected error, got no error")
+			}
+
+			if response.Diagnostics.HasError() && !test.expectError {
+				t.Fatalf("got unexpected error: %s", response.Diagnostics)
+			}
+		})
+	}
+}
diff --git a/internal/provider/query_resource_test.go b/internal/provider/query_resource_test.go
new file mode 100644
index 0000000..5da98f8
--- /dev/null
+++ b/internal/provider/query_resource_test.go
@@ -0,0 +1,224 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/hashicorp/terraform-plugin-testing/terraform"
+	"github.com/stretchr/testify/require"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test"
+)
+
+func TestAcc_QueryResource(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+	dataset := testAccDataset()
+	c := testAccClient(t)
+	col, err := c.Columns.Create(ctx, dataset, &client.Column{
+		KeyName: test.RandomStringWithPrefix("test-", 10),
+		Type:    client.ToPtr(client.ColumnTypeFloat),
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		c.Columns.Delete(ctx, dataset, col.ID)
+	})
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		Steps: []resource.TestStep{
+			{
+				Config: testAccConfigBasicQueryTest(dataset, col.KeyName, 1),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					testAccEnsureQueryExists(t, "honeycombio_query.test"),
+					resource.TestCheckResourceAttrSet("honeycombio_query.test", "id"),
+					resource.TestCheckResourceAttrSet("honeycombio_query.test", "query_json"),
+					resource.TestCheckResourceAttr("honeycombio_query.test", "dataset", dataset),
+				),
+			},
+			{
+				Config: testAccConfigBasicQueryTest(dataset, col.KeyName, 2),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					testAccEnsureQueryExists(t, "honeycombio_query.test"),
+					resource.TestCheckResourceAttrSet("honeycombio_query.test", "id"),
+					resource.TestCheckResourceAttrSet("honeycombio_query.test", "query_json"),
+					resource.TestCheckResourceAttr("honeycombio_query.test", "dataset", dataset),
+				),
+				PlanOnly:           true,
+				ExpectNonEmptyPlan: true,
+			},
+			{
+				ResourceName:        "honeycombio_query.test",
+				ImportStateIdPrefix: fmt.Sprintf("%v/", dataset),
+				ImportState:         true,
+			},
+		},
+	})
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		Steps: []resource.TestStep{
+			{
+				Config: `
+resource "honeycombio_query" "test" {
+  dataset = "does-not-matter"
+  query_json = <<EOT
+"invalid": "json"]
+EOT
+}`,
+				ExpectError: regexp.MustCompile(`json: cannot unmarshal string`),
+			},
+			{
+				Config: `
+resource "honeycombio_query" "test" {
+  dataset = "does-not-matter"
+  query_json = <<EOT
+{
+  "calculations": [
+    {
+      "op": "COUNT"
+    }
+  ],
+  "query_window": 7200
+}
+EOT
+}`,
+				ExpectError: regexp.MustCompile(`json: unknown field "query_window"`),
+			},
+		},
+	})
+}
+
+// TestAcc_QueryResourceUpgradeFromVersion022 is intended to test the migration
+// case from the last SDK-based version of the Query resource to the current Framework-based
+// version.
+//
+// See: https://developer.hashicorp.com/terraform/plugin/framework/migrating/testing#testing-migration
+func TestAcc_QueryResourceUpgradeFromVersion022(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+	dataset := testAccDataset()
+	c := testAccClient(t)
+	col, err := c.Columns.Create(ctx, dataset, &client.Column{
+		KeyName: test.RandomStringWithPrefix("test-", 10),
+		Type:    client.ToPtr(client.ColumnTypeFloat),
+	})
+	require.NoError(t, err)
+	t.Cleanup(func() {
+		c.Columns.Delete(ctx, dataset, col.ID)
+	})
+	config := testAccConfigBasicQueryTest(dataset, col.KeyName, 1)
+
+	resource.Test(t, resource.TestCase{
+		Steps: []resource.TestStep{
+			{
+				ExternalProviders: map[string]resource.ExternalProvider{
+					"honeycombio": {
+						VersionConstraint: "~> 0.22.0",
+						Source:            "honeycombio/honeycombio",
+					},
+				},
+				Config: config,
+				Check: resource.ComposeTestCheckFunc(
+					testAccEnsureQueryExists(t, "honeycombio_query.test"),
+				),
+			},
+			{
+				ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+				Config:                   config,
+				PlanOnly:                 true,
+				ExpectNonEmptyPlan:       false,
+			},
+		},
+	})
+}
+
+// TestAcc_QueryResourceEquivalentQuerySpecSupressed tests the  behavior of the
+// resource when an equivalent query is suppressed by the plan modifier.
+func TestAcc_QueryResourceEquivalentQuerySpecSupressed(t *testing.T) {
+	t.Parallel()
+	dataset := testAccDataset()
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		Steps: []resource.TestStep{
+			{
+				Config: fmt.Sprintf(`
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = "{}"
+}`, dataset),
+			},
+			{
+				// The query is technically equivalent to the previous one, so it should be suppressed.
+				//  n.b. spec copied from the UI from a simple 'COUNT' query
+				Config: fmt.Sprintf(`
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = <<EOT
+{
+  "time_range": 7200,
+  "granularity": 0,
+  "breakdowns": [],
+  "calculations": [
+    {
+      "op": "COUNT"
+    }
+  ],
+  "orders": [],
+  "havings": [],
+  "limit": 1000
+}
+EOT
+}`, dataset),
+				PlanOnly:           true,
+				ExpectNonEmptyPlan: false,
+			},
+		},
+	})
+}
+
+func testAccConfigBasicQueryTest(dataset, column string, value float64) string {
+	return fmt.Sprintf(`
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op     = "AVG"
+    column = "%[2]s"
+  }
+
+  filter {
+    column = "%[2]s"
+    op     = ">"
+    value  = %[3]f
+  }
+}
+
+resource "honeycombio_query" "test" {
+  dataset    = "%[1]s"
+  query_json = data.honeycombio_query_specification.test.json
+}`, dataset, column, value)
+}
+
+func testAccEnsureQueryExists(t *testing.T, name string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		resourceState, ok := s.RootModule().Resources[name]
+		if !ok {
+			return fmt.Errorf("\"%s\" not found in state", name)
+		}
+
+		client := testAccClient(t)
+		_, err := client.Queries.Get(context.Background(), resourceState.Primary.Attributes["dataset"], resourceState.Primary.ID)
+		if err != nil {
+			return fmt.Errorf("failed to fetch created query: %w", err)
+		}
+
+		return nil
+	}
+}