Skip to content

Commit 3e7c4fc

Browse files
Refactor Dataset Tag Function (#322)
Rely on the database to identify if the dataset is absent or the tag already exists. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6350bbf commit 3e7c4fc

8 files changed

Lines changed: 114 additions & 29 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,14 @@ jobs:
2828
- uses: actions/setup-python@v6
2929
with:
3030
python-version: 3.x
31-
32-
# https://github.com/docker/compose/issues/10596
3331
- name: Start services
3432
run: |
3533
services="python-api"
3634
if [ "${{ matrix.php_api }}" = "true" ]; then
3735
sed -i 's/INDEX_ES_DURING_STARTUP=false/INDEX_ES_DURING_STARTUP=true/' docker/php/.env
3836
services="$services php-api"
3937
fi
40-
docker compose up $services --detach --wait --remove-orphans || exit $(docker compose ps -q | xargs docker inspect -f '{{.State.ExitCode}}' | grep -v '^0' | wc -l)
41-
38+
docker compose up $services --detach --wait --remove-orphans
4239
- name: Run tests
4340
run: |
4441
marker="${{ matrix.php_api == true && 'php_api' || 'not php_api' }} and ${{ matrix.mutations == true && 'mut' || 'not mut' }}"

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
docker/mysql/data
2+
.claude/
3+
.ignore/
24
*.log
35
logs/
46
.DS_Store

src/database/datasets.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,15 @@
55

66
from sqlalchemy import text
77
from sqlalchemy.engine import Row
8+
from sqlalchemy.exc import IntegrityError
89
from sqlalchemy.ext.asyncio import AsyncConnection
910

11+
from database.exceptions import (
12+
_DUPLICATE_ENTRY,
13+
_FOREIGN_KEY_CONSTRAINT_FAILED,
14+
DuplicatePrimaryKeyError,
15+
ForeignKeyConstraintError,
16+
)
1017
from schemas.datasets.openml import Feature
1118

1219

@@ -54,19 +61,27 @@ async def get_tags_for(id_: int, connection: AsyncConnection) -> list[str]:
5461

5562

5663
async def tag(id_: int, tag_: str, *, user_id: int, connection: AsyncConnection) -> None:
57-
await connection.execute(
58-
text(
59-
"""
60-
INSERT INTO dataset_tag(`id`, `tag`, `uploader`)
61-
VALUES (:dataset_id, :tag, :user_id)
62-
""",
63-
),
64-
parameters={
65-
"dataset_id": id_,
66-
"user_id": user_id,
67-
"tag": tag_,
68-
},
69-
)
64+
try:
65+
await connection.execute(
66+
text(
67+
"""
68+
INSERT INTO dataset_tag(`id`, `tag`, `uploader`)
69+
VALUES (:dataset_id, :tag, :user_id)
70+
""",
71+
),
72+
parameters={
73+
"dataset_id": id_,
74+
"user_id": user_id,
75+
"tag": tag_,
76+
},
77+
)
78+
except IntegrityError as e:
79+
code, msg = e.orig.args
80+
if code == _FOREIGN_KEY_CONSTRAINT_FAILED:
81+
raise ForeignKeyConstraintError(msg) from e
82+
if code == _DUPLICATE_ENTRY:
83+
raise DuplicatePrimaryKeyError(msg) from e
84+
raise
7085

7186

7287
async def get_description(

src/database/exceptions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Defines exceptions of the database layer."""
2+
3+
_FOREIGN_KEY_CONSTRAINT_FAILED = 1452
4+
_DUPLICATE_ENTRY = 1062
5+
6+
7+
class ForeignKeyConstraintError(Exception):
8+
"""Foreign key constraint violated."""
9+
10+
def __init__(self, msg: str) -> None:
11+
"""Initialize the error with a message `msg`."""
12+
super().__init__()
13+
self.msg: str = msg
14+
15+
16+
class DuplicatePrimaryKeyError(Exception):
17+
"""Primary key already present."""
18+
19+
def __init__(self, msg: str) -> None:
20+
"""Initialize the error with a message `msg`."""
21+
super().__init__()
22+
self.msg: str = msg

src/routers/openml/datasets.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
_format_dataset_url,
3333
_format_parquet_url,
3434
)
35+
from database.exceptions import DuplicatePrimaryKeyError, ForeignKeyConstraintError
3536
from database.users import User
3637
from routers.dependencies import (
3738
Pagination,
@@ -40,7 +41,13 @@
4041
fetch_user_or_raise,
4142
userdb_connection,
4243
)
43-
from routers.types import CasualString128, IntegerRange, SystemString64, integer_range_regex
44+
from routers.types import (
45+
CasualString128,
46+
Identifier,
47+
IntegerRange,
48+
SystemString64,
49+
integer_range_regex,
50+
)
4451
from schemas.datasets.openml import DatasetMetadata, DatasetStatus, Feature, FeatureType
4552

4653
router = APIRouter(prefix="/datasets", tags=["datasets"])
@@ -50,21 +57,26 @@
5057
path="/tag",
5158
)
5259
async def tag_dataset(
53-
data_id: Annotated[int, Body()],
60+
data_id: Annotated[Identifier, Body()],
5461
tag: Annotated[str, SystemString64],
5562
user: Annotated[User, Depends(fetch_user_or_raise)],
56-
expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)] = None,
63+
expdb_db: Annotated[AsyncConnection, Depends(expdb_connection)],
5764
) -> dict[str, dict[str, Any]]:
58-
assert expdb_db is not None # noqa: S101
59-
tags = await database.datasets.get_tags_for(data_id, expdb_db)
60-
if tag.casefold() in [t.casefold() for t in tags]:
65+
try:
66+
await database.datasets.tag(data_id, tag, user_id=user.user_id, connection=expdb_db)
67+
except ForeignKeyConstraintError:
68+
msg = f"Dataset {data_id} not found."
69+
raise DatasetNotFoundError(msg, code=472) from None
70+
except DuplicatePrimaryKeyError:
6171
msg = f"Dataset {data_id} already tagged with {tag!r}."
62-
raise TagAlreadyExistsError(msg)
72+
raise TagAlreadyExistsError(msg) from None
73+
74+
logger.info("Dataset {data_id} tagged '{tag}'.", data_id=data_id, tag=tag)
75+
76+
tags = await database.datasets.get_tags_for(data_id, expdb_db)
6377

64-
await database.datasets.tag(data_id, tag, user_id=user.user_id, connection=expdb_db)
65-
logger.info("Dataset {dataset_id} tagged '{tag}'.", dataset_id=data_id, tag=tag)
6678
return {
67-
"data_tag": {"id": str(data_id), "tag": [*tags, tag]},
79+
"data_tag": {"id": str(data_id), "tag": tags},
6880
}
6981

7082

src/routers/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
from typing import Annotated
2+
13
from fastapi import Body
4+
from pydantic import Field
25

36
SystemString64 = Body(pattern=r"^[\w\-\.]+$", min_length=1, max_length=64)
47

58
CasualString128 = Body(pattern=r"^[\w\-\.\(\),]+$", min_length=1, max_length=128)
69

10+
Identifier = Annotated[int, Field(gt=0)]
11+
712
integer_range_regex = r"^(\d+)(\.\.\d+)?$"
813
IntegerRange = Body(
914
pattern=integer_range_regex,

tests/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
DATASET_ID_THAT_DOES_NOT_EXIST = 9_9999_999
2+
SOME_PRIVATE_DATASET_ID = 130
13
PRIVATE_DATASET_ID = {130}
24
IN_PREPARATION_ID = {33, 161, 162, 163}
5+
SOME_DEACTIVATED_DATASET_ID = 131
36
DEACTIVATED_DATASETS = {131}
47
DATASETS = set(range(1, 132)) | {161, 162, 163}
58

tests/routers/openml/dataset_tag_test.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from sqlalchemy.ext.asyncio import AsyncConnection
77

88
from core.conversions import nested_remove_single_element_list
9-
from core.errors import TagAlreadyExistsError
9+
from core.errors import DatasetNotFoundError, TagAlreadyExistsError
1010
from database.datasets import get_tags_for
1111
from database.users import User
1212
from routers.openml.datasets import tag_dataset
@@ -96,13 +96,32 @@ async def test_dataset_tag_fails_if_tag_exists(expdb_test: AsyncConnection) -> N
9696
assert tag in e.value.detail
9797

9898

99+
async def test_dataset_tag_fails_if_dataset_does_not_exist(expdb_test: AsyncConnection) -> None:
100+
dataset_id = 1_000_000
101+
with pytest.raises(DatasetNotFoundError) as e:
102+
await tag_dataset(
103+
data_id=dataset_id,
104+
tag="foo",
105+
user=ADMIN_USER,
106+
expdb_db=expdb_test,
107+
)
108+
assert str(dataset_id) in e.value.detail
109+
dataset_not_found_in_tag_endpoint = 472
110+
assert e.value.code == dataset_not_found_in_tag_endpoint
111+
112+
99113
# -- migration tests --
100114

101115

102116
@pytest.mark.mut
103117
@pytest.mark.parametrize(
104118
"dataset_id",
105-
[*range(1, 10), 101, 131],
119+
[
120+
*range(1, 10),
121+
101,
122+
constants.SOME_DEACTIVATED_DATASET_ID,
123+
constants.DATASET_ID_THAT_DOES_NOT_EXIST,
124+
],
106125
)
107126
@pytest.mark.parametrize(
108127
"api_key",
@@ -142,6 +161,7 @@ async def test_dataset_tag_response_is_identical(
142161
and php_response.json()["error"]["message"] == "An Elastic Search Exception occured."
143162
):
144163
pytest.skip("Encountered Elastic Search error.")
164+
145165
py_response = await py_api.post(
146166
f"/datasets/tag?api_key={api_key}",
147167
json={"data_id": dataset_id, "tag": tag},
@@ -158,6 +178,15 @@ async def test_dataset_tag_response_is_identical(
158178
)
159179
return
160180

181+
if py_response.status_code == HTTPStatus.NOT_FOUND:
182+
assert php_response.status_code == HTTPStatus.PRECONDITION_FAILED
183+
py_error = py_response.json()
184+
php_error = php_response.json()["error"]
185+
assert py_error["code"] == php_error["code"]
186+
assert php_error["message"] == "Entity not found."
187+
assert re.match(r"Dataset \d+ not found.", py_error["detail"])
188+
return
189+
161190
assert py_response.status_code == php_response.status_code, php_response.json()
162191
if py_response.status_code != HTTPStatus.OK:
163192
assert py_response.json()["code"] == php_response.json()["error"]["code"]

0 commit comments

Comments
 (0)