Compare commits
282 Commits
next
...
alternator
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac47f494b | ||
|
|
d93df8c184 | ||
|
|
48e22c2910 | ||
|
|
bbb6689938 | ||
|
|
6c252feb0c | ||
|
|
33d5015edf | ||
|
|
0be2f17952 | ||
|
|
a74d5fcce8 | ||
|
|
8e9e7cc952 | ||
|
|
ce5db951be | ||
|
|
5a9b9bdf4d | ||
|
|
518f01099c | ||
|
|
db617b459c | ||
|
|
72ac19623a | ||
|
|
eb1cc6610c | ||
|
|
24789b549a | ||
|
|
e11ba56dbc | ||
|
|
a2d68eac4c | ||
|
|
c14fc8d854 | ||
|
|
81dcee8961 | ||
|
|
6aa840543c | ||
|
|
84598b39b9 | ||
|
|
287ba6ddde | ||
|
|
a700da216d | ||
|
|
a6d2117a77 | ||
|
|
c46458f61d | ||
|
|
dc09d96b22 | ||
|
|
350180125d | ||
|
|
78a153518a | ||
|
|
70d4afc637 | ||
|
|
fea29fdfd1 | ||
|
|
93f1072cee | ||
|
|
edd03312ed | ||
|
|
faad21f72b | ||
|
|
298f5caa38 | ||
|
|
b6662bac35 | ||
|
|
707a70235e | ||
|
|
c223fafba3 | ||
|
|
7a749a4772 | ||
|
|
5fc1e70751 | ||
|
|
93fbbbc202 | ||
|
|
cfcf841c0d | ||
|
|
c0c4d90df9 | ||
|
|
86e80ea320 | ||
|
|
6d62ed213a | ||
|
|
05920f7c3b | ||
|
|
9390dd41d0 | ||
|
|
a795c290f3 | ||
|
|
d4b19f55b2 | ||
|
|
5e8644413e | ||
|
|
40e3b7a0b6 | ||
|
|
76c3e4e0d6 | ||
|
|
9567691551 | ||
|
|
2220c8c681 | ||
|
|
b2fa0c9c1c | ||
|
|
ace1ffc8da | ||
|
|
6b8224ef85 | ||
|
|
2e68eafb22 | ||
|
|
3044f71438 | ||
|
|
0af6ab6fc6 | ||
|
|
70ae5cc235 | ||
|
|
c9345d8a0e | ||
|
|
587b38cd69 | ||
|
|
7d68d5030d | ||
|
|
c49e009e3e | ||
|
|
eebb2f0a0f | ||
|
|
fd10eee1ae | ||
|
|
28b3819c23 | ||
|
|
656f62722b | ||
|
|
ecb571d7e3 | ||
|
|
dd4638d499 | ||
|
|
aaf559c4f9 | ||
|
|
f7d0ca3c92 | ||
|
|
8394225741 | ||
|
|
091b1b40c2 | ||
|
|
b914ba11fa | ||
|
|
1b2b2c7009 | ||
|
|
188c6a552a | ||
|
|
b8964ab0ba | ||
|
|
c4fd846dbb | ||
|
|
338b7e9e67 | ||
|
|
154d1649c6 | ||
|
|
5620a46024 | ||
|
|
fc9744791c | ||
|
|
e0b01a0233 | ||
|
|
b3bf2fab2e | ||
|
|
05b895ca84 | ||
|
|
6b145b59d3 | ||
|
|
76bc30a82d | ||
|
|
972474a215 | ||
|
|
3342ebff22 | ||
|
|
cd2c581c7c | ||
|
|
e19a7f908e | ||
|
|
e9f1540de1 | ||
|
|
eb678ed63a | ||
|
|
ebdd4022cf | ||
|
|
d6a8626e90 | ||
|
|
62858c8466 | ||
|
|
3716d23ce4 | ||
|
|
1611b5dd4f | ||
|
|
105533c046 | ||
|
|
7c23e23e7d | ||
|
|
a5057a3b6e | ||
|
|
8d8baccdc4 | ||
|
|
f8c7a2e0b8 | ||
|
|
99fd032b1f | ||
|
|
7984050054 | ||
|
|
d7f75b405b | ||
|
|
1d19934bc6 | ||
|
|
493890c6f6 | ||
|
|
e550a666e3 | ||
|
|
ea62ce67f4 | ||
|
|
7ce0a30766 | ||
|
|
d141c3b5bd | ||
|
|
a6ca5e19e4 | ||
|
|
482ac08a45 | ||
|
|
7efa1aa48a | ||
|
|
196efbba13 | ||
|
|
19111136c3 | ||
|
|
87626beaae | ||
|
|
b5bbdc18e4 | ||
|
|
34833707be | ||
|
|
cb9cb0e58e | ||
|
|
a69455df2b | ||
|
|
6435895e2f | ||
|
|
3c7b07c20c | ||
|
|
f9063208e9 | ||
|
|
99d7a34c00 | ||
|
|
3212167ac4 | ||
|
|
33300fd30c | ||
|
|
0372ce0649 | ||
|
|
43049bbec0 | ||
|
|
80163a67a2 | ||
|
|
f0448c67b0 | ||
|
|
94097b98d4 | ||
|
|
7062b19461 | ||
|
|
7af4304592 | ||
|
|
f2fac82bf0 | ||
|
|
30aea6d942 | ||
|
|
e3b1e2860c | ||
|
|
bb169ff51c | ||
|
|
76fa348dd9 | ||
|
|
2933648013 | ||
|
|
2a2e2a5b3b | ||
|
|
3135b9b5a5 | ||
|
|
8d117c0f25 | ||
|
|
9cb2bf0820 | ||
|
|
ddbcdd9736 | ||
|
|
c66f32a4ff | ||
|
|
de284b1111 | ||
|
|
a0825043f4 | ||
|
|
7107923e1e | ||
|
|
a6b007b753 | ||
|
|
3909370146 | ||
|
|
0230c04702 | ||
|
|
d5a92727d2 | ||
|
|
042e087066 | ||
|
|
dcd271393e | ||
|
|
f2767d973b | ||
|
|
51a1a01605 | ||
|
|
067f00050e | ||
|
|
9fc44d2f0e | ||
|
|
67c86461cb | ||
|
|
d018539b07 | ||
|
|
1e488458b2 | ||
|
|
b4b6a377e4 | ||
|
|
90b8870a16 | ||
|
|
00ed12e56c | ||
|
|
04610f5299 | ||
|
|
ef58e3fc1e | ||
|
|
21c7e53c1c | ||
|
|
66cad2975f | ||
|
|
bd251e81df | ||
|
|
f807e2792d | ||
|
|
d1942091d7 | ||
|
|
01ff0d1ba8 | ||
|
|
19c855287c | ||
|
|
4c2c920250 | ||
|
|
e6f1841756 | ||
|
|
d4ad58f353 | ||
|
|
102034a742 | ||
|
|
d914f37a08 | ||
|
|
c63fefd37b | ||
|
|
c2cf2ab99b | ||
|
|
41526884f1 | ||
|
|
2e703e6b73 | ||
|
|
ecd585ef59 | ||
|
|
cd040d6674 | ||
|
|
a1dbde66fd | ||
|
|
67c35cde40 | ||
|
|
18b2f656f2 | ||
|
|
90d7e6673e | ||
|
|
182450623a | ||
|
|
f14c7f8200 | ||
|
|
b81fbabe37 | ||
|
|
9c934b64f8 | ||
|
|
4ad39f714f | ||
|
|
f56a0fbcd9 | ||
|
|
296c2566c5 | ||
|
|
d760097dad | ||
|
|
523c5ee159 | ||
|
|
8146eb6027 | ||
|
|
725c18bf6c | ||
|
|
c8270831ec | ||
|
|
0987916542 | ||
|
|
ec23a14f82 | ||
|
|
7e4f7a20dc | ||
|
|
930234e48e | ||
|
|
9055096021 | ||
|
|
65934ada59 | ||
|
|
b28d3cdb5d | ||
|
|
cf58acc23f | ||
|
|
75c3f33a8c | ||
|
|
be516a080a | ||
|
|
176b7dfd17 | ||
|
|
30d4b4e689 | ||
|
|
3b73c49ac8 | ||
|
|
b556356a7d | ||
|
|
90c12b4ea3 | ||
|
|
287a986715 | ||
|
|
9b46c6ac2d | ||
|
|
f0436aeecc | ||
|
|
e3ba65003d | ||
|
|
d25a07a6c0 | ||
|
|
426f53bc89 | ||
|
|
1d85558d47 | ||
|
|
1c7b1ac165 | ||
|
|
c4c71989bf | ||
|
|
4dad76a6a7 | ||
|
|
323268e9ab | ||
|
|
a441ad9360 | ||
|
|
d04a5b01c3 | ||
|
|
4da8171b42 | ||
|
|
fbc6f222b8 | ||
|
|
31cce0323e | ||
|
|
f3d1cefe3e | ||
|
|
8bf6e963f2 | ||
|
|
ac65f91b5d | ||
|
|
c7d7c1e50d | ||
|
|
829f5fe359 | ||
|
|
5f9409af68 | ||
|
|
f28680ec5c | ||
|
|
a7175ddd44 | ||
|
|
90f56c32b0 | ||
|
|
2808f7ae3f | ||
|
|
ac278d67ca | ||
|
|
53a9567804 | ||
|
|
5a751ddcff | ||
|
|
dae70c892f | ||
|
|
4cbb40d5d8 | ||
|
|
ae779ade37 | ||
|
|
8839b22277 | ||
|
|
35bc488f5b | ||
|
|
c85dcfb71d | ||
|
|
a37b334e16 | ||
|
|
d52e7de7be | ||
|
|
dd884b5552 | ||
|
|
6c6c5a37a1 | ||
|
|
4ff599b21f | ||
|
|
6178694b0e | ||
|
|
700a3bb7be | ||
|
|
eb13166fb5 | ||
|
|
325773d65b | ||
|
|
4e12a4f212 | ||
|
|
3600a096be | ||
|
|
9fbf601f44 | ||
|
|
7b400913fc | ||
|
|
623fd5d10d | ||
|
|
8c9e88f77d | ||
|
|
4f3b0af6d0 | ||
|
|
830805e608 | ||
|
|
b87057eb08 | ||
|
|
53ac8dfb0a | ||
|
|
8a19ae8e39 | ||
|
|
41775a6e5d | ||
|
|
7adde41f55 | ||
|
|
c926f1bb53 | ||
|
|
e6ff060944 | ||
|
|
b5fa4ce3f3 | ||
|
|
6e697e4cca | ||
|
|
b2b7ae4e41 | ||
|
|
fca1e655b4 |
33
alternator-test/README.md
Normal file
33
alternator-test/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
Tests for Alternator that should also pass, identically, against DynamoDB.
|
||||
|
||||
Tests use the boto3 library for AWS API, and the pytest frameworks
|
||||
(both are available from Linux distributions, or with "pip install").
|
||||
|
||||
To run all tests against the local installation of Alternator on
|
||||
http://localhost:8000, just run `pytest`.
|
||||
|
||||
Some additional pytest options:
|
||||
* To run all tests in a single file, do `pytest test_table.py`.
|
||||
* To run a single specific test, do `pytest test_table.py::test_create_table_unsupported_names`.
|
||||
* Additional useful pytest options, especially useful for debugging tests:
|
||||
* -v: show the names of each individual test running instead of just dots.
|
||||
* -s: show the full output of running tests (by default, pytest captures the test's output and only displays it if a test fails)
|
||||
|
||||
Add the `--aws` option to test against AWS instead of the local installation.
|
||||
For example - `pytest --aws test_item.py` or `pytest --aws`.
|
||||
|
||||
If you plan to run tests against AWS and not just a local Scylla installation,
|
||||
the files ~/.aws/credentials should be configured with your AWS key:
|
||||
|
||||
```
|
||||
[default]
|
||||
aws_access_key_id = XXXXXXXXXXXXXXXXXXXX
|
||||
aws_secret_access_key = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
and ~/.aws/config with the default region to use in the test:
|
||||
```
|
||||
[default]
|
||||
region = us-east-1
|
||||
```
|
||||
|
||||
169
alternator-test/conftest.py
Normal file
169
alternator-test/conftest.py
Normal file
@@ -0,0 +1,169 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# This file contains "test fixtures", a pytest concept described in
|
||||
# https://docs.pytest.org/en/latest/fixture.html.
|
||||
# A "fixture" is some sort of setup which an invididual test requires to run.
|
||||
# The fixture has setup code and teardown code, and if multiple tests
|
||||
# require the same fixture, it can be set up only once - while still allowing
|
||||
# the user to run individual tests and automatically set up the fixtures they need.
|
||||
|
||||
import pytest
|
||||
import boto3
|
||||
from util import create_test_table
|
||||
|
||||
# Test that the Boto libraries are new enough. These tests want to test a
|
||||
# large variety of DynamoDB API features, and to do this we need a new-enough
|
||||
# version of the the Boto libraries (boto3 and botocore) so that they can
|
||||
# access all these API features.
|
||||
# In particular, the BillingMode feature was added in botocore 1.12.54.
|
||||
import botocore
|
||||
import sys
|
||||
from distutils.version import LooseVersion
|
||||
if (LooseVersion(botocore.__version__) < LooseVersion('1.12.54')):
|
||||
pytest.exit("Your Boto library is too old. Please upgrade it,\ne.g. using:\n sudo pip{} install --upgrade boto3".format(sys.version_info[0]))
|
||||
|
||||
# By default, tests run against a local Scylla installation on localhost:8080/.
|
||||
# The "--aws" option can be used to run against Amazon DynamoDB in the us-east-1
|
||||
# region.
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption("--aws", action="store_true",
|
||||
help="run against AWS instead of a local Scylla installation")
|
||||
|
||||
# "dynamodb" fixture: set up client object for communicating with the DynamoDB
|
||||
# API. Currently this chooses either Amazon's DynamoDB in the default region
|
||||
# or a local Alternator installation on http://localhost:8080 - depending on the
|
||||
# existence of the "--aws" option. In the future we should provide options
|
||||
# for choosing other Amazon regions or local installations.
|
||||
# We use scope="session" so that all tests will reuse the same client object.
|
||||
@pytest.fixture(scope="session")
|
||||
def dynamodb(request):
|
||||
if request.config.getoption('aws'):
|
||||
return boto3.resource('dynamodb')
|
||||
else:
|
||||
# Even though we connect to the local installation, Boto3 still
|
||||
# requires us to specify dummy region and credential parameters,
|
||||
# otherwise the user is forced to properly configure ~/.aws even
|
||||
# for local runs.
|
||||
return boto3.resource('dynamodb', endpoint_url='http://localhost:8000',
|
||||
region_name='us-east-1', aws_access_key_id='whatever', aws_secret_access_key='whatever')
|
||||
|
||||
# "test_table" fixture: Create and return a temporary table to be used in tests
|
||||
# that need a table to work on. The table is automatically deleted at the end.
|
||||
# We use scope="session" so that all tests will reuse the same client object.
|
||||
# This "test_table" creates a table which has a specific key schema: both a
|
||||
# partition key and a sort key, and both are strings. Other fixtures (below)
|
||||
# can be used to create different types of tables.
|
||||
#
|
||||
# TODO: Although we are careful about deleting temporary tables when the
|
||||
# fixture is torn down, in some cases (e.g., interrupted tests) we can be left
|
||||
# with some tables not deleted, and they will never be deleted. Because all
|
||||
# our temporary tables have the same test_table_prefix, we can actually find
|
||||
# and remove these old tables with this prefix. We can have a fixture, which
|
||||
# test_table will require, which on teardown will delete all remaining tables
|
||||
# (possibly from an older run). Because the table's name includes the current
|
||||
# time, we can also remove just tables older than a particular age. Such
|
||||
# mechanism will allow running tests in parallel, without the risk of deleting
|
||||
# a parallel run's temporary tables.
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
])
|
||||
yield table
|
||||
# We get back here when this fixture is torn down. We ask Dynamo to delete
|
||||
# this table, but not wait for the deletion to complete. The next time
|
||||
# we create a test_table fixture, we'll choose a different table name
|
||||
# anyway.
|
||||
table.delete()
|
||||
|
||||
# The following fixtures test_table_* are similar to test_table but create
|
||||
# tables with different key schemas.
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_s(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, ],
|
||||
AttributeDefinitions=[ { 'AttributeName': 'p', 'AttributeType': 'S' } ])
|
||||
yield table
|
||||
table.delete()
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_b(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, ],
|
||||
AttributeDefinitions=[ { 'AttributeName': 'p', 'AttributeType': 'B' } ])
|
||||
yield table
|
||||
table.delete()
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_sb(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' } ],
|
||||
AttributeDefinitions=[ { 'AttributeName': 'p', 'AttributeType': 'S' }, { 'AttributeName': 'c', 'AttributeType': 'B' } ])
|
||||
yield table
|
||||
table.delete()
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_sn(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' } ],
|
||||
AttributeDefinitions=[ { 'AttributeName': 'p', 'AttributeType': 'S' }, { 'AttributeName': 'c', 'AttributeType': 'N' } ])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
# "filled_test_table" fixture: Create a temporary table to be used in tests
|
||||
# that involve reading data - GetItem, Scan, etc. The table is filled with
|
||||
# 328 items - each consisting of a partition key, clustering key and two
|
||||
# string attributes. 164 of the items are in a single partition (with the
|
||||
# partition key 'long') and the 164 other items are each in a separate
|
||||
# partition. Finally, a 329th item is added with different attributes.
|
||||
# This table is supposed to be read from, not updated nor overwritten.
|
||||
# This fixture returns both a table object and the description of all items
|
||||
# inserted into it.
|
||||
@pytest.fixture(scope="session")
|
||||
def filled_test_table(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
])
|
||||
count = 164
|
||||
items = [{
|
||||
'p': str(i),
|
||||
'c': str(i),
|
||||
'attribute': "x" * 7,
|
||||
'another': "y" * 16
|
||||
} for i in range(count)]
|
||||
items = items + [{
|
||||
'p': 'long',
|
||||
'c': str(i),
|
||||
'attribute': "x" * (1 + i % 7),
|
||||
'another': "y" * (1 + i % 16)
|
||||
} for i in range(count)]
|
||||
items.append({'p': 'hello', 'c': 'world', 'str': 'and now for something completely different'})
|
||||
|
||||
with table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
|
||||
yield table, items
|
||||
table.delete()
|
||||
253
alternator-test/test_batch.py
Normal file
253
alternator-test/test_batch.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for batch operations - BatchWriteItem, BatchReadItem.
|
||||
# Note that various other tests in other files also use these operations,
|
||||
# so they are actually tested by other tests as well.
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from util import random_string, full_scan, full_query, multiset
|
||||
|
||||
# Test ensuring that items inserted by a batched statement can be properly extracted
|
||||
# via GetItem. Schema has both hash and sort keys.
|
||||
def test_basic_batch_write_item(test_table):
|
||||
count = 7
|
||||
|
||||
with test_table.batch_writer() as batch:
|
||||
for i in range(count):
|
||||
batch.put_item(Item={
|
||||
'p': "batch{}".format(i),
|
||||
'c': "batch_ck{}".format(i),
|
||||
'attribute': str(i),
|
||||
'another': 'xyz'
|
||||
})
|
||||
|
||||
for i in range(count):
|
||||
item = test_table.get_item(Key={'p': "batch{}".format(i), 'c': "batch_ck{}".format(i)}, ConsistentRead=True)['Item']
|
||||
assert item['p'] == "batch{}".format(i)
|
||||
assert item['c'] == "batch_ck{}".format(i)
|
||||
assert item['attribute'] == str(i)
|
||||
assert item['another'] == 'xyz'
|
||||
|
||||
# Test batch write to a table with only a hash key
|
||||
def test_batch_write_hash_only(test_table_s):
|
||||
items = [{'p': random_string(), 'val': random_string()} for i in range(10)]
|
||||
with test_table_s.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for item in items:
|
||||
assert test_table_s.get_item(Key={'p': item['p']}, ConsistentRead=True)['Item'] == item
|
||||
|
||||
# Test batch delete operation (DeleteRequest): We create a bunch of items, and
|
||||
# then delete them all.
|
||||
def test_batch_write_delete(test_table_s):
|
||||
items = [{'p': random_string(), 'val': random_string()} for i in range(10)]
|
||||
with test_table_s.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for item in items:
|
||||
assert test_table_s.get_item(Key={'p': item['p']}, ConsistentRead=True)['Item'] == item
|
||||
with test_table_s.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.delete_item(Key={'p': item['p']})
|
||||
# Verify that all items are now missing:
|
||||
for item in items:
|
||||
assert not 'Item' in test_table_s.get_item(Key={'p': item['p']}, ConsistentRead=True)
|
||||
|
||||
# Test the same batch including both writes and delete. Should be fine.
|
||||
def test_batch_write_and_delete(test_table_s):
|
||||
p1 = random_string()
|
||||
p2 = random_string()
|
||||
test_table_s.put_item(Item={'p': p1})
|
||||
assert 'Item' in test_table_s.get_item(Key={'p': p1}, ConsistentRead=True)
|
||||
assert not 'Item' in test_table_s.get_item(Key={'p': p2}, ConsistentRead=True)
|
||||
with test_table_s.batch_writer() as batch:
|
||||
batch.put_item({'p': p2})
|
||||
batch.delete_item(Key={'p': p1})
|
||||
assert not 'Item' in test_table_s.get_item(Key={'p': p1}, ConsistentRead=True)
|
||||
assert 'Item' in test_table_s.get_item(Key={'p': p2}, ConsistentRead=True)
|
||||
|
||||
# It is forbidden to update the same key twice in the same batch.
|
||||
# DynamoDB says "Provided list of item keys contains duplicates".
|
||||
def test_batch_write_duplicate_write(test_table_s, test_table):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*duplicates'):
|
||||
with test_table_s.batch_writer() as batch:
|
||||
batch.put_item({'p': p})
|
||||
batch.put_item({'p': p})
|
||||
c = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*duplicates'):
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.put_item({'p': p, 'c': c})
|
||||
batch.put_item({'p': p, 'c': c})
|
||||
# But it is fine to touch items with one component the same, but the other not.
|
||||
other = random_string()
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.put_item({'p': p, 'c': c})
|
||||
batch.put_item({'p': p, 'c': other})
|
||||
batch.put_item({'p': other, 'c': c})
|
||||
|
||||
def test_batch_write_duplicate_delete(test_table_s, test_table):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*duplicates'):
|
||||
with test_table_s.batch_writer() as batch:
|
||||
batch.delete_item(Key={'p': p})
|
||||
batch.delete_item(Key={'p': p})
|
||||
c = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*duplicates'):
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.delete_item(Key={'p': p, 'c': c})
|
||||
batch.delete_item(Key={'p': p, 'c': c})
|
||||
# But it is fine to touch items with one component the same, but the other not.
|
||||
other = random_string()
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.delete_item(Key={'p': p, 'c': c})
|
||||
batch.delete_item(Key={'p': p, 'c': other})
|
||||
batch.delete_item(Key={'p': other, 'c': c})
|
||||
|
||||
def test_batch_write_duplicate_write_and_delete(test_table_s, test_table):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*duplicates'):
|
||||
with test_table_s.batch_writer() as batch:
|
||||
batch.delete_item(Key={'p': p})
|
||||
batch.put_item({'p': p})
|
||||
c = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*duplicates'):
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.delete_item(Key={'p': p, 'c': c})
|
||||
batch.put_item({'p': p, 'c': c})
|
||||
# But it is fine to touch items with one component the same, but the other not.
|
||||
other = random_string()
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.delete_item(Key={'p': p, 'c': c})
|
||||
batch.put_item({'p': p, 'c': other})
|
||||
batch.put_item({'p': other, 'c': c})
|
||||
|
||||
# Test that BatchWriteItem's PutRequest completely replaces an existing item.
|
||||
# It shouldn't merge it with a previously existing value. See also the same
|
||||
# test for PutItem - test_put_item_replace().
|
||||
def test_batch_put_item_replace(test_table_s, test_table):
|
||||
p = random_string()
|
||||
with test_table_s.batch_writer() as batch:
|
||||
batch.put_item(Item={'p': p, 'a': 'hi'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hi'}
|
||||
with test_table_s.batch_writer() as batch:
|
||||
batch.put_item(Item={'p': p, 'b': 'hello'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 'hello'}
|
||||
c = random_string()
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.put_item(Item={'p': p, 'c': c, 'a': 'hi'})
|
||||
assert test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c, 'a': 'hi'}
|
||||
with test_table.batch_writer() as batch:
|
||||
batch.put_item(Item={'p': p, 'c': c, 'b': 'hello'})
|
||||
assert test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c, 'b': 'hello'}
|
||||
|
||||
# Test that if one of the batch's operations is invalid, because a key
|
||||
# column is missing or has the wrong type, the entire batch is rejected
|
||||
# before any write is done.
|
||||
def test_batch_write_invalid_operation(test_table_s):
|
||||
# test key attribute with wrong type:
|
||||
p1 = random_string()
|
||||
p2 = random_string()
|
||||
items = [{'p': p1}, {'p': 3}, {'p': p2}]
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
with test_table_s.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for p in [p1, p2]:
|
||||
assert not 'item' in test_table_s.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
# test missing key attribute:
|
||||
p1 = random_string()
|
||||
p2 = random_string()
|
||||
items = [{'p': p1}, {'x': 'whatever'}, {'p': p2}]
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
with test_table_s.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for p in [p1, p2]:
|
||||
assert not 'item' in test_table_s.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
|
||||
# Basic test for BatchGetItem, reading several entire items.
|
||||
# Schema has both hash and sort keys.
|
||||
def test_batch_get_item(test_table):
|
||||
items = [{'p': random_string(), 'c': random_string(), 'val': random_string()} for i in range(10)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
keys = [{k: x[k] for k in ('p', 'c')} for x in items]
|
||||
# We use the low-level batch_get_item API for lack of a more convenient
|
||||
# API. At least it spares us the need to encode the key's types...
|
||||
reply = test_table.meta.client.batch_get_item(RequestItems = {test_table.name: {'Keys': keys, 'ConsistentRead': True}})
|
||||
print(reply)
|
||||
got_items = reply['Responses'][test_table.name]
|
||||
assert multiset(got_items) == multiset(items)
|
||||
|
||||
# Same, with schema has just hash key.
|
||||
def test_batch_get_item_hash(test_table_s):
|
||||
items = [{'p': random_string(), 'val': random_string()} for i in range(10)]
|
||||
with test_table_s.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
keys = [{k: x[k] for k in ('p')} for x in items]
|
||||
reply = test_table_s.meta.client.batch_get_item(RequestItems = {test_table_s.name: {'Keys': keys, 'ConsistentRead': True}})
|
||||
got_items = reply['Responses'][test_table_s.name]
|
||||
assert multiset(got_items) == multiset(items)
|
||||
|
||||
# Test what do we get if we try to read two *missing* values in addition to
|
||||
# an existing one. It turns out the missing items are simply not returned,
|
||||
# with no sign they are missing.
|
||||
def test_batch_get_item_missing(test_table_s):
|
||||
p = random_string();
|
||||
test_table_s.put_item(Item={'p': p})
|
||||
reply = test_table_s.meta.client.batch_get_item(RequestItems = {test_table_s.name: {'Keys': [{'p': random_string()}, {'p': random_string()}, {'p': p}], 'ConsistentRead': True}})
|
||||
got_items = reply['Responses'][test_table_s.name]
|
||||
assert got_items == [{'p' : p}]
|
||||
|
||||
# If all the keys requested from a particular table are missing, we still
|
||||
# get a response array for that table - it's just empty.
|
||||
def test_batch_get_item_completely_missing(test_table_s):
|
||||
reply = test_table_s.meta.client.batch_get_item(RequestItems = {test_table_s.name: {'Keys': [{'p': random_string()}], 'ConsistentRead': True}})
|
||||
got_items = reply['Responses'][test_table_s.name]
|
||||
assert got_items == []
|
||||
|
||||
# Test GetItem with AttributesToGet
|
||||
def test_batch_get_item_attributes_to_get(test_table):
|
||||
items = [{'p': random_string(), 'c': random_string(), 'val1': random_string(), 'val2': random_string()} for i in range(10)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
keys = [{k: x[k] for k in ('p', 'c')} for x in items]
|
||||
for wanted in [['p'], ['p', 'c'], ['val1'], ['p', 'val2']]:
|
||||
reply = test_table.meta.client.batch_get_item(RequestItems = {test_table.name: {'Keys': keys, 'AttributesToGet': wanted, 'ConsistentRead': True}})
|
||||
got_items = reply['Responses'][test_table.name]
|
||||
expected_items = [{k: item[k] for k in wanted if k in item} for item in items]
|
||||
assert multiset(got_items) == multiset(expected_items)
|
||||
|
||||
# Test GetItem with ProjectionExpression (just a simple one, with
|
||||
# top-level attributes)
|
||||
def test_batch_get_item_projection_expression(test_table):
|
||||
items = [{'p': random_string(), 'c': random_string(), 'val1': random_string(), 'val2': random_string()} for i in range(10)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
keys = [{k: x[k] for k in ('p', 'c')} for x in items]
|
||||
for wanted in [['p'], ['p', 'c'], ['val1'], ['p', 'val2']]:
|
||||
reply = test_table.meta.client.batch_get_item(RequestItems = {test_table.name: {'Keys': keys, 'ProjectionExpression': ",".join(wanted), 'ConsistentRead': True}})
|
||||
got_items = reply['Responses'][test_table.name]
|
||||
expected_items = [{k: item[k] for k in wanted if k in item} for item in items]
|
||||
assert multiset(got_items) == multiset(expected_items)
|
||||
40
alternator-test/test_condition_expression.py
Normal file
40
alternator-test/test_condition_expression.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the ConditionExpression parameter
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from util import random_string
|
||||
|
||||
# Test that ConditionExpression works as expected
|
||||
@pytest.mark.xfail(reason="ConditionExpression not yet implemented")
|
||||
def test_update_condition_expression(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1',
|
||||
ConditionExpression='b = :oldval',
|
||||
ExpressionAttributeValues={':val1': 6, ':oldval': 4})
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException.*'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1',
|
||||
ConditionExpression='b = :oldval',
|
||||
ExpressionAttributeValues={':val1': 8, ':oldval': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 6}
|
||||
47
alternator-test/test_describe_endpoints.py
Normal file
47
alternator-test/test_describe_endpoints.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Test for the DescribeEndpoints operation
|
||||
|
||||
import boto3
|
||||
|
||||
# Test that the DescribeEndpoints operation works as expected: that it
|
||||
# returns one endpoint (it may return more, but it never does this in
|
||||
# Amazon), and this endpoint can be used to make more requests.
|
||||
def test_describe_endpoints(dynamodb):
|
||||
endpoints = dynamodb.meta.client.describe_endpoints()['Endpoints']
|
||||
# It is not strictly necessary that only a single endpoint be returned,
|
||||
# but this is what Amazon DynamoDB does today (and so does Alternator).
|
||||
assert len(endpoints) == 1
|
||||
for endpoint in endpoints:
|
||||
assert 'CachePeriodInMinutes' in endpoint.keys()
|
||||
address = endpoint['Address']
|
||||
# Check that the address is a valid endpoint by checking that we can
|
||||
# send it another describe_endpoints() request ;-) Note that the
|
||||
# address does not include the "http://" or "https://" prefix, and
|
||||
# we need to choose one manually.
|
||||
url = "http://" + address
|
||||
if address.endswith('.amazonaws.com'):
|
||||
boto3.client('dynamodb',endpoint_url=url).describe_endpoints()
|
||||
else:
|
||||
# Even though we connect to the local installation, Boto3 still
|
||||
# requires us to specify dummy region and credential parameters,
|
||||
# otherwise the user is forced to properly configure ~/.aws even
|
||||
# for local runs.
|
||||
boto3.client('dynamodb',endpoint_url=url, region_name='us-east-1', aws_access_key_id='whatever', aws_secret_access_key='whatever').describe_endpoints()
|
||||
# Nothing to check here - if the above call failed with an exception,
|
||||
# the test would fail.
|
||||
170
alternator-test/test_describe_table.py
Normal file
170
alternator-test/test_describe_table.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the DescribeTable operation.
|
||||
# Some attributes used only by a specific major feature will be tested
|
||||
# elsewhere:
|
||||
# 1. Tests for describing tables with global or local secondary indexes
|
||||
# (the GlobalSecondaryIndexes and LocalSecondaryIndexes attributes)
|
||||
# are in test_gsi.py and test_lsi.py.
|
||||
# 2. Tests for the stream feature (LatestStreamArn, LatestStreamLabel,
|
||||
# StreamSpecification) will be in the tests devoted to the stream
|
||||
# feature.
|
||||
# 3. Tests for describing a restored table (RestoreSummary, TableId)
|
||||
# will be together with tests devoted to the backup/restore feature.
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
import re
|
||||
import time
|
||||
from util import multiset
|
||||
|
||||
# Test that DescribeTable correctly returns the table's name and state
|
||||
def test_describe_table_basic(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert got['TableName'] == test_table.name
|
||||
assert got['TableStatus'] == 'ACTIVE'
|
||||
|
||||
# Test that DescribeTable correctly returns the table's schema, in
|
||||
# AttributeDefinitions and KeySchema attributes
|
||||
@pytest.mark.xfail(reason="DescribeTable does not yet return schema")
|
||||
def test_describe_table_schema(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
expected = { # Copied from test_table()'s fixture
|
||||
'KeySchema': [ { 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'AttributeDefinitions': [
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
]
|
||||
}
|
||||
assert got['KeySchema'] == expected['KeySchema']
|
||||
# The list of attribute definitions may be arbitrarily reordered
|
||||
assert multiset(got['AttributeDefinitions']) == multiset(expected['AttributeDefinitions'])
|
||||
|
||||
# Test that DescribeTable correctly returns the table's billing mode,
|
||||
# in the BillingModeSummary attribute.
|
||||
def test_describe_table_billing(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert got['BillingModeSummary']['BillingMode'] == 'PAY_PER_REQUEST'
|
||||
# The BillingModeSummary should also contain a
|
||||
# LastUpdateToPayPerRequestDateTime attribute, which is a date.
|
||||
# We don't know what date this is supposed to be, but something we
|
||||
# do know is that the test table was created already with this billing
|
||||
# mode, so the table creation date should be the same as the billing
|
||||
# mode setting date.
|
||||
assert 'LastUpdateToPayPerRequestDateTime' in got['BillingModeSummary']
|
||||
assert got['BillingModeSummary']['LastUpdateToPayPerRequestDateTime'] == got['CreationDateTime']
|
||||
|
||||
# Test that DescribeTable correctly returns the table's creation time.
|
||||
# We don't know what this creation time is supposed to be, so this test
|
||||
# cannot be very thorough... We currently just tests against something we
|
||||
# know to be wrong - returning the *current* time, which changes on every
|
||||
# call.
|
||||
@pytest.mark.xfail(reason="DescribeTable does not return table creation time")
|
||||
def test_describe_table_creation_time(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert 'CreationDateTime' in got
|
||||
time1 = got['CreationDateTime']
|
||||
time.sleep(1)
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
time2 = got['CreationDateTime']
|
||||
assert time1 == time2
|
||||
|
||||
# Test that DescribeTable returns the table's estimated item count
|
||||
# in the ItemCount attribute. Unfortunately, there's not much we can
|
||||
# really test here... The documentation says that the count can be
|
||||
# delayed by six hours, so the number we get here may have no relation
|
||||
# to the current number of items in the test table. The attribute should exist,
|
||||
# though. This test does NOT verify that ItemCount isn't always returned as
|
||||
# zero - such stub implementation will pass this test.
|
||||
@pytest.mark.xfail(reason="DescribeTable does not return table item count")
|
||||
def test_describe_table_item_count(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert 'ItemCount' in got
|
||||
|
||||
# Similar test for estimated size in bytes - TableSizeBytes - which again,
|
||||
# may reflect the size as long as six hours ago.
|
||||
@pytest.mark.xfail(reason="DescribeTable does not return table size")
|
||||
def test_describe_table_size(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert 'TableSizeBytes' in got
|
||||
|
||||
# Test the ProvisionedThroughput attribute returned by DescribeTable.
|
||||
# This is a very partial test: Our test table is configured without
|
||||
# provisioned throughput, so obviously it will not have interesting settings
|
||||
# for it. DynamoDB returns zeros for some of the attributes, even though
|
||||
# the documentation suggests missing values should have been fine too.
|
||||
@pytest.mark.xfail(reason="DescribeTable does not return provisioned throughput")
|
||||
def test_describe_table_provisioned_throughput(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert got['ProvisionedThroughput']['NumberOfDecreasesToday'] == 0
|
||||
assert got['ProvisionedThroughput']['WriteCapacityUnits'] == 0
|
||||
assert got['ProvisionedThroughput']['ReadCapacityUnits'] == 0
|
||||
|
||||
# This is a silly test for the RestoreSummary attribute in DescribeTable -
|
||||
# it should not exist in a table not created by a restore. When testing
|
||||
# the backup/restore feature, we will have more meaninful tests for the
|
||||
# value of this attribute in that case.
|
||||
def test_describe_table_restore_summary(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert not 'RestoreSummary' in got
|
||||
|
||||
# This is a silly test for the SSEDescription attribute in DescribeTable -
|
||||
# by default, a table is encrypted with AWS-owned keys, not using client-
|
||||
# owned keys, and the SSEDescription attribute is not returned at all.
|
||||
def test_describe_table_encryption(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert not 'SSEDescription' in got
|
||||
|
||||
# This is a silly test for the StreamSpecification attribute in DescribeTable -
|
||||
# when there are no streams, this attribute should be missing.
|
||||
def test_describe_table_stream_specification(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert not 'StreamSpecification' in got
|
||||
|
||||
# Test that the table has an ARN, a unique identifier for the table which
|
||||
# includes which zone it is on, which account, and of course the table's
|
||||
# name. The ARN format is described in
|
||||
# https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#genref-arns
|
||||
@pytest.mark.xfail(reason="DescribeTable does not return ARN")
|
||||
def test_describe_table_arn(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert 'TableArn' in got and got['TableArn'].startswith('arn:')
|
||||
|
||||
# Test that the table has a TableId.
|
||||
# TODO: Figure out what is this TableId supposed to be, it is just a
|
||||
# unique id that is created with the table and never changes? Or anything
|
||||
# else?
|
||||
@pytest.mark.xfail(reason="DescribeTable does not return TableId")
|
||||
def test_describe_table_id(test_table):
|
||||
got = test_table.meta.client.describe_table(TableName=test_table.name)['Table']
|
||||
assert 'TableId' in got
|
||||
|
||||
# DescribeTable error path: trying to describe a non-existent table should
|
||||
# result in a ResourceNotFoundException.
|
||||
def test_describe_table_non_existent_table(dynamodb):
|
||||
with pytest.raises(ClientError, match='ResourceNotFoundException') as einfo:
|
||||
dynamodb.meta.client.describe_table(TableName='non_existent_table')
|
||||
# As one of the first error-path tests that we wrote, let's test in more
|
||||
# detail that the error reply has the appropriate fields:
|
||||
response = einfo.value.response
|
||||
print(response)
|
||||
err = response['Error']
|
||||
assert err['Code'] == 'ResourceNotFoundException'
|
||||
assert re.match(err['Message'], 'Requested resource not found: Table: non_existent_table not found')
|
||||
369
alternator-test/test_expected.py
Normal file
369
alternator-test/test_expected.py
Normal file
@@ -0,0 +1,369 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the "Expected" parameter used to make certain operations (PutItem,
|
||||
# UpdateItem and DeleteItem) conditional on the existing attribute values.
|
||||
# "Expected" is the older version of ConditionExpression parameter, which
|
||||
# is tested by the separate test_condition_expression.py.
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from util import random_string
|
||||
|
||||
# Most of the tests in this file check that the "Expected" parameter works for
|
||||
# the UpdateItem operation. It should also work the same for the PutItem and
|
||||
# DeleteItem operations, and we'll make a small effort verifying that at
|
||||
# the end of the file.
|
||||
|
||||
# Somewhat pedanticly, DynamoDB forbids using old-style Expected together
|
||||
# with new-style UpdateExpression... Expected can only be used with
|
||||
# AttributeUpdates (and for UpdateExpression, ConditionExpression should be
|
||||
# used).
|
||||
def test_update_expression_and_expected(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = :val1',
|
||||
ExpressionAttributeValues={':val1': 1})
|
||||
with pytest.raises(ClientError, match='ValidationException.*UpdateExpression'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = :val1',
|
||||
ExpressionAttributeValues={':val1': 2},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': [1]}}
|
||||
)
|
||||
|
||||
# The following string of tests test the various types of Expected conditions
|
||||
# on a single attribute. This condition is defined using ComparisonOperator
|
||||
# (there are many types of those!) or by Value or Exists, and we need to check
|
||||
# all these types of conditions.
|
||||
#
|
||||
# In each case we have tests for the "true" case of the condition, meaning
|
||||
# that the condition evaluates to true and the update is supposed to happen,
|
||||
# and the "false" case, where the condition evaluates to false, so the update
|
||||
# doesn't happen and we get a ConditionalCheckFailedException instead.
|
||||
|
||||
def test_update_expected_1_eq_true(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
# Case where expected and update are on the same attribute:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': [1]}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 2}
|
||||
# Case where expected and update are on different attribute:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': [2]}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 2, 'b': 3}
|
||||
# For EQ, AttributeValueList must have a single element
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': [2, 3]}}
|
||||
)
|
||||
|
||||
# Check that set equality is checked correctly. Unlike string equality (for
|
||||
# example), it cannot be done with just naive string comparison of the JSON
|
||||
# representation, and we need to allow for any order.
|
||||
@pytest.mark.xfail(reason="bug in EQ test of sets")
|
||||
def test_update_expected_1_eq_set(test_table_s):
|
||||
p = random_string()
|
||||
# Because boto3 sorts the set values we give it, in order to generate a
|
||||
# set with a different order, we need to build it incrementally.
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': set(['dog', 'chinchilla']), 'Action': 'PUT'}})
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='ADD a :val1',
|
||||
ExpressionAttributeValues={':val1': set(['cat', 'mouse'])})
|
||||
# Sanity check - the attribute contains the set we think it does
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == set(['chinchilla', 'cat', 'dog', 'mouse'])
|
||||
# Now finally check that "Expected"'s equality check knows the equality too.
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': [set(['chinchilla', 'cat', 'dog', 'mouse'])]}}
|
||||
)
|
||||
assert 'b' in test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']
|
||||
|
||||
def test_update_expected_1_eq_false(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': [2]}}
|
||||
)
|
||||
# If the compared value has a different type, it results in the
|
||||
# condition failing normally (it's not a validation error).
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': ['dog']}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1}
|
||||
|
||||
def test_update_expected_1_begins_with_true(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 'hello', 'Action': 'PUT'}})
|
||||
# Case where expected and update are on different attribute:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'BEGINS_WITH',
|
||||
'AttributeValueList': ['hell']}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello', 'b': 3}
|
||||
# For BEGIN_WITH, AttributeValueList must have a single element
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': ['hell', 'heaven']}}
|
||||
)
|
||||
|
||||
def test_update_expected_1_begins_with_false(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 'hello', 'Action': 'PUT'}})
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': ['dog']}}
|
||||
)
|
||||
# Although BEGINS_WITH requires String or Binary type, giving it a
|
||||
# number results not with a ValidationException but rather a
|
||||
# failed condition (ConditionalCheckFailedException)
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'ComparisonOperator': 'EQ',
|
||||
'AttributeValueList': [3]}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello'}
|
||||
|
||||
# FIXME: need to test many more ComparisonOperator options... See full list in
|
||||
# description in https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/LegacyConditionalParameters.Expected.html
|
||||
|
||||
# Instead of ComparisonOperator and AttributeValueList, one can specify either
|
||||
# Value or Exists:
|
||||
def test_update_expected_1_value_true(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Value': 1}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'b': 2}
|
||||
|
||||
def test_update_expected_1_value_false(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Value': 2}}
|
||||
)
|
||||
# If the expected attribute is completely missing, the condition also fails
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'z': {'Value': 1}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1}
|
||||
|
||||
def test_update_expected_1_exists_true(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
# Surprisingly, the "Exists: True" cannot be used to confirm that the
|
||||
# attribute had *any* old value (use the NOT_NULL comparison operator
|
||||
# for that). It can only be used together with "Value", and in that case
|
||||
# doesn't mean a thing.
|
||||
# Only "Exists: False" has an interesting meaning.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True}}
|
||||
)
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'c': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 1}}
|
||||
)
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'d': {'Value': 4, 'Action': 'PUT'}},
|
||||
Expected={'z': {'Exists': False}}
|
||||
)
|
||||
# Exists: False cannot be used together with a Value:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'c': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': False, 'Value': 1}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'c': 3, 'd': 4}
|
||||
|
||||
def test_update_expected_1_exists_false(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': False}}
|
||||
)
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 2}}
|
||||
)
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1}
|
||||
|
||||
# Test that it's not allowed to combine ComparisonOperator and Exists or Value
|
||||
def test_update_expected_operator_clash(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': False, 'ComparisonOperator': 'EQ', 'AttributeValueList': [3]}})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'b': {'Value': 2, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Value': 3, 'ComparisonOperator': 'EQ', 'AttributeValueList': [3]}})
|
||||
|
||||
# All the previous tests involved a single condition on a single attribute.
|
||||
# The following tests involving multiple conditions on multiple attributes.
|
||||
# ConditionalOperator defaults to AND, and can also be set to OR.
|
||||
|
||||
def test_update_expected_multi_true(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'},
|
||||
'b': {'Value': 2, 'Action': 'PUT'}})
|
||||
# Test several conditions with default "AND" operator
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 1},
|
||||
'b': {'ComparisonOperator': 'EQ', 'AttributeValueList': [2]},
|
||||
'c': {'Exists': False}})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'b': 2, 'z': 3}
|
||||
# Same with explicit "AND" operator
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 4, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 1},
|
||||
'b': {'ComparisonOperator': 'EQ', 'AttributeValueList': [2]},
|
||||
'c': {'Exists': False}},
|
||||
ConditionalOperator="AND")
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'b': 2, 'z': 4}
|
||||
# With "OR" operator, it's enough that just one conditions is true
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 5, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 74},
|
||||
'b': {'ComparisonOperator': 'EQ', 'AttributeValueList': [999]},
|
||||
'c': {'Exists': False}},
|
||||
ConditionalOperator="OR")
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'b': 2, 'z': 5}
|
||||
|
||||
def test_update_expected_multi_false(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'},
|
||||
'b': {'Value': 2, 'Action': 'PUT'},
|
||||
'c': {'Value': 3, 'Action': 'PUT'}})
|
||||
# Test several conditions, one of them false, with default "AND" operator
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 1},
|
||||
'b': {'ComparisonOperator': 'EQ', 'AttributeValueList': [3]},
|
||||
'd': {'Exists': False}})
|
||||
# Same with explicit "AND" operator
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 4, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 1},
|
||||
'b': {'ComparisonOperator': 'EQ', 'AttributeValueList': [3]},
|
||||
'd': {'Exists': False}},
|
||||
ConditionalOperator="AND")
|
||||
# With "OR" operator, all the conditions need to be false to fail
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 5, 'Action': 'PUT'}},
|
||||
Expected={'a': {'Exists': True, 'Value': 74},
|
||||
'b': {'ComparisonOperator': 'EQ', 'AttributeValueList': [999]},
|
||||
'c': {'Exists': False}},
|
||||
ConditionalOperator='OR')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'b': 2, 'c': 3}
|
||||
|
||||
# Verify the behaviour of an empty Expected parameter:
|
||||
def test_update_expected_empty(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
# An empty Expected array results in a successful update:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 3, 'Action': 'PUT'}},
|
||||
Expected={})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'z': 3}
|
||||
# Trying with ConditionalOperator complains that you can't have
|
||||
# ConditionalOperator without Expected (despite Expected existing, though empty).
|
||||
with pytest.raises(ClientError, match='ValidationException.*ConditionalOperator'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 4, 'Action': 'PUT'}},
|
||||
Expected={}, ConditionalOperator='OR')
|
||||
with pytest.raises(ClientError, match='ValidationException.*ConditionalOperator'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'z': {'Value': 4, 'Action': 'PUT'}},
|
||||
Expected={}, ConditionalOperator='AND')
|
||||
|
||||
# All of the above tests tested "Expected" with the UpdateItem operation.
|
||||
# We now want to test that it works also with the PutItem and DeleteItems
|
||||
# operations. We don't need to check again all the different sub-cases tested
|
||||
# above - we can assume that exactly the same code gets used to test the
|
||||
# expected value. So we just need one test for each operation, to verify that
|
||||
# this code actually gets called.
|
||||
|
||||
def test_delete_item_expected(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.delete_item(Key={'p': p}, Expected={'a': {'Value': 2}})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1}
|
||||
test_table_s.delete_item(Key={'p': p}, Expected={'a': {'Value': 1}})
|
||||
assert not 'Item' in test_table_s.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
|
||||
def test_put_item_expected(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 1, 'Action': 'PUT'}})
|
||||
test_table_s.put_item(Item={'p': p, 'a': 2}, Expected={'a': {'Value': 1}})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 2}
|
||||
with pytest.raises(ClientError, match='ConditionalCheckFailedException'):
|
||||
test_table_s.put_item(Item={'p': p, 'a': 3}, Expected={'a': {'Value': 1}})
|
||||
801
alternator-test/test_gsi.py
Normal file
801
alternator-test/test_gsi.py
Normal file
@@ -0,0 +1,801 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests of GSI (Global Secondary Indexes)
|
||||
#
|
||||
# Note that many of these tests are slower than usual, because many of them
|
||||
# need to create new tables and/or new GSIs of different types, operations
|
||||
# which are extremely slow in DynamoDB, often taking minutes (!).
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from botocore.exceptions import ClientError, ParamValidationError
|
||||
from util import create_test_table, random_string, full_scan, full_query, multiset, list_tables
|
||||
|
||||
# GSIs only support eventually consistent reads, so tests that involve
|
||||
# writing to a table and then expect to read something from it cannot be
|
||||
# guaranteed to succeed without retrying the read. The following utility
|
||||
# functions make it easy to write such tests.
|
||||
# Note that in practice, there repeated reads are almost never necessary:
|
||||
# Amazon claims that "Changes to the table data are propagated to the global
|
||||
# secondary indexes within a fraction of a second, under normal conditions"
|
||||
# and indeed, in practice, the tests here almost always succeed without a
|
||||
# retry.
|
||||
def assert_index_query(table, index_name, expected_items, **kwargs):
|
||||
for i in range(3):
|
||||
if multiset(expected_items) == multiset(full_query(table, IndexName=index_name, **kwargs)):
|
||||
return
|
||||
print('assert_index_query retrying')
|
||||
time.sleep(1)
|
||||
assert multiset(expected_items) == multiset(full_query(table, IndexName=index_name, **kwargs))
|
||||
|
||||
def assert_index_scan(table, index_name, expected_items, **kwargs):
|
||||
for i in range(3):
|
||||
if multiset(expected_items) == multiset(full_scan(table, IndexName=index_name, **kwargs)):
|
||||
return
|
||||
print('assert_index_scan retrying')
|
||||
time.sleep(1)
|
||||
assert multiset(expected_items) == multiset(full_scan(table, IndexName=index_name, **kwargs))
|
||||
|
||||
# Although quite silly, it is actually allowed to create an index which is
|
||||
# identical to the base table.
|
||||
def test_gsi_identical(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }],
|
||||
AttributeDefinitions=[{ 'AttributeName': 'p', 'AttributeType': 'S' }],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [{ 'AttributeName': 'p', 'KeyType': 'HASH' }],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
items = [{'p': random_string(), 'x': random_string()} for i in range(10)]
|
||||
with table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
# Scanning the entire table directly or via the index yields the same
|
||||
# results (in different order).
|
||||
assert multiset(items) == multiset(full_scan(table))
|
||||
assert_index_scan(table, 'hello', items)
|
||||
# We can't scan a non-existant index
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
full_scan(table, IndexName='wrong')
|
||||
table.delete()
|
||||
|
||||
# One of the simplest forms of a non-trivial GSI: The base table has a hash
|
||||
# and sort key, and the index reverses those roles. Other attributes are just
|
||||
# copied.
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_gsi_1(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'c', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'p', 'KeyType': 'RANGE' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
],
|
||||
)
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
def test_gsi_simple(test_table_gsi_1):
|
||||
items = [{'p': random_string(), 'c': random_string(), 'x': random_string()} for i in range(10)]
|
||||
with test_table_gsi_1.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
c = items[0]['c']
|
||||
# The index allows a query on just a specific sort key, which isn't
|
||||
# allowed on the base table.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
full_query(test_table_gsi_1, KeyConditions={'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}})
|
||||
expected_items = [x for x in items if x['c'] == c]
|
||||
assert_index_query(test_table_gsi_1, 'hello', expected_items,
|
||||
KeyConditions={'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}})
|
||||
# Scanning the entire table directly or via the index yields the same
|
||||
# results (in different order).
|
||||
assert_index_scan(test_table_gsi_1, 'hello', full_scan(test_table_gsi_1))
|
||||
|
||||
def test_gsi_same_key(test_table_gsi_1):
|
||||
c = random_string();
|
||||
# All these items have the same sort key 'c' but different hash key 'p'
|
||||
items = [{'p': random_string(), 'c': c, 'x': random_string()} for i in range(10)]
|
||||
with test_table_gsi_1.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
assert_index_query(test_table_gsi_1, 'hello', items,
|
||||
KeyConditions={'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
# Check we get an appropriate error when trying to read a non-existing index
|
||||
# of an existing table. Although the documentation specifies that a
|
||||
# ResourceNotFoundException should be returned if "The operation tried to
|
||||
# access a nonexistent table or index", in fact in the specific case that
|
||||
# the table does exist but an index does not - we get a ValidationException.
|
||||
def test_gsi_missing_index(test_table_gsi_1):
|
||||
with pytest.raises(ClientError, match='ValidationException.*wrong_name'):
|
||||
full_query(test_table_gsi_1, IndexName='wrong_name',
|
||||
KeyConditions={'x': {'AttributeValueList': [1], 'ComparisonOperator': 'EQ'}})
|
||||
with pytest.raises(ClientError, match='ValidationException.*wrong_name'):
|
||||
full_scan(test_table_gsi_1, IndexName='wrong_name')
|
||||
|
||||
# Nevertheless, if the table itself does not exist, a query should return
|
||||
# a ResourceNotFoundException, not ValidationException:
|
||||
def test_gsi_missing_table(dynamodb):
|
||||
with pytest.raises(ClientError, match='ResourceNotFoundException'):
|
||||
dynamodb.meta.client.query(TableName='nonexistent_table', IndexName='any_name', KeyConditions={'x': {'AttributeValueList': [1], 'ComparisonOperator': 'EQ'}})
|
||||
with pytest.raises(ClientError, match='ResourceNotFoundException'):
|
||||
dynamodb.meta.client.scan(TableName='nonexistent_table', IndexName='any_name')
|
||||
|
||||
# Verify that strongly-consistent reads on GSI are *not* allowed.
|
||||
@pytest.mark.xfail(reason="GSI strong consistency not checked")
|
||||
def test_gsi_strong_consistency(test_table_gsi_1):
|
||||
with pytest.raises(ClientError, match='ValidationException.*Consistent'):
|
||||
full_query(test_table_gsi_1, KeyConditions={'c': {'AttributeValueList': ['hi'], 'ComparisonOperator': 'EQ'}}, IndexName='hello', ConsistentRead=True)
|
||||
with pytest.raises(ClientError, match='ValidationException.*Consistent'):
|
||||
full_scan(test_table_gsi_1, IndexName='hello', ConsistentRead=True)
|
||||
|
||||
# Verify that a GSI is correctly listed in describe_table
|
||||
@pytest.mark.xfail(reason="DescribeTable provides index names only, no size or item count")
|
||||
def test_gsi_describe(test_table_gsi_1):
|
||||
desc = test_table_gsi_1.meta.client.describe_table(TableName=test_table_gsi_1.name)
|
||||
assert 'Table' in desc
|
||||
assert 'GlobalSecondaryIndexes' in desc['Table']
|
||||
gsis = desc['Table']['GlobalSecondaryIndexes']
|
||||
assert len(gsis) == 1
|
||||
gsi = gsis[0]
|
||||
assert gsi['IndexName'] == 'hello'
|
||||
assert 'IndexSizeBytes' in gsi # actual size depends on content
|
||||
assert 'ItemCount' in gsi
|
||||
assert gsi['Projection'] == {'ProjectionType': 'ALL'}
|
||||
assert gsi['IndexStatus'] == 'ACTIVE'
|
||||
assert gsi['KeySchema'] == [{'KeyType': 'HASH', 'AttributeName': 'c'},
|
||||
{'KeyType': 'RANGE', 'AttributeName': 'p'}]
|
||||
# TODO: check also ProvisionedThroughput, IndexArn
|
||||
|
||||
# When a GSI's key includes an attribute not in the base table's key, we
|
||||
# need to remember to add its type to AttributeDefinitions.
|
||||
def test_gsi_missing_attribute_definition(dynamodb):
|
||||
with pytest.raises(ClientError, match='ValidationException.*AttributeDefinitions'):
|
||||
create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[ { 'AttributeName': 'p', 'AttributeType': 'S' } ],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [ { 'AttributeName': 'c', 'KeyType': 'HASH' } ],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
|
||||
# test_table_gsi_1_hash_only is a variant of test_table_gsi_1: It's another
|
||||
# case where the index doesn't involve non-key attributes. Again the base
|
||||
# table has a hash and sort key, but in this case the index has *only* a
|
||||
# hash key (which is the base's hash key). In the materialized-view-based
|
||||
# implementation, we need to remember the other part of the base key as a
|
||||
# clustering key.
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_gsi_1_hash_only(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'c', 'KeyType': 'HASH' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
],
|
||||
)
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
def test_gsi_key_not_in_index(test_table_gsi_1_hash_only):
|
||||
# Test with items with different 'c' values:
|
||||
items = [{'p': random_string(), 'c': random_string(), 'x': random_string()} for i in range(10)]
|
||||
with test_table_gsi_1_hash_only.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
c = items[0]['c']
|
||||
expected_items = [x for x in items if x['c'] == c]
|
||||
assert_index_query(test_table_gsi_1_hash_only, 'hello', expected_items,
|
||||
KeyConditions={'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}})
|
||||
# Test items with the same sort key 'c' but different hash key 'p'
|
||||
c = random_string();
|
||||
items = [{'p': random_string(), 'c': c, 'x': random_string()} for i in range(10)]
|
||||
with test_table_gsi_1_hash_only.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
assert_index_query(test_table_gsi_1_hash_only, 'hello', items,
|
||||
KeyConditions={'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}})
|
||||
# Scanning the entire table directly or via the index yields the same
|
||||
# results (in different order).
|
||||
assert_index_scan(test_table_gsi_1_hash_only, 'hello', full_scan(test_table_gsi_1_hash_only))
|
||||
|
||||
|
||||
# A second scenario of GSI. Base table has just hash key, Index has a
|
||||
# different hash key - one of the non-key attributes from the base table.
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_gsi_2(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'x', 'KeyType': 'HASH' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
def test_gsi_2(test_table_gsi_2):
|
||||
items1 = [{'p': random_string(), 'x': random_string()} for i in range(10)]
|
||||
x1 = items1[0]['x']
|
||||
x2 = random_string()
|
||||
items2 = [{'p': random_string(), 'x': x2} for i in range(10)]
|
||||
items = items1 + items2
|
||||
with test_table_gsi_2.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
expected_items = [i for i in items if i['x'] == x1]
|
||||
assert_index_query(test_table_gsi_2, 'hello', expected_items,
|
||||
KeyConditions={'x': {'AttributeValueList': [x1], 'ComparisonOperator': 'EQ'}})
|
||||
expected_items = [i for i in items if i['x'] == x2]
|
||||
assert_index_query(test_table_gsi_2, 'hello', expected_items,
|
||||
KeyConditions={'x': {'AttributeValueList': [x2], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
# Test that when a table has a GSI, if the indexed attribute is missing, the
|
||||
# item is added to the base table but not the index.
|
||||
def test_gsi_missing_attribute(test_table_gsi_2):
|
||||
p1 = random_string()
|
||||
x1 = random_string()
|
||||
test_table_gsi_2.put_item(Item={'p': p1, 'x': x1})
|
||||
p2 = random_string()
|
||||
test_table_gsi_2.put_item(Item={'p': p2})
|
||||
|
||||
# Both items are now in the base table:
|
||||
assert test_table_gsi_2.get_item(Key={'p': p1})['Item'] == {'p': p1, 'x': x1}
|
||||
assert test_table_gsi_2.get_item(Key={'p': p2})['Item'] == {'p': p2}
|
||||
|
||||
# But only the first item is in the index: It can be found using a
|
||||
# Query, and a scan of the index won't find it (but a scan on the base
|
||||
# will).
|
||||
assert_index_query(test_table_gsi_2, 'hello', [{'p': p1, 'x': x1}],
|
||||
KeyConditions={'x': {'AttributeValueList': [x1], 'ComparisonOperator': 'EQ'}})
|
||||
assert any([i['p'] == p1 for i in full_scan(test_table_gsi_2)])
|
||||
# Note: with eventually consistent read, we can't really be sure that
|
||||
# and item will "never" appear in the index. We do this test last,
|
||||
# so if we had a bug and such item did appear, hopefully we had enough
|
||||
# time for the bug to become visible. At least sometimes.
|
||||
assert not any([i['p'] == p2 for i in full_scan(test_table_gsi_2, IndexName='hello')])
|
||||
|
||||
# Test when a table has a GSI, if the indexed attribute has the wrong type,
|
||||
# the update operation is rejected, and is added to neither base table nor
|
||||
# index. This is different from the case of a *missing* attribute, where
|
||||
# the item is added to the base table but not index.
|
||||
# The following three tests test_gsi_wrong_type_attribute_{put,update,batch}
|
||||
# test updates using PutItem, UpdateItem, and BatchWriteItem respectively.
|
||||
def test_gsi_wrong_type_attribute_put(test_table_gsi_2):
|
||||
# PutItem with wrong type for 'x' is rejected, item isn't created even
|
||||
# in the base table.
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*mismatch'):
|
||||
test_table_gsi_2.put_item(Item={'p': p, 'x': 3})
|
||||
assert not 'Item' in test_table_gsi_2.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
|
||||
def test_gsi_wrong_type_attribute_update(test_table_gsi_2):
|
||||
# An UpdateItem with wrong type for 'x' is also rejected, but naturally
|
||||
# if the item already existed, it remains as it was.
|
||||
p = random_string()
|
||||
x = random_string()
|
||||
test_table_gsi_2.put_item(Item={'p': p, 'x': x})
|
||||
with pytest.raises(ClientError, match='ValidationException.*mismatch'):
|
||||
test_table_gsi_2.update_item(Key={'p': p}, AttributeUpdates={'x': {'Value': 3, 'Action': 'PUT'}})
|
||||
assert test_table_gsi_2.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'x': x}
|
||||
|
||||
def test_gsi_wrong_type_attribute_batch(test_table_gsi_2):
|
||||
# In a BatchWriteItem, if any update is forbidden, the entire batch is
|
||||
# rejected, and none of the updates happen at all.
|
||||
p1 = random_string()
|
||||
p2 = random_string()
|
||||
p3 = random_string()
|
||||
items = [{'p': p1, 'x': random_string()},
|
||||
{'p': p2, 'x': 3},
|
||||
{'p': p3, 'x': random_string()}]
|
||||
with pytest.raises(ClientError, match='ValidationException.*mismatch'):
|
||||
with test_table_gsi_2.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for p in [p1, p2, p3]:
|
||||
assert not 'Item' in test_table_gsi_2.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
|
||||
# A third scenario of GSI. Index has a hash key and a sort key, both are
|
||||
# non-key attributes from the base table. This scenario may be very
|
||||
# difficult to implement in Alternator because Scylla's materialized-views
|
||||
# implementation only allows one new key column in the view, and here
|
||||
# we need two (which, also, aren't actual columns, but map items).
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_gsi_3(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'a', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'b', 'AttributeType': 'S' }
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'a', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'b', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
def test_gsi_3(test_table_gsi_3):
|
||||
items = [{'p': random_string(), 'a': random_string(), 'b': random_string()} for i in range(10)]
|
||||
with test_table_gsi_3.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
assert_index_query(test_table_gsi_3, 'hello', [items[3]],
|
||||
KeyConditions={'a': {'AttributeValueList': [items[3]['a']], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [items[3]['b']], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
@pytest.mark.xfail(reason="GSI in alternator currently have a bug on updating the second regular base column")
|
||||
def test_gsi_update_second_regular_base_column(test_table_gsi_3):
|
||||
items = [{'p': random_string(), 'a': random_string(), 'b': random_string(), 'd': random_string()} for i in range(10)]
|
||||
with test_table_gsi_3.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
items[3]['b'] = 'updated'
|
||||
test_table_gsi_3.update_item(Key={'p': items[3]['p']}, AttributeUpdates={'b': {'Value': 'updated', 'Action': 'PUT'}})
|
||||
assert_index_query(test_table_gsi_3, 'hello', [items[3]],
|
||||
KeyConditions={'a': {'AttributeValueList': [items[3]['a']], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [items[3]['b']], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
|
||||
# A fourth scenario of GSI. Two GSIs on a single base table.
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_gsi_4(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'a', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'b', 'AttributeType': 'S' }
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello_a',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'a', 'KeyType': 'HASH' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
},
|
||||
{ 'IndexName': 'hello_b',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'b', 'KeyType': 'HASH' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
# Test that a base table with two GSIs updates both as expected.
|
||||
def test_gsi_4(test_table_gsi_4):
|
||||
items = [{'p': random_string(), 'a': random_string(), 'b': random_string()} for i in range(10)]
|
||||
with test_table_gsi_4.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
assert_index_query(test_table_gsi_4, 'hello_a', [items[3]],
|
||||
KeyConditions={'a': {'AttributeValueList': [items[3]['a']], 'ComparisonOperator': 'EQ'}})
|
||||
assert_index_query(test_table_gsi_4, 'hello_b', [items[3]],
|
||||
KeyConditions={'b': {'AttributeValueList': [items[3]['b']], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
# Verify that describe_table lists the two GSIs.
|
||||
def test_gsi_4_describe(test_table_gsi_4):
|
||||
desc = test_table_gsi_4.meta.client.describe_table(TableName=test_table_gsi_4.name)
|
||||
assert 'Table' in desc
|
||||
assert 'GlobalSecondaryIndexes' in desc['Table']
|
||||
gsis = desc['Table']['GlobalSecondaryIndexes']
|
||||
assert len(gsis) == 2
|
||||
assert multiset([g['IndexName'] for g in gsis]) == multiset(['hello_a', 'hello_b'])
|
||||
|
||||
# A scenario for GSI in which the table has both hash and sort key
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_gsi_5(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'x', 'KeyType': 'RANGE' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
def test_gsi_5(test_table_gsi_5):
|
||||
items1 = [{'p': random_string(), 'c': random_string(), 'x': random_string()} for i in range(10)]
|
||||
p1, x1 = items1[0]['p'], items1[0]['x']
|
||||
p2, x2 = random_string(), random_string()
|
||||
items2 = [{'p': p2, 'c': random_string(), 'x': x2} for i in range(10)]
|
||||
items = items1 + items2
|
||||
with test_table_gsi_5.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
expected_items = [i for i in items if i['p'] == p1 and i['x'] == x1]
|
||||
assert_index_query(test_table_gsi_5, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p1], 'ComparisonOperator': 'EQ'},
|
||||
'x': {'AttributeValueList': [x1], 'ComparisonOperator': 'EQ'}})
|
||||
expected_items = [i for i in items if i['p'] == p2 and i['x'] == x2]
|
||||
assert_index_query(test_table_gsi_5, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p2], 'ComparisonOperator': 'EQ'},
|
||||
'x': {'AttributeValueList': [x2], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
# All tests above involved "ProjectionType: ALL". This test checks how
|
||||
# "ProjectionType:: KEYS_ONLY" works. We note that it projects both
|
||||
# the index's key, *and* the base table's key. So items which had different
|
||||
# base-table keys cannot suddenly become the same item in the index.
|
||||
@pytest.mark.xfail(reason="GSI not supported")
|
||||
def test_gsi_projection_keys_only(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'x', 'KeyType': 'HASH' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'KEYS_ONLY' }
|
||||
}
|
||||
])
|
||||
items = [{'p': random_string(), 'x': random_string(), 'y': random_string()} for i in range(10)]
|
||||
with table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
wanted = ['p', 'x']
|
||||
expected_items = [{k: x[k] for k in wanted if k in x} for x in items]
|
||||
assert_index_scan(table, 'hello', expected_items)
|
||||
table.delete()
|
||||
|
||||
# Test for "ProjectionType:: INCLUDE". The secondary table includes the
|
||||
# its own and the base's keys (as in KEYS_ONLY) plus the extra keys given
|
||||
# in NonKeyAttributes.
|
||||
@pytest.mark.xfail(reason="GSI not supported")
|
||||
def test_gsi_projection_include(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'x', 'KeyType': 'HASH' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'INCLUDE',
|
||||
'NonKeyAttributes': ['a', 'b'] }
|
||||
}
|
||||
])
|
||||
# Some items have the projected attributes a,b and some don't:
|
||||
items = [{'p': random_string(), 'x': random_string(), 'a': random_string(), 'b': random_string(), 'y': random_string()} for i in range(10)]
|
||||
items = items + [{'p': random_string(), 'x': random_string(), 'y': random_string()} for i in range(10)]
|
||||
with table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
wanted = ['p', 'x', 'a', 'b']
|
||||
expected_items = [{k: x[k] for k in wanted if k in x} for x in items]
|
||||
assert_index_scan(table, 'hello', expected_items)
|
||||
print(len(expected_items))
|
||||
table.delete()
|
||||
|
||||
# DynamoDB's says the "Projection" argument of GlobalSecondaryIndexes is
|
||||
# mandatory, and indeed Boto3 enforces that it must be passed. The
|
||||
# documentation then goes on to claim that the "ProjectionType" member of
|
||||
# "Projection" is optional - and Boto3 allows it to be missing. But in
|
||||
# fact, it is not allowed to be missing: DynamoDB complains: "Unknown
|
||||
# ProjectionType: null".
|
||||
@pytest.mark.xfail(reason="GSI not supported")
|
||||
def test_gsi_missing_projection_type(dynamodb):
|
||||
with pytest.raises(ClientError, match='ValidationException.*ProjectionType'):
|
||||
create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }],
|
||||
AttributeDefinitions=[{ 'AttributeName': 'p', 'AttributeType': 'S' }],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [{ 'AttributeName': 'p', 'KeyType': 'HASH' }],
|
||||
'Projection': {}
|
||||
}
|
||||
])
|
||||
|
||||
# update_table() for creating a GSI is an asynchronous operation.
|
||||
# The table's TableStatus changes from ACTIVE to UPDATING for a short while
|
||||
# and then goes back to ACTIVE, but the new GSI's IndexStatus appears as
|
||||
# CREATING, until eventually (after a *long* time...) it becomes ACTIVE.
|
||||
# During the CREATING phase, at some point the Backfilling attribute also
|
||||
# appears, until it eventually disappears. We need to wait until all three
|
||||
# markers indicate completion.
|
||||
# Unfortunately, while boto3 has a client.get_waiter('table_exists') to
|
||||
# wait for a table to exists, there is no such function to wait for an
|
||||
# index to come up, so we need to code it ourselves.
|
||||
def wait_for_gsi(table, gsi_name):
|
||||
start_time = time.time()
|
||||
# Surprisingly, even for tiny tables this can take a very long time
|
||||
# on DynamoDB - often many minutes!
|
||||
for i in range(300):
|
||||
time.sleep(1)
|
||||
desc = table.meta.client.describe_table(TableName=table.name)
|
||||
table_status = desc['Table']['TableStatus']
|
||||
if table_status != 'ACTIVE':
|
||||
print('%d Table status still %s' % (i, table_status))
|
||||
continue
|
||||
index_desc = [x for x in desc['Table']['GlobalSecondaryIndexes'] if x['IndexName'] == gsi_name]
|
||||
assert len(index_desc) == 1
|
||||
index_status = index_desc[0]['IndexStatus']
|
||||
if index_status != 'ACTIVE':
|
||||
print('%d Index status still %s' % (i, index_status))
|
||||
continue
|
||||
# When the index is ACTIVE, this must be after backfilling completed
|
||||
assert not 'Backfilling' in index_desc[0]
|
||||
print('wait_for_gsi took %d seconds' % (time.time() - start_time))
|
||||
return
|
||||
raise AssertionError("wait_for_gsi did not complete")
|
||||
|
||||
# Similarly to how wait_for_gsi() waits for a GSI to finish adding,
|
||||
# this function waits for a GSI to be finally deleted.
|
||||
def wait_for_gsi_gone(table, gsi_name):
|
||||
start_time = time.time()
|
||||
for i in range(300):
|
||||
time.sleep(1)
|
||||
desc = table.meta.client.describe_table(TableName=table.name)
|
||||
table_status = desc['Table']['TableStatus']
|
||||
if table_status != 'ACTIVE':
|
||||
print('%d Table status still %s' % (i, table_status))
|
||||
continue
|
||||
if 'GlobalSecondaryIndexes' in desc['Table']:
|
||||
index_desc = [x for x in desc['Table']['GlobalSecondaryIndexes'] if x['IndexName'] == gsi_name]
|
||||
if len(index_desc) != 0:
|
||||
index_status = index_desc[0]['IndexStatus']
|
||||
print('%d Index status still %s' % (i, index_status))
|
||||
continue
|
||||
print('wait_for_gsi_gone took %d seconds' % (time.time() - start_time))
|
||||
return
|
||||
raise AssertionError("wait_for_gsi_gone did not complete")
|
||||
|
||||
# All tests above involved creating a new table with a GSI up-front. This
|
||||
# test will test creating a base table *without* a GSI, putting data in
|
||||
# it, and then adding a GSI with the UpdateTable operation. This starts
|
||||
# a backfilling stage - where data is copied to the index - and when this
|
||||
# stage is done, the index is usable. Items whose indexed column contains
|
||||
# the wrong type are silently ignored and not added to the index (it would
|
||||
# not have been possible to add such items if the GSI was already configured
|
||||
# when they were added).
|
||||
@pytest.mark.xfail(reason="GSI not supported")
|
||||
def test_gsi_backfill(dynamodb):
|
||||
# First create, and fill, a table without GSI. The items in items1
|
||||
# will have the appropriate string type for 'x' and will later get
|
||||
# indexed. Items in item2 have no value for 'x', and in item3 'x' is in
|
||||
# not a string; So the items in items2 and items3 will be missing
|
||||
# in the index we'll create later.
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[ { 'AttributeName': 'p', 'AttributeType': 'S' } ])
|
||||
items1 = [{'p': random_string(), 'x': random_string(), 'y': random_string()} for i in range(10)]
|
||||
items2 = [{'p': random_string(), 'y': random_string()} for i in range(10)]
|
||||
items3 = [{'p': random_string(), 'x': i} for i in range(10)]
|
||||
items = items1 + items2 + items3
|
||||
with table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
assert multiset(items) == multiset(full_scan(table))
|
||||
# Now use UpdateTable to create the GSI
|
||||
dynamodb.meta.client.update_table(TableName=table.name,
|
||||
AttributeDefinitions=[{ 'AttributeName': 'x', 'AttributeType': 'S' }],
|
||||
GlobalSecondaryIndexUpdates=[ { 'Create':
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [{ 'AttributeName': 'x', 'KeyType': 'HASH' }],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}}])
|
||||
# update_table is an asynchronous operation. We need to wait until it
|
||||
# finishes and the table is backfilled.
|
||||
wait_for_gsi(table, 'hello')
|
||||
# As explained above, only items in items1 got copied to the gsi,
|
||||
# and Scan on them works as expected.
|
||||
# Note that we don't need to retry the reads here (i.e., use the
|
||||
# assert_index_scan() or assert_index_query() functions) because after
|
||||
# we waited for backfilling to complete, we know all the pre-existing
|
||||
# data is already in the index.
|
||||
assert multiset(items1) == multiset(full_scan(table, IndexName='hello'))
|
||||
# We can also use Query on the new GSI, to search on the attribute x:
|
||||
assert multiset([items1[3]]) == multiset(full_query(table,
|
||||
IndexName='hello',
|
||||
KeyConditions={'x': {'AttributeValueList': [items1[3]['x']], 'ComparisonOperator': 'EQ'}}))
|
||||
# Let's also test that we cannot add another index with the same name
|
||||
# that already exists
|
||||
with pytest.raises(ClientError, match='ValidationException.*already exists'):
|
||||
dynamodb.meta.client.update_table(TableName=table.name,
|
||||
AttributeDefinitions=[{ 'AttributeName': 'y', 'AttributeType': 'S' }],
|
||||
GlobalSecondaryIndexUpdates=[ { 'Create':
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [{ 'AttributeName': 'y', 'KeyType': 'HASH' }],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}}])
|
||||
table.delete()
|
||||
|
||||
# Test deleting an existing GSI using UpdateTable
|
||||
@pytest.mark.xfail(reason="GSI not supported")
|
||||
def test_gsi_delete(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'x', 'KeyType': 'HASH' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
items = [{'p': random_string(), 'x': random_string()} for i in range(10)]
|
||||
with table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
# So far, we have the index for "x" and can use it:
|
||||
assert_index_query(table, 'hello', [items[3]],
|
||||
KeyConditions={'x': {'AttributeValueList': [items[3]['x']], 'ComparisonOperator': 'EQ'}})
|
||||
# Now use UpdateTable to delete the GSI for "x"
|
||||
dynamodb.meta.client.update_table(TableName=table.name,
|
||||
GlobalSecondaryIndexUpdates=[{ 'Delete':
|
||||
{ 'IndexName': 'hello' } }])
|
||||
# update_table is an asynchronous operation. We need to wait until it
|
||||
# finishes and the GSI is removed.
|
||||
wait_for_gsi_gone(table, 'hello')
|
||||
# Now index is gone. We cannot query using it.
|
||||
with pytest.raises(ClientError, match='ValidationException.*hello'):
|
||||
full_query(table, IndexName='hello',
|
||||
KeyConditions={'x': {'AttributeValueList': [items[3]['x']], 'ComparisonOperator': 'EQ'}})
|
||||
table.delete()
|
||||
|
||||
# Utility function for creating a new table a GSI with the given name,
|
||||
# and, if creation was successful, delete it. Useful for testing which
|
||||
# GSI names work.
|
||||
def create_gsi(dynamodb, index_name):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }],
|
||||
AttributeDefinitions=[{ 'AttributeName': 'p', 'AttributeType': 'S' }],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': index_name,
|
||||
'KeySchema': [{ 'AttributeName': 'p', 'KeyType': 'HASH' }],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
# Verify that the GSI wasn't just ignored, as Scylla originally did ;-)
|
||||
assert 'GlobalSecondaryIndexes' in table.meta.client.describe_table(TableName=table.name)['Table']
|
||||
table.delete()
|
||||
|
||||
# Like table names (tested in test_table.py), index names must must also
|
||||
# be 3-255 characters and match the regex [a-zA-Z0-9._-]+. This test
|
||||
# is similar to test_create_table_unsupported_names(), but for GSI names.
|
||||
# Note that Scylla is actually more limited in the length of the index
|
||||
# names, because both table name and index name, together, have to fit in
|
||||
# 221 characters. But we don't verify here this specific limitation.
|
||||
def test_gsi_unsupported_names(dynamodb):
|
||||
# Unfortunately, the boto library tests for names shorter than the
|
||||
# minimum length (3 characters) immediately, and failure results in
|
||||
# ParamValidationError. But the other invalid names are passed to
|
||||
# DynamoDB, which returns an HTTP response code, which results in a
|
||||
# CientError exception.
|
||||
with pytest.raises(ParamValidationError):
|
||||
create_gsi(dynamodb, 'n')
|
||||
with pytest.raises(ParamValidationError):
|
||||
create_gsi(dynamodb, 'nn')
|
||||
with pytest.raises(ClientError, match='ValidationException.*nnnnn'):
|
||||
create_gsi(dynamodb, 'n' * 256)
|
||||
with pytest.raises(ClientError, match='ValidationException.*nyh'):
|
||||
create_gsi(dynamodb, 'nyh@test')
|
||||
|
||||
# On the other hand, names following the above rules should be accepted. Even
|
||||
# names which the Scylla rules forbid, such as a name starting with .
|
||||
def test_gsi_non_scylla_name(dynamodb):
|
||||
create_gsi(dynamodb, '.alternator_test')
|
||||
|
||||
# Index names with 255 characters are allowed in Dynamo. In Scylla, the
|
||||
# limit is different - the sum of both table and index length cannot
|
||||
# exceed 211 characters. So we test a much shorter limit.
|
||||
# (compare test_create_and_delete_table_very_long_name()).
|
||||
def test_gsi_very_long_name(dynamodb):
|
||||
#create_gsi(dynamodb, 'n' * 255) # works on DynamoDB, but not on Scylla
|
||||
create_gsi(dynamodb, 'n' * 190)
|
||||
|
||||
# Verify that ListTables does not list materialized views used for indexes.
|
||||
# This is hard to test, because we don't really know which table names
|
||||
# should be listed beyond those we created, and don't want to assume that
|
||||
# no other test runs in parallel with us. So the method we chose is to use a
|
||||
# unique random name for an index, and check that no table contains this
|
||||
# name. This assumes that materialized-view names are composed using the
|
||||
# index's name (which is currently what we do).
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_gsi_random_name(dynamodb):
|
||||
index_name = random_string()
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': index_name,
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'c', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'p', 'KeyType': 'RANGE' },
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
],
|
||||
)
|
||||
yield [table, index_name]
|
||||
table.delete()
|
||||
|
||||
def test_gsi_list_tables(dynamodb, test_table_gsi_random_name):
|
||||
table, index_name = test_table_gsi_random_name
|
||||
# Check that the random "index_name" isn't a substring of any table name:
|
||||
tables = list_tables(dynamodb)
|
||||
for name in tables:
|
||||
assert not index_name in name
|
||||
# But of course, the table's name should be in the list:
|
||||
assert table.name in tables
|
||||
402
alternator-test/test_item.py
Normal file
402
alternator-test/test_item.py
Normal file
@@ -0,0 +1,402 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the CRUD item operations: PutItem, GetItem, UpdateItem, DeleteItem
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from decimal import Decimal
|
||||
from util import random_string, random_bytes
|
||||
|
||||
# Basic test for creating a new item with a random name, and reading it back
|
||||
# with strong consistency.
|
||||
# Only the string type is used for keys and attributes. None of the various
|
||||
# optional PutItem features (Expected, ReturnValues, ReturnConsumedCapacity,
|
||||
# ReturnItemCollectionMetrics, ConditionalOperator, ConditionExpression,
|
||||
# ExpressionAttributeNames, ExpressionAttributeValues) are used, and
|
||||
# for GetItem strong consistency is requested as well as all attributes,
|
||||
# but no other optional features (AttributesToGet, ReturnConsumedCapacity,
|
||||
# ProjectionExpression, ExpressionAttributeNames)
|
||||
def test_basic_string_put_and_get(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
val = random_string()
|
||||
val2 = random_string()
|
||||
test_table.put_item(Item={'p': p, 'c': c, 'attribute': val, 'another': val2})
|
||||
item = test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item']
|
||||
assert item['p'] == p
|
||||
assert item['c'] == c
|
||||
assert item['attribute'] == val
|
||||
assert item['another'] == val2
|
||||
|
||||
# Similar to test_basic_string_put_and_get, just uses UpdateItem instead of
|
||||
# PutItem. Because the item does not yet exist, it should work the same.
|
||||
def test_basic_string_update_and_get(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
val = random_string()
|
||||
val2 = random_string()
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'attribute': {'Value': val, 'Action': 'PUT'}, 'another': {'Value': val2, 'Action': 'PUT'}})
|
||||
item = test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item']
|
||||
assert item['p'] == p
|
||||
assert item['c'] == c
|
||||
assert item['attribute'] == val
|
||||
assert item['another'] == val2
|
||||
|
||||
# Test put_item and get_item of various types for the *attributes*,
|
||||
# including both scalars as well as nested documents, lists and sets.
|
||||
# The full list of types tested here:
|
||||
# number, boolean, bytes, null, list, map, string set, number set,
|
||||
# binary set.
|
||||
# The keys are still strings.
|
||||
# Note that only top-level attributes are written and read in this test -
|
||||
# this test does not attempt to modify *nested* attributes.
|
||||
# See https://boto3.amazonaws.com/v1/documentation/api/latest/reference/customizations/dynamodb.html
|
||||
# on how to pass these various types to Boto3's put_item().
|
||||
def test_put_and_get_attribute_types(test_table):
|
||||
key = {'p': random_string(), 'c': random_string()}
|
||||
test_items = [
|
||||
Decimal("12.345"),
|
||||
42,
|
||||
True,
|
||||
False,
|
||||
b'xyz',
|
||||
None,
|
||||
['hello', 'world', 42],
|
||||
{'hello': 'world', 'life': 42},
|
||||
{'hello': {'test': 'hi', 'hello': True, 'list': [1, 2, 'hi']}},
|
||||
set(['hello', 'world', 'hi']),
|
||||
set([1, 42, Decimal("3.14")]),
|
||||
set([b'xyz', b'hi']),
|
||||
]
|
||||
item = { str(i) : test_items[i] for i in range(len(test_items)) }
|
||||
item.update(key)
|
||||
test_table.put_item(Item=item)
|
||||
got_item = test_table.get_item(Key=key, ConsistentRead=True)['Item']
|
||||
assert item == got_item
|
||||
|
||||
# The test_empty_* tests below verify support for empty items, with no
|
||||
# attributes except the key. This is a difficult case for Scylla, because
|
||||
# for an empty row to exist, Scylla needs to add a "CQL row marker".
|
||||
# There are several ways to create empty items - via PutItem, UpdateItem
|
||||
# and deleting attributes from non-empty items, and we need to check them
|
||||
# all, in several test_empty_* tests:
|
||||
def test_empty_put(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
test_table.put_item(Item={'p': p, 'c': c})
|
||||
item = test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item']
|
||||
assert item == {'p': p, 'c': c}
|
||||
def test_empty_put_delete(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
test_table.put_item(Item={'p': p, 'c': c, 'hello': 'world'})
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'hello': {'Action': 'DELETE'}})
|
||||
item = test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item']
|
||||
assert item == {'p': p, 'c': c}
|
||||
def test_empty_update(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={})
|
||||
item = test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item']
|
||||
assert item == {'p': p, 'c': c}
|
||||
def test_empty_update_delete(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'hello': {'Value': 'world', 'Action': 'PUT'}})
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'hello': {'Action': 'DELETE'}})
|
||||
item = test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item']
|
||||
assert item == {'p': p, 'c': c}
|
||||
|
||||
# Test error handling of UpdateItem passed a bad "Action" field.
|
||||
def test_update_bad_action(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
val = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'attribute': {'Value': val, 'Action': 'NONEXISTENT'}})
|
||||
|
||||
# A more elaborate UpdateItem test, updating different attributes at different
|
||||
# times. Includes PUT and DELETE operations.
|
||||
def test_basic_string_more_update(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
val1 = random_string()
|
||||
val2 = random_string()
|
||||
val3 = random_string()
|
||||
val4 = random_string()
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'a3': {'Value': val1, 'Action': 'PUT'}})
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'a1': {'Value': val1, 'Action': 'PUT'}})
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'a2': {'Value': val2, 'Action': 'PUT'}})
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'a1': {'Value': val3, 'Action': 'PUT'}})
|
||||
test_table.update_item(Key={'p': p, 'c': c}, AttributeUpdates={'a3': {'Action': 'DELETE'}})
|
||||
item = test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item']
|
||||
assert item['p'] == p
|
||||
assert item['c'] == c
|
||||
assert item['a1'] == val3
|
||||
assert item['a2'] == val2
|
||||
assert not 'a3' in item
|
||||
|
||||
# Test that item operations on a non-existant table name fail with correct
|
||||
# error code.
|
||||
def test_item_operations_nonexistent_table(dynamodb):
|
||||
with pytest.raises(ClientError, match='ResourceNotFoundException'):
|
||||
dynamodb.meta.client.put_item(TableName='non_existent_table',
|
||||
Item={'a':{'S':'b'}})
|
||||
|
||||
# Fetching a non-existant item. According to the DynamoDB doc, "If there is no
|
||||
# matching item, GetItem does not return any data and there will be no Item
|
||||
# element in the response."
|
||||
def test_get_item_missing_item(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
assert not "Item" in test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)
|
||||
|
||||
# Test that if we have a table with string hash and sort keys, we can't read
|
||||
# or write items with other key types to it.
|
||||
def test_put_item_wrong_key_type(test_table):
|
||||
b = random_bytes()
|
||||
s = random_string()
|
||||
n = Decimal("3.14")
|
||||
# Should succeed (correct key types)
|
||||
test_table.put_item(Item={'p': s, 'c': s})
|
||||
assert test_table.get_item(Key={'p': s, 'c': s}, ConsistentRead=True)['Item'] == {'p': s, 'c': s}
|
||||
# Should fail (incorrect hash key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.put_item(Item={'p': b, 'c': s})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.put_item(Item={'p': n, 'c': s})
|
||||
# Should fail (incorrect sort key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.put_item(Item={'p': s, 'c': b})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.put_item(Item={'p': s, 'c': n})
|
||||
# Should fail (missing hash key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.put_item(Item={'c': s})
|
||||
# Should fail (missing sort key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.put_item(Item={'p': s})
|
||||
def test_update_item_wrong_key_type(test_table, test_table_s):
|
||||
b = random_bytes()
|
||||
s = random_string()
|
||||
n = Decimal("3.14")
|
||||
# Should succeed (correct key types)
|
||||
test_table.update_item(Key={'p': s, 'c': s}, AttributeUpdates={})
|
||||
assert test_table.get_item(Key={'p': s, 'c': s}, ConsistentRead=True)['Item'] == {'p': s, 'c': s}
|
||||
# Should fail (incorrect hash key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.update_item(Key={'p': b, 'c': s}, AttributeUpdates={})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.update_item(Key={'p': n, 'c': s}, AttributeUpdates={})
|
||||
# Should fail (incorrect sort key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.update_item(Key={'p': s, 'c': b}, AttributeUpdates={})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.update_item(Key={'p': s, 'c': n}, AttributeUpdates={})
|
||||
# Should fail (missing hash key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.update_item(Key={'c': s}, AttributeUpdates={})
|
||||
# Should fail (missing sort key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.update_item(Key={'p': s}, AttributeUpdates={})
|
||||
# Should fail (spurious key columns)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'p': s, 'c': s, 'spurious': s})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': s, 'c': s})
|
||||
def test_get_item_wrong_key_type(test_table, test_table_s):
|
||||
b = random_bytes()
|
||||
s = random_string()
|
||||
n = Decimal("3.14")
|
||||
# Should succeed (correct key types) but have empty result
|
||||
assert not "Item" in test_table.get_item(Key={'p': s, 'c': s}, ConsistentRead=True)
|
||||
# Should fail (incorrect hash key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'p': b, 'c': s})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'p': n, 'c': s})
|
||||
# Should fail (incorrect sort key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'p': s, 'c': b})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'p': s, 'c': n})
|
||||
# Should fail (missing hash key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'c': s})
|
||||
# Should fail (missing sort key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'p': s})
|
||||
# Should fail (spurious key columns)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.get_item(Key={'p': s, 'c': s, 'spurious': s})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': s, 'c': s})
|
||||
def test_delete_item_wrong_key_type(test_table, test_table_s):
|
||||
b = random_bytes()
|
||||
s = random_string()
|
||||
n = Decimal("3.14")
|
||||
# Should succeed (correct key types)
|
||||
test_table.delete_item(Key={'p': s, 'c': s})
|
||||
# Should fail (incorrect hash key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.delete_item(Key={'p': b, 'c': s})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.delete_item(Key={'p': n, 'c': s})
|
||||
# Should fail (incorrect sort key types)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.delete_item(Key={'p': s, 'c': b})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.delete_item(Key={'p': s, 'c': n})
|
||||
# Should fail (missing hash key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.delete_item(Key={'c': s})
|
||||
# Should fail (missing sort key)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.delete_item(Key={'p': s})
|
||||
# Should fail (spurious key columns)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table.delete_item(Key={'p': s, 'c': s, 'spurious': s})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.delete_item(Key={'p': s, 'c': s})
|
||||
|
||||
# Most of the tests here arbitrarily used a table with both hash and sort keys
|
||||
# (both strings). Let's check that a table with *only* a hash key works ok
|
||||
# too, for PutItem, GetItem, and UpdateItem.
|
||||
def test_only_hash_key(test_table_s):
|
||||
s = random_string()
|
||||
test_table_s.put_item(Item={'p': s, 'hello': 'world'})
|
||||
assert test_table_s.get_item(Key={'p': s}, ConsistentRead=True)['Item'] == {'p': s, 'hello': 'world'}
|
||||
test_table_s.update_item(Key={'p': s}, AttributeUpdates={'hi': {'Value': 'there', 'Action': 'PUT'}})
|
||||
assert test_table_s.get_item(Key={'p': s}, ConsistentRead=True)['Item'] == {'p': s, 'hello': 'world', 'hi': 'there'}
|
||||
|
||||
# Tests for item operations in tables with non-string hash or sort keys.
|
||||
# These tests focus only on the type of the key - everything else is as
|
||||
# simple as we can (string attributes, no special options for GetItem
|
||||
# and PutItem). These tests also focus on individual items only, and
|
||||
# not about the sort order of sort keys - this should be verified in
|
||||
# test_query.py, for example.
|
||||
def test_bytes_hash_key(test_table_b):
|
||||
# Bytes values are passed using base64 encoding, which has weird cases
|
||||
# depending on len%3 and len%4. So let's try various lengths.
|
||||
for len in range(10,18):
|
||||
p = random_bytes(len)
|
||||
val = random_string()
|
||||
test_table_b.put_item(Item={'p': p, 'attribute': val})
|
||||
assert test_table_b.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'attribute': val}
|
||||
def test_bytes_sort_key(test_table_sb):
|
||||
p = random_string()
|
||||
c = random_bytes()
|
||||
val = random_string()
|
||||
test_table_sb.put_item(Item={'p': p, 'c': c, 'attribute': val})
|
||||
assert test_table_sb.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c, 'attribute': val}
|
||||
|
||||
# Tests for using a large binary blob as hash key, sort key, or attribute.
|
||||
# DynamoDB strictly limits the size of the binary hash key to 2048 bytes,
|
||||
# and binary sort key to 1024 bytes, and refuses anything larger. The total
|
||||
# size of an item is limited to 400KB, which also limits the size of the
|
||||
# largest attributes. For more details on these limits, see
|
||||
# https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html
|
||||
# Alternator currently does *not* have these limitations, and can accept much
|
||||
# larger keys and attributes, but what we do in the following tests is to verify
|
||||
# that items up to DynamoDB's maximum sizes also work well in Alternator.
|
||||
def test_large_blob_hash_key(test_table_b):
|
||||
b = random_bytes(2048)
|
||||
test_table_b.put_item(Item={'p': b})
|
||||
assert test_table_b.get_item(Key={'p': b}, ConsistentRead=True)['Item'] == {'p': b}
|
||||
def test_large_blob_sort_key(test_table_sb):
|
||||
s = random_string()
|
||||
b = random_bytes(1024)
|
||||
test_table_sb.put_item(Item={'p': s, 'c': b})
|
||||
assert test_table_sb.get_item(Key={'p': s, 'c': b}, ConsistentRead=True)['Item'] == {'p': s, 'c': b}
|
||||
def test_large_blob_attribute(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
b = random_bytes(409500) # a bit less than 400KB
|
||||
test_table.put_item(Item={'p': p, 'c': c, 'attribute': b })
|
||||
assert test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c, 'attribute': b}
|
||||
|
||||
# Checks what it is not allowed to use in a single UpdateItem request both
|
||||
# old-style AttributeUpdates and new-style UpdateExpression.
|
||||
def test_update_item_two_update_methods(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
AttributeUpdates={'a': {'Value': 3, 'Action': 'PUT'}},
|
||||
UpdateExpression='SET b = :val1',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
|
||||
# Verify that having neither AttributeUpdates nor UpdateExpression is
|
||||
# allowed, and results in creation of an empty item.
|
||||
def test_update_item_no_update_method(test_table_s):
|
||||
p = random_string()
|
||||
assert not "Item" in test_table_s.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
test_table_s.update_item(Key={'p': p})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p}
|
||||
|
||||
# Test GetItem with the AttributesToGet parameter. Result should include the
|
||||
# selected attributes only - if one wants the key attributes as well, one
|
||||
# needs to select them explicitly. When no key attributes are selected,
|
||||
# some items may have *none* of the selected attributes. Those items are
|
||||
# returned too, as empty items - they are not outright missing.
|
||||
def test_getitem_attributes_to_get(dynamodb, test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
item = {'p': p, 'c': c, 'a': 'hello', 'b': 'hi'}
|
||||
test_table.put_item(Item=item)
|
||||
for wanted in [ ['a'], # only non-key attribute
|
||||
['c', 'a'], # a key attribute (sort key) and non-key
|
||||
['p', 'c'], # entire key
|
||||
['nonexistent'] # Our item doesn't have this
|
||||
]:
|
||||
got_item = test_table.get_item(Key={'p': p, 'c': c}, AttributesToGet=wanted, ConsistentRead=True)['Item']
|
||||
expected_item = {k: item[k] for k in wanted if k in item}
|
||||
assert expected_item == got_item
|
||||
|
||||
# Basic test for DeleteItem, with hash key only
|
||||
def test_delete_item_hash(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p})
|
||||
assert 'Item' in test_table_s.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
test_table_s.delete_item(Key={'p': p})
|
||||
assert not 'Item' in test_table_s.get_item(Key={'p': p}, ConsistentRead=True)
|
||||
|
||||
# Basic test for DeleteItem, with hash and sort key
|
||||
def test_delete_item_sort(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
key = {'p': p, 'c': c}
|
||||
test_table.put_item(Item=key)
|
||||
assert 'Item' in test_table.get_item(Key=key, ConsistentRead=True)
|
||||
test_table.delete_item(Key=key)
|
||||
assert not 'Item' in test_table.get_item(Key=key, ConsistentRead=True)
|
||||
|
||||
# Test that PutItem completely replaces an existing item. It shouldn't merge
|
||||
# it with a previously existing value, as UpdateItem does!
|
||||
# We test for a table with just hash key, and for a table with both hash and
|
||||
# sort keys.
|
||||
def test_put_item_replace(test_table_s, test_table):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hi'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hi'}
|
||||
test_table_s.put_item(Item={'p': p, 'b': 'hello'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 'hello'}
|
||||
c = random_string()
|
||||
test_table.put_item(Item={'p': p, 'c': c, 'a': 'hi'})
|
||||
assert test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c, 'a': 'hi'}
|
||||
test_table.put_item(Item={'p': p, 'c': c, 'b': 'hello'})
|
||||
assert test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c, 'b': 'hello'}
|
||||
365
alternator-test/test_lsi.py
Normal file
365
alternator-test/test_lsi.py
Normal file
@@ -0,0 +1,365 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests of LSI (Local Secondary Indexes)
|
||||
#
|
||||
# Note that many of these tests are slower than usual, because many of them
|
||||
# need to create new tables and/or new LSIs of different types, operations
|
||||
# which are extremely slow in DynamoDB, often taking minutes (!).
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from botocore.exceptions import ClientError, ParamValidationError
|
||||
from util import create_test_table, random_string, full_scan, full_query, multiset, list_tables
|
||||
|
||||
# Currently, Alternator's LSIs only support eventually consistent reads, so tests
|
||||
# that involve writing to a table and then expect to read something from it cannot
|
||||
# be guaranteed to succeed without retrying the read. The following utility
|
||||
# functions make it easy to write such tests.
|
||||
def assert_index_query(table, index_name, expected_items, **kwargs):
|
||||
for i in range(3):
|
||||
if multiset(expected_items) == multiset(full_query(table, IndexName=index_name, **kwargs)):
|
||||
return
|
||||
print('assert_index_query retrying')
|
||||
time.sleep(1)
|
||||
assert multiset(expected_items) == multiset(full_query(table, IndexName=index_name, **kwargs))
|
||||
|
||||
def assert_index_scan(table, index_name, expected_items, **kwargs):
|
||||
for i in range(3):
|
||||
if multiset(expected_items) == multiset(full_scan(table, IndexName=index_name, **kwargs)):
|
||||
return
|
||||
print('assert_index_scan retrying')
|
||||
time.sleep(1)
|
||||
assert multiset(expected_items) == multiset(full_scan(table, IndexName=index_name, **kwargs))
|
||||
|
||||
# Although quite silly, it is actually allowed to create an index which is
|
||||
# identical to the base table.
|
||||
def test_lsi_identical(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' }],
|
||||
AttributeDefinitions=[{ 'AttributeName': 'p', 'AttributeType': 'S' }, { 'AttributeName': 'c', 'AttributeType': 'S' }],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [{ 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' }],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
items = [{'p': random_string(), 'c': random_string()} for i in range(10)]
|
||||
with table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
# Scanning the entire table directly or via the index yields the same
|
||||
# results (in different order).
|
||||
assert multiset(items) == multiset(full_scan(table))
|
||||
assert_index_scan(table, 'hello', items)
|
||||
# We can't scan a non-existant index
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
full_scan(table, IndexName='wrong')
|
||||
table.delete()
|
||||
|
||||
# Checks that providing a hash key different than the base table is not allowed,
|
||||
# and so is providing duplicated keys or no sort key at all
|
||||
def test_lsi_wrong(dynamodb):
|
||||
with pytest.raises(ClientError, match='ValidationException.*'):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'a', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'b', 'AttributeType': 'S' }
|
||||
],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'b', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'p', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
table.delete()
|
||||
with pytest.raises(ClientError, match='ValidationException.*'):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'a', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'b', 'AttributeType': 'S' }
|
||||
],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'p', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
table.delete()
|
||||
with pytest.raises(ClientError, match='ValidationException.*'):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'a', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'b', 'AttributeType': 'S' }
|
||||
],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
table.delete()
|
||||
|
||||
# A simple scenario for LSI. Base table has just hash key, Index has an
|
||||
# additional sort key - one of the non-key attributes from the base table.
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_lsi_1(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'b', 'AttributeType': 'S' },
|
||||
],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'b', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
}
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
def test_lsi_1(test_table_lsi_1):
|
||||
items1 = [{'p': random_string(), 'c': random_string(), 'b': random_string()} for i in range(10)]
|
||||
p1, b1 = items1[0]['p'], items1[0]['b']
|
||||
p2, b2 = random_string(), random_string()
|
||||
items2 = [{'p': p2, 'c': p2, 'b': b2}]
|
||||
items = items1 + items2
|
||||
with test_table_lsi_1.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
expected_items = [i for i in items if i['p'] == p1 and i['b'] == b1]
|
||||
assert_index_query(test_table_lsi_1, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p1], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b1], 'ComparisonOperator': 'EQ'}})
|
||||
expected_items = [i for i in items if i['p'] == p2 and i['b'] == b2]
|
||||
assert_index_query(test_table_lsi_1, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p2], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b2], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
# A second scenario of LSI. Base table has both hash and sort keys,
|
||||
# a local index is created on each non-key parameter
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_lsi_4(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x1', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x2', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x3', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x4', 'AttributeType': 'S' },
|
||||
],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello_' + column,
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': column, 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'ALL' }
|
||||
} for column in ['x1','x2','x3','x4']
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
def test_lsi_4(test_table_lsi_4):
|
||||
items1 = [{'p': random_string(), 'c': random_string(),
|
||||
'x1': random_string(), 'x2': random_string(), 'x3': random_string(), 'x4': random_string()} for i in range(10)]
|
||||
i_values = items1[0]
|
||||
i5 = random_string()
|
||||
items2 = [{'p': i5, 'c': i5, 'x1': i5, 'x2': i5, 'x3': i5, 'x4': i5}]
|
||||
items = items1 + items2
|
||||
with test_table_lsi_4.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for column in ['x1', 'x2', 'x3', 'x4']:
|
||||
expected_items = [i for i in items if (i['p'], i[column]) == (i_values['p'], i_values[column])]
|
||||
assert_index_query(test_table_lsi_4, 'hello_' + column, expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [i_values['p']], 'ComparisonOperator': 'EQ'},
|
||||
column: {'AttributeValueList': [i_values[column]], 'ComparisonOperator': 'EQ'}})
|
||||
expected_items = [i for i in items if (i['p'], i[column]) == (i5, i5)]
|
||||
assert_index_query(test_table_lsi_4, 'hello_' + column, expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [i5], 'ComparisonOperator': 'EQ'},
|
||||
column: {'AttributeValueList': [i5], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
def test_lsi_describe(test_table_lsi_4):
|
||||
desc = test_table_lsi_4.meta.client.describe_table(TableName=test_table_lsi_4.name)
|
||||
assert 'Table' in desc
|
||||
assert 'LocalSecondaryIndexes' in desc['Table']
|
||||
lsis = desc['Table']['LocalSecondaryIndexes']
|
||||
assert(sorted([lsi['IndexName'] for lsi in lsis]) == ['hello_x1', 'hello_x2', 'hello_x3', 'hello_x4'])
|
||||
# TODO: check projection and key params
|
||||
# TODO: check also ProvisionedThroughput, IndexArn
|
||||
|
||||
# A table with selective projection - only keys are projected into the index
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_lsi_keys_only(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'b', 'AttributeType': 'S' }
|
||||
],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'b', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'KEYS_ONLY' }
|
||||
}
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
# Check that it's possible to extract a non-projected attribute from the index,
|
||||
# as the documentation promises
|
||||
def test_lsi_get_not_projected_attribute(test_table_lsi_keys_only):
|
||||
items1 = [{'p': random_string(), 'c': random_string(), 'b': random_string(), 'd': random_string()} for i in range(10)]
|
||||
p1, b1, d1 = items1[0]['p'], items1[0]['b'], items1[0]['d']
|
||||
p2, b2, d2 = random_string(), random_string(), random_string()
|
||||
items2 = [{'p': p2, 'c': p2, 'b': b2, 'd': d2}]
|
||||
items = items1 + items2
|
||||
with test_table_lsi_keys_only.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
expected_items = [i for i in items if i['p'] == p1 and i['b'] == b1 and i['d'] == d1]
|
||||
assert_index_query(test_table_lsi_keys_only, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p1], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b1], 'ComparisonOperator': 'EQ'}},
|
||||
Select='ALL_ATTRIBUTES')
|
||||
expected_items = [i for i in items if i['p'] == p2 and i['b'] == b2 and i['d'] == d2]
|
||||
assert_index_query(test_table_lsi_keys_only, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p2], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b2], 'ComparisonOperator': 'EQ'}},
|
||||
Select='ALL_ATTRIBUTES')
|
||||
expected_items = [{'d': i['d']} for i in items if i['p'] == p2 and i['b'] == b2 and i['d'] == d2]
|
||||
assert_index_query(test_table_lsi_keys_only, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p2], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b2], 'ComparisonOperator': 'EQ'}},
|
||||
Select='SPECIFIC_ATTRIBUTES', AttributesToGet=['d'])
|
||||
|
||||
# Check that only projected attributes can be extracted
|
||||
@pytest.mark.xfail(reason="LSI in alternator currently only implement full projections")
|
||||
def test_lsi_get_all_projected_attributes(test_table_lsi_keys_only):
|
||||
items1 = [{'p': random_string(), 'c': random_string(), 'b': random_string(), 'd': random_string()} for i in range(10)]
|
||||
p1, b1, d1 = items1[0]['p'], items1[0]['b'], items1[0]['d']
|
||||
p2, b2, d2 = random_string(), random_string(), random_string()
|
||||
items2 = [{'p': p2, 'c': p2, 'b': b2, 'd': d2}]
|
||||
items = items1 + items2
|
||||
with test_table_lsi_keys_only.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
expected_items = [{'p': i['p'], 'c': i['c'],'b': i['b']} for i in items if i['p'] == p1 and i['b'] == b1]
|
||||
assert_index_query(test_table_lsi_keys_only, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p1], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b1], 'ComparisonOperator': 'EQ'}})
|
||||
|
||||
# Check that strongly consistent reads are allowed for LSI
|
||||
def test_lsi_consistent_read(test_table_lsi_1):
|
||||
items1 = [{'p': random_string(), 'c': random_string(), 'b': random_string()} for i in range(10)]
|
||||
p1, b1 = items1[0]['p'], items1[0]['b']
|
||||
p2, b2 = random_string(), random_string()
|
||||
items2 = [{'p': p2, 'c': p2, 'b': b2}]
|
||||
items = items1 + items2
|
||||
with test_table_lsi_1.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
expected_items = [i for i in items if i['p'] == p1 and i['b'] == b1]
|
||||
assert_index_query(test_table_lsi_1, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p1], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b1], 'ComparisonOperator': 'EQ'}},
|
||||
ConsistentRead=True)
|
||||
expected_items = [i for i in items if i['p'] == p2 and i['b'] == b2]
|
||||
assert_index_query(test_table_lsi_1, 'hello', expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p2], 'ComparisonOperator': 'EQ'},
|
||||
'b': {'AttributeValueList': [b2], 'ComparisonOperator': 'EQ'}},
|
||||
ConsistentRead=True)
|
||||
|
||||
# A table with both gsi and lsi present
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_lsi_gsi(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[ { 'AttributeName': 'p', 'KeyType': 'HASH' }, { 'AttributeName': 'c', 'KeyType': 'RANGE' } ],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'x1', 'AttributeType': 'S' },
|
||||
],
|
||||
GlobalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello_g1',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'x1', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'KEYS_ONLY' }
|
||||
}
|
||||
],
|
||||
LocalSecondaryIndexes=[
|
||||
{ 'IndexName': 'hello_l1',
|
||||
'KeySchema': [
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'x1', 'KeyType': 'RANGE' }
|
||||
],
|
||||
'Projection': { 'ProjectionType': 'KEYS_ONLY' }
|
||||
}
|
||||
])
|
||||
yield table
|
||||
table.delete()
|
||||
|
||||
# Test that GSI and LSI can coexist, even if they're identical
|
||||
def test_lsi_and_gsi(test_table_lsi_gsi):
|
||||
desc = test_table_lsi_gsi.meta.client.describe_table(TableName=test_table_lsi_gsi.name)
|
||||
assert 'Table' in desc
|
||||
assert 'LocalSecondaryIndexes' in desc['Table']
|
||||
assert 'GlobalSecondaryIndexes' in desc['Table']
|
||||
lsis = desc['Table']['LocalSecondaryIndexes']
|
||||
gsis = desc['Table']['GlobalSecondaryIndexes']
|
||||
assert(sorted([lsi['IndexName'] for lsi in lsis]) == ['hello_l1'])
|
||||
assert(sorted([gsi['IndexName'] for gsi in gsis]) == ['hello_g1'])
|
||||
|
||||
items = [{'p': random_string(), 'c': random_string(), 'x1': random_string()} for i in range(17)]
|
||||
p1, c1, x1 = items[0]['p'], items[0]['c'], items[0]['x1']
|
||||
with test_table_lsi_gsi.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
|
||||
for index in ['hello_g1', 'hello_l1']:
|
||||
expected_items = [i for i in items if i['p'] == p1 and i['x1'] == x1]
|
||||
assert_index_query(test_table_lsi_gsi, index, expected_items,
|
||||
KeyConditions={'p': {'AttributeValueList': [p1], 'ComparisonOperator': 'EQ'},
|
||||
'x1': {'AttributeValueList': [x1], 'ComparisonOperator': 'EQ'}})
|
||||
60
alternator-test/test_nested.py
Normal file
60
alternator-test/test_nested.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Test for operations on items with *nested* attributes.
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from util import random_string
|
||||
|
||||
# Test that we can write a top-level attribute that is a nested document, and
|
||||
# read it back correctly.
|
||||
def test_nested_document_attribute_write(test_table_s):
|
||||
nested_value = {
|
||||
'a': 3,
|
||||
'b': {'c': 'hello', 'd': ['hi', 'there', {'x': 'y'}, '42']},
|
||||
}
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': nested_value})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': nested_value}
|
||||
|
||||
# Test that if we have a top-level attribute that is a nested document (i.e.,
|
||||
# a dictionary), updating this attribute will replace it entirely by a new
|
||||
# nested document - not merge into the old content with the new content.
|
||||
def test_nested_document_attribute_overwrite(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': {'b': 3, 'c': 4}, 'd': 5})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': {'b': 3, 'c': 4}, 'd': 5}
|
||||
test_table_s.update_item(Key={'p': p}, AttributeUpdates={'a': {'Value': {'c': 5}, 'Action': 'PUT'}})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': {'c': 5}, 'd': 5}
|
||||
|
||||
# Moreover, we can overwrite an entire nested document by, say, a string,
|
||||
# and that's also fine.
|
||||
def test_nested_document_attribute_overwrite_2(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': {'b': 3, 'c': 4}, 'd': 5})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': {'b': 3, 'c': 4}, 'd': 5}
|
||||
test_table_s.update_item(Key={'p': p}, AttributeUpdates={'a': {'Value': 'hi', 'Action': 'PUT'}})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hi', 'd': 5}
|
||||
|
||||
# Verify that AttributeUpdates cannot be used to update a nested attribute -
|
||||
# trying to use a dot in the name of the attribute, will just create one with
|
||||
# an actual dot in its name.
|
||||
def test_attribute_updates_dot(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p}, AttributeUpdates={'a.b': {'Value': 3, 'Action': 'PUT'}})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a.b': 3}
|
||||
201
alternator-test/test_projection_expression.py
Normal file
201
alternator-test/test_projection_expression.py
Normal file
@@ -0,0 +1,201 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the various operations (GetItem, Query, Scan) with a
|
||||
# ProjectionExpression parameter.
|
||||
#
|
||||
# ProjectionExpression is an expension of the legacy AttributesToGet
|
||||
# parameter. Both parameters request that only a subset of the attributes
|
||||
# be fetched for each item, instead of all of them. But while AttributesToGet
|
||||
# was limited to top-level attributes, ProjectionExpression can request also
|
||||
# nested attributes.
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from util import random_string, full_scan, full_query, multiset
|
||||
|
||||
# Basic test for ProjectionExpression, requesting only top-level attributes.
|
||||
# Result should include the selected attributes only - if one wants the key
|
||||
# attributes as well, one needs to select them explicitly. When no key
|
||||
# attributes are selected, an item may have *none* of the selected
|
||||
# attributes, and returned as an empty item.
|
||||
def test_projection_expression_toplevel(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
item = {'p': p, 'c': c, 'a': 'hello', 'b': 'hi'}
|
||||
test_table.put_item(Item=item)
|
||||
for wanted in [ ['a'], # only non-key attribute
|
||||
['c', 'a'], # a key attribute (sort key) and non-key
|
||||
['p', 'c'], # entire key
|
||||
['nonexistent'] # Our item doesn't have this
|
||||
]:
|
||||
got_item = test_table.get_item(Key={'p': p, 'c': c}, ProjectionExpression=",".join(wanted), ConsistentRead=True)['Item']
|
||||
expected_item = {k: item[k] for k in wanted if k in item}
|
||||
assert expected_item == got_item
|
||||
|
||||
# Various simple tests for ProjectionExpression's syntax, using only top-evel
|
||||
# attributes.
|
||||
def test_projection_expression_toplevel_syntax(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello', 'b': 'hi'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a')['Item'] == {'a': 'hello'}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='#name', ExpressionAttributeNames={'#name': 'a'})['Item'] == {'a': 'hello'}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a,b')['Item'] == {'a': 'hello', 'b': 'hi'}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression=' a , b ')['Item'] == {'a': 'hello', 'b': 'hi'}
|
||||
# Missing or unused names in ExpressionAttributeNames are errors:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='#name', ExpressionAttributeNames={'#wrong': 'a'})['Item'] == {'a': 'hello'}
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='#name', ExpressionAttributeNames={'#name': 'a', '#unused': 'b'})['Item'] == {'a': 'hello'}
|
||||
# It is not allowed to fetch the same top-level attribute twice (or in
|
||||
# general, list two overlapping attributes). We get an error like
|
||||
# "Invalid ProjectionExpression: Two document paths overlap with each
|
||||
# other; must remove or rewrite one of these paths; path one: [a], path
|
||||
# two: [a]".
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a,a')['Item']
|
||||
# A comma with nothing after it is a syntax error:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a,')['Item']
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression=',a')['Item']
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a,,b')['Item']
|
||||
# An empty ProjectionExpression is not allowed. DynamoDB recognizes its
|
||||
# syntax, but then writes: "Invalid ProjectionExpression: The expression
|
||||
# can not be empty".
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='')['Item']
|
||||
|
||||
# The following two tests are similar to test_projection_expression_toplevel()
|
||||
# which tested the GetItem operation - but these test Scan and Query.
|
||||
# Both test ProjectionExpression with only top-level attributes.
|
||||
def test_projection_expression_scan(filled_test_table):
|
||||
table, items = filled_test_table
|
||||
for wanted in [ ['another'], # only non-key attributes (one item doesn't have it!)
|
||||
['c', 'another'], # a key attribute (sort key) and non-key
|
||||
['p', 'c'], # entire key
|
||||
['nonexistent'] # none of the items have this attribute!
|
||||
]:
|
||||
got_items = full_scan(table, ProjectionExpression=",".join(wanted))
|
||||
expected_items = [{k: x[k] for k in wanted if k in x} for x in items]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
def test_projection_expression_query(test_table):
|
||||
p = random_string()
|
||||
items = [{'p': p, 'c': str(i), 'a': str(i*10), 'b': str(i*100) } for i in range(10)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for wanted in [ ['a'], # only non-key attributes
|
||||
['c', 'a'], # a key attribute (sort key) and non-key
|
||||
['p', 'c'], # entire key
|
||||
['nonexistent'] # none of the items have this attribute!
|
||||
]:
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}}, ProjectionExpression=",".join(wanted))
|
||||
expected_items = [{k: x[k] for k in wanted if k in x} for x in items]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
# The previous tests all fetched only top-level attributes. They could all
|
||||
# be written using AttributesToGet instead of ProjectionExpression (and,
|
||||
# in fact, we do have similar tests with AttributesToGet in other files),
|
||||
# but the previous test checked that the alternative syntax works correctly.
|
||||
# The following test checks fetching more elaborate attribute paths from
|
||||
# nested documents.
|
||||
@pytest.mark.xfail(reason="ProjectionExpression does not yet support attribute paths")
|
||||
def test_projection_expression_path(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={
|
||||
'p': p,
|
||||
'a': {'b': [2, 4, {'x': 'hi', 'y': 'yo'}], 'c': 5},
|
||||
'b': 'hello'
|
||||
})
|
||||
# Fetching the entire nested document "a" works, of course:
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a')['Item'] == {'a': {'b': [2, 4, {'x': 'hi', 'y': 'yo'}], 'c': 5}}
|
||||
# If we fetch a.b, we get only the content of b - but it's still inside
|
||||
# the a dictionary:
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b')['Item'] == {'a': {'b': [2, 4, {'x': 'hi', 'y': 'yo'}]}}
|
||||
# Similarly, fetching a.b[0] gives us a one-element array in a dictionary.
|
||||
# Note that [0] is the first element of an array.
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[0]')['Item'] == {'a': {'b': [2]}}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[2]')['Item'] == {'a': {'b': [{'x': 'hi', 'y': 'yo'}]}}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[2].y')['Item'] == {'a': {'b': [{'y': 'yo'}]}}
|
||||
# Trying to read any sort of non-existant attribute returns an empty item.
|
||||
# This includes a non-existing top-level attribute, an attempt to read
|
||||
# beyond the end of an array or a non-existant member of a dictionary, as
|
||||
# well as paths which begin with a non-existant prefix.
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='x')['Item'] == {}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[3]')['Item'] == {}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.x')['Item'] == {}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.x.y')['Item'] == {}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[3].x')['Item'] == {}
|
||||
# We can read multiple paths - the result are merged into one object
|
||||
# structured the same was as in the original item:
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[0],a.b[1]')['Item'] == {'a': {'b': [2, 4]}}
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[0],a.c')['Item'] == {'a': {'b': [2], 'c': 5}}
|
||||
# It is not allowed to read the same path multiple times. The error from
|
||||
# DynamoDB looks like: "Invalid ProjectionExpression: Two document paths
|
||||
# overlap with each other; must remove or rewrite one of these paths;
|
||||
# path one: [a, b, [0]], path two: [a, b, [0]]".
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a.b[0],a.b[0]')['Item']
|
||||
# Two paths are considered to "overlap" if the content of one path
|
||||
# contains the content of the second path. So requesting both "a" and
|
||||
# "a.b[0]" is not allowed.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a,a.b[0]')['Item']
|
||||
|
||||
@pytest.mark.xfail(reason="ProjectionExpression does not yet support attribute paths")
|
||||
def test_query_projection_expression_path(test_table):
|
||||
p = random_string()
|
||||
items = [{'p': p, 'c': str(i), 'a': {'x': str(i*10), 'y': 'hi'}, 'b': 'hello' } for i in range(10)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}}, ProjectionExpression="a.x")
|
||||
expected_items = [{'a': {'x': x['a']['x']}} for x in items]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
@pytest.mark.xfail(reason="ProjectionExpression does not yet support attribute paths")
|
||||
def test_scan_projection_expression_path(test_table):
|
||||
# This test is similar to test_query_projection_expression_path above,
|
||||
# but uses a scan instead of a query. The scan will generate unrelated
|
||||
# partitions created by other tests (hopefully not too many...) that we
|
||||
# need to ignore. We also need to ask for "p" too, so we can filter by it.
|
||||
p = random_string()
|
||||
items = [{'p': p, 'c': str(i), 'a': {'x': str(i*10), 'y': 'hi'}, 'b': 'hello' } for i in range(10)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
got_items = [ x for x in full_scan(test_table, ProjectionExpression="p, a.x") if x['p'] == p]
|
||||
expected_items = [{'p': p, 'a': {'x': x['a']['x']}} for x in items]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
# It is not allowed to use both ProjectionExpression and its older cousin,
|
||||
# AttributesToGet, together. If trying to do this, DynamoDB produces an error
|
||||
# like "Can not use both expression and non-expression parameters in the same
|
||||
# request: Non-expression parameters: {AttributesToGet} Expression
|
||||
# parameters: {ProjectionExpression}
|
||||
def test_projection_expression_and_attributes_to_get(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello', 'b': 'hi'})
|
||||
with pytest.raises(ClientError, match='ValidationException.*both'):
|
||||
test_table_s.get_item(Key={'p': p}, ConsistentRead=True, ProjectionExpression='a', AttributesToGet=['b'])['Item']
|
||||
with pytest.raises(ClientError, match='ValidationException.*both'):
|
||||
full_scan(test_table_s, ProjectionExpression='a', AttributesToGet=['a'])
|
||||
with pytest.raises(ClientError, match='ValidationException.*both'):
|
||||
full_query(test_table_s, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}}, ProjectionExpression='a', AttributesToGet=['a'])
|
||||
358
alternator-test/test_query.py
Normal file
358
alternator-test/test_query.py
Normal file
@@ -0,0 +1,358 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the Query operation
|
||||
|
||||
import random
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from decimal import Decimal
|
||||
from util import random_string, random_bytes, full_query, multiset
|
||||
from boto3.dynamodb.conditions import Key, Attr
|
||||
|
||||
# Test that scanning works fine with in-stock paginator
|
||||
def test_query_basic_restrictions(dynamodb, filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
paginator = dynamodb.meta.client.get_paginator('query')
|
||||
|
||||
# EQ
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long']) == multiset(got_items)
|
||||
|
||||
# LT
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': ['12'], 'ComparisonOperator': 'LT'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['c'] < '12']) == multiset(got_items)
|
||||
|
||||
# LE
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': ['14'], 'ComparisonOperator': 'LE'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['c'] <= '14']) == multiset(got_items)
|
||||
|
||||
# GT
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': ['15'], 'ComparisonOperator': 'GT'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['c'] > '15']) == multiset(got_items)
|
||||
|
||||
# GE
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': ['14'], 'ComparisonOperator': 'GE'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['c'] >= '14']) == multiset(got_items)
|
||||
|
||||
# BETWEEN
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': ['155', '164'], 'ComparisonOperator': 'BETWEEN'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['c'] >= '155' and item['c'] <= '164']) == multiset(got_items)
|
||||
|
||||
# BEGINS_WITH
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': ['11'], 'ComparisonOperator': 'BEGINS_WITH'}
|
||||
}):
|
||||
print([item for item in items if item['p'] == 'long' and item['c'].startswith('11')])
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['c'].startswith('11')]) == multiset(got_items)
|
||||
|
||||
# Test that KeyConditionExpression parameter is supported
|
||||
@pytest.mark.xfail(reason="KeyConditionExpression not supported yet")
|
||||
def test_query_key_condition_expression(dynamodb, filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
paginator = dynamodb.meta.client.get_paginator('query')
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditionExpression=Key("p").eq("long") & Key("c").lt("12")):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['c'] < '12']) == multiset(got_items)
|
||||
|
||||
def test_begins_with(dynamodb, test_table):
|
||||
paginator = dynamodb.meta.client.get_paginator('query')
|
||||
items = [{'p': 'unorthodox_chars', 'c': sort_key, 'str': 'a'} for sort_key in [u'ÿÿÿ', u'cÿbÿ', u'cÿbÿÿabg'] ]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
|
||||
# TODO(sarna): Once bytes type is supported, /xFF character should be tested
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['unorthodox_chars'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': [u'ÿÿ'], 'ComparisonOperator': 'BEGINS_WITH'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert sorted([d['c'] for d in got_items]) == sorted([d['c'] for d in items if d['c'].startswith(u'ÿÿ')])
|
||||
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['unorthodox_chars'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': [u'cÿbÿ'], 'ComparisonOperator': 'BEGINS_WITH'}
|
||||
}):
|
||||
got_items += page['Items']
|
||||
print(got_items)
|
||||
assert sorted([d['c'] for d in got_items]) == sorted([d['c'] for d in items if d['c'].startswith(u'cÿbÿ')])
|
||||
|
||||
def test_begins_with_wrong_type(dynamodb, test_table_sn):
|
||||
paginator = dynamodb.meta.client.get_paginator('query')
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
for page in paginator.paginate(TableName=test_table_sn.name, KeyConditions={
|
||||
'p' : {'AttributeValueList': ['unorthodox_chars'], 'ComparisonOperator': 'EQ'},
|
||||
'c' : {'AttributeValueList': [17], 'ComparisonOperator': 'BEGINS_WITH'}
|
||||
}):
|
||||
pass
|
||||
|
||||
# Items returned by Query should be sorted by the sort key. The following
|
||||
# tests verify that this is indeed the case, for the three allowed key types:
|
||||
# strings, binary, and numbers. These tests test not just the Query operation,
|
||||
# but inherently that the sort-key sorting works.
|
||||
def test_query_sort_order_string(test_table):
|
||||
# Insert a lot of random items in one new partition:
|
||||
# str(i) has a non-obvious sort order (e.g., "100" comes before "2") so is a nice test.
|
||||
p = random_string()
|
||||
items = [{'p': p, 'c': str(i)} for i in range(128)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}})
|
||||
assert len(items) == len(got_items)
|
||||
# Extract just the sort key ("c") from the items
|
||||
sort_keys = [x['c'] for x in items]
|
||||
got_sort_keys = [x['c'] for x in got_items]
|
||||
# Verify that got_sort_keys are already sorted (in string order)
|
||||
assert sorted(got_sort_keys) == got_sort_keys
|
||||
# Verify that got_sort_keys are a sorted version of the expected sort_keys
|
||||
assert sorted(sort_keys) == got_sort_keys
|
||||
def test_query_sort_order_bytes(test_table_sb):
|
||||
# Insert a lot of random items in one new partition:
|
||||
# We arbitrarily use random_bytes with a random length.
|
||||
p = random_string()
|
||||
items = [{'p': p, 'c': random_bytes(10)} for i in range(128)]
|
||||
with test_table_sb.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
got_items = full_query(test_table_sb, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}})
|
||||
assert len(items) == len(got_items)
|
||||
sort_keys = [x['c'] for x in items]
|
||||
got_sort_keys = [x['c'] for x in got_items]
|
||||
# Boto3's "Binary" objects are sorted as if bytes are signed integers.
|
||||
# This isn't the order that DynamoDB itself uses (byte 0 should be first,
|
||||
# not byte -128). Sorting the byte array ".value" works.
|
||||
assert sorted(got_sort_keys, key=lambda x: x.value) == got_sort_keys
|
||||
assert sorted(sort_keys) == got_sort_keys
|
||||
def test_query_sort_order_number(test_table_sn):
|
||||
# This is a list of numbers, sorted in correct order, and each suitable
|
||||
# for accurate representation by Alternator's number type.
|
||||
numbers = [
|
||||
Decimal("-2e10"),
|
||||
Decimal("-7.1e2"),
|
||||
Decimal("-4.1"),
|
||||
Decimal("-0.1"),
|
||||
Decimal("-1e-5"),
|
||||
Decimal("0"),
|
||||
Decimal("2e-5"),
|
||||
Decimal("0.15"),
|
||||
Decimal("1"),
|
||||
Decimal("1.00000000000000000000000001"),
|
||||
Decimal("3.14159"),
|
||||
Decimal("3.1415926535897932384626433832795028841"),
|
||||
Decimal("31.4"),
|
||||
Decimal("1.4e10"),
|
||||
]
|
||||
# Insert these numbers, in random order, into one partition:
|
||||
p = random_string()
|
||||
items = [{'p': p, 'c': num} for num in random.sample(numbers, len(numbers))]
|
||||
with test_table_sn.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
# Finally, verify that we get back exactly the same numbers (with identical
|
||||
# precision), and in their original sorted order.
|
||||
got_items = full_query(test_table_sn, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}})
|
||||
got_sort_keys = [x['c'] for x in got_items]
|
||||
assert got_sort_keys == numbers
|
||||
|
||||
def test_query_filtering_attributes_equality(filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
|
||||
query_filter = {
|
||||
"attribute" : {
|
||||
"AttributeValueList" : [ "xxxx" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'}}, QueryFilter=query_filter)
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['attribute'] == 'xxxx']) == multiset(got_items)
|
||||
|
||||
query_filter = {
|
||||
"attribute" : {
|
||||
"AttributeValueList" : [ "xxxx" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
},
|
||||
"another" : {
|
||||
"AttributeValueList" : [ "yy" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'}}, QueryFilter=query_filter)
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['attribute'] == 'xxxx' and item['another'] == 'yy']) == multiset(got_items)
|
||||
|
||||
# Test that FilterExpression works as expected
|
||||
@pytest.mark.xfail(reason="FilterExpression not supported yet")
|
||||
def test_query_filter_expression(filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'}}, FilterExpression=Attr("attribute").eq("xxxx"))
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['attribute'] == 'xxxx']) == multiset(got_items)
|
||||
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'}}, FilterExpression=Attr("attribute").eq("xxxx") & Attr("another").eq("yy"))
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if item['p'] == 'long' and item['attribute'] == 'xxxx' and item['another'] == 'yy']) == multiset(got_items)
|
||||
|
||||
# QueryFilter can only contain non-key attributes in order to be compatible
|
||||
def test_query_filtering_key_equality(filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
query_filter = {
|
||||
"c" : {
|
||||
"AttributeValueList" : [ "5" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'}}, QueryFilter=query_filter)
|
||||
print(got_items)
|
||||
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
query_filter = {
|
||||
"attribute" : {
|
||||
"AttributeValueList" : [ "x" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
},
|
||||
"p" : {
|
||||
"AttributeValueList" : [ "5" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': ['long'], 'ComparisonOperator': 'EQ'}}, QueryFilter=query_filter)
|
||||
print(got_items)
|
||||
|
||||
# Test Query with the AttributesToGet parameter. Result should include the
|
||||
# selected attributes only - if one wants the key attributes as well, one
|
||||
# needs to select them explicitly. When no key attributes are selected,
|
||||
# some items may have *none* of the selected attributes. Those items are
|
||||
# returned too, as empty items - they are not outright missing.
|
||||
def test_query_attributes_to_get(dynamodb, test_table):
|
||||
p = random_string()
|
||||
items = [{'p': p, 'c': str(i), 'a': str(i*10), 'b': str(i*100) } for i in range(10)]
|
||||
with test_table.batch_writer() as batch:
|
||||
for item in items:
|
||||
batch.put_item(item)
|
||||
for wanted in [ ['a'], # only non-key attributes
|
||||
['c', 'a'], # a key attribute (sort key) and non-key
|
||||
['p', 'c'], # entire key
|
||||
['nonexistent'] # none of the items have this attribute!
|
||||
]:
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}}, AttributesToGet=wanted)
|
||||
expected_items = [{k: x[k] for k in wanted if k in x} for x in items]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
# Test that in a table with both hash key and sort key, which keys we can
|
||||
# Query by: We can Query by the hash key, by a combination of both hash and
|
||||
# sort keys, but *cannot* query by just the sort key, and obviously not
|
||||
# by any non-key column.
|
||||
def test_query_which_key(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
p2 = random_string()
|
||||
c2 = random_string()
|
||||
item1 = {'p': p, 'c': c}
|
||||
item2 = {'p': p, 'c': c2}
|
||||
item3 = {'p': p2, 'c': c}
|
||||
for i in [item1, item2, item3]:
|
||||
test_table.put_item(Item=i)
|
||||
# Query by hash key only:
|
||||
got_items = full_query(test_table, KeyConditions={'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'}})
|
||||
expected_items = [item1, item2]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
# Query by hash key *and* sort key (this is basically a GetItem):
|
||||
got_items = full_query(test_table, KeyConditions={
|
||||
'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'},
|
||||
'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}
|
||||
})
|
||||
expected_items = [item1]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
# Query by sort key alone is not allowed. DynamoDB reports:
|
||||
# "Query condition missed key schema element: p".
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
full_query(test_table, KeyConditions={
|
||||
'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}
|
||||
})
|
||||
# Query by a non-key isn't allowed, for the same reason - that the
|
||||
# actual hash key (p) is missing in the query:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
full_query(test_table, KeyConditions={
|
||||
'z': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}
|
||||
})
|
||||
# If we try both p and a non-key we get a complaint that the sort
|
||||
# key is missing: "Query condition missed key schema element: c"
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
full_query(test_table, KeyConditions={
|
||||
'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'},
|
||||
'z': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}
|
||||
})
|
||||
# If we try p, c and another key, we get an error that
|
||||
# "Conditions can be of length 1 or 2 only".
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
full_query(test_table, KeyConditions={
|
||||
'p': {'AttributeValueList': [p], 'ComparisonOperator': 'EQ'},
|
||||
'c': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'},
|
||||
'z': {'AttributeValueList': [c], 'ComparisonOperator': 'EQ'}
|
||||
})
|
||||
191
alternator-test/test_scan.py
Normal file
191
alternator-test/test_scan.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the Scan operation
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from util import random_string, full_scan, multiset
|
||||
from boto3.dynamodb.conditions import Attr
|
||||
|
||||
# Test that scanning works fine with/without pagination
|
||||
def test_scan_basic(filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
for limit in [None,1,2,4,33,50,100,9007,16*1024*1024]:
|
||||
pos = None
|
||||
got_items = []
|
||||
while True:
|
||||
if limit:
|
||||
response = test_table.scan(Limit=limit, ExclusiveStartKey=pos) if pos else test_table.scan(Limit=limit)
|
||||
assert len(response['Items']) <= limit
|
||||
else:
|
||||
response = test_table.scan(ExclusiveStartKey=pos) if pos else test_table.scan()
|
||||
pos = response.get('LastEvaluatedKey', None)
|
||||
got_items += response['Items']
|
||||
if not pos:
|
||||
break
|
||||
|
||||
assert len(items) == len(got_items)
|
||||
assert multiset(items) == multiset(got_items)
|
||||
|
||||
def test_scan_with_paginator(dynamodb, filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
paginator = dynamodb.meta.client.get_paginator('scan')
|
||||
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name):
|
||||
got_items += page['Items']
|
||||
|
||||
assert len(items) == len(got_items)
|
||||
assert multiset(items) == multiset(got_items)
|
||||
|
||||
for page_size in [1, 17, 1234]:
|
||||
got_items = []
|
||||
for page in paginator.paginate(TableName=test_table.name, PaginationConfig={'PageSize': page_size}):
|
||||
got_items += page['Items']
|
||||
|
||||
assert len(items) == len(got_items)
|
||||
assert multiset(items) == multiset(got_items)
|
||||
|
||||
# Although partitions are scanned in seemingly-random order, inside a
|
||||
# partition items must be returned by Scan sorted in sort-key order.
|
||||
# This test verifies this, for string sort key. We'll need separate
|
||||
# tests for the other sort-key types (number and binary)
|
||||
def test_scan_sort_order_string(filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
got_items = full_scan(test_table)
|
||||
assert len(items) == len(got_items)
|
||||
# Extract just the sort key ("c") from the partition "long"
|
||||
items_long = [x['c'] for x in items if x['p'] == 'long']
|
||||
got_items_long = [x['c'] for x in got_items if x['p'] == 'long']
|
||||
# Verify that got_items_long are already sorted (in string order)
|
||||
assert sorted(got_items_long) == got_items_long
|
||||
# Verify that got_items_long are a sorted version of the expected items_long
|
||||
assert sorted(items_long) == got_items_long
|
||||
|
||||
# Test Scan with the AttributesToGet parameter. Result should include the
|
||||
# selected attributes only - if one wants the key attributes as well, one
|
||||
# needs to select them explicitly. When no key attributes are selected,
|
||||
# some items may have *none* of the selected attributes. Those items are
|
||||
# returned too, as empty items - they are not outright missing.
|
||||
def test_scan_attributes_to_get(dynamodb, filled_test_table):
|
||||
table, items = filled_test_table
|
||||
for wanted in [ ['another'], # only non-key attributes (one item doesn't have it!)
|
||||
['c', 'another'], # a key attribute (sort key) and non-key
|
||||
['p', 'c'], # entire key
|
||||
['nonexistent'] # none of the items have this attribute!
|
||||
]:
|
||||
print(wanted)
|
||||
got_items = full_scan(table, AttributesToGet=wanted)
|
||||
expected_items = [{k: x[k] for k in wanted if k in x} for x in items]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
def test_scan_with_attribute_equality_filtering(dynamodb, filled_test_table):
|
||||
table, items = filled_test_table
|
||||
scan_filter = {
|
||||
"attribute" : {
|
||||
"AttributeValueList" : [ "xxxxx" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
|
||||
got_items = full_scan(table, ScanFilter=scan_filter)
|
||||
expected_items = [item for item in items if "attribute" in item.keys() and item["attribute"] == "xxxxx" ]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
scan_filter = {
|
||||
"another" : {
|
||||
"AttributeValueList" : [ "y" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
},
|
||||
"attribute" : {
|
||||
"AttributeValueList" : [ "xxxxx" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
|
||||
got_items = full_scan(table, ScanFilter=scan_filter)
|
||||
expected_items = [item for item in items if "attribute" in item.keys() and item["attribute"] == "xxxxx" and item["another"] == "y" ]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
# Test that FilterExpression works as expected
|
||||
@pytest.mark.xfail(reason="FilterExpression not supported yet")
|
||||
def test_scan_filter_expression(filled_test_table):
|
||||
test_table, items = filled_test_table
|
||||
|
||||
got_items = full_scan(test_table, FilterExpression=Attr("attribute").eq("xxxx"))
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if 'attribute' in item.keys() and item['attribute'] == 'xxxx']) == multiset(got_items)
|
||||
|
||||
got_items = full_scan(test_table, FilterExpression=Attr("attribute").eq("xxxx") & Attr("another").eq("yy"))
|
||||
print(got_items)
|
||||
assert multiset([item for item in items if 'attribute' in item.keys() and 'another' in item.keys() and item['attribute'] == 'xxxx' and item['another'] == 'yy']) == multiset(got_items)
|
||||
|
||||
def test_scan_with_key_equality_filtering(dynamodb, filled_test_table):
|
||||
table, items = filled_test_table
|
||||
scan_filter_p = {
|
||||
"p" : {
|
||||
"AttributeValueList" : [ "7" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
scan_filter_c = {
|
||||
"c" : {
|
||||
"AttributeValueList" : [ "9" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
scan_filter_p_and_attribute = {
|
||||
"p" : {
|
||||
"AttributeValueList" : [ "7" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
},
|
||||
"attribute" : {
|
||||
"AttributeValueList" : [ "x"*7 ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
scan_filter_c_and_another = {
|
||||
"c" : {
|
||||
"AttributeValueList" : [ "9" ],
|
||||
"ComparisonOperator": "EQ"
|
||||
},
|
||||
"another" : {
|
||||
"AttributeValueList" : [ "y"*16 ],
|
||||
"ComparisonOperator": "EQ"
|
||||
}
|
||||
}
|
||||
|
||||
# Filtering on the hash key
|
||||
got_items = full_scan(table, ScanFilter=scan_filter_p)
|
||||
expected_items = [item for item in items if "p" in item.keys() and item["p"] == "7" ]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
# Filtering on the sort key
|
||||
got_items = full_scan(table, ScanFilter=scan_filter_c)
|
||||
expected_items = [item for item in items if "c" in item.keys() and item["c"] == "9"]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
# Filtering on the hash key and an attribute
|
||||
got_items = full_scan(table, ScanFilter=scan_filter_p_and_attribute)
|
||||
expected_items = [item for item in items if "p" in item.keys() and "another" in item.keys() and item["p"] == "7" and item["another"] == "y"*16]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
|
||||
# Filtering on the sort key and an attribute
|
||||
got_items = full_scan(table, ScanFilter=scan_filter_c_and_another)
|
||||
expected_items = [item for item in items if "c" in item.keys() and "another" in item.keys() and item["c"] == "9" and item["another"] == "y"*16]
|
||||
assert multiset(expected_items) == multiset(got_items)
|
||||
276
alternator-test/test_table.py
Normal file
276
alternator-test/test_table.py
Normal file
@@ -0,0 +1,276 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for basic table operations: CreateTable, DeleteTable, ListTables.
|
||||
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from util import list_tables, test_table_name, create_test_table, random_string
|
||||
|
||||
# Utility function for create a table with a given name and some valid
|
||||
# schema.. This function initiates the table's creation, but doesn't
|
||||
# wait for the table to actually become ready.
|
||||
def create_table(dynamodb, name, BillingMode='PAY_PER_REQUEST', **kwargs):
|
||||
return dynamodb.create_table(
|
||||
TableName=name,
|
||||
BillingMode=BillingMode,
|
||||
KeySchema=[
|
||||
{
|
||||
'AttributeName': 'p',
|
||||
'KeyType': 'HASH'
|
||||
},
|
||||
{
|
||||
'AttributeName': 'c',
|
||||
'KeyType': 'RANGE'
|
||||
}
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{
|
||||
'AttributeName': 'p',
|
||||
'AttributeType': 'S'
|
||||
},
|
||||
{
|
||||
'AttributeName': 'c',
|
||||
'AttributeType': 'S'
|
||||
},
|
||||
],
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# Utility function for creating a table with a given name, and then deleting
|
||||
# it immediately, waiting for these operations to complete. Since the wait
|
||||
# uses DescribeTable, this function requires all of CreateTable, DescribeTable
|
||||
# and DeleteTable to work correctly.
|
||||
# Note that in DynamoDB, table deletion takes a very long time, so tests
|
||||
# successfully using this function are very slow.
|
||||
def create_and_delete_table(dynamodb, name, **kwargs):
|
||||
table = create_table(dynamodb, name, **kwargs)
|
||||
table.meta.client.get_waiter('table_exists').wait(TableName=name)
|
||||
table.delete()
|
||||
table.meta.client.get_waiter('table_not_exists').wait(TableName=name)
|
||||
|
||||
##############################################################################
|
||||
|
||||
# Test creating a table, and then deleting it, waiting for each operation
|
||||
# to have completed before proceeding. Since the wait uses DescribeTable,
|
||||
# this tests requires all of CreateTable, DescribeTable and DeleteTable to
|
||||
# function properly in their basic use cases.
|
||||
# Unfortunately, this test is extremely slow with DynamoDB because deleting
|
||||
# a table is extremely slow until it really happens.
|
||||
def test_create_and_delete_table(dynamodb):
|
||||
create_and_delete_table(dynamodb, 'alternator_test')
|
||||
|
||||
# DynamoDB documentation specifies that table names must be 3-255 characters,
|
||||
# and match the regex [a-zA-Z0-9._-]+. Names not matching these rules should
|
||||
# be rejected, and no table be created.
|
||||
def test_create_table_unsupported_names(dynamodb):
|
||||
from botocore.exceptions import ParamValidationError, ClientError
|
||||
# Intererstingly, the boto library tests for names shorter than the
|
||||
# minimum length (3 characters) immediately, and failure results in
|
||||
# ParamValidationError. But the other invalid names are passed to
|
||||
# DynamoDB, which returns an HTTP response code, which results in a
|
||||
# CientError exception.
|
||||
with pytest.raises(ParamValidationError):
|
||||
create_table(dynamodb, 'n')
|
||||
with pytest.raises(ParamValidationError):
|
||||
create_table(dynamodb, 'nn')
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
create_table(dynamodb, 'n' * 256)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
create_table(dynamodb, 'nyh@test')
|
||||
|
||||
# On the other hand, names following the above rules should be accepted. Even
|
||||
# names which the Scylla rules forbid, such as a name starting with .
|
||||
def test_create_and_delete_table_non_scylla_name(dynamodb):
|
||||
create_and_delete_table(dynamodb, '.alternator_test')
|
||||
|
||||
# names with 255 characters are allowed in Dynamo, but they are not currently
|
||||
# supported in Scylla because we create a directory whose name is the table's
|
||||
# name followed by 33 bytes (underscore and UUID). So currently, we only
|
||||
# correctly support names with length up to 222.
|
||||
def test_create_and_delete_table_very_long_name(dynamodb):
|
||||
# In the future, this should work:
|
||||
#create_and_delete_table(dynamodb, 'n' * 255)
|
||||
# But for now, only 222 works:
|
||||
create_and_delete_table(dynamodb, 'n' * 222)
|
||||
# We cannot test the following on DynamoDB because it will succeed
|
||||
# (DynamoDB allows up to 255 bytes)
|
||||
#with pytest.raises(ClientError, match='ValidationException'):
|
||||
# create_table(dynamodb, 'n' * 223)
|
||||
|
||||
# Tests creating a table with an invalid schema should return a
|
||||
# ValidationException error.
|
||||
def test_create_table_invalid_schema(dynamodb):
|
||||
# The name of the table "created" by this test shouldn't matter, the
|
||||
# creation should not succeed anyway.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.create_table(
|
||||
TableName='name_doesnt_matter',
|
||||
BillingMode='PAY_PER_REQUEST',
|
||||
KeySchema=[
|
||||
{ 'AttributeName': 'p', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'HASH' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
],
|
||||
)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.create_table(
|
||||
TableName='name_doesnt_matter',
|
||||
BillingMode='PAY_PER_REQUEST',
|
||||
KeySchema=[
|
||||
{ 'AttributeName': 'p', 'KeyType': 'RANGE' },
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
],
|
||||
)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.create_table(
|
||||
TableName='name_doesnt_matter',
|
||||
BillingMode='PAY_PER_REQUEST',
|
||||
KeySchema=[
|
||||
{ 'AttributeName': 'c', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
],
|
||||
)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.create_table(
|
||||
TableName='name_doesnt_matter',
|
||||
BillingMode='PAY_PER_REQUEST',
|
||||
KeySchema=[
|
||||
{ 'AttributeName': 'c', 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': 'p', 'KeyType': 'RANGE' },
|
||||
{ 'AttributeName': 'z', 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'c', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'p', 'AttributeType': 'S' },
|
||||
{ 'AttributeName': 'z', 'AttributeType': 'S' }
|
||||
],
|
||||
)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.create_table(
|
||||
TableName='name_doesnt_matter',
|
||||
BillingMode='PAY_PER_REQUEST',
|
||||
KeySchema=[
|
||||
{ 'AttributeName': 'c', 'KeyType': 'HASH' },
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'z', 'AttributeType': 'S' }
|
||||
],
|
||||
)
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.create_table(
|
||||
TableName='name_doesnt_matter',
|
||||
BillingMode='PAY_PER_REQUEST',
|
||||
KeySchema=[
|
||||
{ 'AttributeName': 'k', 'KeyType': 'HASH' },
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': 'k', 'AttributeType': 'Q' }
|
||||
],
|
||||
)
|
||||
|
||||
# Test that trying to create a table that already exists fails in the
|
||||
# appropriate way (ResourceInUseException)
|
||||
def test_create_table_already_exists(dynamodb, test_table):
|
||||
with pytest.raises(ClientError, match='ResourceInUseException'):
|
||||
create_table(dynamodb, test_table.name)
|
||||
|
||||
# Test that BillingMode error path works as expected - only the values
|
||||
# PROVISIONED or PAY_PER_REQUEST are allowed. The former requires
|
||||
# ProvisionedThroughput to be set, the latter forbids it.
|
||||
# If BillingMode is outright missing, it defaults (as original
|
||||
# DynamoDB did) to PROVISIONED so ProvisionedThroughput is allowed.
|
||||
def test_create_table_billing_mode_errors(dynamodb, test_table):
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
create_table(dynamodb, test_table_name(), BillingMode='unknown')
|
||||
# billing mode is case-sensitive
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
create_table(dynamodb, test_table_name(), BillingMode='pay_per_request')
|
||||
# PAY_PER_REQUEST cannot come with a ProvisionedThroughput:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
create_table(dynamodb, test_table_name(),
|
||||
BillingMode='PAY_PER_REQUEST', ProvisionedThroughput={'ReadCapacityUnits': 10, 'WriteCapacityUnits': 10})
|
||||
# On the other hand, PROVISIONED requires ProvisionedThroughput:
|
||||
# By the way, ProvisionedThroughput not only needs to appear, it must
|
||||
# have both ReadCapacityUnits and WriteCapacityUnits - but we can't test
|
||||
# this with boto3, because boto3 has its own verification that if
|
||||
# ProvisionedThroughput is given, it must have the correct form.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
create_table(dynamodb, test_table_name(), BillingMode='PROVISIONED')
|
||||
# If BillingMode is completely missing, it defaults to PROVISIONED, so
|
||||
# ProvisionedThroughput is required
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.create_table(TableName=test_table_name(),
|
||||
KeySchema=[{ 'AttributeName': 'p', 'KeyType': 'HASH' }],
|
||||
AttributeDefinitions=[{ 'AttributeName': 'p', 'AttributeType': 'S' }])
|
||||
|
||||
# Our first implementation had a special column name called "attrs" where
|
||||
# we stored a map for all non-key columns. If the user tried to name one
|
||||
# of the key columns with this same name, the result was a disaster - Scylla
|
||||
# goes into a bad state after trying to write data with two updates to same-
|
||||
# named columns.
|
||||
special_column_name1 = 'attrs'
|
||||
special_column_name2 = ':attrs'
|
||||
@pytest.fixture(scope="session")
|
||||
def test_table_special_column_name(dynamodb):
|
||||
table = create_test_table(dynamodb,
|
||||
KeySchema=[
|
||||
{ 'AttributeName': special_column_name1, 'KeyType': 'HASH' },
|
||||
{ 'AttributeName': special_column_name2, 'KeyType': 'RANGE' }
|
||||
],
|
||||
AttributeDefinitions=[
|
||||
{ 'AttributeName': special_column_name1, 'AttributeType': 'S' },
|
||||
{ 'AttributeName': special_column_name2, 'AttributeType': 'S' },
|
||||
],
|
||||
)
|
||||
yield table
|
||||
table.delete()
|
||||
@pytest.mark.xfail(reason="special attrs column not yet hidden correctly")
|
||||
def test_create_table_special_column_name(test_table_special_column_name):
|
||||
s = random_string()
|
||||
c = random_string()
|
||||
h = random_string()
|
||||
expected = {special_column_name1: s, special_column_name2: c, 'hello': h}
|
||||
test_table_special_column_name.put_item(Item=expected)
|
||||
got = test_table_special_column_name.get_item(Key={special_column_name1: s, special_column_name2: c}, ConsistentRead=True)['Item']
|
||||
assert got == expected
|
||||
|
||||
# Test that all tables we create are listed, and pagination works properly.
|
||||
# Note that the DyanamoDB setup we run this against may have hundreds of
|
||||
# other tables, for all we know. We just need to check that the tables we
|
||||
# created are indeed listed.
|
||||
def test_list_tables_paginated(dynamodb, test_table, test_table_s, test_table_b):
|
||||
my_tables_set = {table.name for table in [test_table, test_table_s, test_table_b]}
|
||||
for limit in [1, 2, 3, 4, 50, 100]:
|
||||
print("testing limit={}".format(limit))
|
||||
list_tables_set = set(list_tables(dynamodb, limit))
|
||||
assert my_tables_set.issubset(list_tables_set)
|
||||
|
||||
# Test that pagination limit is validated
|
||||
def test_list_tables_wrong_limit(dynamodb):
|
||||
# lower limit (min. 1) is imposed by boto3 library checks
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
dynamodb.meta.client.list_tables(Limit=101)
|
||||
854
alternator-test/test_update_expression.py
Normal file
854
alternator-test/test_update_expression.py
Normal file
@@ -0,0 +1,854 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Tests for the UpdateItem operations with an UpdateExpression parameter
|
||||
|
||||
import random
|
||||
import string
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from decimal import Decimal
|
||||
from util import random_string
|
||||
|
||||
# The simplest test of using UpdateExpression to set a top-level attribute,
|
||||
# instead of the older AttributeUpdates parameter.
|
||||
# Checks only one "SET" action in an UpdateExpression.
|
||||
def test_update_expression_set(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 4}
|
||||
|
||||
# An empty UpdateExpression is NOT allowed, and generates a "The expression
|
||||
# can not be empty" error. This contrasts with an empty AttributeUpdates which
|
||||
# is allowed, and results in the creation of an empty item if it didn't exist
|
||||
# yet (see test_empty_update()).
|
||||
def test_update_expression_empty(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='')
|
||||
|
||||
# A basic test with multiple SET actions in one expression
|
||||
def test_update_expression_set_multi(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET x = :val1, y = :val1',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'x': 4, 'y': 4}
|
||||
|
||||
# SET can be used to copy an existing attribute to a new one
|
||||
def test_update_expression_set_copy(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello'}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET b = a')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello', 'b': 'hello'}
|
||||
# Copying an non-existing attribute generates an error
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET c = z')
|
||||
# It turns out that attributes to be copied are read before the SET
|
||||
# starts to write, so "SET x = :val1, y = x" does not work...
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET x = :val1, y = x', ExpressionAttributeValues={':val1': 4})
|
||||
# SET z=z does nothing if z exists, or fails if it doesn't
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = a')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello', 'b': 'hello'}
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET z = z')
|
||||
# We can also use name references in either LHS or RHS of SET, e.g.,
|
||||
# SET #one = #two. We need to also take the references used in the RHS
|
||||
# when we want to complain about unused names in ExpressionAttributeNames.
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #one = #two',
|
||||
ExpressionAttributeNames={'#one': 'c', '#two': 'a'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello', 'b': 'hello', 'c': 'hello'}
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #one = #two',
|
||||
ExpressionAttributeNames={'#one': 'c', '#two': 'a', '#three': 'z'})
|
||||
|
||||
# Test for read-before-write action where the value to be read is nested inside a - operator
|
||||
def test_update_expression_set_nested_copy(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #n = :two',
|
||||
ExpressionAttributeNames={'#n': 'n'}, ExpressionAttributeValues={':two': 2})
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #nn = :seven - #n',
|
||||
ExpressionAttributeNames={'#nn': 'nn', '#n': 'n'}, ExpressionAttributeValues={':seven': 7})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'n': 2, 'nn': 5}
|
||||
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #nnn = :nnn',
|
||||
ExpressionAttributeNames={'#nnn': 'nnn'}, ExpressionAttributeValues={':nnn': [2,4]})
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #nnnn = list_append(:val1, #nnn)',
|
||||
ExpressionAttributeNames={'#nnnn': 'nnnn', '#nnn': 'nnn'}, ExpressionAttributeValues={':val1': [1,3]})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'n': 2, 'nn': 5, 'nnn': [2,4], 'nnnn': [1,3,2,4]}
|
||||
|
||||
# Test for getting a key value with read-before-write
|
||||
def test_update_expression_set_key(test_table_sn):
|
||||
p = random_string()
|
||||
test_table_sn.update_item(Key={'p': p, 'c': 7});
|
||||
test_table_sn.update_item(Key={'p': p, 'c': 7}, UpdateExpression='SET #n = #p',
|
||||
ExpressionAttributeNames={'#n': 'n', '#p': 'p'})
|
||||
test_table_sn.update_item(Key={'p': p, 'c': 7}, UpdateExpression='SET #nn = #c + #c',
|
||||
ExpressionAttributeNames={'#nn': 'nn', '#c': 'c'})
|
||||
assert test_table_sn.get_item(Key={'p': p, 'c': 7}, ConsistentRead=True)['Item'] == {'p': p, 'c': 7, 'n': p, 'nn': 14}
|
||||
|
||||
# Simple test for the "REMOVE" action
|
||||
def test_update_expression_remove(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello', 'b': 'hi'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello', 'b': 'hi'}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='REMOVE a')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 'hi'}
|
||||
|
||||
# Demonstrate that although all DynamoDB examples give UpdateExpression
|
||||
# action names in uppercase - e.g., "SET", it can actually be any case.
|
||||
def test_update_expression_action_case(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET b = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 3}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='set b = :val1', ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 4}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='sEt b = :val1', ExpressionAttributeValues={':val1': 5})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 5}
|
||||
|
||||
# Demonstrate that whitespace is ignored in UpdateExpression parsing.
|
||||
def test_update_expression_action_whitespace(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='set b = :val1', ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 4}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression=' set b=:val1 ', ExpressionAttributeValues={':val1': 5})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 5}
|
||||
|
||||
# In UpdateExpression, the attribute name can appear directly in the expression
|
||||
# (without a "#placeholder" notation) only if it is a single "token" as
|
||||
# determined by DynamoDB's lexical analyzer rules: Such token is composed of
|
||||
# alphanumeric characters whose first character must be alphabetic. Other
|
||||
# names cause the parser to see multiple tokens, and produce syntax errors.
|
||||
def test_update_expression_name_token(test_table_s):
|
||||
p = random_string()
|
||||
# Alphanumeric names starting with an alphabetical character work
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET alnum = :val1', ExpressionAttributeValues={':val1': 1})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['alnum'] == 1
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET Alpha_Numeric_123 = :val1', ExpressionAttributeValues={':val1': 2})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['Alpha_Numeric_123'] == 2
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET A123_ = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['A123_'] == 3
|
||||
# But alphanumeric names cannot start with underscore or digits.
|
||||
# DynamoDB's lexical analyzer doesn't recognize them, and produces
|
||||
# a ValidationException looking like:
|
||||
# Invalid UpdateExpression: Syntax error; token: "_", near: "SET _123"
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET _123 = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET _abc = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET 123a = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET 123 = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
# Various other non-alpha-numeric characters, split a token and NOT allowed
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET hi-there = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET hi$there = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET "hithere" = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET !hithere = :val1', ExpressionAttributeValues={':val1': 3})
|
||||
|
||||
# In addition to the literal names, DynamoDB also allows references to any
|
||||
# name, using the "#reference" syntax. It turns out the reference name is
|
||||
# also a token following the rules as above, with one interesting point:
|
||||
# since "#" already started the token, the next character may be any
|
||||
# alphanumeric and doesn't need to be only alphabetical.
|
||||
# Note that the reference target - the actual attribute name - can include
|
||||
# absolutely any characters, and we use silly_name below as an example
|
||||
silly_name = '3can include any character!.#='
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #Alpha_Numeric_123 = :val1', ExpressionAttributeValues={':val1': 4}, ExpressionAttributeNames={'#Alpha_Numeric_123': silly_name})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'][silly_name] == 4
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #123a = :val1', ExpressionAttributeValues={':val1': 5}, ExpressionAttributeNames={'#123a': silly_name})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'][silly_name] == 5
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #123 = :val1', ExpressionAttributeValues={':val1': 6}, ExpressionAttributeNames={'#123': silly_name})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'][silly_name] == 6
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #_ = :val1', ExpressionAttributeValues={':val1': 7}, ExpressionAttributeNames={'#_': silly_name})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'][silly_name] == 7
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #hi-there = :val1', ExpressionAttributeValues={':val1': 7}, ExpressionAttributeNames={'#hi-there': silly_name})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #!hi = :val1', ExpressionAttributeValues={':val1': 7}, ExpressionAttributeNames={'#!hi': silly_name})
|
||||
# Just a "#" is not enough as a token. Interestingly, DynamoDB will
|
||||
# find the bad name in ExpressionAttributeNames before it actually tries
|
||||
# to parse UpdateExpression, but we can verify the parse fails too by
|
||||
# using a valid but irrelevant name in ExpressionAttributeNames:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET # = :val1', ExpressionAttributeValues={':val1': 7}, ExpressionAttributeNames={'#': silly_name})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET # = :val1', ExpressionAttributeValues={':val1': 7}, ExpressionAttributeNames={'#a': silly_name})
|
||||
|
||||
# There is also the value references, ":reference", for the right-hand
|
||||
# side of an assignment. These have similar naming rules like "#reference".
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :Alpha_Numeric_123', ExpressionAttributeValues={':Alpha_Numeric_123': 8})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 8
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :123a', ExpressionAttributeValues={':123a': 9})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 9
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :123', ExpressionAttributeValues={':123': 10})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 10
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :_', ExpressionAttributeValues={':_': 11})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 11
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :hi!there', ExpressionAttributeValues={':hi!there': 12})
|
||||
# Just a ":" is not enough as a token.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :', ExpressionAttributeValues={':': 7})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :', ExpressionAttributeValues={':a': 7})
|
||||
# Trying to use a :reference on the left-hand side of an assignment will
|
||||
# not work. In DynamoDB, it's a different type of token (and generates
|
||||
# syntax error).
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET :a = :b', ExpressionAttributeValues={':a': 1, ':b': 2})
|
||||
|
||||
# Multiple actions are allowed in one expression, but actions are divided
|
||||
# into clauses (SET, REMOVE, DELETE, ADD) and each of those can only appear
|
||||
# once.
|
||||
def test_update_expression_multi(test_table_s):
|
||||
p = random_string()
|
||||
# We can have two SET actions in one SET clause:
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :val1, b = :val2', ExpressionAttributeValues={':val1': 1, ':val2': 2})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 1, 'b': 2}
|
||||
# But not two SET clauses - we get error "The "SET" section can only be used once in an update expression"
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :val1 SET b = :val2', ExpressionAttributeValues={':val1': 1, ':val2': 2})
|
||||
# We can have a REMOVE and a SET clause (note no comma between clauses):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='REMOVE a SET b = :val2', ExpressionAttributeValues={':val2': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 3}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET c = :val2 REMOVE b', ExpressionAttributeValues={':val2': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'c': 3}
|
||||
# The same clause (e.g., SET) cannot be used twice, even if interleaved with something else
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :val1 REMOVE a SET b = :val2', ExpressionAttributeValues={':val1': 1, ':val2': 2})
|
||||
|
||||
# Trying to modify the same item twice in the same update is forbidden.
|
||||
# For "SET a=:v REMOVE a" DynamoDB says: "Invalid UpdateExpression: Two
|
||||
# document paths overlap with each other; must remove or rewrite one of
|
||||
# these paths; path one: [a], path two: [a]".
|
||||
# It is actually good for Scylla that such updates are forbidden, because had
|
||||
# we allowed "SET a=:v REMOVE a" the result would be surprising - because data
|
||||
# wins over a delete with the same timestamp, so "a" would be set despite the
|
||||
# REMOVE command appearing later in the command line.
|
||||
def test_update_expression_multi_overlap(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello'}
|
||||
# Neither "REMOVE a SET a = :v" nor "SET a = :v REMOVE a" are allowed:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='REMOVE a SET a = :v', ExpressionAttributeValues={':v': 'hi'})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :v REMOVE a', ExpressionAttributeValues={':v': 'yo'})
|
||||
# It's also not allowed to set a twice in the same clause
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :v1, a = :v2', ExpressionAttributeValues={':v1': 'yo', ':v2': 'he'})
|
||||
# Obviously, the paths are compared after the name references are evaluated
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #a1 = :v1, #a2 = :v2', ExpressionAttributeValues={':v1': 'yo', ':v2': 'he'}, ExpressionAttributeNames={'#a1': 'a', '#a2': 'a'})
|
||||
|
||||
# The problem isn't just with identical paths - we can't modify two paths that
|
||||
# "overlap" in the sense that one is the ancestor of the other.
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_update_expression_multi_overlap_nested(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*overlap'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :val1, a.b = :val2',
|
||||
ExpressionAttributeValues={':val1': {'b': 7}, ':val2': 'there'})
|
||||
test_table_s.put_item(Item={'p': p, 'a': {'b': {'c': 2}}})
|
||||
with pytest.raises(ClientError, match='ValidationException.*overlap'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a.b = :val1, a.b.c = :val2',
|
||||
ExpressionAttributeValues={':val1': 'hi', ':val2': 'there'})
|
||||
|
||||
# In the previous test we saw that *modifying* the same item twice in the same
|
||||
# update is forbidden; But it is allowed to *read* an item in the same update
|
||||
# that also modifies it, and we check this here.
|
||||
def test_update_expression_multi_with_copy(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': 'hello'}
|
||||
# "REMOVE a SET b = a" works: as noted in test_update_expression_set_copy()
|
||||
# the value of 'a' is read before the actual REMOVE operation happens.
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='REMOVE a SET b = a')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 'hello'}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET c = b REMOVE b')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'c': 'hello'}
|
||||
|
||||
|
||||
# Test case where a :val1 is referenced, without being defined
|
||||
def test_update_expression_set_missing_value(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1',
|
||||
ExpressionAttributeValues={':val2': 4})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1')
|
||||
|
||||
# It is forbidden for ExpressionAttributeValues to contain values not used
|
||||
# by the expression. DynamoDB produces an error like: "Value provided in
|
||||
# ExpressionAttributeValues unused in expressions: keys: {:val1}"
|
||||
def test_update_expression_spurious_value(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a = :val1',
|
||||
ExpressionAttributeValues={':val1': 3, ':val2': 4})
|
||||
|
||||
# Test case where a #name is referenced, without being defined
|
||||
def test_update_expression_set_missing_name(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET #name = :val1',
|
||||
ExpressionAttributeValues={':val2': 4},
|
||||
ExpressionAttributeNames={'#wrongname': 'hello'})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET #name = :val1',
|
||||
ExpressionAttributeValues={':val2': 4})
|
||||
|
||||
# It is forbidden for ExpressionAttributeNames to contain names not used
|
||||
# by the expression. DynamoDB produces an error like: "Value provided in
|
||||
# ExpressionAttributeNames unused in expressions: keys: {#b}"
|
||||
def test_update_expression_spurious_name(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #a = :val1',
|
||||
ExpressionAttributeNames={'#a': 'hello', '#b': 'hi'},
|
||||
ExpressionAttributeValues={':val1': 3, ':val2': 4})
|
||||
|
||||
# Test that the key attributes (hash key or sort key) cannot be modified
|
||||
# by an update
|
||||
def test_update_expression_cannot_modify_key(test_table):
|
||||
p = random_string()
|
||||
c = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c},
|
||||
UpdateExpression='SET p = :val1', ExpressionAttributeValues={':val1': 4})
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c},
|
||||
UpdateExpression='SET c = :val1', ExpressionAttributeValues={':val1': 4})
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c}, UpdateExpression='REMOVE p')
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c}, UpdateExpression='REMOVE c')
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c},
|
||||
UpdateExpression='ADD p :val1', ExpressionAttributeValues={':val1': 4})
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c},
|
||||
UpdateExpression='ADD c :val1', ExpressionAttributeValues={':val1': 4})
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c},
|
||||
UpdateExpression='DELETE p :val1', ExpressionAttributeValues={':val1': set(['cat', 'mouse'])})
|
||||
with pytest.raises(ClientError, match='ValidationException.*key'):
|
||||
test_table.update_item(Key={'p': p, 'c': c},
|
||||
UpdateExpression='DELETE c :val1', ExpressionAttributeValues={':val1': set(['cat', 'mouse'])})
|
||||
# As sanity check, verify we *can* modify a non-key column
|
||||
test_table.update_item(Key={'p': p, 'c': c}, UpdateExpression='SET a = :val1', ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c, 'a': 4}
|
||||
test_table.update_item(Key={'p': p, 'c': c}, UpdateExpression='REMOVE a')
|
||||
assert test_table.get_item(Key={'p': p, 'c': c}, ConsistentRead=True)['Item'] == {'p': p, 'c': c}
|
||||
|
||||
# Test that trying to start an expression with some nonsense like HELLO
|
||||
# instead of SET, REMOVE, ADD or DELETE, fails.
|
||||
def test_update_expression_non_existant_clause(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='HELLO b = :val1',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
|
||||
# Test support for "SET a = :val1 + :val2", "SET a = :val1 - :val2"
|
||||
# Only exactly these combinations work - e.g., it's a syntax error to
|
||||
# try to add three. Trying to add a string fails.
|
||||
def test_update_expression_plus_basic(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1 + :val2',
|
||||
ExpressionAttributeValues={':val1': 4, ':val2': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 7}
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1 - :val2',
|
||||
ExpressionAttributeValues={':val1': 5, ':val2': 2})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': 3}
|
||||
# Only the addition of exactly two values is supported!
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1 + :val2 + :val3',
|
||||
ExpressionAttributeValues={':val1': 4, ':val2': 3, ':val3': 2})
|
||||
# Only numeric values can be added - other things like strings or lists
|
||||
# cannot be added, and we get an error like "Incorrect operand type for
|
||||
# operator or function; operator or function: +, operand type: S".
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1 + :val2',
|
||||
ExpressionAttributeValues={':val1': 'dog', ':val2': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1 + :val2',
|
||||
ExpressionAttributeValues={':val1': ['a', 'b'], ':val2': ['1', '2']})
|
||||
|
||||
# While most of the Alternator code just saves high-precision numbers
|
||||
# unchanged, the "+" and "-" operations need to calculate with them, and
|
||||
# we should check the calculation isn't done with some lower-precision
|
||||
# representation, e.g., double
|
||||
def test_update_expression_plus_precision(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1 + :val2',
|
||||
ExpressionAttributeValues={':val1': Decimal("1"), ':val2': Decimal("10000000000000000000000")})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': Decimal("10000000000000000000001")}
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val2 - :val1',
|
||||
ExpressionAttributeValues={':val1': Decimal("1"), ':val2': Decimal("10000000000000000000000")})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'b': Decimal("9999999999999999999999")}
|
||||
|
||||
# Test support for "SET a = b + :val2" et al., i.e., a version of the
|
||||
# above test_update_expression_plus_basic with read before write.
|
||||
def test_update_expression_plus_rmw(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 2})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 2
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = a + :val1',
|
||||
ExpressionAttributeValues={':val1': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 5
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = :val1 + a',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 9
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = :val1 + a',
|
||||
ExpressionAttributeValues={':val1': 1})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['b'] == 10
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = b + a')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 19
|
||||
|
||||
# Test the list_append() function in SET, for the most basic use case of
|
||||
# concatenating two value references. Because this is the first test of
|
||||
# functions in SET, we also test some generic features of how functions
|
||||
# are parsed.
|
||||
def test_update_expression_list_append_basic(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(:val1, :val2)',
|
||||
ExpressionAttributeValues={':val1': [4, 'hello'], ':val2': ['hi', 7]})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': [4, 'hello', 'hi', 7]}
|
||||
# Unlike the operation name "SET", function names are case-sensitive!
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = LIST_APPEND(:val1, :val2)',
|
||||
ExpressionAttributeValues={':val1': [4, 'hello'], ':val2': ['hi', 7]})
|
||||
# As usual, spaces are ignored by the parser
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(:val1, :val2)',
|
||||
ExpressionAttributeValues={':val1': ['a'], ':val2': ['b']})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': ['a', 'b']}
|
||||
# The list_append function only allows two parameters. The parser can
|
||||
# correctly parse fewer or more, but then an error is generated: "Invalid
|
||||
# UpdateExpression: Incorrect number of operands for operator or function;
|
||||
# operator or function: list_append, number of operands: 1".
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(:val1)',
|
||||
ExpressionAttributeValues={':val1': ['a']})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(:val1, :val2, :val3)',
|
||||
ExpressionAttributeValues={':val1': [4, 'hello'], ':val2': [7], ':val3': ['a']})
|
||||
# If list_append is used on value which isn't a list, we get
|
||||
# error: "Invalid UpdateExpression: Incorrect operand type for operator
|
||||
# or function; operator or function: list_append, operand type: S"
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(:val1, :val2)',
|
||||
ExpressionAttributeValues={':val1': [4, 'hello'], ':val2': 'hi'})
|
||||
|
||||
# Additional list_append() tests, also using attribute paths as parameters
|
||||
# (i.e., read-modify-write).
|
||||
def test_update_expression_list_append(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = :val1',
|
||||
ExpressionAttributeValues={':val1': ['hi', 2]})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] ==['hi', 2]
|
||||
# Often, list_append is used to append items to a list attribute
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(a, :val1)',
|
||||
ExpressionAttributeValues={':val1': [4, 'hello']})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == ['hi', 2, 4, 'hello']
|
||||
# But it can also be used to just concatenate in other ways:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(:val1, a)',
|
||||
ExpressionAttributeValues={':val1': ['dog']})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == ['dog', 'hi', 2, 4, 'hello']
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = list_append(a, :val1)',
|
||||
ExpressionAttributeValues={':val1': ['cat']})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['b'] == ['dog', 'hi', 2, 4, 'hello', 'cat']
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET c = list_append(a, b)')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['c'] == ['dog', 'hi', 2, 4, 'hello', 'dog', 'hi', 2, 4, 'hello', 'cat']
|
||||
# As usual, #references are allowed instead of inline names:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET #name1 = list_append(#name2,:val1)',
|
||||
ExpressionAttributeValues={':val1': [8]},
|
||||
ExpressionAttributeNames={'#name1': 'a', '#name2': 'a'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == ['dog', 'hi', 2, 4, 'hello', 8]
|
||||
|
||||
# Test the "if_not_exists" function in SET
|
||||
# The test also checks additional features of function-call parsing.
|
||||
def test_update_expression_if_not_exists(test_table_s):
|
||||
p = random_string()
|
||||
# Since attribute a doesn't exist, set it:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = if_not_exists(a, :val1)',
|
||||
ExpressionAttributeValues={':val1': 2})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 2
|
||||
# Now the attribute does exist, so set does nothing:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = if_not_exists(a, :val1)',
|
||||
ExpressionAttributeValues={':val1': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 2
|
||||
# if_not_exists can also be used to check one attribute and set another,
|
||||
# but note that if_not_exists(a, :val) means a's value if it exists,
|
||||
# otherwise :val!
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(c, :val1)',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['b'] == 4
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 2
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(c, :val1)',
|
||||
ExpressionAttributeValues={':val1': 5})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['b'] == 5
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(a, :val1)',
|
||||
ExpressionAttributeValues={':val1': 6})
|
||||
# note how because 'a' does exist, its value is copied, overwriting b's
|
||||
# value:
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['b'] == 2
|
||||
# The parser expects function parameters to be value references, paths,
|
||||
# or nested call to functions. Other crap will cause syntax errors:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(non@sense, :val1)',
|
||||
ExpressionAttributeValues={':val1': 6})
|
||||
# if_not_exists() requires that the first parameter be a path. However,
|
||||
# the parser doesn't know this, and allows for a function parameter
|
||||
# also a value reference or a function call. If try one of these other
|
||||
# things the parser succeeds, but we get a later error, looking like:
|
||||
# "Invalid UpdateExpression: Operator or function requires a document
|
||||
# path; operator or function: if_not_exists"
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(if_not_exists(a, :val2), :val1)',
|
||||
ExpressionAttributeValues={':val1': 6, ':val2': 3})
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(:val2, :val1)',
|
||||
ExpressionAttributeValues={':val1': 6, ':val2': 3})
|
||||
# Surprisingly, if the wrong argument is a :val value reference, the
|
||||
# parser first tries to look it up in ExpressionAttributeValues (and
|
||||
# fails if it's missing), before realizing any value reference would be
|
||||
# wrong... So the following fails like the above does - but with a
|
||||
# different error message (which we do not check here): "Invalid
|
||||
# UpdateExpression: An expression attribute value used in expression
|
||||
# is not defined; attribute value: :val2"
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(:val2, :val1)',
|
||||
ExpressionAttributeValues={':val1': 6})
|
||||
|
||||
# When the expression parser parses a function call f(value, value), each
|
||||
# value may itself be a function call - ad infinitum. So expressions like
|
||||
# list_append(if_not_exists(a, :val1), :val2) are legal and so is deeper
|
||||
# nesting.
|
||||
@pytest.mark.xfail(reason="SET functions not yet implemented")
|
||||
def test_update_expression_function_nesting(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(if_not_exists(a, :val1), :val2)',
|
||||
ExpressionAttributeValues={':val1': ['a', 'b'], ':val2': ['cat', 'dog']})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == ['a', 'b', 'cat', 'dog']
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(if_not_exists(a, :val1), :val2)',
|
||||
ExpressionAttributeValues={':val1': ['a', 'b'], ':val2': ['1', '2']})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == ['a', 'b', 'cat', 'dog', '1', '2']
|
||||
# I don't understand why the following expression isn't accepted, but it
|
||||
# isn't! It produces a "Invalid UpdateExpression: The function is not
|
||||
# allowed to be used this way in an expression; function: list_append".
|
||||
# I don't know how to explain it. In any case, the *parsing* works -
|
||||
# this is not a syntax error - the failure is in some verification later.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(list_append(:val1, :val2), :val3)',
|
||||
ExpressionAttributeValues={':val1': ['a'], ':val2': ['1'], ':val3': ['hi']})
|
||||
# Ditto, the following passes the parser but fails some later check with
|
||||
# the same error message as above.
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = list_append(list_append(list_append(:val1, :val2), :val3), :val4)',
|
||||
ExpressionAttributeValues={':val1': ['a'], ':val2': ['1'], ':val3': ['hi'], ':val4': ['yo']})
|
||||
|
||||
# Verify how in SET expressions, "+" (or "-") nests with functions.
|
||||
# We discover that f(x)+f(y) works but f(x+y) does NOT (results in a syntax
|
||||
# error on the "+"). This means that the parser has two separate rules:
|
||||
# 1. set_action: SET path = value + value
|
||||
# 2. value: VALREF | NAME | NAME (value, ...)
|
||||
def test_update_expression_function_plus_nesting(test_table_s):
|
||||
p = random_string()
|
||||
# As explained above, this - with "+" outside the expression, works:
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET b = if_not_exists(b, :val1)+:val2',
|
||||
ExpressionAttributeValues={':val1': 2, ':val2': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['b'] == 5
|
||||
# ...but this - with the "+" inside an expression parameter, is a syntax
|
||||
# error:
|
||||
with pytest.raises(ClientError, match='ValidationException'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET c = if_not_exists(c, :val1+:val2)',
|
||||
ExpressionAttributeValues={':val1': 5, ':val2': 4})
|
||||
|
||||
# This test tries to use an undefined function "f". This, obviously, fails,
|
||||
# but where we to actually print the error we would see "Invalid
|
||||
# UpdateExpression: Invalid function name; function: f". Not a syntax error.
|
||||
# This means that the parser accepts any alphanumeric name as a function
|
||||
# name, and only later use of this function fails because it's not one of
|
||||
# the supported file.
|
||||
def test_update_expression_unknown_function(test_table_s):
|
||||
p = random_string()
|
||||
with pytest.raises(ClientError, match='ValidationException.*f'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = f(b,c,d)')
|
||||
with pytest.raises(ClientError, match='ValidationException.*f123_hi'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = f123_hi(b,c,d)')
|
||||
# Just like unreferenced column names parsed by the DynamoDB parser,
|
||||
# function names must also start with an alphabetic character. Trying
|
||||
# to use _f as a function name will result with an actual syntax error,
|
||||
# on the "_" token.
|
||||
with pytest.raises(ClientError, match='ValidationException.*yntax error'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='SET a = _f(b,c,d)')
|
||||
|
||||
# Test "ADD" operation for numbers
|
||||
def test_update_expression_add_numbers(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 3, 'b': 'hi'})
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='ADD a :val1',
|
||||
ExpressionAttributeValues={':val1': 4})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == 7
|
||||
# If the value to be added isn't a number, we get an error like "Invalid
|
||||
# UpdateExpression: Incorrect operand type for operator or function;
|
||||
# operator: ADD, operand type: STRING".
|
||||
with pytest.raises(ClientError, match='ValidationException.*type'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='ADD a :val1',
|
||||
ExpressionAttributeValues={':val1': 'hello'})
|
||||
# Similarly, if the attribute we're adding to isn't a number, we get an
|
||||
# error like "An operand in the update expression has an incorrect data
|
||||
# type"
|
||||
with pytest.raises(ClientError, match='ValidationException.*type'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='ADD b :val1',
|
||||
ExpressionAttributeValues={':val1': 1})
|
||||
|
||||
# Test "ADD" operation for sets
|
||||
def test_update_expression_add_sets(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': set(['dog', 'cat', 'mouse']), 'b': 'hi'})
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='ADD a :val1',
|
||||
ExpressionAttributeValues={':val1': set(['pig'])})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == set(['dog', 'cat', 'mouse', 'pig'])
|
||||
|
||||
# TODO: right now this test won't detect duplicated values in the returned result,
|
||||
# because boto3 parses a set out of the returned JSON anyway. This check should leverage
|
||||
# lower level API (if exists) to ensure that the JSON contains no duplicates
|
||||
# in the set representation. It has been verified manually.
|
||||
test_table_s.put_item(Item={'p': p, 'a': set(['beaver', 'lynx', 'coati']), 'b': 'hi'})
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='ADD a :val1',
|
||||
ExpressionAttributeValues={':val1': set(['coati', 'beaver', 'badger'])})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == set(['beaver', 'badger', 'lynx', 'coati'])
|
||||
|
||||
# The value to be added needs to be a set of the same type - it can't
|
||||
# be a single element or anything else. If the value has the wrong type,
|
||||
# we get an error like "Invalid UpdateExpression: Incorrect operand type
|
||||
# for operator or function; operator: ADD, operand type: STRING".
|
||||
with pytest.raises(ClientError, match='ValidationException.*type'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='ADD a :val1',
|
||||
ExpressionAttributeValues={':val1': 'hello'})
|
||||
|
||||
# Test "DELETE" operation for sets
|
||||
def test_update_expression_delete_sets(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': set(['dog', 'cat', 'mouse']), 'b': 'hi'})
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='DELETE a :val1',
|
||||
ExpressionAttributeValues={':val1': set(['cat', 'mouse'])})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == set(['dog'])
|
||||
# Deleting an element not present in the set is not an error - it just
|
||||
# does nothing
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='DELETE a :val1',
|
||||
ExpressionAttributeValues={':val1': set(['pig'])})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == set(['dog'])
|
||||
# The value to be deleted must be a set of the same type - it can't
|
||||
# be a single element or anything else. If the value has the wrong type,
|
||||
# we get an error like "Invalid UpdateExpression: Incorrect operand type
|
||||
# for operator or function; operator: DELETE, operand type: STRING".
|
||||
with pytest.raises(ClientError, match='ValidationException.*type'):
|
||||
test_table_s.update_item(Key={'p': p},
|
||||
UpdateExpression='DELETE a :val1',
|
||||
ExpressionAttributeValues={':val1': 'hello'})
|
||||
|
||||
######## Tests for paths and nested attribute updates:
|
||||
|
||||
# A dot inside a name in ExpressionAttributeNames is a literal dot, and
|
||||
# results in a top-level attribute with an actual dot in its name - not
|
||||
# a nested attribute path.
|
||||
def test_update_expression_dot_in_name(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET #a = :val1',
|
||||
ExpressionAttributeValues={':val1': 3},
|
||||
ExpressionAttributeNames={'#a': 'a.b'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a.b': 3}
|
||||
|
||||
# A basic test for direct update of a nested attribute: One of the top-level
|
||||
# attributes is itself a document, and we update only one of that document's
|
||||
# nested attributes.
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_update_expression_nested_attribute_dot(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': {'b': 3, 'c': 4}, 'd': 5})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': {'b': 3, 'c': 4}, 'd': 5}
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a.c = :val1',
|
||||
ExpressionAttributeValues={':val1': 7})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': {'b': 3, 'c': 7}, 'd': 5}
|
||||
# Of course we can also add new nested attributes, not just modify
|
||||
# existing ones:
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a.d = :val1',
|
||||
ExpressionAttributeValues={':val1': 3})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': {'b': 3, 'c': 7, 'd': 3}, 'd': 5}
|
||||
|
||||
# Similar test, for a list: one of the top-level attributes is a list, we
|
||||
# can update one of its items.
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_update_expression_nested_attribute_index(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': ['one', 'two', 'three']})
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a[1] = :val1',
|
||||
ExpressionAttributeValues={':val1': 'hello'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': ['one', 'hello', 'three']}
|
||||
|
||||
# Test that just like happens in top-level attributes, also in nested
|
||||
# attributes, setting them replaces the old value - potentially an entire
|
||||
# nested document, by the whole value (which may have a different type)
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_update_expression_nested_different_type(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': {'b': 3, 'c': {'one': 1, 'two': 2}}})
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a.c = :val1',
|
||||
ExpressionAttributeValues={':val1': 7})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': {'b': 3, 'c': 7}}
|
||||
|
||||
# Yet another test of a nested attribute update. This one uses deeper
|
||||
# level of nesting (dots and indexes), adds #name references to the mix.
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_update_expression_nested_deep(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': {'b': 3, 'c': ['hi', {'x': {'y': [3, 5, 7]}}]}})
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a.c[1].#name.y[1] = :val1',
|
||||
ExpressionAttributeValues={':val1': 9}, ExpressionAttributeNames={'#name': 'x'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == {'b': 3, 'c': ['hi', {'x': {'y': [3, 9, 7]}}]}
|
||||
# A deep path can also appear on the right-hand-side of an assignment
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a.z = a.c[1].#name.y[1]',
|
||||
ExpressionAttributeNames={'#name': 'x'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a']['z'] == 9
|
||||
|
||||
# A REMOVE operation can be used to remove nested attributes, and also
|
||||
# individual list items.
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_update_expression_nested_remove(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': {'b': 3, 'c': ['hi', {'x': {'y': [3, 5, 7]}, 'q': 2}]}})
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='REMOVE a.c[1].x.y[1], a.c[1].q')
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item']['a'] == {'b': 3, 'c': ['hi', {'x': {'y': [3, 7]}}]}
|
||||
|
||||
# The DynamoDB documentation specifies: "When you use SET to update a list
|
||||
# element, the contents of that element are replaced with the new data that
|
||||
# you specify. If the element does not already exist, SET will append the
|
||||
# new element at the end of the list."
|
||||
# So if we take a three-element list a[7], and set a[7], the new element
|
||||
# will be put at the end of the list, not position 7 specifically.
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_nested_attribute_update_array_out_of_bounds(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': ['one', 'two', 'three']})
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a[7] = :val1',
|
||||
ExpressionAttributeValues={':val1': 'hello'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': ['one', 'two', 'three', 'hello']}
|
||||
# The DynamoDB documentation also says: "If you add multiple elements
|
||||
# in a single SET operation, the elements are sorted in order by element
|
||||
# number.
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a[84] = :val1, a[37] = :val2',
|
||||
ExpressionAttributeValues={':val1': 'a1', ':val2': 'a2'})
|
||||
assert test_table_s.get_item(Key={'p': p}, ConsistentRead=True)['Item'] == {'p': p, 'a': ['one', 'two', 'three', 'hello', 'a2', 'a1']}
|
||||
|
||||
# Test what happens if we try to write to a.b, which would only make sense if
|
||||
# a were a nested document, but a doesn't exist, or exists and is NOT a nested
|
||||
# document but rather a scalar or list or something.
|
||||
# DynamoDB actually detects this case and prints an error:
|
||||
# ClientError: An error occurred (ValidationException) when calling the
|
||||
# UpdateItem operation: The document path provided in the update expression
|
||||
# is invalid for update
|
||||
# Because Scylla doesn't read before write, it cannot detect this as an error,
|
||||
# so we'll probably want to allow for that possibility as well.
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_nested_attribute_update_bad_path_dot(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello', 'b': ['hi']})
|
||||
with pytest.raises(ClientError, match='ValidationException.*path'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a.c = :val1',
|
||||
ExpressionAttributeValues={':val1': 7})
|
||||
with pytest.raises(ClientError, match='ValidationException.*path'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET b.c = :val1',
|
||||
ExpressionAttributeValues={':val1': 7})
|
||||
with pytest.raises(ClientError, match='ValidationException.*path'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET c.c = :val1',
|
||||
ExpressionAttributeValues={':val1': 7})
|
||||
|
||||
|
||||
# Similarly for other types of bad paths - using [0] on something which
|
||||
# isn't an array,
|
||||
@pytest.mark.xfail(reason="nested updates not yet implemented")
|
||||
def test_nested_attribute_update_bad_path_array(test_table_s):
|
||||
p = random_string()
|
||||
test_table_s.put_item(Item={'p': p, 'a': 'hello'})
|
||||
with pytest.raises(ClientError, match='ValidationException.*path'):
|
||||
test_table_s.update_item(Key={'p': p}, UpdateExpression='SET a[0] = :val1',
|
||||
ExpressionAttributeValues={':val1': 7})
|
||||
121
alternator-test/util.py
Normal file
121
alternator-test/util.py
Normal file
@@ -0,0 +1,121 @@
|
||||
# Copyright 2019 ScyllaDB
|
||||
#
|
||||
# This file is part of Scylla.
|
||||
#
|
||||
# Scylla is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Scylla is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Various utility functions which are useful for multiple tests
|
||||
|
||||
import string
|
||||
import random
|
||||
import collections
|
||||
import time
|
||||
|
||||
def random_string(length=10, chars=string.ascii_uppercase + string.digits):
|
||||
return ''.join(random.choice(chars) for x in range(length))
|
||||
|
||||
def random_bytes(length=10):
|
||||
return bytearray(random.getrandbits(8) for _ in range(length))
|
||||
|
||||
# Utility functions for scan and query into an array of items:
|
||||
# TODO: add to full_scan and full_query by default ConsistentRead=True, as
|
||||
# it's not useful for tests without it!
|
||||
def full_scan(table, **kwargs):
|
||||
response = table.scan(**kwargs)
|
||||
items = response['Items']
|
||||
while 'LastEvaluatedKey' in response:
|
||||
response = table.scan(ExclusiveStartKey=response['LastEvaluatedKey'], **kwargs)
|
||||
items.extend(response['Items'])
|
||||
return items
|
||||
|
||||
# Utility function for fetching the entire results of a query into an array of items
|
||||
def full_query(table, **kwargs):
|
||||
response = table.query(**kwargs)
|
||||
items = response['Items']
|
||||
while 'LastEvaluatedKey' in response:
|
||||
response = table.query(ExclusiveStartKey=response['LastEvaluatedKey'], **kwargs)
|
||||
items.extend(response['Items'])
|
||||
return items
|
||||
|
||||
# To compare two lists of items (each is a dict) without regard for order,
|
||||
# "==" is not good enough because it will fail if the order is different.
|
||||
# The following function, multiset() converts the list into a multiset
|
||||
# (set with duplicates) where order doesn't matter, so the multisets can
|
||||
# be compared.
|
||||
|
||||
def freeze(item):
|
||||
if isinstance(item, dict):
|
||||
return frozenset((key, freeze(value)) for key, value in item.items())
|
||||
elif isinstance(item, list):
|
||||
return tuple(freeze(value) for value in item)
|
||||
return item
|
||||
|
||||
def multiset(items):
|
||||
return collections.Counter([freeze(item) for item in items])
|
||||
|
||||
|
||||
test_table_prefix = 'alternator_test_'
|
||||
def test_table_name():
|
||||
current_ms = int(round(time.time() * 1000))
|
||||
# In the off chance that test_table_name() is called twice in the same millisecond...
|
||||
if test_table_name.last_ms >= current_ms:
|
||||
current_ms = test_table_name.last_ms + 1
|
||||
test_table_name.last_ms = current_ms
|
||||
return test_table_prefix + str(current_ms)
|
||||
test_table_name.last_ms = 0
|
||||
|
||||
def create_test_table(dynamodb, **kwargs):
|
||||
name = test_table_name()
|
||||
print("fixture creating new table {}".format(name))
|
||||
table = dynamodb.create_table(TableName=name,
|
||||
BillingMode='PAY_PER_REQUEST', **kwargs)
|
||||
waiter = table.meta.client.get_waiter('table_exists')
|
||||
# recheck every second instead of the default, lower, frequency. This can
|
||||
# save a few seconds on AWS with its very slow table creation, but can
|
||||
# more on tests on Scylla with its faster table creation turnaround.
|
||||
waiter.config.delay = 1
|
||||
waiter.config.max_attempts = 200
|
||||
waiter.wait(TableName=name)
|
||||
return table
|
||||
|
||||
# DynamoDB's ListTables request returns up to a single page of table names
|
||||
# (e.g., up to 100) and it is up to the caller to call it again and again
|
||||
# to get the next page. This is a utility function which calls it repeatedly
|
||||
# as much as necessary to get the entire list.
|
||||
# We deliberately return a list and not a set, because we want the caller
|
||||
# to be able to recognize bugs in ListTables which causes the same table
|
||||
# to be returned twice.
|
||||
def list_tables(dynamodb, limit=100):
|
||||
ret = []
|
||||
pos = None
|
||||
while True:
|
||||
if pos:
|
||||
page = dynamodb.meta.client.list_tables(Limit=limit, ExclusiveStartTableName=pos);
|
||||
else:
|
||||
page = dynamodb.meta.client.list_tables(Limit=limit);
|
||||
results = page.get('TableNames', None)
|
||||
assert(results)
|
||||
ret = ret + results
|
||||
newpos = page.get('LastEvaluatedTableName', None)
|
||||
if not newpos:
|
||||
break;
|
||||
# It doesn't make sense for Dynamo to tell us we need more pages, but
|
||||
# not send anything in *this* page!
|
||||
assert len(results) > 0
|
||||
assert newpos != pos
|
||||
# Note that we only checked that we got back tables, not that we got
|
||||
# any new tables not already in ret. So a buggy implementation might
|
||||
# still cause an endless loop getting the same tables again and again.
|
||||
pos = newpos
|
||||
return ret
|
||||
111
alternator/base64.cc
Normal file
111
alternator/base64.cc
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The DynamoAPI dictates that "binary" (a.k.a. "bytes" or "blob") values
|
||||
// be encoded in the JSON API as base64-encoded strings. This is code to
|
||||
// convert byte arrays to base64-encoded strings, and back.
|
||||
|
||||
#include "base64.hh"
|
||||
|
||||
#include <ctype.h>
|
||||
|
||||
|
||||
// Arrays for quickly converting to and from an integer between 0 and 63,
|
||||
// and the character used in base64 encoding to represent it.
|
||||
static class base64_chars {
|
||||
public:
|
||||
static constexpr const char* to =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
int8_t from[255];
|
||||
base64_chars() {
|
||||
static_assert(strlen(to) == 64);
|
||||
for (int i = 0; i < 255; i++) {
|
||||
from[i] = 255; // signal invalid character
|
||||
}
|
||||
for (int i = 0; i < 64; i++) {
|
||||
from[(unsigned) to[i]] = i;
|
||||
}
|
||||
}
|
||||
} base64_chars;
|
||||
|
||||
std::string base64_encode(bytes_view in) {
|
||||
std::string ret;
|
||||
ret.reserve(((4 * in.size() / 3) + 3) & ~3);
|
||||
int i = 0;
|
||||
unsigned char chunk3[3]; // chunk of input
|
||||
for (auto byte : in) {
|
||||
chunk3[i++] = byte;
|
||||
if (i == 3) {
|
||||
ret += base64_chars.to[ (chunk3[0] & 0xfc) >> 2 ];
|
||||
ret += base64_chars.to[ ((chunk3[0] & 0x03) << 4) + ((chunk3[1] & 0xf0) >> 4) ];
|
||||
ret += base64_chars.to[ ((chunk3[1] & 0x0f) << 2) + ((chunk3[2] & 0xc0) >> 6) ];
|
||||
ret += base64_chars.to[ chunk3[2] & 0x3f ];
|
||||
i = 0;
|
||||
}
|
||||
}
|
||||
if (i) {
|
||||
// i can be 1 or 2.
|
||||
for(int j = i; j < 3; j++)
|
||||
chunk3[j] = '\0';
|
||||
ret += base64_chars.to[ ( chunk3[0] & 0xfc) >> 2 ];
|
||||
ret += base64_chars.to[ ((chunk3[0] & 0x03) << 4) + ((chunk3[1] & 0xf0) >> 4) ];
|
||||
if (i == 2) {
|
||||
ret += base64_chars.to[ ((chunk3[1] & 0x0f) << 2) + ((chunk3[2] & 0xc0) >> 6) ];
|
||||
} else {
|
||||
ret += '=';
|
||||
}
|
||||
ret += '=';
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
bytes base64_decode(std::string_view in) {
|
||||
int i = 0;
|
||||
int8_t chunk4[4]; // chunk of input, each byte converted to 0..63;
|
||||
std::string ret;
|
||||
ret.reserve(in.size() * 3 / 4);
|
||||
for (unsigned char c : in) {
|
||||
uint8_t dc = base64_chars.from[c];
|
||||
if (dc == 255) {
|
||||
// Any unexpected character, include the "=" character usually
|
||||
// used for padding, signals the end of the decode.
|
||||
break;
|
||||
}
|
||||
chunk4[i++] = dc;
|
||||
if (i == 4) {
|
||||
ret += (chunk4[0] << 2) + ((chunk4[1] & 0x30) >> 4);
|
||||
ret += ((chunk4[1] & 0xf) << 4) + ((chunk4[2] & 0x3c) >> 2);
|
||||
ret += ((chunk4[2] & 0x3) << 6) + chunk4[3];
|
||||
i = 0;
|
||||
}
|
||||
}
|
||||
if (i) {
|
||||
// i can be 2 or 3, meaning 1 or 2 more output characters
|
||||
if (i>=2)
|
||||
ret += (chunk4[0] << 2) + ((chunk4[1] & 0x30) >> 4);
|
||||
if (i==3)
|
||||
ret += ((chunk4[1] & 0xf) << 4) + ((chunk4[2] & 0x3c) >> 2);
|
||||
}
|
||||
// FIXME: This copy is sad. The problem is we need back "bytes"
|
||||
// but "bytes" doesn't have efficient append and std::string.
|
||||
// To fix this we need to use bytes' "uninitialized" feature.
|
||||
return bytes(ret.begin(), ret.end());
|
||||
}
|
||||
28
alternator/base64.hh
Normal file
28
alternator/base64.hh
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include "bytes.hh"
|
||||
|
||||
std::string base64_encode(bytes_view);
|
||||
bytes base64_decode(std::string_view);
|
||||
245
alternator/conditions.cc
Normal file
245
alternator/conditions.cc
Normal file
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <list>
|
||||
#include <map>
|
||||
#include <string_view>
|
||||
#include "alternator/conditions.hh"
|
||||
#include "alternator/error.hh"
|
||||
#include "cql3/constants.hh"
|
||||
#include <unordered_map>
|
||||
#include "rjson.hh"
|
||||
|
||||
namespace alternator {
|
||||
|
||||
static logging::logger clogger("alternator-conditions");
|
||||
|
||||
comparison_operator_type get_comparison_operator(const rjson::value& comparison_operator) {
|
||||
static std::unordered_map<std::string, comparison_operator_type> ops = {
|
||||
{"EQ", comparison_operator_type::EQ},
|
||||
{"LE", comparison_operator_type::LE},
|
||||
{"LT", comparison_operator_type::LT},
|
||||
{"GE", comparison_operator_type::GE},
|
||||
{"GT", comparison_operator_type::GT},
|
||||
{"BETWEEN", comparison_operator_type::BETWEEN},
|
||||
{"BEGINS_WITH", comparison_operator_type::BEGINS_WITH},
|
||||
}; //TODO(sarna): NE, IN, CONTAINS, NULL, NOT_NULL
|
||||
if (!comparison_operator.IsString()) {
|
||||
throw api_error("ValidationException", format("Invalid comparison operator definition {}", rjson::print(comparison_operator)));
|
||||
}
|
||||
std::string op = comparison_operator.GetString();
|
||||
auto it = ops.find(op);
|
||||
if (it == ops.end()) {
|
||||
throw api_error("ValidationException", format("Unsupported comparison operator {}", op));
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
static ::shared_ptr<cql3::restrictions::single_column_restriction::contains> make_map_element_restriction(const column_definition& cdef, std::string_view key, const rjson::value& value) {
|
||||
bytes raw_key = utf8_type->from_string(sstring_view(key.data(), key.size()));
|
||||
auto key_value = ::make_shared<cql3::constants::value>(cql3::raw_value::make_value(std::move(raw_key)));
|
||||
bytes raw_value = serialize_item(value);
|
||||
auto entry_value = ::make_shared<cql3::constants::value>(cql3::raw_value::make_value(std::move(raw_value)));
|
||||
return make_shared<cql3::restrictions::single_column_restriction::contains>(cdef, std::move(key_value), std::move(entry_value));
|
||||
}
|
||||
|
||||
static ::shared_ptr<cql3::restrictions::single_column_restriction::EQ> make_key_eq_restriction(const column_definition& cdef, const rjson::value& value) {
|
||||
bytes raw_value = get_key_from_typed_value(value, cdef, type_to_string(cdef.type));
|
||||
auto restriction_value = ::make_shared<cql3::constants::value>(cql3::raw_value::make_value(std::move(raw_value)));
|
||||
return make_shared<cql3::restrictions::single_column_restriction::EQ>(cdef, std::move(restriction_value));
|
||||
}
|
||||
|
||||
::shared_ptr<cql3::restrictions::statement_restrictions> get_filtering_restrictions(schema_ptr schema, const column_definition& attrs_col, const rjson::value& query_filter) {
|
||||
clogger.trace("Getting filtering restrictions for: {}", rjson::print(query_filter));
|
||||
auto filtering_restrictions = ::make_shared<cql3::restrictions::statement_restrictions>(schema, true);
|
||||
for (auto it = query_filter.MemberBegin(); it != query_filter.MemberEnd(); ++it) {
|
||||
std::string_view column_name(it->name.GetString(), it->name.GetStringLength());
|
||||
const rjson::value& condition = it->value;
|
||||
|
||||
const rjson::value& comp_definition = rjson::get(condition, "ComparisonOperator");
|
||||
const rjson::value& attr_list = rjson::get(condition, "AttributeValueList");
|
||||
comparison_operator_type op = get_comparison_operator(comp_definition);
|
||||
|
||||
if (op != comparison_operator_type::EQ) {
|
||||
throw api_error("ValidationException", "Filtering is currently implemented for EQ operator only");
|
||||
}
|
||||
if (attr_list.Size() != 1) {
|
||||
throw api_error("ValidationException", format("EQ restriction needs exactly 1 attribute value: {}", rjson::print(attr_list)));
|
||||
}
|
||||
if (const column_definition* cdef = schema->get_column_definition(to_bytes(column_name.data()))) {
|
||||
// Primary key restriction
|
||||
filtering_restrictions->add_restriction(make_key_eq_restriction(*cdef, attr_list[0]), false, true);
|
||||
} else {
|
||||
// Regular column restriction
|
||||
filtering_restrictions->add_restriction(make_map_element_restriction(attrs_col, column_name, attr_list[0]), false, true);
|
||||
}
|
||||
|
||||
}
|
||||
return filtering_restrictions;
|
||||
}
|
||||
|
||||
// Check if two JSON-encoded values match with the EQ relation
|
||||
static bool check_EQ(const rjson::value& v1, const rjson::value& v2) {
|
||||
return v1 == v2;
|
||||
}
|
||||
|
||||
// Check if two JSON-encoded values match with the BEGINS_WITH relation
|
||||
static bool check_BEGINS_WITH(const rjson::value& v1, const rjson::value& v2) {
|
||||
// BEGINS_WITH only supports comparing two strings or two binaries -
|
||||
// any other combinations of types, or other malformed values, return
|
||||
// false (no match).
|
||||
if (!v1.IsObject() || v1.MemberCount() != 1 || !v2.IsObject() || v2.MemberCount() != 1) {
|
||||
return false;
|
||||
}
|
||||
auto it1 = v1.MemberBegin();
|
||||
auto it2 = v2.MemberBegin();
|
||||
if (it1->name != it2->name) {
|
||||
return false;
|
||||
}
|
||||
if (it1->name != "S" && it1->name != "B") {
|
||||
return false;
|
||||
}
|
||||
std::string_view val1(it1->value.GetString(), it1->value.GetStringLength());
|
||||
std::string_view val2(it2->value.GetString(), it2->value.GetStringLength());
|
||||
return val1.substr(0, val2.size()) == val2;
|
||||
}
|
||||
|
||||
// Verify one Expect condition on one attribute (whose content is "got")
|
||||
// for the verify_expected() below.
|
||||
// This function returns true or false depending on whether the condition
|
||||
// succeeded - it does not throw ConditionalCheckFailedException.
|
||||
// However, it may throw ValidationException on input validation errors.
|
||||
static bool verify_expected_one(const rjson::value& condition, const rjson::value* got) {
|
||||
const rjson::value* comparison_operator = rjson::find(condition, "ComparisonOperator");
|
||||
const rjson::value* attribute_value_list = rjson::find(condition, "AttributeValueList");
|
||||
const rjson::value* value = rjson::find(condition, "Value");
|
||||
const rjson::value* exists = rjson::find(condition, "Exists");
|
||||
// There are three types of conditions that Expected supports:
|
||||
// A value, not-exists, and a comparison of some kind. Each allows
|
||||
// and requires a different combinations of parameters in the request
|
||||
if (value) {
|
||||
if (exists && (!exists->IsBool() || exists->GetBool() != true)) {
|
||||
throw api_error("ValidationException", "Cannot combine Value with Exists!=true");
|
||||
}
|
||||
if (comparison_operator) {
|
||||
throw api_error("ValidationException", "Cannot combine Value with ComparisonOperator");
|
||||
}
|
||||
return got && check_EQ(*got, *value);
|
||||
} else if (exists) {
|
||||
if (comparison_operator) {
|
||||
throw api_error("ValidationException", "Cannot combine Exists with ComparisonOperator");
|
||||
}
|
||||
if (!exists->IsBool() || exists->GetBool() != false) {
|
||||
throw api_error("ValidationException", "Exists!=false requires Value");
|
||||
}
|
||||
// Remember Exists=false, so we're checking that the attribute does *not* exist:
|
||||
return !got;
|
||||
} else {
|
||||
if (!comparison_operator) {
|
||||
throw api_error("ValidationException", "Missing ComparisonOperator, Value or Exists");
|
||||
}
|
||||
if (!attribute_value_list || !attribute_value_list->IsArray()) {
|
||||
throw api_error("ValidationException", "With ComparisonOperator, AttributeValueList must be given and an array");
|
||||
}
|
||||
comparison_operator_type op = get_comparison_operator(*comparison_operator);
|
||||
switch (op) {
|
||||
case comparison_operator_type::EQ:
|
||||
if (attribute_value_list->Size() != 1) {
|
||||
throw api_error("ValidationException", "EQ operator requires one element in AttributeValueList");
|
||||
}
|
||||
if (got) {
|
||||
const rjson::value& expected = (*attribute_value_list)[0];
|
||||
return check_EQ(*got, expected);
|
||||
}
|
||||
return false;
|
||||
case comparison_operator_type::BEGINS_WITH:
|
||||
if (attribute_value_list->Size() != 1) {
|
||||
throw api_error("ValidationException", "BEGINS_WITH operator requires one element in AttributeValueList");
|
||||
}
|
||||
if (got) {
|
||||
const rjson::value& expected = (*attribute_value_list)[0];
|
||||
return check_BEGINS_WITH(*got, expected);
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
// FIXME: implement all the missing types, so there will be no default here.
|
||||
throw api_error("ValidationException", format("ComparisonOperator {} is not yet supported", *comparison_operator));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify that the existing values of the item (previous_item) match the
|
||||
// conditions given by the Expected and ConditionalOperator parameters
|
||||
// (if they exist) in the request (an UpdateItem, PutItem or DeleteItem).
|
||||
// This function will throw a ConditionalCheckFailedException API error
|
||||
// if the values do not match the condition, or ValidationException if there
|
||||
// are errors in the format of the condition itself.
|
||||
void verify_expected(const rjson::value& req, const std::unique_ptr<rjson::value>& previous_item) {
|
||||
const rjson::value* expected = rjson::find(req, "Expected");
|
||||
if (!expected) {
|
||||
return;
|
||||
}
|
||||
if (!expected->IsObject()) {
|
||||
throw api_error("ValidationException", "'Expected' parameter, if given, must be an object");
|
||||
}
|
||||
// ConditionalOperator can be "AND" for requiring all conditions, or
|
||||
// "OR" for requiring one condition, and defaults to "AND" if missing.
|
||||
const rjson::value* conditional_operator = rjson::find(req, "ConditionalOperator");
|
||||
bool require_all = true;
|
||||
if (conditional_operator) {
|
||||
if (!conditional_operator->IsString()) {
|
||||
throw api_error("ValidationException", "'ConditionalOperator' parameter, if given, must be a string");
|
||||
}
|
||||
std::string_view s(conditional_operator->GetString(), conditional_operator->GetStringLength());
|
||||
if (s == "AND") {
|
||||
// require_all is already true
|
||||
} else if (s == "OR") {
|
||||
require_all = false;
|
||||
} else {
|
||||
throw api_error("ValidationException", "'ConditionalOperator' parameter must be AND, OR or missing");
|
||||
}
|
||||
if (expected->GetObject().ObjectEmpty()) {
|
||||
throw api_error("ValidationException", "'ConditionalOperator' parameter cannot be specified for empty Expression");
|
||||
}
|
||||
}
|
||||
|
||||
for (auto it = expected->MemberBegin(); it != expected->MemberEnd(); ++it) {
|
||||
const rjson::value* got = nullptr;
|
||||
if (previous_item && previous_item->IsObject() && previous_item->HasMember("Item")) {
|
||||
got = rjson::find((*previous_item)["Item"], rjson::string_ref_type(it->name.GetString()));
|
||||
}
|
||||
bool success = verify_expected_one(it->value, got);
|
||||
if (success && !require_all) {
|
||||
// When !require_all, one success is enough!
|
||||
return;
|
||||
} else if (!success && require_all) {
|
||||
// When require_all, one failure is enough!
|
||||
throw api_error("ConditionalCheckFailedException", "Failed condition.");
|
||||
}
|
||||
}
|
||||
// If we got here and require_all, none of the checks failed, so succeed.
|
||||
// If we got here and !require_all, all of the checks failed, so fail.
|
||||
if (!require_all) {
|
||||
throw api_error("ConditionalCheckFailedException", "None of ORed Expect conditions were successful.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
49
alternator/conditions.hh
Normal file
49
alternator/conditions.hh
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file contains definitions and functions related to placing conditions
|
||||
* on Alternator queries (equivalent of CQL's restrictions).
|
||||
*
|
||||
* With conditions, it's possible to add criteria to selection requests (Scan, Query)
|
||||
* and use them for narrowing down the result set, by means of filtering or indexing.
|
||||
*
|
||||
* Ref: https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Condition.html
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "cql3/restrictions/statement_restrictions.hh"
|
||||
#include "serialization.hh"
|
||||
|
||||
namespace alternator {
|
||||
|
||||
enum class comparison_operator_type {
|
||||
EQ, NE, LE, LT, GE, GT, IN, BETWEEN, CONTAINS, IS_NULL, NOT_NULL, BEGINS_WITH
|
||||
};
|
||||
|
||||
comparison_operator_type get_comparison_operator(const rjson::value& comparison_operator);
|
||||
|
||||
::shared_ptr<cql3::restrictions::statement_restrictions> get_filtering_restrictions(schema_ptr schema, const column_definition& attrs_col, const rjson::value& query_filter);
|
||||
|
||||
void verify_expected(const rjson::value& req, const std::unique_ptr<rjson::value>& previous_item);
|
||||
|
||||
}
|
||||
50
alternator/error.hh
Normal file
50
alternator/error.hh
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <seastar/http/httpd.hh>
|
||||
#include "seastarx.hh"
|
||||
|
||||
namespace alternator {
|
||||
|
||||
// DynamoDB's error messages are described in detail in
|
||||
// https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html
|
||||
// Ah An error message has a "type", e.g., "ResourceNotFoundException", a coarser
|
||||
// HTTP code (almost always, 400), and a human readable message. Eventually these
|
||||
// will be wrapped into a JSON object returned to the client.
|
||||
class api_error : public std::exception {
|
||||
public:
|
||||
using status_type = httpd::reply::status_type;
|
||||
status_type _http_code;
|
||||
std::string _type;
|
||||
std::string _msg;
|
||||
api_error(std::string type, std::string msg, status_type http_code = status_type::bad_request)
|
||||
: _http_code(std::move(http_code))
|
||||
, _type(std::move(type))
|
||||
, _msg(std::move(msg))
|
||||
{ }
|
||||
api_error() = default;
|
||||
virtual const char* what() const noexcept override { return _msg.c_str(); }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
2236
alternator/executor.cc
Normal file
2236
alternator/executor.cc
Normal file
File diff suppressed because it is too large
Load Diff
71
alternator/executor.hh
Normal file
71
alternator/executor.hh
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <seastar/core/future.hh>
|
||||
#include <seastar/http/httpd.hh>
|
||||
#include "seastarx.hh"
|
||||
#include <seastar/json/json_elements.hh>
|
||||
|
||||
#include "service/storage_proxy.hh"
|
||||
#include "service/migration_manager.hh"
|
||||
#include "service/client_state.hh"
|
||||
|
||||
#include "stats.hh"
|
||||
|
||||
namespace alternator {
|
||||
|
||||
class executor {
|
||||
service::storage_proxy& _proxy;
|
||||
service::migration_manager& _mm;
|
||||
|
||||
public:
|
||||
using client_state = service::client_state;
|
||||
stats _stats;
|
||||
static constexpr auto ATTRS_COLUMN_NAME = ":attrs";
|
||||
static constexpr auto KEYSPACE_NAME = "alternator";
|
||||
|
||||
executor(service::storage_proxy& proxy, service::migration_manager& mm) : _proxy(proxy), _mm(mm) {}
|
||||
|
||||
future<json::json_return_type> create_table(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> describe_table(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> delete_table(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> put_item(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> get_item(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> delete_item(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> update_item(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> list_tables(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> scan(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> describe_endpoints(client_state& client_state, std::string content, std::string host_header);
|
||||
future<json::json_return_type> batch_write_item(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> batch_get_item(client_state& client_state, std::string content);
|
||||
future<json::json_return_type> query(client_state& client_state, std::string content);
|
||||
|
||||
future<> start();
|
||||
future<> stop() { return make_ready_future<>(); }
|
||||
|
||||
future<> maybe_create_keyspace();
|
||||
|
||||
static void maybe_trace_query(client_state& client_state, sstring_view op, sstring_view query);
|
||||
};
|
||||
|
||||
}
|
||||
98
alternator/expressions.cc
Normal file
98
alternator/expressions.cc
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "expressions.hh"
|
||||
#include "alternator/expressionsLexer.hpp"
|
||||
#include "alternator/expressionsParser.hpp"
|
||||
|
||||
#include <seastarx.hh>
|
||||
|
||||
#include <seastar/core/print.hh>
|
||||
#include <seastar/util/log.hh>
|
||||
|
||||
#include <functional>
|
||||
|
||||
namespace alternator {
|
||||
|
||||
template <typename Func, typename Result = std::result_of_t<Func(expressionsParser&)>>
|
||||
Result do_with_parser(std::string input, Func&& f) {
|
||||
expressionsLexer::InputStreamType input_stream{
|
||||
reinterpret_cast<const ANTLR_UINT8*>(input.data()),
|
||||
ANTLR_ENC_UTF8,
|
||||
static_cast<ANTLR_UINT32>(input.size()),
|
||||
nullptr };
|
||||
expressionsLexer lexer(&input_stream);
|
||||
expressionsParser::TokenStreamType tstream(ANTLR_SIZE_HINT, lexer.get_tokSource());
|
||||
expressionsParser parser(&tstream);
|
||||
|
||||
auto result = f(parser);
|
||||
return result;
|
||||
}
|
||||
|
||||
parsed::update_expression
|
||||
parse_update_expression(std::string query) {
|
||||
try {
|
||||
return do_with_parser(query, std::mem_fn(&expressionsParser::update_expression));
|
||||
} catch (...) {
|
||||
throw expressions_syntax_error(format("Failed parsing UpdateExpression '{}': {}", query, std::current_exception()));
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<parsed::path>
|
||||
parse_projection_expression(std::string query) {
|
||||
try {
|
||||
return do_with_parser(query, std::mem_fn(&expressionsParser::projection_expression));
|
||||
} catch (...) {
|
||||
throw expressions_syntax_error(format("Failed parsing ProjectionExpression '{}': {}", query, std::current_exception()));
|
||||
}
|
||||
}
|
||||
|
||||
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
|
||||
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
|
||||
|
||||
namespace parsed {
|
||||
|
||||
void update_expression::add(update_expression::action a) {
|
||||
std::visit(overloaded {
|
||||
[&] (action::set&) { seen_set = true; },
|
||||
[&] (action::remove&) { seen_remove = true; },
|
||||
[&] (action::add&) { seen_add = true; },
|
||||
[&] (action::del&) { seen_del = true; }
|
||||
}, a._action);
|
||||
_actions.push_back(std::move(a));
|
||||
}
|
||||
|
||||
void update_expression::append(update_expression other) {
|
||||
if ((seen_set && other.seen_set) ||
|
||||
(seen_remove && other.seen_remove) ||
|
||||
(seen_add && other.seen_add) ||
|
||||
(seen_del && other.seen_del)) {
|
||||
throw expressions_syntax_error("Each of SET, REMOVE, ADD, DELETE may only appear once in UpdateExpression");
|
||||
}
|
||||
std::move(other._actions.begin(), other._actions.end(), std::back_inserter(_actions));
|
||||
seen_set |= other.seen_set;
|
||||
seen_remove |= other.seen_remove;
|
||||
seen_add |= other.seen_add;
|
||||
seen_del |= other.seen_del;
|
||||
}
|
||||
|
||||
} // namespace parsed
|
||||
} // namespace alternator
|
||||
214
alternator/expressions.g
Normal file
214
alternator/expressions.g
Normal file
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*
|
||||
* This file is part of Scylla. See the LICENSE.PROPRIETARY file in the
|
||||
* top-level directory for licensing information.
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/*
|
||||
* The DynamoDB protocol is based on JSON, and most DynamoDB requests
|
||||
* describe the operation and its parameters via JSON objects such as maps
|
||||
* and lists. Nevertheless, in some types of requests an "expression" is
|
||||
* passed as a single string, and we need to parse this string. These
|
||||
* cases include:
|
||||
* 1. Attribute paths, such as "a[3].b.c", are used in projection
|
||||
* expressions as well as inside other expressions described below.
|
||||
* 2. Condition expressions, such as "(NOT (a=b OR c=d)) AND e=f",
|
||||
* used in conditional updates, filters, and other places.
|
||||
* 3. Update expressions, such as "SET #a.b = :x, c = :y DELETE d"
|
||||
*
|
||||
* All these expression syntaxes are very simple: Most of them could be
|
||||
* parsed as regular expressions, and the parenthesized condition expression
|
||||
* could be done with a simple hand-written lexical analyzer and recursive-
|
||||
* descent parser. Nevertheless, we decided to specify these parsers in the
|
||||
* ANTLR3 language already used in the Scylla project, hopefully making these
|
||||
* parsers easier to reason about, and easier to change if needed - and
|
||||
* reducing the amount of boiler-plate code.
|
||||
*/
|
||||
|
||||
grammar expressions;
|
||||
|
||||
options {
|
||||
language = Cpp;
|
||||
}
|
||||
|
||||
@parser::namespace{alternator}
|
||||
@lexer::namespace{alternator}
|
||||
|
||||
/* TODO: explain what these traits things are. I haven't seen them explained
|
||||
* in any document... Compilation fails without these fail because a definition
|
||||
* of "expressionsLexerTraits" and "expressionParserTraits" is needed.
|
||||
*/
|
||||
@lexer::traits {
|
||||
class expressionsLexer;
|
||||
class expressionsParser;
|
||||
typedef antlr3::Traits<expressionsLexer, expressionsParser> expressionsLexerTraits;
|
||||
}
|
||||
@parser::traits {
|
||||
typedef expressionsLexerTraits expressionsParserTraits;
|
||||
}
|
||||
|
||||
@lexer::header {
|
||||
#include "alternator/expressions.hh"
|
||||
// ANTLR generates a bunch of unused variables and functions. Yuck...
|
||||
#pragma GCC diagnostic ignored "-Wunused-variable"
|
||||
#pragma GCC diagnostic ignored "-Wunused-function"
|
||||
}
|
||||
@parser::header {
|
||||
#include "expressionsLexer.hpp"
|
||||
}
|
||||
|
||||
/* By default, ANTLR3 composes elaborate syntax-error messages, saying which
|
||||
* token was unexpected, where, and so on on, but then dutifully writes these
|
||||
* error messages to the standard error, and returns from the parser as if
|
||||
* everything was fine, with a half-constructed output object! If we define
|
||||
* the "displayRecognitionError" method, it will be called upon to build this
|
||||
* error message, and we can instead throw an exception to stop the parsing
|
||||
* immediately. This is good enough for now, for our simple needs, but if
|
||||
* we ever want to show more information about the syntax error, Cql3.g
|
||||
* contains an elaborate implementation (it would be nice if we could reuse
|
||||
* it, not duplicate it).
|
||||
* Unfortunately, we have to repeat the same definition twice - once for the
|
||||
* parser, and once for the lexer.
|
||||
*/
|
||||
@parser::context {
|
||||
void displayRecognitionError(ANTLR_UINT8** token_names, ExceptionBaseType* ex) {
|
||||
throw expressions_syntax_error("syntax error");
|
||||
}
|
||||
}
|
||||
@lexer::context {
|
||||
void displayRecognitionError(ANTLR_UINT8** token_names, ExceptionBaseType* ex) {
|
||||
throw expressions_syntax_error("syntax error");
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Lexical analysis phase, i.e., splitting the input up to tokens.
|
||||
* Lexical analyzer rules have names starting in capital letters.
|
||||
* "fragment" rules do not generate tokens, and are just aliases used to
|
||||
* make other rules more readable.
|
||||
* Characters *not* listed here, e.g., '=', '(', etc., will be handled
|
||||
* as individual tokens on their own right.
|
||||
* Whitespace spans are skipped, so do not generate tokens.
|
||||
*/
|
||||
WHITESPACE: (' ' | '\t' | '\n' | '\r')+ { skip(); };
|
||||
|
||||
/* shortcuts for case-insensitive keywords */
|
||||
fragment A:('a'|'A');
|
||||
fragment B:('b'|'B');
|
||||
fragment C:('c'|'C');
|
||||
fragment D:('d'|'D');
|
||||
fragment E:('e'|'E');
|
||||
fragment F:('f'|'F');
|
||||
fragment G:('g'|'G');
|
||||
fragment H:('h'|'H');
|
||||
fragment I:('i'|'I');
|
||||
fragment J:('j'|'J');
|
||||
fragment K:('k'|'K');
|
||||
fragment L:('l'|'L');
|
||||
fragment M:('m'|'M');
|
||||
fragment N:('n'|'N');
|
||||
fragment O:('o'|'O');
|
||||
fragment P:('p'|'P');
|
||||
fragment Q:('q'|'Q');
|
||||
fragment R:('r'|'R');
|
||||
fragment S:('s'|'S');
|
||||
fragment T:('t'|'T');
|
||||
fragment U:('u'|'U');
|
||||
fragment V:('v'|'V');
|
||||
fragment W:('w'|'W');
|
||||
fragment X:('x'|'X');
|
||||
fragment Y:('y'|'Y');
|
||||
fragment Z:('z'|'Z');
|
||||
/* These keywords must be appear before the generic NAME token below,
|
||||
* because NAME matches too, and the first to match wins.
|
||||
*/
|
||||
SET: S E T;
|
||||
REMOVE: R E M O V E;
|
||||
ADD: A D D;
|
||||
DELETE: D E L E T E;
|
||||
|
||||
fragment ALPHA: 'A'..'Z' | 'a'..'z';
|
||||
fragment DIGIT: '0'..'9';
|
||||
fragment ALNUM: ALPHA | DIGIT | '_';
|
||||
INTEGER: DIGIT+;
|
||||
NAME: ALPHA ALNUM*;
|
||||
NAMEREF: '#' ALNUM+;
|
||||
VALREF: ':' ALNUM+;
|
||||
|
||||
/*
|
||||
* Parsing phase - parsing the string of tokens generated by the lexical
|
||||
* analyzer defined above.
|
||||
*/
|
||||
|
||||
path_component: NAME | NAMEREF;
|
||||
path returns [parsed::path p]:
|
||||
root=path_component { $p.set_root($root.text); }
|
||||
( '.' name=path_component { $p.add_dot($name.text); }
|
||||
| '[' INTEGER ']' { $p.add_index(std::stoi($INTEGER.text)); }
|
||||
)*;
|
||||
|
||||
update_expression_set_value returns [parsed::value v]:
|
||||
VALREF { $v.set_valref($VALREF.text); }
|
||||
| path { $v.set_path($path.p); }
|
||||
| NAME { $v.set_func_name($NAME.text); }
|
||||
'(' x=update_expression_set_value { $v.add_func_parameter($x.v); }
|
||||
(',' x=update_expression_set_value { $v.add_func_parameter($x.v); })*
|
||||
')'
|
||||
;
|
||||
|
||||
update_expression_set_rhs returns [parsed::set_rhs rhs]:
|
||||
v=update_expression_set_value { $rhs.set_value(std::move($v.v)); }
|
||||
( '+' v=update_expression_set_value { $rhs.set_plus(std::move($v.v)); }
|
||||
| '-' v=update_expression_set_value { $rhs.set_minus(std::move($v.v)); }
|
||||
)?
|
||||
;
|
||||
|
||||
update_expression_set_action returns [parsed::update_expression::action a]:
|
||||
path '=' rhs=update_expression_set_rhs { $a.assign_set($path.p, $rhs.rhs); };
|
||||
|
||||
update_expression_remove_action returns [parsed::update_expression::action a]:
|
||||
path { $a.assign_remove($path.p); };
|
||||
|
||||
update_expression_add_action returns [parsed::update_expression::action a]:
|
||||
path VALREF { $a.assign_add($path.p, $VALREF.text); };
|
||||
|
||||
update_expression_delete_action returns [parsed::update_expression::action a]:
|
||||
path VALREF { $a.assign_del($path.p, $VALREF.text); };
|
||||
|
||||
update_expression_clause returns [parsed::update_expression e]:
|
||||
SET s=update_expression_set_action { $e.add(s); }
|
||||
(',' s=update_expression_set_action { $e.add(s); })*
|
||||
| REMOVE r=update_expression_remove_action { $e.add(r); }
|
||||
(',' r=update_expression_remove_action { $e.add(r); })*
|
||||
| ADD a=update_expression_add_action { $e.add(a); }
|
||||
(',' a=update_expression_add_action { $e.add(a); })*
|
||||
| DELETE d=update_expression_delete_action { $e.add(d); }
|
||||
(',' d=update_expression_delete_action { $e.add(d); })*
|
||||
;
|
||||
|
||||
// Note the "EOF" token at the end of the update expression. We want to the
|
||||
// parser to match the entire string given to it - not just its beginning!
|
||||
update_expression returns [parsed::update_expression e]:
|
||||
(update_expression_clause { e.append($update_expression_clause.e); })* EOF;
|
||||
|
||||
projection_expression returns [std::vector<parsed::path> v]:
|
||||
p=path { $v.push_back(std::move($p.p)); }
|
||||
(',' p=path { $v.push_back(std::move($p.p)); } )* EOF;
|
||||
41
alternator/expressions.hh
Normal file
41
alternator/expressions.hh
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
#include "expressions_types.hh"
|
||||
|
||||
namespace alternator {
|
||||
|
||||
class expressions_syntax_error : public std::runtime_error {
|
||||
public:
|
||||
using runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
parsed::update_expression parse_update_expression(std::string query);
|
||||
std::vector<parsed::path> parse_projection_expression(std::string query);
|
||||
|
||||
|
||||
} /* namespace alternator */
|
||||
166
alternator/expressions_types.hh
Normal file
166
alternator/expressions_types.hh
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
|
||||
/*
|
||||
* Parsed representation of expressions and their components.
|
||||
*
|
||||
* Types in alternator::parse namespace are used for holding the parse
|
||||
* tree - objects generated by the Antlr rules after parsing an expression.
|
||||
* Because of the way Antlr works, all these objects are default-constructed
|
||||
* first, and then assigned when the rule is completed, so all these types
|
||||
* have only default constructors - but setter functions to set them later.
|
||||
*/
|
||||
|
||||
namespace alternator {
|
||||
namespace parsed {
|
||||
|
||||
// "path" is an attribute's path in a document, e.g., a.b[3].c.
|
||||
class path {
|
||||
// All paths have a "root", a top-level attribute, and any number of
|
||||
// "dereference operators" - each either an index (e.g., "[2]") or a
|
||||
// dot (e.g., ".xyz").
|
||||
std::string _root;
|
||||
std::vector<std::variant<std::string, unsigned>> _operators;
|
||||
public:
|
||||
void set_root(std::string root) {
|
||||
_root = std::move(root);
|
||||
}
|
||||
void add_index(unsigned i) {
|
||||
_operators.emplace_back(i);
|
||||
}
|
||||
void add_dot(std::string(name)) {
|
||||
_operators.emplace_back(std::move(name));
|
||||
}
|
||||
const std::string& root() const {
|
||||
return _root;
|
||||
}
|
||||
bool has_operators() const {
|
||||
return !_operators.empty();
|
||||
}
|
||||
};
|
||||
|
||||
// "value" is is a value used in the right hand side of an assignment
|
||||
// expression, "SET a = ...". It can be a reference to a value included in
|
||||
// the request (":val"), a path to an attribute from the existing item
|
||||
// (e.g., "a.b[3].c"), or a function of other such values.
|
||||
// Note that the real right-hand-side of an assignment is actually a bit
|
||||
// more general - it allows either a value, or a value+value or value-value -
|
||||
// see class set_rhs below.
|
||||
struct value {
|
||||
struct function_call {
|
||||
std::string _function_name;
|
||||
std::vector<value> _parameters;
|
||||
};
|
||||
std::variant<std::string, path, function_call> _value;
|
||||
void set_valref(std::string s) {
|
||||
_value = std::move(s);
|
||||
}
|
||||
void set_path(path p) {
|
||||
_value = std::move(p);
|
||||
}
|
||||
void set_func_name(std::string s) {
|
||||
_value = function_call {std::move(s), {}};
|
||||
}
|
||||
void add_func_parameter(value v) {
|
||||
std::get<function_call>(_value)._parameters.emplace_back(std::move(v));
|
||||
}
|
||||
};
|
||||
|
||||
// The right-hand-side of a SET in an update expression can be either a
|
||||
// single value (see above), or value+value, or value-value.
|
||||
class set_rhs {
|
||||
public:
|
||||
char _op; // '+', '-', or 'v''
|
||||
value _v1;
|
||||
value _v2;
|
||||
void set_value(value&& v1) {
|
||||
_op = 'v';
|
||||
_v1 = std::move(v1);
|
||||
}
|
||||
void set_plus(value&& v2) {
|
||||
_op = '+';
|
||||
_v2 = std::move(v2);
|
||||
}
|
||||
void set_minus(value&& v2) {
|
||||
_op = '-';
|
||||
_v2 = std::move(v2);
|
||||
}
|
||||
};
|
||||
|
||||
class update_expression {
|
||||
public:
|
||||
struct action {
|
||||
path _path;
|
||||
struct set {
|
||||
set_rhs _rhs;
|
||||
};
|
||||
struct remove {
|
||||
};
|
||||
struct add {
|
||||
std::string _valref;
|
||||
};
|
||||
struct del {
|
||||
std::string _valref;
|
||||
};
|
||||
std::variant<set, remove, add, del> _action;
|
||||
|
||||
void assign_set(path p, set_rhs rhs) {
|
||||
_path = std::move(p);
|
||||
_action = set { std::move(rhs) };
|
||||
}
|
||||
void assign_remove(path p) {
|
||||
_path = std::move(p);
|
||||
_action = remove { };
|
||||
}
|
||||
void assign_add(path p, std::string v) {
|
||||
_path = std::move(p);
|
||||
_action = add { std::move(v) };
|
||||
}
|
||||
void assign_del(path p, std::string v) {
|
||||
_path = std::move(p);
|
||||
_action = del { std::move(v) };
|
||||
}
|
||||
};
|
||||
private:
|
||||
std::vector<action> _actions;
|
||||
bool seen_set = false;
|
||||
bool seen_remove = false;
|
||||
bool seen_add = false;
|
||||
bool seen_del = false;
|
||||
public:
|
||||
void add(action a);
|
||||
void append(update_expression other);
|
||||
bool empty() const {
|
||||
return _actions.empty();
|
||||
}
|
||||
const std::vector<action>& actions() const {
|
||||
return _actions;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace parsed
|
||||
} // namespace alternator
|
||||
120
alternator/rjson.cc
Normal file
120
alternator/rjson.cc
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "rjson.hh"
|
||||
#include "error.hh"
|
||||
#include <seastar/core/print.hh>
|
||||
|
||||
namespace rjson {
|
||||
|
||||
static allocator the_allocator;
|
||||
|
||||
std::string print(const rjson::value& value) {
|
||||
string_buffer buffer;
|
||||
writer writer(buffer);
|
||||
value.Accept(writer);
|
||||
return std::string(buffer.GetString());
|
||||
}
|
||||
|
||||
rjson::value copy(const rjson::value& value) {
|
||||
return rjson::value(value, the_allocator);
|
||||
}
|
||||
|
||||
rjson::value parse(const std::string& str) {
|
||||
return parse_raw(str.c_str(), str.size());
|
||||
}
|
||||
|
||||
rjson::value parse_raw(const char* c_str, size_t size) {
|
||||
rjson::document d;
|
||||
d.Parse(c_str, size);
|
||||
if (d.HasParseError()) {
|
||||
throw rjson::error(format("Parsing JSON failed: {}", GetParseError_En(d.GetParseError())));
|
||||
}
|
||||
rjson::value& v = d;
|
||||
return std::move(v);
|
||||
}
|
||||
|
||||
rjson::value& get(rjson::value& value, rjson::string_ref_type name) {
|
||||
auto member_it = value.FindMember(name);
|
||||
if (member_it != value.MemberEnd())
|
||||
return member_it->value;
|
||||
else {
|
||||
throw rjson::error(format("JSON parameter {} not found", name));
|
||||
}
|
||||
}
|
||||
|
||||
const rjson::value& get(const rjson::value& value, rjson::string_ref_type name) {
|
||||
auto member_it = value.FindMember(name);
|
||||
if (member_it != value.MemberEnd())
|
||||
return member_it->value;
|
||||
else {
|
||||
throw rjson::error(format("JSON parameter {} not found", name));
|
||||
}
|
||||
}
|
||||
|
||||
rjson::value from_string(const std::string& str) {
|
||||
return rjson::value(str.c_str(), str.size(), the_allocator);
|
||||
}
|
||||
|
||||
rjson::value from_string(const sstring& str) {
|
||||
return rjson::value(str.c_str(), str.size(), the_allocator);
|
||||
}
|
||||
|
||||
rjson::value from_string(const char* str, size_t size) {
|
||||
return rjson::value(str, size, the_allocator);
|
||||
}
|
||||
|
||||
const rjson::value* find(const rjson::value& value, string_ref_type name) {
|
||||
auto member_it = value.FindMember(name);
|
||||
return member_it != value.MemberEnd() ? &member_it->value : nullptr;
|
||||
}
|
||||
|
||||
rjson::value* find(rjson::value& value, string_ref_type name) {
|
||||
auto member_it = value.FindMember(name);
|
||||
return member_it != value.MemberEnd() ? &member_it->value : nullptr;
|
||||
}
|
||||
|
||||
void set_with_string_name(rjson::value& base, const std::string& name, rjson::value&& member) {
|
||||
base.AddMember(rjson::value(name.c_str(), name.size(), the_allocator), std::move(member), the_allocator);
|
||||
}
|
||||
|
||||
void set_with_string_name(rjson::value& base, const std::string& name, rjson::string_ref_type member) {
|
||||
base.AddMember(rjson::value(name.c_str(), name.size(), the_allocator), rjson::value(member), the_allocator);
|
||||
}
|
||||
|
||||
void set(rjson::value& base, rjson::string_ref_type name, rjson::value&& member) {
|
||||
base.AddMember(name, std::move(member), the_allocator);
|
||||
}
|
||||
|
||||
void set(rjson::value& base, rjson::string_ref_type name, rjson::string_ref_type member) {
|
||||
base.AddMember(name, rjson::value(member), the_allocator);
|
||||
}
|
||||
|
||||
void push_back(rjson::value& base_array, rjson::value&& item) {
|
||||
base_array.PushBack(std::move(item), the_allocator);
|
||||
|
||||
}
|
||||
|
||||
} // end namespace rjson
|
||||
|
||||
std::ostream& std::operator<<(std::ostream& os, const rjson::value& v) {
|
||||
return os << rjson::print(v);
|
||||
}
|
||||
159
alternator/rjson.hh
Normal file
159
alternator/rjson.hh
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
/*
|
||||
* rjson is a wrapper over rapidjson library, providing fast JSON parsing and generation.
|
||||
*
|
||||
* rapidjson has strict copy elision policies, which, among other things, involves
|
||||
* using provided char arrays without copying them and allows copying objects only explicitly.
|
||||
* As such, one should be careful when passing strings with limited liveness
|
||||
* (e.g. data underneath local std::strings) to rjson functions, because created JSON objects
|
||||
* may end up relying on dangling char pointers. All rjson functions that create JSONs from strings
|
||||
* by rjson have both APIs for string_ref_type (more optimal, used when the string is known to live
|
||||
* at least as long as the object, e.g. a static char array) and for std::strings. The more optimal
|
||||
* variants should be used *only* if the liveness of the string is guaranteed, otherwise it will
|
||||
* result in undefined behaviour.
|
||||
* Also, bear in mind that methods exposed by rjson::value are generic, but some of them
|
||||
* work fine only for specific types. In case the type does not match, an rjson::error will be thrown.
|
||||
* Examples of such mismatched usages is calling MemberCount() on a JSON value not of object type
|
||||
* or calling Size() on a non-array value.
|
||||
*/
|
||||
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace rjson {
|
||||
class error : public std::exception {
|
||||
std::string _msg;
|
||||
public:
|
||||
error() = default;
|
||||
error(const std::string& msg) : _msg(msg) {}
|
||||
|
||||
virtual const char* what() const noexcept override { return _msg.c_str(); }
|
||||
};
|
||||
}
|
||||
|
||||
// rapidjson configuration macros
|
||||
#define RAPIDJSON_HAS_STDSTRING 1
|
||||
// Default rjson policy is to use assert() - which is dangerous for two reasons:
|
||||
// 1. assert() can be turned off with -DNDEBUG
|
||||
// 2. assert() crashes a program
|
||||
// Fortunately, the default policy can be overridden, and so rapidjson errors will
|
||||
// throw an rjson::error exception instead.
|
||||
#define RAPIDJSON_ASSERT(x) do { if (!(x)) throw rjson::error(std::string("JSON error: condition not met: ") + #x); } while (0)
|
||||
|
||||
#include <rapidjson/document.h>
|
||||
#include <rapidjson/writer.h>
|
||||
#include <rapidjson/stringbuffer.h>
|
||||
#include <rapidjson/error/en.h>
|
||||
#include <seastar/core/sstring.hh>
|
||||
#include "seastarx.hh"
|
||||
|
||||
namespace rjson {
|
||||
|
||||
using allocator = rapidjson::CrtAllocator;
|
||||
using encoding = rapidjson::UTF8<>;
|
||||
using document = rapidjson::GenericDocument<encoding, allocator>;
|
||||
using value = rapidjson::GenericValue<encoding, allocator>;
|
||||
using string_ref_type = value::StringRefType;
|
||||
using string_buffer = rapidjson::GenericStringBuffer<encoding>;
|
||||
using writer = rapidjson::Writer<string_buffer, encoding>;
|
||||
using type = rapidjson::Type;
|
||||
|
||||
// Returns an object representing JSON's null
|
||||
inline rjson::value null_value() {
|
||||
return rjson::value(rapidjson::kNullType);
|
||||
}
|
||||
|
||||
// Returns an empty JSON object - {}
|
||||
inline rjson::value empty_object() {
|
||||
return rjson::value(rapidjson::kObjectType);
|
||||
}
|
||||
|
||||
// Returns an empty JSON array - []
|
||||
inline rjson::value empty_array() {
|
||||
return rjson::value(rapidjson::kArrayType);
|
||||
}
|
||||
|
||||
// Returns an empty JSON string - ""
|
||||
inline rjson::value empty_string() {
|
||||
return rjson::value(rapidjson::kStringType);
|
||||
}
|
||||
|
||||
// Convert the JSON value to a string with JSON syntax, the opposite of parse().
|
||||
// The representation is dense - without any redundant indentation.
|
||||
std::string print(const rjson::value& value);
|
||||
|
||||
// Copies given JSON value - involves allocation
|
||||
rjson::value copy(const rjson::value& value);
|
||||
|
||||
// Parses a JSON value from given string or raw character array.
|
||||
// The string/char array liveness does not need to be persisted,
|
||||
// as both parse() and parse_raw() will allocate member names and values.
|
||||
// Throws rjson::error if parsing failed.
|
||||
rjson::value parse(const std::string& str);
|
||||
rjson::value parse_raw(const char* c_str, size_t size);
|
||||
|
||||
// Creates a JSON value (of JSON string type) out of internal string representations.
|
||||
// The string value is copied, so str's liveness does not need to be persisted.
|
||||
rjson::value from_string(const std::string& str);
|
||||
rjson::value from_string(const sstring& str);
|
||||
rjson::value from_string(const char* str, size_t size);
|
||||
|
||||
// Returns a pointer to JSON member if it exists, nullptr otherwise
|
||||
rjson::value* find(rjson::value& value, rjson::string_ref_type name);
|
||||
const rjson::value* find(const rjson::value& value, rjson::string_ref_type name);
|
||||
|
||||
// Returns a reference to JSON member if it exists, throws otherwise
|
||||
rjson::value& get(rjson::value& value, rjson::string_ref_type name);
|
||||
const rjson::value& get(const rjson::value& value, rjson::string_ref_type name);
|
||||
|
||||
// Sets a member in given JSON object by moving the member - allocates the name.
|
||||
// Throws if base is not a JSON object.
|
||||
void set_with_string_name(rjson::value& base, const std::string& name, rjson::value&& member);
|
||||
|
||||
// Sets a string member in given JSON object by assigning its reference - allocates the name.
|
||||
// NOTICE: member string liveness must be ensured to be at least as long as base's.
|
||||
// Throws if base is not a JSON object.
|
||||
void set_with_string_name(rjson::value& base, const std::string& name, rjson::string_ref_type member);
|
||||
|
||||
// Sets a member in given JSON object by moving the member.
|
||||
// NOTICE: name liveness must be ensured to be at least as long as base's.
|
||||
// Throws if base is not a JSON object.
|
||||
void set(rjson::value& base, rjson::string_ref_type name, rjson::value&& member);
|
||||
|
||||
// Sets a string member in given JSON object by assigning its reference.
|
||||
// NOTICE: name liveness must be ensured to be at least as long as base's.
|
||||
// NOTICE: member liveness must be ensured to be at least as long as base's.
|
||||
// Throws if base is not a JSON object.
|
||||
void set(rjson::value& base, rjson::string_ref_type name, rjson::string_ref_type member);
|
||||
|
||||
// Adds a value to a JSON list by moving the item to its end.
|
||||
// Throws if base_array is not a JSON array.
|
||||
void push_back(rjson::value& base_array, rjson::value&& item);
|
||||
|
||||
} // end namespace rjson
|
||||
|
||||
namespace std {
|
||||
std::ostream& operator<<(std::ostream& os, const rjson::value& v);
|
||||
}
|
||||
230
alternator/serialization.cc
Normal file
230
alternator/serialization.cc
Normal file
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "base64.hh"
|
||||
#include "log.hh"
|
||||
#include "serialization.hh"
|
||||
#include "error.hh"
|
||||
#include "rapidjson/writer.h"
|
||||
#include "concrete_types.hh"
|
||||
|
||||
static logging::logger slogger("alternator-serialization");
|
||||
|
||||
namespace alternator {
|
||||
|
||||
type_info type_info_from_string(std::string type) {
|
||||
static thread_local const std::unordered_map<std::string, type_info> type_infos = {
|
||||
{"S", {alternator_type::S, utf8_type}},
|
||||
{"B", {alternator_type::B, bytes_type}},
|
||||
{"BOOL", {alternator_type::BOOL, boolean_type}},
|
||||
{"N", {alternator_type::N, decimal_type}}, //FIXME: Replace with custom Alternator type when implemented
|
||||
};
|
||||
auto it = type_infos.find(type);
|
||||
if (it == type_infos.end()) {
|
||||
return {alternator_type::NOT_SUPPORTED_YET, utf8_type};
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
type_representation represent_type(alternator_type atype) {
|
||||
static thread_local const std::unordered_map<alternator_type, type_representation> type_representations = {
|
||||
{alternator_type::S, {"S", utf8_type}},
|
||||
{alternator_type::B, {"B", bytes_type}},
|
||||
{alternator_type::BOOL, {"BOOL", boolean_type}},
|
||||
{alternator_type::N, {"N", decimal_type}}, //FIXME: Replace with custom Alternator type when implemented
|
||||
};
|
||||
auto it = type_representations.find(atype);
|
||||
if (it == type_representations.end()) {
|
||||
throw std::runtime_error(format("Unknown alternator type {}", int8_t(atype)));
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
struct from_json_visitor {
|
||||
const rjson::value& v;
|
||||
bytes_ostream& bo;
|
||||
|
||||
void operator()(const reversed_type_impl& t) const { visit(*t.underlying_type(), from_json_visitor{v, bo}); };
|
||||
void operator()(const string_type_impl& t) {
|
||||
bo.write(t.from_string(sstring_view(v.GetString(), v.GetStringLength())));
|
||||
}
|
||||
void operator()(const bytes_type_impl& t) const {
|
||||
bo.write(base64_decode(std::string_view(v.GetString(), v.GetStringLength())));
|
||||
}
|
||||
void operator()(const boolean_type_impl& t) const {
|
||||
bo.write(boolean_type->decompose(v.GetBool()));
|
||||
}
|
||||
void operator()(const decimal_type_impl& t) const {
|
||||
bo.write(t.from_string(sstring_view(v.GetString(), v.GetStringLength())));
|
||||
}
|
||||
// default
|
||||
void operator()(const abstract_type& t) const {
|
||||
bo.write(t.from_json_object(Json::Value(rjson::print(v)), cql_serialization_format::internal()));
|
||||
}
|
||||
};
|
||||
|
||||
bytes serialize_item(const rjson::value& item) {
|
||||
if (item.IsNull() || item.MemberCount() != 1) {
|
||||
throw api_error("ValidationException", format("An item can contain only one attribute definition: {}", item));
|
||||
}
|
||||
auto it = item.MemberBegin();
|
||||
type_info type_info = type_info_from_string(it->name.GetString()); // JSON keys are guaranteed to be strings
|
||||
|
||||
if (type_info.atype == alternator_type::NOT_SUPPORTED_YET) {
|
||||
slogger.trace("Non-optimal serialization of type {}", it->name.GetString());
|
||||
return bytes{int8_t(type_info.atype)} + to_bytes(rjson::print(item));
|
||||
}
|
||||
|
||||
bytes_ostream bo;
|
||||
bo.write(bytes{int8_t(type_info.atype)});
|
||||
visit(*type_info.dtype, from_json_visitor{it->value, bo});
|
||||
|
||||
return bytes(bo.linearize());
|
||||
}
|
||||
|
||||
struct to_json_visitor {
|
||||
rjson::value& deserialized;
|
||||
const std::string& type_ident;
|
||||
bytes_view bv;
|
||||
|
||||
void operator()(const reversed_type_impl& t) const { visit(*t.underlying_type(), to_json_visitor{deserialized, type_ident, bv}); };
|
||||
void operator()(const decimal_type_impl& t) const {
|
||||
auto s = decimal_type->to_json_string(bytes(bv));
|
||||
//FIXME(sarna): unnecessary copy
|
||||
rjson::set_with_string_name(deserialized, type_ident, rjson::from_string(s));
|
||||
}
|
||||
void operator()(const string_type_impl& t) {
|
||||
rjson::set_with_string_name(deserialized, type_ident, rjson::from_string(reinterpret_cast<const char *>(bv.data()), bv.size()));
|
||||
}
|
||||
void operator()(const bytes_type_impl& t) const {
|
||||
std::string b64 = base64_encode(bv);
|
||||
rjson::set_with_string_name(deserialized, type_ident, rjson::from_string(b64));
|
||||
}
|
||||
// default
|
||||
void operator()(const abstract_type& t) const {
|
||||
rjson::set_with_string_name(deserialized, type_ident, rjson::parse(t.to_string(bytes(bv))));
|
||||
}
|
||||
};
|
||||
|
||||
rjson::value deserialize_item(bytes_view bv) {
|
||||
rjson::value deserialized(rapidjson::kObjectType);
|
||||
if (bv.empty()) {
|
||||
throw api_error("ValidationException", "Serialized value empty");
|
||||
}
|
||||
|
||||
alternator_type atype = alternator_type(bv[0]);
|
||||
bv.remove_prefix(1);
|
||||
|
||||
if (atype == alternator_type::NOT_SUPPORTED_YET) {
|
||||
slogger.trace("Non-optimal deserialization of alternator type {}", int8_t(atype));
|
||||
return rjson::parse_raw(reinterpret_cast<const char *>(bv.data()), bv.size());
|
||||
}
|
||||
type_representation type_representation = represent_type(atype);
|
||||
visit(*type_representation.dtype, to_json_visitor{deserialized, type_representation.ident, bv});
|
||||
|
||||
return deserialized;
|
||||
}
|
||||
|
||||
std::string type_to_string(data_type type) {
|
||||
static thread_local std::unordered_map<data_type, std::string> types = {
|
||||
{utf8_type, "S"},
|
||||
{bytes_type, "B"},
|
||||
{boolean_type, "BOOL"},
|
||||
{decimal_type, "N"}, // FIXME: use a specialized Alternator number type instead of the general decimal_type
|
||||
};
|
||||
auto it = types.find(type);
|
||||
if (it == types.end()) {
|
||||
throw std::runtime_error(format("Unknown type {}", type->name()));
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
bytes get_key_column_value(const rjson::value& item, const column_definition& column) {
|
||||
std::string column_name = column.name_as_text();
|
||||
std::string expected_type = type_to_string(column.type);
|
||||
|
||||
const rjson::value& key_typed_value = rjson::get(item, rjson::value::StringRefType(column_name.c_str()));
|
||||
if (!key_typed_value.IsObject() || key_typed_value.MemberCount() != 1) {
|
||||
throw api_error("ValidationException",
|
||||
format("Missing or invalid value object for key column {}: {}", column_name, item));
|
||||
}
|
||||
return get_key_from_typed_value(key_typed_value, column, expected_type);
|
||||
}
|
||||
|
||||
bytes get_key_from_typed_value(const rjson::value& key_typed_value, const column_definition& column, const std::string& expected_type) {
|
||||
auto it = key_typed_value.MemberBegin();
|
||||
if (it->name.GetString() != expected_type) {
|
||||
throw api_error("ValidationException",
|
||||
format("Type mismatch: expected type {} for key column {}, got type {}",
|
||||
expected_type, column.name_as_text(), it->name.GetString()));
|
||||
}
|
||||
if (column.type == bytes_type) {
|
||||
return base64_decode(it->value.GetString());
|
||||
} else {
|
||||
return column.type->from_string(it->value.GetString());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
rjson::value json_key_column_value(bytes_view cell, const column_definition& column) {
|
||||
if (column.type == bytes_type) {
|
||||
std::string b64 = base64_encode(cell);
|
||||
return rjson::from_string(b64);
|
||||
} if (column.type == utf8_type) {
|
||||
return rjson::from_string(std::string(reinterpret_cast<const char*>(cell.data()), cell.size()));
|
||||
} else if (column.type == decimal_type) {
|
||||
// FIXME: use specialized Alternator number type, not the more
|
||||
// general "decimal_type". A dedicated type can be more efficient
|
||||
// in storage space and in parsing speed.
|
||||
auto s = decimal_type->to_json_string(bytes(cell));
|
||||
return rjson::from_string(s);
|
||||
} else {
|
||||
// We shouldn't get here, we shouldn't see such key columns.
|
||||
throw std::runtime_error(format("Unexpected key type: {}", column.type->name()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
partition_key pk_from_json(const rjson::value& item, schema_ptr schema) {
|
||||
std::vector<bytes> raw_pk;
|
||||
// FIXME: this is a loop, but we really allow only one partition key column.
|
||||
for (const column_definition& cdef : schema->partition_key_columns()) {
|
||||
bytes raw_value = get_key_column_value(item, cdef);
|
||||
raw_pk.push_back(std::move(raw_value));
|
||||
}
|
||||
return partition_key::from_exploded(raw_pk);
|
||||
}
|
||||
|
||||
clustering_key ck_from_json(const rjson::value& item, schema_ptr schema) {
|
||||
if (schema->clustering_key_size() == 0) {
|
||||
return clustering_key::make_empty();
|
||||
}
|
||||
std::vector<bytes> raw_ck;
|
||||
// FIXME: this is a loop, but we really allow only one clustering key column.
|
||||
for (const column_definition& cdef : schema->clustering_key_columns()) {
|
||||
bytes raw_value = get_key_column_value(item, cdef);
|
||||
raw_ck.push_back(std::move(raw_value));
|
||||
}
|
||||
|
||||
return clustering_key::from_exploded(raw_ck);
|
||||
}
|
||||
|
||||
}
|
||||
61
alternator/serialization.hh
Normal file
61
alternator/serialization.hh
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include "types.hh"
|
||||
#include "schema.hh"
|
||||
#include "keys.hh"
|
||||
#include "rjson.hh"
|
||||
|
||||
namespace alternator {
|
||||
|
||||
enum class alternator_type : int8_t {
|
||||
S, B, BOOL, N, NOT_SUPPORTED_YET
|
||||
};
|
||||
|
||||
struct type_info {
|
||||
alternator_type atype;
|
||||
data_type dtype;
|
||||
};
|
||||
|
||||
struct type_representation {
|
||||
std::string ident;
|
||||
data_type dtype;
|
||||
};
|
||||
|
||||
type_info type_info_from_string(std::string type);
|
||||
type_representation represent_type(alternator_type atype);
|
||||
|
||||
bytes serialize_item(const rjson::value& item);
|
||||
rjson::value deserialize_item(bytes_view bv);
|
||||
|
||||
std::string type_to_string(data_type type);
|
||||
|
||||
bytes get_key_column_value(const rjson::value& item, const column_definition& column);
|
||||
bytes get_key_from_typed_value(const rjson::value& key_typed_value, const column_definition& column, const std::string& expected_type);
|
||||
rjson::value json_key_column_value(bytes_view cell, const column_definition& column);
|
||||
|
||||
partition_key pk_from_json(const rjson::value& item, schema_ptr schema);
|
||||
clustering_key ck_from_json(const rjson::value& item, schema_ptr schema);
|
||||
|
||||
}
|
||||
172
alternator/server.cc
Normal file
172
alternator/server.cc
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "alternator/server.hh"
|
||||
#include "log.hh"
|
||||
#include <seastar/http/function_handlers.hh>
|
||||
#include <seastar/json/json_elements.hh>
|
||||
#include <seastarx.hh>
|
||||
#include <boost/algorithm/string/split.hpp>
|
||||
#include <boost/algorithm/string/classification.hpp>
|
||||
#include "error.hh"
|
||||
#include "rjson.hh"
|
||||
|
||||
static logging::logger slogger("alternator-server");
|
||||
|
||||
using namespace httpd;
|
||||
|
||||
namespace alternator {
|
||||
|
||||
static constexpr auto TARGET = "X-Amz-Target";
|
||||
|
||||
inline std::vector<sstring> split(const sstring& text, const char* separator) {
|
||||
if (text == "") {
|
||||
return std::vector<sstring>();
|
||||
}
|
||||
std::vector<sstring> tokens;
|
||||
return boost::split(tokens, text, boost::is_any_of(separator));
|
||||
}
|
||||
|
||||
// DynamoDB HTTP error responses are structured as follows
|
||||
// https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Programming.Errors.html
|
||||
// Our handlers throw an exception to report an error. If the exception
|
||||
// is of type alternator::api_error, it unwrapped and properly reported to
|
||||
// the user directly. Other exceptions are unexpected, and reported as
|
||||
// Internal Server Error.
|
||||
class api_handler : public handler_base {
|
||||
public:
|
||||
api_handler(const future_json_function& _handle) : _f_handle(
|
||||
[_handle](std::unique_ptr<request> req, std::unique_ptr<reply> rep) {
|
||||
return seastar::futurize_apply(_handle, std::move(req)).then_wrapped([rep = std::move(rep)](future<json::json_return_type> resf) mutable {
|
||||
if (resf.failed()) {
|
||||
// Exceptions of type api_error are wrapped as JSON and
|
||||
// returned to the client as expected. Other types of
|
||||
// exceptions are unexpected, and returned to the user
|
||||
// as an internal server error:
|
||||
api_error ret;
|
||||
try {
|
||||
resf.get();
|
||||
} catch (api_error &ae) {
|
||||
ret = ae;
|
||||
} catch (rjson::error & re) {
|
||||
ret = api_error("ValidationException", re.what());
|
||||
} catch (...) {
|
||||
ret = api_error(
|
||||
"Internal Server Error",
|
||||
format("Internal server error: {}", std::current_exception()),
|
||||
reply::status_type::internal_server_error);
|
||||
}
|
||||
// FIXME: what is this version number?
|
||||
rep->_content += "{\"__type\":\"com.amazonaws.dynamodb.v20120810#" + ret._type + "\"," +
|
||||
"\"message\":\"" + ret._msg + "\"}";
|
||||
rep->_status = ret._http_code;
|
||||
slogger.trace("api_handler error case: {}", rep->_content);
|
||||
return make_ready_future<std::unique_ptr<reply>>(std::move(rep));
|
||||
}
|
||||
slogger.trace("api_handler success case");
|
||||
auto res = resf.get0();
|
||||
if (res._body_writer) {
|
||||
rep->write_body("json", std::move(res._body_writer));
|
||||
} else {
|
||||
rep->_content += res._res;
|
||||
}
|
||||
return make_ready_future<std::unique_ptr<reply>>(std::move(rep));
|
||||
});
|
||||
}), _type("json") { }
|
||||
|
||||
api_handler(const api_handler&) = default;
|
||||
future<std::unique_ptr<reply>> handle(const sstring& path,
|
||||
std::unique_ptr<request> req, std::unique_ptr<reply> rep) override {
|
||||
return _f_handle(std::move(req), std::move(rep)).then(
|
||||
[this](std::unique_ptr<reply> rep) {
|
||||
rep->done(_type);
|
||||
return make_ready_future<std::unique_ptr<reply>>(std::move(rep));
|
||||
});
|
||||
}
|
||||
|
||||
protected:
|
||||
future_handler_function _f_handle;
|
||||
sstring _type;
|
||||
};
|
||||
|
||||
void server::set_routes(routes& r) {
|
||||
using alternator_callback = std::function<future<json::json_return_type>(executor&, executor::client_state&, std::unique_ptr<request>)>;
|
||||
std::unordered_map<std::string, alternator_callback> routes{
|
||||
{"CreateTable", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) {
|
||||
return e.maybe_create_keyspace().then([&e, &client_state, req = std::move(req)] { return e.create_table(client_state, req->content); }); }
|
||||
},
|
||||
{"DescribeTable", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.describe_table(client_state, req->content); }},
|
||||
{"DeleteTable", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.delete_table(client_state, req->content); }},
|
||||
{"PutItem", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.put_item(client_state, req->content); }},
|
||||
{"UpdateItem", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.update_item(client_state, req->content); }},
|
||||
{"GetItem", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.get_item(client_state, req->content); }},
|
||||
{"DeleteItem", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.delete_item(client_state, req->content); }},
|
||||
{"ListTables", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.list_tables(client_state, req->content); }},
|
||||
{"Scan", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.scan(client_state, req->content); }},
|
||||
{"DescribeEndpoints", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.describe_endpoints(client_state, req->content, req->get_header("Host")); }},
|
||||
{"BatchWriteItem", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.batch_write_item(client_state, req->content); }},
|
||||
{"BatchGetItem", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.batch_get_item(client_state, req->content); }},
|
||||
{"Query", [] (executor& e, executor::client_state& client_state, std::unique_ptr<request> req) { return e.query(client_state, req->content); }},
|
||||
};
|
||||
|
||||
api_handler* handler = new api_handler([this, routes = std::move(routes)](std::unique_ptr<request> req) -> future<json::json_return_type> {
|
||||
_executor.local()._stats.total_operations++;
|
||||
sstring target = req->get_header(TARGET);
|
||||
std::vector<sstring> split_target = split(target, ".");
|
||||
//NOTICE(sarna): Target consists of Dynamo API version folllowed by a dot '.' and operation type (e.g. CreateTable)
|
||||
sstring op = split_target.empty() ? sstring() : split_target.back();
|
||||
slogger.trace("Request: {} {}", op, req->content);
|
||||
auto callback_it = routes.find(op);
|
||||
if (callback_it == routes.end()) {
|
||||
_executor.local()._stats.unsupported_operations++;
|
||||
throw api_error("UnknownOperationException",
|
||||
format("Unsupported operation {}", op));
|
||||
}
|
||||
//FIXME: Client state can provide more context, e.g. client's endpoint address
|
||||
return do_with(executor::client_state::for_internal_calls(), [this, callback_it = std::move(callback_it), op = std::move(op), req = std::move(req)] (executor::client_state& client_state) mutable {
|
||||
client_state.set_raw_keyspace(executor::KEYSPACE_NAME);
|
||||
executor::maybe_trace_query(client_state, op, req->content);
|
||||
tracing::trace(client_state.get_trace_state(), op);
|
||||
return callback_it->second(_executor.local(), client_state, std::move(req));
|
||||
});
|
||||
});
|
||||
|
||||
r.add(operation_type::POST, url("/"), handler);
|
||||
}
|
||||
|
||||
future<> server::init(net::inet_address addr, uint16_t port) {
|
||||
return _executor.invoke_on_all([] (executor& e) {
|
||||
return e.start();
|
||||
}).then([this] {
|
||||
return _control.start();
|
||||
}).then([this] {
|
||||
return _control.set_routes(std::bind(&server::set_routes, this, std::placeholders::_1));
|
||||
}).then([this, addr, port] {
|
||||
return _control.listen(socket_address{addr, port});
|
||||
}).then([addr, port] {
|
||||
slogger.info("Alternator HTTP server listening on {} port {}", addr, port);
|
||||
}).handle_exception([addr, port] (std::exception_ptr e) {
|
||||
slogger.warn("Failed to set up Alternator HTTP server on {} port {}: {}", addr, port, e);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
42
alternator/server.hh
Normal file
42
alternator/server.hh
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "alternator/executor.hh"
|
||||
#include <seastar/core/future.hh>
|
||||
#include <seastar/http/httpd.hh>
|
||||
|
||||
namespace alternator {
|
||||
|
||||
class server {
|
||||
seastar::httpd::http_server_control _control;
|
||||
seastar::sharded<executor>& _executor;
|
||||
public:
|
||||
server(seastar::sharded<executor>& executor) : _executor(executor) {}
|
||||
|
||||
seastar::future<> init(net::inet_address addr, uint16_t port);
|
||||
private:
|
||||
void set_routes(seastar::httpd::routes& r);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
98
alternator/stats.cc
Normal file
98
alternator/stats.cc
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "stats.hh"
|
||||
|
||||
#include <seastar/core/metrics.hh>
|
||||
|
||||
namespace alternator {
|
||||
|
||||
const char* ALTERNATOR_METRICS = "alternator";
|
||||
|
||||
stats::stats() : api_operations{} {
|
||||
// Register the
|
||||
seastar::metrics::label op("op");
|
||||
|
||||
_metrics.add_group("alternator", {
|
||||
#define OPERATION(name, CamelCaseName) \
|
||||
seastar::metrics::make_total_operations("operation", api_operations.name, \
|
||||
seastar::metrics::description("number of operations via Alternator API"), {op(CamelCaseName)}),
|
||||
#define OPERATION_LATENCY(name, CamelCaseName) \
|
||||
seastar::metrics::make_histogram("op_latency", \
|
||||
seastar::metrics::description("Latency histogram of an operation via Alternator API"), {op(CamelCaseName)}, [this]{return api_operations.name.get_histogram(1,20);}),
|
||||
OPERATION(batch_write_item, "BatchWriteItem")
|
||||
OPERATION(create_backup, "CreateBackup")
|
||||
OPERATION(create_global_table, "CreateGlobalTable")
|
||||
OPERATION(create_table, "CreateTable")
|
||||
OPERATION(delete_backup, "DeleteBackup")
|
||||
OPERATION(delete_item, "DeleteItem")
|
||||
OPERATION(delete_table, "DeleteTable")
|
||||
OPERATION(describe_backup, "DescribeBackup")
|
||||
OPERATION(describe_continuous_backups, "DescribeContinuousBackups")
|
||||
OPERATION(describe_endpoints, "DescribeEndpoints")
|
||||
OPERATION(describe_global_table, "DescribeGlobalTable")
|
||||
OPERATION(describe_global_table_settings, "DescribeGlobalTableSettings")
|
||||
OPERATION(describe_limits, "DescribeLimits")
|
||||
OPERATION(describe_table, "DescribeTable")
|
||||
OPERATION(describe_time_to_live, "DescribeTimeToLive")
|
||||
OPERATION(get_item, "GetItem")
|
||||
OPERATION(list_backups, "ListBackups")
|
||||
OPERATION(list_global_tables, "ListGlobalTables")
|
||||
OPERATION(list_tables, "ListTables")
|
||||
OPERATION(list_tags_of_resource, "ListTagsOfResource")
|
||||
OPERATION(put_item, "PutItem")
|
||||
OPERATION(query, "Query")
|
||||
OPERATION(restore_table_from_backup, "RestoreTableFromBackup")
|
||||
OPERATION(restore_table_to_point_in_time, "RestoreTableToPointInTime")
|
||||
OPERATION(scan, "Scan")
|
||||
OPERATION(tag_resource, "TagResource")
|
||||
OPERATION(transact_get_items, "TransactGetItems")
|
||||
OPERATION(transact_write_items, "TransactWriteItems")
|
||||
OPERATION(untag_resource, "UntagResource")
|
||||
OPERATION(update_continuous_backups, "UpdateContinuousBackups")
|
||||
OPERATION(update_global_table, "UpdateGlobalTable")
|
||||
OPERATION(update_global_table_settings, "UpdateGlobalTableSettings")
|
||||
OPERATION(update_item, "UpdateItem")
|
||||
OPERATION(update_table, "UpdateTable")
|
||||
OPERATION(update_time_to_live, "UpdateTimeToLive")
|
||||
OPERATION_LATENCY(put_item_latency, "PutItem")
|
||||
OPERATION_LATENCY(get_item_latency, "GetItem")
|
||||
OPERATION_LATENCY(delete_item_latency, "DeleteItem")
|
||||
OPERATION_LATENCY(update_item_latency, "UpdateItem")
|
||||
});
|
||||
_metrics.add_group("alternator", {
|
||||
seastar::metrics::make_total_operations("unsupported_operations", unsupported_operations,
|
||||
seastar::metrics::description("number of unsupported operations via Alternator API")),
|
||||
seastar::metrics::make_total_operations("total_operations", total_operations,
|
||||
seastar::metrics::description("number of total operations via Alternator API")),
|
||||
seastar::metrics::make_total_operations("reads_before_write", reads_before_write,
|
||||
seastar::metrics::description("number of performed read-before-write operations")),
|
||||
seastar::metrics::make_total_operations("filtered_rows_read_total", cql_stats.filtered_rows_read_total,
|
||||
seastar::metrics::description("number of rows read during filtering operations")),
|
||||
seastar::metrics::make_total_operations("filtered_rows_matched_total", cql_stats.filtered_rows_matched_total,
|
||||
seastar::metrics::description("number of rows read and matched during filtering operations")),
|
||||
seastar::metrics::make_total_operations("filtered_rows_dropped_total", [this] { return cql_stats.filtered_rows_read_total - cql_stats.filtered_rows_matched_total; },
|
||||
seastar::metrics::description("number of rows read and dropped during filtering operations")),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
95
alternator/stats.hh
Normal file
95
alternator/stats.hh
Normal file
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2019 ScyllaDB
|
||||
*/
|
||||
|
||||
/*
|
||||
* This file is part of Scylla.
|
||||
*
|
||||
* Scylla is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Scylla is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Scylla. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <seastar/core/metrics_registration.hh>
|
||||
#include "seastarx.hh"
|
||||
#include "utils/estimated_histogram.hh"
|
||||
#include "cql3/stats.hh"
|
||||
|
||||
namespace alternator {
|
||||
|
||||
// Object holding per-shard statistics related to Alternator.
|
||||
// While this object is alive, these metrics are also registered to be
|
||||
// visible by the metrics REST API, with the "alternator" prefix.
|
||||
class stats {
|
||||
public:
|
||||
stats();
|
||||
// Count of DynamoDB API operations by types
|
||||
struct {
|
||||
uint64_t batch_get_item = 0;
|
||||
uint64_t batch_write_item = 0;
|
||||
uint64_t create_backup = 0;
|
||||
uint64_t create_global_table = 0;
|
||||
uint64_t create_table = 0;
|
||||
uint64_t delete_backup = 0;
|
||||
uint64_t delete_item = 0;
|
||||
uint64_t delete_table = 0;
|
||||
uint64_t describe_backup = 0;
|
||||
uint64_t describe_continuous_backups = 0;
|
||||
uint64_t describe_endpoints = 0;
|
||||
uint64_t describe_global_table = 0;
|
||||
uint64_t describe_global_table_settings = 0;
|
||||
uint64_t describe_limits = 0;
|
||||
uint64_t describe_table = 0;
|
||||
uint64_t describe_time_to_live = 0;
|
||||
uint64_t get_item = 0;
|
||||
uint64_t list_backups = 0;
|
||||
uint64_t list_global_tables = 0;
|
||||
uint64_t list_tables = 0;
|
||||
uint64_t list_tags_of_resource = 0;
|
||||
uint64_t put_item = 0;
|
||||
uint64_t query = 0;
|
||||
uint64_t restore_table_from_backup = 0;
|
||||
uint64_t restore_table_to_point_in_time = 0;
|
||||
uint64_t scan = 0;
|
||||
uint64_t tag_resource = 0;
|
||||
uint64_t transact_get_items = 0;
|
||||
uint64_t transact_write_items = 0;
|
||||
uint64_t untag_resource = 0;
|
||||
uint64_t update_continuous_backups = 0;
|
||||
uint64_t update_global_table = 0;
|
||||
uint64_t update_global_table_settings = 0;
|
||||
uint64_t update_item = 0;
|
||||
uint64_t update_table = 0;
|
||||
uint64_t update_time_to_live = 0;
|
||||
|
||||
utils::estimated_histogram put_item_latency;
|
||||
utils::estimated_histogram get_item_latency;
|
||||
utils::estimated_histogram delete_item_latency;
|
||||
utils::estimated_histogram update_item_latency;
|
||||
} api_operations;
|
||||
// Miscellaneous event counters
|
||||
uint64_t total_operations = 0;
|
||||
uint64_t unsupported_operations = 0;
|
||||
uint64_t reads_before_write = 0;
|
||||
// CQL-derived stats
|
||||
cql3::cql_stats cql_stats;
|
||||
private:
|
||||
// The metric_groups object holds this stat object's metrics registered
|
||||
// as long as the stats object is alive.
|
||||
seastar::metrics::metric_groups _metrics;
|
||||
};
|
||||
|
||||
}
|
||||
14
configure.py
14
configure.py
@@ -772,6 +772,18 @@ api = ['api/api.cc',
|
||||
'api/api-doc/config.json',
|
||||
]
|
||||
|
||||
alternator = [
|
||||
'alternator/server.cc',
|
||||
'alternator/executor.cc',
|
||||
'alternator/stats.cc',
|
||||
'alternator/base64.cc',
|
||||
'alternator/serialization.cc',
|
||||
'alternator/expressions.cc',
|
||||
Antlr3Grammar('alternator/expressions.g'),
|
||||
'alternator/conditions.cc',
|
||||
'alternator/rjson.cc',
|
||||
]
|
||||
|
||||
idls = ['idl/gossip_digest.idl.hh',
|
||||
'idl/uuid.idl.hh',
|
||||
'idl/range.idl.hh',
|
||||
@@ -815,7 +827,7 @@ scylla_tests_dependencies = scylla_core + idls + scylla_tests_generic_dependenci
|
||||
]
|
||||
|
||||
deps = {
|
||||
'scylla': idls + ['main.cc', 'release.cc'] + scylla_core + api,
|
||||
'scylla': idls + ['main.cc', 'release.cc'] + scylla_core + api + alternator,
|
||||
}
|
||||
|
||||
pure_boost_tests = set([
|
||||
|
||||
@@ -221,7 +221,7 @@ void alter_table_statement::add_column(schema_ptr schema, const table& cf, schem
|
||||
schema_builder builder(view);
|
||||
if (view->view_info()->include_all_columns()) {
|
||||
builder.with_column(column_name->name(), type);
|
||||
} else if (!view->view_info()->base_non_pk_column_in_view_pk()) {
|
||||
} else if (view->view_info()->base_non_pk_columns_in_view_pk().empty()) {
|
||||
db::view::create_virtual_column(builder, column_name->name(), type);
|
||||
}
|
||||
view_updates.push_back(view_ptr(builder.build()));
|
||||
|
||||
@@ -686,6 +686,8 @@ db::config::config(std::shared_ptr<db::extensions> exts)
|
||||
, enable_shard_aware_drivers(this, "enable_shard_aware_drivers", value_status::Used, true, "Enable native transport drivers to use connection-per-shard for better performance")
|
||||
, enable_ipv6_dns_lookup(this, "enable_ipv6_dns_lookup", value_status::Used, false, "Use IPv6 address resolution")
|
||||
, abort_on_internal_error(this, "abort_on_internal_error", liveness::LiveUpdate, value_status::Used, false, "Abort the server instead of throwing exception when internal invariants are violated")
|
||||
, alternator_port(this, "alternator_port", value_status::Used, 0, "Alternator API port")
|
||||
, alternator_address(this, "alternator_address", value_status::Used, "0.0.0.0", "Alternator API listening address")
|
||||
, default_log_level(this, "default_log_level", value_status::Used)
|
||||
, logger_log_level(this, "logger_log_level", value_status::Used)
|
||||
, log_to_stdout(this, "log_to_stdout", value_status::Used)
|
||||
|
||||
@@ -283,6 +283,9 @@ public:
|
||||
named_value<bool> enable_ipv6_dns_lookup;
|
||||
named_value<bool> abort_on_internal_error;
|
||||
|
||||
named_value<uint16_t> alternator_port;
|
||||
named_value<sstring> alternator_address;
|
||||
|
||||
seastar::logging_settings logging_settings(const boost::program_options::variables_map&) const;
|
||||
|
||||
boost::program_options::options_description_easy_init&
|
||||
|
||||
@@ -135,16 +135,15 @@ const column_definition* view_info::view_column(const column_definition& base_de
|
||||
return _schema.get_column_definition(base_def.name());
|
||||
}
|
||||
|
||||
std::optional<column_id> view_info::base_non_pk_column_in_view_pk() const {
|
||||
return _base_non_pk_column_in_view_pk;
|
||||
const std::vector<column_id>& view_info::base_non_pk_columns_in_view_pk() const {
|
||||
return _base_non_pk_columns_in_view_pk;
|
||||
}
|
||||
|
||||
void view_info::initialize_base_dependent_fields(const schema& base) {
|
||||
for (auto&& view_col : boost::range::join(_schema.partition_key_columns(), _schema.clustering_key_columns())) {
|
||||
auto* base_col = base.get_column_definition(view_col.name());
|
||||
if (base_col && !base_col->is_primary_key()) {
|
||||
_base_non_pk_column_in_view_pk.emplace(base_col->id);
|
||||
break;
|
||||
_base_non_pk_columns_in_view_pk.push_back(base_col->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,11 +285,14 @@ row_marker view_updates::compute_row_marker(const clustering_row& base_row) cons
|
||||
*/
|
||||
|
||||
auto marker = base_row.marker();
|
||||
auto col_id = _view_info.base_non_pk_column_in_view_pk();
|
||||
if (col_id) {
|
||||
auto& def = _base->regular_column_at(*col_id);
|
||||
// FIXME: The code assumes that if multiple regular base columns are present in the view key,
|
||||
// they share liveness information. It's true especially in the only case currently allowed by CQL,
|
||||
// which assumes there's up to one non-pk column in the view key.
|
||||
const auto& col_ids = _view_info.base_non_pk_columns_in_view_pk();
|
||||
if (!col_ids.empty()) {
|
||||
auto& def = _base->regular_column_at(col_ids[0]);
|
||||
// Note: multi-cell columns can't be part of the primary key.
|
||||
auto cell = base_row.cells().cell_at(*col_id).as_atomic_cell(def);
|
||||
auto cell = base_row.cells().cell_at(col_ids[0]).as_atomic_cell(def);
|
||||
return cell.is_live_and_has_ttl() ? row_marker(cell.timestamp(), cell.ttl(), cell.expiry()) : row_marker(cell.timestamp());
|
||||
}
|
||||
|
||||
@@ -506,13 +508,13 @@ void view_updates::delete_old_entry(const partition_key& base_key, const cluster
|
||||
|
||||
void view_updates::do_delete_old_entry(const partition_key& base_key, const clustering_row& existing, const clustering_row& update, gc_clock::time_point now) {
|
||||
auto& r = get_view_row(base_key, existing);
|
||||
auto col_id = _view_info.base_non_pk_column_in_view_pk();
|
||||
if (col_id) {
|
||||
const auto& col_ids = _view_info.base_non_pk_columns_in_view_pk();
|
||||
if (!col_ids.empty()) {
|
||||
// We delete the old row using a shadowable row tombstone, making sure that
|
||||
// the tombstone deletes everything in the row (or it might still show up).
|
||||
// Note: multi-cell columns can't be part of the primary key.
|
||||
auto& def = _base->regular_column_at(*col_id);
|
||||
auto cell = existing.cells().cell_at(*col_id).as_atomic_cell(def);
|
||||
auto& def = _base->regular_column_at(col_ids[0]);
|
||||
auto cell = existing.cells().cell_at(col_ids[0]).as_atomic_cell(def);
|
||||
if (cell.is_live()) {
|
||||
r.apply(shadowable_tombstone(cell.timestamp(), now));
|
||||
}
|
||||
@@ -647,8 +649,8 @@ void view_updates::generate_update(
|
||||
return;
|
||||
}
|
||||
|
||||
auto col_id = _view_info.base_non_pk_column_in_view_pk();
|
||||
if (!col_id) {
|
||||
const auto& col_ids = _view_info.base_non_pk_columns_in_view_pk();
|
||||
if (col_ids.empty()) {
|
||||
// The view key is necessarily the same pre and post update.
|
||||
if (existing && existing->is_live(*_base)) {
|
||||
if (update.is_live(*_base)) {
|
||||
@@ -662,28 +664,39 @@ void view_updates::generate_update(
|
||||
return;
|
||||
}
|
||||
|
||||
auto* after = update.cells().find_cell(*col_id);
|
||||
// Note: multi-cell columns can't be part of the primary key.
|
||||
auto& cdef = _base->regular_column_at(*col_id);
|
||||
if (existing) {
|
||||
auto* before = existing->cells().find_cell(*col_id);
|
||||
if (before && before->as_atomic_cell(cdef).is_live()) {
|
||||
if (after && after->as_atomic_cell(cdef).is_live()) {
|
||||
auto cmp = compare_atomic_cell_for_merge(before->as_atomic_cell(cdef), after->as_atomic_cell(cdef));
|
||||
if (cmp == 0) {
|
||||
update_entry(base_key, update, *existing, now);
|
||||
bool should_update = false;
|
||||
bool should_replace = false;
|
||||
bool should_create = false;
|
||||
for (auto col_id : col_ids) {
|
||||
auto* after = update.cells().find_cell(col_id);
|
||||
// Note: multi-cell columns can't be part of the primary key.
|
||||
auto& cdef = _base->regular_column_at(col_id);
|
||||
if (existing) {
|
||||
auto* before = existing->cells().find_cell(col_id);
|
||||
if (before && before->as_atomic_cell(cdef).is_live()) {
|
||||
if (after && after->as_atomic_cell(cdef).is_live()) {
|
||||
auto cmp = compare_atomic_cell_for_merge(before->as_atomic_cell(cdef), after->as_atomic_cell(cdef));
|
||||
if (cmp == 0) {
|
||||
should_update = true;
|
||||
} else {
|
||||
should_replace = true;
|
||||
}
|
||||
} else {
|
||||
replace_entry(base_key, update, *existing, now);
|
||||
delete_old_entry(base_key, *existing, update, now);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
delete_old_entry(base_key, *existing, update, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (after && after->as_atomic_cell(cdef).is_live()) {
|
||||
should_create = true;
|
||||
}
|
||||
}
|
||||
|
||||
// No existing row or the cell wasn't live
|
||||
if (after && after->as_atomic_cell(cdef).is_live()) {
|
||||
if (should_replace) {
|
||||
replace_entry(base_key, update, *existing, now);
|
||||
} else if (should_update) {
|
||||
update_entry(base_key, update, *existing, now);
|
||||
} else if (should_create) {
|
||||
create_entry(base_key, update, now);
|
||||
}
|
||||
}
|
||||
|
||||
2
dist/docker/redhat/commandlineparser.py
vendored
2
dist/docker/redhat/commandlineparser.py
vendored
@@ -16,6 +16,8 @@ def parse():
|
||||
parser.add_argument('--broadcast-address', default=None, dest='broadcastAddress')
|
||||
parser.add_argument('--broadcast-rpc-address', default=None, dest='broadcastRpcAddress')
|
||||
parser.add_argument('--api-address', default=None, dest='apiAddress')
|
||||
parser.add_argument('--alternator-address', default=None, dest='alternatorAddress', help="Alternator API address to listen to. Defaults to listen address.")
|
||||
parser.add_argument('--alternator-port', default=None, dest='alternatorPort', help="Alternator API port to listen to. Disabled by default.")
|
||||
parser.add_argument('--disable-version-check', default=False, action='store_true', dest='disable_housekeeping', help="Disable version check")
|
||||
parser.add_argument('--authenticator', default=None, dest='authenticator', help="Set authenticator class")
|
||||
parser.add_argument('--authorizer', default=None, dest='authorizer', help="Set authorizer class")
|
||||
|
||||
9
dist/docker/redhat/scyllasetup.py
vendored
9
dist/docker/redhat/scyllasetup.py
vendored
@@ -11,9 +11,11 @@ class ScyllaSetup:
|
||||
self._cpuset = arguments.cpuset
|
||||
self._listenAddress = arguments.listenAddress
|
||||
self._rpcAddress = arguments.rpcAddress
|
||||
self._alternatorAddress = arguments.alternatorAddress
|
||||
self._broadcastAddress = arguments.broadcastAddress
|
||||
self._broadcastRpcAddress = arguments.broadcastRpcAddress
|
||||
self._apiAddress = arguments.apiAddress
|
||||
self._alternatorPort = arguments.alternatorPort
|
||||
self._smp = arguments.smp
|
||||
self._memory = arguments.memory
|
||||
self._overprovisioned = arguments.overprovisioned
|
||||
@@ -81,6 +83,9 @@ class ScyllaSetup:
|
||||
if self._rpcAddress is None:
|
||||
self._rpcAddress = self._listenAddress
|
||||
|
||||
if self._alternatorAddress is None:
|
||||
self._alternatorAddress = self._listenAddress
|
||||
|
||||
if self._seeds is None:
|
||||
if self._broadcastAddress is not None:
|
||||
self._seeds = self._broadcastAddress
|
||||
@@ -99,6 +104,10 @@ class ScyllaSetup:
|
||||
if self._apiAddress is not None:
|
||||
args += ["--api-address %s" % self._apiAddress]
|
||||
|
||||
if self._alternatorPort is not None:
|
||||
args += ["--alternator-address %s" % self._alternatorAddress]
|
||||
args += ["--alternator-port %s" % self._alternatorPort]
|
||||
|
||||
if self._authenticator is not None:
|
||||
args += ["--authenticator %s" % self._authenticator]
|
||||
|
||||
|
||||
210
docs/alternator/alternator.md
Normal file
210
docs/alternator/alternator.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Alternator: DynamoDB API in Scylla
|
||||
|
||||
## Introduction
|
||||
Alternator is a Scylla feature adding compatibility with Amazon DynamoDB(TM).
|
||||
DynamoDB's API uses JSON-encoded requests and responses which are sent over
|
||||
an HTTP or HTTPS transport. It is described in detail on Amazon's site:
|
||||
https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/
|
||||
|
||||
Our goal is that any application written to use Amazon DynamoDB could
|
||||
be run, unmodified, against Scylla with Alternator enabled. However, at this
|
||||
stage the Alternator implementation is incomplete, and some of DynamoDB's
|
||||
API features are not yet supported. The extent of Alternator's compatibility
|
||||
with DynamoDB is described in the "current compatibility" section below.
|
||||
|
||||
## Running Alternator
|
||||
By default, Scylla does not listen for DynamoDB API requests. To enable
|
||||
such requests, you must set the `alternator-port` configuration option
|
||||
(via command line or YAML) to the port on which you wish to listen for
|
||||
DynamoDB API requests.
|
||||
|
||||
For example., "`--alternator-port=8000`" on the command line will run
|
||||
Alternator on port 8000 - the traditional port used by DynamoDB.
|
||||
|
||||
By default, Scylla listens on this port on all network interfaces.
|
||||
To listen only on a specific interface, pass also an "`alternator-address`"
|
||||
option.
|
||||
|
||||
DynamoDB clients usually specify a single "endpoint" address, e.g.,
|
||||
`dynamodb.us-east-1.amazonaws.com`, and a DNS server hosted on that address
|
||||
distributes the connections to many different backend nodes. Alternator
|
||||
does not yet provide such a DNS server, so you should either supply your
|
||||
own (having it return one of the live Scylla nodes at random, with a TTL
|
||||
of a few seconds), or you should use a different mechanism to distribute
|
||||
different DynamoDB requests to different Scylla nodes, to balance the load.
|
||||
|
||||
Alternator tables are stored as Scylla tables in the "alternator" keyspace.
|
||||
This keyspace is initialized when the first Alternator table is created
|
||||
(with a CreateTable request). The replication factor (RF) for this keyspace
|
||||
and all Alternator tables is chosen at that point, depending on the size of
|
||||
the cluster: RF=3 is used on clusters with three or more live nodes, and
|
||||
RF=1 is used for smaller clusters. Such smaller clusters are, of course,
|
||||
only recommended for tests because of the risk of data loss.
|
||||
|
||||
## Current compatibility with DynamoDB
|
||||
|
||||
Our goal is that any application written to use Amazon DynamoDB could
|
||||
be run, unmodified, against Scylla with Alternator enabled. However, at this
|
||||
stage the Alternator implementation is incomplete, and some of DynamoDB's
|
||||
API features are not yet supported. This section documents the extent of
|
||||
Alternator's compatibility with DynamoDB, and will be updated as the work
|
||||
progresses and compatibility continues to improve.
|
||||
|
||||
### API Server
|
||||
* Transport: HTTP mostly supported, but small features like CRC header and
|
||||
compression are still missing. HTTPS not tested.
|
||||
* Authorization (verifying the originator of the request): Not yet supported.
|
||||
* DNS server for load balancing: Not yet supported. Client needs to pick
|
||||
one of the live Scylla nodes and send a request to it.
|
||||
### Table Operations
|
||||
* CreateTable: Supported. Note our implementation is synchronous.
|
||||
* UpdateTable: Not supported.
|
||||
* DescribeTable: Partial implementation. Missing creation date and size esitmate.
|
||||
* DeleteTable: Supported. Note our implementation is synchronous.
|
||||
* ListTables: Supported.
|
||||
### Item Operations
|
||||
* GetItem: Support almost complete except that projection expressions can
|
||||
only ask for top-level attributes.
|
||||
* PutItem: Does not yet support conditional expressions (to only add an item
|
||||
if some condition is true), nor return values (optional return of pre-put
|
||||
content).
|
||||
* UpdateItem: Like PutItem does not yet support conditional expression nor
|
||||
return values. Read-modify-write operations such as `SET a=b`,
|
||||
`SET a=if_not_exist(a,bal)`, or `SET a=a+1, are supported but not protected
|
||||
against concurrent operations. Nested documents are supported but updates
|
||||
to nested attributes are not (e.g., `SET a.b[3].c=val`).
|
||||
* DeleteItem: Mostly works, but again does not support conditional expression
|
||||
or return values.
|
||||
### Batch Operations
|
||||
* BatchGetItem: Almost complete except that projection expressions can only
|
||||
ask for top-level attributes.
|
||||
* BatchWriteItem: Supported. Doesn't yet verify that there are no duplicates
|
||||
in the list of items. Doesn't limit the number of items (DynamoDB limits to
|
||||
25) or size of items (400 KB) or total request size (16 MB).
|
||||
### Scans
|
||||
* Scan: As usual, projection expressions only support top-level attributes.
|
||||
Filter expressions (to filter some of the items) partially supported:
|
||||
The ScanFilter syntax is supported but FilterExpression is not yet, and
|
||||
only equality operator is supported so far.
|
||||
The "Select" options which allows to count items instead of returning them
|
||||
is not yet supported. Parallel scan is not yet supported.
|
||||
* Query: Same issues as Scan above. Additionally, missing support for
|
||||
KeyConditionExpression (an alternative syntax replacing the older
|
||||
KeyConditions parameter which we do support).
|
||||
### Secondary Indexes
|
||||
Global Secondary Indexes (GSI) and Local Secondary Indexes (LSI) are
|
||||
implemented, with the following limitations:
|
||||
* GSIs and LSIs can be added only at CreateTable time: GSIs cannot be added
|
||||
or removed at a later time (UpdateTable is not yet supported).
|
||||
* Marking a read from an index as strongly-consistent currently changes
|
||||
nothing. Such reads ought to be forbidden for GSI, and be strongly-
|
||||
consistent for LSI (see https://github.com/scylladb/scylla/issues/4365)
|
||||
* DescribeTable lists the indexes for the table, but is missing some
|
||||
additional information on each index.
|
||||
* Projection of only a subset of the base-table attributes to the index is
|
||||
not respected: All attributes are projected.
|
||||
### Time To Live (TTL)
|
||||
* Not yet supported. Note that this is a different feature from Scylla's
|
||||
feature with the same name.
|
||||
### Replication (one availability zone)
|
||||
* Most of the code is already correct, including writes done in LOCAL_QURUM
|
||||
and reads in LOCAL_ONE (eventual consistency) or LOCAL_QUORUM (strong
|
||||
consistency). However, executor::start() currently creates a keyspace with
|
||||
just RF=1 and no concern for racks or number of nodes. This should be
|
||||
fixed.
|
||||
### Global Tables
|
||||
* Not yet supported: CreateGlobalTable, UpdateGlobalTable,
|
||||
DescribeGlobalTable, ListGlobalTables, UpdateGlobalTableSettings,
|
||||
DescribeGlobalTableSettings. Implementation will use Scylla's multi-DC
|
||||
features.
|
||||
### Backup and Restore
|
||||
* On-demand backup: Not yet supported: CreateBackup, DescribeBackup,
|
||||
DeleteBackup, ListBackups, RestoreTableFromBackup.
|
||||
* Continuous backup: Not yet supported: UpdateContinuousBackups,
|
||||
DescribeContinuousBackups, RestoreTableToPoinInTime.
|
||||
### Transations
|
||||
* Not yet supported: TransactWriteItems, TransactGetItems.
|
||||
Note that this is a new DynamoDB feature - these are more powerful than
|
||||
the old conditional updates which were "lightweight transactions".
|
||||
### Streams (CDC)
|
||||
* Not yet supported
|
||||
### Encryption at rest
|
||||
* Supported natively by Scylla, but needs to be enabled by default.
|
||||
### ARNs and tags
|
||||
* Various features use ARN (Amazon Resource Names) which we don't support.
|
||||
* Not yet supported: ListTagsOfResource, TagResource, UntagResource.
|
||||
### Accounting and capping
|
||||
* Not yet supported. Mainly for multi-tenant cloud use, we need to track
|
||||
resource use of individual requests (the API should also optionally
|
||||
return this use), and be able to sum this use for different tenants and/or
|
||||
tables, and possible cap use according to reservation.
|
||||
### Multi-tenant support
|
||||
* Not yet supported (related to authorization, accounting, etc.)
|
||||
### DAX (cache)
|
||||
* Not yet supported
|
||||
### Metrics
|
||||
* Several metrics are available internally but need more and make them
|
||||
more similar to what AWS users are used to.
|
||||
|
||||
## Alternator design and implementation
|
||||
|
||||
This section provides only a very brief introduction to Alternator's
|
||||
design. A much more detailed document about the features of the DynamoDB
|
||||
API and how they are, or could be, implemented in Scylla can be found in:
|
||||
https://docs.google.com/document/d/1i4yjF5OSAazAY_-T8CBce9-2ykW4twx_E_Nt2zDoOVs
|
||||
|
||||
Almost all of Alternator's source code (except some initialization code)
|
||||
can be found in the alternator/ subdirectory of Scylla's source code.
|
||||
Extensive functional tests can be found in the alternator-test/
|
||||
subdirectory. These tests are written in Python, and can be run against
|
||||
both Alternator and Amazon's DynamoDB; This allows verifying that
|
||||
Alternator's behavior matches the one observed on DynamoDB.
|
||||
See alternator-test/README.md for more information about the tests and
|
||||
how to run them.
|
||||
|
||||
With Alternator enabled on port 8000 (for example), every Scylla node
|
||||
listens for DynamoDB API requests on this port. These requests, in
|
||||
JSON format over HTTP, are parsed and result in calls to internal Scylla
|
||||
C++ functions - there is no CQL generation or parsing involved.
|
||||
In Scylla terminology, the node receiving the request acts as the the
|
||||
*coordinator*, and often passes the request on to one or more other nodes -
|
||||
*replicas* which hold copies of the requested data.
|
||||
|
||||
DynamoDB supports two consistency levels for reads, "eventual consistency"
|
||||
and "strong consistency". These two modes are implemented using Scylla's CL
|
||||
(consistency level) feature: All writes are done using the LOCAL_QUORUM
|
||||
consistency level, then strongly-consistent reads are done with
|
||||
LOCAL_QUORUM, while eventually-consistent reads are with just LOCAL_ONE.
|
||||
|
||||
Each table in Alternator is stored as a Scylla table in the "alternator"
|
||||
keyspace. The DynamoDB key columns (hash and sort key) have known types,
|
||||
and become partition and clustering key columns of the Scylla table.
|
||||
All other attributes may be different for each row, so are stored in one
|
||||
map column in Scylla, and not as separate columns.
|
||||
|
||||
In Scylla (and its inspiration, Cassandra), high write performance is
|
||||
achieved by ensuring that writes do not require reads from disk.
|
||||
The DynamoDB API, however, provides many types of requests that need a read
|
||||
before the write (a.k.a. RMW requests - read-modify-write). For example,
|
||||
a request may copy an existing attribute, increment an attribute,
|
||||
be conditional on some expression involving existing values of attribute,
|
||||
or request that the previous values of attributes be returned.
|
||||
Alternator currently implements all these RMW operations naively - it simply
|
||||
performs a read before the write. This naive approach is **not safe** when
|
||||
there are concurrent operations on the same attributes, so it will be revised
|
||||
in the future. We will probably use lightweight transactions - a feature
|
||||
which already exists in Cassandra and is planned to soon reach Scylla.
|
||||
|
||||
DynamoDB allows attributes to be **nested** - a top-level attribute may
|
||||
be a list or a map, and each of its elements may further be lists or
|
||||
maps, etc. Alternator currently stores the entire content of a top-level
|
||||
attribute as one JSON object. This is good enough for most needs, except
|
||||
one DynamoDB feature which we cannot support safely: we cannot modify
|
||||
a non-top-level attribute (e.g., a.b[3].c) directly without RMW. We plan
|
||||
to fix this in a future version by rethinking the data model we use for
|
||||
attributes, or rethinking our implementation of RMW (as explained above).
|
||||
|
||||
For reasons explained above, the data model used by Alternator to store
|
||||
data on disk is still in a state of flux, and may change in future versions.
|
||||
Therefore, in this early stage it is not recommended to store important
|
||||
production data using Alternator.
|
||||
87
docs/alternator/getting-started.md
Normal file
87
docs/alternator/getting-started.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Getting Started With ScyllaDB Alternator
|
||||
---
|
||||
## Installing Scylla
|
||||
Before you can start using ScyllaDB Alternator, you will have to have an up
|
||||
and running scylla cluster configured to expose the alternator port.
|
||||
This section will guide you through the steps for setting up the cluster:
|
||||
### Get Scylla with alternator support from a docker:
|
||||
1. Alternator's image name is: `scylladb/scylla-nightly:alternator`
|
||||
2. follow the steps in the [Scylla official download web page](https://www.scylladb.com/download/open-source/#docker)
|
||||
add to every "docker run" command: `-p 8000:8000` before the image name and `--alternator-port=8000` at the end.
|
||||
ie: (image name in this example is scylladb/scylla) `docker run --name scylla -d scylladb/scylla-nightly:alternator` becomes
|
||||
`docker run --name scylla -d -p 8000:8000 scylladb/scylla-nightly:alternator --alternator-port=8000`
|
||||
|
||||
## Testing Scylla's DynamoDB API support:
|
||||
### Running AWS Tic Tac Toe demo app to test the cluster:
|
||||
1. Follow the instructions on the [AWS github page](https://github.com/awsdocs/amazon-dynamodb-developer-guide/blob/master/doc_source/TicTacToe.Phase1.md)
|
||||
2. Enjoy your tic-tac-toe game :-)
|
||||
|
||||
### Setting up the python environment
|
||||
Run the following commands on your machine, this will install boto3 python library
|
||||
which also contains drivers for DynamoDB:
|
||||
|
||||
```
|
||||
sudo pip install --upgrade boto3
|
||||
```
|
||||
### Runnning some simple scripts:
|
||||
The following is a 3 scripts test that creates a table named _usertable_ writes the
|
||||
famous hello world record to it, and then, reads it back.
|
||||
|
||||
1. Put the following **create table** example script in a python file and run it (changing local host
|
||||
to the address of your docker node if you are using docker):
|
||||
```python
|
||||
import boto3
|
||||
dynamodb = boto3.resource('dynamodb',endpoint_url='http://localhost:8000',
|
||||
region_name='None', aws_access_key_id='None', aws_secret_access_key='None')
|
||||
|
||||
dynamodb.create_table(
|
||||
AttributeDefinitions=[
|
||||
{
|
||||
'AttributeName': 'key',
|
||||
'AttributeType': 'S'
|
||||
},
|
||||
],
|
||||
BillingMode='PAY_PER_REQUEST',
|
||||
TableName='usertable',
|
||||
KeySchema=[
|
||||
{
|
||||
'AttributeName': 'key',
|
||||
'KeyType': 'HASH'
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
2. Put the following **write** example script in a python file and run it (changing local host
|
||||
to the address of your docker node if you are using docker):
|
||||
|
||||
```python
|
||||
import boto3
|
||||
dynamodb = boto3.resource('dynamodb',endpoint_url='http://localhost:8000',
|
||||
region_name='None', aws_access_key_id='None', aws_secret_access_key='None')
|
||||
|
||||
dynamodb.batch_write_item(RequestItems={
|
||||
'usertable': [
|
||||
{
|
||||
'PutRequest': {
|
||||
'Item': {
|
||||
'key': 'test', 'x' : {'hello': 'world'}
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
3. Put the following **read** example script in a python file and run it (changing local host
|
||||
to the address of your docker node if you are using docker):
|
||||
```python
|
||||
import boto3
|
||||
dynamodb = boto3.resource('dynamodb',endpoint_url='http://localhost:8000',
|
||||
region_name='None', aws_access_key_id='None', aws_secret_access_key='None')
|
||||
|
||||
print(dynamodb.batch_get_item(RequestItems={
|
||||
'usertable' : { 'Keys': [{ 'key': 'test' }] }
|
||||
}))
|
||||
```
|
||||
|
||||
You should see the record you inserted in step 2 along with some http info printed to screen.
|
||||
@@ -145,6 +145,24 @@ $ docker run --name some-scylla -d scylladb/scylla --listen-address 10.0.0.5
|
||||
|
||||
**Since: 1.4**
|
||||
|
||||
### `--alternator-address ADDR`
|
||||
|
||||
The `--alternator-address` command line option configures the Alternator API listen address. The default value is the same as `--listen-address`.
|
||||
|
||||
**Since: 3.2**
|
||||
|
||||
### `--alternator-port PORT`
|
||||
|
||||
The `--alternator-port` command line option configures the Alternator API listen port. The Alternator API is disabled by default. You need to specify the port to enable it.
|
||||
|
||||
For example, to configure Scylla to listen to Alternator API at port `8000`:
|
||||
|
||||
```console
|
||||
$ docker run --name some-scylla -d scylladb/scylla --alternator-port 8000
|
||||
```
|
||||
|
||||
**Since: 3.2**
|
||||
|
||||
### `--broadcast-address ADDR`
|
||||
|
||||
The `--broadcast-address` command line option configures the IP address the Scylla instance tells other Scylla nodes in the cluster to connect to.
|
||||
|
||||
@@ -25,6 +25,7 @@ debian_base_packages=(
|
||||
python3-pyparsing
|
||||
libsnappy-dev
|
||||
libjsoncpp-dev
|
||||
rapidjson-dev
|
||||
scylla-libthrift010-dev
|
||||
scylla-antlr35-c++-dev
|
||||
thrift-compiler
|
||||
@@ -38,6 +39,7 @@ fedora_packages=(
|
||||
antlr3-tool
|
||||
antlr3-C++-devel
|
||||
jsoncpp-devel
|
||||
rapidjson-devel
|
||||
snappy-devel
|
||||
systemd-devel
|
||||
git
|
||||
@@ -78,6 +80,7 @@ centos_packages=(
|
||||
scylla-antlr35-tool
|
||||
scylla-antlr35-C++-devel
|
||||
jsoncpp-devel snappy-devel
|
||||
rapidjson-devel
|
||||
scylla-boost163-static
|
||||
scylla-python34-pyparsing20
|
||||
systemd-devel
|
||||
|
||||
17
main.cc
17
main.cc
@@ -70,6 +70,8 @@
|
||||
#include "gms/feature_service.hh"
|
||||
#include "distributed_loader.hh"
|
||||
|
||||
#include "alternator/server.hh"
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
seastar::metrics::metric_groups app_metrics;
|
||||
@@ -1012,6 +1014,21 @@ int main(int ac, char** av) {
|
||||
return service::get_local_storage_service().start_rpc_server();
|
||||
}).get();
|
||||
}
|
||||
|
||||
if (cfg->alternator_port()) {
|
||||
net::inet_address addr;
|
||||
try {
|
||||
addr = net::dns::get_host_by_name(cfg->alternator_address(), family).get0().addr_list.front();
|
||||
} catch (...) {
|
||||
std::throw_with_nested(std::runtime_error(fmt::format("Unable to resolve alternator_address {}", cfg->alternator_address())));
|
||||
}
|
||||
static sharded<alternator::executor> alternator_executor;
|
||||
alternator_executor.start(std::ref(proxy), std::ref(mm)).get();
|
||||
static alternator::server alternator_server(alternator_executor);
|
||||
alternator_server.init(addr, cfg->alternator_port()).get();
|
||||
startlog.info("Alternator server listening on {} port {}", addr, cfg->alternator_port());
|
||||
}
|
||||
|
||||
if (cfg->defragment_memory_on_idle()) {
|
||||
smp::invoke_on_all([] () {
|
||||
engine().set_idle_cpu_handler([] (reactor::work_waiting_on_reactor check_for_work) {
|
||||
|
||||
@@ -1640,7 +1640,7 @@ void row::apply_monotonically(const schema& s, column_kind kind, row&& other) {
|
||||
// we erase the live cells according to the shadowable_tombstone rules.
|
||||
static bool dead_marker_shadows_row(const schema& s, column_kind kind, const row_marker& marker) {
|
||||
return s.is_view()
|
||||
&& s.view_info()->base_non_pk_column_in_view_pk()
|
||||
&& !s.view_info()->base_non_pk_columns_in_view_pk().empty()
|
||||
&& !marker.is_live()
|
||||
&& kind == column_kind::regular_column; // not applicable to static rows
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class view_info final {
|
||||
mutable std::optional<query::partition_slice> _partition_slice;
|
||||
mutable std::optional<dht::partition_range_vector> _partition_ranges;
|
||||
// Id of a regular base table column included in the view's PK, if any.
|
||||
mutable std::optional<column_id> _base_non_pk_column_in_view_pk;
|
||||
mutable std::vector<column_id> _base_non_pk_columns_in_view_pk;
|
||||
public:
|
||||
view_info(const schema& schema, const raw_view_info& raw_view_info);
|
||||
|
||||
@@ -63,7 +63,7 @@ public:
|
||||
const dht::partition_range_vector& partition_ranges() const;
|
||||
const column_definition* view_column(const schema& base, column_id base_id) const;
|
||||
const column_definition* view_column(const column_definition& base_def) const;
|
||||
std::optional<column_id> base_non_pk_column_in_view_pk() const;
|
||||
const std::vector<column_id>& base_non_pk_columns_in_view_pk() const;
|
||||
void initialize_base_dependent_fields(const schema& base);
|
||||
|
||||
friend bool operator==(const view_info& x, const view_info& y) {
|
||||
|
||||
Reference in New Issue
Block a user