Repository: honeycombio/terraform-provider-honeycombio
Pull request #100 (2022-01-25 20:09:18)

Require Query IDs over inline query JSON
As discussed in #96, Triggers and Boards now require Query IDs instead of JSON. 


Patch:
diff --git a/client/trigger.go b/client/trigger.go
index c5811ca..7f22784 100644
--- a/client/trigger.go
+++ b/client/trigger.go
@@ -2,6 +2,7 @@ package client
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 )
@@ -55,7 +56,8 @@ type Trigger struct {
 	// properties described with and validated by MatchesTriggerSubset.
 	// Additionally, time_range of the query can be at most 1 day and may not
 	// be greater than 4 times the frequency.
-	Query *QuerySpec `json:"query"`
+	Query   *QuerySpec `json:"query,omitempty"`
+	QueryID string     `json:"query_id,omitempty"`
 	// Threshold. This fild is required.
 	Threshold *TriggerThreshold `json:"threshold"`
 	// Frequency describes how often the trigger should run. Frequency is an
@@ -144,6 +146,30 @@ func TriggerRecipientTypes() []TriggerRecipientType {
 	}
 }
 
+func (t *Trigger) MarshalJSON() ([]byte, error) {
+	// aliased type to avoid stack overflows due to recursion
+	type ATrigger Trigger
+
+	if t.QueryID != "" && t.Query != nil {
+		// we can't sent both to the API, so favour QueryID
+		// this doesn't work in the general case, but this
+		// client is now purpose-built for the Terraform provider
+		a := &ATrigger{
+			ID:          t.ID,
+			Name:        t.Name,
+			Description: t.Description,
+			Disabled:    t.Disabled,
+			QueryID:     t.QueryID,
+			Threshold:   t.Threshold,
+			Frequency:   t.Frequency,
+			Recipients:  t.Recipients,
+		}
+		return json.Marshal(&struct{ *ATrigger }{ATrigger: (*ATrigger)(a)})
+	}
+
+	return json.Marshal(&struct{ *ATrigger }{ATrigger: (*ATrigger)(t)})
+}
+
 func (s *triggers) List(ctx context.Context, dataset string) ([]Trigger, error) {
 	var t []Trigger
 	err := s.client.performRequest(ctx, "GET", "/1/triggers/"+urlEncodeDataset(dataset), nil, &t)
diff --git a/docs/data-sources/query.md b/docs/data-sources/query_specification.md
similarity index 97%
rename from docs/data-sources/query.md
rename to docs/data-sources/query_specification.md
index 6df7eea..14357bd 100644
--- a/docs/data-sources/query.md
+++ b/docs/data-sources/query_specification.md
@@ -1,4 +1,4 @@
-# Data Source: honeycombio_query
+# Data Source: honeycombio_query_specification
 
 Construct a query that can be used in triggers and boards. For more information about the query specification, check out [Query Specification](https://docs.honeycomb.io/api/query-specification/).
 
@@ -7,7 +7,7 @@ The `json` attribute contains a serialized JSON representation which can be pass
 ## Example Usage
 
 ```hcl
-data "honeycombio_query" "example" {
+data "honeycombio_query_specification" "example" {
   # zero or more calculation blocks
   calculation {
     op     = "AVG"
@@ -34,7 +34,7 @@ data "honeycombio_query" "example" {
 }
 
 output "json_query" {
-    value = data.honeycombio_query.example.json
+    value = data.honeycombio_query_specification.example.json
 }
 ```
 
diff --git a/docs/data-sources/trigger_recipient.md b/docs/data-sources/trigger_recipient.md
index c1d85af..25e490d 100644
--- a/docs/data-sources/trigger_recipient.md
+++ b/docs/data-sources/trigger_recipient.md
@@ -16,7 +16,7 @@ data "honeycombio_trigger_recipient" "slack" {
   target  = "honeycomb-triggers"
 }
 
-data "honeycombio_query" "example" {
+data "honeycombio_query_specification" "example" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -26,7 +26,7 @@ data "honeycombio_query" "example" {
 resource "honeycombio_trigger" "example" {
   name        = "Requests are slower than usuals"
 
-  query_json = data.honeycombio_query.example.json
+  query_json = data.honeycombio_query_specification.example.json
   dataset    = var.dataset
 
   threshold {
diff --git a/docs/resources/board.md b/docs/resources/board.md
index 08975aa..68efcc1 100644
--- a/docs/resources/board.md
+++ b/docs/resources/board.md
@@ -13,11 +13,11 @@ locals {
   percentiles = ["P50", "P75", "P90", "P95"]
 }
 
-data "honeycombio_query" "query" {
-  count = length(local.percentiles)
+data "honeycombio_query_specification" "query" {
+  for_each = toset(local.percentiles)
 
   calculation {
-    op     = local.percentiles[count.index]
+    op     = local.percentiles[each.key]
     column = "duration_ms"
   }
 
@@ -33,6 +33,13 @@ data "honeycombio_query" "query" {
   }
 }
 
+resource "honeycombio_query" "query" {
+  for_each = toset(local.percentiles)
+
+  dataset    = var.dataset
+  query_json = data.honeycombio_query_specification.query[each.key].json
+}
+
 resource "honeycombio_board" "board" {
   name        = "Request percentiles"
   description = "${join(", ", local.percentiles)} of all requests for ThatSpecialTenant for the last 15 minutes."
@@ -46,7 +53,7 @@ resource "honeycombio_board" "board" {
       caption     = query.value
       query_style = "combo"
       dataset     = var.dataset
-      query_json  = data.honeycombio_query.query[query.key].json
+      query_json  = data.honeycombio_query_specification.query[query.key].json
     }
   }
 }
@@ -63,7 +70,8 @@ The following arguments are supported:
 
 Each board configuration may have zero or more `query` blocks, which accepts the following arguments:
 
-* `query_json` - (Required) A JSON object describing the query according to the [Query Specification](https://docs.honeycomb.io/api/query-specification/#fields-on-a-query-specification). While the JSON can be constructed manually, it is easiest to use the [`honeycombio_query`](../data-sources/query.md) data source.
+* `query_id` - (Required) The ID of the Query to run.
+* `query_annotation_id` - (Optional) The ID of the Query Annotation to associate with this query.
 * `dataset` - (Required) The dataset this query is associated with.
 * `caption` - (Optional) A description of the query that will be displayed on the board. Supports markdown.
 * `query_style` - (Optional) How the query should be displayed within the board, either `graph` (the default), `table` or `combo`.
@@ -72,7 +80,7 @@ Each board configuration may have zero or more `query` blocks, which accepts the
 
 In addition to all arguments above, the following attributes are exported:
 
-* `id` - ID of the board.
+* `id` - ID of the trigger.
 
 ## Import
 
diff --git a/docs/resources/query.md b/docs/resources/query.md
index e711773..a840924 100644
--- a/docs/resources/query.md
+++ b/docs/resources/query.md
@@ -11,7 +11,7 @@ variable "dataset" {
   type = string
 }
 
-data "honeycombio_query" "test_query" {
+data "honeycombio_query_specification" "test_query" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -26,7 +26,7 @@ data "honeycombio_query" "test_query" {
 
 resource "honeycombio_query" "test_query" {
   dataset = "%s"
-  query_json = data.honeycombio_query.test_query.json
+  query_json = data.honeycombio_query_specification.test_query.json
 }
 ```
 
@@ -35,7 +35,7 @@ resource "honeycombio_query" "test_query" {
 The following arguments are supported:
 
 * `dataset` - (Required) The dataset this query is added to.
-* `query_json` - (Required) A JSON object describing the query according to the [Query Specification](https://docs.honeycomb.io/api/query-specification/#fields-on-a-query-specification). While the JSON can be constructed manually, it is easiest to use the [`honeycombio_query`](../data-sources/query.md) data source.
+* `query_json` - (Required) A JSON object describing the query according to the [Query Specification](https://docs.honeycomb.io/api/query-specification/#fields-on-a-query-specification). While the JSON can be constructed manually, it is easiest to use the [`honeycombio_query_specification`](../data-sources/query_specification.md) data source.
 
 ## Attribute Reference
 
diff --git a/docs/resources/query_annotation.md b/docs/resources/query_annotation.md
index b7118b9..33a5c75 100644
--- a/docs/resources/query_annotation.md
+++ b/docs/resources/query_annotation.md
@@ -3,7 +3,7 @@
 Creates a query annotation in a dataset.
 
 -> **Note** A query annotation points to a specific query. Any change to the query will result in a new query ID and the annotation will no longer apply.
-If you use the "honeycombio_query" to determine the `query_id` parameter (as in the example below), Terraform will destroy the old query annotation and create a new one.
+If you use the "honeycombio_query_specification" to determine the `query_id` parameter (as in the example below), Terraform will destroy the old query annotation and create a new one.
 If this is wrong for your use case, please open an issue in [kvrhdn/terraform-provider-honeycombio](https://github.com/kvrhdn/terraform-provider-honeycombio).
 
 ## Example Usage
@@ -13,7 +13,7 @@ variable "dataset" {
   type = string
 }
 
-data "honeycombio_query" "test_query" {
+data "honeycombio_query_specification" "test_query" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -28,7 +28,7 @@ data "honeycombio_query" "test_query" {
 
 resource "honeycombio_query" "test_query" {
   dataset    = var.dataset
-  query_json = data.honeycombio_query.test_query.json
+  query_json = data.honeycombio_query_specification.test_query.json
 }
 
 resource "honeycombio_query_annotation" "test_annotation" {
diff --git a/docs/resources/trigger.md b/docs/resources/trigger.md
index fcdd544..8610bb5 100644
--- a/docs/resources/trigger.md
+++ b/docs/resources/trigger.md
@@ -9,7 +9,7 @@ variable "dataset" {
   type = string
 }
 
-data "honeycombio_query" "example" {
+data "honeycombio_query_specification" "example" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -21,11 +21,16 @@ data "honeycombio_query" "example" {
   }
 }
 
+resource "honeycombio_query" "example" {
+  dataset    = var.dataset
+  query_json = data.honeycombio_query_specification.example.json
+}
+
 resource "honeycombio_trigger" "example" {
   name        = "Requests are slower than usuals"
   description = "Average duration of all requests for the last 10 minutes."
 
-  query_json = data.honeycombio_query.example.json
+  query_json = honeycombio_query.example.id
   dataset    = var.dataset
 
   frequency = 600 // in seconds, 10 minutes
@@ -54,14 +59,14 @@ The following arguments are supported:
 
 * `name` - (Required) Name of the trigger.
 * `dataset` - (Required) The dataset this trigger is associated with.
-* `query_json` - (Required) A JSON object describng the query according to the [Query Specification](https://docs.honeycomb.io/api/query-specification/#fields-on-a-query-specification). While the JSON can be constructed manually, it is easiest to use the [`honeycombio_query`](../data-sources/query.md) data source.
+* `query_id` - (Required) The ID of the Query that the Trigger will execute.
 * `threshold` - (Required) A configuration block (described below) describing the threshold of the trigger.
 * `description` - (Optional) Description of the trigger.
 * `disabled` - (Optional) The state of the trigger. If true, the trigger will not be run. Defaults to false.
 * `frequency` - (Optional) The interval (in seconds) in which to check the results of the query’s calculation against the threshold. Value must be divisible by 60 and between 60 and 86400 (between 1 minute and 1 day). Defaults to 900 (15 minutes).
 * `recipient` - (Optional) Zero or more configuration blocks (described below) with the recipients to notify when the trigger fires.
 
--> **NOTE** The query used in a trigger must follow a strict subset: a query must contain exactly one calcuation and may only contain `calculation`, `filter`, `flter_combination` and `breakdowns` fields. This will be validated during the plan phase.
+-> **NOTE** The query used in a Trigger must follow a strict subset: a query must contain exactly one calcuation and may only contain `calculation`, `filter`, `filter_combination` and `breakdowns` fields. The query's duration cannot be more than four times the trigger's frequency.
 
 Each trigger configuration must contain exactly one `threshold` block, which accepts the following arguments:
 
diff --git a/example/board/main.tf b/example/board/main.tf
index 2dc5102..4ac266f 100644
--- a/example/board/main.tf
+++ b/example/board/main.tf
@@ -14,8 +14,8 @@ locals {
   percentiles = ["P50", "P75", "P90", "P95"]
 }
 
-data "honeycombio_query" "query" {
-  count = length(local.percentiles)
+data "honeycombio_query_specification" "query" {
+  for_each = toset(local.percentiles)
 
   calculation {
     op     = local.percentiles[count.index]
@@ -33,6 +33,13 @@ data "honeycombio_query" "query" {
   }
 }
 
+resource "honeycombio_query" "query" {
+  for_each = to_set(local.percentiles)
+
+  dataset    = var.dataset
+  query_json = data.honeycombio_query_specification.query[each.key].json
+}
+
 resource "honeycombio_board" "board" {
   name        = "Request percentiles"
   description = "${join(", ", local.percentiles)} of all requests for ThatSpecialTenant for the last 15 minutes."
@@ -43,9 +50,9 @@ resource "honeycombio_board" "board" {
     for_each = local.percentiles
 
     content {
-      caption    = query.value
-      dataset    = var.dataset
-      query_json = data.honeycombio_query.query[query.key].json
+      caption = query.value
+      dataset = var.dataset
+      query_id = honeycombio_query.query[query.key].id
     }
   }
 }
diff --git a/example/trigger/main.tf b/example/trigger/main.tf
index 453e5fe..cd634e8 100644
--- a/example/trigger/main.tf
+++ b/example/trigger/main.tf
@@ -10,7 +10,7 @@ variable "dataset" {
   type = string
 }
 
-data "honeycombio_query" "query" {
+data "honeycombio_query_specification" "query" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -29,14 +29,19 @@ data "honeycombio_query" "query" {
   time_range = 900 // in seconds, 15 minutes
 }
 
+resource "honeycombio_query" "trigger-query" {
+  dataset    = var.dataset
+  query_json = data.honeycombio_query_specification.query.json
+}
+
 resource "honeycombio_trigger" "trigger" {
   name        = "Requests are slower than usual"
   description = "Average duration of all requests for ThatSpecialTenant for the last 15 minutes."
 
   disabled = false
 
-  query_json = data.honeycombio_query.query.json
-  dataset    = var.dataset
+  query_id = honeycombio_query.trigger-query.id
+  dataset  = var.dataset
 
   frequency = 900 // in seconds, 15 minutes
 
diff --git a/example/trigger_with_slack_recipient/main.tf b/example/trigger_with_slack_recipient/main.tf
index f3297b4..e3ff55d 100644
--- a/example/trigger_with_slack_recipient/main.tf
+++ b/example/trigger_with_slack_recipient/main.tf
@@ -10,7 +10,7 @@ variable "dataset" {
   type = string
 }
 
-data "honeycombio_query" "query" {
+data "honeycombio_query_specification" "query" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -26,11 +26,16 @@ data "honeycombio_trigger_recipient" "slack" {
   target  = "#honeycombio"
 }
 
+resource "honeycombio_query" "trigger-query" {
+  dataset    = var.dataset
+  query_json = data.honeycombio_query_specification.query.json
+}
+
 resource "honeycombio_trigger" "trigger" {
   name = "Requests are slower than usual"
 
-  query_json = data.honeycombio_query.query.json
-  dataset    = var.dataset
+  query_id = honeycombio_query.trigger-query.id
+  dataset  = var.dataset
 
   threshold {
     op    = ">"
diff --git a/honeycombio/data_source_query.go b/honeycombio/data_source_query_specification.go
similarity index 96%
rename from honeycombio/data_source_query.go
rename to honeycombio/data_source_query_specification.go
index 9f29e18..c76a6e3 100644
--- a/honeycombio/data_source_query.go
+++ b/honeycombio/data_source_query_specification.go
@@ -13,9 +13,9 @@ import (
 	"github.com/honeycombio/terraform-provider-honeycombio/honeycombio/internal/hashcode"
 )
 
-func dataSourceHoneycombioQuery() *schema.Resource {
+func dataSourceHoneycombioQuerySpec() *schema.Resource {
 	return &schema.Resource{
-		ReadContext: dataSourceHoneycombioQueryRead,
+		ReadContext: dataSourceHoneycombioQuerySpecRead,
 
 		Schema: map[string]*schema.Schema{
 			"calculation": {
@@ -79,9 +79,9 @@ func dataSourceHoneycombioQuery() *schema.Resource {
 				},
 			},
 			"filter_combination": {
-				Type:         schema.TypeString,
-				Optional:     true,
-				Default:      "AND",
+				Type:     schema.TypeString,
+				Optional: true,
+				// Note: API assumes 'AND' if not provided
 				ValidateFunc: validation.StringInSlice([]string{"AND", "OR"}, false),
 			},
 			"breakdowns": {
@@ -148,7 +148,7 @@ func dataSourceHoneycombioQuery() *schema.Resource {
 	}
 }
 
-func dataSourceHoneycombioQueryRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+func dataSourceHoneycombioQuerySpecRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
 	calculations, err := extractCalculations(d)
 	if err != nil {
 		return diag.FromErr(err)
diff --git a/honeycombio/internal/verify/json.go b/honeycombio/internal/verify/json.go
new file mode 100644
index 0000000..22d8605
--- /dev/null
+++ b/honeycombio/internal/verify/json.go
@@ -0,0 +1,36 @@
+package verify
+
+import (
+	"bytes"
+	"encoding/json"
+	"reflect"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+)
+
+func SuppressEquivJSONDiffs(_, orig, new string, d *schema.ResourceData) bool {
+	oldBuf := bytes.NewBufferString("")
+	if err := json.Compact(oldBuf, []byte(orig)); err != nil {
+		return false
+	}
+
+	newBuf := bytes.NewBufferString("")
+	if err := json.Compact(newBuf, []byte(new)); err != nil {
+		return false
+	}
+
+	return JSONBytesEqual(oldBuf.Bytes(), newBuf.Bytes())
+}
+
+func JSONBytesEqual(b1, b2 []byte) bool {
+	var o1, o2 interface{}
+
+	if err := json.Unmarshal(b1, &o1); err != nil {
+		return false
+	}
+	if err := json.Unmarshal(b2, &o2); err != nil {
+		return false
+	}
+
+	return reflect.DeepEqual(o1, o2)
+}
diff --git a/honeycombio/provider.go b/honeycombio/provider.go
index 90d7fc6..0960c4d 100644
--- a/honeycombio/provider.go
+++ b/honeycombio/provider.go
@@ -44,9 +44,9 @@ func Provider() *schema.Provider {
 			},
 		},
 		DataSourcesMap: map[string]*schema.Resource{
-			"honeycombio_datasets":          dataSourceHoneycombioDatasets(),
-			"honeycombio_query":             dataSourceHoneycombioQuery(),
-			"honeycombio_trigger_recipient": dataSourceHoneycombioSlackRecipient(),
+			"honeycombio_datasets":            dataSourceHoneycombioDatasets(),
+			"honeycombio_query_specification": dataSourceHoneycombioQuerySpec(),
+			"honeycombio_trigger_recipient":   dataSourceHoneycombioSlackRecipient(),
 		},
 		ResourcesMap: map[string]*schema.Resource{
 			"honeycombio_board":            newBoard(),
diff --git a/honeycombio/resource_board.go b/honeycombio/resource_board.go
index 820a599..1711a5c 100644
--- a/honeycombio/resource_board.go
+++ b/honeycombio/resource_board.go
@@ -2,7 +2,6 @@ package honeycombio
 
 import (
 	"context"
-	"encoding/json"
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
@@ -54,10 +53,13 @@ func newBoard() *schema.Resource {
 							Type:     schema.TypeString,
 							Required: true,
 						},
-						"query_json": {
-							Type:             schema.TypeString,
-							Required:         true,
-							ValidateDiagFunc: validateQueryJSON(),
+						"query_id": {
+							Type:     schema.TypeString,
+							Required: true,
+						},
+						"query_annotation_id": {
+							Type:     schema.TypeString,
+							Optional: true,
 						},
 					},
 				},
@@ -95,15 +97,6 @@ func resourceBoardRead(ctx context.Context, d *schema.ResourceData, meta interfa
 		return diag.FromErr(err)
 	}
 
-	// API returns "" for filterCombination if set to the default value "AND"
-	// To keep the Terraform config simple, we'll explicitly set "AND" ourself
-	for i := range b.Queries {
-		q := &b.Queries[i]
-		if q.Query.FilterCombination == "" {
-			q.Query.FilterCombination = honeycombio.FilterCombinationAnd
-		}
-	}
-
 	d.SetId(b.ID)
 	d.Set("name", b.Name)
 	d.Set("description", b.Description)
@@ -112,16 +105,12 @@ func resourceBoardRead(ctx context.Context, d *schema.ResourceData, meta interfa
 	queries := make([]map[string]interface{}, len(b.Queries))
 
 	for i, q := range b.Queries {
-		queryJSON, err := encodeQuery(q.Query)
-		if err != nil {
-			return diag.FromErr(err)
-		}
-
 		queries[i] = map[string]interface{}{
-			"caption":     q.Caption,
-			"query_style": q.QueryStyle,
-			"dataset":     q.Dataset,
-			"query_json":  queryJSON,
+			"caption":             q.Caption,
+			"query_style":         q.QueryStyle,
+			"dataset":             q.Dataset,
+			"query_id":            q.QueryID,
+			"query_annotation_id": q.QueryAnnotationID,
 		}
 	}
 
@@ -164,17 +153,12 @@ func expandBoard(d *schema.ResourceData) (*honeycombio.Board, error) {
 	for _, q := range qs {
 		m := q.(map[string]interface{})
 
-		var query honeycombio.QuerySpec
-		err := json.Unmarshal([]byte(m["query_json"].(string)), &query)
-		if err != nil {
-			return nil, err
-		}
-
 		queries = append(queries, honeycombio.BoardQuery{
-			Caption:    m["caption"].(string),
-			QueryStyle: honeycombio.BoardQueryStyle(m["query_style"].(string)),
-			Dataset:    m["dataset"].(string),
-			Query:      &query,
+			Caption:           m["caption"].(string),
+			QueryStyle:        honeycombio.BoardQueryStyle(m["query_style"].(string)),
+			Dataset:           m["dataset"].(string),
+			QueryID:           m["query_id"].(string),
+			QueryAnnotationID: m["query_annotation_id"].(string),
 		})
 	}
 
diff --git a/honeycombio/resource_query.go b/honeycombio/resource_query.go
index 5b79a06..72cc1ae 100644
--- a/honeycombio/resource_query.go
+++ b/honeycombio/resource_query.go
@@ -7,6 +7,7 @@ import (
 	"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 {
@@ -23,6 +24,7 @@ func newQuery() *schema.Resource {
 				Required:         true,
 				ForceNew:         true,
 				ValidateDiagFunc: validateQueryJSON(),
+				DiffSuppressFunc: verify.SuppressEquivJSONDiffs,
 			},
 			"dataset": {
 				Type:     schema.TypeString,
@@ -66,12 +68,10 @@ func resourceQueryRead(ctx context.Context, d *schema.ResourceData, meta interfa
 		return diag.FromErr(err)
 	}
 
+	// Track ID at the Resource level
 	d.SetId(*query.ID)
-	// Normalize the query before converting it to JSON to avoid unwanted diffs
 	query.ID = nil
-	if query.FilterCombination == "" {
-		query.FilterCombination = honeycombio.FilterCombinationAnd
-	}
+
 	queryJSON, err := encodeQuery(query)
 	if err != nil {
 		return diag.FromErr(err)
diff --git a/honeycombio/resource_trigger.go b/honeycombio/resource_trigger.go
index 7710d1c..cdf051e 100644
--- a/honeycombio/resource_trigger.go
+++ b/honeycombio/resource_trigger.go
@@ -2,7 +2,6 @@ package honeycombio
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"strings"
 
@@ -40,16 +39,9 @@ func newTrigger() *schema.Resource {
 				Type:     schema.TypeBool,
 				Optional: true,
 			},
-			"query_json": {
+			"query_id": {
 				Type:     schema.TypeString,
 				Required: true,
-				ValidateDiagFunc: validateQueryJSON(func(q *honeycombio.QuerySpec) diag.Diagnostics {
-					err := honeycombio.MatchesTriggerSubset(q)
-					if err != nil {
-						return diag.FromErr(err)
-					}
-					return nil
-				}),
 			},
 			"threshold": {
 				Type:     schema.TypeList,
@@ -157,22 +149,11 @@ func resourceTriggerRead(ctx context.Context, d *schema.ResourceData, meta inter
 		return diag.FromErr(err)
 	}
 
-	// API returns nil for filterCombination if set to the default value "AND"
-	// To keep the Terraform config simple, we'll explicitly set "AND" ourself
-	if t.Query.FilterCombination == "" {
-		t.Query.FilterCombination = honeycombio.FilterCombinationAnd
-	}
-
 	d.SetId(t.ID)
 	d.Set("name", t.Name)
 	d.Set("description", t.Description)
 	d.Set("disabled", t.Disabled)
-
-	encodedQuery, err := encodeQuery(t.Query)
-	if err != nil {
-		return diag.FromErr(err)
-	}
-	d.Set("query_json", encodedQuery)
+	d.Set("query_id", t.QueryID)
 
 	err = d.Set("threshold", flattenTriggerThreshold(t.Threshold))
 	if err != nil {
@@ -220,19 +201,12 @@ func resourceTriggerDelete(ctx context.Context, d *schema.ResourceData, meta int
 }
 
 func expandTrigger(d *schema.ResourceData) (*honeycombio.Trigger, error) {
-	var query honeycombio.QuerySpec
-
-	err := json.Unmarshal([]byte(d.Get("query_json").(string)), &query)
-	if err != nil {
-		return nil, err
-	}
-
 	trigger := &honeycombio.Trigger{
 		ID:          d.Id(),
 		Name:        d.Get("name").(string),
 		Description: d.Get("description").(string),
 		Disabled:    d.Get("disabled").(bool),
-		Query:       &query,
+		QueryID:     d.Get("query_id").(string),
 		Threshold:   expandTriggerThreshold(d.Get("threshold").([]interface{})),
 		Frequency:   d.Get("frequency").(int),
 		Recipients:  expandTriggerRecipients(d.Get("recipient").([]interface{})),


Test patch:
diff --git a/client/trigger_test.go b/client/trigger_test.go
index 295f5f3..8f94cff 100644
--- a/client/trigger_test.go
+++ b/client/trigger_test.go
@@ -70,6 +70,7 @@ func TestTriggers(t *testing.T) {
 
 		// copy IDs before asserting equality
 		data.ID = trigger.ID
+		data.QueryID = trigger.QueryID
 		for i := range trigger.Recipients {
 			data.Recipients[i].ID = trigger.Recipients[i].ID
 		}
@@ -98,6 +99,9 @@ func TestTriggers(t *testing.T) {
 
 		result, err := c.Triggers.Update(ctx, dataset, trigger)
 
+		// copy IDs before asserting equality
+		trigger.QueryID = result.QueryID
+
 		assert.NoError(t, err)
 		assert.Equal(t, trigger, result)
 	})
diff --git a/honeycombio/data_source_query_test.go b/honeycombio/data_source_query_specification_test.go
similarity index 86%
rename from honeycombio/data_source_query_test.go
rename to honeycombio/data_source_query_specification_test.go
index 88d202b..5ae39fe 100644
--- a/honeycombio/data_source_query_test.go
+++ b/honeycombio/data_source_query_specification_test.go
@@ -24,7 +24,7 @@ func TestAccDataSourceHoneycombioQuery_basic(t *testing.T) {
 }
 
 const testAccQueryConfig = `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
     calculation {
         op     = "AVG"
         column = "duration_ms"
@@ -57,13 +57,13 @@ data "honeycombio_query" "test" {
     }
 
     limit 	    = 250
-	time_range  = 7200
-	start_time  = 1577836800
+    time_range  = 7200
+    start_time  = 1577836800
     granularity = 30
 }
 
 output "query_json" {
-    value = data.honeycombio_query.test.json
+    value = data.honeycombio_query_specification.test.json
 }`
 
 //Note: By default go encodes `<` and `>` for html, hence the `\u003e`
@@ -90,7 +90,6 @@ const expectedJSON string = `{
       "value": "ThatSpecialTenant"
     }
   ],
-  "filter_combination": "AND",
   "breakdowns": [
     "column_1"
   ],
@@ -126,7 +125,7 @@ func TestAccDataSourceHoneycombioQuery_validationChecks(t *testing.T) {
 var testStepsQueryValidationChecks_calculation = []resource.TestStep{
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   calculation {
     op     = "COUNT"
     column = "we-should-not-specify-a-column-with-COUNT"
@@ -137,7 +136,7 @@ data "honeycombio_query" "test" {
 	},
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   calculation {
     op     = "AVG"
   }
@@ -150,7 +149,7 @@ data "honeycombio_query" "test" {
 var testStepsQueryValidationChecks_filter = []resource.TestStep{
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   filter {
     column = "column"
     op     = "exists"
@@ -162,7 +161,7 @@ data "honeycombio_query" "test" {
 	},
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   filter {
     column = "column"
     op     = ">"
@@ -173,7 +172,7 @@ data "honeycombio_query" "test" {
 	},
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   filter {
     column        = "column"
     op            = ">"
@@ -186,7 +185,7 @@ data "honeycombio_query" "test" {
 	},
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   filter {
     column        = "column"
     op            = "in"
@@ -200,7 +199,7 @@ data "honeycombio_query" "test" {
 
 func testStepsQueryValidationChecks_limit() []resource.TestStep {
 	var queryLimitFmt = `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   limit = %d
 }`
 	return []resource.TestStep{
@@ -222,7 +221,7 @@ data "honeycombio_query" "test" {
 var testStepsQueryValidationChecks_time = []resource.TestStep{
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   time_range = 7200
   start_time = 1577836800
   end_time   = 1577844000
@@ -232,7 +231,7 @@ data "honeycombio_query" "test" {
 	},
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   time_range  = 120
   granularity = 13
 }
@@ -241,7 +240,7 @@ data "honeycombio_query" "test" {
 	},
 	{
 		Config: `
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   time_range  = 60000
   granularity = 59
 }
@@ -259,15 +258,13 @@ func appendAllTestSteps(steps ...[]resource.TestStep) []resource.TestStep {
 }
 
 func TestAccDataSourceHoneycombioQuery_filterOpInAndNotIn(t *testing.T) {
-	dataset := testAccDataset()
-
 	resource.Test(t, resource.TestCase{
 		PreCheck:          testAccPreCheck(t),
 		ProviderFactories: testAccProviderFactories,
 		Steps: []resource.TestStep{
 			{
-				Config: fmt.Sprintf(`
-data "honeycombio_query" "test" {
+				Config: `
+data "honeycombio_query_specification" "test" {
   calculation {
     op = "COUNT"
   }
@@ -283,14 +280,7 @@ data "honeycombio_query" "test" {
     value  = "fzz,bzz"
   }
 }
-
-resource "honeycombio_board" "test" {
-  name = "terraform-provider-honeycombio - Test honeycombio-query - filter ops in/not-in"
-  query {
-    dataset    = "%v"
-    query_json = data.honeycombio_query.test.json
-  }
-}`, dataset),
+`,
 			},
 		},
 	})
diff --git a/honeycombio/resource_board_test.go b/honeycombio/resource_board_test.go
index c07190a..b63585a 100644
--- a/honeycombio/resource_board_test.go
+++ b/honeycombio/resource_board_test.go
@@ -36,7 +36,7 @@ func TestAccHoneycombioBoard_basic(t *testing.T) {
 
 func testAccBoardConfig(dataset string) string {
 	return fmt.Sprintf(`
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   count = 2
 
   calculation {
@@ -51,22 +51,40 @@ data "honeycombio_query" "test" {
   }
 }
 
+resource "honeycombio_query" "test" {
+  count = 2
+
+  dataset    = "%s"
+  query_json = data.honeycombio_query_specification.test[count.index].json
+}
+
+resource "honeycombio_query_annotation" "test" {
+  count = 2
+
+  dataset     = "%s"
+  name        = "My annotated query"
+  description = "My lovely description"
+  query_id    = honeycombio_query.test[count.index].id
+}
+
 resource "honeycombio_board" "test" {
   name  = "Test board from terraform-provider-honeycombio"
   style = "list"
-     
+
   query {
-    caption = "test query 0"
-    dataset = "%s"
-    query_json = data.honeycombio_query.test[0].json
+    caption             = "test query 0"
+    dataset             = "%s"
+    query_id            = honeycombio_query.test[0].id
+    query_annotation_id = honeycombio_query_annotation.test[0].id
   }
   query {
-    caption     = "test query 1"
-    query_style = "combo"
-    dataset     = "%s"
-    query_json  = data.honeycombio_query.test[1].json
+    caption             = "test query 1"
+    query_style         = "combo"
+    dataset             = "%s"
+    query_id            = honeycombio_query.test[1].id
+    query_annotation_id = honeycombio_query_annotation.test[1].id
   }
-}`, dataset, dataset)
+}`, dataset, dataset, dataset, dataset)
 }
 
 func testAccCheckBoardExists(t *testing.T, name string) resource.TestCheckFunc {
@@ -89,10 +107,11 @@ func testAccCheckBoardExists(t *testing.T, name string) resource.TestCheckFunc {
 			Style:       honeycombio.BoardStyleList,
 			Queries: []honeycombio.BoardQuery{
 				{
-					Caption:    "test query 0",
-					QueryStyle: honeycombio.BoardQueryStyleGraph,
-					Dataset:    testAccDataset(),
-					QueryID:    createdBoard.Queries[0].QueryID,
+					Caption:           "test query 0",
+					QueryStyle:        honeycombio.BoardQueryStyleGraph,
+					Dataset:           testAccDataset(),
+					QueryID:           createdBoard.Queries[0].QueryID,
+					QueryAnnotationID: createdBoard.Queries[0].QueryAnnotationID,
 					Query: &honeycombio.QuerySpec{
 						Calculations: []honeycombio.CalculationSpec{
 							{
@@ -111,10 +130,11 @@ func testAccCheckBoardExists(t *testing.T, name string) resource.TestCheckFunc {
 					},
 				},
 				{
-					Caption:    "test query 1",
-					QueryStyle: honeycombio.BoardQueryStyleCombo,
-					Dataset:    testAccDataset(),
-					QueryID:    createdBoard.Queries[1].QueryID,
+					Caption:           "test query 1",
+					QueryStyle:        honeycombio.BoardQueryStyleCombo,
+					Dataset:           testAccDataset(),
+					QueryID:           createdBoard.Queries[1].QueryID,
+					QueryAnnotationID: createdBoard.Queries[1].QueryAnnotationID,
 					Query: &honeycombio.QuerySpec{
 						Calculations: []honeycombio.CalculationSpec{
 							{
diff --git a/honeycombio/resource_query_annotation_test.go b/honeycombio/resource_query_annotation_test.go
index 14fa750..1573988 100644
--- a/honeycombio/resource_query_annotation_test.go
+++ b/honeycombio/resource_query_annotation_test.go
@@ -37,7 +37,7 @@ func TestAccHoneycombioQueryAnnotation_update(t *testing.T) {
 
 func testAccResourceQueryAnnotationConfig(dataset string, name string) string {
 	return fmt.Sprintf(`
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -52,7 +52,7 @@ data "honeycombio_query" "test" {
 
 resource "honeycombio_query" "test" {
   dataset = "%s"
-  query_json = data.honeycombio_query.test.json
+  query_json = data.honeycombio_query_specification.test.json
 }
 
 resource "honeycombio_query_annotation" "test" {
diff --git a/honeycombio/resource_query_test.go b/honeycombio/resource_query_test.go
index afa0a24..59bb437 100644
--- a/honeycombio/resource_query_test.go
+++ b/honeycombio/resource_query_test.go
@@ -40,7 +40,7 @@ func TestAccHoneycombioQuery_update(t *testing.T) {
 
 func testAccResourceQueryConfig(dataset string, duration int) string {
 	return fmt.Sprintf(`
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -55,7 +55,7 @@ data "honeycombio_query" "test" {
 
 resource "honeycombio_query" "test" {
   dataset = "%s"
-  query_json = data.honeycombio_query.test.json
+  query_json = data.honeycombio_query_specification.test.json
 }
 `, duration, dataset)
 }
diff --git a/honeycombio/resource_trigger_test.go b/honeycombio/resource_trigger_test.go
index 2187455..b53ced0 100644
--- a/honeycombio/resource_trigger_test.go
+++ b/honeycombio/resource_trigger_test.go
@@ -3,7 +3,6 @@ package honeycombio
 import (
 	"context"
 	"fmt"
-	"regexp"
 	"testing"
 
 	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
@@ -106,76 +105,9 @@ func TestAccHoneycombioTrigger_triggerRecipientById(t *testing.T) {
 	})
 }
 
-func TestAccHoneycombioTrigger_validationErrors(t *testing.T) {
-	dataset := testAccDataset()
-
-	resource.ParallelTest(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		Steps: []resource.TestStep{
-			{
-				Config:      testAccTriggerConfigWithQuery(dataset, `{]`),
-				ExpectError: regexp.MustCompile("value of query_json is not a valid query specification"),
-			},
-			{
-				Config:      testAccTriggerConfigWithQuery(dataset, `{"calculations":"bar"}`),
-				ExpectError: regexp.MustCompile("value of query_json is not a valid query specification"),
-			},
-			{
-				Config: testAccTriggerConfigWithQuery(dataset, `
-{
-    "calculations": [
-        {"op": "COUNT"},
-        {"op": "AVG", "column": "duration_ms"}
-    ]
-}`),
-				ExpectError: regexp.MustCompile("a trigger query should contain exactly one calculation"),
-			},
-			{
-				Config: testAccTriggerConfigWithQuery(dataset, `
-{
-    "calculations": [
-        {"op": "HEATMAP"}
-    ]
-}`),
-				ExpectError: regexp.MustCompile("a trigger query may not contain a HEATMAP calculation"),
-			},
-			{
-				Config: testAccTriggerConfigWithQuery(dataset, `
-{
-    "calculations": [
-        {"op": "AVG", "column": "duration_ms"}
-    ],
-    "orders": [
-        {"column": "column_1"}
-    ]
-}`),
-				ExpectError: regexp.MustCompile("orders is not allowed in a trigger query"),
-			},
-			{
-				Config: testAccTriggerConfigWithQuery(dataset, `
-{
-    "calculations": [
-        {"op": "AVG", "column": "duration_ms"}
-    ],
-    "limit": 100
-}`),
-				ExpectError: regexp.MustCompile("limit is not allowed in a trigger query"),
-			},
-			{
-				ResourceName:      "honeycombio_trigger.test",
-				ImportStateId:     "someId",
-				ImportState:       true,
-				ImportStateVerify: true,
-				ExpectError:       regexp.MustCompile("invalid import ID, supplied ID must be written as <dataset>/<trigger ID>"),
-			},
-		},
-	})
-}
-
 func testAccTriggerConfigWithFrequency(dataset string, frequency int) string {
 	return fmt.Sprintf(`
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -183,11 +115,16 @@ data "honeycombio_query" "test" {
   time_range = 1200
 }
 
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = data.honeycombio_query_specification.test.json
+}
+
 resource "honeycombio_trigger" "test" {
   name    = "Test trigger from terraform-provider-honeycombio"
   dataset = "%s"
 
-  query_json = data.honeycombio_query.test.json
+  query_id = honeycombio_query.test.id
 
   threshold {
     op    = ">"
@@ -205,51 +142,39 @@ resource "honeycombio_trigger" "test" {
     type   = "email"
     target = "bye@example.com"
   }
-}`, dataset, frequency)
+}`, dataset, dataset, frequency)
 }
 
 func testAccTriggerConfigWithCount(dataset string) string {
 	return fmt.Sprintf(`
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   calculation {
     op     = "COUNT"
   }
   time_range = 1200
 }
 
-resource "honeycombio_trigger" "test" {
-  name    = "Test trigger from terraform-provider-honeycombio"
-  dataset = "%s"
-
-  query_json = data.honeycombio_query.test.json
-
-  threshold {
-    op    = ">"
-    value = 100
-  }
-}`, dataset)
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = data.honeycombio_query_specification.test.json
 }
 
-func testAccTriggerConfigWithQuery(dataset, query string) string {
-	return fmt.Sprintf(`
 resource "honeycombio_trigger" "test" {
   name    = "Test trigger from terraform-provider-honeycombio"
   dataset = "%s"
 
-  query_json = <<EOF
-%s
-EOF
-    
+  query_id = honeycombio_query.test.id
+
   threshold {
     op    = ">"
     value = 100
   }
-}`, dataset, query)
+}`, dataset, dataset)
 }
 
 func testAccTriggerConfigWithRecipientID(dataset, recipientID string) string {
 	return fmt.Sprintf(`
-data "honeycombio_query" "test" {
+data "honeycombio_query_specification" "test" {
   calculation {
     op     = "AVG"
     column = "duration_ms"
@@ -257,12 +182,17 @@ data "honeycombio_query" "test" {
   time_range = 1800
 }
 
+resource "honeycombio_query" "test" {
+  dataset    = "%s"
+  query_json = data.honeycombio_query_specification.test.json
+}
+
 resource "honeycombio_trigger" "test" {
   name    = "Test trigger from terraform-provider-honeycombio"
   dataset = "%s"
 
-  query_json = data.honeycombio_query.test.json
-        
+  query_id = honeycombio_query.test.id
+
   threshold {
     op    = ">"
     value = 100
@@ -271,5 +201,5 @@ resource "honeycombio_trigger" "test" {
   recipient {
     id = "%s"
   }
-}`, dataset, recipientID)
+}`, dataset, dataset, recipientID)
 }