Skip to content

Commit

Permalink
Merge pull request #47 from freol35241/sqlalchemy2-support
Browse files Browse the repository at this point in the history
WIP: Updating codebase for breaking changes introduced by bumping the sqlalchemy version to
  • Loading branch information
freol35241 authored Feb 28, 2023
2 parents 78c4a6a + 56355ef commit 3bc3803
Show file tree
Hide file tree
Showing 7 changed files with 88 additions and 84 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Long time state storage (LTSS) custom component for Home Assistant
========================================

**NOTE:** From version 2.0 LTSS requires at least Home Assistant 2023.3

**NOTE:** Starting 2020-09-13 attributes are stored with type JSONB instead of as a plain string, in addition a GIN index is created on this column by default. At first startup after updating of LTSS, migration of your DB happens automatically. Note that this can take a couple of minutes and HASS will not finish starting (i.e. frontend will not be available) until migration is done.

**WARNING:** I take no responsibility for any data loss that may happen as a result of this. Please make sure to backup your data before upgrading!
Expand Down
126 changes: 63 additions & 63 deletions custom_components/ltss/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
EVENT_STATE_CHANGED,
STATE_UNKNOWN
STATE_UNKNOWN,
)
from homeassistant.components import persistent_notification
from homeassistant.core import CoreState, HomeAssistant, callback
Expand Down Expand Up @@ -56,7 +56,9 @@
DOMAIN: INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend(
{
vol.Required(CONF_DB_URL): cv.string,
vol.Optional(CONF_CHUNK_TIME_INTERVAL, default=2592000000000): cv.positive_int, # 30 days
vol.Optional(
CONF_CHUNK_TIME_INTERVAL, default=2592000000000
): cv.positive_int, # 30 days
}
)
},
Expand Down Expand Up @@ -84,37 +86,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return await instance.async_db_ready


@contextmanager
def session_scope(*, session=None):
"""Provide a transactional scope around a series of operations."""

if session is None:
raise RuntimeError("Session required")

need_rollback = False
try:
yield session
if session.transaction:
need_rollback = True
session.commit()
except Exception as err: # pylint: disable=broad-except
_LOGGER.error("Error executing query: %s", err)
if need_rollback:
session.rollback()
raise
finally:
session.close()


class LTSS_DB(threading.Thread):
"""A threaded LTSS class."""

def __init__(
self,
hass: HomeAssistant,
uri: str,
chunk_time_interval: int,
entity_filter: Callable[[str], bool],
self,
hass: HomeAssistant,
uri: str,
chunk_time_interval: int,
entity_filter: Callable[[str], bool],
) -> None:
"""Initialize the ltss."""
threading.Thread.__init__(self, name="LTSS")
Expand Down Expand Up @@ -158,6 +138,7 @@ def run(self):
tries += 1

if not connected:

@callback
def connection_failed():
"""Connect failed tasks."""
Expand Down Expand Up @@ -222,17 +203,18 @@ def notify_hass_started(event):
if tries != 1:
time.sleep(CONNECT_RETRY_WAIT)
try:
with session_scope(session=self.get_session()) as session:
try:
row = LTSS.from_event(event)
session.add(row)
except (TypeError, ValueError):
_LOGGER.warning(
"State is not JSON serializable: %s",
event.data.get("new_state"),
)

updated = True
with self.get_session() as session:
with session.begin():
try:
row = LTSS.from_event(event)
session.add(row)
except (TypeError, ValueError):
_LOGGER.warning(
"State is not JSON serializable: %s",
event.data.get("new_state"),
)

updated = True

except exc.OperationalError as err:
_LOGGER.error(
Expand Down Expand Up @@ -273,36 +255,49 @@ def _setup_connection(self):
if self.engine is not None:
self.engine.dispose()

self.engine = create_engine(self.db_url, echo=False,
json_serializer=lambda obj: json.dumps(obj, cls=JSONEncoder))
self.engine = create_engine(
self.db_url,
echo=False,
json_serializer=lambda obj: json.dumps(obj, cls=JSONEncoder),
)

inspector = inspect(self.engine)

with self.engine.connect() as con:
available_extensions = {row['name']: row['installed_version'] for row in
con.execute(text("SELECT name, installed_version FROM pg_available_extensions"))}
con = con.execution_options(isolation_level="AUTOCOMMIT")
available_extensions = {
row.name: row.installed_version
for row in con.execute(
text("SELECT name, installed_version FROM pg_available_extensions")
)
}

# create table if necessary
if not inspector.has_table(LTSS.__tablename__):
self._create_table(available_extensions)

if 'timescaledb' in available_extensions:
if "timescaledb" in available_extensions:
# chunk_time_interval can be adjusted even after first setup
try:
con.execute(
text(f"SELECT set_chunk_time_interval('{LTSS.__tablename__}', {self.chunk_time_interval})")
.execution_options(autocommit=True)
text(
f"SELECT set_chunk_time_interval('{LTSS.__tablename__}', {self.chunk_time_interval})"
)
)
except exc.ProgrammingError as exception:
if isinstance(exception.orig, psycopg2.errors.UndefinedTable):
# The table does exist but is not a hypertable, not much we can do except log that fact
_LOGGER.exception(
"TimescaleDB is available as an extension but the LTSS table is not a hypertable!")
"TimescaleDB is available as an extension but the LTSS table is not a hypertable!"
)
else:
raise

# check if table has been set up with location extraction
if "location" in [column_conf["name"] for column_conf in inspector.get_columns(LTSS.__tablename__)]:
if "location" in [
column_conf["name"]
for column_conf in inspector.get_columns(LTSS.__tablename__)
]:
# activate location extraction in model/ORM
LTSS.activate_location_extraction()

Expand All @@ -314,28 +309,33 @@ def _setup_connection(self):
def _create_table(self, available_extensions):
_LOGGER.info("Creating LTSS table")
with self.engine.connect() as con:
if 'postgis' in available_extensions:
_LOGGER.info("PostGIS extension is available, activating location extraction...")
con.execute(
text("CREATE EXTENSION IF NOT EXISTS postgis CASCADE"
).execution_options(autocommit=True))
con = con.execution_options(isolation_level="AUTOCOMMIT")
if "postgis" in available_extensions:
_LOGGER.info(
"PostGIS extension is available, activating location extraction..."
)
con.execute(text("CREATE EXTENSION IF NOT EXISTS postgis CASCADE"))

# activate location extraction in model/ORM to add necessary column when calling create_all()
LTSS.activate_location_extraction()

Base.metadata.create_all(self.engine)

if 'timescaledb' in available_extensions:
_LOGGER.info("TimescaleDB extension is available, creating hypertable...")
con.execute(
text("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"
).execution_options(autocommit=True))
if "timescaledb" in available_extensions:
_LOGGER.info(
"TimescaleDB extension is available, creating hypertable..."
)
con.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"))

# Create hypertable
con.execute(text(f"""SELECT create_hypertable(
'{LTSS.__tablename__}',
'time',
if_not_exists => TRUE);""").execution_options(autocommit=True))
con.execute(
text(
f"""SELECT create_hypertable(
'{LTSS.__tablename__}',
'time',
if_not_exists => TRUE);"""
)
)

def _close_connection(self):
"""Close the connection."""
Expand Down
28 changes: 14 additions & 14 deletions custom_components/ltss/manifest.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"domain": "ltss",
"version": "1.1.0",
"name": "Long Time State Storage (LTSS)",
"documentation": "https://github.com/freol35241/ltss",
"requirements": [
"sqlalchemy>=1.0,<2.0",
"psycopg2>=2.8,<3.0",
"geoalchemy2>=0.8,<0.9"
],
"dependencies": [],
"codeowners": [
"@freol35241"
]
}
"domain": "ltss",
"version": "2.0.0",
"name": "Long Time State Storage (LTSS)",
"documentation": "https://github.com/freol35241/ltss",
"requirements": [
"sqlalchemy>=2.0,<3.0",
"psycopg2>=2.8,<3.0",
"geoalchemy2>=0.8,<1.0"
],
"dependencies": [],
"codeowners": [
"@freol35241"
]
}
3 changes: 1 addition & 2 deletions custom_components/ltss/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@
from sqlalchemy.schema import Index
from sqlalchemy.dialects.postgresql import JSONB
from geoalchemy2 import Geometry
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import column_property
from sqlalchemy.orm import column_property, declarative_base

# SQLAlchemy Schema
# pylint: disable=invalid-name
Expand Down
2 changes: 2 additions & 0 deletions info.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
**NOTE:** From version 2.0 LTSS requires at least Home Assistant 2023.3

**NOTE:** Starting 2020-09-13 attributes are stored with type JSONB instead of as a plain string, in addition a GIN index is created on this column by default. At first startup after updating of LTSS, migration of your DB happens automatically. Note that this can take a couple of minutes and HASS will not finish starting (i.e. frontend will not be available) until migration is done.

**WARNING:** I take no responsibility for any data loss that may happen as a result of this. Please make sure to backup your data before upgrading!
Expand Down
5 changes: 3 additions & 2 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
docker==5.0.3
GeoAlchemy2==0.11.1
homeassistant==2021.9.7
psycopg2==2.9.3
SQLAlchemy==2.0.4
homeassistant==2021.9.7

pytest-docker==0.11.0
pytest==7.1.1
SQLAlchemy==1.4.32
6 changes: 3 additions & 3 deletions tests/test_databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ def test_default_db(self):

@staticmethod
def _is_hypertable(con):
timescaledb_version = con.execute("SELECT installed_version "
timescaledb_version = con.execute(text("SELECT installed_version "
"FROM pg_available_extensions "
"WHERE name = 'timescaledb'").scalar()
"WHERE name = 'timescaledb'")).scalar()

# timescaledb's table/column name changed with v2
if int(timescaledb_version.split('.')[0]) >= 2:
Expand All @@ -100,7 +100,7 @@ def _is_hypertable(con):
query = f"SELECT 1 FROM timescaledb_information.hypertable "
f"WHERE table_name = '{LTSS.__tablename__}'"

return 1 == con.execute(query).rowcount
return 1 == con.execute(text(query)).rowcount

@staticmethod
def _has_columns(con):
Expand Down

0 comments on commit 3bc3803

Please sign in to comment.