Skip to content

Commit c77b863

Browse files
authored
feat: #1276 add Asyncio SQLAlchemy support (#1633)
1 parent fcd0a35 commit c77b863

File tree

5 files changed

+700
-43
lines changed

5 files changed

+700
-43
lines changed

requirements/testing.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ boto3<=2
1717
# For AWS tests
1818
moto>=4.0.13,<6
1919
mypy<=1.14.1
20+
# For AsyncSQLAlchemy tests
21+
greenlet<=4
22+
aiosqlite<=1

slack_sdk/oauth/installation_store/sqlalchemy/__init__.py

Lines changed: 290 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@
1616
)
1717
from sqlalchemy.engine import Engine
1818
from sqlalchemy.sql.sqltypes import Boolean
19-
19+
from sqlalchemy.ext.asyncio import AsyncEngine
2020
from slack_sdk.oauth.installation_store.installation_store import InstallationStore
2121
from slack_sdk.oauth.installation_store.models.bot import Bot
2222
from slack_sdk.oauth.installation_store.models.installation import Installation
23+
from slack_sdk.oauth.installation_store.async_installation_store import (
24+
AsyncInstallationStore,
25+
)
2326

2427

2528
class SQLAlchemyInstallationStore(InstallationStore):
@@ -219,21 +222,7 @@ def find_bot(
219222
with self.engine.connect() as conn:
220223
result: object = conn.execute(query)
221224
for row in result.mappings(): # type: ignore[attr-defined]
222-
return Bot(
223-
app_id=row["app_id"],
224-
enterprise_id=row["enterprise_id"],
225-
enterprise_name=row["enterprise_name"],
226-
team_id=row["team_id"],
227-
team_name=row["team_name"],
228-
bot_token=row["bot_token"],
229-
bot_id=row["bot_id"],
230-
bot_user_id=row["bot_user_id"],
231-
bot_scopes=row["bot_scopes"],
232-
bot_refresh_token=row["bot_refresh_token"],
233-
bot_token_expires_at=row["bot_token_expires_at"],
234-
is_enterprise_install=row["is_enterprise_install"],
235-
installed_at=row["installed_at"],
236-
)
225+
return self.build_bot_entity(row)
237226
return None
238227

239228
def find_installation(
@@ -267,33 +256,7 @@ def find_installation(
267256
with self.engine.connect() as conn:
268257
result: object = conn.execute(query)
269258
for row in result.mappings(): # type: ignore[attr-defined]
270-
installation = Installation(
271-
app_id=row["app_id"],
272-
enterprise_id=row["enterprise_id"],
273-
enterprise_name=row["enterprise_name"],
274-
enterprise_url=row["enterprise_url"],
275-
team_id=row["team_id"],
276-
team_name=row["team_name"],
277-
bot_token=row["bot_token"],
278-
bot_id=row["bot_id"],
279-
bot_user_id=row["bot_user_id"],
280-
bot_scopes=row["bot_scopes"],
281-
bot_refresh_token=row["bot_refresh_token"],
282-
bot_token_expires_at=row["bot_token_expires_at"],
283-
user_id=row["user_id"],
284-
user_token=row["user_token"],
285-
user_scopes=row["user_scopes"],
286-
user_refresh_token=row["user_refresh_token"],
287-
user_token_expires_at=row["user_token_expires_at"],
288-
# Only the incoming webhook issued in the latest installation is set in this logic
289-
incoming_webhook_url=row["incoming_webhook_url"],
290-
incoming_webhook_channel=row["incoming_webhook_channel"],
291-
incoming_webhook_channel_id=row["incoming_webhook_channel_id"],
292-
incoming_webhook_configuration_url=row["incoming_webhook_configuration_url"],
293-
is_enterprise_install=row["is_enterprise_install"],
294-
token_type=row["token_type"],
295-
installed_at=row["installed_at"],
296-
)
259+
installation = self.build_installation_entity(row)
297260

298261
has_user_installation = user_id is not None and installation is not None
299262
no_bot_token_installation = installation is not None and installation.bot_token is None
@@ -362,3 +325,287 @@ def delete_installation(
362325
)
363326
)
364327
conn.execute(deletion)
328+
329+
@classmethod
330+
def build_installation_entity(cls, row) -> Installation:
331+
return Installation(
332+
app_id=row["app_id"],
333+
enterprise_id=row["enterprise_id"],
334+
enterprise_name=row["enterprise_name"],
335+
enterprise_url=row["enterprise_url"],
336+
team_id=row["team_id"],
337+
team_name=row["team_name"],
338+
bot_token=row["bot_token"],
339+
bot_id=row["bot_id"],
340+
bot_user_id=row["bot_user_id"],
341+
bot_scopes=row["bot_scopes"],
342+
bot_refresh_token=row["bot_refresh_token"],
343+
bot_token_expires_at=row["bot_token_expires_at"],
344+
user_id=row["user_id"],
345+
user_token=row["user_token"],
346+
user_scopes=row["user_scopes"],
347+
user_refresh_token=row["user_refresh_token"],
348+
user_token_expires_at=row["user_token_expires_at"],
349+
# Only the incoming webhook issued in the latest installation is set in this logic
350+
incoming_webhook_url=row["incoming_webhook_url"],
351+
incoming_webhook_channel=row["incoming_webhook_channel"],
352+
incoming_webhook_channel_id=row["incoming_webhook_channel_id"],
353+
incoming_webhook_configuration_url=row["incoming_webhook_configuration_url"],
354+
is_enterprise_install=row["is_enterprise_install"],
355+
token_type=row["token_type"],
356+
installed_at=row["installed_at"],
357+
)
358+
359+
@classmethod
360+
def build_bot_entity(cls, row) -> Bot:
361+
return Bot(
362+
app_id=row["app_id"],
363+
enterprise_id=row["enterprise_id"],
364+
enterprise_name=row["enterprise_name"],
365+
team_id=row["team_id"],
366+
team_name=row["team_name"],
367+
bot_token=row["bot_token"],
368+
bot_id=row["bot_id"],
369+
bot_user_id=row["bot_user_id"],
370+
bot_scopes=row["bot_scopes"],
371+
bot_refresh_token=row["bot_refresh_token"],
372+
bot_token_expires_at=row["bot_token_expires_at"],
373+
is_enterprise_install=row["is_enterprise_install"],
374+
installed_at=row["installed_at"],
375+
)
376+
377+
378+
class AsyncSQLAlchemyInstallationStore(AsyncInstallationStore):
379+
default_bots_table_name: str = "slack_bots"
380+
default_installations_table_name: str = "slack_installations"
381+
382+
client_id: str
383+
engine: AsyncEngine
384+
metadata: MetaData
385+
installations: Table
386+
387+
def __init__(
388+
self,
389+
client_id: str,
390+
engine: AsyncEngine,
391+
bots_table_name: str = default_bots_table_name,
392+
installations_table_name: str = default_installations_table_name,
393+
logger: Logger = logging.getLogger(__name__),
394+
):
395+
self.metadata = sqlalchemy.MetaData()
396+
self.bots = self.build_bots_table(metadata=self.metadata, table_name=bots_table_name)
397+
self.installations = self.build_installations_table(metadata=self.metadata, table_name=installations_table_name)
398+
self.client_id = client_id
399+
self._logger = logger
400+
self.engine = engine
401+
402+
@classmethod
403+
def build_installations_table(cls, metadata: MetaData, table_name: str) -> Table:
404+
return SQLAlchemyInstallationStore.build_installations_table(metadata, table_name)
405+
406+
@classmethod
407+
def build_bots_table(cls, metadata: MetaData, table_name: str) -> Table:
408+
return SQLAlchemyInstallationStore.build_bots_table(metadata, table_name)
409+
410+
async def create_tables(self):
411+
async with self.engine.begin() as conn:
412+
await conn.run_sync(self.metadata.create_all)
413+
414+
@property
415+
def logger(self) -> Logger:
416+
return self._logger
417+
418+
async def async_save(self, installation: Installation):
419+
async with self.engine.begin() as conn:
420+
i = installation.to_dict()
421+
i["client_id"] = self.client_id
422+
423+
i_column = self.installations.c
424+
installations_rows = await conn.execute(
425+
sqlalchemy.select(i_column.id)
426+
.where(
427+
and_(
428+
i_column.client_id == self.client_id,
429+
i_column.enterprise_id == installation.enterprise_id,
430+
i_column.team_id == installation.team_id,
431+
i_column.installed_at == i.get("installed_at"),
432+
)
433+
)
434+
.limit(1)
435+
)
436+
installations_row_id: Optional[str] = None
437+
for row in installations_rows.mappings():
438+
installations_row_id = row["id"]
439+
if installations_row_id is None:
440+
await conn.execute(self.installations.insert(), i)
441+
else:
442+
update_statement = self.installations.update().where(i_column.id == installations_row_id).values(**i)
443+
await conn.execute(update_statement, i)
444+
445+
# bots
446+
await self.async_save_bot(installation.to_bot())
447+
448+
async def async_save_bot(self, bot: Bot):
449+
async with self.engine.begin() as conn:
450+
# bots
451+
b = bot.to_dict()
452+
b["client_id"] = self.client_id
453+
454+
b_column = self.bots.c
455+
bots_rows = await conn.execute(
456+
sqlalchemy.select(b_column.id)
457+
.where(
458+
and_(
459+
b_column.client_id == self.client_id,
460+
b_column.enterprise_id == bot.enterprise_id,
461+
b_column.team_id == bot.team_id,
462+
b_column.installed_at == b.get("installed_at"),
463+
)
464+
)
465+
.limit(1)
466+
)
467+
bots_row_id: Optional[str] = None
468+
for row in bots_rows.mappings():
469+
bots_row_id = row["id"]
470+
if bots_row_id is None:
471+
await conn.execute(self.bots.insert(), b)
472+
else:
473+
update_statement = self.bots.update().where(b_column.id == bots_row_id).values(**b)
474+
await conn.execute(update_statement, b)
475+
476+
async def async_find_bot(
477+
self,
478+
*,
479+
enterprise_id: Optional[str],
480+
team_id: Optional[str],
481+
is_enterprise_install: Optional[bool] = False,
482+
) -> Optional[Bot]:
483+
if is_enterprise_install or team_id is None:
484+
team_id = None
485+
486+
c = self.bots.c
487+
query = (
488+
self.bots.select()
489+
.where(
490+
and_(
491+
c.client_id == self.client_id,
492+
c.enterprise_id == enterprise_id,
493+
c.team_id == team_id,
494+
c.bot_token.is_not(None), # the latest one that has a bot token
495+
)
496+
)
497+
.order_by(desc(c.installed_at))
498+
.limit(1)
499+
)
500+
501+
async with self.engine.connect() as conn:
502+
result: object = await conn.execute(query)
503+
for row in result.mappings(): # type: ignore[attr-defined]
504+
return SQLAlchemyInstallationStore.build_bot_entity(row)
505+
return None
506+
507+
async def async_find_installation(
508+
self,
509+
*,
510+
enterprise_id: Optional[str],
511+
team_id: Optional[str],
512+
user_id: Optional[str] = None,
513+
is_enterprise_install: Optional[bool] = False,
514+
) -> Optional[Installation]:
515+
if is_enterprise_install or team_id is None:
516+
team_id = None
517+
518+
c = self.installations.c
519+
where_clause = and_(
520+
c.client_id == self.client_id,
521+
c.enterprise_id == enterprise_id,
522+
c.team_id == team_id,
523+
)
524+
if user_id is not None:
525+
where_clause = and_(
526+
c.client_id == self.client_id,
527+
c.enterprise_id == enterprise_id,
528+
c.team_id == team_id,
529+
c.user_id == user_id,
530+
)
531+
532+
query = self.installations.select().where(where_clause).order_by(desc(c.installed_at)).limit(1)
533+
534+
installation: Optional[Installation] = None
535+
async with self.engine.connect() as conn:
536+
result: object = await conn.execute(query)
537+
for row in result.mappings(): # type: ignore[attr-defined]
538+
installation = SQLAlchemyInstallationStore.build_installation_entity(row)
539+
540+
has_user_installation = user_id is not None and installation is not None
541+
no_bot_token_installation = installation is not None and installation.bot_token is None
542+
should_find_bot_installation = has_user_installation or no_bot_token_installation
543+
if should_find_bot_installation:
544+
# Retrieve the latest bot token, just in case
545+
# See also: https://github.com/slackapi/bolt-python/issues/664
546+
latest_bot_installation = await self.async_find_bot(
547+
enterprise_id=enterprise_id,
548+
team_id=team_id,
549+
is_enterprise_install=is_enterprise_install,
550+
)
551+
if (
552+
latest_bot_installation is not None
553+
and installation is not None
554+
and installation.bot_token != latest_bot_installation.bot_token
555+
):
556+
installation.bot_id = latest_bot_installation.bot_id
557+
installation.bot_user_id = latest_bot_installation.bot_user_id
558+
installation.bot_token = latest_bot_installation.bot_token
559+
installation.bot_scopes = latest_bot_installation.bot_scopes
560+
installation.bot_refresh_token = latest_bot_installation.bot_refresh_token
561+
installation.bot_token_expires_at = latest_bot_installation.bot_token_expires_at
562+
563+
return installation
564+
565+
async def async_delete_bot(
566+
self,
567+
*,
568+
enterprise_id: Optional[str],
569+
team_id: Optional[str],
570+
) -> None:
571+
table = self.bots
572+
c = table.c
573+
async with self.engine.begin() as conn:
574+
deletion = table.delete().where(
575+
and_(
576+
c.client_id == self.client_id,
577+
c.enterprise_id == enterprise_id,
578+
c.team_id == team_id,
579+
)
580+
)
581+
await conn.execute(deletion)
582+
583+
async def async_delete_installation(
584+
self,
585+
*,
586+
enterprise_id: Optional[str],
587+
team_id: Optional[str],
588+
user_id: Optional[str] = None,
589+
) -> None:
590+
table = self.installations
591+
c = table.c
592+
async with self.engine.begin() as conn:
593+
if user_id is not None:
594+
deletion = table.delete().where(
595+
and_(
596+
c.client_id == self.client_id,
597+
c.enterprise_id == enterprise_id,
598+
c.team_id == team_id,
599+
c.user_id == user_id,
600+
)
601+
)
602+
await conn.execute(deletion)
603+
else:
604+
deletion = table.delete().where(
605+
and_(
606+
c.client_id == self.client_id,
607+
c.enterprise_id == enterprise_id,
608+
c.team_id == team_id,
609+
)
610+
)
611+
await conn.execute(deletion)

0 commit comments

Comments
 (0)