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 }