github.com/thiagoyeds/go-cloud@v0.26.0/docstore/awsdynamodb/query_test.go (about)

     1  // Copyright 2019 The Go Cloud Development Kit Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     https://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package awsdynamodb
    16  
    17  import (
    18  	"fmt"
    19  	"strings"
    20  	"testing"
    21  	"time"
    22  
    23  	"github.com/aws/aws-sdk-go/aws"
    24  	"github.com/aws/aws-sdk-go/service/dynamodb"
    25  	"github.com/google/go-cmp/cmp"
    26  	"github.com/google/go-cmp/cmp/cmpopts"
    27  	"gocloud.dev/docstore/driver"
    28  	"gocloud.dev/docstore/drivertest"
    29  )
    30  
    31  func TestPlanQuery(t *testing.T) {
    32  	c := &collection{
    33  		table:        "T",
    34  		partitionKey: "tableP",
    35  		description:  &dynamodb.TableDescription{},
    36  		opts:         &Options{AllowScans: true, RevisionField: "rev"},
    37  	}
    38  
    39  	// Build an ExpressionAttributeNames map with the given names.
    40  	eans := func(names ...string) map[string]*string {
    41  		m := map[string]*string{}
    42  		for i, n := range names {
    43  			m[fmt.Sprintf("#%d", i)] = aws.String(n)
    44  		}
    45  		return m
    46  	}
    47  
    48  	// Build an ExpressionAttributeValues map. Filter values are always the number 1
    49  	// and the keys are always :0, :1, ..., so we only need to know how many entries.
    50  	eavs := func(n int) map[string]*dynamodb.AttributeValue {
    51  		if n == 0 {
    52  			return nil
    53  		}
    54  		one := new(dynamodb.AttributeValue).SetN("1")
    55  		m := map[string]*dynamodb.AttributeValue{}
    56  		for i := 0; i < n; i++ {
    57  			m[fmt.Sprintf(":%d", i)] = one
    58  		}
    59  		return m
    60  	}
    61  
    62  	// Ignores the ConsistentRead field from both QueryInput and ScanInput.
    63  	opts := []cmp.Option{
    64  		cmpopts.IgnoreFields(dynamodb.ScanInput{}, "ConsistentRead"),
    65  		cmpopts.IgnoreFields(dynamodb.QueryInput{}, "ConsistentRead"),
    66  	}
    67  
    68  	for _, test := range []struct {
    69  		desc string
    70  		// In all cases, the table has a partition key called "tableP".
    71  		tableSortKey            string   // if non-empty, the table sort key
    72  		localIndexSortKey       string   // if non-empty, there is a local index with this sort key
    73  		localIndexFields        []string // the fields projected into the local index
    74  		globalIndexPartitionKey string   // if non-empty, there is a global index with this partition key
    75  		globalIndexSortKey      string   // if non-empty, the global index  has this sort key
    76  		globalIndexFields       []string // the fields projected into the global index
    77  		query                   *driver.Query
    78  		want                    interface{} // either a ScanInput or a QueryInput
    79  		wantPlan                string
    80  	}{
    81  		{
    82  			desc: "empty query",
    83  			// A query with no filters requires a scan.
    84  			query:    &driver.Query{},
    85  			want:     &dynamodb.ScanInput{TableName: &c.table},
    86  			wantPlan: "Scan",
    87  		},
    88  		{
    89  			desc: "equality filter on table partition field",
    90  			// A filter that compares the table's partition key for equality is the minimum
    91  			// requirement for querying the table.
    92  			query: &driver.Query{Filters: []driver.Filter{{[]string{"tableP"}, "=", 1}}},
    93  			want: &dynamodb.QueryInput{
    94  				KeyConditionExpression:    aws.String("#0 = :0"),
    95  				ExpressionAttributeNames:  eans("tableP"),
    96  				ExpressionAttributeValues: eavs(1),
    97  			},
    98  			wantPlan: "Table",
    99  		},
   100  		{
   101  			desc: "equality filter on table partition field (sort key)",
   102  			// Same as above, but the table has a sort key; shouldn't make a difference.
   103  			tableSortKey: "tableS",
   104  			query:        &driver.Query{Filters: []driver.Filter{{[]string{"tableP"}, "=", 1}}},
   105  			want: &dynamodb.QueryInput{
   106  				KeyConditionExpression:    aws.String("#0 = :0"),
   107  				ExpressionAttributeNames:  eans("tableP"),
   108  				ExpressionAttributeValues: eavs(1),
   109  			},
   110  			wantPlan: "Table",
   111  		},
   112  		{
   113  			desc: "equality filter on other field",
   114  			// This query has an equality filter, but not on the table's partition key.
   115  			// Since there are no matching indexes, we must scan.
   116  			query: &driver.Query{Filters: []driver.Filter{{[]string{"other"}, "=", 1}}},
   117  			want: &dynamodb.ScanInput{
   118  				FilterExpression:          aws.String("#0 = :0"),
   119  				ExpressionAttributeNames:  eans("other"),
   120  				ExpressionAttributeValues: eavs(1),
   121  			},
   122  			wantPlan: "Scan",
   123  		},
   124  		{
   125  			desc: "non-equality filter on table partition field",
   126  			// If the query doesn't have an equality filter on the partition key, and there
   127  			// are no indexes, we must scan. The filter becomes a FilterExpression, evaluated
   128  			// on the backend.
   129  			query: &driver.Query{Filters: []driver.Filter{{[]string{"tableP"}, ">", 1}}},
   130  			want: &dynamodb.ScanInput{
   131  				FilterExpression:          aws.String("#0 > :0"),
   132  				ExpressionAttributeNames:  eans("tableP"),
   133  				ExpressionAttributeValues: eavs(1),
   134  			},
   135  			wantPlan: "Scan",
   136  		},
   137  		{
   138  			desc: "equality filter on partition, filter on other",
   139  			// The equality filter on the table's partition key lets us query the table.
   140  			// The other filter is used in the filter expression.
   141  			query: &driver.Query{Filters: []driver.Filter{
   142  				{[]string{"tableP"}, "=", 1},
   143  				{[]string{"other"}, "<=", 1},
   144  			}},
   145  			want: &dynamodb.QueryInput{
   146  				KeyConditionExpression:    aws.String("#1 = :1"),
   147  				FilterExpression:          aws.String("#0 <= :0"),
   148  				ExpressionAttributeNames:  eans("other", "tableP"),
   149  				ExpressionAttributeValues: eavs(2),
   150  			},
   151  			wantPlan: "Table",
   152  		},
   153  		{
   154  			desc: "equality filter on partition, filter on sort",
   155  			// If the table has a sort key and the query has a filter on it as well
   156  			// as an equality filter on the table's partition key, we can query the
   157  			// table.
   158  			tableSortKey: "tableS",
   159  			query: &driver.Query{Filters: []driver.Filter{
   160  				{[]string{"tableP"}, "=", 1},
   161  				{[]string{"tableS"}, "<=", 1},
   162  			}},
   163  			want: &dynamodb.QueryInput{
   164  				KeyConditionExpression:    aws.String("(#0 = :0) AND (#1 <= :1)"),
   165  				ExpressionAttributeNames:  eans("tableP", "tableS"),
   166  				ExpressionAttributeValues: eavs(2),
   167  			},
   168  			wantPlan: "Table",
   169  		},
   170  		{
   171  			desc: "equality filter on table partition, filter on local index sort",
   172  			// The equality filter on the table's partition key allows us to query
   173  			// the table, but there is a better choice: a local index with a sort key
   174  			// that is mentioned in the query.
   175  			localIndexSortKey: "localS",
   176  			query: &driver.Query{Filters: []driver.Filter{
   177  				{[]string{"tableP"}, "=", 1},
   178  				{[]string{"localS"}, "<=", 1},
   179  			}},
   180  			want: &dynamodb.QueryInput{
   181  				IndexName:                aws.String("local"),
   182  				KeyConditionExpression:   aws.String("(#0 = :0) AND (#1 <= :1)"),
   183  				ExpressionAttributeNames: eans("tableP", "localS"),
   184  			},
   185  			wantPlan: `Index: "local"`,
   186  		},
   187  		{
   188  			desc: "equality filter on table partition, filter on local index sort, bad projection",
   189  			// The equality filter on the table's partition key allows us to query
   190  			// the table. There seems to be a better choice: a local index with a sort key
   191  			// that is mentioned in the query. But the query wants the entire document,
   192  			// and the local index only has some fields.
   193  			localIndexSortKey: "localS",
   194  			localIndexFields:  []string{}, // keys only
   195  			query: &driver.Query{Filters: []driver.Filter{
   196  				{[]string{"tableP"}, "=", 1},
   197  				{[]string{"localS"}, "<=", 1},
   198  			}},
   199  			want: &dynamodb.QueryInput{
   200  				KeyConditionExpression:   aws.String("#1 = :1"),
   201  				FilterExpression:         aws.String("#0 <= :0"),
   202  				ExpressionAttributeNames: eans("localS", "tableP"),
   203  			},
   204  			wantPlan: "Table",
   205  		},
   206  		{
   207  			desc: "equality filter on table partition, filter on local index sort, good projection",
   208  			// Same as above, but now the query no longer asks for all fields, so
   209  			// we will only read the requested fields from the table.
   210  			localIndexSortKey: "localS",
   211  			localIndexFields:  []string{}, // keys only
   212  			query: &driver.Query{
   213  				FieldPaths: [][]string{{"tableP"}, {"localS"}},
   214  				Filters: []driver.Filter{
   215  					{[]string{"tableP"}, "=", 1},
   216  					{[]string{"localS"}, "<=", 1},
   217  				}},
   218  			want: &dynamodb.QueryInput{
   219  				IndexName:                 aws.String("local"),
   220  				KeyConditionExpression:    aws.String("(#0 = :0) AND (#1 <= :1)"),
   221  				ExpressionAttributeNames:  eans("tableP", "localS"),
   222  				ExpressionAttributeValues: eavs(2),
   223  				ProjectionExpression:      aws.String("#0, #1"),
   224  			},
   225  			wantPlan: `Index: "local"`,
   226  		},
   227  		{
   228  			desc: "equality filter on table partition, filters on local index and table sort",
   229  			// Given the choice of querying the table or a local index, prefer the table.
   230  			tableSortKey:      "tableS",
   231  			localIndexSortKey: "localS",
   232  			query: &driver.Query{Filters: []driver.Filter{
   233  				{[]string{"tableP"}, "=", 1},
   234  				{[]string{"localS"}, "<=", 1},
   235  				{[]string{"tableS"}, ">", 1},
   236  			}},
   237  			want: &dynamodb.QueryInput{
   238  				IndexName:                nil,
   239  				KeyConditionExpression:   aws.String("(#1 = :1) AND (#2 > :2)"),
   240  				FilterExpression:         aws.String("#0 <= :0"),
   241  				ExpressionAttributeNames: eans("localS", "tableP", "tableS"),
   242  			},
   243  			wantPlan: "Table",
   244  		},
   245  		{
   246  			desc: "equality filter on other field with index",
   247  			// The query is the same as in "equality filter on other field," but now there
   248  			// is a global index with that field as partition key, so we can query it.
   249  			globalIndexPartitionKey: "other",
   250  			query:                   &driver.Query{Filters: []driver.Filter{{[]string{"other"}, "=", 1}}},
   251  			want: &dynamodb.QueryInput{
   252  				IndexName:                aws.String("global"),
   253  				KeyConditionExpression:   aws.String("#0 = :0"),
   254  				ExpressionAttributeNames: eans("other"),
   255  			},
   256  			wantPlan: `Index: "global"`,
   257  		},
   258  		{
   259  			desc: "equality filter on table partition, filter on global index sort",
   260  			// The equality filter on the table's partition key allows us to query
   261  			// the table, but there is a better choice: a global index with the same
   262  			// partition key and a sort key that is mentioned in the query.
   263  			// (In these tests, the global index has all the fields of the table by default.)
   264  			globalIndexPartitionKey: "tableP",
   265  			globalIndexSortKey:      "globalS",
   266  			query: &driver.Query{Filters: []driver.Filter{
   267  				{[]string{"tableP"}, "=", 1},
   268  				{[]string{"globalS"}, "<=", 1},
   269  			}},
   270  			want: &dynamodb.QueryInput{
   271  				IndexName:                aws.String("global"),
   272  				KeyConditionExpression:   aws.String("(#0 = :0) AND (#1 <= :1)"),
   273  				ExpressionAttributeNames: eans("tableP", "globalS"),
   274  			},
   275  			wantPlan: `Index: "global"`,
   276  		},
   277  		{
   278  			desc: "equality filter on table partition, filter on global index sort, bad projection",
   279  			// Although there is a global index that matches the filters best, it doesn't
   280  			// have the necessary fields. So we query against the table.
   281  			// The query does not specify FilterPaths, so it retrieves the entire document.
   282  			// globalIndexFields explicitly lists the fields that the global index has.
   283  			// Since the global index does not have all the document fields, it can't be used.
   284  			globalIndexPartitionKey: "tableP",
   285  			globalIndexSortKey:      "globalS",
   286  			globalIndexFields:       []string{"other"},
   287  			query: &driver.Query{Filters: []driver.Filter{
   288  				{[]string{"tableP"}, "=", 1},
   289  				{[]string{"globalS"}, "<=", 1},
   290  			}},
   291  			want: &dynamodb.QueryInput{
   292  				IndexName:                nil,
   293  				KeyConditionExpression:   aws.String("#1 = :1"),
   294  				FilterExpression:         aws.String("#0 <= :0"),
   295  				ExpressionAttributeNames: eans("globalS", "tableP"),
   296  			},
   297  			wantPlan: "Table",
   298  		},
   299  		{
   300  			desc: "equality filter on table partition, filter on global index sort, good projection",
   301  			// The global index matches the filters best and has the necessary
   302  			// fields. So we query against it.
   303  			globalIndexPartitionKey: "tableP",
   304  			globalIndexSortKey:      "globalS",
   305  			globalIndexFields:       []string{"other", "rev"},
   306  			query: &driver.Query{
   307  				FieldPaths: [][]string{{"other"}},
   308  				Filters: []driver.Filter{
   309  					{[]string{"tableP"}, "=", 1},
   310  					{[]string{"globalS"}, "<=", 1},
   311  				}},
   312  			want: &dynamodb.QueryInput{
   313  				IndexName:                 aws.String("global"),
   314  				KeyConditionExpression:    aws.String("(#0 = :0) AND (#1 <= :1)"),
   315  				ProjectionExpression:      aws.String("#2, #0"),
   316  				ExpressionAttributeNames:  eans("tableP", "globalS", "other"),
   317  				ExpressionAttributeValues: eavs(2),
   318  			},
   319  			wantPlan: `Index: "global"`,
   320  		},
   321  	} {
   322  		t.Run(test.desc, func(t *testing.T) {
   323  			c.sortKey = test.tableSortKey
   324  			if test.localIndexSortKey == "" {
   325  				c.description.LocalSecondaryIndexes = nil
   326  			} else {
   327  				c.description.LocalSecondaryIndexes = []*dynamodb.LocalSecondaryIndexDescription{
   328  					{
   329  						IndexName:  aws.String("local"),
   330  						KeySchema:  keySchema("tableP", test.localIndexSortKey),
   331  						Projection: indexProjection(test.localIndexFields),
   332  					},
   333  				}
   334  			}
   335  			if test.globalIndexPartitionKey == "" {
   336  				c.description.GlobalSecondaryIndexes = nil
   337  			} else {
   338  				c.description.GlobalSecondaryIndexes = []*dynamodb.GlobalSecondaryIndexDescription{
   339  					{
   340  						IndexName:  aws.String("global"),
   341  						KeySchema:  keySchema(test.globalIndexPartitionKey, test.globalIndexSortKey),
   342  						Projection: indexProjection(test.globalIndexFields),
   343  					},
   344  				}
   345  			}
   346  			gotRunner, err := c.planQuery(test.query)
   347  			if err != nil {
   348  				t.Fatal(err)
   349  			}
   350  			var got interface{}
   351  			switch tw := test.want.(type) {
   352  			case *dynamodb.ScanInput:
   353  				got = gotRunner.scanIn
   354  				tw.TableName = &c.table
   355  				if tw.ExpressionAttributeValues == nil {
   356  					tw.ExpressionAttributeValues = eavs(len(tw.ExpressionAttributeNames))
   357  				}
   358  			case *dynamodb.QueryInput:
   359  				got = gotRunner.queryIn
   360  				tw.TableName = &c.table
   361  				if tw.ExpressionAttributeValues == nil {
   362  					tw.ExpressionAttributeValues = eavs(len(tw.ExpressionAttributeNames))
   363  				}
   364  			default:
   365  				t.Fatalf("bad type for test.want: %T", test.want)
   366  			}
   367  			if diff := cmp.Diff(got, test.want, opts...); diff != "" {
   368  				t.Error("input:\n", diff)
   369  			}
   370  			gotPlan := gotRunner.queryPlan()
   371  			if diff := cmp.Diff(gotPlan, test.wantPlan); diff != "" {
   372  				t.Error("plan:\n", diff)
   373  			}
   374  		})
   375  	}
   376  }
   377  
   378  func TestQueryNoScans(t *testing.T) {
   379  	c := &collection{
   380  		table:        "T",
   381  		partitionKey: "tableP",
   382  		description:  &dynamodb.TableDescription{},
   383  		opts:         &Options{AllowScans: false},
   384  	}
   385  
   386  	for _, test := range []struct {
   387  		q       *driver.Query
   388  		wantErr bool
   389  	}{
   390  		{&driver.Query{}, false},
   391  		{&driver.Query{Filters: []driver.Filter{{[]string{"other"}, "=", 1}}}, true},
   392  	} {
   393  		qr, err := c.planQuery(test.q)
   394  		if err != nil {
   395  			t.Fatalf("%v: %v", test.q, err)
   396  		}
   397  		err = c.checkPlan(qr)
   398  		if test.wantErr {
   399  			if err == nil || !strings.Contains(err.Error(), "AllowScans") {
   400  				t.Errorf("%v: got %v, want an error that mentions the AllowScans option", test.q, err)
   401  			}
   402  		} else if err != nil {
   403  			t.Errorf("%v: got %v, want nil", test.q, err)
   404  		}
   405  	}
   406  }
   407  
   408  // Make a key schema from the names of the partition and sort keys.
   409  func keySchema(pkey, skey string) []*dynamodb.KeySchemaElement {
   410  	return []*dynamodb.KeySchemaElement{
   411  		{AttributeName: &pkey, KeyType: aws.String("HASH")},
   412  		{AttributeName: &skey, KeyType: aws.String("RANGE")},
   413  	}
   414  }
   415  
   416  func indexProjection(fields []string) *dynamodb.Projection {
   417  	var ptype string
   418  	switch {
   419  	case fields == nil:
   420  		ptype = "ALL"
   421  	case len(fields) == 0:
   422  		ptype = "KEYS_ONLY"
   423  	default:
   424  		ptype = "INCLUDE"
   425  	}
   426  	proj := &dynamodb.Projection{ProjectionType: &ptype}
   427  	for _, f := range fields {
   428  		f := f
   429  		proj.NonKeyAttributes = append(proj.NonKeyAttributes, &f)
   430  	}
   431  	return proj
   432  }
   433  
   434  func TestGlobalFieldsIncluded(t *testing.T) {
   435  	c := &collection{partitionKey: "tableP", sortKey: "tableS"}
   436  	gi := &dynamodb.GlobalSecondaryIndexDescription{
   437  		KeySchema: keySchema("globalP", "globalS"),
   438  	}
   439  	for _, test := range []struct {
   440  		desc         string
   441  		queryFields  []string
   442  		wantKeysOnly bool // when the projection includes only table and index keys
   443  		wantInclude  bool // when the projection includes fields "f" and "g".
   444  	}{
   445  		{
   446  			desc:         "all",
   447  			queryFields:  nil,
   448  			wantKeysOnly: false,
   449  			wantInclude:  false,
   450  		},
   451  		{
   452  			desc:         "key fields",
   453  			queryFields:  []string{"tableS", "globalP"},
   454  			wantKeysOnly: true,
   455  			wantInclude:  true,
   456  		},
   457  		{
   458  			desc:         "included fields",
   459  			queryFields:  []string{"f", "g"},
   460  			wantKeysOnly: false,
   461  			wantInclude:  true,
   462  		},
   463  		{
   464  			desc:         "included and key fields",
   465  			queryFields:  []string{"f", "g", "tableP", "globalS"},
   466  			wantKeysOnly: false,
   467  			wantInclude:  true,
   468  		},
   469  		{
   470  			desc:         "not included field",
   471  			queryFields:  []string{"f", "g", "h"},
   472  			wantKeysOnly: false,
   473  			wantInclude:  false,
   474  		},
   475  	} {
   476  		t.Run(test.desc, func(t *testing.T) {
   477  			var fps [][]string
   478  			for _, qf := range test.queryFields {
   479  				fps = append(fps, strings.Split(qf, "."))
   480  			}
   481  			q := &driver.Query{FieldPaths: fps}
   482  			for _, p := range []struct {
   483  				name string
   484  				proj *dynamodb.Projection
   485  				want bool
   486  			}{
   487  				{"ALL", indexProjection(nil), true},
   488  				{"KEYS_ONLY", indexProjection([]string{}), test.wantKeysOnly},
   489  				{"INCLUDE", indexProjection([]string{"f", "g"}), test.wantInclude},
   490  			} {
   491  				t.Run(p.name, func(t *testing.T) {
   492  					gi.Projection = p.proj
   493  					got := c.globalFieldsIncluded(q, gi)
   494  					if got != p.want {
   495  						t.Errorf("got %t, want %t", got, p.want)
   496  					}
   497  				})
   498  			}
   499  		})
   500  	}
   501  }
   502  
   503  func TestCompare(t *testing.T) {
   504  	tm := time.Now()
   505  	for _, test := range []struct {
   506  		a, b interface{}
   507  		want int
   508  	}{
   509  		{1, 1, 0},
   510  		{1, 2, -1},
   511  		{2, 1, 1},
   512  		{1.5, 2, -1},
   513  		{2.5, 2.1, 1},
   514  		{3.8, 3.8, 0},
   515  		{"x", "x", 0},
   516  		{"x", "xx", -1},
   517  		{"x", "a", 1},
   518  		{tm, tm, 0},
   519  		{tm, tm.Add(1), -1},
   520  		{tm, tm.Add(-1), 1},
   521  		{[]byte("x"), []byte("x"), 0},
   522  		{[]byte("x"), []byte("xx"), -1},
   523  		{[]byte("x"), []byte("a"), 1},
   524  	} {
   525  		got := compare(test.a, test.b)
   526  		if got != test.want {
   527  			t.Errorf("compare(%v, %v) = %d, want %d", test.a, test.b, got, test.want)
   528  		}
   529  	}
   530  }
   531  
   532  func TestCopyTopLevel(t *testing.T) {
   533  	type E struct{ C int }
   534  	type S struct {
   535  		A int
   536  		B int
   537  		E
   538  	}
   539  
   540  	s := &S{A: 1, B: 2, E: E{C: 3}}
   541  	m := map[string]interface{}{"A": 1, "B": 2, "C": 3}
   542  	for _, test := range []struct {
   543  		dest, src interface{}
   544  		want      interface{}
   545  	}{
   546  		{
   547  			dest: map[string]interface{}{},
   548  			src:  m,
   549  			want: m,
   550  		},
   551  		{
   552  			dest: &S{},
   553  			src:  s,
   554  			want: s,
   555  		},
   556  		{
   557  			dest: map[string]interface{}{},
   558  			src:  s,
   559  			want: m,
   560  		},
   561  		{
   562  			dest: &S{},
   563  			src:  m,
   564  			want: s,
   565  		},
   566  	} {
   567  		dest := drivertest.MustDocument(test.dest)
   568  		src := drivertest.MustDocument(test.src)
   569  		if err := copyTopLevel(dest, src); err != nil {
   570  			t.Fatalf("src=%+v: %v", test.src, err)
   571  		}
   572  		if !cmp.Equal(test.dest, test.want) {
   573  			t.Errorf("src=%+v: got %v, want %v", test.src, test.dest, test.want)
   574  		}
   575  	}
   576  }