Skip to content

Commit 2a8ec2f

Browse files
committed
🗃️(api) migrate database enum names to values (1)
By default, python-derived enum fields are stored using the enum name instead of its value in database. This forces us to map the corresponding value when exporting data. In this first step of the migration, we create the new enum types (using values) and then alter columns to the legacy enums to a varchar.
1 parent 6ca2aa6 commit 2a8ec2f

File tree

4 files changed

+321
-28
lines changed

4 files changed

+321
-28
lines changed

src/api/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to
2727
- Cache the "get PointDeCharge id from its `id_pdc_itinerance`" database query
2828
- Improve JSON string parsing using pyarrow engine
2929
- Add default values for optional Statique model fields
30+
- Migrate database enum types from names to values
3031
- Upgrade alembic to `1.14.1`
3132
- Upgrade geoalchemy2 to `0.17.0`
3233
- Upgrade psycopg to `3.2.4`
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""Alter db enum to varchar
2+
3+
Revision ID: 3e8ea07e5b66
4+
Revises: 8144a7d2553d
5+
Create Date: 2025-01-20 13:13:04.259534
6+
7+
"""
8+
9+
from enum import Enum, StrEnum
10+
from typing import Any, Dict, List, Sequence, Type, Union
11+
12+
from sqlalchemy import Connection
13+
14+
from alembic import op
15+
from sqlalchemy.dialects import postgresql
16+
from sqlalchemy.types import VARCHAR
17+
from qualicharge.models.dynamic import EtatPDCEnum, EtatPriseEnum, OccupationPDCEnum
18+
from sqlmodel import SQLModel
19+
20+
from qualicharge.schemas.core import PointDeCharge, Station, Status
21+
22+
# revision identifiers, used by Alembic.
23+
revision: str = "3e8ea07e5b66"
24+
down_revision: Union[str, None] = "8144a7d2553d"
25+
branch_labels: Union[str, Sequence[str], None] = None
26+
depends_on: Union[str, Sequence[str], None] = None
27+
28+
29+
class ImplantationStationEnum(StrEnum):
30+
"""Statique.implantation_station field enum."""
31+
32+
VOIRIE = "Voirie"
33+
PARKING_PUBLIC = "Parking public"
34+
PARKING_PRIVE_USAGE_PUBLIC = "Parking privé à usage public"
35+
PARKING_PRIVE_CLIENTELE = "Parking privé réservé à la clientèle"
36+
STATION_RECHARGE_RAPIDE = "Station dédiée à la recharge rapide"
37+
38+
39+
class ConditionAccesEnum(StrEnum):
40+
"""Statique.condition_acces field enum."""
41+
42+
ACCESS_LIBRE = "Accès libre"
43+
ACCESS_RESERVE = "Accès réservé"
44+
45+
46+
class AccessibilitePMREnum(StrEnum):
47+
"""Statique.accessibilite_pmr field enum."""
48+
49+
RESERVE_PMR = "Réservé PMR"
50+
NON_RESERVE = "Accessible mais non réservé PMR"
51+
NON_ACCESSIBLE = "Non accessible"
52+
INCONNUE = "Accessibilité inconnue"
53+
54+
55+
class RaccordementEnum(StrEnum):
56+
"""Statique.raccordement field enum."""
57+
58+
DIRECT = "Direct"
59+
INDIRECT = "Indirect"
60+
61+
62+
def enum_to_dict(enum_: Type[Enum], revert: bool = False) -> Dict[str, Any]:
63+
"""Convert enum to dict."""
64+
if revert:
65+
return {member.value: member.name for member in enum_}
66+
return {member.name: member.value for member in enum_}
67+
68+
69+
def migrate_db_enum(
70+
connection: Connection,
71+
enum_: Type[Enum],
72+
existing_enum_db_name: str,
73+
new_enum_db_name: str,
74+
schema: SQLModel,
75+
column_names: List[str],
76+
revert: bool = False,
77+
):
78+
"""Migrate database Enum from keys to values (if revert if False).
79+
80+
When revert is True, it migrates values to keys.
81+
"""
82+
print(f"Will migrate {enum_.__name__} for table {schema.__tablename__} ({revert=})")
83+
84+
# Create the new ENUM database type
85+
postgresql.ENUM(
86+
*enum_to_dict(enum_, revert).values(),
87+
name=new_enum_db_name,
88+
).create(connection, checkfirst=True)
89+
90+
for column_name in column_names:
91+
print(f"{column_name=}")
92+
93+
# Alter table column to a generic VARCHAR
94+
op.alter_column(
95+
schema.__tablename__,
96+
column_name,
97+
existing_type=postgresql.ENUM(
98+
*enum_to_dict(enum_, revert).keys(),
99+
name=existing_enum_db_name,
100+
),
101+
type_=VARCHAR,
102+
existing_nullable=False,
103+
postgresql_using=f"{column_name}::VARCHAR",
104+
)
105+
106+
107+
def upgrade() -> None:
108+
# Alembic connection to the database
109+
connection = op.get_bind()
110+
111+
fields_params = [
112+
{
113+
"enum_": ImplantationStationEnum,
114+
"existing_enum_db_name": "implantationstationenum",
115+
"new_enum_db_name": "implantation_station_enum",
116+
"schema": Station,
117+
"column_names": ["implantation_station"],
118+
},
119+
{
120+
"enum_": ConditionAccesEnum,
121+
"existing_enum_db_name": "conditionaccesenum",
122+
"new_enum_db_name": "condition_acces_enum",
123+
"schema": Station,
124+
"column_names": ["condition_acces"],
125+
},
126+
{
127+
"enum_": RaccordementEnum,
128+
"existing_enum_db_name": "raccordementenum",
129+
"new_enum_db_name": "raccordement_enum",
130+
"schema": Station,
131+
"column_names": ["raccordement"],
132+
},
133+
{
134+
"enum_": AccessibilitePMREnum,
135+
"existing_enum_db_name": "accessibilitepmrenum",
136+
"new_enum_db_name": "accessibilite_pmr_enum",
137+
"schema": PointDeCharge,
138+
"column_names": ["accessibilite_pmr"],
139+
},
140+
{
141+
"enum_": EtatPDCEnum,
142+
"existing_enum_db_name": "etatpdcenum",
143+
"new_enum_db_name": "etat_pdc_enum",
144+
"schema": Status,
145+
"column_names": ["etat_pdc"],
146+
},
147+
{
148+
"enum_": OccupationPDCEnum,
149+
"existing_enum_db_name": "occupationpdcenum",
150+
"new_enum_db_name": "occupation_pdc_enum",
151+
"schema": Status,
152+
"column_names": ["occupation_pdc"],
153+
},
154+
{
155+
"enum_": EtatPriseEnum,
156+
"existing_enum_db_name": "etatpriseenum",
157+
"new_enum_db_name": "etat_prise_enum",
158+
"schema": Status,
159+
"column_names": [
160+
"etat_prise_type_2",
161+
"etat_prise_type_combo_ccs",
162+
"etat_prise_type_chademo",
163+
"etat_prise_type_ef",
164+
],
165+
},
166+
]
167+
for field_params in fields_params:
168+
migrate_db_enum(connection, **field_params)
169+
170+
171+
def downgrade() -> None:
172+
# Alembic connection to the database
173+
connection = op.get_bind()
174+
175+
fields_params = [
176+
{
177+
"enum_": ImplantationStationEnum,
178+
"existing_enum_db_name": "implantation_station_enum",
179+
"new_enum_db_name": "implantationstationenum",
180+
"schema": Station,
181+
"column_names": ["implantation_station"],
182+
},
183+
{
184+
"enum_": ConditionAccesEnum,
185+
"existing_enum_db_name": "condition_acces_enum",
186+
"new_enum_db_name": "conditionaccesenum",
187+
"schema": Station,
188+
"column_names": ["condition_acces"],
189+
},
190+
{
191+
"enum_": RaccordementEnum,
192+
"existing_enum_db_name": "raccordement_enum",
193+
"new_enum_db_name": "raccordementenum",
194+
"schema": Station,
195+
"column_names": ["raccordement"],
196+
},
197+
{
198+
"enum_": AccessibilitePMREnum,
199+
"existing_enum_db_name": "accessibilite_pmr_enum",
200+
"new_enum_db_name": "accessibilitepmrenum",
201+
"schema": PointDeCharge,
202+
"column_names": ["accessibilite_pmr"],
203+
},
204+
{
205+
"enum_": EtatPDCEnum,
206+
"existing_enum_db_name": "etat_pdc_enum",
207+
"new_enum_db_name": "etatpdcenum",
208+
"schema": Status,
209+
"column_names": ["etat_pdc"],
210+
},
211+
{
212+
"enum_": OccupationPDCEnum,
213+
"existing_enum_db_name": "occupation_pdc_enum",
214+
"new_enum_db_name": "occupationpdcenum",
215+
"schema": Status,
216+
"column_names": ["occupation_pdc"],
217+
},
218+
{
219+
"enum_": EtatPriseEnum,
220+
"existing_enum_db_name": "etat_prise_enum",
221+
"new_enum_db_name": "etatpriseenum",
222+
"schema": Status,
223+
"column_names": [
224+
"etat_prise_type_2",
225+
"etat_prise_type_combo_ccs",
226+
"etat_prise_type_chademo",
227+
"etat_prise_type_ef",
228+
],
229+
},
230+
]
231+
for field_params in fields_params:
232+
migrate_db_enum(connection, **field_params, revert=True)

src/api/qualicharge/schemas/core.py

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from pydantic_extra_types.coordinate import Coordinate
2121
from shapely.geometry import mapping
2222
from sqlalchemy import event
23+
from sqlalchemy.dialects.postgresql import ENUM as PgEnum
2324
from sqlalchemy.schema import Column as SAColumn
2425
from sqlalchemy.types import Date, DateTime, String
2526
from sqlmodel import Field, Relationship, UniqueConstraint, select
@@ -28,7 +29,13 @@
2829

2930
from qualicharge.exceptions import ObjectDoesNotExist
3031

31-
from ..models.dynamic import SessionBase, StatusBase
32+
from ..models.dynamic import (
33+
EtatPDCEnum,
34+
EtatPriseEnum,
35+
OccupationPDCEnum,
36+
SessionBase,
37+
StatusBase,
38+
)
3239
from ..models.static import (
3340
AccessibilitePMREnum,
3441
ConditionAccesEnum,
@@ -51,6 +58,56 @@ class OperationalUnitTypeEnum(IntEnum):
5158
MOBILITY = 2
5259

5360

61+
# Enum definition for database: we want to store Enum values instead of keys (this is
62+
# the default behavior).
63+
def get_enum_values(enum_):
64+
"""Get enum values."""
65+
return [m.value for m in enum_]
66+
67+
68+
ImplantationStationDBEnum: PgEnum = PgEnum(
69+
ImplantationStationEnum,
70+
name="implantation_station_enum",
71+
values_callable=get_enum_values,
72+
)
73+
74+
ConditionAccesDBEnum: PgEnum = PgEnum(
75+
ConditionAccesEnum,
76+
name="condition_acces_enum",
77+
values_callable=get_enum_values,
78+
)
79+
80+
AccessibilitePMRDBEnum: PgEnum = PgEnum(
81+
AccessibilitePMREnum,
82+
name="accessibilite_pmr_enum",
83+
values_callable=get_enum_values,
84+
)
85+
86+
RaccordementDBEnum: PgEnum = PgEnum(
87+
RaccordementEnum,
88+
name="raccordement_enum",
89+
values_callable=get_enum_values,
90+
)
91+
92+
EtatPDCDBEnum: PgEnum = PgEnum(
93+
EtatPDCEnum,
94+
name="etat_pdc_enum",
95+
values_callable=get_enum_values,
96+
)
97+
98+
EtatPriseDBEnum: PgEnum = PgEnum(
99+
EtatPriseEnum,
100+
name="etat_prise_enum",
101+
values_callable=get_enum_values,
102+
)
103+
104+
OccupationPDCDBEnum: PgEnum = PgEnum(
105+
OccupationPDCEnum,
106+
name="occupation_pdc_enum",
107+
values_callable=get_enum_values,
108+
)
109+
110+
54111
class Amenageur(BaseTimestampedSQLModel, table=True):
55112
"""Amenageur table."""
56113

@@ -232,12 +289,18 @@ class Station(BaseTimestampedSQLModel, table=True):
232289
)
233290
id_station_local: Optional[str]
234291
nom_station: str
235-
implantation_station: ImplantationStationEnum
292+
implantation_station: ImplantationStationEnum = Field(
293+
sa_column=SAColumn(ImplantationStationDBEnum, nullable=False)
294+
)
236295
nbre_pdc: PositiveInt
237-
condition_acces: ConditionAccesEnum
296+
condition_acces: ConditionAccesEnum = Field(
297+
sa_column=SAColumn(ConditionAccesDBEnum, nullable=False)
298+
)
238299
horaires: str = Field(regex=r"(.*?)((\d{1,2}:\d{2})-(\d{1,2}:\d{2})|24/7)")
239300
station_deux_roues: bool
240-
raccordement: Optional[RaccordementEnum]
301+
raccordement: Optional[RaccordementEnum] = Field(
302+
sa_column=SAColumn(RaccordementDBEnum, nullable=True)
303+
)
241304
num_pdl: Optional[str] = Field(max_length=64)
242305
date_maj: NotFutureDate = Field(sa_type=Date)
243306
date_mise_en_service: Optional[PastDate] = Field(sa_type=Date)
@@ -325,7 +388,9 @@ class PointDeCharge(BaseTimestampedSQLModel, table=True):
325388
paiement_autre: Optional[bool]
326389
tarification: Optional[str]
327390
reservation: bool
328-
accessibilite_pmr: AccessibilitePMREnum
391+
accessibilite_pmr: AccessibilitePMREnum = Field(
392+
sa_column=SAColumn(AccessibilitePMRDBEnum, nullable=False)
393+
)
329394
restriction_gabarit: str
330395
observations: Optional[str]
331396
cable_t2_attache: Optional[bool]
@@ -379,6 +444,23 @@ class Status(BaseTimestampedSQLModel, StatusBase, table=True):
379444
description="The timestamp indicating when the status changed.",
380445
) # type: ignore
381446

447+
etat_pdc: EtatPDCEnum = Field(sa_column=SAColumn(EtatPDCDBEnum, nullable=False))
448+
occupation_pdc: OccupationPDCEnum = Field(
449+
sa_column=SAColumn(OccupationPDCDBEnum, nullable=False)
450+
)
451+
etat_prise_type_2: Optional[EtatPriseEnum] = Field(
452+
sa_column=SAColumn(EtatPriseDBEnum, nullable=True)
453+
)
454+
etat_prise_type_combo_ccs: Optional[EtatPriseEnum] = Field(
455+
sa_column=SAColumn(EtatPriseDBEnum, nullable=True)
456+
)
457+
etat_prise_type_chademo: Optional[EtatPriseEnum] = Field(
458+
sa_column=SAColumn(EtatPriseDBEnum, nullable=True)
459+
)
460+
etat_prise_type_ef: Optional[EtatPriseEnum] = Field(
461+
sa_column=SAColumn(EtatPriseDBEnum, nullable=True)
462+
)
463+
382464
# Relationships
383465
point_de_charge_id: Optional[UUID] = Field(
384466
default=None, foreign_key="pointdecharge.id"

0 commit comments

Comments
 (0)