Skip to content

Commit aad94da

Browse files
authored
Database creation and deletion (#22)
* Database creation and deletion * Fixing docs
1 parent 745f6e6 commit aad94da

File tree

7 files changed

+257
-22
lines changed

7 files changed

+257
-22
lines changed

arangoasync/collection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,9 @@ async def get(
195195
def response_handler(resp: Response) -> Optional[U]:
196196
if resp.is_success:
197197
return self._doc_deserializer.loads(resp.raw_body)
198-
elif resp.error_code == HTTP_NOT_FOUND:
198+
elif resp.status_code == HTTP_NOT_FOUND:
199199
return None
200-
elif resp.error_code == HTTP_PRECONDITION_FAILED:
200+
elif resp.status_code == HTTP_PRECONDITION_FAILED:
201201
raise DocumentRevisionError(resp, request)
202202
else:
203203
raise DocumentGetError(resp, request)

arangoasync/database.py

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,22 @@
88

99
from arangoasync.collection import CollectionType, StandardCollection
1010
from arangoasync.connection import Connection
11-
from arangoasync.errno import HTTP_NOT_FOUND
11+
from arangoasync.errno import HTTP_FORBIDDEN, HTTP_NOT_FOUND
1212
from arangoasync.exceptions import (
1313
CollectionCreateError,
1414
CollectionDeleteError,
1515
CollectionListError,
16+
DatabaseCreateError,
17+
DatabaseDeleteError,
18+
DatabaseListError,
1619
ServerStatusError,
1720
)
1821
from arangoasync.executor import ApiExecutor, DefaultApiExecutor
1922
from arangoasync.request import Method, Request
2023
from arangoasync.response import Response
2124
from arangoasync.serialization import Deserializer, Serializer
2225
from arangoasync.typings import Json, Jsons, Params, Result
23-
from arangoasync.wrapper import KeyOptions, ServerStatusInformation
26+
from arangoasync.wrapper import KeyOptions, ServerStatusInformation, User
2427

2528
T = TypeVar("T")
2629
U = TypeVar("U")
@@ -76,6 +79,137 @@ def response_handler(resp: Response) -> ServerStatusInformation:
7679

7780
return await self._executor.execute(request, response_handler)
7881

82+
async def has_database(self, name: str) -> Result[bool]:
83+
"""Check if a database exists.
84+
85+
Args:
86+
name (str): Database name.
87+
88+
Returns:
89+
bool: `True` if the database exists, `False` otherwise.
90+
91+
Raises:
92+
DatabaseListError: If failed to retrieve the list of databases.
93+
"""
94+
request = Request(method=Method.GET, endpoint="/_api/database")
95+
96+
def response_handler(resp: Response) -> bool:
97+
if not resp.is_success:
98+
raise DatabaseListError(resp, request)
99+
body = self.deserializer.loads(resp.raw_body)
100+
return name in body["result"]
101+
102+
return await self._executor.execute(request, response_handler)
103+
104+
async def create_database(
105+
self,
106+
name: str,
107+
users: Optional[Sequence[Json | User]] = None,
108+
replication_factor: Optional[int | str] = None,
109+
write_concern: Optional[int] = None,
110+
sharding: Optional[bool] = None,
111+
) -> Result[bool]:
112+
"""Create a new database.
113+
114+
Args:
115+
name (str): Database name.
116+
users (list | None): Optional list of users with access to the new
117+
database, where each user is of :class:`User
118+
<arangoasync.wrapper.User>` type, or a dictionary with fields
119+
"username", "password" and "active". If not set, the default user
120+
**root** will be used to ensure that the new database will be
121+
accessible after it is created.
122+
replication_factor (int | str | None): Default replication factor for new
123+
collections created in this database. Special values include
124+
“satellite”, which will replicate the collection to every DB-Server
125+
(Enterprise Edition only), and 1, which disables replication. Used
126+
for clusters only.
127+
write_concern (int | None): Default write concern for collections created
128+
in this database. Determines how many copies of each shard are required
129+
to be in sync on different DB-Servers. If there are less than these many
130+
copies in the cluster a shard will refuse to write. Writes to shards with
131+
enough up-to-date copies will succeed at the same time, however. Value of
132+
this parameter can not be larger than the value of **replication_factor**.
133+
Used for clusters only.
134+
sharding (str | None): Sharding method used for new collections in this
135+
database. Allowed values are: "", "flexible" and "single". The first
136+
two are equivalent. Used for clusters only.
137+
138+
Returns:
139+
bool: True if the database was created successfully.
140+
141+
Raises:
142+
DatabaseCreateError: If creation fails.
143+
"""
144+
data: Json = {"name": name}
145+
146+
options: Json = {}
147+
if replication_factor is not None:
148+
options["replicationFactor"] = replication_factor
149+
if write_concern is not None:
150+
options["writeConcern"] = write_concern
151+
if sharding is not None:
152+
options["sharding"] = sharding
153+
if options:
154+
data["options"] = options
155+
156+
if users is not None:
157+
data["users"] = [
158+
{
159+
"username": user["username"],
160+
"passwd": user["password"],
161+
"active": user.get("active", True),
162+
"extra": user.get("extra", {}),
163+
}
164+
for user in users
165+
]
166+
167+
request = Request(
168+
method=Method.POST,
169+
endpoint="/_api/database",
170+
data=self.serializer.dumps(data),
171+
)
172+
173+
def response_handler(resp: Response) -> bool:
174+
if resp.is_success:
175+
return True
176+
raise DatabaseCreateError(resp, request)
177+
178+
return await self._executor.execute(request, response_handler)
179+
180+
async def delete_database(
181+
self, name: str, ignore_missing: bool = False
182+
) -> Result[bool]:
183+
"""Delete a database.
184+
185+
Args:
186+
name (str): Database name.
187+
ignore_missing (bool): Do not raise an exception on missing database.
188+
189+
Returns:
190+
bool: True if the database was deleted successfully, `False` if the
191+
database was not found but **ignore_missing** was set to `True`.
192+
193+
Raises:
194+
DatabaseDeleteError: If deletion fails.
195+
"""
196+
request = Request(method=Method.DELETE, endpoint=f"/_api/database/{name}")
197+
198+
def response_handler(resp: Response) -> bool:
199+
if resp.is_success:
200+
return True
201+
if resp.status_code == HTTP_NOT_FOUND and ignore_missing:
202+
return False
203+
if resp.status_code == HTTP_FORBIDDEN:
204+
raise DatabaseDeleteError(
205+
resp,
206+
request,
207+
"This request can only be executed in the _system database.",
208+
)
209+
raise DatabaseDeleteError(resp, request)
210+
211+
return await self._executor.execute(request, response_handler)
212+
79213
def collection(
80214
self,
81215
name: str,
@@ -231,7 +365,7 @@ async def create_collection(
231365
data["isSystem"] = is_system
232366
if key_options is not None:
233367
if isinstance(key_options, dict):
234-
key_options = KeyOptions(key_options)
368+
key_options = KeyOptions(data=key_options)
235369
key_options.validate()
236370
data["keyOptions"] = key_options.to_dict()
237371
if schema is not None:
@@ -311,7 +445,7 @@ def response_handler(resp: Response) -> bool:
311445
nonlocal ignore_missing
312446
if resp.is_success:
313447
return True
314-
if resp.error_code == HTTP_NOT_FOUND and ignore_missing:
448+
if resp.status_code == HTTP_NOT_FOUND and ignore_missing:
315449
return False
316450
raise CollectionDeleteError(resp, request)
317451

arangoasync/exceptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ class ClientConnectionError(ArangoClientError):
9292
"""The request was unable to reach the server."""
9393

9494

95+
class DatabaseCreateError(ArangoServerError):
96+
"""Failed to create database."""
97+
98+
99+
class DatabaseDeleteError(ArangoServerError):
100+
"""Failed to delete database."""
101+
102+
103+
class DatabaseListError(ArangoServerError):
104+
"""Failed to retrieve databases."""
105+
106+
95107
class DeserializationError(ArangoClientError):
96108
"""Failed to deserialize the server response."""
97109

arangoasync/wrapper.py

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,7 @@ class KeyOptions(JsonWrapper):
5454
5555
https://docs.arangodb.com/stable/develop/http-api/collections/#create-a-collection_body_keyOptions
5656
57-
Example:
58-
.. code-block:: json
59-
60-
"keyOptions": {
61-
"type": "autoincrement",
62-
"increment": 5,
63-
"allowUserKeys": true
64-
}
65-
6657
Args:
67-
data (dict | None): Key options. If this parameter is specified, the
68-
other parameters are ignored.
6958
allow_user_keys (bool): If set to `True`, then you are allowed to supply own
7059
key values in the `_key` attribute of documents. If set to `False`, then
7160
the key generator is solely responsible for generating keys and an error
@@ -78,15 +67,26 @@ class KeyOptions(JsonWrapper):
7867
generator. Not allowed for other key generator types.
7968
offset (int | None): The initial offset value for the "autoincrement" key
8069
generator. Not allowed for other key generator types.
70+
data (dict | None): Key options. If this parameter is specified, the
71+
other parameters are ignored.
72+
73+
Example:
74+
.. code-block:: json
75+
76+
{
77+
"type": "autoincrement",
78+
"increment": 5,
79+
"allowUserKeys": true
80+
}
8181
"""
8282

8383
def __init__(
8484
self,
85-
data: Optional[Json] = None,
8685
allow_user_keys: bool = True,
8786
generator_type: str = "traditional",
8887
increment: Optional[int] = None,
8988
offset: Optional[int] = None,
89+
data: Optional[Json] = None,
9090
) -> None:
9191
if data is None:
9292
data = {
@@ -123,6 +123,61 @@ def validate(self) -> None:
123123
)
124124

125125

126+
class User(JsonWrapper):
127+
"""User information.
128+
129+
https://docs.arangodb.com/stable/develop/http-api/users/#get-a-user
130+
131+
Args:
132+
username (str): The name of the user.
133+
password (str | None): The user password as a string. Note that user
134+
password is not returned back by the server.
135+
active (bool): `True` if user is active, `False` otherwise.
136+
extra (dict | None): Additional user information. For internal use only.
137+
Should not be set or modified by end users.
138+
139+
Example:
140+
.. code-block:: json
141+
142+
{
143+
"username": "john",
144+
"password": "secret",
145+
"active": true,
146+
"extra": {}
147+
}
148+
"""
149+
150+
def __init__(
151+
self,
152+
username: str,
153+
password: Optional[str] = None,
154+
active: bool = True,
155+
extra: Optional[Json] = None,
156+
) -> None:
157+
data = {"username": username, "active": active}
158+
if password is not None:
159+
data["password"] = password
160+
if extra is not None:
161+
data["extra"] = extra
162+
super().__init__(data)
163+
164+
@property
165+
def username(self) -> str:
166+
return self._data.get("username") # type: ignore[return-value]
167+
168+
@property
169+
def password(self) -> Optional[str]:
170+
return self._data.get("password")
171+
172+
@property
173+
def active(self) -> bool:
174+
return self._data.get("active") # type: ignore[return-value]
175+
176+
@property
177+
def extra(self) -> Optional[Json]:
178+
return self._data.get("extra")
179+
180+
126181
class ServerStatusInformation(JsonWrapper):
127182
"""Status information about the server.
128183

tests/helpers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
from uuid import uuid4
22

33

4+
def generate_db_name():
5+
"""Generate and return a random database name.
6+
7+
Returns:
8+
str: Random database name.
9+
"""
10+
return f"test_database_{uuid4().hex}"
11+
12+
413
def generate_col_name():
514
"""Generate and return a random collection name.
615

tests/test_connection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,10 @@ async def test_BasicConnection_prep_response_bad_response(
109109
with pytest.raises(ServerConnectionError):
110110
connection.raise_for_status(request, response)
111111

112-
error = b'{"error": true, "errorMessage": "msg", "errorNum": 404}'
112+
error = b'{"error": true, "errorMessage": "msg", "errorNum": 1234}'
113113
response = Response(Method.GET, url, {}, 0, "ERROR", error)
114114
connection.prep_response(request, response)
115-
assert response.error_code == 404
115+
assert response.error_code == 1234
116116
assert response.error_message == "msg"
117117

118118

tests/test_database.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from arangoasync.auth import Auth
44
from arangoasync.client import ArangoClient
55
from arangoasync.collection import StandardCollection
6-
from tests.helpers import generate_col_name
6+
from tests.helpers import generate_col_name, generate_db_name
77

88

99
@pytest.mark.asyncio
@@ -17,6 +17,28 @@ async def test_database_misc_methods(url, sys_db_name, root, password):
1717
assert status["server"] == "arango"
1818

1919

20+
@pytest.mark.asyncio
21+
async def test_create_drop_database(url, sys_db_name, root, password):
22+
auth = Auth(username=root, password=password)
23+
24+
# TODO also handle exceptions
25+
# TODO use more options (cluster must be enabled for that)
26+
async with ArangoClient(hosts=url) as client:
27+
sys_db = await client.db(
28+
sys_db_name, auth_method="basic", auth=auth, verify=True
29+
)
30+
db_name = generate_db_name()
31+
assert await sys_db.create_database(db_name) is True
32+
await client.db(db_name, auth_method="basic", auth=auth, verify=True)
33+
assert await sys_db.has_database(db_name) is True
34+
assert await sys_db.delete_database(db_name) is True
35+
non_existent_db = generate_db_name()
36+
assert await sys_db.has_database(non_existent_db) is False
37+
assert (
38+
await sys_db.delete_database(non_existent_db, ignore_missing=True) is False
39+
)
40+
41+
2042
@pytest.mark.asyncio
2143
async def test_create_drop_collection(url, sys_db_name, root, password):
2244
auth = Auth(username=root, password=password)
@@ -28,7 +50,10 @@ async def test_create_drop_collection(url, sys_db_name, root, password):
2850
col = await db.create_collection(col_name)
2951
assert isinstance(col, StandardCollection)
3052
assert await db.has_collection(col_name)
31-
await db.delete_collection(col_name)
53+
assert await db.delete_collection(col_name) is True
3254
assert not await db.has_collection(col_name)
3355
non_existent_col = generate_col_name()
3456
assert await db.has_collection(non_existent_col) is False
57+
assert (
58+
await db.delete_collection(non_existent_col, ignore_missing=True) is False
59+
)

0 commit comments

Comments
 (0)