Skip to content

Commit

Permalink
Database: Add patches and polyfills from application adapters
Browse files Browse the repository at this point in the history
Sources: MLflow, LangChain, Singer/Meltano, rdflib-sqlalchemy
  • Loading branch information
amotl committed Jun 18, 2024
1 parent 4d922f7 commit 9514aec
Show file tree
Hide file tree
Showing 5 changed files with 212 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/sqlalchemy_cratedb/support/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from sqlalchemy_cratedb.support.pandas import insert_bulk
from sqlalchemy_cratedb.support.polyfill import check_uniqueness_factory
from sqlalchemy_cratedb.support.util import refresh_table

__all__ = [
check_uniqueness_factory,
insert_bulk,
refresh_table,
]
129 changes: 129 additions & 0 deletions src/sqlalchemy_cratedb/support/polyfill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import sqlalchemy as sa
from sqlalchemy.event import listen
import typing as t

from sqlalchemy_cratedb.support.util import do_flush


def polyfill_autoincrement_timestamp():
"""
Configure SQLAlchemy model columns with an alternative to `autoincrement=True`.
Use the current timestamp instead.
This is used by CrateDB's MLflow adapter.
TODO: Maybe enable through a dialect parameter `crate_polyfill_autoincrement` or such.
"""
import sqlalchemy.sql.schema as schema
from sqlalchemy import func

Check warning on line 18 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L17-L18

Added lines #L17 - L18 were not covered by tests

init_dist = schema.Column.__init__

Check warning on line 20 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L20

Added line #L20 was not covered by tests

def __init__(self, *args, **kwargs):
if "autoincrement" in kwargs:
del kwargs["autoincrement"]
if "default" not in kwargs:
kwargs["default"] = func.now()
init_dist(self, *args, **kwargs)

Check warning on line 27 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L22-L27

Added lines #L22 - L27 were not covered by tests

schema.Column.__init__ = __init__ # type: ignore[method-assign]

Check warning on line 29 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L29

Added line #L29 was not covered by tests


def check_uniqueness_factory(sa_entity, *attribute_names):
"""
Run a manual column value uniqueness check on a table, and raise an IntegrityError if applicable.
CrateDB does not support the UNIQUE constraint on columns. This attempts to emulate it.
https://github.com/crate/sqlalchemy-cratedb/issues/76
This is used by CrateDB's MLflow adapter.
TODO: Maybe enable through a dialect parameter `crate_polyfill_unique` or such.
"""

# Synthesize a canonical "name" for the constraint,
# composed of all column names involved.
constraint_name: str = "-".join(attribute_names)

def check_uniqueness(mapper, connection, target):
from sqlalchemy.exc import IntegrityError

if isinstance(target, sa_entity):
# TODO: How to use `session.query(SqlExperiment)` here?
stmt = mapper.selectable.select()
for attribute_name in attribute_names:
stmt = stmt.filter(getattr(sa_entity, attribute_name) == getattr(target, attribute_name))
stmt = stmt.compile(bind=connection.engine)
results = connection.execute(stmt)
if results.rowcount > 0:
raise IntegrityError(
statement=stmt,
params=[],
orig=Exception(
f"DuplicateKeyException in table '{target.__tablename__}' " f"on constraint '{constraint_name}'"
),
)

return check_uniqueness


def polyfill_refresh_after_dml_session(session: sa.orm.Session):
"""
Run `REFRESH TABLE` after each DML operation (INSERT, UPDATE, DELETE).
CrateDB is eventually consistent, i.e. write operations are not flushed to
disk immediately, so readers may see stale data. In a traditional OLTP-like
application, this is not applicable.
This SQLAlchemy extension makes sure that data is synchronized after each
operation manipulating data.
> `after_{insert,update,delete}` events only apply to the session flush operation
> and do not apply to the ORM DML operations described at ORM-Enabled INSERT,
> UPDATE, and DELETE statements. To intercept ORM DML events, use
> `SessionEvents.do_orm_execute().`
> -- https://docs.sqlalchemy.org/en/20/orm/events.html#sqlalchemy.orm.MapperEvents.after_insert
> Intercept statement executions that occur on behalf of an ORM Session object.
> -- https://docs.sqlalchemy.org/en/20/orm/events.html#sqlalchemy.orm.SessionEvents.do_orm_execute
> Execute after flush has completed, but before commit has been called.
> -- https://docs.sqlalchemy.org/en/20/orm/events.html#sqlalchemy.orm.SessionEvents.after_flush
This is used by CrateDB's LangChain adapter.
TODO: Maybe enable through a dialect parameter `crate_dml_refresh` or such.
""" # noqa: E501
listen(session, "after_flush", do_flush)

Check warning on line 98 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L98

Added line #L98 was not covered by tests


def polyfill_refresh_after_dml_engine(engine: sa.engine.Engine):
"""
Run `REFRESH TABLE` after each DML operation (INSERT, UPDATE, DELETE).
This is used by CrateDB's Singer/Meltano and `rdflib-sqlalchemy` adapters.
"""
def receive_after_execute(

Check warning on line 107 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L107

Added line #L107 was not covered by tests
conn: sa.engine.Connection, clauseelement, multiparams, params, execution_options, result
):
if isinstance(clauseelement, (sa.sql.Insert, sa.sql.Update, sa.sql.Delete)):
if not isinstance(clauseelement.table, sa.sql.Join):
full_table_name = f'"{clauseelement.table.name}"'
if clauseelement.table.schema is not None:
full_table_name = f'"{clauseelement.table.schema}".' + full_table_name
conn.execute(sa.text(f'REFRESH TABLE {full_table_name};'))

Check warning on line 115 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L110-L115

Added lines #L110 - L115 were not covered by tests

sa.event.listen(engine, "after_execute", receive_after_execute)

Check warning on line 117 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L117

Added line #L117 was not covered by tests


def polyfill_refresh_after_dml(engine_or_session: t.Union[sa.engine.Engine, sa.orm.Session]):
"""
Run `REFRESH TABLE` after each DML operation (INSERT, UPDATE, DELETE).
"""
if isinstance(engine_or_session, sa.engine.Engine):
return polyfill_refresh_after_dml_engine(engine_or_session)

Check warning

Code scanning / CodeQL

Use of the return value of a procedure Warning

The result of
polyfill_refresh_after_dml_engine
is used even though it is always None.
elif isinstance(engine_or_session, (sa.orm.Session, sa.orm.scoping.scoped_session)):
return polyfill_refresh_after_dml_session(engine_or_session)

Check warning

Code scanning / CodeQL

Use of the return value of a procedure Warning

The result of
polyfill_refresh_after_dml_session
is used even though it is always None.

Check warning on line 127 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L124-L127

Added lines #L124 - L127 were not covered by tests
else:
raise TypeError(f"Unknown type: {type(engine_or_session)}")

Check warning on line 129 in src/sqlalchemy_cratedb/support/polyfill.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/polyfill.py#L129

Added line #L129 was not covered by tests
29 changes: 29 additions & 0 deletions src/sqlalchemy_cratedb/support/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import itertools
import typing as t

import sqlalchemy as sa
from sqlalchemy.orm import DeclarativeBase


def refresh_table(connection, target: t.Union[str, DeclarativeBase]):
"""
Invoke a `REFRESH TABLE` statement.
"""
if isinstance(target, DeclarativeBase):
sql = f"REFRESH TABLE {target.__tablename__}"

Check warning on line 13 in src/sqlalchemy_cratedb/support/util.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/util.py#L12-L13

Added lines #L12 - L13 were not covered by tests
else:
sql = f"REFRESH TABLE {target}"
connection.execute(sa.text(sql))

Check warning on line 16 in src/sqlalchemy_cratedb/support/util.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/util.py#L15-L16

Added lines #L15 - L16 were not covered by tests


def do_flush(session, flush_context):
"""
Invoke a `REFRESH TABLE` statement on each table entity flagged as "dirty".
SQLAlchemy event handler for the 'after_flush' event,
invoking `REFRESH TABLE` on each table which has been modified.
"""
dirty_entities = itertools.chain(session.new, session.dirty, session.deleted)
dirty_classes = {entity.__class__ for entity in dirty_entities}
for class_ in dirty_classes:
refresh_table(session, class_)

Check warning on line 29 in src/sqlalchemy_cratedb/support/util.py

View check run for this annotation

Codecov / codecov/patch

src/sqlalchemy_cratedb/support/util.py#L26-L29

Added lines #L26 - L29 were not covered by tests
1 change: 1 addition & 0 deletions tests/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def drop_tables():
"DROP TABLE IF EXISTS archived_tasks",
"DROP TABLE IF EXISTS characters",
"DROP TABLE IF EXISTS cities",
"DROP TABLE IF EXISTS foobar",
"DROP TABLE IF EXISTS locations",
"DROP BLOB TABLE IF EXISTS myfiles",
"DROP TABLE IF EXISTS search",
Expand Down
49 changes: 49 additions & 0 deletions tests/test_support_polyfill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import pytest
import sqlalchemy as sa
from sqlalchemy.event import listen
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker

try:
from sqlalchemy.orm import declarative_base
except ImportError:
from sqlalchemy.ext.declarative import declarative_base

from sqlalchemy_cratedb.support import check_uniqueness_factory


def test_check_uniqueness_factory(cratedb_service):
"""
An integration test validating basic synthetic UNIQUE constraints.
https://github.com/crate/sqlalchemy-cratedb/issues/76
"""

engine = cratedb_service.database.engine
session = sessionmaker(bind=engine)()
Base = declarative_base()

# Define DDL.
class FooBar(Base):
__tablename__ = 'foobar'
id = sa.Column(sa.String, primary_key=True)
name = sa.Column(sa.String)

# Add synthetic UNIQUE constraint on `name` column.
listen(FooBar, "before_insert", check_uniqueness_factory(FooBar, "name"))

Base.metadata.drop_all(engine, checkfirst=True)
Base.metadata.create_all(engine, checkfirst=True)

# Insert baseline record.
foo_item = FooBar(id="foo", name="foo")
session.add(foo_item)
session.commit()
session.execute(sa.text("REFRESH TABLE foobar"))

# Insert second record, violating the uniqueness constraint.
bar_item = FooBar(id="bar", name="foo")
session.add(bar_item)
with pytest.raises(IntegrityError) as ex:
session.commit()
assert ex.match("DuplicateKeyException in table 'foobar' on constraint 'name'")

0 comments on commit 9514aec

Please sign in to comment.