Skip to content

Commit 506582b

Browse files
fix: rely on naive datetime for sqlalchemy (#1833)
1 parent 8b0bd0b commit 506582b

9 files changed

Lines changed: 216 additions & 8 deletions

File tree

.github/workflows/ci-build.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,63 @@ jobs:
114114
token: ${{ secrets.CODECOV_TOKEN }}
115115
verbose: true
116116

117+
databases:
118+
# TODO: Add MySQL and other database testing when possible
119+
name: Database Unit Tests
120+
runs-on: ubuntu-latest
121+
timeout-minutes: 5
122+
permissions:
123+
contents: read
124+
services:
125+
postgres:
126+
image: postgres@sha256:e4842c8a99ca99339e1693e6fe5fe62c7becb31991f066f989047dfb2fbf47af # 16
127+
env:
128+
POSTGRES_USER: test_user
129+
POSTGRES_PASSWORD: password
130+
POSTGRES_DB: test
131+
ports:
132+
- 5432:5432
133+
options: >-
134+
--health-cmd pg_isready
135+
--health-interval 10s
136+
--health-timeout 5s
137+
--health-retries 5
138+
steps:
139+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
140+
with:
141+
persist-credentials: false
142+
- name: Set up Python ${{ env.LATEST_SUPPORTED_PY }}
143+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
144+
with:
145+
python-version: ${{ env.LATEST_SUPPORTED_PY }}
146+
cache: pip
147+
- name: Install dependencies
148+
run: |
149+
pip install -U pip
150+
pip install -r requirements/testing.txt
151+
pip install -r requirements/optional.txt
152+
pip install -r requirements/databases.txt
153+
- name: Run sync tests (PostgreSQL)
154+
env:
155+
TEST_DATABASE_URL: postgresql://test_user:password@localhost/test
156+
run: |
157+
PYTHONPATH=$PWD:$PYTHONPATH pytest -s tests/slack_sdk/oauth/installation_store/test_sqlalchemy.py
158+
PYTHONPATH=$PWD:$PYTHONPATH pytest -s tests/slack_sdk/oauth/state_store/test_sqlalchemy.py
159+
- name: Run async tests (PostgreSQL)
160+
env:
161+
ASYNC_TEST_DATABASE_URL: postgresql+asyncpg://test_user:password@localhost/test
162+
run: |
163+
PYTHONPATH=$PWD:$PYTHONPATH pytest -s tests/slack_sdk/oauth/installation_store/test_async_sqlalchemy.py
164+
PYTHONPATH=$PWD:$PYTHONPATH pytest -s tests/slack_sdk/oauth/state_store/test_async_sqlalchemy.py
165+
117166
notifications:
118167
name: Regression notifications
119168
runs-on: ubuntu-latest
120169
needs:
121170
- lint
122171
- typecheck
123172
- unittest
173+
- databases
124174
if: ${{ !success() && github.ref == 'refs/heads/main' && github.event_name != 'workflow_dispatch' }}
125175
steps:
126176
- name: Send notifications of failing tests

requirements/databases.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Database drivers for CI testing
2+
3+
# PostgreSQL drivers
4+
psycopg2-binary>=2.9,<3
5+
asyncpg>=0.27,<1

slack_sdk/oauth/installation_store/sqlalchemy/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from slack_sdk.oauth.installation_store.async_installation_store import (
2424
AsyncInstallationStore,
2525
)
26+
from slack_sdk.oauth.sqlalchemy_utils import normalize_datetime_for_db
2627

2728

2829
class SQLAlchemyInstallationStore(InstallationStore):
@@ -140,6 +141,9 @@ def save(self, installation: Installation):
140141
with self.engine.begin() as conn:
141142
i = installation.to_dict()
142143
i["client_id"] = self.client_id
144+
i["installed_at"] = normalize_datetime_for_db(i.get("installed_at"))
145+
i["bot_token_expires_at"] = normalize_datetime_for_db(i.get("bot_token_expires_at"))
146+
i["user_token_expires_at"] = normalize_datetime_for_db(i.get("user_token_expires_at"))
143147

144148
i_column = self.installations.c
145149
installations_rows = conn.execute(
@@ -171,6 +175,8 @@ def save_bot(self, bot: Bot):
171175
# bots
172176
b = bot.to_dict()
173177
b["client_id"] = self.client_id
178+
b["installed_at"] = normalize_datetime_for_db(b.get("installed_at"))
179+
b["bot_token_expires_at"] = normalize_datetime_for_db(b.get("bot_token_expires_at"))
174180

175181
b_column = self.bots.c
176182
bots_rows = conn.execute(
@@ -419,6 +425,9 @@ async def async_save(self, installation: Installation):
419425
async with self.engine.begin() as conn:
420426
i = installation.to_dict()
421427
i["client_id"] = self.client_id
428+
i["installed_at"] = normalize_datetime_for_db(i.get("installed_at"))
429+
i["bot_token_expires_at"] = normalize_datetime_for_db(i.get("bot_token_expires_at"))
430+
i["user_token_expires_at"] = normalize_datetime_for_db(i.get("user_token_expires_at"))
422431

423432
i_column = self.installations.c
424433
installations_rows = await conn.execute(
@@ -450,6 +459,8 @@ async def async_save_bot(self, bot: Bot):
450459
# bots
451460
b = bot.to_dict()
452461
b["client_id"] = self.client_id
462+
b["installed_at"] = normalize_datetime_for_db(b.get("installed_at"))
463+
b["bot_token_expires_at"] = normalize_datetime_for_db(b.get("bot_token_expires_at"))
453464

454465
b_column = self.bots.c
455466
bots_rows = await conn.execute(
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from datetime import datetime
2+
from typing import Optional
3+
4+
5+
# TODO: Remove this function in next major release (v4.0.0) after updating all
6+
# DateTime columns to DateTime(timezone=True). See issue #1832 for context.
7+
def normalize_datetime_for_db(dt: Optional[datetime]) -> Optional[datetime]:
8+
"""
9+
Normalize timezone-aware datetime to naive UTC datetime for database storage.
10+
11+
Ensures compatibility with existing databases using TIMESTAMP WITHOUT TIME ZONE.
12+
SQLAlchemy DateTime columns without timezone=True create naive timestamp columns
13+
in databases like PostgreSQL. This function strips timezone information from
14+
timezone-aware datetimes (which are already in UTC) to enable safe comparisons.
15+
16+
Args:
17+
dt: A timezone-aware or naive datetime object, or None
18+
19+
Returns:
20+
A naive datetime in UTC, or None if input is None
21+
22+
Example:
23+
>>> from datetime import datetime, timezone
24+
>>> aware_dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
25+
>>> naive_dt = normalize_datetime_for_db(aware_dt)
26+
>>> naive_dt.tzinfo is None
27+
True
28+
"""
29+
if dt is None:
30+
return None
31+
if dt.tzinfo is not None:
32+
return dt.replace(tzinfo=None)
33+
return dt

slack_sdk/oauth/state_store/sqlalchemy/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy import Table, Column, Integer, String, DateTime, and_, MetaData
1111
from sqlalchemy.engine import Engine
1212
from sqlalchemy.ext.asyncio import AsyncEngine
13+
from slack_sdk.oauth.sqlalchemy_utils import normalize_datetime_for_db
1314

1415

1516
class SQLAlchemyOAuthStateStore(OAuthStateStore):
@@ -55,7 +56,7 @@ def logger(self) -> Logger:
5556

5657
def issue(self, *args, **kwargs) -> str:
5758
state: str = str(uuid4())
58-
now = datetime.fromtimestamp(time.time() + self.expiration_seconds, tz=timezone.utc)
59+
now = normalize_datetime_for_db(datetime.fromtimestamp(time.time() + self.expiration_seconds, tz=timezone.utc))
5960
with self.engine.begin() as conn:
6061
conn.execute(
6162
self.oauth_states.insert(),
@@ -65,9 +66,10 @@ def issue(self, *args, **kwargs) -> str:
6566

6667
def consume(self, state: str) -> bool:
6768
try:
69+
now = normalize_datetime_for_db(datetime.now(tz=timezone.utc))
6870
with self.engine.begin() as conn:
6971
c = self.oauth_states.c
70-
query = self.oauth_states.select().where(and_(c.state == state, c.expire_at > datetime.now(tz=timezone.utc)))
72+
query = self.oauth_states.select().where(and_(c.state == state, c.expire_at > now))
7173
result = conn.execute(query)
7274
for row in result.mappings():
7375
self.logger.debug(f"consume's query result: {row}")
@@ -124,7 +126,7 @@ def logger(self) -> Logger:
124126

125127
async def async_issue(self, *args, **kwargs) -> str:
126128
state: str = str(uuid4())
127-
now = datetime.fromtimestamp(time.time() + self.expiration_seconds, tz=timezone.utc)
129+
now = normalize_datetime_for_db(datetime.fromtimestamp(time.time() + self.expiration_seconds, tz=timezone.utc))
128130
async with self.engine.begin() as conn:
129131
await conn.execute(
130132
self.oauth_states.insert(),
@@ -134,9 +136,10 @@ async def async_issue(self, *args, **kwargs) -> str:
134136

135137
async def async_consume(self, state: str) -> bool:
136138
try:
139+
now = normalize_datetime_for_db(datetime.now(tz=timezone.utc))
137140
async with self.engine.begin() as conn:
138141
c = self.oauth_states.c
139-
query = self.oauth_states.select().where(and_(c.state == state, c.expire_at > datetime.now(tz=timezone.utc)))
142+
query = self.oauth_states.select().where(and_(c.state == state, c.expire_at > now))
140143
result = await conn.execute(query)
141144
for row in result.mappings():
142145
self.logger.debug(f"consume's query result: {row}")

tests/slack_sdk/oauth/installation_store/test_async_sqlalchemy.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1+
import os
12
import unittest
23
from tests.helpers import async_test
34
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
45

56
from slack_sdk.oauth.installation_store import Installation
67
from slack_sdk.oauth.installation_store.sqlalchemy import AsyncSQLAlchemyInstallationStore
78

9+
database_url = os.environ.get("ASYNC_TEST_DATABASE_URL", "sqlite+aiosqlite:///:memory:")
10+
11+
12+
def setUpModule():
13+
"""Emit database configuration for CI visibility across builds."""
14+
print(f"\n[InstallationStore/AsyncSQLAlchemy] Database: {database_url}")
15+
816

917
class TestAsyncSQLAlchemy(unittest.TestCase):
1018
engine: AsyncEngine
1119

1220
@async_test
1321
async def setUp(self):
14-
self.engine = create_async_engine("sqlite+aiosqlite:///:memory:")
22+
self.engine = create_async_engine(database_url)
1523
self.store = AsyncSQLAlchemyInstallationStore(client_id="111.222", engine=self.engine)
1624
async with self.engine.begin() as conn:
1725
await conn.run_sync(self.store.metadata.create_all)
@@ -296,3 +304,27 @@ async def test_issue_1441_mixing_user_and_bot_installations(self):
296304
self.assertIsNone(installation)
297305
installation = await store.async_find_installation(enterprise_id=None, team_id="T111")
298306
self.assertIsNone(installation)
307+
308+
@async_test
309+
async def test_timezone_aware_datetime_compatibility(self):
310+
installation = Installation(
311+
app_id="A111",
312+
enterprise_id="E111",
313+
team_id="T111",
314+
user_id="U111",
315+
bot_id="B111",
316+
bot_token="xoxb-111",
317+
bot_scopes=["chat:write"],
318+
bot_user_id="U222",
319+
)
320+
321+
# First save
322+
await self.store.async_save(installation)
323+
found = await self.store.async_find_installation(enterprise_id="E111", team_id="T111")
324+
self.assertIsNotNone(found)
325+
self.assertEqual(found.app_id, "A111")
326+
327+
# Second save (update) - tests WHERE clause with installed_at comparison
328+
await self.store.async_save(installation)
329+
found = await self.store.async_find_installation(enterprise_id="E111", team_id="T111")
330+
self.assertIsNotNone(found)

tests/slack_sdk/oauth/installation_store/test_sqlalchemy.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import unittest
23

34
import sqlalchemy
@@ -6,12 +7,19 @@
67
from slack_sdk.oauth.installation_store import Installation
78
from slack_sdk.oauth.installation_store.sqlalchemy import SQLAlchemyInstallationStore
89

10+
database_url = os.environ.get("TEST_DATABASE_URL", "sqlite:///:memory:")
11+
12+
13+
def setUpModule():
14+
"""Emit database configuration for CI visibility across builds."""
15+
print(f"\n[InstallationStore/SQLAlchemy] Database: {database_url}")
16+
917

1018
class TestSQLAlchemy(unittest.TestCase):
1119
engine: Engine
1220

1321
def setUp(self):
14-
self.engine = sqlalchemy.create_engine("sqlite:///:memory:")
22+
self.engine = sqlalchemy.create_engine(database_url)
1523
self.store = SQLAlchemyInstallationStore(client_id="111.222", engine=self.engine)
1624
self.store.metadata.create_all(self.engine)
1725

@@ -289,3 +297,26 @@ def test_issue_1441_mixing_user_and_bot_installations(self):
289297
self.assertIsNone(installation)
290298
installation = store.find_installation(enterprise_id=None, team_id="T111")
291299
self.assertIsNone(installation)
300+
301+
def test_timezone_aware_datetime_compatibility(self):
302+
installation = Installation(
303+
app_id="A111",
304+
enterprise_id="E111",
305+
team_id="T111",
306+
user_id="U111",
307+
bot_id="B111",
308+
bot_token="xoxb-111",
309+
bot_scopes=["chat:write"],
310+
bot_user_id="U222",
311+
)
312+
313+
# First save
314+
self.store.save(installation)
315+
found = self.store.find_installation(enterprise_id="E111", team_id="T111")
316+
self.assertIsNotNone(found)
317+
self.assertEqual(found.app_id, "A111")
318+
319+
# Second save (update) - tests WHERE clause with installed_at comparison
320+
self.store.save(installation)
321+
found = self.store.find_installation(enterprise_id="E111", team_id="T111")
322+
self.assertIsNotNone(found)

tests/slack_sdk/oauth/state_store/test_async_sqlalchemy.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
import asyncio
2+
import os
23
import unittest
34
from tests.helpers import async_test
45
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
56

67
from slack_sdk.oauth.state_store.sqlalchemy import AsyncSQLAlchemyOAuthStateStore
78

9+
database_url = os.environ.get("ASYNC_TEST_DATABASE_URL", "sqlite+aiosqlite:///:memory:")
10+
11+
12+
def setUpModule():
13+
"""Emit database configuration for CI visibility across builds."""
14+
print(f"\n[StateStore/AsyncSQLAlchemy] Database: {database_url}")
15+
816

917
class TestSQLAlchemy(unittest.TestCase):
1018
engine: AsyncEngine
1119

1220
@async_test
1321
async def setUp(self):
14-
self.engine = create_async_engine("sqlite+aiosqlite:///:memory:")
22+
self.engine = create_async_engine(database_url)
1523
self.store = AsyncSQLAlchemyOAuthStateStore(engine=self.engine, expiration_seconds=2)
1624
async with self.engine.begin() as conn:
1725
await conn.run_sync(self.store.metadata.create_all)
@@ -36,3 +44,17 @@ async def test_expiration(self):
3644
await asyncio.sleep(3)
3745
result = await self.store.async_consume(state)
3846
self.assertFalse(result)
47+
48+
@async_test
49+
async def test_timezone_aware_datetime_compatibility(self):
50+
# Issue a state (tests INSERT with timezone-aware datetime)
51+
state = await self.store.async_issue()
52+
self.assertIsNotNone(state)
53+
54+
# Consume it immediately (tests WHERE clause comparison with timezone-aware datetime)
55+
result = await self.store.async_consume(state)
56+
self.assertTrue(result)
57+
58+
# Second consume should fail (state already consumed)
59+
result = await self.store.async_consume(state)
60+
self.assertFalse(result)

tests/slack_sdk/oauth/state_store/test_sqlalchemy.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import time
23
import unittest
34

@@ -6,12 +7,19 @@
67

78
from slack_sdk.oauth.state_store.sqlalchemy import SQLAlchemyOAuthStateStore
89

10+
database_url = os.environ.get("TEST_DATABASE_URL", "sqlite:///:memory:")
11+
12+
13+
def setUpModule():
14+
"""Emit database configuration for CI visibility across builds."""
15+
print(f"\n[StateStore/SQLAlchemy] Database: {database_url}")
16+
917

1018
class TestSQLAlchemy(unittest.TestCase):
1119
engine: Engine
1220

1321
def setUp(self):
14-
self.engine = sqlalchemy.create_engine("sqlite:///:memory:")
22+
self.engine = sqlalchemy.create_engine(database_url)
1523
self.store = SQLAlchemyOAuthStateStore(engine=self.engine, expiration_seconds=2)
1624
self.store.metadata.create_all(self.engine)
1725

@@ -31,3 +39,16 @@ def test_expiration(self):
3139
time.sleep(3)
3240
result = self.store.consume(state)
3341
self.assertFalse(result)
42+
43+
def test_timezone_aware_datetime_compatibility(self):
44+
# Issue a state (tests INSERT with timezone-aware datetime)
45+
state = self.store.issue()
46+
self.assertIsNotNone(state)
47+
48+
# Consume it immediately (tests WHERE clause comparison with timezone-aware datetime)
49+
result = self.store.consume(state)
50+
self.assertTrue(result)
51+
52+
# Second consume should fail (state already consumed)
53+
result = self.store.consume(state)
54+
self.assertFalse(result)

0 commit comments

Comments
 (0)