├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml ├── pyproject.toml ├── sqlalchemy_timescaledb ├── __init__.py ├── dialect.py └── functions.py └── tests ├── __init__.py ├── alembic.ini ├── async ├── __init__.py ├── conftest.py └── test_hypertable.py ├── conftest.py ├── factories.py ├── migrations ├── env.py ├── script.py.mako └── versions │ └── .empty ├── models.py ├── test_alembic.py ├── test_ddl_compiler.py ├── test_functions.py └── test_hypertable.py /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | 4 | jobs: 5 | container-job: 6 | runs-on: ubuntu-latest 7 | 8 | services: 9 | database: 10 | image: timescale/timescaledb:latest-pg14 11 | env: 12 | POSTGRES_DB: database 13 | POSTGRES_HOST: database 14 | POSTGRES_PORT: 5432 15 | POSTGRES_USER: user 16 | POSTGRES_PASSWORD: password 17 | ports: 18 | - 5432:5432 19 | options: >- 20 | --health-cmd pg_isready 21 | --health-interval 10s 22 | --health-timeout 5s 23 | --health-retries 5 24 | 25 | steps: 26 | - name: Check out repository code 27 | uses: actions/checkout@v3 28 | 29 | - name: Set up Python 3 30 | uses: actions/setup-python@v3 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install ".[test]" 36 | 37 | - name: Build coverage file 38 | working-directory: ./tests 39 | run: | 40 | pytest --junitxml=pytest.xml --cov-report=term-missing:skip-covered --cov=sqlalchemy_timescaledb 41 | 42 | - name: Upload coverage reports to Codecov 43 | uses: codecov/codecov-action@v3 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | share/python-wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # Unit test / coverage reports 27 | htmlcov/ 28 | .tox/ 29 | .nox/ 30 | .coverage 31 | .coverage.* 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | *.cover 36 | *.py,cover 37 | .hypothesis/ 38 | .pytest_cache/ 39 | cover/ 40 | 41 | # Sphinx documentation 42 | docs/_build/ 43 | 44 | # PyCharm 45 | .idea/ 46 | 47 | # Alembic 48 | tests/migrations/versions/* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 The Python Packaging Authority 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SQLAlchemy TimescaleDB 2 | 3 | [![PyPI version](https://badge.fury.io/py/sqlalchemy-timescaledb.svg)][1] 4 | [![Tests](https://github.com/dorosch/sqlalchemy-timescaledb/actions/workflows/tests.yml/badge.svg)][2] 5 | [![codecov](https://codecov.io/gh/dorosch/sqlalchemy-timescaledb/branch/develop/graph/badge.svg?token=Gzh7KpADjZ)][3] 6 | [![Downloads](https://pepy.tech/badge/sqlalchemy-timescaledb)][4] 7 | 8 | This is the TimescaleDB dialect driver for SQLAlchemy. Drivers `psycopg2` and `asyncpg` are supported. 9 | 10 | ## Install 11 | 12 | ```bash 13 | $ pip install sqlalchemy-timescaledb 14 | ``` 15 | 16 | ## Usage 17 | 18 | Adding to table `timescaledb_hypertable` option allows you to configure the [hypertable parameters][5]: 19 | 20 | ```Python 21 | import datetime 22 | from sqlalchemy import create_engine, MetaData 23 | from sqlalchemy import Table, Column, Integer, String, DateTime 24 | 25 | engine = create_engine('timescaledb://user:password@host:port/database') 26 | metadata = MetaData() 27 | metadata.bind = engine 28 | 29 | Metric = Table( 30 | 'metric', metadata, 31 | Column('name', String), 32 | Column('value', Integer), 33 | Column('timestamp', DateTime(), default=datetime.datetime.now), 34 | timescaledb_hypertable={ 35 | 'time_column_name': 'timestamp' 36 | } 37 | ) 38 | 39 | metadata.create_all(engine) 40 | ``` 41 | 42 | Or using `declarative_base` style: 43 | 44 | ```Python 45 | import datetime 46 | 47 | from sqlalchemy.orm import declarative_base 48 | from sqlalchemy import Column, Float, String, DateTime 49 | 50 | Base = declarative_base() 51 | 52 | class Metric(Base): 53 | __table_args__ = ({ 54 | 'timescaledb_hypertable': { 55 | 'time_column_name': 'timestamp' 56 | } 57 | }) 58 | 59 | name = Column(String) 60 | value = Column(Float) 61 | timestamp = Column( 62 | DateTime(), default=datetime.datetime.now, primary_key=True 63 | ) 64 | ``` 65 | 66 | ## Parameters 67 | 68 | * [chunk_time_interval][6] 69 | 70 | ## Functions 71 | 72 | Timescaledb functions implemented: 73 | 74 | ### [first(value, time)][7] 75 | 76 | ```Python 77 | func.first(Metric.value, Metric.timestamp) 78 | ``` 79 | 80 | ### [last(value, time)][8] 81 | 82 | ```Python 83 | func.last(Metric.value, Metric.timestamp) 84 | ``` 85 | 86 | 87 | [1]: https://badge.fury.io/py/sqlalchemy-timescaledb 88 | [2]: https://github.com/dorosch/sqlalchemy-timescaledb/actions/workflows/tests.yml 89 | [3]: https://codecov.io/gh/dorosch/sqlalchemy-timescaledb 90 | [4]: https://pepy.tech/project/sqlalchemy-timescaledb 91 | [5]: https://docs.timescale.com/api/latest/hypertable/create_hypertable/#optional-arguments 92 | [6]: https://docs.timescale.com/api/latest/hypertable/set_chunk_time_interval/ 93 | [7]: https://docs.timescale.com/api/latest/hyperfunctions/first/ 94 | [8]: https://docs.timescale.com/api/latest/hyperfunctions/last/ 95 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | 4 | services: 5 | database: 6 | image: timescale/timescaledb:latest-pg14 7 | hostname: $${POSTGRES_HOST} 8 | container_name: postgresql 9 | environment: 10 | - POSTGRES_DB=database 11 | - POSTGRES_HOST=database 12 | - POSTGRES_PORT=5432 13 | - POSTGRES_USER=user 14 | - POSTGRES_PASSWORD=password 15 | ports: 16 | - 5432:5432 17 | volumes: 18 | - database:/var/lib/postgresql/data 19 | 20 | 21 | volumes: 22 | database: 23 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "sqlalchemy-timescaledb" 3 | version = "0.4" 4 | authors = [ 5 | { name="Andrei Kliatsko", email="andrey.daraschenka@gmail.com" }, 6 | ] 7 | description = "A SQLAlchemy dialect for TimescaleDB" 8 | readme = "README.md" 9 | dependencies = [ 10 | "sqlalchemy>=1.3" 11 | ] 12 | 13 | [project.optional-dependencies] 14 | test = [ 15 | "pytest==7.2.1", 16 | "pytest-cov==4.0.0", 17 | "pytest-factoryboy==2.5.1", 18 | "sqlalchemy[asyncio]>=1.3", 19 | "psycopg2-binary==2.9.5", 20 | "alembic==1.9.4", 21 | "asyncpg==0.27.0", 22 | "pytest-asyncio==0.20.3" 23 | ] 24 | 25 | [project.urls] 26 | "Homepage" = "https://github.com/dorosch/sqlalchemy-timescaledb" 27 | 28 | [project.entry-points."sqlalchemy.dialects"] 29 | "timescaledb" = "sqlalchemy_timescaledb.dialect:TimescaledbPsycopg2Dialect" 30 | "timescaledb.psycopg2" = "sqlalchemy_timescaledb.dialect:TimescaledbPsycopg2Dialect" 31 | "timescaledb.asyncpg" = "sqlalchemy_timescaledb.dialect:TimescaledbAsyncpgDialect" 32 | 33 | [build-system] 34 | requires = ["setuptools", "wheel"] 35 | build-backend = "setuptools.build_meta" 36 | -------------------------------------------------------------------------------- /sqlalchemy_timescaledb/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.dialects import registry 2 | 3 | 4 | registry.register( 5 | 'timescaledb', 6 | 'sqlalchemy_timescaledb.dialect', 7 | 'TimescaledbPsycopg2Dialect' 8 | ) 9 | registry.register( 10 | 'timescaledb.psycopg2', 11 | 'sqlalchemy_timescaledb.dialect', 12 | 'TimescaledbPsycopg2Dialect' 13 | ) 14 | registry.register( 15 | 'timescaledb.asyncpg', 16 | 'sqlalchemy_timescaledb.dialect', 17 | 'TimescaledbAsyncpgDialect' 18 | ) 19 | -------------------------------------------------------------------------------- /sqlalchemy_timescaledb/dialect.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import schema, event, DDL 2 | from sqlalchemy.dialects.postgresql.asyncpg import PGDialect_asyncpg 3 | from sqlalchemy.dialects.postgresql.base import PGDDLCompiler 4 | from sqlalchemy.dialects.postgresql.psycopg2 import PGDialect_psycopg2 5 | 6 | try: 7 | import alembic 8 | except ImportError: 9 | pass 10 | else: 11 | from alembic.ddl import postgresql 12 | 13 | class TimescaledbImpl(postgresql.PostgresqlImpl): 14 | __dialect__ = 'timescaledb' 15 | 16 | 17 | class TimescaledbDDLCompiler(PGDDLCompiler): 18 | def post_create_table(self, table): 19 | hypertable = table.kwargs.get('timescaledb_hypertable', {}) 20 | 21 | if hypertable: 22 | event.listen( 23 | table, 24 | 'after_create', 25 | self.ddl_hypertable( 26 | table.name, hypertable 27 | ).execute_if( 28 | dialect='timescaledb' 29 | ) 30 | ) 31 | 32 | return super().post_create_table(table) 33 | 34 | @staticmethod 35 | def ddl_hypertable(table_name, hypertable): 36 | time_column_name = hypertable['time_column_name'] 37 | chunk_time_interval = hypertable.get('chunk_time_interval', '7 days') 38 | 39 | if isinstance(chunk_time_interval, str): 40 | if chunk_time_interval.isdigit(): 41 | chunk_time_interval = int(chunk_time_interval) 42 | else: 43 | chunk_time_interval = f"INTERVAL '{chunk_time_interval}'" 44 | 45 | return DDL( 46 | f""" 47 | SELECT create_hypertable( 48 | '{table_name}', 49 | '{time_column_name}', 50 | chunk_time_interval => {chunk_time_interval}, 51 | if_not_exists => TRUE 52 | ); 53 | """ 54 | ) 55 | 56 | 57 | class TimescaledbDialect: 58 | name = 'timescaledb' 59 | ddl_compiler = TimescaledbDDLCompiler 60 | construct_arguments = [ 61 | ( 62 | schema.Table, { 63 | "hypertable": {} 64 | } 65 | ) 66 | ] 67 | 68 | 69 | class TimescaledbPsycopg2Dialect(TimescaledbDialect, PGDialect_psycopg2): 70 | driver = 'psycopg2' 71 | supports_statement_cache = True 72 | 73 | 74 | class TimescaledbAsyncpgDialect(TimescaledbDialect, PGDialect_asyncpg): 75 | driver = 'asyncpg' 76 | supports_statement_cache = True 77 | -------------------------------------------------------------------------------- /sqlalchemy_timescaledb/functions.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql.functions import GenericFunction 2 | 3 | 4 | class First(GenericFunction): 5 | identifier = 'first' 6 | inherit_cache = True 7 | 8 | 9 | class Last(GenericFunction): 10 | identifier = 'last' 11 | inherit_cache = True 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dorosch/sqlalchemy-timescaledb/f60bac03790643471a2eea97537c54e78be2770e/tests/__init__.py -------------------------------------------------------------------------------- /tests/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = ./migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to migrations/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # the output encoding used when revision files 55 | # are written from script.py.mako 56 | # output_encoding = utf-8 57 | 58 | sqlalchemy.url = driver://user:pass@localhost/dbname 59 | 60 | 61 | [post_write_hooks] 62 | # post_write_hooks defines scripts or Python functions that are run 63 | # on newly generated revision scripts. See the documentation for further 64 | # detail and examples 65 | 66 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 67 | # hooks = black 68 | # black.type = console_scripts 69 | # black.entrypoint = black 70 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 71 | 72 | # Logging configuration 73 | [loggers] 74 | keys = root,sqlalchemy,alembic 75 | 76 | [handlers] 77 | keys = console 78 | 79 | [formatters] 80 | keys = generic 81 | 82 | [logger_root] 83 | level = WARN 84 | handlers = console 85 | qualname = 86 | 87 | [logger_sqlalchemy] 88 | level = WARN 89 | handlers = 90 | qualname = sqlalchemy.engine 91 | 92 | [logger_alembic] 93 | level = INFO 94 | handlers = 95 | qualname = alembic 96 | 97 | [handler_console] 98 | class = StreamHandler 99 | args = (sys.stderr,) 100 | level = NOTSET 101 | formatter = generic 102 | 103 | [formatter_generic] 104 | format = %(levelname)-5.5s [%(name)s] %(message)s 105 | datefmt = %H:%M:%S 106 | -------------------------------------------------------------------------------- /tests/async/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dorosch/sqlalchemy-timescaledb/f60bac03790643471a2eea97537c54e78be2770e/tests/async/__init__.py -------------------------------------------------------------------------------- /tests/async/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest_asyncio 2 | from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession 3 | 4 | from tests.models import Base, DATABASE_URL 5 | 6 | 7 | @pytest_asyncio.fixture 8 | def async_engine(): 9 | yield create_async_engine( 10 | DATABASE_URL.set(drivername='timescaledb+asyncpg') 11 | ) 12 | 13 | 14 | @pytest_asyncio.fixture 15 | async def async_session(async_engine): 16 | async with AsyncSession(async_engine) as session: 17 | yield session 18 | 19 | 20 | @pytest_asyncio.fixture(autouse=True) 21 | async def setup(async_engine): 22 | async with async_engine.begin() as connection: 23 | await connection.run_sync(Base.metadata.create_all) 24 | yield 25 | async with async_engine.begin() as connection: 26 | await connection.run_sync(Base.metadata.drop_all) 27 | -------------------------------------------------------------------------------- /tests/async/test_hypertable.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import text 3 | 4 | from tests.models import Metric, User 5 | 6 | 7 | @pytest.mark.asyncio 8 | class TestHypertable: 9 | async def test_is_hypertable(self, async_session): 10 | assert (await async_session.execute( 11 | text( 12 | f""" 13 | SELECT count(*) 14 | FROM _timescaledb_catalog.hypertable 15 | WHERE table_name = '{Metric.__tablename__}' 16 | """ 17 | ) 18 | )).scalar_one() 19 | 20 | async def test_is_not_hypertable(self, async_session): 21 | assert not (await async_session.execute( 22 | text( 23 | f""" 24 | SELECT count(*) 25 | FROM _timescaledb_catalog.hypertable 26 | WHERE table_name = '{User.__tablename__}' 27 | """ 28 | ) 29 | )).scalar_one() 30 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest_factoryboy import register 3 | from sqlalchemy import text, create_engine 4 | from sqlalchemy.orm import Session 5 | 6 | from tests.factories import MetricFactory, FactorySession 7 | from tests.models import Base, DATABASE_URL 8 | 9 | register(MetricFactory) 10 | 11 | 12 | @pytest.fixture 13 | def engine(): 14 | yield create_engine(DATABASE_URL) 15 | 16 | 17 | @pytest.fixture 18 | def session(engine): 19 | with Session(engine) as session: 20 | yield session 21 | 22 | 23 | @pytest.fixture(autouse=True) 24 | def setup(engine): 25 | FactorySession.configure(bind=engine) 26 | Base.metadata.create_all(bind=engine) 27 | yield 28 | Base.metadata.drop_all(bind=engine) 29 | 30 | 31 | @pytest.fixture 32 | def is_hypertable(session): 33 | def check_hypertable(table): 34 | return session.execute( 35 | text( 36 | f""" 37 | SELECT count(*) 38 | FROM _timescaledb_catalog.hypertable 39 | WHERE table_name = '{table.__tablename__}' 40 | """ 41 | ) 42 | ).scalar_one() == 1 43 | 44 | return check_hypertable 45 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from factory.fuzzy import FuzzyText 3 | from sqlalchemy import orm 4 | 5 | from tests.models import Metric 6 | 7 | FactorySession = orm.scoped_session(orm.sessionmaker()) 8 | 9 | 10 | class MetricFactory(factory.alchemy.SQLAlchemyModelFactory): 11 | class Meta: 12 | model = Metric 13 | sqlalchemy_session = FactorySession 14 | sqlalchemy_session_persistence = 'commit' 15 | 16 | name = FuzzyText() 17 | value = 0 18 | -------------------------------------------------------------------------------- /tests/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config 5 | from sqlalchemy import pool 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | if config.config_file_name is not None: 14 | fileConfig(config.config_file_name) 15 | 16 | from models import Base, DATABASE_URL 17 | 18 | config.set_section_option( 19 | config.config_ini_section, 20 | "sqlalchemy.url", 21 | DATABASE_URL.render_as_string(hide_password=False) 22 | ) 23 | 24 | target_metadata = Base.metadata 25 | 26 | 27 | def run_migrations_offline() -> None: 28 | """Run migrations in 'offline' mode. 29 | 30 | This configures the context with just a URL 31 | and not an Engine, though an Engine is acceptable 32 | here as well. By skipping the Engine creation 33 | we don't even need a DBAPI to be available. 34 | 35 | Calls to context.execute() here emit the given string to the 36 | script output. 37 | 38 | """ 39 | url = config.get_main_option("sqlalchemy.url") 40 | context.configure( 41 | url=url, 42 | target_metadata=target_metadata, 43 | literal_binds=True, 44 | dialect_opts={"paramstyle": "named"}, 45 | ) 46 | 47 | with context.begin_transaction(): 48 | context.run_migrations() 49 | 50 | 51 | def run_migrations_online() -> None: 52 | """Run migrations in 'online' mode. 53 | 54 | In this scenario we need to create an Engine 55 | and associate a connection with the context. 56 | 57 | """ 58 | connectable = engine_from_config( 59 | config.get_section(config.config_ini_section, {}), 60 | prefix="sqlalchemy.", 61 | poolclass=pool.NullPool, 62 | ) 63 | 64 | with connectable.connect() as connection: 65 | context.configure( 66 | connection=connection, target_metadata=target_metadata 67 | ) 68 | 69 | with context.begin_transaction(): 70 | context.run_migrations() 71 | 72 | 73 | if context.is_offline_mode(): 74 | run_migrations_offline() 75 | else: 76 | run_migrations_online() 77 | -------------------------------------------------------------------------------- /tests/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /tests/migrations/versions/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dorosch/sqlalchemy-timescaledb/f60bac03790643471a2eea97537c54e78be2770e/tests/migrations/versions/.empty -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | from sqlalchemy import Column, String, DateTime, Float, Integer 5 | from sqlalchemy.engine import URL 6 | from sqlalchemy.orm import declarative_base 7 | 8 | DATABASE_URL = URL.create( 9 | host=os.environ.get('POSTGRES_HOST', '0.0.0.0'), 10 | port=os.environ.get('POSTGRES_PORT', 5432), 11 | username=os.environ.get('POSTGRES_USER', 'user'), 12 | password=os.environ.get('POSTGRES_PASSWORD', 'password'), 13 | database=os.environ.get('POSTGRES_DB', 'database'), 14 | drivername=os.environ.get('DRIVERNAME', 'timescaledb') 15 | ) 16 | 17 | Base = declarative_base() 18 | 19 | 20 | class Metric(Base): 21 | __tablename__ = 'metrics' 22 | __table_args__ = ( 23 | { 24 | 'timescaledb_hypertable': { 25 | 'time_column_name': 'timestamp' 26 | } 27 | } 28 | ) 29 | 30 | id = Column(Integer, primary_key=True , autoincrement=True) 31 | name = Column(String) 32 | value = Column(Float) 33 | timestamp = Column( 34 | DateTime(), default=datetime.datetime.now, primary_key=True 35 | ) 36 | 37 | 38 | class User(Base): 39 | __tablename__ = 'users' 40 | 41 | id = Column(Integer, primary_key=True, autoincrement=True) 42 | name = Column(String) 43 | -------------------------------------------------------------------------------- /tests/test_alembic.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from alembic import command 5 | from alembic.config import Config 6 | 7 | from tests.models import Base 8 | 9 | 10 | class TestAlembic: 11 | def setup_class(self): 12 | # TODO: Disable output for alembic 13 | self.config = Config( 14 | os.path.join(os.path.dirname(__file__), 'alembic.ini') 15 | ) 16 | self.migration_versions_path = os.path.join( 17 | os.path.dirname(__file__), 'migrations', 'versions' 18 | ) 19 | 20 | def test_create_revision(self, engine): 21 | Base.metadata.drop_all(bind=engine) 22 | script = command.revision( 23 | self.config, message='initial', autogenerate=True 24 | ) 25 | migration_file = os.path.join( 26 | self.migration_versions_path, f'{script.revision}_initial.py' 27 | ) 28 | 29 | assert script.down_revision is None 30 | assert Path(migration_file).is_file() 31 | 32 | Path(migration_file).unlink() 33 | Base.metadata.create_all(bind=engine) 34 | -------------------------------------------------------------------------------- /tests/test_ddl_compiler.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy import DDL 3 | 4 | from sqlalchemy_timescaledb.dialect import TimescaledbDDLCompiler 5 | 6 | 7 | class TestTimescaledbDDLCompiler: 8 | def test_default_params(self): 9 | assert TimescaledbDDLCompiler.ddl_hypertable( 10 | 'test', {'time_column_name': 'timestamp'} 11 | ).compile().string == DDL( 12 | f""" 13 | SELECT create_hypertable( 14 | 'test', 15 | 'timestamp', 16 | chunk_time_interval => INTERVAL '7 days', 17 | if_not_exists => TRUE 18 | ); 19 | """ 20 | ).compile().string 21 | 22 | @pytest.mark.parametrize('interval,expected', [ 23 | ('1 days', "INTERVAL '1 days'"), 24 | ('7 hour', "INTERVAL '7 hour'"), 25 | (86400, 86400), 26 | ('86400', 86400) 27 | ]) 28 | def test_chunk_time_interval(self, interval, expected): 29 | assert TimescaledbDDLCompiler.ddl_hypertable( 30 | 'test', { 31 | 'time_column_name': 'timestamp', 32 | 'chunk_time_interval': interval 33 | } 34 | ).compile().string == DDL( 35 | f""" 36 | SELECT create_hypertable( 37 | 'test', 38 | 'timestamp', 39 | chunk_time_interval => {expected}, 40 | if_not_exists => TRUE 41 | ); 42 | """ 43 | ).compile().string 44 | -------------------------------------------------------------------------------- /tests/test_functions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from sqlalchemy import select 4 | 5 | from sqlalchemy_timescaledb.functions import First, Last 6 | from tests.models import Metric 7 | 8 | 9 | class TestFirstFunction: 10 | def setup_class(self): 11 | self.today = datetime.now() 12 | self.three_days_ago = self.today - timedelta(days=3) 13 | self.six_days_ago = self.today - timedelta(days=6) 14 | self.query = select( 15 | First(Metric.value, Metric.timestamp) 16 | ).select_from(Metric) 17 | 18 | def test_first_record_without_data(self, session): 19 | assert session.execute(self.query).scalar_one_or_none() is None 20 | 21 | def test_first_record_with_one_record(self, session, metric_factory): 22 | metric_factory(timestamp=self.today, value=1) 23 | 24 | assert session.execute(self.query).scalar_one_or_none() 25 | 26 | def test_first_record_with_multiple_records(self, session, metric_factory): 27 | metric_factory(timestamp=self.today) 28 | metric_factory(timestamp=self.three_days_ago) 29 | metric_factory(timestamp=self.six_days_ago, value=1) 30 | 31 | assert session.execute(self.query).scalar_one() 32 | 33 | def test_first_record_with_group_by(self, session, metric_factory): 34 | metric_factory(name='test', timestamp=self.today) 35 | metric_factory(name='test', timestamp=self.three_days_ago) 36 | metric_factory(name='test', timestamp=self.six_days_ago, value=1) 37 | 38 | assert session.execute(self.query.group_by(Metric.name)).scalar_one() 39 | 40 | 41 | class TestLastFunction: 42 | def setup_class(self): 43 | self.today = datetime.now() 44 | self.three_days_ago = self.today - timedelta(days=3) 45 | self.six_days_ago = self.today - timedelta(days=6) 46 | self.query = select( 47 | Last(Metric.value, Metric.timestamp) 48 | ).select_from(Metric) 49 | 50 | def test_last_record_without_data(self, session): 51 | assert session.execute(self.query).scalar_one_or_none() is None 52 | 53 | def test_last_record_with_one_record(self, session, metric_factory): 54 | metric_factory(timestamp=self.today, value=1) 55 | 56 | assert session.execute(self.query).scalar_one_or_none() 57 | 58 | def test_last_record_with_multiple_records(self, session, metric_factory): 59 | metric_factory(timestamp=self.today, value=1) 60 | metric_factory(timestamp=self.three_days_ago) 61 | metric_factory(timestamp=self.six_days_ago) 62 | 63 | assert session.execute(self.query).scalar_one() 64 | 65 | def test_last_record_with_group_by(self, session, metric_factory): 66 | metric_factory(name='test', timestamp=self.today, value=1) 67 | metric_factory(name='test', timestamp=self.three_days_ago) 68 | metric_factory(name='test', timestamp=self.six_days_ago) 69 | 70 | assert session.execute(self.query.group_by(Metric.name)).scalar_one() 71 | -------------------------------------------------------------------------------- /tests/test_hypertable.py: -------------------------------------------------------------------------------- 1 | from tests.models import Metric, User 2 | 3 | 4 | class TestHypertable: 5 | def test_is_hypertable(self, is_hypertable): 6 | assert is_hypertable(Metric) 7 | 8 | def test_is_not_hypertable(self, is_hypertable): 9 | assert not is_hypertable(User) 10 | --------------------------------------------------------------------------------