Skip to content

Commit 6571f32

Browse files
authored
Merge pull request #74 from ioet/feature/add-migrations-support#36
Closes #36 Allow to run migrations
2 parents 2b32c82 + b8d2aa1 commit 6571f32

File tree

16 files changed

+305
-41
lines changed

16 files changed

+305
-41
lines changed

.env.template

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ export FLASK_APP=time_tracker_api
44

55
# Common attributes
66
## In case you use an Azure SQL database, you must specify the database connection URI. Check out the README.md for more details
7-
#export DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
7+
#export SQL_DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
88

99
## For Azure Cosmos DB
1010
export DATABASE_ACCOUNT_URI=https://<project_db_name>.documents.azure.com:443
1111
export DATABASE_MASTER_KEY=<db_master_key>
1212
export DATABASE_NAME=<db_name>
1313
### or
14-
# export DATABASE_URI=AccountEndpoint=<ACCOUNT_URI>;AccountKey=<ACCOUNT_KEY>
14+
# export COSMOS_DATABASE_URI=AccountEndpoint=<ACCOUNT_URI>;AccountKey=<ACCOUNT_KEY>

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,8 @@ htmlcov/
2828
timetracker-api-postman-collection.json
2929
swagger.json
3030

31-
# Ignore any SQLite generated database
31+
# SQLite databases
3232
*.db
33+
34+
# Local migration files
35+
migration_status.csv

README.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,10 @@ Driver=/usr/local/lib/libmsodbcsql.17.dylib
6060
UsageCount=2
6161
```
6262

63-
Then specify the driver name, in this case _DBC Driver 17 for SQL Server_ in the `DATABASE_URI`, e.g.:
63+
Then specify the driver name, in this case _DBC Driver 17 for SQL Server_ in the `SQL_DATABASE_URI`, e.g.:
6464

6565
```.dotenv
66-
DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
66+
SQL_DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
6767
```
6868

6969
To troubleshoot issues regarding this part please check out:
@@ -115,7 +115,7 @@ tests as [system testing](https://en.wikipedia.org/wiki/System_testing):
115115
python3 -m pytest -v
116116
```
117117

118-
The database tests will be done in the table `tests` of the database specified by the variable `DATABASE_URI`. If this
118+
The database tests will be done in the table `tests` of the database specified by the variable `SQL_DATABASE_URI`. If this
119119
variable is not specified it will automatically connect to SQLite database in-memory. This will do, because we are using
120120
[SQL Alchemy](https://www.sqlalchemy.org/features.html) to be able connect to any SQL database maintaining the same
121121
codebase.
@@ -190,6 +190,36 @@ docker run -p 5000:5000 time_tracker_api:local
190190

191191
3. Visit `127.0.0.1:5000`
192192

193+
## Migrations
194+
Looking for a DB-agnostic migration tool, the only choice I found was [migrate-anything](https://pypi.org/project/migrate-anything/).
195+
An specific requirement file was created to run the migrations in `requirements/migrations.txt`. This way we do not mix
196+
any possible vulnerable dependency brought by these dependencies to the environment `prod`. Therefore the dependencies
197+
to run the migrations shall be installed this way:
198+
199+
```bash
200+
pip install -r requirements/<app>/prod.txt
201+
pip install -r requirements/migrations.txt
202+
```
203+
204+
All the migrations will be handled and created in the python package `migrations`. In order to create a migration we
205+
must do it manually (for now) and prefixed by a number, e.g. `migrations/01-initialize-db.py` in order to warranty the
206+
order of execution alphabetically.
207+
Inside every migration there is an `up` and `down` method. The `down` method is executed from the persisted migration in
208+
the database. Whe a `down` logic that used external dependencies was tested it failed, whilst I put that same logic in
209+
the an `up` method it run correctly. In general the library seems to present [design issues](https://github.com/Lieturd/migrate-anything/issues/3).
210+
Therefore, it is recommended to apply changes just in one direction: `up`.
211+
For more information, please check out [some examples](https://github.com/Lieturd/migrate-anything/tree/master/examples)
212+
that illustrates the usage of this migration tool.
213+
214+
Basically, for running the migrations you must execute
215+
216+
```bash
217+
migrate-anything migrations
218+
```
219+
220+
They will be automatically run during the Continuous Deployment process.
221+
222+
193223
## Built with
194224
- [Python version 3](https://www.python.org/download/releases/3.0/) as backend programming language. Strong typing for
195225
the win.

commons/data_access_layer/cosmos_db.py

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
import dataclasses
2+
import logging
23
import uuid
34
from typing import Callable
45

56
import azure.cosmos.cosmos_client as cosmos_client
67
import azure.cosmos.exceptions as exceptions
7-
from azure.cosmos import ContainerProxy
8+
from azure.cosmos import ContainerProxy, PartitionKey
89
from flask import Flask
910

1011

1112
class CosmosDBFacade:
12-
def __init__(self, app: Flask): # pragma: no cover
13-
self.app = app
13+
def __init__(self, client, db_id: str, logger=None): # pragma: no cover
14+
self.client = client
15+
self.db = self.client.get_database_client(db_id)
16+
if logger is None:
17+
self.logger = logging.getLogger(CosmosDBFacade.__name__)
18+
else:
19+
self.logger = logger
1420

15-
db_uri = app.config.get('DATABASE_URI')
21+
@classmethod
22+
def from_flask_config(cls, app: Flask):
23+
db_uri = app.config.get('COSMOS_DATABASE_URI')
1624
if db_uri is None:
17-
app.logger.warn("DATABASE_URI was not found. Looking for alternative variables.")
25+
app.logger.warn("COSMOS_DATABASE_URI was not found. Looking for alternative variables.")
1826
account_uri = app.config.get('DATABASE_ACCOUNT_URI')
1927
if account_uri is None:
2028
raise EnvironmentError("DATABASE_ACCOUNT_URI is not defined in the environment")
@@ -23,31 +31,26 @@ def __init__(self, app: Flask): # pragma: no cover
2331
if master_key is None:
2432
raise EnvironmentError("DATABASE_MASTER_KEY is not defined in the environment")
2533

26-
self.client = cosmos_client.CosmosClient(account_uri, {'masterKey': master_key},
27-
user_agent="CosmosDBDotnetQuickstart",
28-
user_agent_overwrite=True)
34+
client = cosmos_client.CosmosClient(account_uri, {'masterKey': master_key},
35+
user_agent="CosmosDBDotnetQuickstart",
36+
user_agent_overwrite=True)
2937
else:
30-
self.client = cosmos_client.CosmosClient.from_connection_string(db_uri)
38+
client = cosmos_client.CosmosClient.from_connection_string(db_uri)
3139

3240
db_id = app.config.get('DATABASE_NAME')
3341
if db_id is None:
3442
raise EnvironmentError("DATABASE_NAME is not defined in the environment")
3543

36-
self.db = self.client.get_database_client(db_id)
44+
return cls(client, db_id, logger=app.logger)
3745

3846
def create_container(self, container_definition: dict):
39-
try:
40-
return self.db.create_container(**container_definition)
47+
return self.db.create_container(**container_definition)
4148

42-
except exceptions.CosmosResourceExistsError: # pragma: no cover
43-
self.app.logger.info('Container with id \'{0}\' was found'.format(container_definition["id"]))
49+
def create_container_if_not_exists(self, container_definition: dict):
50+
return self.db.create_container_if_not_exists(**container_definition)
4451

4552
def delete_container(self, container_id: str):
46-
try:
47-
return self.db.delete_container(container_id)
48-
49-
except exceptions.CosmosHttpResponseError: # pragma: no cover
50-
self.app.logger.info('Container with id \'{0}\' was not deleted'.format(container_id))
53+
return self.db.delete_container(container_id)
5154

5255

5356
cosmos_helper: CosmosDBFacade = None
@@ -61,8 +64,13 @@ def __init__(self, data):
6164
setattr(self, k, v)
6265

6366

67+
def partition_key_attribute(pk: PartitionKey) -> str:
68+
return pk.path.strip('/')
69+
70+
6471
class CosmosDBRepository:
6572
def __init__(self, container_id: str,
73+
partition_key_attribute: str,
6674
mapper: Callable = None,
6775
custom_cosmos_helper: CosmosDBFacade = None):
6876
global cosmos_helper
@@ -71,12 +79,16 @@ def __init__(self, container_id: str,
7179
raise ValueError("The cosmos_db module has not been initialized!")
7280
self.mapper = mapper
7381
self.container: ContainerProxy = self.cosmos_helper.db.get_container_client(container_id)
82+
self.partition_key_attribute: str = partition_key_attribute
7483

7584
@classmethod
7685
def from_definition(cls, container_definition: dict,
7786
mapper: Callable = None,
7887
custom_cosmos_helper: CosmosDBFacade = None):
79-
return cls(container_definition['id'], mapper, custom_cosmos_helper)
88+
pk_attrib = partition_key_attribute(container_definition['partition_key'])
89+
return cls(container_definition['id'], pk_attrib,
90+
mapper=mapper,
91+
custom_cosmos_helper=custom_cosmos_helper)
8092

8193
def create(self, data: dict, mapper: Callable = None):
8294
function_mapper = self.get_mapper_or_dict(mapper)
@@ -93,11 +105,12 @@ def find_all(self, partition_key_value: str, max_count=None, offset=0,
93105
max_count = self.get_page_size_or(max_count)
94106
result = self.container.query_items(
95107
query="""
96-
SELECT * FROM c WHERE c.tenant_id=@tenant_id AND {visibility_condition}
108+
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value AND {visibility_condition}
97109
OFFSET @offset LIMIT @max_count
98-
""".format(visibility_condition=self.create_sql_condition_for_visibility(visible_only)),
110+
""".format(partition_key_attribute=self.partition_key_attribute,
111+
visibility_condition=self.create_sql_condition_for_visibility(visible_only)),
99112
parameters=[
100-
{"name": "@tenant_id", "value": partition_key_value},
113+
{"name": "@partition_key_value", "value": partition_key_value},
101114
{"name": "@offset", "value": offset},
102115
{"name": "@max_count", "value": max_count},
103116
],
@@ -122,6 +135,9 @@ def delete(self, id: str, partition_key_value: str, mapper: Callable = None):
122135
'deleted': str(uuid.uuid4())
123136
}, partition_key_value, visible_only=True, mapper=mapper)
124137

138+
def delete_permanently(self, id: str, partition_key_value: str) -> None:
139+
self.container.delete_item(id, partition_key_value)
140+
125141
def check_visibility(self, item, throw_not_found_if_deleted):
126142
if throw_not_found_if_deleted and item.get('deleted') is not None:
127143
raise exceptions.CosmosResourceNotFoundError(message='Deleted item',
@@ -146,4 +162,4 @@ def get_page_size_or(self, custom_page_size: int) -> int:
146162

147163
def init_app(app: Flask) -> None:
148164
global cosmos_helper
149-
cosmos_helper = CosmosDBFacade(app)
165+
cosmos_helper = CosmosDBFacade.from_flask_config(app)

migrations/01-initialize-db.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
def up():
2+
from commons.data_access_layer.cosmos_db import cosmos_helper
3+
import azure.cosmos.exceptions as exceptions
4+
from . import app
5+
6+
app.logger.info("Creating TimeTracker initial containers...")
7+
8+
try:
9+
app.logger.info('- Project')
10+
from time_tracker_api.projects.projects_model import container_definition as project_definition
11+
cosmos_helper.create_container(project_definition)
12+
13+
app.logger.info('- Project type')
14+
from time_tracker_api.project_types.project_types_model import container_definition as project_type_definition
15+
cosmos_helper.create_container(project_type_definition)
16+
17+
app.logger.info('- Activity')
18+
from time_tracker_api.activities.activities_model import container_definition as activity_definition
19+
cosmos_helper.create_container(activity_definition)
20+
21+
app.logger.info('- Customer')
22+
from time_tracker_api.customers.customers_model import container_definition as customer_definition
23+
cosmos_helper.create_container(customer_definition)
24+
25+
app.logger.info('- Time entry')
26+
from time_tracker_api.time_entries.time_entries_model import container_definition as time_entry_definition
27+
cosmos_helper.create_container(time_entry_definition)
28+
except exceptions.CosmosResourceExistsError as e:
29+
app.logger.warning("Unexpected error while creating initial database schema: %s" % e.message)
30+
31+
app.logger.info("Done!")
32+
33+
34+
def down():
35+
print("Not implemented!")

migrations/__init__.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from azure.cosmos import PartitionKey
2+
from migrate_anything import configure
3+
from migrate_anything.storage import Storage
4+
5+
from time_tracker_api import create_app
6+
7+
8+
class CustomStorage(object):
9+
def __init__(self, file):
10+
self.file = file
11+
12+
def save_migration(self, name, code):
13+
with open(self.file, "a", encoding="utf-8") as file:
14+
file.write("{},{}\n".format(name, code))
15+
16+
def list_migrations(self):
17+
try:
18+
with open(self.file, encoding="utf-8") as file:
19+
return [
20+
line.split(",")
21+
for line in file.readlines()
22+
if line.strip() # Skip empty lines
23+
]
24+
except FileNotFoundError:
25+
return []
26+
27+
def remove_migration(self, name):
28+
migrations = [
29+
migration for migration in self.list_migrations() if migration[0] != name
30+
]
31+
32+
with open(self.file, "w", encoding="utf-8") as file:
33+
for row in migrations:
34+
file.write("{},{}\n".format(*row))
35+
36+
37+
app = create_app('time_tracker_api.config.CLIConfig')
38+
from commons.data_access_layer.cosmos_db import cosmos_helper, init_app, CosmosDBRepository
39+
40+
if cosmos_helper is None:
41+
init_app(app)
42+
from commons.data_access_layer.cosmos_db import cosmos_helper
43+
44+
45+
class CosmosDBStorage(Storage):
46+
def __init__(self, collection_id, app_id):
47+
self.collection_id = collection_id
48+
self.app_id = app_id
49+
migrations_definition = {
50+
'id': collection_id,
51+
'partition_key': PartitionKey(path='/app_id'),
52+
'unique_key_policy': {
53+
'uniqueKeys': [
54+
{'paths': ['/name']},
55+
]
56+
}
57+
}
58+
cosmos_helper.create_container_if_not_exists(migrations_definition)
59+
self.repository = CosmosDBRepository.from_definition(migrations_definition)
60+
61+
def save_migration(self, name, code):
62+
self.repository.create({"id": name,
63+
"name": name,
64+
"code": code,
65+
"app_id": self.app_id})
66+
67+
def list_migrations(self):
68+
migrations = self.repository.find_all(self.app_id)
69+
return [
70+
[item['name'], item['code']] for item in migrations
71+
]
72+
73+
def remove_migration(self, name):
74+
self.repository.delete_permanently(name, self.app_id)
75+
76+
77+
configure(storage=CosmosDBStorage("migrations", "time-tracker-api"))

requirements/migrations.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# requirements/migrations.txt
2+
3+
# For running any kind of data migration
4+
5+
# Migration tool
6+
migrate-anything==0.1.6

tests/commons/data_access_layer/cosmos_db_test.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,32 @@ def test_partial_update_should_not_find_element_that_is_already_deleted(
507507
except Exception as e:
508508
assert type(e) is CosmosResourceNotFoundError
509509
assert e.status_code == 404
510+
511+
512+
def test_delete_permanently_with_invalid_id_should_fail(
513+
cosmos_db_repository: CosmosDBRepository,
514+
sample_item: dict):
515+
try:
516+
cosmos_db_repository.delete_permanently(fake.uuid4(), sample_item['tenant_id'])
517+
fail('It should have not found the deleted item')
518+
except Exception as e:
519+
assert type(e) is CosmosResourceNotFoundError
520+
assert e.status_code == 404
521+
522+
523+
def test_delete_permanently_with_valid_id_should_succeed(
524+
cosmos_db_repository: CosmosDBRepository,
525+
sample_item: dict):
526+
found_item = cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id'])
527+
528+
assert found_item is not None
529+
assert found_item['id'] == sample_item['id']
530+
531+
cosmos_db_repository.delete_permanently(sample_item['id'], sample_item['tenant_id'])
532+
533+
try:
534+
cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id'])
535+
fail('It should have not found the deleted item')
536+
except Exception as e:
537+
assert type(e) is CosmosResourceNotFoundError
538+
assert e.status_code == 404

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def cosmos_db_repository(app: Flask, cosmos_db_model) -> CosmosDBRepository:
8080
from commons.data_access_layer.cosmos_db import cosmos_helper
8181

8282
app.logger.info("Creating Cosmos DB test models...")
83-
cosmos_helper.create_container(cosmos_db_model)
83+
cosmos_helper.create_container_if_not_exists(cosmos_db_model)
8484
app.logger.info("Cosmos DB test models created!")
8585

8686
yield CosmosDBRepository.from_definition(cosmos_db_model)

0 commit comments

Comments
 (0)