[timescaledb] Initial contribution (#20412)

* [timescaledb] Add native TimescaleDB persistence service

Signed-off-by: René Ulbricht <rene_ulbricht@outlook.com>
This commit is contained in:
ulbi
2026-03-27 10:18:02 +01:00
committed by GitHub
parent 4cee46aea4
commit 0309a622b5
31 changed files with 7505 additions and 0 deletions
+1
View File
@@ -472,6 +472,7 @@
/bundles/org.openhab.persistence.mapdb/ @openhab/add-ons-maintainers
/bundles/org.openhab.persistence.mongodb/ @openhab/add-ons-maintainers
/bundles/org.openhab.persistence.rrd4j/ @openhab/add-ons-maintainers
/bundles/org.openhab.persistence.timescaledb/ @ulbi
/bundles/org.openhab.transform.basicprofiles/ @cweitkamp @J-N-K
/bundles/org.openhab.transform.bin2json/ @paulianttila
/bundles/org.openhab.transform.exec/ @openhab/add-ons-maintainers
+5
View File
@@ -2341,6 +2341,11 @@
<artifactId>org.openhab.persistence.rrd4j</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.persistence.timescaledb</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.transform.basicprofiles</artifactId>
@@ -0,0 +1,312 @@
# AGENTS.md - TimescaleDB Persistence Development Guide
## Context
You are working on a **native TimescaleDB persistence service** for openHAB.
TimescaleDB is a time-series extension for PostgreSQL — all standard PostgreSQL JDBC drivers work, but the schema and queries use TimescaleDB-specific features.
**openHAB has no built-in downsampling/aggregation framework.** `FilterCriteria`, `PersistenceStrategy` and `PersistenceItemConfiguration` contain no aggregation concepts. Everything must be implemented inside this service.
---
## Architecture
### Key Classes
| Class | Role |
|---|---|
| `TimescaleDBPersistenceService` | Main OSGi service, implements `ModifiablePersistenceService` |
| `TimescaleDBMapper` | `State` ↔ SQL value conversion (all openHAB item types) |
| `TimescaleDBSchema` | Schema creation and migration on startup |
| `TimescaleDBQuery` | SQL query builder for all persistence operations |
| `TimescaleDBMetadataService` | Reads per-item downsampling config from `MetadataRegistry` |
| `TimescaleDBDownsampleJob` | Scheduled daily job: aggregates + deletes raw rows in-place |
### OSGi Service Registration
- Service ID: `timescaledb`
- Implements: `ModifiablePersistenceService` (= `QueryablePersistenceService` + `remove()`)
- Config PID: `org.openhab.persistence.timescaledb`
- `OH-INF/addon/addon.xml` required — registers the addon in the openHAB UI (Settings → Add-ons → TimescaleDB). Without it the bundle runs but is invisible to the UI.
- Config description: `OH-INF/config/timescaledb.xml`
- `ConfigurationPolicy.REQUIRE` — service does not start without configuration
- Scheduler: `ThreadPoolManager.getScheduledPool("timescaledb")` (shared pool — never call `shutdownNow()`)
- Deactivate: `ScheduledFuture.cancel(false)`, then `HikariDataSource.close()`
- State indicator: `dataSource != null` — no `initialized` boolean
### Dependencies
- JDBC Driver: `org.postgresql:postgresql`
- Connection pooling: HikariCP (already used in other openHAB bundles)
- openHAB Core: `org.openhab.core.persistence`, `org.openhab.core.items` (for `MetadataRegistry`)
---
## Database Schema
```sql
CREATE TABLE item_meta (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE items (
time TIMESTAMPTZ NOT NULL,
item_id INTEGER NOT NULL REFERENCES item_meta(id),
value DOUBLE PRECISION,
string TEXT,
unit TEXT, -- stored per row, NOT in item_meta
downsampled BOOLEAN NOT NULL DEFAULT FALSE
);
SELECT create_hypertable('items', 'time');
CREATE INDEX ON items (item_id, time DESC);
```
### Why `unit` is per row, not in `item_meta`
A `QuantityType` unit can change over time (sensor reconfiguration, firmware update, etc.). Storing it in `item_meta` would corrupt historical reads. The unit is stored with each measurement and read back from the row when reconstructing `QuantityType` states.
### Why downsampling is in-place (same hypertable)
openHAB reads persisted data directly from the hypertable via `QueryablePersistenceService`. If aggregated data lived in separate views or tables, openHAB would not see it without query-layer changes. In-place replacement (delete raw rows → insert aggregated rows with `downsampled=TRUE`) keeps a single source of truth that openHAB reads transparently.
---
## State Type Mapping
All openHAB item types are fully supported. `TimescaleDBMapper` handles the conversion in both directions.
### Store direction (`toRow`)
| State type | `value` column | `string` column | `unit` column |
|---|---|---|---|
| `QuantityType` | numeric | null | unit string (e.g. `"°C"`) |
| `DecimalType` | numeric | null | null |
| `OnOffType` | `ON=1.0 / OFF=0.0` | null | null |
| `OpenClosedType` | `OPEN=1.0 / CLOSED=0.0` | null | null |
| `PercentType` | 0.0100.0 | null | null |
| `UpDownType` | `UP=0.0 / DOWN=1.0` | null | null |
| `HSBType` | null | `"H,S,B"` | null |
| `DateTimeType` | null | ISO-8601 string | null |
| `PointType` | null | `"lat,lon[,alt]"` | null |
| `PlayPauseType` | null | enum name (`"PLAY"`, `"PAUSE"`, …) | null |
| `StringListType` | null | comma-separated values | null |
| `RawType` | null | Base64-encoded bytes | MIME type |
| `StringType` | null | raw string | null |
### Load direction (`toState`)
`GroupItem` is unwrapped to its base item before dispatch. Item type determines how the row is interpreted:
- `ColorItem``HSBType` (parsed from `string`)
- `DateTimeItem``DateTimeType` (parsed from `string`)
- `LocationItem``PointType` (parsed from `string`)
- `PlayerItem``PlayPauseType` (parsed from `string`)
- `CallItem``StringListType` (parsed from `string`)
- `ImageItem``RawType` (Base64-decoded from `string`, MIME type from `unit`)
- `DimmerItem` / `RollershutterItem``PercentType` (**must be checked before `SwitchItem`**)
- `SwitchItem``OnOffType`
- `ContactItem``OpenClosedType`
- `NumberItem` with `unit != null``QuantityType`
- `NumberItem` without unit → `DecimalType`
- anything else with `string``StringType`
**Critical instanceof ordering in `toRow()`:** `HSBType` before `PercentType` before `DecimalType`
(because `HSBType extends PercentType extends DecimalType`).
---
## Per-Item Downsampling via Item Metadata
### How to read metadata (same pattern as InfluxDB persistence)
```java
@Reference
private MetadataRegistry metadataRegistry;
private Optional<Metadata> getItemMetadata(String itemName) {
MetadataKey key = new MetadataKey("timescaledb", itemName);
return Optional.ofNullable(metadataRegistry.get(key));
}
```
`Metadata` has:
- `getValue()` → main value string, e.g. `"AVG"`, `"MAX"`, `"MIN"`, `"SUM"`, or `""` (no aggregation)
- `getConfiguration()``Map<String, Object>` with keys like `"downsampleInterval"`, `"retainRawDays"`, `"retentionDays"`
### Metadata format (configured by users in .items files)
```java
Number:Temperature MySensor {
timescaledb="AVG" [ downsampleInterval="1h", retainRawDays="5", retentionDays="365" ]
}
```
### Parsing the metadata
```java
public record DownsampleConfig(
AggregationFunction function, // AVG / MAX / MIN / SUM
Duration interval, // e.g. Duration.ofHours(1)
int retainRawDays, // default 5
int retentionDays // default 0 = disabled
) {}
public enum AggregationFunction { AVG, MAX, MIN, SUM }
```
Interval parsing — **validate against an allowlist** (used in SQL string formatting):
| Metadata value | SQL interval literal |
|---|---|
| `1m` | `1 minute` |
| `5m` | `5 minutes` |
| `15m` | `15 minutes` |
| `30m` | `30 minutes` |
| `1h` | `1 hour` |
| `6h` | `6 hours` |
| `1d` | `1 day` |
Throw `IllegalArgumentException` for any value not in this list to prevent SQL injection.
---
## Downsampling Job (`TimescaleDBDownsampleJob`)
Runs daily (e.g. via `@Scheduled` or openHAB's `CronScheduler`). For each item that has `timescaledb` metadata with a non-empty aggregation function:
```sql
-- Step 1: aggregate raw rows older than retainRawDays into buckets
INSERT INTO items (time, item_id, value, unit, downsampled)
SELECT
time_bucket('<interval>', time) AS time,
item_id,
<AGG_FN>(value) AS value,
last(unit, time) AS unit, -- keep most recent unit in bucket
TRUE AS downsampled
FROM items
WHERE item_id = ?
AND downsampled = FALSE
AND time < NOW() - INTERVAL '<retainRawDays> days'
GROUP BY time_bucket('<interval>', time), item_id
ON CONFLICT DO NOTHING;
-- Step 2: delete replaced raw rows
DELETE FROM items
WHERE item_id = ?
AND downsampled = FALSE
AND time < NOW() - INTERVAL '<retainRawDays> days';
-- Step 3 (if retentionDays > 0): drop everything older than retention window
DELETE FROM items
WHERE item_id = ?
AND time < NOW() - INTERVAL '<retentionDays> days';
```
**Important:**
- `<interval>`, `<AGG_FN>`, `<retainRawDays>`, `<retentionDays>` are formatted into the SQL string — **never from user input directly**. Validate interval against allowlist, validate function against enum. Use `?` for `item_id`.
- `last(unit, time)` is a TimescaleDB hyperfuction — verify it is available, otherwise use `MAX(unit)` as fallback.
- Run steps 1+2 in a transaction per item to avoid partial state.
- Log errors per item and continue (don't abort the entire job on a single-item failure).
---
## Query Implementation
- All item name / date / state lookups use JDBC `PreparedStatement` — no string concatenation for user-controlled values.
- `time_bucket()` interval is formatted as a string but validated against the allowlist above.
- `historicState`: `WHERE item_id=? AND time <= ? ORDER BY time DESC LIMIT 1`
- `getAllStatesBetween`: `WHERE item_id=? AND time BETWEEN ? AND ? ORDER BY time ASC` — returns both raw and downsampled rows.
- Aggregate queries (`averageSince`, `minSince`, etc.): `WHERE item_id=? AND time >= ?` — operate on all rows including downsampled ones, which is correct.
---
## item_id Caching
Cache `name → item_id` in a `ConcurrentHashMap` to avoid a SELECT on every `store()` call. Invalidate on service restart. Auto-insert into `item_meta` on first `store()` if the item is unknown.
```java
private final Map<String, Integer> itemIdCache = new ConcurrentHashMap<>();
private int getOrCreateItemId(String name, @Nullable String label) {
return itemIdCache.computeIfAbsent(name, n -> fetchOrInsertItemMeta(n, label));
}
```
---
## Testing
### Unit Tests (no DB required)
Location: `src/test/java/org/openhab/persistence/timescaledb/internal/`
- `BundleManifestTest` — OSGi Import-Package allowlist + presence of `OH-INF/addon/addon.xml`
- `TimescaleDBMapperTest` — State ↔ SQL value round-trips for all state types
- `TimescaleDBMetadataServiceTest` — parsing of metadata values and config keys
- `TimescaleDBDownsampleJobTest` — SQL generation for aggregation/delete, interval allowlist validation
Run with `mvn test` — last result: **183 tests, 0 failures** (2026-03-13).
### Integration Tests (requires Docker + TimescaleDB)
Tagged `@Tag("integration")`, run automatically via Testcontainers during `mvn test`:
```java
@Container
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("timescale/timescaledb:latest-pg16")
.withDatabaseName("openhab_test")
.withUsername("openhab")
.withPassword("openhab");
```
Test: schema creation, store/query round-trips, downsampling job result, compression policy creation.
### Performance Tests (requires external DB)
Tagged `@Tag("performance")`**excluded from `mvn test`** via `<excludedGroups>performance,external-integration</excludedGroups>` in pom.xml.
Run explicitly against an external TimescaleDB:
```bash
HOST=... PORT=5432 DBNAME=openhab USER=openhab PASSWORD=... \
mvn test -Dtest=TimescaleDBPerformanceIT \
-pl bundles/org.openhab.persistence.timescaledb
```
See `PERFORMANCE_TESTS.md` for SLOs, scale constants, and the heavy 18-month scenario.
---
## Common Pitfalls
1. **TimescaleDB extension not installed**: check on startup with `SELECT extname FROM pg_extension WHERE extname='timescaledb'`, fail with a clear error if missing.
2. **`last()` availability**: `last(unit, time)` requires the TimescaleDB Toolkit — check availability, fall back to `MAX(unit)` otherwise.
3. **Compression + INSERT conflict**: compressed chunks are read-only. The downsampling INSERT must target the uncompressed region (data newer than `compressionAfterDays`). Ensure `retainRawDays < compressionAfterDays`.
4. **Interval allowlist is mandatory**: `time_bucket('1h', time)` is dynamically formatted — any non-allowlisted value must throw before it reaches SQL.
5. **`ON CONFLICT DO NOTHING`** on the aggregation INSERT: the job may run twice if interrupted; duplicate bucket rows must be prevented.
6. **`QuantityType` unit changes**: never update `item_meta` with a unit — the unit lives on each row. On read, take the `unit` value from the row.
---
## Relevant openHAB Core APIs
- `org.openhab.core.persistence.QueryablePersistenceService` — implement this
- `org.openhab.core.persistence.FilterCriteria` — query parameters passed to `query()`
- `org.openhab.core.items.MetadataRegistry` — OSGi service, inject via `@Reference`
- `org.openhab.core.items.Metadata``getValue()` + `getConfiguration()` for per-item config
- `org.openhab.core.items.MetadataKey` — constructed as `new MetadataKey("timescaledb", itemName)`
- `org.openhab.core.library.types.*``QuantityType`, `DecimalType`, `OnOffType`, etc.
## References
- [TimescaleDB docs](https://docs.timescale.com/)
- [time_bucket()](https://docs.timescale.com/api/latest/hyperfunctions/time_bucket/)
- [last()](https://docs.timescale.com/api/latest/hyperfunctions/last/)
- [Compression](https://docs.timescale.com/use-timescale/latest/compression/)
- InfluxDB persistence (metadata pattern): `bundles/org.openhab.persistence.influxdb/src/main/java/.../InfluxDBMetadataService.java`
- Existing downsampling logic (Python/MongoDB): `DOWNSAMPLE_IMPLEMENTATION_GUIDE.md` in this bundle
@@ -0,0 +1,234 @@
# TimescaleDB Persistence
This service persists and queries openHAB item states using [TimescaleDB](https://www.timescale.com/), a time-series database built on PostgreSQL.
Unlike the generic JDBC persistence (which can also connect to TimescaleDB via the PostgreSQL driver), this service is purpose-built for TimescaleDB and leverages its native time-series features:
- **Hypertables** for automatic time-based partitioning and fast range queries
- **In-place downsampling** — raw data is aggregated and replaced in the hypertable directly, so openHAB reads aggregated data transparently without any schema changes
- **Per-item downsampling config** via item metadata (namespace `timescaledb`)
- **Compression Policies** to automatically compress older data and reduce storage
- **Retention Policies** to automatically drop data older than a configured threshold
## Prerequisites
- TimescaleDB 2.x installed and running (as a PostgreSQL extension)
- A database and user created for openHAB
```sql
CREATE DATABASE openhab;
CREATE USER openhab WITH PASSWORD 'openhab';
GRANT ALL PRIVILEGES ON DATABASE openhab TO openhab;
-- Connect to openhab database, then:
CREATE EXTENSION IF NOT EXISTS timescaledb;
```
## Database Schema
The service **creates all tables automatically on startup** — no manual DDL required.
Item states are stored in a single hypertable `items` (columns: `time`, `item_id`, `value`, `string`, `unit`, `downsampled`) and a name-lookup table `item_meta`.
## State Type Mapping
| openHAB Type | `value` column | `string` column | `unit` column |
|-------------------|--------------------------------|-----------------|------------------------|
| `DecimalType` | numeric value | — | — |
| `QuantityType` | numeric value (stripped) | — | unit string, e.g. `°C` |
| `OnOffType` | `1.0` (ON) / `0.0` (OFF) | — | — |
| `OpenClosedType` | `1.0` (OPEN) / `0.0` (CLOSED) | — | — |
| `PercentType` | `0.0``100.0` | — | — |
| `UpDownType` | `0.0` (UP) / `1.0` (DOWN) | — | — |
| `HSBType` | — | `H,S,B` | — |
| `DateTimeType` | — | ISO-8601 | — |
| `StringType` | — | raw string | — |
## Configuration
Configure via `$OPENHAB_CONF/services/timescaledb.cfg` or in the UI under `Settings → Add-ons → TimescaleDB → Configure`.
| Property | Default | Required | Description |
|------------------------|-----------|:--------:|-----------------------------------------------------------|
| `url` | | Yes | JDBC URL, e.g. `jdbc:postgresql://localhost:5432/openhab` |
| `user` | `openhab` | No | Database user |
| `password` | | Yes | Database password |
| `chunkInterval` | `7 days` | No | TimescaleDB chunk interval for the hypertable |
| `retentionDays` | `0` | No | Drop data older than N days. `0` = disabled |
| `compressionAfterDays` | `0` | No | Compress chunks older than N days. `0` = disabled |
| `maxConnections` | `5` | No | Maximum DB connections in the pool |
| `connectTimeout` | `5000` | No | Connection timeout in milliseconds |
## Persistence Configuration
All item- and event-related configuration is defined in `persistence/timescaledb.persist`:
```java
Strategies {
everyMinute : "0 * * * * ?"
everyHour : "0 0 * * * ?"
everyDay : "0 0 0 * * ?"
default = everyChange
}
Items {
* : strategy = everyChange, restoreOnStartup
Temperature_* : strategy = everyMinute
Energy_* : strategy = everyHour
}
```
## Per-Item Downsampling
Downsampling is configured **per item** via item metadata in the `timescaledb` namespace.
### Metadata format
```text
timescaledb="<operation>" [downsampleInterval="<interval>", retainRawDays="<n>", retentionDays="<n>"]
```
| Metadata key | Values | Description |
|----------------------|--------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
| value (main) | `AVG`, `MAX`, `MIN`, `SUM`, or `" "` | Aggregation function. Use a single space `" "` for retention-only (no downsampling). openHAB rejects a truly empty value, so a space is required. |
| `downsampleInterval` | e.g. `1h`, `15m`, `1d` | Time bucket size for aggregation. Required when value is an aggregation function. |
| `retainRawDays` | integer, default `5` | Keep raw data for N days before replacing with aggregated rows. |
| `retentionDays` | integer, default `0` | Drop all data (raw + downsampled) older than N days. `0` = off. |
### Configuration in `.items` files
```java
Number:Temperature Sensor_Temperature_Living "Living Room [%.1f °C]" {
timescaledb="AVG" [ downsampleInterval="1h", retainRawDays="5" ]
}
Number:Power Meter_Power_House "House Power [%.1f W]" {
timescaledb="AVG" [ downsampleInterval="15m", retainRawDays="3", retentionDays="365" ]
}
Number:Energy Meter_Energy_House "House Energy [%.3f kWh]" {
timescaledb="SUM" [ downsampleInterval="1h", retainRawDays="7" ]
}
// Retention-only: no downsampling, just drop data older than 30 days.
// The value must be a single space " " — openHAB rejects a truly empty string.
Number:Temperature Sensor_Temp_Outdoor {
timescaledb=" " [ retentionDays="30" ]
}
```
### Configuration in mainUI
**Downsampling + Retention:**
`Item → Metadata → Add Metadata → Enter namespace "timescaledb"`:
- Value: `AVG`
- Additional config: `downsampleInterval=1h`, `retainRawDays=5`, `retentionDays=365`
**Retention-only (no downsampling):**
`Item → Metadata → Add Metadata → Enter namespace "timescaledb"`:
- Value: ` ` (a single space — openHAB rejects an empty value)
- Additional config: `retentionDays=30`
### How in-place downsampling works
The downsampling runs as a scheduled job (daily, at midnight):
```text
For each item with timescaledb metadata:
1. Parse operation + downsampleInterval from metadata
2. Compute cutoff = NOW() - retainRawDays
3. SELECT time_bucket(interval, time), agg_fn(value), MAX(unit)
FROM items
WHERE item_id = ? AND downsampled = FALSE AND time < cutoff
GROUP BY bucket
4. INSERT aggregated rows with downsampled = TRUE
5. DELETE original rows (downsampled = FALSE, time < cutoff)
```
This keeps the hypertable as the single source of truth. openHAB reads aggregated and raw data from the same table — no query changes needed.
### Supported intervals
| Metadata value | SQL interval |
|----------------|---------------|
| `1m` | `1 minute` |
| `5m` | `5 minutes` |
| `15m` | `15 minutes` |
| `30m` | `30 minutes` |
| `1h` | `1 hour` |
| `6h` | `6 hours` |
| `1d` | `1 day` |
## Querying from openHAB
The service implements the full `QueryablePersistenceService` interface:
| openHAB Query | TimescaleDB Implementation |
|--------------------------------------------|---------------------------------------|
| `historicState(item, timestamp)` | `SELECT … ORDER BY time DESC LIMIT 1` |
| `averageSince(item, timestamp)` | `AVG(value) WHERE time >= ?` |
| `sumSince(item, timestamp)` | `SUM(value) WHERE time >= ?` |
| `minSince(item, timestamp)` | `MIN(value) WHERE time >= ?` |
| `maxSince(item, timestamp)` | `MAX(value) WHERE time >= ?` |
| `countSince(item, timestamp)` | `COUNT(*) WHERE time >= ?` |
| `getAllStatesBetween(item, begin, end)` | Range scan (raw + downsampled) |
| `removeAllStatesBetween(item, begin, end)` | `DELETE WHERE time BETWEEN ? AND ?` |
## Compression
When `compressionAfterDays > 0`, the service configures automatic chunk compression:
```sql
ALTER TABLE items SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'item_id',
timescaledb.compress_orderby = 'time DESC'
);
SELECT add_compression_policy('items', INTERVAL '30 days');
```
## Retention
When `retentionDays > 0` (global config), a TimescaleDB retention policy is added:
```sql
SELECT add_retention_policy('items', INTERVAL '365 days');
```
Per-item retention (via metadata `retentionDays`) is applied by the daily downsampling job using a targeted DELETE.
This works independently of downsampling: an item can have `retentionDays` set without any aggregation function
(use a single space `" "` as the metadata value in that case).
## Grafana Integration
TimescaleDB works natively with the Grafana PostgreSQL data source:
```sql
-- Raw + downsampled data for a sensor (last 24 h)
SELECT
time_bucket('5 minutes', time) AS time,
AVG(value) AS temperature,
MAX(unit) AS unit
FROM items
JOIN item_meta ON items.item_id = item_meta.id
WHERE item_meta.name = 'Sensor_Temperature_Living'
AND time > NOW() - INTERVAL '24 hours'
GROUP BY 1
ORDER BY 1;
```
## Differences from JDBC Persistence
| Feature | JDBC Persistence | TimescaleDB Persistence |
|--------------------------------|----------------------|-------------------------|
| TimescaleDB hypertables | No (plain tables) | Yes |
| In-place downsampling | No | Yes |
| Per-item aggregation config | No | Yes (item metadata) |
| Automatic compression | No | Yes |
| Retention policies | No | Yes (global + per-item) |
| Unit stored per measurement | No | Yes |
| Multiple DB backends | Yes | No (TimescaleDB only) |
| Schema (one table per item) | Yes | No (single hypertable) |
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.2.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.persistence.timescaledb</artifactId>
<name>openHAB Add-ons :: Bundles :: Persistence Service :: TimescaleDB</name>
<properties>
<bnd.importpackage>!com.codahale.metrics.*,!io.prometheus.*,!org.checkerframework.*,!org.jetbrains.annotations.*,!org.hibernate.*,!waffle.windows.auth.*,!org.osgi.service.jdbc.*,!com.sun.jna.*,!javassist.*</bnd.importpackage>
<postgresql.version>42.7.9</postgresql.version>
<hikari.version>5.1.0</hikari.version>
</properties>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>${hikari.version}</version>
<scope>compile</scope>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludedGroups>performance,external-integration</excludedGroups>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.persistence.timescaledb-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<!-- Keep dependency version numbers explicit, don't use variables from pom as this will break distro build -->
<feature name="openhab-persistence-timescaledb" description="TimescaleDB Persistence" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.persistence.timescaledb/${project.version}</bundle>
</feature>
</features>
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Aggregation function used for per-item downsampling.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
public enum AggregationFunction {
AVG,
MAX,
MIN,
SUM;
/** Returns the SQL aggregate function name, e.g. {@code AVG(value)}. */
public String toSql() {
return name();
}
}
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Per-item configuration parsed from item metadata (namespace {@code timescaledb}).
*
* <p>
* A config is either a full downsampling config (function + interval + retention) or a
* retention-only config (function and sqlInterval are {@code null}). Use {@link #hasDownsampling()}
* to distinguish the two cases.
*
* @param function Aggregation function (AVG, MAX, MIN, SUM), or {@code null} for retention-only.
* @param sqlInterval Validated SQL interval literal, e.g. {@code "1 hour"}, or {@code null} for retention-only.
* @param retainRawDays Keep raw data for N days before aggregating. Ignored for retention-only configs.
* @param retentionDays Drop all data older than N days. 0 = disabled.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
public record DownsampleConfig(@Nullable AggregationFunction function, @Nullable String sqlInterval, int retainRawDays,
int retentionDays) {
/** Allowlist mapping from metadata interval strings to SQL interval literals. */
public static final java.util.Map<String, String> INTERVAL_MAP = java.util.Map.of("1m", "1 minute", "5m",
"5 minutes", "15m", "15 minutes", "30m", "30 minutes", "1h", "1 hour", "2h", "2 hours", "6h", "6 hours",
"12h", "12 hours", "1d", "1 day");
/**
* Converts a metadata interval string to its SQL literal.
*
* @param interval The metadata interval string, e.g. {@code "1h"}.
* @return The SQL interval literal.
* @throws IllegalArgumentException if the interval is not in the allowlist.
*/
public static String toSqlInterval(String interval) {
String sql = INTERVAL_MAP.get(interval);
if (sql == null) {
throw new IllegalArgumentException(
"Invalid downsampleInterval '" + interval + "'. Allowed: " + INTERVAL_MAP.keySet());
}
return sql;
}
/**
* Returns {@code true} if this config describes a full downsampling run (aggregation + raw-data pruning).
* Returns {@code false} for retention-only configs where only the retention DELETE is executed.
*/
public boolean hasDownsampling() {
return function != null;
}
/**
* Creates a retention-only config: no aggregation, just a periodic DELETE of rows older than
* {@code retentionDays} days.
*
* @param retentionDays Days after which all rows are deleted. Must be &gt; 0.
*/
public static DownsampleConfig retentionOnly(int retentionDays) {
if (retentionDays <= 0) {
throw new IllegalArgumentException("retentionDays must be > 0, got " + retentionDays);
}
return new DownsampleConfig(null, null, 0, retentionDays);
}
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.io.console.Console;
import org.openhab.core.io.console.ConsoleCommandCompleter;
import org.openhab.core.io.console.StringsCompleter;
import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* Karaf console commands for the TimescaleDB persistence service.
*
* <pre>
* openhab:timescaledb downsample - run the downsampling job immediately
* </pre>
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
@Component(service = ConsoleCommandExtension.class)
public class TimescaleDBConsoleCommandExtension extends AbstractConsoleCommandExtension
implements ConsoleCommandCompleter {
private static final String CMD_DOWNSAMPLE = "downsample";
private static final StringsCompleter CMD_COMPLETER = new StringsCompleter(List.of(CMD_DOWNSAMPLE), false);
private final TimescaleDBPersistenceService persistenceService;
@Activate
public TimescaleDBConsoleCommandExtension(@Reference TimescaleDBPersistenceService persistenceService) {
super("timescaledb", "TimescaleDB persistence commands.");
this.persistenceService = persistenceService;
}
@Override
public List<String> getUsages() {
return List.of(buildCommandUsage(CMD_DOWNSAMPLE, "run the downsampling/retention job immediately"));
}
@Override
public @Nullable ConsoleCommandCompleter getCompleter() {
return this;
}
@Override
public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List<String> candidates) {
return CMD_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates);
}
@Override
public void execute(String[] args, Console console) {
if (args.length == 1 && CMD_DOWNSAMPLE.equals(args[0])) {
console.println("Starting downsampling job...");
boolean ran = persistenceService.runDownsampleNow();
if (ran) {
console.println("Downsampling job finished.");
} else {
console.println("TimescaleDB persistence service is not active — cannot run job.");
}
} else {
printUsage(console);
}
}
}
@@ -0,0 +1,200 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import javax.sql.DataSource;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Daily scheduled job that performs in-place downsampling for all items
* configured with {@code timescaledb} metadata.
*
* <p>
* For each eligible item, the job runs atomically in a single transaction:
* <ol>
* <li>INSERT aggregated rows (time_bucket + agg_fn) with {@code downsampled=TRUE}</li>
* <li>DELETE original raw rows that have been aggregated</li>
* <li>If {@code retentionDays > 0}: DELETE all rows older than the retention window</li>
* </ol>
*
* <p>
* <strong>Security note:</strong> The SQL interval and aggregation function are
* formatted into the query string but are validated against an allowlist
* ({@link DownsampleConfig#INTERVAL_MAP} and {@link AggregationFunction} enum)
* before use. The {@code item_id} is always a JDBC bind parameter.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
public class TimescaleDBDownsampleJob implements Runnable {
private final Logger logger = LoggerFactory.getLogger(TimescaleDBDownsampleJob.class);
/**
* INSERT aggregated rows for one item, skipping buckets that already have a downsampled row.
* The NOT EXISTS guard makes this statement idempotent at the query level; the trailing
* ON CONFLICT DO NOTHING (no conflict target) provides a second line of defence for
* concurrent execution. The target-less form is required because TimescaleDB hypertables
* do not support column-inference conflict targets. Because the schema uses
* UNIQUE(time, item_id, downsampled) and we always insert downsampled=TRUE, this never
* silently drops a bucket that merely collides with a raw row at the bucket boundary.
* Placeholder: (1) item_id.
* Interval and agg-fn are pre-validated strings from the allowlist/enum.
*/
private static final String SQL_INSERT_AGGREGATED_TEMPLATE = """
INSERT INTO items (time, item_id, value, unit, downsampled)
SELECT bucket, item_id, agg_value, agg_unit, TRUE
FROM (
SELECT
time_bucket('%s', time) AS bucket,
item_id,
%s(value) AS agg_value,
MAX(unit) AS agg_unit
FROM items
WHERE item_id = ?
AND downsampled = FALSE
AND time < NOW() - INTERVAL '%d days'
GROUP BY time_bucket('%s', time), item_id
) AS new_buckets
WHERE NOT EXISTS (
SELECT 1
FROM items existing
WHERE existing.item_id = new_buckets.item_id
AND existing.downsampled = TRUE
AND existing.time = new_buckets.bucket
)
ON CONFLICT DO NOTHING
""";
/**
* DELETE raw rows that were just aggregated.
* Placeholder: (1) item_id.
*/
private static final String SQL_DELETE_RAW_TEMPLATE = """
DELETE FROM items
WHERE item_id = ?
AND downsampled = FALSE
AND time < NOW() - INTERVAL '%d days'
""";
/**
* DELETE all rows (raw + downsampled) outside the per-item retention window.
* Placeholder: (1) item_id.
*/
private static final String SQL_DELETE_RETENTION_TEMPLATE = """
DELETE FROM items
WHERE item_id = ?
AND time < NOW() - INTERVAL '%d days'
""";
private final DataSource dataSource;
private final TimescaleDBMetadataService metadataService;
/**
* @param dataSource The connection pool.
* @param metadataService The metadata parser.
*/
public TimescaleDBDownsampleJob(DataSource dataSource, TimescaleDBMetadataService metadataService) {
this.dataSource = dataSource;
this.metadataService = metadataService;
}
@Override
public void run() {
List<String> itemNames = metadataService.getConfiguredItemNames();
logger.info("Downsampling job started: {} item(s) to process", itemNames.size());
int success = 0;
int skipped = 0;
int failed = 0;
for (String itemName : itemNames) {
DownsampleConfig config;
{
var configOpt = metadataService.getDownsampleConfig(itemName);
if (configOpt.isEmpty()) {
logger.debug("Item '{}': no valid DownsampleConfig — skipping", itemName);
skipped++;
continue;
}
config = configOpt.get();
}
try {
downsampleItem(itemName, config);
success++;
} catch (Exception e) {
logger.error("Downsampling failed for item '{}': {}", itemName, e.getMessage(), e);
failed++;
}
}
logger.info("Downsampling job finished: {} succeeded, {} skipped, {} failed", success, skipped, failed);
}
private void downsampleItem(String itemName, DownsampleConfig config) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
var itemIdOpt = TimescaleDBQuery.findItemId(conn, itemName);
if (itemIdOpt.isEmpty()) {
logger.debug("Item '{}': not yet known to the database — skipping", itemName);
return;
}
int itemId = itemIdOpt.get();
conn.setAutoCommit(false);
try {
if (config.hasDownsampling()) {
AggregationFunction fn = config.function();
String interval = config.sqlInterval();
if (fn != null && interval != null) {
String sqlInsert = SQL_INSERT_AGGREGATED_TEMPLATE.formatted(interval, fn.toSql(),
config.retainRawDays(), interval);
String sqlDeleteRaw = SQL_DELETE_RAW_TEMPLATE.formatted(config.retainRawDays());
int inserted = executeUpdate(conn, sqlInsert, itemId);
int deleted = executeUpdate(conn, sqlDeleteRaw, itemId);
logger.debug("Item '{}': aggregated {} bucket(s), deleted {} raw row(s)", itemName, inserted,
deleted);
}
}
if (config.retentionDays() > 0) {
String sqlRetention = SQL_DELETE_RETENTION_TEMPLATE.formatted(config.retentionDays());
int dropped = executeUpdate(conn, sqlRetention, itemId);
logger.debug("Item '{}': dropped {} row(s) outside {}d retention window", itemName, dropped,
config.retentionDays());
}
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(true);
}
}
}
private static int executeUpdate(Connection conn, String sql, int itemId) throws SQLException {
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, itemId);
return ps.executeUpdate();
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.persistence.HistoricItem;
import org.openhab.core.types.State;
/**
* A historic item returned by TimescaleDB queries.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
public class TimescaleDBHistoricItem implements HistoricItem {
private final String name;
private final State state;
private final Instant timestamp;
public TimescaleDBHistoricItem(String name, State state, Instant timestamp) {
this.name = name;
this.state = state;
this.timestamp = timestamp;
}
@Override
public String getName() {
return name;
}
@Override
public State getState() {
return state;
}
@Override
public ZonedDateTime getTimestamp() {
return timestamp.atZone(ZoneId.systemDefault());
}
@Override
public String toString() {
return "TimescaleDBHistoricItem{name='" + name + "', state=" + state + ", timestamp=" + timestamp + "}";
}
}
@@ -0,0 +1,253 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Base64;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.Item;
import org.openhab.core.library.items.CallItem;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.ContactItem;
import org.openhab.core.library.items.DateTimeItem;
import org.openhab.core.library.items.DimmerItem;
import org.openhab.core.library.items.ImageItem;
import org.openhab.core.library.items.LocationItem;
import org.openhab.core.library.items.PlayerItem;
import org.openhab.core.library.items.RollershutterItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringListType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.types.UpDownType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Converts between openHAB {@link State} objects and the three-column schema
* ({@code value DOUBLE PRECISION}, {@code string TEXT}, {@code unit TEXT}).
*
* <p>
* Mapping rules (store direction):
* <ul>
* <li>{@link DecimalType} → value, unit=null, string=null</li>
* <li>{@link QuantityType} → value=numeric, unit=unit-string, string=null</li>
* <li>{@link OnOffType} → value (ON=1.0, OFF=0.0)</li>
* <li>{@link OpenClosedType} → value (OPEN=1.0, CLOSED=0.0)</li>
* <li>{@link PercentType} → value (0.0100.0)</li>
* <li>{@link UpDownType} → value (UP=0.0, DOWN=1.0)</li>
* <li>{@link HSBType} → string="H,S,B"</li>
* <li>{@link DateTimeType} → string=ISO-8601</li>
* <li>{@link PointType} → string="lat,lon[,alt]"</li>
* <li>{@link PlayPauseType} → string=enum name</li>
* <li>{@link StringListType} → string=comma-separated values</li>
* <li>{@link RawType} → string=Base64-encoded bytes, unit=MIME type</li>
* <li>{@link StringType} → string=raw value</li>
* </ul>
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
public class TimescaleDBMapper {
private static final Logger LOGGER = LoggerFactory.getLogger(TimescaleDBMapper.class);
private TimescaleDBMapper() {
// utility class
}
/**
* SQL row representation of a persisted state.
*
* @param value Numeric value, or null for string-only states.
* @param string String value, or null for numeric states.
* @param unit Unit string for {@link QuantityType}, null otherwise.
*/
public record Row(@Nullable Double value, @Nullable String string, @Nullable String unit) {
}
/**
* Converts an openHAB {@link State} to a SQL {@link Row}.
*
* @param state The state to convert.
* @return The row to be stored, or null if the state type is unsupported.
*/
public static @Nullable Row toRow(State state) {
if (state instanceof QuantityType<?> qty) {
return new Row(qty.doubleValue(), null, qty.getUnit().toString());
} else if (state instanceof HSBType hsb) {
// HSBType extends PercentType extends DecimalType — must be checked before both
return new Row(null, hsb.toString(), null);
} else if (state instanceof OnOffType onOff) {
return new Row(onOff == OnOffType.ON ? 1.0 : 0.0, null, null);
} else if (state instanceof OpenClosedType openClosed) {
return new Row(openClosed == OpenClosedType.OPEN ? 1.0 : 0.0, null, null);
} else if (state instanceof PercentType pct) {
return new Row(pct.doubleValue(), null, null);
} else if (state instanceof DecimalType dec) {
return new Row(dec.doubleValue(), null, null);
} else if (state instanceof UpDownType upDown) {
return new Row(upDown == UpDownType.UP ? 0.0 : 1.0, null, null);
} else if (state instanceof DateTimeType dt) {
return new Row(null, dt.getZonedDateTime(ZoneId.systemDefault()).toString(), null);
} else if (state instanceof PointType point) {
return new Row(null, point.toString(), null);
} else if (state instanceof PlayPauseType playPause) {
return new Row(null, playPause.toString(), null);
} else if (state instanceof StringListType stringList) {
return new Row(null, stringList.toString(), null);
} else if (state instanceof RawType raw) {
return new Row(null, Base64.getEncoder().encodeToString(raw.getBytes()), raw.getMimeType());
} else if (state instanceof StringType str) {
return new Row(null, str.toString(), null);
} else {
LOGGER.warn("Unsupported state type for TimescaleDB persistence: {}", state.getClass().getSimpleName());
return null;
}
}
/**
* Reconstructs an openHAB {@link State} from a SQL row, using the item type for disambiguation.
*
* @param item The openHAB item (used for type-based disambiguation of numeric states).
* @param value The {@code value} column, may be null.
* @param string The {@code string} column, may be null.
* @param unit The {@code unit} column, may be null.
* @return The reconstructed state, or {@link UnDefType#UNDEF} if reconstruction fails.
*/
public static State toState(Item item, @Nullable Double value, @Nullable String string, @Nullable String unit) {
// Unwrap GroupItem to its base item for type dispatch
Item realItem = item;
if (item instanceof GroupItem groupItem) {
Item baseItem = groupItem.getBaseItem();
if (baseItem != null) {
realItem = baseItem;
}
}
// QuantityType: unit column present together with a numeric value
if (unit != null && value != null) {
try {
return new QuantityType<>(value + " " + unit);
} catch (IllegalArgumentException e) {
LOGGER.warn("Failed to parse QuantityType for item '{}' with value={} unit={}: {}", item.getName(),
value, unit, e.getMessage());
return UnDefType.UNDEF;
}
}
// String-based states
if (string != null) {
if (realItem instanceof ColorItem) {
try {
return new HSBType(string);
} catch (IllegalArgumentException e) {
LOGGER.warn("Failed to parse HSBType for item '{}': {}", item.getName(), e.getMessage());
return UnDefType.UNDEF;
}
}
if (realItem instanceof DateTimeItem) {
try {
return new DateTimeType(ZonedDateTime.parse(string));
} catch (Exception e) {
LOGGER.warn("Failed to parse DateTimeType for item '{}': {}", item.getName(), e.getMessage());
return UnDefType.UNDEF;
}
}
if (realItem instanceof LocationItem) {
try {
return new PointType(string);
} catch (IllegalArgumentException e) {
LOGGER.warn("Failed to parse PointType for item '{}': {}", item.getName(), e.getMessage());
return UnDefType.UNDEF;
}
}
if (realItem instanceof PlayerItem) {
try {
return PlayPauseType.valueOf(string);
} catch (IllegalArgumentException e) {
LOGGER.warn("Failed to parse PlayPauseType for item '{}': {}", item.getName(), e.getMessage());
return UnDefType.UNDEF;
}
}
if (realItem instanceof CallItem) {
return new StringListType(string);
}
if (realItem instanceof ImageItem) {
try {
byte[] bytes = Base64.getDecoder().decode(string);
String mimeType = unit != null ? unit : "application/octet-stream";
return new RawType(bytes, mimeType);
} catch (IllegalArgumentException e) {
LOGGER.warn("Failed to decode RawType for item '{}': {}", item.getName(), e.getMessage());
return UnDefType.UNDEF;
}
}
// StringItem, GenericItem, and anything else with a string value
return new StringType(string);
}
// Numeric states without unit
if (value != null) {
if (realItem instanceof DimmerItem || realItem instanceof RollershutterItem) {
// DimmerItem extends SwitchItem — must be checked before SwitchItem
return new PercentType((int) Math.round(value));
}
if (realItem instanceof SwitchItem) {
return value >= 0.5 ? OnOffType.ON : OnOffType.OFF;
}
if (realItem instanceof ContactItem) {
return value >= 0.5 ? OpenClosedType.OPEN : OpenClosedType.CLOSED;
}
// NumberItem, GenericItem, UpDownType stored as decimal, etc.
return new DecimalType(value);
}
LOGGER.warn("Cannot reconstruct state for item '{}': value=null, string=null, unit=null", item.getName());
return UnDefType.UNDEF;
}
/**
* Converts a SQL operator from {@link org.openhab.core.persistence.FilterCriteria.Operator}
* to a JDBC-compatible SQL operator string.
*
* @param operator The filter operator.
* @return The SQL operator string, or null if not supported.
*/
public static @Nullable String toSqlOperator(org.openhab.core.persistence.FilterCriteria.Operator operator) {
return switch (operator) {
case EQ -> "=";
case NEQ -> "<>";
case LT -> "<";
case LTE -> "<=";
case GT -> ">";
case GTE -> ">=";
default -> null;
};
}
}
@@ -0,0 +1,177 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Reads and parses per-item downsampling configuration from the {@link MetadataRegistry}
* using the {@code timescaledb} namespace.
*
* <p>
* Example item metadata:
*
* <pre>
* Number:Temperature MySensor {
* timescaledb="AVG" [ downsampleInterval="1h", retainRawDays="5", retentionDays="365" ]
* }
* </pre>
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
@Component(service = TimescaleDBMetadataService.class)
public class TimescaleDBMetadataService {
private static final Logger LOGGER = LoggerFactory.getLogger(TimescaleDBMetadataService.class);
/** The metadata namespace used by this persistence service. */
public static final String METADATA_NAMESPACE = "timescaledb";
private static final int DEFAULT_RETAIN_RAW_DAYS = 5;
private static final int DEFAULT_RETENTION_DAYS = 0;
private final MetadataRegistry metadataRegistry;
@Activate
public TimescaleDBMetadataService(final @Reference MetadataRegistry metadataRegistry) {
this.metadataRegistry = metadataRegistry;
}
/**
* Returns the {@link DownsampleConfig} for the given item, or empty if no downsampling
* is configured or the metadata cannot be parsed.
*
* @param itemName The item name.
* @return Optional containing the parsed config, or empty.
*/
public Optional<DownsampleConfig> getDownsampleConfig(String itemName) {
MetadataKey key = new MetadataKey(METADATA_NAMESPACE, itemName);
@Nullable
Metadata metadata = metadataRegistry.get(key);
if (metadata == null) {
return Optional.empty();
}
return parseConfig(itemName, metadata);
}
/**
* Returns the names of all items that have a {@code timescaledb} metadata entry,
* regardless of whether they configure downsampling, retention-only, or both.
*
* @return List of item names with any timescaledb metadata.
*/
public List<String> getConfiguredItemNames() {
List<String> result = new ArrayList<>();
for (Metadata metadata : metadataRegistry.getAll()) {
if (!METADATA_NAMESPACE.equals(metadata.getUID().getNamespace())) {
continue;
}
result.add(metadata.getUID().getItemName());
}
return result;
}
private Optional<DownsampleConfig> parseConfig(String itemName, Metadata metadata) {
String functionStr = metadata.getValue();
if (functionStr.isBlank()) {
// No aggregation function — check for retention-only config.
// Note: openHAB requires a non-empty metadata value, so use a single space (" ")
// in item files and the UI when you only want retention without downsampling.
int retentionDays = getInt(metadata.getConfiguration(), "retentionDays", DEFAULT_RETENTION_DAYS);
if (retentionDays < 0) {
LOGGER.warn("Item '{}': retentionDays must be >= 0, ignoring negative value {}", itemName,
retentionDays);
return Optional.empty();
}
if (retentionDays > 0) {
LOGGER.debug("Item '{}': retention-only config with retentionDays={}", itemName, retentionDays);
return Optional.of(DownsampleConfig.retentionOnly(retentionDays));
}
return Optional.empty();
}
AggregationFunction function;
try {
function = AggregationFunction.valueOf(functionStr.trim().toUpperCase());
} catch (IllegalArgumentException e) {
LOGGER.warn("Item '{}': unknown aggregation function '{}' in timescaledb metadata — skipping", itemName,
functionStr);
return Optional.empty();
}
var config = metadata.getConfiguration();
String intervalStr = getString(config, "downsampleInterval", null);
if (intervalStr == null || intervalStr.isBlank()) {
LOGGER.warn("Item '{}': timescaledb metadata has function '{}' but no downsampleInterval — skipping",
itemName, functionStr);
return Optional.empty();
}
String sqlInterval;
try {
sqlInterval = DownsampleConfig.toSqlInterval(intervalStr);
} catch (IllegalArgumentException e) {
LOGGER.warn("Item '{}': {}", itemName, e.getMessage());
return Optional.empty();
}
int retainRawDays = getInt(config, "retainRawDays", DEFAULT_RETAIN_RAW_DAYS);
if (retainRawDays < 0) {
LOGGER.warn("Item '{}': retainRawDays must be >= 0, using default {}", itemName, DEFAULT_RETAIN_RAW_DAYS);
retainRawDays = DEFAULT_RETAIN_RAW_DAYS;
}
int retentionDays = getInt(config, "retentionDays", DEFAULT_RETENTION_DAYS);
if (retentionDays < 0) {
LOGGER.warn("Item '{}': retentionDays must be >= 0, using default {}", itemName, DEFAULT_RETENTION_DAYS);
retentionDays = DEFAULT_RETENTION_DAYS;
}
DownsampleConfig result = new DownsampleConfig(function, sqlInterval, retainRawDays, retentionDays);
LOGGER.debug("Item '{}': parsed DownsampleConfig {}", itemName, result);
return Optional.of(result);
}
private static @Nullable String getString(java.util.Map<String, Object> config, String key,
@Nullable String defaultValue) {
Object val = config.get(key);
return val != null ? val.toString() : defaultValue;
}
private static int getInt(java.util.Map<String, Object> config, String key, int defaultValue) {
Object val = config.get(key);
if (val == null) {
return defaultValue;
}
try {
return Integer.parseInt(val.toString());
} catch (NumberFormatException e) {
LOGGER.warn("Invalid integer value '{}' for metadata key '{}', using default {}", val, key, defaultValue);
return defaultValue;
}
}
}
@@ -0,0 +1,427 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.config.core.ConfigurableService;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.HistoricItem;
import org.openhab.core.persistence.ModifiablePersistenceService;
import org.openhab.core.persistence.PersistenceService;
import org.openhab.core.persistence.QueryablePersistenceService;
import org.openhab.core.persistence.strategy.PersistenceStrategy;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.osgi.framework.Constants;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.ConfigurationPolicy;
import org.osgi.service.component.annotations.Deactivate;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
/**
* TimescaleDB persistence service for openHAB.
*
* <p>
* Implements {@link ModifiablePersistenceService} to support store, query, and remove operations
* against a TimescaleDB (PostgreSQL extension) hypertable.
*
* <p>
* Item names are cached in-memory ({@code name → item_id}) to avoid a SELECT on every
* {@link #store} call. The cache is populated lazily on first store per item.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
@Component(service = { PersistenceService.class, QueryablePersistenceService.class, ModifiablePersistenceService.class,
TimescaleDBPersistenceService.class }, configurationPid = "org.openhab.timescaledb", configurationPolicy = ConfigurationPolicy.REQUIRE, property = Constants.SERVICE_PID
+ "=org.openhab.timescaledb")
@ConfigurableService(category = "persistence", label = "TimescaleDB Persistence Service", description_uri = TimescaleDBPersistenceService.CONFIG_URI)
public class TimescaleDBPersistenceService implements ModifiablePersistenceService {
static final String CONFIG_URI = "persistence:timescaledb";
static final String CONFIGURATION_PID = "org.openhab.timescaledb";
private static final Logger LOGGER = LoggerFactory.getLogger(TimescaleDBPersistenceService.class);
private static final String THREAD_POOL_NAME = "timescaledb";
private static final String SERVICE_ID = "timescaledb";
private static final String SERVICE_LABEL = "TimescaleDB";
// item name → item_id, populated lazily
private final Map<String, Integer> itemIdCache = new ConcurrentHashMap<>();
private final ItemRegistry itemRegistry;
private final TimescaleDBMetadataService metadataService;
private @Nullable HikariDataSource dataSource;
private @Nullable ScheduledFuture<?> downsampleJob;
private @Nullable TimescaleDBDownsampleJob downsampleJobInstance;
@Activate
public TimescaleDBPersistenceService(final @Reference ItemRegistry itemRegistry,
final @Reference TimescaleDBMetadataService metadataService) {
this.itemRegistry = itemRegistry;
this.metadataService = metadataService;
}
/** Package-private constructor for unit tests — skips OSGi activation, allows injecting a DataSource. */
TimescaleDBPersistenceService(ItemRegistry itemRegistry, TimescaleDBMetadataService metadataService,
@Nullable HikariDataSource dataSource) {
this.itemRegistry = itemRegistry;
this.metadataService = metadataService;
this.dataSource = dataSource;
}
@Activate
public void activate(final Map<String, Object> config) {
String url = (String) config.getOrDefault("url", "");
if (url.isBlank()) {
LOGGER.warn("TimescaleDB persistence not configured: missing 'url'. "
+ "Configure org.openhab.timescaledb:url.");
return;
}
String user = (String) config.getOrDefault("user", "openhab");
String password = (String) config.getOrDefault("password", "");
int maxConnections = parseIntConfig(config, "maxConnections", 5);
int connectTimeout = parseIntConfig(config, "connectTimeout", 5000);
String chunkInterval = (String) config.getOrDefault("chunkInterval", "7 days");
int retentionDays = parseIntConfig(config, "retentionDays", 0);
int compressionAfterDays = parseIntConfig(config, "compressionAfterDays", 0);
LOGGER.debug(
"Activating TimescaleDB persistence: url={}, user={}, maxConnections={}, "
+ "chunkInterval={}, retentionDays={}, compressionAfterDays={}",
url, user, maxConnections, chunkInterval, retentionDays, compressionAfterDays);
HikariDataSource ds;
try {
ds = createDataSource(url, user, password, maxConnections, connectTimeout);
} catch (Exception e) {
LOGGER.error("Failed to create TimescaleDB connection pool: {}", e.getMessage(), e);
return;
}
dataSource = ds;
try (Connection conn = ds.getConnection()) {
TimescaleDBSchema.initialize(conn, chunkInterval, compressionAfterDays, retentionDays);
} catch (SQLException e) {
LOGGER.error("Failed to initialize TimescaleDB schema: {}", e.getMessage(), e);
ds.close();
dataSource = null;
return;
}
if (compressionAfterDays > 0) {
LOGGER.warn("TimescaleDB: compressionAfterDays={} is set. Ensure all per-item retainRawDays "
+ "are less than compressionAfterDays, otherwise downsampling will attempt to write into "
+ "already-compressed (read-only) chunks and cause SQLExceptions.", compressionAfterDays);
}
// Schedule the daily downsampling job via the openHAB shared thread pool
TimescaleDBDownsampleJob job = new TimescaleDBDownsampleJob(ds, metadataService);
downsampleJobInstance = job;
long initialDelay = secondsUntilMidnight();
downsampleJob = ThreadPoolManager.getScheduledPool(THREAD_POOL_NAME).scheduleWithFixedDelay(job, initialDelay,
TimeUnit.DAYS.toSeconds(1), TimeUnit.SECONDS);
LOGGER.info("Downsampling job scheduled: first run in {}s, then every 24h", initialDelay);
LOGGER.info("TimescaleDB persistence service activated");
}
/**
* Triggers the downsampling job immediately in the calling thread.
* Intended for use by the Karaf console command for on-demand testing.
*
* @return {@code true} if the job ran, {@code false} if the service is not yet activated.
*/
public boolean runDownsampleNow() {
TimescaleDBDownsampleJob job = downsampleJobInstance;
if (job == null) {
return false;
}
job.run();
return true;
}
@Deactivate
public void deactivate() {
LOGGER.debug("Deactivating TimescaleDB persistence service");
itemIdCache.clear();
ScheduledFuture<?> job = downsampleJob;
if (job != null) {
job.cancel(false);
downsampleJob = null;
}
downsampleJobInstance = null;
HikariDataSource ds = dataSource;
if (ds != null) {
ds.close();
dataSource = null;
}
LOGGER.info("TimescaleDB persistence service deactivated");
}
// -------------------------------------------------------------------------
// PersistenceService
// -------------------------------------------------------------------------
@Override
public String getId() {
return SERVICE_ID;
}
@Override
public String getLabel(@Nullable Locale locale) {
return SERVICE_LABEL;
}
@Override
public List<PersistenceStrategy> getSuggestedStrategies() {
return Collections.emptyList();
}
@Override
public void store(Item item) {
store(item, null);
}
@Override
public void store(Item item, @Nullable String alias) {
store(item, ZonedDateTime.now(), item.getState(), alias);
}
// -------------------------------------------------------------------------
// ModifiablePersistenceService (includes QueryablePersistenceService)
// -------------------------------------------------------------------------
@Override
public void store(Item item, ZonedDateTime date, State state) {
store(item, date, state, null);
}
@Override
public void store(Item item, ZonedDateTime date, State state, @Nullable String alias) {
if (state instanceof UnDefType) {
LOGGER.trace("Skipping store for item '{}': state is UnDefType", item.getName());
return;
}
TimescaleDBMapper.Row row = TimescaleDBMapper.toRow(state);
if (row == null) {
// Warning is already logged by the mapper
return;
}
String name = alias != null ? alias : item.getName();
@Nullable
String label = item.getLabel();
HikariDataSource ds = dataSource;
if (ds == null) {
LOGGER.warn("TimescaleDB data source not available — cannot store item '{}'", name);
return;
}
try (Connection conn = ds.getConnection()) {
int itemId = getOrCreateItemId(conn, name, label);
TimescaleDBQuery.insert(conn, itemId, date, row);
} catch (SQLException e) {
LOGGER.error("Failed to store item '{}': {}", name, e.getMessage(), e);
}
}
@Override
public Iterable<HistoricItem> query(FilterCriteria filter) {
return query(filter, null);
}
@Override
public Iterable<HistoricItem> query(FilterCriteria filter, @Nullable String alias) {
String itemName = filter.getItemName();
if (itemName == null) {
LOGGER.warn("FilterCriteria has no item name — returning empty query result");
return Collections.emptyList();
}
String queryName = alias != null ? alias : itemName;
@Nullable
Integer itemId = itemIdCache.get(queryName);
if (itemId == null) {
HikariDataSource dsFallback = dataSource;
if (dsFallback == null) {
LOGGER.warn(
"TimescaleDB data source not available while resolving item_id for '{}' — returning empty query result",
queryName);
return Collections.emptyList();
}
try (Connection connFallback = dsFallback.getConnection()) {
Optional<Integer> resolved = TimescaleDBQuery.findItemId(connFallback, queryName);
if (resolved.isEmpty()) {
LOGGER.debug("Item '{}' not present in TimescaleDB — returning empty query result", queryName);
return Collections.emptyList();
}
itemId = resolved.get();
itemIdCache.put(queryName, itemId);
} catch (SQLException e) {
LOGGER.error("Failed to resolve item_id for item '{}': {}", queryName, e.getMessage(), e);
return Collections.emptyList();
}
}
Item item;
try {
item = itemRegistry.getItem(itemName);
} catch (ItemNotFoundException e) {
LOGGER.warn("Item '{}' not found in ItemRegistry — returning empty query result", itemName);
return Collections.emptyList();
}
HikariDataSource ds = dataSource;
if (ds == null) {
LOGGER.warn("TimescaleDB data source not available — returning empty query result");
return Collections.emptyList();
}
try (Connection conn = ds.getConnection()) {
return TimescaleDBQuery.query(conn, item, itemId, filter);
} catch (SQLException e) {
LOGGER.error("Query failed for item '{}': {}", queryName, e.getMessage(), e);
return Collections.emptyList();
}
}
@Override
public boolean remove(FilterCriteria filter) {
String itemName = filter.getItemName();
if (itemName == null) {
LOGGER.warn("FilterCriteria has no item name — cannot remove data");
return false;
}
@Nullable
Integer itemId = itemIdCache.get(itemName);
if (itemId == null) {
HikariDataSource dsFallback = dataSource;
if (dsFallback == null) {
LOGGER.warn(
"TimescaleDB data source not available while resolving item_id for '{}' — cannot remove data",
itemName);
return false;
}
try (Connection connFallback = dsFallback.getConnection()) {
Optional<Integer> resolved = TimescaleDBQuery.findItemId(connFallback, itemName);
if (resolved.isEmpty()) {
LOGGER.debug("Item '{}' not present in TimescaleDB — nothing to remove", itemName);
return false;
}
itemId = resolved.get();
itemIdCache.put(itemName, itemId);
} catch (SQLException e) {
LOGGER.error("Failed to resolve item_id for item '{}': {}", itemName, e.getMessage(), e);
return false;
}
}
HikariDataSource ds = dataSource;
if (ds == null) {
LOGGER.warn("TimescaleDB data source not available — cannot remove data for item '{}'", itemName);
return false;
}
try (Connection conn = ds.getConnection()) {
int deleted = TimescaleDBQuery.remove(conn, itemId, filter);
LOGGER.debug("Removed {} row(s) for item '{}'", deleted, itemName);
return true;
} catch (SQLException e) {
LOGGER.error("Failed to remove data for item '{}': {}", itemName, e.getMessage(), e);
return false;
}
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
private int getOrCreateItemId(Connection conn, String name, @Nullable String label) throws SQLException {
Integer cached = itemIdCache.get(name);
if (cached != null) {
return cached;
}
int id = TimescaleDBQuery.getOrCreateItemId(conn, name, label);
itemIdCache.put(name, id);
return id;
}
private static HikariDataSource createDataSource(String url, String user, String password, int maxConnections,
int connectTimeoutMs) {
HikariConfig cfg = new HikariConfig();
// Explicitly set the driver class name so HikariCP uses Class.forName() in the
// bundle classloader instead of DriverManager.getDriver(). In an OSGi runtime
// DriverManager lives in the boot classloader and cannot see the PostgreSQL driver
// that is embedded in this bundle, which causes "Failed to get driver instance".
cfg.setDriverClassName("org.postgresql.Driver");
cfg.setJdbcUrl(url);
cfg.setUsername(user);
cfg.setPassword(password);
cfg.setMaximumPoolSize(maxConnections);
cfg.setConnectionTimeout(connectTimeoutMs);
cfg.setPoolName("timescaledb-persistence");
return new HikariDataSource(cfg);
}
static int parseIntConfig(Map<String, Object> config, String key, int defaultValue) {
Object val = config.get(key);
if (val == null) {
return defaultValue;
}
try {
return Integer.parseInt(val.toString());
} catch (NumberFormatException e) {
LOGGER.warn("Invalid integer value '{}' for config key '{}', using default {}", val, key, defaultValue);
return defaultValue;
}
}
static long secondsUntilMidnight() {
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime midnight = now.toLocalDate().plusDays(1).atStartOfDay(now.getZone());
return java.time.Duration.between(now, midnight).getSeconds();
}
}
@@ -0,0 +1,276 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item;
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.FilterCriteria.Ordering;
import org.openhab.core.persistence.HistoricItem;
import org.openhab.core.types.State;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* SQL query builder and executor for all TimescaleDB persistence operations.
*
* <p>
* All user-controlled values (item name, timestamps, state values) are passed as
* JDBC {@link PreparedStatement} parameters to prevent SQL injection.
* The only dynamically formatted strings are validated enum/allowlist values
* (SQL operator, ORDER BY direction).
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
public class TimescaleDBQuery {
private static final Logger LOGGER = LoggerFactory.getLogger(TimescaleDBQuery.class);
// --- INSERT ---
// ON CONFLICT DO NOTHING (no conflict target) silently discards duplicate writes.
// TimescaleDB hypertables do not support column-inference conflict targets, so the target-less
// form must be used. The UNIQUE(time, item_id, downsampled) constraint on the table ensures
// that a raw row (downsampled=FALSE) and a downsampled row (downsampled=TRUE) at the same
// timestamp can coexist and only true duplicates are dropped.
private static final String SQL_INSERT = "INSERT INTO items (time, item_id, value, string, unit) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING";
// --- item_meta lookup / insert ---
private static final String SQL_SELECT_ITEM_ID = "SELECT id FROM item_meta WHERE name = ?";
private static final String SQL_INSERT_ITEM_META = "INSERT INTO item_meta (name, label) VALUES (?, ?) ON CONFLICT (name) DO UPDATE SET label = EXCLUDED.label RETURNING id";
// --- SELECT base ---
private static final String SQL_SELECT_BASE = "SELECT time, value, string, unit FROM items WHERE item_id = ?";
// --- DELETE ---
private static final String SQL_DELETE_BASE = "DELETE FROM items WHERE item_id = ?";
private TimescaleDBQuery() {
// utility class
}
/**
* Inserts a single item state row.
*
* @param connection The JDBC connection.
* @param itemId The item_id from {@code item_meta}.
* @param timestamp The measurement timestamp.
* @param row The mapped state row.
* @throws SQLException on any database error.
*/
public static void insert(Connection connection, int itemId, ZonedDateTime timestamp, TimescaleDBMapper.Row row)
throws SQLException {
try (PreparedStatement ps = connection.prepareStatement(SQL_INSERT)) {
ps.setTimestamp(1, Timestamp.from(timestamp.toInstant()));
ps.setInt(2, itemId);
Double value = row.value();
if (value != null) {
ps.setDouble(3, value);
} else {
ps.setNull(3, Types.DOUBLE);
}
ps.setString(4, row.string());
ps.setString(5, row.unit());
ps.executeUpdate();
}
LOGGER.debug("Stored item_id={} at {} value={} string={} unit={}", itemId, timestamp, row.value(), row.string(),
row.unit());
}
/**
* Returns the item_id for the given name, or inserts a new {@code item_meta} row and returns its id.
*
* @param connection The JDBC connection.
* @param name The item name.
* @param label The item label (may be null; stored for informational purposes).
* @return The item_id.
* @throws SQLException on any database error.
*/
public static int getOrCreateItemId(Connection connection, String name, @Nullable String label)
throws SQLException {
// Try SELECT first (fast path for known items)
try (PreparedStatement ps = connection.prepareStatement(SQL_SELECT_ITEM_ID)) {
ps.setString(1, name);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return rs.getInt(1);
}
}
}
// Not found: INSERT with ON CONFLICT DO UPDATE so concurrent calls are safe
try (PreparedStatement ps = connection.prepareStatement(SQL_INSERT_ITEM_META)) {
ps.setString(1, name);
ps.setString(2, label);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
int id = rs.getInt(1);
LOGGER.debug("Registered new item '{}' with item_id={}", name, id);
return id;
}
}
}
throw new SQLException("Failed to get or create item_meta entry for item '" + name + "'");
}
/**
* Queries historic items according to the given filter criteria.
*
* @param connection The JDBC connection.
* @param item The openHAB item (used for state reconstruction).
* @param itemId The item_id from {@code item_meta}.
* @param filter The filter criteria.
* @return An ordered list of matching {@link HistoricItem}s.
* @throws SQLException on any database error.
*/
public static List<HistoricItem> query(Connection connection, Item item, int itemId, FilterCriteria filter)
throws SQLException {
StringBuilder sql = new StringBuilder(SQL_SELECT_BASE);
List<Object> params = new ArrayList<>();
params.add(itemId);
// Date range filters
ZonedDateTime beginDate = filter.getBeginDate();
if (beginDate != null) {
sql.append(" AND time >= ?");
params.add(Timestamp.from(beginDate.toInstant()));
}
ZonedDateTime endDate = filter.getEndDate();
if (endDate != null) {
sql.append(" AND time <= ?");
params.add(Timestamp.from(endDate.toInstant()));
}
// Optional state filter on the value column (numeric states only)
State filterState = filter.getState();
if (filterState != null) {
TimescaleDBMapper.Row filterRow = TimescaleDBMapper.toRow(filterState);
String sqlOp = TimescaleDBMapper.toSqlOperator(filter.getOperator());
Double filterValue = filterRow != null ? filterRow.value() : null;
if (filterValue != null && sqlOp != null) {
sql.append(" AND value ").append(sqlOp).append(" ?");
params.add(filterValue);
} else {
LOGGER.debug("State filter on non-numeric or unsupported state/operator — ignoring");
}
}
// ORDER BY
String direction = filter.getOrdering() == Ordering.ASCENDING ? "ASC" : "DESC";
sql.append(" ORDER BY time ").append(direction);
// Pagination
if (filter.getPageSize() > 0) {
sql.append(" LIMIT ?");
params.add(filter.getPageSize());
if (filter.getPageNumber() > 0) {
sql.append(" OFFSET ?");
params.add((long) filter.getPageNumber() * filter.getPageSize());
}
}
LOGGER.debug("Query SQL: {} params={}", sql, params);
List<HistoricItem> results = new ArrayList<>();
try (PreparedStatement ps = connection.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Timestamp time = rs.getTimestamp(1);
Double value = (Double) rs.getObject(2);
String string = rs.getString(3);
String unit = rs.getString(4);
State state = TimescaleDBMapper.toState(item, value, string, unit);
results.add(new TimescaleDBHistoricItem(item.getName(), state, time.toInstant()));
}
}
}
LOGGER.debug("Query returned {} items for item_id={}", results.size(), itemId);
return results;
}
/**
* Deletes rows matching the filter criteria.
*
* @param connection The JDBC connection.
* @param itemId The item_id from {@code item_meta}.
* @param filter The filter criteria (only date range is evaluated).
* @return The number of deleted rows.
* @throws SQLException on any database error.
*/
public static int remove(Connection connection, int itemId, FilterCriteria filter) throws SQLException {
StringBuilder sql = new StringBuilder(SQL_DELETE_BASE);
List<Object> params = new ArrayList<>();
params.add(itemId);
ZonedDateTime beginDate = filter.getBeginDate();
if (beginDate != null) {
sql.append(" AND time >= ?");
params.add(Timestamp.from(beginDate.toInstant()));
}
ZonedDateTime endDate = filter.getEndDate();
if (endDate != null) {
sql.append(" AND time <= ?");
params.add(Timestamp.from(endDate.toInstant()));
}
LOGGER.debug("Remove SQL: {} params={}", sql, params);
try (PreparedStatement ps = connection.prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
int deleted = ps.executeUpdate();
LOGGER.debug("Deleted {} rows for item_id={}", deleted, itemId);
return deleted;
}
}
/**
* Looks up the item_id for the given name from {@code item_meta}, without creating a new entry.
*
* <p>
* Used by the downsampling job to resolve item names that exist in the DB but are not yet in
* the in-memory cache (e.g. after a bundle restart where no {@code store()} call has been made).
*
* @param connection The JDBC connection.
* @param name The item name.
* @return An {@link java.util.Optional} containing the item_id, or empty if not found.
* @throws SQLException on any database error.
*/
public static java.util.Optional<Integer> findItemId(Connection connection, String name) throws SQLException {
try (PreparedStatement ps = connection.prepareStatement(SQL_SELECT_ITEM_ID)) {
ps.setString(1, name);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return java.util.Optional.of(rs.getInt(1));
}
}
}
return java.util.Optional.empty();
}
}
@@ -0,0 +1,180 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Creates and migrates the TimescaleDB schema on startup.
*
* <p>
* Schema overview:
* <ul>
* <li>{@code item_meta} — name-to-ID lookup table for items</li>
* <li>{@code items} — single hypertable for all item states</li>
* </ul>
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault
public class TimescaleDBSchema {
private static final Logger LOGGER = LoggerFactory.getLogger(TimescaleDBSchema.class);
private static final String SQL_CREATE_ITEM_META = """
CREATE TABLE IF NOT EXISTS item_meta (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""";
private static final String SQL_CREATE_ITEMS = """
CREATE TABLE IF NOT EXISTS items (
time TIMESTAMPTZ NOT NULL,
item_id INTEGER NOT NULL REFERENCES item_meta(id),
value DOUBLE PRECISION,
string TEXT,
unit TEXT,
downsampled BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT items_time_item_id_downsampled_ukey UNIQUE (time, item_id, downsampled)
)
""";
/**
* Migration: replaces any legacy UNIQUE(time, item_id) constraint with the correct
* UNIQUE(time, item_id, downsampled) constraint so that a raw row and a downsampled row
* at the same timestamp can coexist. For fresh installs the correct constraint is already
* part of CREATE TABLE above, so the DO block is a no-op.
*/
private static final String SQL_MIGRATE_ADD_UNIQUE_CONSTRAINT = """
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'items_time_item_id_ukey'
) THEN
ALTER TABLE items DROP CONSTRAINT items_time_item_id_ukey;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'items_time_item_id_downsampled_ukey'
) THEN
ALTER TABLE items ADD CONSTRAINT items_time_item_id_downsampled_ukey UNIQUE (time, item_id, downsampled);
END IF;
END $$
""";
private static final String SQL_CREATE_HYPERTABLE = "SELECT create_hypertable('items', 'time', if_not_exists => TRUE, chunk_time_interval => ?::INTERVAL)";
private static final String SQL_CREATE_INDEX = "CREATE INDEX IF NOT EXISTS items_item_id_time_idx ON items (item_id, time DESC)";
private static final String SQL_CHECK_TIMESCALEDB = "SELECT extname FROM pg_extension WHERE extname = 'timescaledb'";
private static final String SQL_ENABLE_COMPRESSION = """
ALTER TABLE items SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'item_id',
timescaledb.compress_orderby = 'time DESC'
)
""";
private static final String SQL_ADD_COMPRESSION_POLICY = "SELECT add_compression_policy('items', INTERVAL '%d days', if_not_exists => TRUE)";
private static final String SQL_ADD_RETENTION_POLICY = "SELECT add_retention_policy('items', INTERVAL '%d days', if_not_exists => TRUE)";
private TimescaleDBSchema() {
// utility class
}
/**
* Initializes the full schema. Throws {@link SQLException} if the TimescaleDB extension
* is not installed or if any DDL statement fails.
*
* @param connection An open JDBC connection.
* @param chunkInterval Chunk interval for the hypertable, e.g. {@code "7 days"}.
* @param compressionAfterDays Compress chunks older than N days. 0 = disabled.
* @param retentionDays Drop data older than N days via retention policy. 0 = disabled.
* @throws SQLException on any database error, including missing TimescaleDB extension.
*/
public static void initialize(Connection connection, String chunkInterval, int compressionAfterDays,
int retentionDays) throws SQLException {
checkTimescaleDBExtension(connection);
createTables(connection, chunkInterval);
if (compressionAfterDays > 0) {
setupCompression(connection, compressionAfterDays);
}
if (retentionDays > 0) {
setupRetentionPolicy(connection, retentionDays);
}
LOGGER.info("TimescaleDB schema initialized (chunkInterval={}, compression={}d, retention={}d)", chunkInterval,
compressionAfterDays, retentionDays);
}
private static void checkTimescaleDBExtension(Connection connection) throws SQLException {
try (Statement stmt = connection.createStatement(); ResultSet rs = stmt.executeQuery(SQL_CHECK_TIMESCALEDB)) {
if (!rs.next()) {
throw new SQLException("TimescaleDB extension is not installed in the target database. "
+ "Run 'CREATE EXTENSION IF NOT EXISTS timescaledb;' first.");
}
LOGGER.debug("TimescaleDB extension found");
}
}
private static void createTables(Connection connection, String chunkInterval) throws SQLException {
try (Statement stmt = connection.createStatement()) {
stmt.execute(SQL_CREATE_ITEM_META);
LOGGER.debug("Table item_meta ready");
stmt.execute(SQL_CREATE_ITEMS);
LOGGER.debug("Table items ready");
}
try (PreparedStatement ps = connection.prepareStatement(SQL_CREATE_HYPERTABLE)) {
ps.setString(1, chunkInterval);
ps.execute();
LOGGER.debug("Hypertable configured with chunk interval '{}'", chunkInterval);
}
try (Statement stmt = connection.createStatement()) {
stmt.execute(SQL_CREATE_INDEX);
LOGGER.debug("Index on (item_id, time DESC) ready");
stmt.execute(SQL_MIGRATE_ADD_UNIQUE_CONSTRAINT);
LOGGER.debug("UNIQUE(time, item_id, downsampled) constraint ensured");
}
}
private static void setupCompression(Connection connection, int compressionAfterDays) throws SQLException {
try (Statement stmt = connection.createStatement()) {
stmt.execute(SQL_ENABLE_COMPRESSION);
stmt.execute(SQL_ADD_COMPRESSION_POLICY.formatted(compressionAfterDays));
LOGGER.info("Compression policy set: compress after {} days", compressionAfterDays);
}
}
private static void setupRetentionPolicy(Connection connection, int retentionDays) throws SQLException {
try (Statement stmt = connection.createStatement()) {
stmt.execute(SQL_ADD_RETENTION_POLICY.formatted(retentionDays));
LOGGER.info("Retention policy set: drop data older than {} days", retentionDays);
}
}
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="timescaledb" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>persistence</type>
<name>TimescaleDB Persistence</name>
<description>This is the persistence add-on for TimescaleDB.</description>
<connection>local</connection>
<config-description-ref uri="persistence:timescaledb"/>
</addon:addon>
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0
https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="persistence:timescaledb">
<parameter-group name="connection">
<label>Connection</label>
<advanced>false</advanced>
</parameter-group>
<parameter-group name="tuning">
<label>Tuning</label>
<advanced>true</advanced>
</parameter-group>
<parameter name="url" type="text" required="true" groupName="connection">
<context>url</context>
<label>JDBC URL</label>
<description>JDBC connection URL, e.g. jdbc:postgresql://localhost:5432/openhab</description>
</parameter>
<parameter name="user" type="text" required="false" groupName="connection">
<label>Username</label>
<description>Database username</description>
<default>openhab</default>
</parameter>
<parameter name="password" type="text" required="true" groupName="connection">
<context>password</context>
<label>Password</label>
<description>Database password</description>
</parameter>
<parameter name="chunkInterval" type="text" required="false" groupName="tuning">
<label>Chunk Interval</label>
<description>TimescaleDB hypertable chunk interval (e.g. "7 days", "1 day")</description>
<default>7 days</default>
</parameter>
<parameter name="retentionDays" type="integer" required="false" groupName="tuning" min="0">
<label>Retention (days)</label>
<description>Drop all data older than N days. 0 = disabled.</description>
<default>0</default>
</parameter>
<parameter name="compressionAfterDays" type="integer" required="false" groupName="tuning" min="0">
<label>Compress After (days)</label>
<description>Compress chunks older than N days. 0 = disabled.</description>
<default>0</default>
</parameter>
<parameter name="maxConnections" type="integer" required="false" groupName="tuning" min="1">
<label>Max Connections</label>
<description>Maximum number of database connections in the pool</description>
<default>5</default>
</parameter>
<parameter name="connectTimeout" type="integer" required="false" groupName="tuning" min="0">
<label>Connect Timeout (ms)</label>
<description>Connection timeout in milliseconds</description>
<default>5000</default>
</parameter>
</config-description>
</config-description:config-descriptions>
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.jar.Manifest;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* Verifies structural requirements of the OSGi bundle to ensure it deploys and integrates
* correctly into the openHAB runtime and UI.
*
* <ul>
* <li>Checks that all {@code Import-Package} entries are available in the openHAB OSGi
* runtime (prevents "Unresolved requirement" errors on startup).</li>
* <li>Checks that {@code OH-INF/addon/addon.xml} exists (required for the addon to appear
* in the openHAB UI and settings).</li>
* </ul>
*
* @author Contributors to the openHAB project - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
class BundleManifestTest {
/**
* Package prefixes known to be available in the openHAB OSGi runtime.
* Each entry must end with '.' so that e.g. "org.slf4j." matches both
* the exact package "org.slf4j" and any sub-package "org.slf4j.Logger".
*/
private static final Set<String> KNOWN_OSGI_PACKAGES = Set.of("java.", // Java SE
"javax.", // Java SE extensions
"org.osgi.", // OSGi framework
"org.slf4j.", // SLF4J (embedded in openHAB core)
"org.openhab.", // openHAB core
"org.w3c.dom.", // Java XML DOM
"org.xml.sax.", // Java XML SAX
"org.ietf.jgss.", // Java GSSAPI
"io.micrometer." // Micrometer metrics (embedded in openHAB core)
);
@Test
void bundleManifestImportsOnlyPackagesAvailableInOpenHABRuntime() throws IOException {
Path manifestPath = Path.of("target/classes/META-INF/MANIFEST.MF");
assumeTrue(Files.exists(manifestPath), "MANIFEST.MF not yet generated — run 'mvn compile' first");
String importPackageHeader;
try (InputStream in = Files.newInputStream(manifestPath)) {
Manifest manifest = new Manifest(in);
importPackageHeader = manifest.getMainAttributes().getValue("Import-Package");
}
assertNotNull(importPackageHeader, "Import-Package header must be present in MANIFEST.MF");
// Append '.' so that e.g. "org.slf4j" matches prefix "org.slf4j."
// and "org.slf4j.Logger" also matches "org.slf4j.".
List<String> unknownPackages = parsePackageNames(importPackageHeader).stream()
.filter(pkg -> KNOWN_OSGI_PACKAGES.stream().noneMatch(known -> (pkg + ".").startsWith(known))).toList();
assertTrue(unknownPackages.isEmpty(), () -> """
Bundle imports packages not available in the openHAB OSGi runtime: %s
These are likely optional features of embedded libraries (e.g. GSSAPI auth, \
bytecode weaving).
Fix: add '!the.package.*' to <bnd.importpackage> in pom.xml for each package.
Example: add ',!%s.*' to the existing bnd.importpackage property.
""".formatted(unknownPackages, unknownPackages.get(0)));
}
@Test
void servicePidFollowsOpenHABConvention() {
assertTrue(TimescaleDBPersistenceService.CONFIGURATION_PID.matches("org\\.openhab\\.[a-z]+"),
"CONFIGURATION_PID must be 'org.openhab.<name>' (no extra segments) so that "
+ "timescaledb.cfg is resolved correctly by the openHAB ConfigDispatcher. Got: "
+ TimescaleDBPersistenceService.CONFIGURATION_PID);
}
@Test
void addonXmlExists() {
assertTrue(Files.exists(Path.of("src/main/resources/OH-INF/addon/addon.xml")),
"OH-INF/addon/addon.xml is missing — the addon will not appear in the openHAB UI. "
+ "Create src/main/resources/OH-INF/addon/addon.xml.");
}
/**
* Parses the OSGi {@code Import-Package} header value into a list of package names,
* stripping directives and attributes (e.g. {@code version="[1.0,2)"}).
*
* <p>
* The header is comma-separated, but commas also appear inside quoted version ranges
* like {@code version="[1.16,2)"}. This parser handles quoted strings correctly.
*/
static List<String> parsePackageNames(String importPackageHeader) {
List<String> packages = new ArrayList<>();
int start = 0;
boolean inQuotes = false;
for (int i = 0; i < importPackageHeader.length(); i++) {
char c = importPackageHeader.charAt(i);
if (c == '"') {
inQuotes = !inQuotes;
} else if (c == ',' && !inQuotes) {
addPackageName(packages, importPackageHeader.substring(start, i));
start = i + 1;
}
}
addPackageName(packages, importPackageHeader.substring(start));
return packages;
}
private static void addPackageName(List<String> result, String clause) {
String trimmed = clause.trim();
if (trimmed.isEmpty()) {
return;
}
// A clause is "package.name;attr=val;directive:=val" — take only the package name part
int semicolon = trimmed.indexOf(';');
result.add(semicolon >= 0 ? trimmed.substring(0, semicolon).trim() : trimmed);
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link DownsampleConfig}.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
class DownsampleConfigTest {
// --- retentionOnly() guard (Fix 6) ---
@Test
void retentionOnlyWithPositiveDaysSucceeds() {
DownsampleConfig cfg = DownsampleConfig.retentionOnly(30);
assertFalse(cfg.hasDownsampling());
assertNull(cfg.function());
assertNull(cfg.sqlInterval());
assertEquals(30, cfg.retentionDays());
assertEquals(0, cfg.retainRawDays());
}
@Test
void retentionOnlyWithZeroDaysThrows() {
assertThrows(IllegalArgumentException.class, () -> DownsampleConfig.retentionOnly(0),
"retentionDays=0 must throw — a zero-day retention policy would delete everything immediately");
}
@Test
void retentionOnlyWithNegativeDaysThrows() {
assertThrows(IllegalArgumentException.class, () -> DownsampleConfig.retentionOnly(-1),
"Negative retentionDays must throw — would generate dangerous SQL window");
}
@Test
void retentionOnlyExceptionMessageMentionsValue() {
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> DownsampleConfig.retentionOnly(-5));
String msg = ex.getMessage();
assertNotNull(msg, "Exception must have a message");
assertTrue(msg.contains("-5"), "Exception message should include the rejected value");
}
// --- toSqlInterval() allowlist ---
@Test
void toSqlIntervalValidIntervalReturnsLiteral() {
assertEquals("1 hour", DownsampleConfig.toSqlInterval("1h"));
assertEquals("1 day", DownsampleConfig.toSqlInterval("1d"));
assertEquals("15 minutes", DownsampleConfig.toSqlInterval("15m"));
}
@Test
void toSqlIntervalUnknownIntervalThrows() {
assertThrows(IllegalArgumentException.class, () -> DownsampleConfig.toSqlInterval("99x"));
}
// --- hasDownsampling() ---
@Test
void hasDownsamplingTrueForFullConfig() {
DownsampleConfig cfg = new DownsampleConfig(AggregationFunction.AVG, "1 hour", 5, 365);
assertTrue(cfg.hasDownsampling());
}
@Test
void hasDownsamplingFalseForRetentionOnly() {
DownsampleConfig cfg = DownsampleConfig.retentionOnly(90);
assertFalse(cfg.hasDownsampling());
}
}
@@ -0,0 +1,795 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.stream.StreamSupport;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.openhab.core.items.GroupItem;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
import org.openhab.core.library.items.CallItem;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.ContactItem;
import org.openhab.core.library.items.DateTimeItem;
import org.openhab.core.library.items.DimmerItem;
import org.openhab.core.library.items.ImageItem;
import org.openhab.core.library.items.LocationItem;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.items.PlayerItem;
import org.openhab.core.library.items.RollershutterItem;
import org.openhab.core.library.items.StringItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringListType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.FilterCriteria.Ordering;
import org.openhab.core.persistence.HistoricItem;
import org.openhab.core.types.State;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
/**
* Integration tests that run against a real TimescaleDB instance via Testcontainers.
*
* <p>
* These tests verify end-to-end behavior: schema creation, store/query round-trips for
* all state types, downsampling job execution, and the {@code remove()} operation.
*
* <p>
* Tag: {@code integration} — can be excluded from fast unit-test runs with
* {@code mvn test -Dgroups='!integration'}.
*
* @author René Ulbricht - Initial contribution
*/
@Tag("integration")
@Testcontainers
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
@SuppressWarnings("null")
class TimescaleDBContainerTest {
@Container
static final PostgreSQLContainer DB;
static {
var container = new PostgreSQLContainer(
DockerImageName.parse("timescale/timescaledb:latest-pg16").asCompatibleSubstituteFor("postgres"));
container.withDatabaseName("openhab_test");
container.withUsername("openhab");
container.withPassword("openhab");
DB = container;
}
private static HikariDataSource dataSource;
@BeforeAll
static void setUpDataSource() {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(DB.getJdbcUrl());
cfg.setUsername(DB.getUsername());
cfg.setPassword(DB.getPassword());
cfg.setMaximumPoolSize(3);
dataSource = new HikariDataSource(cfg);
}
@BeforeEach
void initSchema() throws SQLException {
// Drop and recreate for test isolation
try (Connection conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS items CASCADE");
stmt.execute("DROP TABLE IF EXISTS item_meta CASCADE");
}
try (Connection conn = dataSource.getConnection()) {
TimescaleDBSchema.initialize(conn, "7 days", 0, 0);
}
}
// ------------------------------------------------------------------
// Schema
// ------------------------------------------------------------------
@Test
@Order(1)
void schemaHypertableexists() throws SQLException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn
.prepareStatement("SELECT hypertable_name FROM timescaledb_information.hypertables "
+ "WHERE hypertable_name = 'items'");
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next(), "Hypertable 'items' should exist");
}
}
@Test
@Order(2)
void schemaItemmetatableexists() throws SQLException {
try (Connection conn = dataSource.getConnection();
var stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM item_meta")) {
assertTrue(rs.next());
assertEquals(0, rs.getInt(1), "item_meta should be empty after init");
}
}
// ------------------------------------------------------------------
// Store / query round-trips — all state types
// ------------------------------------------------------------------
@Test
@Order(10)
void roundTripDecimaltype() throws SQLException {
storeAndVerify("DecimalSensor", new NumberItem("DecimalSensor"), new DecimalType(42.5),
s -> assertInstanceOf(DecimalType.class, s),
s -> assertEquals(42.5, ((DecimalType) s).doubleValue(), 1e-6));
}
@Test
@Order(11)
void roundTripQuantitytypeTemperature() throws SQLException {
storeAndVerify("TempSensor", new NumberItem("TempSensor"), new QuantityType<>("23.4 °C"),
s -> assertInstanceOf(QuantityType.class, s), s -> assertEquals("23.4 °C", s.toString()));
}
@Test
@Order(12)
void roundTripOnofftype() throws SQLException {
storeAndVerify("Switch1", new SwitchItem("Switch1"), OnOffType.ON, s -> assertEquals(OnOffType.ON, s));
storeAndVerify("Switch2", new SwitchItem("Switch2"), OnOffType.OFF, s -> assertEquals(OnOffType.OFF, s));
}
@Test
@Order(13)
void roundTripOpenclosedtype() throws SQLException {
storeAndVerify("Contact1", new ContactItem("Contact1"), OpenClosedType.OPEN,
s -> assertEquals(OpenClosedType.OPEN, s));
}
@Test
@Order(14)
void roundTripPercenttype() throws SQLException {
storeAndVerify("Dimmer1", new DimmerItem("Dimmer1"), new PercentType(75),
s -> assertInstanceOf(PercentType.class, s), s -> assertEquals(75, ((PercentType) s).intValue()));
}
@Test
@Order(15)
void roundTripHsbtype() throws SQLException {
storeAndVerify("Color1", new ColorItem("Color1"), new HSBType("120,50,80"),
s -> assertInstanceOf(HSBType.class, s), s -> assertEquals("120,50,80", s.toString()));
}
@Test
@Order(16)
void roundTripDatetimetype() throws SQLException {
ZonedDateTime dt = ZonedDateTime.parse("2024-06-01T10:00:00+02:00");
storeAndVerify("DateTime1", new DateTimeItem("DateTime1"), new DateTimeType(dt),
s -> assertInstanceOf(DateTimeType.class, s));
}
@Test
@Order(17)
void roundTripStringtype() throws SQLException {
storeAndVerify("String1", new StringItem("String1"), new StringType("hello world"),
s -> assertEquals("hello world", s.toString()));
}
@Test
@Order(18)
void roundTripPointtypeLocationitem() throws SQLException {
storeAndVerify("Location1", new LocationItem("Location1"), new PointType("52.5200,13.4050,34.0000"),
s -> assertInstanceOf(PointType.class, s), s -> {
PointType pt = (PointType) s;
assertEquals(52.52, pt.getLatitude().doubleValue(), 1e-3);
assertEquals(13.405, pt.getLongitude().doubleValue(), 1e-3);
});
}
@Test
@Order(19)
void roundTripPlaypausetypePlayeritem() throws SQLException {
storeAndVerify("Player1", new PlayerItem("Player1"), PlayPauseType.PLAY,
s -> assertEquals(PlayPauseType.PLAY, s));
storeAndVerify("Player2", new PlayerItem("Player2"), PlayPauseType.PAUSE,
s -> assertEquals(PlayPauseType.PAUSE, s));
}
@Test
@Order(20)
void roundTripStringlisttypeCallitem() throws SQLException {
storeAndVerify("Call1", new CallItem("Call1"), new StringListType("Alice", "Bob"),
s -> assertInstanceOf(StringListType.class, s),
s -> assertEquals(new StringListType("Alice", "Bob").toString(), s.toString()));
}
@Test
@Order(21)
void roundTripRawtypeImageitem() throws SQLException {
byte[] bytes = { (byte) 0x89, 0x50, 0x4E, 0x47 }; // PNG magic bytes
storeAndVerify("Image1", new ImageItem("Image1"), new RawType(bytes, "image/png"),
s -> assertInstanceOf(RawType.class, s), s -> {
RawType raw = (RawType) s;
assertEquals("image/png", raw.getMimeType());
assertArrayEquals(bytes, raw.getBytes());
});
}
@Test
@Order(22)
void roundTripUpdowntypeRollershutteritem() throws SQLException {
storeAndVerify("Roller2", new RollershutterItem("Roller2"), new PercentType(30),
s -> assertInstanceOf(PercentType.class, s), s -> assertEquals(30, ((PercentType) s).intValue()));
}
@Test
@Order(23)
void roundTripGroupitemWithnumberbase() throws SQLException {
var baseItem = new NumberItem("GBase");
var groupItem = new GroupItem("Group1", baseItem);
storeAndVerify("Group1", groupItem, new DecimalType(55.5), s -> assertInstanceOf(DecimalType.class, s),
s -> assertEquals(55.5, ((DecimalType) s).doubleValue(), 1e-6));
}
@Test
@Order(24)
void roundTripGroupitemWithswitchbase() throws SQLException {
var baseItem = new SwitchItem("GBase");
var groupItem = new GroupItem("GroupSwitch1", baseItem);
storeAndVerify("GroupSwitch1", groupItem, OnOffType.ON, s -> assertEquals(OnOffType.ON, s));
}
@Test
@Order(25)
void roundTripGroupitemWithcolorbase() throws SQLException {
var baseItem = new ColorItem("GBase");
var groupItem = new GroupItem("GroupColor1", baseItem);
storeAndVerify("GroupColor1", groupItem, new HSBType("240,100,50"), s -> assertInstanceOf(HSBType.class, s),
s -> assertEquals("240,100,50", s.toString()));
}
// ------------------------------------------------------------------
// Query — filtering and ordering
// ------------------------------------------------------------------
@Test
@Order(31)
void queryDaterangeReturnsonlyrowsinrange() throws SQLException {
ZonedDateTime t1 = ZonedDateTime.now().minusDays(10);
ZonedDateTime t2 = ZonedDateTime.now().minusDays(5);
ZonedDateTime t3 = ZonedDateTime.now().minusDays(1);
NumberItem item = new NumberItem("RangeSensor");
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "RangeSensor", null);
TimescaleDBQuery.insert(conn, id, t1, new TimescaleDBMapper.Row(1.0, null, null));
TimescaleDBQuery.insert(conn, id, t2, new TimescaleDBMapper.Row(2.0, null, null));
TimescaleDBQuery.insert(conn, id, t3, new TimescaleDBMapper.Row(3.0, null, null));
FilterCriteria filter = new FilterCriteria();
filter.setItemName("RangeSensor");
filter.setBeginDate(ZonedDateTime.now().minusDays(7));
filter.setEndDate(ZonedDateTime.now().minusDays(2));
List<HistoricItem> results = TimescaleDBQuery.query(conn, item, id, filter);
assertEquals(1, results.size(), "Only t2 should be in range");
assertEquals(2.0, ((DecimalType) results.get(0).getState()).doubleValue(), 1e-6);
}
}
@Test
@Order(32)
void queryOrderingAscending() throws SQLException {
NumberItem item = new NumberItem("OrderSensor");
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "OrderSensor", null);
ZonedDateTime base = ZonedDateTime.now().minusHours(3);
TimescaleDBQuery.insert(conn, id, base.plusHours(2), new TimescaleDBMapper.Row(3.0, null, null));
TimescaleDBQuery.insert(conn, id, base, new TimescaleDBMapper.Row(1.0, null, null));
TimescaleDBQuery.insert(conn, id, base.plusHours(1), new TimescaleDBMapper.Row(2.0, null, null));
FilterCriteria filter = new FilterCriteria();
filter.setItemName("OrderSensor");
filter.setOrdering(Ordering.ASCENDING);
List<HistoricItem> results = TimescaleDBQuery.query(conn, item, id, filter);
assertEquals(3, results.size());
assertEquals(1.0, ((DecimalType) results.get(0).getState()).doubleValue(), 1e-6);
assertEquals(3.0, ((DecimalType) results.get(2).getState()).doubleValue(), 1e-6);
}
}
@Test
@Order(33)
void queryPaginationLimitsresults() throws SQLException {
NumberItem item = new NumberItem("PageSensor");
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "PageSensor", null);
ZonedDateTime base = ZonedDateTime.now().minusHours(10);
for (int i = 0; i < 10; i++) {
TimescaleDBQuery.insert(conn, id, base.plusHours(i), new TimescaleDBMapper.Row((double) i, null, null));
}
FilterCriteria filter = new FilterCriteria();
filter.setItemName("PageSensor");
filter.setOrdering(Ordering.ASCENDING);
filter.setPageSize(3);
filter.setPageNumber(0);
List<HistoricItem> page0 = TimescaleDBQuery.query(conn, item, id, filter);
assertEquals(3, page0.size());
filter.setPageNumber(1);
List<HistoricItem> page1 = TimescaleDBQuery.query(conn, item, id, filter);
assertEquals(3, page1.size());
// Pages should not overlap
assertNotEquals(page0.get(0).getTimestamp(), page1.get(0).getTimestamp());
}
}
// ------------------------------------------------------------------
// Duplicate timestamp handling
// ------------------------------------------------------------------
@Test
@Order(29)
void insertDuplicatetimestampIssilentlyIgnored() throws SQLException {
NumberItem item = new NumberItem("DupSensor");
ZonedDateTime ts = ZonedDateTime.now().minusHours(1);
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "DupSensor", null);
TimescaleDBQuery.insert(conn, id, ts, new TimescaleDBMapper.Row(1.0, null, null));
// Second write for the same (time, item_id) — must be silently ignored
TimescaleDBQuery.insert(conn, id, ts, new TimescaleDBMapper.Row(99.0, null, null));
FilterCriteria filter = new FilterCriteria();
filter.setItemName("DupSensor");
List<HistoricItem> results = TimescaleDBQuery.query(conn, item, id, filter);
assertEquals(1, results.size(), "Duplicate timestamp must be silently ignored, only one row stored");
assertEquals(1.0, ((DecimalType) results.get(0).getState()).doubleValue(), 1e-6,
"First value must be kept, the duplicate discarded");
}
}
// ------------------------------------------------------------------
// remove
// ------------------------------------------------------------------
@Test
@Order(30)
void removeDeletesbydaterange() throws SQLException {
NumberItem item = new NumberItem("DeleteSensor");
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "DeleteSensor", null);
ZonedDateTime base = ZonedDateTime.now().minusDays(5);
TimescaleDBQuery.insert(conn, id, base, new TimescaleDBMapper.Row(1.0, null, null));
TimescaleDBQuery.insert(conn, id, base.plusDays(1), new TimescaleDBMapper.Row(2.0, null, null));
TimescaleDBQuery.insert(conn, id, base.plusDays(4), new TimescaleDBMapper.Row(3.0, null, null));
FilterCriteria filter = new FilterCriteria();
filter.setItemName("DeleteSensor");
filter.setBeginDate(base.minusMinutes(1));
filter.setEndDate(base.plusDays(2));
int deleted = TimescaleDBQuery.remove(conn, id, filter);
assertEquals(2, deleted, "Should delete the first two rows");
FilterCriteria allFilter = new FilterCriteria();
allFilter.setItemName("DeleteSensor");
List<HistoricItem> remaining = TimescaleDBQuery.query(conn, item, id, allFilter);
assertEquals(1, remaining.size(), "One row should remain");
assertEquals(3.0, ((DecimalType) remaining.get(0).getState()).doubleValue(), 1e-6);
}
}
// ------------------------------------------------------------------
// item_id caching
// ------------------------------------------------------------------
@Test
@Order(40)
void itemIdCacheSamenamereturnssameid() throws SQLException {
try (Connection conn = dataSource.getConnection()) {
int id1 = TimescaleDBQuery.getOrCreateItemId(conn, "CacheSensor", "label");
int id2 = TimescaleDBQuery.getOrCreateItemId(conn, "CacheSensor", "label");
assertEquals(id1, id2, "Same name should always return the same item_id");
}
}
// ------------------------------------------------------------------
// Duplicate / constraint behaviour
// ------------------------------------------------------------------
@Test
@Order(45)
void insertDuplicateRawrowIsSilentlydroppedNoerror() throws SQLException {
// Insert the same (time, item_id) twice as raw rows.
// ON CONFLICT DO NOTHING must swallow the conflict silently — no exception.
ZonedDateTime ts = ZonedDateTime.now().minusHours(1);
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "DupSensor", null);
TimescaleDBQuery.insert(conn, id, ts, new TimescaleDBMapper.Row(1.0, null, null));
assertDoesNotThrow(() -> TimescaleDBQuery.insert(conn, id, ts, new TimescaleDBMapper.Row(2.0, null, null)),
"Second insert with same (time, item_id) must not throw — ON CONFLICT DO NOTHING");
}
// Only one row must exist (the first write wins)
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT COUNT(*), value FROM items WHERE item_id = (SELECT id FROM item_meta WHERE name = 'DupSensor') GROUP BY value");
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next(), "At least one row must exist");
assertEquals(1, rs.getInt(1), "Exactly one raw row must be stored despite two inserts");
assertEquals(1.0, rs.getDouble(2), 1e-6, "First write must win");
assertFalse(rs.next(), "No second row must exist");
}
}
@Test
@Order(46)
void downsampleRowAtBucketBoundaryCoexistsWithRawrow() throws SQLException {
// Regression test for: downsampled row at exact bucket-boundary timestamp used to
// conflict with the raw row at the same timestamp because the UNIQUE constraint was
// only on (time, item_id). With UNIQUE(time, item_id, downsampled) both rows can
// coexist (one with downsampled=FALSE, one with downsampled=TRUE).
//
// Setup: one raw row exactly at the 2h bucket boundary (e.g. 02:00:00), plus one
// raw row inside the same bucket (02:30:00). The downsampling job must produce one
// downsampled row at 02:00:00 WITHOUT silently dropping it due to the raw row there.
ZonedDateTime bucketBoundary = ZonedDateTime.now().minusDays(2).withHour(2).withMinute(0).withSecond(0)
.withNano(0);
ZonedDateTime insideBucket = bucketBoundary.plusMinutes(30);
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "BoundarySensor", null);
// raw row exactly on the bucket boundary
TimescaleDBQuery.insert(conn, id, bucketBoundary, new TimescaleDBMapper.Row(10.0, null, null));
// raw row inside the bucket
TimescaleDBQuery.insert(conn, id, insideBucket, new TimescaleDBMapper.Row(20.0, null, null));
}
MetadataRegistry mr = mock(MetadataRegistry.class);
Metadata meta = new Metadata(new MetadataKey("timescaledb", "BoundarySensor"), "AVG",
Map.of("downsampleInterval", "2h", "retainRawDays", "0"));
when(mr.get(new MetadataKey("timescaledb", "BoundarySensor"))).thenReturn(meta);
when(mr.getAll()).thenAnswer(inv -> List.of(meta));
int[] itemId = new int[1];
try (Connection conn = dataSource.getConnection()) {
itemId[0] = TimescaleDBQuery.getOrCreateItemId(conn, "BoundarySensor", null);
}
new TimescaleDBDownsampleJob(dataSource, new TimescaleDBMetadataService(mr)).run();
try (Connection conn = dataSource.getConnection()) {
// The downsampled row at the bucket boundary must exist (avg of 10.0 and 20.0 = 15.0)
try (PreparedStatement ps = conn
.prepareStatement("SELECT value FROM items WHERE item_id = ? AND downsampled = TRUE")) {
ps.setInt(1, itemId[0]);
try (ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next(), "Downsampled row must exist even when a raw row sits on the bucket boundary");
assertEquals(15.0, rs.getDouble(1), 1e-6,
"Downsampled value must be the average of both raw rows (10 + 20) / 2 = 15");
}
}
}
}
// ------------------------------------------------------------------
// Downsampling job (integration)
// ------------------------------------------------------------------
@Test
@Order(50)
void downsampleJobAggregatesrawrowsanddeletesthem() throws SQLException {
// Seed 6 raw rows: 3 buckets of 2h each, all older than retainRawDays=0 (use 0 for test)
// We use retainRawDays=0 so the job processes all rows immediately (NOW() - 0 days)
// Base is set to :30 past midnight so no raw row lands on a 2h bucket boundary — if a raw row
// shared the exact bucket-boundary timestamp the ON CONFLICT DO NOTHING on the aggregated INSERT
// would silently discard the downsampled row (correct behaviour in production, but not what we
// want to assert here).
ZonedDateTime base = ZonedDateTime.now().minusDays(1).withHour(0).withMinute(30).withSecond(0).withNano(0);
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "DownsampleSensor", null);
// 3 pairs, each pair falls in a different 2h bucket (00:30/01:30, 02:30/03:30, 04:30/05:30)
for (int bucket = 0; bucket < 3; bucket++) {
for (int offset = 0; offset < 2; offset++) {
TimescaleDBQuery.insert(conn, id, base.plusHours(bucket * 2L + offset),
new TimescaleDBMapper.Row((double) (bucket * 10 + offset), null, null));
}
}
}
// Configure metadata for downsampling with retainRawDays=0
MetadataRegistry metadataRegistry = mock(MetadataRegistry.class);
Metadata meta = new Metadata(new MetadataKey("timescaledb", "DownsampleSensor"), "AVG",
Map.of("downsampleInterval", "2h", "retainRawDays", "0"));
when(metadataRegistry.get(new MetadataKey("timescaledb", "DownsampleSensor"))).thenReturn(meta);
when(metadataRegistry.getAll()).thenAnswer(inv -> List.of(meta));
TimescaleDBMetadataService metaService = new TimescaleDBMetadataService(metadataRegistry);
// Resolve item_id for the job
int[] storedId = new int[1];
try (Connection conn = dataSource.getConnection()) {
storedId[0] = TimescaleDBQuery.getOrCreateItemId(conn, "DownsampleSensor", null);
}
TimescaleDBDownsampleJob job = new TimescaleDBDownsampleJob(dataSource, metaService);
job.run();
// Verify: raw rows gone, aggregated rows present
try (Connection conn = dataSource.getConnection()) {
int id = storedId[0];
// No raw rows should remain (retainRawDays=0 means all raw rows are eligible)
try (PreparedStatement ps = conn
.prepareStatement("SELECT COUNT(*) FROM items WHERE item_id = ? AND downsampled = FALSE")) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next());
assertEquals(0, rs.getInt(1), "No raw rows should remain after downsampling");
}
}
// 3 aggregated rows should exist (one per 2h bucket)
try (PreparedStatement ps = conn
.prepareStatement("SELECT COUNT(*) FROM items WHERE item_id = ? AND downsampled = TRUE")) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next());
assertEquals(3, rs.getInt(1), "3 aggregated rows should exist");
}
}
}
}
@Test
@Order(51)
void downsampleJobRetentiondaysDropsolddata() throws SQLException {
ZonedDateTime old = ZonedDateTime.now().minusDays(60);
ZonedDateTime recent = ZonedDateTime.now().minusHours(1);
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, "RetentionSensor", null);
TimescaleDBQuery.insert(conn, id, old, new TimescaleDBMapper.Row(1.0, null, null));
TimescaleDBQuery.insert(conn, id, recent, new TimescaleDBMapper.Row(2.0, null, null));
}
MetadataRegistry mr = mock(MetadataRegistry.class);
// retentionDays=30 → the 60-day-old row should be deleted
Metadata meta = new Metadata(new MetadataKey("timescaledb", "RetentionSensor"), "AVG",
Map.of("downsampleInterval", "1h", "retainRawDays", "0", "retentionDays", "30"));
when(mr.get(new MetadataKey("timescaledb", "RetentionSensor"))).thenReturn(meta);
when(mr.getAll()).thenAnswer(inv -> List.of(meta));
TimescaleDBMetadataService ms = new TimescaleDBMetadataService(mr);
int[] retentionId = new int[1];
try (Connection conn = dataSource.getConnection()) {
retentionId[0] = TimescaleDBQuery.getOrCreateItemId(conn, "RetentionSensor", null);
}
new TimescaleDBDownsampleJob(dataSource, ms).run();
try (Connection conn = dataSource.getConnection()) {
FilterCriteria all = new FilterCriteria();
all.setItemName("RetentionSensor");
List<HistoricItem> remaining = TimescaleDBQuery.query(conn, new NumberItem("RetentionSensor"),
retentionId[0], all);
// Only the recent row should survive (as a downsampled row)
assertEquals(1, remaining.size(), "Old data should have been dropped by retention policy");
}
}
// ------------------------------------------------------------------
// Compression / Retention policies (schema level)
// ------------------------------------------------------------------
@Test
@Order(60)
void schemaCompressionpolicyIsregistered() throws SQLException {
// Re-init schema with compression enabled
try (Connection conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS items CASCADE");
stmt.execute("DROP TABLE IF EXISTS item_meta CASCADE");
}
try (Connection conn = dataSource.getConnection()) {
TimescaleDBSchema.initialize(conn, "7 days", 30, 0);
}
// Verify compression is enabled on the hypertable
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn
.prepareStatement("SELECT compression_enabled FROM timescaledb_information.hypertables "
+ "WHERE hypertable_name = 'items'");
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next(), "Hypertable 'items' should exist");
assertTrue(rs.getBoolean(1), "Compression should be enabled on the hypertable");
}
// Verify a background policy job was registered (table was freshly recreated, so any job is the compression
// job)
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT COUNT(*) FROM timescaledb_information.jobs WHERE hypertable_name = 'items'");
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next());
assertTrue(rs.getInt(1) > 0, "A compression policy job should be registered");
}
}
@Test
@Order(61)
void schemaRetentionpolicyIsregistered() throws SQLException {
try (Connection conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS items CASCADE");
stmt.execute("DROP TABLE IF EXISTS item_meta CASCADE");
}
try (Connection conn = dataSource.getConnection()) {
TimescaleDBSchema.initialize(conn, "7 days", 0, 365);
}
// Verify a background policy job was registered (table was freshly recreated, so any job is the retention job)
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT COUNT(*) FROM timescaledb_information.jobs WHERE hypertable_name = 'items'");
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next());
assertTrue(rs.getInt(1) > 0, "A retention policy job should be registered");
}
}
// ------------------------------------------------------------------
// TimescaleDBPersistenceService — full lifecycle via real DB
// ------------------------------------------------------------------
@Test
@Order(70)
void serviceActivateInitializesschemaandschedulesjob() throws Exception {
MetadataRegistry mr = mock(MetadataRegistry.class);
when(mr.getAll()).thenReturn(Collections.emptyList());
TimescaleDBPersistenceService service = new TimescaleDBPersistenceService(mock(ItemRegistry.class),
new TimescaleDBMetadataService(mr));
service.activate(Map.of("url", DB.getJdbcUrl(), "user", DB.getUsername(), "password", DB.getPassword()));
var dsField = TimescaleDBPersistenceService.class.getDeclaredField("dataSource");
dsField.setAccessible(true);
assertNotNull(dsField.get(service), "DataSource must be initialized after activate()");
var jobField = TimescaleDBPersistenceService.class.getDeclaredField("downsampleJob");
jobField.setAccessible(true);
assertNotNull(jobField.get(service), "Downsampling job must be scheduled after activate()");
service.deactivate();
assertNull(dsField.get(service), "DataSource must be null after deactivate()");
assertNull(jobField.get(service), "Downsampling job must be null after deactivate()");
}
@Test
@Order(71)
void serviceStoreandqueryViaserviceinterface() throws Exception {
MetadataRegistry mr = mock(MetadataRegistry.class);
when(mr.getAll()).thenReturn(Collections.emptyList());
ItemRegistry ir = mock(ItemRegistry.class);
NumberItem item = new NumberItem("ServiceSensor");
when(ir.getItem("ServiceSensor")).thenReturn(item);
TimescaleDBPersistenceService service = new TimescaleDBPersistenceService(ir,
new TimescaleDBMetadataService(mr));
service.activate(Map.of("url", DB.getJdbcUrl(), "user", DB.getUsername(), "password", DB.getPassword()));
try {
service.store(item, ZonedDateTime.now(), new DecimalType(77.7), null);
FilterCriteria filter = new FilterCriteria();
filter.setItemName("ServiceSensor");
List<org.openhab.core.persistence.HistoricItem> results = StreamSupport
.stream(service.query(filter).spliterator(), false).toList();
assertFalse(results.isEmpty(), "Service.query() must return the stored value");
assertEquals(77.7, ((DecimalType) results.get(0).getState()).doubleValue(), 1e-6);
assertTrue(service.remove(filter), "Service.remove() must return true for known item");
} finally {
service.deactivate();
}
}
@Test
@Order(72)
@SuppressWarnings("unchecked")
void serviceDeactivateCancelsscheduledfuture() throws Exception {
MetadataRegistry mr = mock(MetadataRegistry.class);
when(mr.getAll()).thenReturn(Collections.emptyList());
TimescaleDBPersistenceService service = new TimescaleDBPersistenceService(mock(ItemRegistry.class),
new TimescaleDBMetadataService(mr));
service.activate(Map.of("url", DB.getJdbcUrl(), "user", DB.getUsername(), "password", DB.getPassword()));
var jobField = TimescaleDBPersistenceService.class.getDeclaredField("downsampleJob");
jobField.setAccessible(true);
ScheduledFuture<Object> job = (ScheduledFuture<Object>) jobField.get(service);
assertNotNull(job);
assertFalse(job.isCancelled(), "Job must not be cancelled before deactivate()");
service.deactivate();
assertTrue(job.isCancelled(), "Job must be cancelled after deactivate()");
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
@SafeVarargs
private void storeAndVerify(String itemName, org.openhab.core.items.Item item, State state,
java.util.function.Consumer<State>... assertions) throws SQLException {
try (Connection conn = dataSource.getConnection()) {
int id = TimescaleDBQuery.getOrCreateItemId(conn, itemName, null);
TimescaleDBMapper.Row row = TimescaleDBMapper.toRow(state);
assertNotNull(row, "Mapper must produce a row for state type: " + state.getClass().getSimpleName());
TimescaleDBQuery.insert(conn, id, ZonedDateTime.now(), row);
FilterCriteria filter = new FilterCriteria();
filter.setItemName(itemName);
List<HistoricItem> results = TimescaleDBQuery.query(conn, item, id, filter);
assertFalse(results.isEmpty(), "Query should return at least one result for item " + itemName);
State loadedState = results.get(0).getState();
for (var assertion : assertions) {
assertion.accept(loadedState);
}
}
}
}
@@ -0,0 +1,376 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
/**
* Unit tests for {@link TimescaleDBDownsampleJob} using mocked DB connections.
*
* <p>
* Verifies:
* <ul>
* <li>Correct SQL is sent for all interval/function combinations</li>
* <li>Both INSERT and DELETE statements are executed within a transaction</li>
* <li>Retention DELETE is only sent when retentionDays &gt; 0</li>
* <li>Retention-only items (blank value + retentionDays) execute only the retention DELETE</li>
* <li>Items not found in item_meta are skipped gracefully without DML</li>
* <li>A per-item SQL error does not abort the entire job</li>
* </ul>
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
@SuppressWarnings("null")
class TimescaleDBDownsampleJobTest {
private DataSource dataSource;
private Connection connection;
private PreparedStatement preparedStatement;
private ResultSet resultSet;
private MetadataRegistry registry;
private TimescaleDBMetadataService metadataService;
@BeforeEach
void setUp() throws SQLException {
dataSource = mock(DataSource.class);
connection = mock(Connection.class);
preparedStatement = mock(PreparedStatement.class);
resultSet = mock(ResultSet.class);
registry = mock(MetadataRegistry.class);
metadataService = new TimescaleDBMetadataService(registry);
when(dataSource.getConnection()).thenReturn(connection);
// Default: all prepareStatement calls return the shared mock
when(connection.prepareStatement(anyString())).thenReturn(preparedStatement);
when(preparedStatement.executeQuery()).thenReturn(resultSet);
// Default: SELECT id FROM item_meta returns nothing (item not in DB)
when(resultSet.next()).thenReturn(false);
when(preparedStatement.executeUpdate()).thenReturn(1);
}
@Test
void runSingleitemExecutesinsertanddeleteintransaction() throws SQLException {
stubMetadata("SensorA", "AVG", Map.of("downsampleInterval", "1h", "retainRawDays", "5"));
stubItemNames(List.of("SensorA"));
stubItemIdInDb(connection, "SensorA", 42);
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
// Transaction: setAutoCommit(false), commit, setAutoCommit(true)
verify(connection).setAutoCommit(false);
verify(connection).commit();
verify(connection, never()).rollback();
// DML: setInt(1, 42) called twice (INSERT + DELETE)
verify(preparedStatement, times(2)).setInt(1, 42);
verify(preparedStatement, times(2)).executeUpdate();
}
@Test
void runWithretentiondaysExecutesthreestatements() throws SQLException {
stubMetadata("SensorA", "MAX",
Map.of("downsampleInterval", "15m", "retainRawDays", "3", "retentionDays", "90"));
stubItemNames(List.of("SensorA"));
stubItemIdInDb(connection, "SensorA", 7);
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
// 3 DML statements: INSERT, DELETE raw, DELETE retention
verify(preparedStatement, times(3)).setInt(1, 7);
}
@Test
void runItemnotInDbSkipsWithoutDml() throws SQLException {
stubMetadata("UnknownItem", "AVG", Map.of("downsampleInterval", "1h"));
stubItemNames(List.of("UnknownItem"));
// No stubItemIdInDb → SELECT returns empty (default setUp)
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
// Connection IS opened for the SELECT lookup, but no DML executed
verify(dataSource, atLeastOnce()).getConnection();
verify(preparedStatement, never()).executeUpdate();
}
@Test
void runSqlfailureforoneitemDoesnotabortotheritems() throws SQLException {
stubMetadata("SensorA", "AVG", Map.of("downsampleInterval", "1h"));
stubMetadata("SensorB", "SUM", Map.of("downsampleInterval", "1d"));
stubItemNames(List.of("SensorA", "SensorB"));
// First connection: SELECT succeeds (item found), INSERT fails
Connection failConn = mock(Connection.class);
PreparedStatement selectPs = mock(PreparedStatement.class);
ResultSet selectRs = mock(ResultSet.class);
when(selectRs.next()).thenReturn(true);
when(selectRs.getInt(1)).thenReturn(1);
when(selectPs.executeQuery()).thenReturn(selectRs);
when(failConn.prepareStatement(contains("SELECT id FROM item_meta"))).thenReturn(selectPs);
when(failConn.prepareStatement(contains("INSERT"))).thenThrow(new SQLException("simulated error"));
// Second connection: succeeds
Connection okConn = mock(Connection.class);
PreparedStatement okSelectPs = mock(PreparedStatement.class);
ResultSet okSelectRs = mock(ResultSet.class);
when(okSelectRs.next()).thenReturn(true);
when(okSelectRs.getInt(1)).thenReturn(2);
when(okSelectPs.executeQuery()).thenReturn(okSelectRs);
PreparedStatement okPs = mock(PreparedStatement.class);
when(okPs.executeUpdate()).thenReturn(1);
when(okConn.prepareStatement(anyString())).thenAnswer(inv -> {
String sql = inv.getArgument(0);
return sql.contains("SELECT id FROM item_meta") ? okSelectPs : okPs;
});
when(dataSource.getConnection()).thenReturn(failConn).thenReturn(okConn);
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
assertNoException(job); // Must not throw
// Second item was still processed
verify(okConn, atLeastOnce()).prepareStatement(contains("INSERT"));
}
@Test
void runNoitemsconfiguredDoesnothing() throws SQLException {
when(registry.getAll()).thenReturn(List.of());
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
verify(dataSource, never()).getConnection();
}
@Test
void runRetentiononlyitemExecutesonlyretentiondelete() throws SQLException {
stubRetentionOnlyItem("SensorRO", 30);
stubItemIdInDb(connection, "SensorRO", 42);
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
// Only 1 DML: the retention DELETE
verify(preparedStatement, times(1)).setInt(1, 42);
verify(preparedStatement, times(1)).executeUpdate();
verify(connection).commit();
verify(connection, never()).rollback();
}
@Test
void runRetentiononlyitemSqlcontainscorrectretentiondays() throws SQLException {
stubRetentionOnlyItem("SensorRO", 14);
PreparedStatement selectPs = mock(PreparedStatement.class);
ResultSet selectRs = mock(ResultSet.class);
when(selectRs.next()).thenReturn(true);
when(selectRs.getInt(1)).thenReturn(42);
when(selectPs.executeQuery()).thenReturn(selectRs);
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
String sql = inv.getArgument(0);
if (sql.contains("SELECT id FROM item_meta")) {
return selectPs;
}
capturedSql.add(sql);
return preparedStatement;
});
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
assertEquals(1, capturedSql.size(), "Retention-only: exactly one DML statement expected");
assertTrue(capturedSql.get(0).contains("14 days"), "Retention DELETE must reference retentionDays=14");
assertFalse(capturedSql.get(0).contains("INSERT"), "No INSERT for retention-only item");
assertFalse(capturedSql.get(0).contains("downsampled = FALSE"), "No raw-delete for retention-only item");
}
@Test
void runRetentiononlyitemWithzeroretentiondaysIsSkipped() throws SQLException {
// Blank value but retentionDays=0 (or missing) → no DB access at all (invalid config)
MetadataKey key = new MetadataKey("timescaledb", "SensorRO");
Metadata meta = new Metadata(key, " ", Map.of());
when(registry.getAll()).thenReturn(List.of(meta));
when(registry.get(key)).thenReturn(meta);
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
verify(dataSource, never()).getConnection();
}
@Test
void runRetentiononlyitemNotInDbIsSkipped() throws SQLException {
stubRetentionOnlyItem("SensorRO", 30);
// No stubItemIdInDb → SELECT returns empty (default setUp)
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
// Connection opened for SELECT but no DML
verify(preparedStatement, never()).executeUpdate();
}
@Test
void runInsertsqlcontainscorrectintervalandfunction() throws SQLException {
stubMetadata("Sensor", "MIN", Map.of("downsampleInterval", "6h", "retainRawDays", "3"));
stubItemNames(List.of("Sensor"));
PreparedStatement selectPs = mock(PreparedStatement.class);
ResultSet selectRs = mock(ResultSet.class);
when(selectRs.next()).thenReturn(true);
when(selectRs.getInt(1)).thenReturn(99);
when(selectPs.executeQuery()).thenReturn(selectRs);
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
String sql = inv.getArgument(0);
if (sql.contains("SELECT id FROM item_meta")) {
return selectPs;
}
capturedSql.add(sql);
return preparedStatement;
});
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
// capturedSql only contains DML (SELECT is handled by selectPs, not the answer)
String insertSql = capturedSql.get(0);
assertTrue(insertSql.contains("6 hours"), "INSERT should contain interval '6 hours'");
assertTrue(insertSql.contains("MIN(value)"), "INSERT should contain aggregation 'MIN(value)'");
assertTrue(insertSql.contains("3 days"), "INSERT should reference retainRawDays=3");
}
@Test
void runDeletesqlreferencesretainrawdays() throws SQLException {
stubMetadata("Sensor", "AVG", Map.of("downsampleInterval", "1h", "retainRawDays", "7"));
stubItemNames(List.of("Sensor"));
PreparedStatement selectPs = mock(PreparedStatement.class);
ResultSet selectRs = mock(ResultSet.class);
when(selectRs.next()).thenReturn(true);
when(selectRs.getInt(1)).thenReturn(5);
when(selectPs.executeQuery()).thenReturn(selectRs);
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
String sql = inv.getArgument(0);
if (sql.contains("SELECT id FROM item_meta")) {
return selectPs;
}
capturedSql.add(sql);
return preparedStatement;
});
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run();
// capturedSql only contains DML (SELECT is handled by selectPs, not the answer)
String deleteSql = capturedSql.get(1);
assertTrue(deleteSql.contains("downsampled = FALSE"), "DELETE should only remove raw rows");
assertTrue(deleteSql.contains("7 days"), "DELETE should reference retainRawDays=7");
}
@Test
void runRollbackonsqlerror() throws SQLException {
stubMetadata("SensorA", "AVG", Map.of("downsampleInterval", "1h"));
stubItemNames(List.of("SensorA"));
stubItemIdInDb(connection, "SensorA", 1);
// Make the INSERT fail
when(connection.prepareStatement(contains("INSERT"))).thenThrow(new SQLException("insert failed"));
var job = new TimescaleDBDownsampleJob(dataSource, metadataService);
job.run(); // should not throw
verify(connection).rollback();
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
/**
* Stubs the SELECT id FROM item_meta query to return the given id for any item name.
*/
private static void stubItemIdInDb(Connection conn, String itemName, int id) throws SQLException {
PreparedStatement selectPs = mock(PreparedStatement.class);
ResultSet selectRs = mock(ResultSet.class);
when(selectRs.next()).thenReturn(true);
when(selectRs.getInt(1)).thenReturn(id);
when(selectPs.executeQuery()).thenReturn(selectRs);
when(conn.prepareStatement(contains("SELECT id FROM item_meta"))).thenReturn(selectPs);
}
private void stubMetadata(String itemName, String value, Map<String, Object> config) {
MetadataKey key = new MetadataKey("timescaledb", itemName);
Metadata meta = new Metadata(key, value, config);
when(registry.get(key)).thenReturn(meta);
}
private void stubItemNames(List<String> names) {
var metaList = names.stream()
.map(n -> new Metadata(new MetadataKey("timescaledb", n), "AVG", Map.of("downsampleInterval", "1h")))
.toList();
when(registry.getAll()).thenAnswer(inv -> metaList);
for (String name : names) {
MetadataKey key = new MetadataKey("timescaledb", name);
if (registry.get(key) == null) {
when(registry.get(key)).thenReturn(new Metadata(key, "AVG", Map.of("downsampleInterval", "1h")));
}
}
}
/**
* Stubs registry.getAll() and registry.get() for a retention-only item
* (blank metadata value + retentionDays configured).
*/
private void stubRetentionOnlyItem(String itemName, int retentionDays) {
MetadataKey key = new MetadataKey("timescaledb", itemName);
Metadata meta = new Metadata(key, " ", Map.of("retentionDays", String.valueOf(retentionDays)));
when(registry.getAll()).thenReturn(List.of(meta));
when(registry.get(key)).thenReturn(meta);
}
private static void assertNoException(Runnable r) {
try {
r.run();
} catch (Exception e) {
org.junit.jupiter.api.Assertions.fail("Expected no exception but got: " + e.getMessage());
}
}
}
@@ -0,0 +1,303 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.core.library.items.ContactItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
/**
* Unit tests for the downsampling semantics of Switch (ON/OFF) and Contact (OPEN/CLOSED).
*
* <p>
* Both types are stored numerically: ON/OPEN = 1.0, OFF/CLOSED = 0.0.
* The DB-side aggregation (MAX, MIN, AVG, SUM) produces a numeric result that
* {@link TimescaleDBMapper#toState} reconstructs via threshold: value >= 0.5 → ON/OPEN.
*
* <p>
* Reference sequence for all tests: [OFF, ON, OFF] = [0.0, 1.0, 0.0]
* <ul>
* <li>MAX = 1.0 → ON / OPEN (was the state ever active?)</li>
* <li>MIN = 0.0 → OFF / CLOSED (was the state ever inactive?)</li>
* <li>AVG = 0.333 → OFF / CLOSED (minority of the time active)</li>
* <li>SUM = 1.0 → ON / OPEN (at least one active event in the bucket)</li>
* </ul>
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
class TimescaleDBDownsampleSemanticsTest {
// ------------------------------------------------------------------
// Encoding: toRow
// ------------------------------------------------------------------
@Test
void encodingSwitchOnIs1() {
var row = TimescaleDBMapper.toRow(OnOffType.ON);
assertNotNull(row);
assertEquals(1.0, row.value());
assertNull(row.string());
assertNull(row.unit());
}
@Test
void encodingSwitchOffIs0() {
var row = TimescaleDBMapper.toRow(OnOffType.OFF);
assertNotNull(row);
assertEquals(0.0, row.value());
assertNull(row.string());
assertNull(row.unit());
}
@Test
void encodingContactOpenIs1() {
var row = TimescaleDBMapper.toRow(OpenClosedType.OPEN);
assertNotNull(row);
assertEquals(1.0, row.value());
assertNull(row.string());
assertNull(row.unit());
}
@Test
void encodingContactClosedIs0() {
var row = TimescaleDBMapper.toRow(OpenClosedType.CLOSED);
assertNotNull(row);
assertEquals(0.0, row.value());
assertNull(row.string());
assertNull(row.unit());
}
// ------------------------------------------------------------------
// Switch: MAX — sequence [OFF, ON, OFF] → MAX(0,1,0) = 1.0 → ON
// ------------------------------------------------------------------
@Test
void switchMaxOffonoffReturnson() {
double aggregated = max(0.0, 1.0, 0.0);
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchMaxAlloffReturnsoff() {
double aggregated = max(0.0, 0.0, 0.0);
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchMaxAllonReturnson() {
double aggregated = max(1.0, 1.0, 1.0);
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
// ------------------------------------------------------------------
// Switch: MIN — sequence [OFF, ON, OFF] → MIN(0,1,0) = 0.0 → OFF
// ------------------------------------------------------------------
@Test
void switchMinOffonoffReturnsoff() {
double aggregated = min(0.0, 1.0, 0.0);
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchMinAllonReturnson() {
double aggregated = min(1.0, 1.0, 1.0);
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchMinAlloffReturnsoff() {
double aggregated = min(0.0, 0.0, 0.0);
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
// ------------------------------------------------------------------
// Switch: AVG — sequence [OFF, ON, OFF] → AVG(0,1,0) = 0.333 → OFF
// ------------------------------------------------------------------
@Test
void switchAvgOffonoffReturnsoff() {
double aggregated = avg(0.0, 1.0, 0.0); // = 0.333
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchAvgMajorityonReturnson() {
// ON, ON, OFF → AVG = 0.666 → ON
double aggregated = avg(1.0, 1.0, 0.0);
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchAvgExactlyhalfReturnson() {
// Boundary: 0.5 exactly → ON (threshold is >= 0.5)
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(new SwitchItem("s"), 0.5, null, null));
}
@Test
void switchAvgJustbelowhalfReturnsoff() {
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(new SwitchItem("s"), 0.499, null, null));
}
// ------------------------------------------------------------------
// Switch: SUM — sequence [OFF, ON, OFF] → SUM(0,1,0) = 1.0 → ON
// ------------------------------------------------------------------
@Test
void switchSumOffonoffReturnson() {
double aggregated = sum(0.0, 1.0, 0.0); // = 1.0
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchSumAlloffReturnsoff() {
double aggregated = sum(0.0, 0.0, 0.0); // = 0.0
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
@Test
void switchSumMultipleonReturnson() {
// Multiple ON events sum to > 1 — still reads as ON
double aggregated = sum(1.0, 1.0, 0.0); // = 2.0
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(new SwitchItem("s"), aggregated, null, null));
}
// ------------------------------------------------------------------
// Contact: MAX — sequence [CLOSED, OPEN, CLOSED] → MAX(0,1,0) = 1.0 → OPEN
// ------------------------------------------------------------------
@Test
void contactMaxClosedopenclosedReturnsopen() {
double aggregated = max(0.0, 1.0, 0.0);
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
@Test
void contactMaxAllclosedReturnsclosed() {
double aggregated = max(0.0, 0.0, 0.0);
assertEquals(OpenClosedType.CLOSED, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
@Test
void contactMaxAllopenReturnsopen() {
double aggregated = max(1.0, 1.0, 1.0);
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
// ------------------------------------------------------------------
// Contact: MIN — sequence [CLOSED, OPEN, CLOSED] → MIN(0,1,0) = 0.0 → CLOSED
// ------------------------------------------------------------------
@Test
void contactMinClosedopenclosedReturnsclosed() {
double aggregated = min(0.0, 1.0, 0.0);
assertEquals(OpenClosedType.CLOSED, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
@Test
void contactMinAllopenReturnsopen() {
double aggregated = min(1.0, 1.0, 1.0);
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
// ------------------------------------------------------------------
// Contact: AVG — sequence [CLOSED, OPEN, CLOSED] → AVG(0,1,0) = 0.333 → CLOSED
// ------------------------------------------------------------------
@Test
void contactAvgClosedopenclosedReturnsclosed() {
double aggregated = avg(0.0, 1.0, 0.0); // = 0.333
assertEquals(OpenClosedType.CLOSED, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
@Test
void contactAvgMajorityopenReturnsopen() {
double aggregated = avg(1.0, 1.0, 0.0); // = 0.666
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
@Test
void contactAvgExactlyhalfReturnsopen() {
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(new ContactItem("c"), 0.5, null, null));
}
@Test
void contactAvgJustbelowhalfReturnsclosed() {
assertEquals(OpenClosedType.CLOSED, TimescaleDBMapper.toState(new ContactItem("c"), 0.499, null, null));
}
// ------------------------------------------------------------------
// Contact: SUM — sequence [CLOSED, OPEN, CLOSED] → SUM(0,1,0) = 1.0 → OPEN
// ------------------------------------------------------------------
@Test
void contactSumClosedopenclosedReturnsopen() {
double aggregated = sum(0.0, 1.0, 0.0); // = 1.0
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
@Test
void contactSumAllclosedReturnsclosed() {
double aggregated = sum(0.0, 0.0, 0.0);
assertEquals(OpenClosedType.CLOSED, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
@Test
void contactSumMultipleopenReturnsopen() {
double aggregated = sum(1.0, 1.0, 0.0); // = 2.0
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(new ContactItem("c"), aggregated, null, null));
}
// ------------------------------------------------------------------
// Helpers — simulate DB-side aggregation on a value series
// ------------------------------------------------------------------
private static double max(double... values) {
double result = Double.NEGATIVE_INFINITY;
for (double v : values) {
result = Math.max(result, v);
}
return result;
}
private static double min(double... values) {
double result = Double.POSITIVE_INFINITY;
for (double v : values) {
result = Math.min(result, v);
}
return result;
}
private static double avg(double... values) {
double sum = 0;
for (double v : values) {
sum += v;
}
return sum / values.length;
}
private static double sum(double... values) {
double result = 0;
for (double v : values) {
result += v;
}
return result;
}
}
@@ -0,0 +1,132 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.*;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
/**
* Integration test that verifies {@link TimescaleDBSchema#initialize(Connection, String, int, int)}
* against the external TimescaleDB instance configured via environment variables:
* HOST, PORT, DBNAME, USER, PASSWORD.
*
* Run with: mvn test -Dgroups=external-integration -DexcludedGroups="" -pl bundles/org.openhab.persistence.timescaledb
*/
@Tag("external-integration")
@NonNullByDefault
@SuppressWarnings("null")
class TimescaleDBExternalIT {
private static @Nullable HikariDataSource dataSource;
@BeforeAll
static void connect() {
String host = System.getenv("HOST");
String port = System.getenv("PORT");
String db = System.getenv("DBNAME");
String user = System.getenv("USER");
String pass = System.getenv("PASSWORD");
assumeTrue(host != null && !host.isBlank(), "HOST env var not set — skipping external integration tests");
assumeTrue(db != null && !db.isBlank(), "DBNAME env var not set — skipping external integration tests");
String url = "jdbc:postgresql://" + host + ":" + (port != null ? port : "5432") + "/" + db;
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(url);
cfg.setUsername(user);
cfg.setPassword(pass);
cfg.setMaximumPoolSize(3);
cfg.setConnectionTimeout(5000);
dataSource = new HikariDataSource(cfg);
}
@AfterAll
static void disconnect() {
if (dataSource != null) {
dataSource.close();
}
}
@Test
void schemaInitializeCreateshypertableandindex() throws SQLException {
HikariDataSource ds = dataSource;
assumeTrue(ds != null, "DataSource not initialized — skipping");
// Drop and recreate for a clean test run
try (Connection conn = ds.getConnection(); var s = conn.createStatement()) {
s.execute("DROP TABLE IF EXISTS items CASCADE");
s.execute("DROP TABLE IF EXISTS item_meta CASCADE");
}
// This is the method under test — must succeed without any manual DDL
try (Connection conn = ds.getConnection()) {
TimescaleDBSchema.initialize(conn, "7 days", 0, 0);
}
// Verify item_meta table
try (Connection conn = ds.getConnection();
var s = conn.createStatement();
ResultSet rs = s.executeQuery("SELECT COUNT(*) FROM item_meta")) {
assertTrue(rs.next());
assertEquals(0, rs.getInt(1), "item_meta should be empty after fresh init");
}
// Verify items is a hypertable
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT hypertable_name, num_chunks FROM timescaledb_information.hypertables WHERE hypertable_name = 'items'");
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next(), "Hypertable 'items' must exist after initialize()");
assertEquals("items", rs.getString("hypertable_name"));
}
// Verify composite index
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(
"SELECT indexname FROM pg_indexes WHERE tablename = 'items' AND indexname = 'items_item_id_time_idx'");
ResultSet rs = ps.executeQuery()) {
assertTrue(rs.next(), "Index items_item_id_time_idx must exist after initialize()");
}
}
@Test
void schemaInitializeIsidempotent() throws SQLException {
HikariDataSource ds = dataSource;
assumeTrue(ds != null, "DataSource not initialized — skipping");
// Calling initialize() twice on an existing schema must not throw
try (Connection conn = ds.getConnection()) {
TimescaleDBSchema.initialize(conn, "7 days", 0, 0);
}
try (Connection conn = ds.getConnection()) {
assertDoesNotThrow(() -> TimescaleDBSchema.initialize(conn, "7 days", 0, 0),
"initialize() must be idempotent — safe to call on existing schema");
}
}
}
@@ -0,0 +1,418 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import java.time.ZonedDateTime;
import java.util.Base64;
import java.util.Objects;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.openhab.core.items.GroupItem;
import org.openhab.core.library.items.CallItem;
import org.openhab.core.library.items.ColorItem;
import org.openhab.core.library.items.ContactItem;
import org.openhab.core.library.items.DateTimeItem;
import org.openhab.core.library.items.DimmerItem;
import org.openhab.core.library.items.ImageItem;
import org.openhab.core.library.items.LocationItem;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.items.PlayerItem;
import org.openhab.core.library.items.RollershutterItem;
import org.openhab.core.library.items.StringItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.HSBType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.OpenClosedType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.PlayPauseType;
import org.openhab.core.library.types.PointType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.library.types.StringListType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.types.UpDownType;
import org.openhab.core.persistence.FilterCriteria.Operator;
import org.openhab.core.types.UnDefType;
/**
* Unit tests for {@link TimescaleDBMapper} — covers all state types in both directions.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
class TimescaleDBMapperTest {
// ------------------------------------------------------------------
// toRow — store direction
// ------------------------------------------------------------------
@Test
void toRowDecimaltype() {
var row = TimescaleDBMapper.toRow(new DecimalType(42.5));
assertNotNull(row);
assertEquals(42.5, row.value());
assertNull(row.string());
assertNull(row.unit());
}
@Test
void toRowQuantitytypeTemperature() {
var row = TimescaleDBMapper.toRow(new QuantityType<>("23.4 °C"));
assertNotNull(row);
assertNotNull(row.value());
assertEquals(23.4, Objects.requireNonNull(row.value()), 1e-6);
assertNull(row.string());
assertEquals("°C", row.unit());
}
@Test
void toRowOnofftypeOn() {
var row = TimescaleDBMapper.toRow(OnOffType.ON);
assertNotNull(row);
assertEquals(1.0, row.value());
assertNull(row.string());
assertNull(row.unit());
}
@Test
void toRowOnofftypeOff() {
var row = TimescaleDBMapper.toRow(OnOffType.OFF);
assertNotNull(row);
assertEquals(0.0, row.value());
}
@Test
void toRowOpenclosedtypeOpen() {
var row = TimescaleDBMapper.toRow(OpenClosedType.OPEN);
assertNotNull(row);
assertEquals(1.0, row.value());
}
@Test
void toRowOpenclosedtypeClosed() {
var row = TimescaleDBMapper.toRow(OpenClosedType.CLOSED);
assertNotNull(row);
assertEquals(0.0, row.value());
}
@Test
void toRowPercenttype() {
var row = TimescaleDBMapper.toRow(new PercentType(75));
assertNotNull(row);
assertEquals(75.0, Objects.requireNonNull(row.value()), 1e-6);
assertNull(row.unit());
}
@Test
void toRowUpdowntypeUp() {
var row = TimescaleDBMapper.toRow(UpDownType.UP);
assertNotNull(row);
assertEquals(0.0, row.value());
}
@Test
void toRowUpdowntypeDown() {
var row = TimescaleDBMapper.toRow(UpDownType.DOWN);
assertNotNull(row);
assertEquals(1.0, row.value());
}
@Test
void toRowHsbtype() {
var row = TimescaleDBMapper.toRow(new HSBType("120,50,80"));
assertNotNull(row);
assertNull(row.value());
assertEquals("120,50,80", row.string());
assertNull(row.unit());
}
@Test
void toRowDatetimetype() {
ZonedDateTime now = ZonedDateTime.now();
var row = TimescaleDBMapper.toRow(new DateTimeType(now));
assertNotNull(row);
assertNull(row.value());
assertNotNull(row.string());
assertNull(row.unit());
}
@Test
void toRowStringtype() {
var row = TimescaleDBMapper.toRow(new StringType("hello world"));
assertNotNull(row);
assertNull(row.value());
assertEquals("hello world", row.string());
assertNull(row.unit());
}
@Test
void toRowPointtype() {
var row = TimescaleDBMapper.toRow(new PointType("52.5,13.4,34.0"));
assertNotNull(row);
assertNull(row.value());
String pointStr = Objects.requireNonNull(row.string());
assertTrue(pointStr.contains("52.5"));
assertTrue(pointStr.contains("13.4"));
assertNull(row.unit());
}
@Test
void toRowPlaypausetypePlay() {
var row = TimescaleDBMapper.toRow(PlayPauseType.PLAY);
assertNotNull(row);
assertNull(row.value());
assertEquals("PLAY", row.string());
assertNull(row.unit());
}
@Test
void toRowPlaypausetypePause() {
var row = TimescaleDBMapper.toRow(PlayPauseType.PAUSE);
assertNotNull(row);
assertEquals("PAUSE", row.string());
}
@Test
void toRowStringlisttype() {
var row = TimescaleDBMapper.toRow(new StringListType("Alice", "Bob", "Charlie"));
assertNotNull(row);
assertNull(row.value());
assertTrue(Objects.requireNonNull(row.string()).contains("Alice"));
assertNull(row.unit());
}
@Test
void toRowRawtype() {
byte[] bytes = { 0x01, 0x02, 0x03 };
var row = TimescaleDBMapper.toRow(new RawType(bytes, "image/png"));
assertNotNull(row);
assertNull(row.value());
assertNotNull(row.string());
assertEquals("image/png", row.unit());
// Verify round-trip base64
byte[] decoded = Base64.getDecoder().decode(Objects.requireNonNull(row.string()));
assertArrayEquals(bytes, decoded);
}
@Test
void toRowUndeftypeReturnsnull() {
var row = TimescaleDBMapper.toRow(UnDefType.UNDEF);
assertNull(row);
}
// ------------------------------------------------------------------
// toState — load direction
// ------------------------------------------------------------------
@Test
void toStateQuantitytypeFromvalueandunit() {
var item = new NumberItem("TestNumber");
var state = TimescaleDBMapper.toState(item, 23.4, null, "°C");
assertInstanceOf(QuantityType.class, state);
assertEquals("23.4 °C", state.toString());
}
@Test
void toStateDecimaltypeFromvalue() {
var item = new NumberItem("TestNumber");
var state = TimescaleDBMapper.toState(item, 42.0, null, null);
assertInstanceOf(DecimalType.class, state);
assertEquals(new DecimalType(42.0), state);
}
@Test
void toStateOnofftypeFromvalueSwitchitem() {
var item = new SwitchItem("TestSwitch");
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(item, 1.0, null, null));
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(item, 0.0, null, null));
}
@Test
void toStateOpenclosedtypeFromvalue() {
var item = new ContactItem("TestContact");
assertEquals(OpenClosedType.OPEN, TimescaleDBMapper.toState(item, 1.0, null, null));
assertEquals(OpenClosedType.CLOSED, TimescaleDBMapper.toState(item, 0.0, null, null));
}
@Test
void toStatePercenttypeFromvalueDimmeritem() {
var item = new DimmerItem("TestDimmer");
var state = TimescaleDBMapper.toState(item, 75.0, null, null);
assertInstanceOf(PercentType.class, state);
assertEquals(75, ((PercentType) state).intValue());
}
@Test
void toStatePercenttypeFromvalueRollershutteritem() {
var item = new RollershutterItem("TestRoller");
var state = TimescaleDBMapper.toState(item, 50.0, null, null);
assertInstanceOf(PercentType.class, state);
}
@Test
void toStateHsbtypeFromstring() {
var item = new ColorItem("TestColor");
var state = TimescaleDBMapper.toState(item, null, "120,50,80", null);
assertInstanceOf(HSBType.class, state);
assertEquals("120,50,80", state.toString());
}
@Test
void toStateDatetimetypeFromstring() {
ZonedDateTime dt = ZonedDateTime.parse("2024-01-15T10:30:00+01:00");
var item = new DateTimeItem("TestDateTime");
var state = TimescaleDBMapper.toState(item, null, dt.toString(), null);
assertInstanceOf(DateTimeType.class, state);
}
@Test
void toStateStringtypeFromstringStringitem() {
var item = new StringItem("TestString");
var state = TimescaleDBMapper.toState(item, null, "hello", null);
assertInstanceOf(StringType.class, state);
assertEquals("hello", state.toString());
}
@Test
void toStatePointtypeFromstringLocationitem() {
var item = new LocationItem("TestLocation");
var state = TimescaleDBMapper.toState(item, null, "52.5200,13.4050,34.0000", null);
assertInstanceOf(PointType.class, state);
PointType point = (PointType) state;
assertEquals(52.52, point.getLatitude().doubleValue(), 1e-3);
assertEquals(13.405, point.getLongitude().doubleValue(), 1e-3);
}
@Test
void toStatePlaypausetypeFromstringPlayeritem() {
var item = new PlayerItem("TestPlayer");
assertEquals(PlayPauseType.PLAY, TimescaleDBMapper.toState(item, null, "PLAY", null));
assertEquals(PlayPauseType.PAUSE, TimescaleDBMapper.toState(item, null, "PAUSE", null));
}
@Test
void toStateStringlisttypeFromstringCallitem() {
var item = new CallItem("TestCall");
var state = TimescaleDBMapper.toState(item, null, "Alice,Bob,Charlie", null);
assertInstanceOf(StringListType.class, state);
assertEquals("Alice,Bob,Charlie", state.toString());
}
@Test
void toStateRawtypeFrombase64Imageitem() {
byte[] bytes = { 0x01, 0x02, 0x03 };
String encoded = Base64.getEncoder().encodeToString(bytes);
var item = new ImageItem("TestImage");
var state = TimescaleDBMapper.toState(item, null, encoded, "image/jpeg");
assertInstanceOf(RawType.class, state);
RawType raw = (RawType) state;
assertEquals("image/jpeg", raw.getMimeType());
assertArrayEquals(bytes, raw.getBytes());
}
@Test
void toStateRawtypeMissingmimetypeUsesdefault() {
byte[] bytes = { 0x00 };
String encoded = Base64.getEncoder().encodeToString(bytes);
var item = new ImageItem("TestImage");
var state = TimescaleDBMapper.toState(item, null, encoded, null);
assertInstanceOf(RawType.class, state);
assertEquals("application/octet-stream", ((RawType) state).getMimeType());
}
@Test
void toStateGroupitemWithnumberbaseitemReturnsdecimaltype() {
var baseItem = new NumberItem("Base");
var groupItem = new GroupItem("TestGroup", baseItem);
var state = TimescaleDBMapper.toState(groupItem, 99.0, null, null);
assertInstanceOf(DecimalType.class, state);
assertEquals(99.0, ((DecimalType) state).doubleValue(), 1e-6);
}
@Test
void toStateGroupitemWithswitchbaseitemReturnsonofftype() {
var baseItem = new SwitchItem("Base");
var groupItem = new GroupItem("TestGroup", baseItem);
assertEquals(OnOffType.ON, TimescaleDBMapper.toState(groupItem, 1.0, null, null));
assertEquals(OnOffType.OFF, TimescaleDBMapper.toState(groupItem, 0.0, null, null));
}
@Test
void toStateGroupitemWithcolorbaseitemReturnshsbtype() {
var baseItem = new ColorItem("Base");
var groupItem = new GroupItem("TestGroup", baseItem);
var state = TimescaleDBMapper.toState(groupItem, null, "240,100,50", null);
assertInstanceOf(HSBType.class, state);
}
@Test
void toStateAllnullreturnsundef() {
var item = new NumberItem("TestNumber");
var state = TimescaleDBMapper.toState(item, null, null, null);
assertEquals(UnDefType.UNDEF, state);
}
@Test
void toStateInvalidquantityunitReturnsundef() {
var item = new NumberItem("TestNumber");
var state = TimescaleDBMapper.toState(item, 10.0, null, "NOT_A_UNIT");
// Should return UNDEF or fall back gracefully
assertNotNull(state);
}
@Test
void toStateInvalidhsbReturnsundef() {
var item = new ColorItem("TestColor");
var state = TimescaleDBMapper.toState(item, null, "not-a-valid-hsb", null);
assertEquals(UnDefType.UNDEF, state);
}
@Test
void toStateInvalidpointtypeReturnsundef() {
var item = new LocationItem("TestLocation");
var state = TimescaleDBMapper.toState(item, null, "not-a-valid-point", null);
assertEquals(UnDefType.UNDEF, state);
}
@Test
void toStateInvalidplaypausetypeReturnsundef() {
var item = new PlayerItem("TestPlayer");
var state = TimescaleDBMapper.toState(item, null, "INVALID_STATE", null);
assertEquals(UnDefType.UNDEF, state);
}
@Test
void toStateInvalidbase64Returnsundef() {
var item = new ImageItem("TestImage");
var state = TimescaleDBMapper.toState(item, null, "!!!not-base64!!!", null);
assertEquals(UnDefType.UNDEF, state);
}
// ------------------------------------------------------------------
// toSqlOperator
// ------------------------------------------------------------------
@ParameterizedTest
@CsvSource({ "EQ,=", "NEQ,<>", "LT,<", "LTE,<=", "GT,>", "GTE,>=" })
void toSqlOperatorAllsupportedoperators(Operator op, String expected) {
assertEquals(expected, TimescaleDBMapper.toSqlOperator(op));
}
}
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.openhab.core.items.Metadata;
import org.openhab.core.items.MetadataKey;
import org.openhab.core.items.MetadataRegistry;
/**
* Unit tests for {@link TimescaleDBMetadataService}.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
@SuppressWarnings("null")
class TimescaleDBMetadataServiceTest {
private MetadataRegistry registry;
private TimescaleDBMetadataService service;
@BeforeEach
void setUp() {
registry = mock(MetadataRegistry.class);
service = new TimescaleDBMetadataService(registry);
}
// ------------------------------------------------------------------
// getDownsampleConfig — happy paths
// ------------------------------------------------------------------
@ParameterizedTest
@CsvSource({ "AVG,1h,1 hour", "MAX,15m,15 minutes", "MIN,1d,1 day", "SUM,30m,30 minutes" })
void getDownsampleConfigValidfunctionandinterval(String function, String interval, String expectedSql) {
stubMetadata("MySensor", function, Map.of("downsampleInterval", interval));
var config = service.getDownsampleConfig("MySensor");
assertTrue(config.isPresent());
assertEquals(AggregationFunction.valueOf(function), config.get().function());
assertEquals(expectedSql, config.get().sqlInterval());
assertEquals(5, config.get().retainRawDays()); // default
assertEquals(0, config.get().retentionDays()); // default
}
@Test
void getDownsampleConfigCustomretainrawandretentiondays() {
stubMetadata("MySensor", "AVG",
Map.of("downsampleInterval", "1h", "retainRawDays", "7", "retentionDays", "365"));
var config = service.getDownsampleConfig("MySensor").orElseThrow();
assertEquals(7, config.retainRawDays());
assertEquals(365, config.retentionDays());
}
@Test
void getDownsampleConfigAllsupportedintervals() {
for (Map.Entry<String, String> entry : DownsampleConfig.INTERVAL_MAP.entrySet()) {
stubMetadata("Item_" + entry.getKey(), "AVG", Map.of("downsampleInterval", entry.getKey()));
var config = service.getDownsampleConfig("Item_" + entry.getKey());
assertTrue(config.isPresent(), "Should parse interval: " + entry.getKey());
assertEquals(entry.getValue(), config.get().sqlInterval());
}
}
// ------------------------------------------------------------------
// getDownsampleConfig — no / empty metadata
// ------------------------------------------------------------------
@Test
void getDownsampleConfigNometadataReturnsempty() {
when(registry.get(new MetadataKey("timescaledb", "Unknown"))).thenReturn(null);
assertTrue(service.getDownsampleConfig("Unknown").isEmpty());
}
@Test
void getDownsampleConfigBlankfunctionWithretentiondaysReturnsretentiononlyconfig() {
// Blank value + retentionDays → retention-only config (no downsampling)
stubMetadata("MySensor", " ", Map.of("retentionDays", "30"));
Optional<DownsampleConfig> result = service.getDownsampleConfig("MySensor");
assertTrue(result.isPresent());
assertFalse(result.get().hasDownsampling());
assertEquals(30, result.get().retentionDays());
assertNull(result.get().function());
assertNull(result.get().sqlInterval());
}
@Test
void getDownsampleConfigBlankfunctionWithoutretentiondaysReturnsempty() {
// Blank value + no retentionDays → nothing to do, skip item
stubMetadata("MySensor", " ", Map.of());
assertTrue(service.getDownsampleConfig("MySensor").isEmpty());
}
// ------------------------------------------------------------------
// getDownsampleConfig — invalid / unsupported values
// ------------------------------------------------------------------
@ParameterizedTest
@ValueSource(strings = { "2h30m", "3m", "1w", "invalid", "" })
void getDownsampleConfigInvalidintervalReturnsempty(String badInterval) {
if (badInterval.isBlank()) {
// handled by the missing-interval branch
stubMetadata("MySensor", "AVG", Map.of());
} else {
stubMetadata("MySensor", "AVG", Map.of("downsampleInterval", badInterval));
}
assertTrue(service.getDownsampleConfig("MySensor").isEmpty());
}
@Test
void getDownsampleConfigInvalidfunctionReturnsempty() {
stubMetadata("MySensor", "MEDIAN", Map.of("downsampleInterval", "1h"));
assertTrue(service.getDownsampleConfig("MySensor").isEmpty());
}
@Test
void getDownsampleConfigMissingintervalReturnsempty() {
// Function present but no interval → cannot downsample
stubMetadata("MySensor", "AVG", Map.of());
assertTrue(service.getDownsampleConfig("MySensor").isEmpty());
}
@Test
void getDownsampleConfigInvalidretainrawdaysUsesdefault() {
stubMetadata("MySensor", "AVG", Map.of("downsampleInterval", "1h", "retainRawDays", "not-a-number"));
var config = service.getDownsampleConfig("MySensor").orElseThrow();
assertEquals(5, config.retainRawDays()); // falls back to default
}
// ------------------------------------------------------------------
// DownsampleConfig.toSqlInterval — allowlist enforcement
// ------------------------------------------------------------------
@Test
void toSqlIntervalInvalidvalueThrowsillegalargument() {
assertThrows(IllegalArgumentException.class, () -> DownsampleConfig.toSqlInterval("99h"));
}
@ParameterizedTest
@CsvSource({ "1m,1 minute", "5m,5 minutes", "15m,15 minutes", "30m,30 minutes", "1h,1 hour", "6h,6 hours",
"1d,1 day" })
void toSqlIntervalValidvalues(String input, String expected) {
assertEquals(expected, DownsampleConfig.toSqlInterval(input));
}
// ------------------------------------------------------------------
// getConfiguredItemNames
// ------------------------------------------------------------------
@Test
void getConfiguredItemNamesReturnsallTimescaledbitemsRegardlessofvalue() {
Metadata withFunction = metadata("SensorA", "AVG", Map.of("downsampleInterval", "1h"));
Metadata retentionOnly = metadata("SensorB", " ", Map.of("retentionDays", "30"));
Metadata otherNamespace = new Metadata(new MetadataKey("influxdb", "SensorC"), "some", Map.of());
when(registry.getAll()).thenAnswer(inv -> List.of(withFunction, retentionOnly, otherNamespace));
List<String> names = service.getConfiguredItemNames();
assertEquals(2, names.size());
assertTrue(names.contains("SensorA"));
assertTrue(names.contains("SensorB"));
assertFalse(names.contains("SensorC"));
}
@Test
void getConfiguredItemNamesEmptyregistryReturnsemptylist() {
when(registry.getAll()).thenReturn(List.of());
assertTrue(service.getConfiguredItemNames().isEmpty());
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private void stubMetadata(String itemName, String value, Map<String, Object> config) {
MetadataKey key = new MetadataKey("timescaledb", itemName);
Metadata meta = metadata(itemName, value, config);
when(registry.get(key)).thenReturn(meta);
}
private static Metadata metadata(String itemName, String value, Map<String, Object> config) {
return new Metadata(new MetadataKey("timescaledb", itemName), value, config);
}
}
@@ -0,0 +1,468 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.time.ZonedDateTime;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.items.ItemRegistry;
import org.openhab.core.items.MetadataRegistry;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.types.UnDefType;
import com.zaxxer.hikari.HikariDataSource;
/**
* Unit tests for {@link TimescaleDBPersistenceService} using a mocked DataSource.
*
* <p>
* The service is constructed and activated directly (bypassing OSGi) to allow
* mock injection.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
@SuppressWarnings("null")
class TimescaleDBPersistenceServiceTest {
private HikariDataSource dataSource;
private Connection connection;
private Statement statement;
private PreparedStatement preparedStatement;
private ResultSet resultSet;
private ItemRegistry itemRegistry;
private MetadataRegistry metadataRegistry;
/**
* Subclass that exposes the DataSource injection point for testing.
*/
private static class TestableService extends TimescaleDBPersistenceService {
private final HikariDataSource injectedDs;
TestableService(ItemRegistry ir, MetadataRegistry mr, HikariDataSource ds) {
super(ir, new TimescaleDBMetadataService(mr));
this.injectedDs = ds;
}
@Override
public void activate(Map<String, Object> config) {
// Skip the real activate (which would try to connect) and wire the mock directly
// via reflection to set the dataSource field.
try {
var dsField = TimescaleDBPersistenceService.class.getDeclaredField("dataSource");
dsField.setAccessible(true);
dsField.set(this, injectedDs);
} catch (ReflectiveOperationException e) {
throw new IllegalStateException(e);
}
}
}
private TestableService service;
@BeforeEach
void setUp() throws Exception {
dataSource = mock(HikariDataSource.class);
connection = mock(Connection.class);
statement = mock(Statement.class);
preparedStatement = mock(PreparedStatement.class);
resultSet = mock(ResultSet.class);
itemRegistry = mock(ItemRegistry.class);
metadataRegistry = mock(MetadataRegistry.class);
when(dataSource.getConnection()).thenReturn(connection);
when(connection.createStatement()).thenReturn(statement);
when(connection.prepareStatement(anyString())).thenReturn(preparedStatement);
when(preparedStatement.executeQuery()).thenReturn(resultSet);
when(preparedStatement.executeUpdate()).thenReturn(1);
when(resultSet.next()).thenReturn(false);
when(metadataRegistry.getAll()).thenReturn(Collections.emptyList());
service = new TestableService(itemRegistry, metadataRegistry, dataSource);
service.activate(Map.of());
}
@AfterEach
void tearDown() {
service.deactivate();
}
// ------------------------------------------------------------------
// Service metadata
// ------------------------------------------------------------------
@Test
void getIdReturnstimescaledb() {
assertEquals("timescaledb", service.getId());
}
@Test
void getLabelReturnshumanreadablelabel() {
assertNotNull(service.getLabel(null));
assertFalse(service.getLabel(null).isBlank());
}
@Test
void getSuggestedStrategiesReturnsEmptyList() {
assertTrue(service.getSuggestedStrategies().isEmpty());
}
// ------------------------------------------------------------------
// store
// ------------------------------------------------------------------
@Test
void storeNormalstateSendsinsert() throws Exception {
// item_id cache is empty → getOrCreateItemId will run SELECT then INSERT
stubItemIdLookup(7);
var item = new NumberItem("Sensor1");
service.store(item, ZonedDateTime.now(), new DecimalType(42.0), null);
// At minimum one connection obtained for the store operation
verify(dataSource, atLeastOnce()).getConnection();
}
@Test
void storeUndefstateDoesnottouchdatabase() throws Exception {
var item = new NumberItem("Sensor1");
service.store(item, ZonedDateTime.now(), UnDefType.UNDEF, null);
verify(dataSource, never()).getConnection();
}
@Test
void storeWithaliasUsesaliasname() throws Exception {
stubItemIdLookup(3);
var item = new NumberItem("RealName");
// Capture which PreparedStatements get setString(1, "AliasName")
PreparedStatement selectPs = mock(PreparedStatement.class);
ResultSet selectRs = mock(ResultSet.class);
when(selectRs.next()).thenReturn(false);
when(selectPs.executeQuery()).thenReturn(selectRs);
when(connection.prepareStatement(contains("SELECT id FROM item_meta"))).thenReturn(selectPs);
service.store(item, ZonedDateTime.now(), new DecimalType(1.0), "AliasName");
// The item_id lookup SELECT must be called with the alias
verify(selectPs, atLeastOnce()).setString(eq(1), eq("AliasName"));
}
// ------------------------------------------------------------------
// query
// ------------------------------------------------------------------
@Test
void queryItemnotincacheAndNotInDbReturnsemptylist() throws Exception {
var filter = new FilterCriteria();
filter.setItemName("UnknownItem");
// Default mock: resultSet.next() = false → item not in DB either
var result = service.query(filter);
assertFalse(result.iterator().hasNext());
// Fix 1: on cache miss the service now performs a DB lookup before giving up
verify(dataSource, atLeastOnce()).getConnection();
}
@Test
void queryCacheMissItemFoundInDbPopulatescacheAndExecutesquery() throws Exception {
// Arrange: item_id lookup returns 42, actual query returns no rows
PreparedStatement findPs = mock(PreparedStatement.class);
ResultSet findRs = mock(ResultSet.class);
when(findRs.next()).thenReturn(true);
when(findRs.getInt(1)).thenReturn(42);
when(findPs.executeQuery()).thenReturn(findRs);
when(connection.prepareStatement(contains("SELECT id FROM item_meta"))).thenReturn(findPs);
when(itemRegistry.getItem("Sensor1")).thenReturn(new NumberItem("Sensor1"));
var filter = new FilterCriteria();
filter.setItemName("Sensor1");
var result = service.query(filter);
// Still returns empty (no rows in the items table mock)
assertFalse(result.iterator().hasNext());
// Fix 1: two connections — one for DB lookup, one for actual query
verify(dataSource, atLeast(2)).getConnection();
// Fix 1: item is now cached → a second query must NOT trigger another DB lookup
reset(findPs);
service.query(filter);
verify(findPs, never()).executeQuery();
}
@Test
void queryNoitemnameReturnsemptylist() {
var filter = new FilterCriteria();
// no item name set
var result = service.query(filter);
assertFalse(result.iterator().hasNext());
}
@Test
void queryKnownitemDelegatestoqueryclass() throws Exception {
// Pre-populate the cache by storing first
stubItemIdLookup(5);
when(itemRegistry.getItem("Sensor1")).thenReturn(new NumberItem("Sensor1"));
var item = new NumberItem("Sensor1");
service.store(item, ZonedDateTime.now(), new DecimalType(1.0), null);
var filter = new FilterCriteria();
filter.setItemName("Sensor1");
service.query(filter);
// Connection should now be used for the query as well
verify(dataSource, atLeast(2)).getConnection();
}
// ------------------------------------------------------------------
// remove
// ------------------------------------------------------------------
@Test
void removeItemnotincacheAndNotInDbReturnsfalse() throws Exception {
var filter = new FilterCriteria();
filter.setItemName("UnknownItem");
// Default mock: resultSet.next() = false → item not in DB either
assertFalse(service.remove(filter));
// Fix 2: on cache miss the service now performs a DB lookup before giving up
verify(dataSource, atLeastOnce()).getConnection();
}
@Test
void removeCacheMissItemFoundInDbPopulatescacheAndExecutesdelete() throws Exception {
// Arrange: item_id lookup returns 7, DELETE returns 3 rows deleted
PreparedStatement findPs = mock(PreparedStatement.class);
ResultSet findRs = mock(ResultSet.class);
when(findRs.next()).thenReturn(true);
when(findRs.getInt(1)).thenReturn(7);
when(findPs.executeQuery()).thenReturn(findRs);
when(connection.prepareStatement(contains("SELECT id FROM item_meta"))).thenReturn(findPs);
when(preparedStatement.executeUpdate()).thenReturn(3);
var filter = new FilterCriteria();
filter.setItemName("Sensor1");
assertTrue(service.remove(filter));
// Fix 2: two connections — one for DB lookup, one for DELETE
verify(dataSource, atLeast(2)).getConnection();
// Fix 2: item is now cached → a second remove must NOT trigger another DB lookup
reset(findPs);
service.remove(filter);
verify(findPs, never()).executeQuery();
}
@Test
void removeKnownitemReturnstrue() throws Exception {
stubItemIdLookup(9);
// Store to populate cache
service.store(new NumberItem("Sensor1"), ZonedDateTime.now(), new DecimalType(1.0), null);
var filter = new FilterCriteria();
filter.setItemName("Sensor1");
when(preparedStatement.executeUpdate()).thenReturn(3);
assertTrue(service.remove(filter));
}
@Test
void removeNoitemnameReturnsfalse() {
var filter = new FilterCriteria();
assertFalse(service.remove(filter));
}
// ------------------------------------------------------------------
// activate() / deactivate() — real code paths
// ------------------------------------------------------------------
@Test
void activateMissingurlDatasourceremainsnull() throws Exception {
var realService = new TimescaleDBPersistenceService(mock(ItemRegistry.class),
new TimescaleDBMetadataService(mock(MetadataRegistry.class)));
realService.activate(Map.of()); // no 'url' key
var dsField = TimescaleDBPersistenceService.class.getDeclaredField("dataSource");
dsField.setAccessible(true);
assertTrue(null == dsField.get(realService), "dataSource must be null when url is missing");
realService.deactivate();
}
@Test
void activateInvalidurlDatasourceremainsnull() throws Exception {
var realService = new TimescaleDBPersistenceService(mock(ItemRegistry.class),
new TimescaleDBMetadataService(mock(MetadataRegistry.class)));
// Unreachable host; short timeout so the test does not block long
realService.activate(
Map.of("url", "jdbc:postgresql://localhost:19999/invalid", "password", "x", "connectTimeout", "500"));
var dsField = TimescaleDBPersistenceService.class.getDeclaredField("dataSource");
dsField.setAccessible(true);
assertTrue(null == dsField.get(realService), "dataSource must be null when connection fails");
realService.deactivate();
}
@Test
@SuppressWarnings("unchecked")
void deactivateWithscheduledjobCancelsjob() throws Exception {
ScheduledFuture<Object> mockJob = mock(ScheduledFuture.class);
var jobField = TimescaleDBPersistenceService.class.getDeclaredField("downsampleJob");
jobField.setAccessible(true);
jobField.set(service, mockJob);
service.deactivate();
verify(mockJob).cancel(false);
}
@Test
void deactivateClosesdatasource() throws Exception {
service.deactivate();
verify(dataSource).close();
}
@Test
void deactivateSetsDownsampleJobInstanceToNullSoRunDownsampleNowReturnsFalse() throws Exception {
// Fix 3: inject a job instance, then deactivate → runDownsampleNow() must return false
var jobField = TimescaleDBPersistenceService.class.getDeclaredField("downsampleJobInstance");
jobField.setAccessible(true);
jobField.set(service, mock(TimescaleDBDownsampleJob.class));
assertTrue(service.runDownsampleNow(), "sanity: job present before deactivate");
service.deactivate();
assertFalse(service.runDownsampleNow(),
"runDownsampleNow() must return false after deactivate() — console command "
+ "must not trigger a job backed by a closed connection pool");
}
// ------------------------------------------------------------------
// Private helpers tested via reflection
// ------------------------------------------------------------------
@Test
void secondsUntilMidnightIspositiveandatmost24h() {
long seconds = TimescaleDBPersistenceService.secondsUntilMidnight();
assertTrue(seconds > 0, "Seconds until midnight must be positive");
assertTrue(seconds <= 86400, "Seconds until midnight must not exceed 24 h");
}
@Test
void parseIntConfigValidvalueReturnsparsed() {
assertEquals(42, TimescaleDBPersistenceService.parseIntConfig(Map.of("k", "42"), "k", 0));
}
@Test
void parseIntConfigMissingkeyReturnsdefault() {
assertEquals(7, TimescaleDBPersistenceService.parseIntConfig(Map.of(), "k", 7));
}
@Test
void parseIntConfigInvalidvalueReturnsdefault() {
assertEquals(3, TimescaleDBPersistenceService.parseIntConfig(Map.of("k", "notanumber"), "k", 3));
}
// ------------------------------------------------------------------
// runDownsampleNow / ConsoleCommandExtension
// ------------------------------------------------------------------
@Test
void runDownsampleNowReturnsFalseWhenNotActivated() {
// A fresh service with no activate() call has no job instance
TimescaleDBPersistenceService fresh = new TimescaleDBPersistenceService(mock(ItemRegistry.class),
new TimescaleDBMetadataService(mock(MetadataRegistry.class)));
assertFalse(fresh.runDownsampleNow(), "runDownsampleNow() must return false before activate()");
}
@Test
void consoleCommandDownsamplePrintsFinishedWhenServiceActive() throws Exception {
// Inject a downsampleJobInstance that does nothing (already activated mock service)
var jobField = TimescaleDBPersistenceService.class.getDeclaredField("downsampleJobInstance");
jobField.setAccessible(true);
TimescaleDBDownsampleJob mockJob = mock(TimescaleDBDownsampleJob.class);
jobField.set(service, mockJob);
var console = mock(org.openhab.core.io.console.Console.class);
var cmd = new TimescaleDBConsoleCommandExtension(service);
cmd.execute(new String[] { "downsample" }, console);
verify(mockJob).run();
verify(console).println(contains("finished"));
}
@Test
void consoleCommandDownsamplePrintsNotActiveWhenServiceInactive() {
TimescaleDBPersistenceService fresh = new TimescaleDBPersistenceService(mock(ItemRegistry.class),
new TimescaleDBMetadataService(mock(MetadataRegistry.class)));
var console = mock(org.openhab.core.io.console.Console.class);
new TimescaleDBConsoleCommandExtension(fresh).execute(new String[] { "downsample" }, console);
verify(console).println(contains("not active"));
}
@Test
void consoleCommandUnknownArgPrintsUsage() {
var console = mock(org.openhab.core.io.console.Console.class);
new TimescaleDBConsoleCommandExtension(service).execute(new String[] { "unknown" }, console);
// printUsage writes to console — at minimum something must be printed
verify(console, atLeastOnce()).printUsage(anyString());
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
/**
* Stubs the item_id lookup: SELECT returns nothing, INSERT returns the given id.
*/
private void stubItemIdLookup(int itemId) throws Exception {
ResultSet selectRs = mock(ResultSet.class);
ResultSet insertRs = mock(ResultSet.class);
PreparedStatement selectPs = mock(PreparedStatement.class);
PreparedStatement insertPs = mock(PreparedStatement.class);
PreparedStatement insertItemPs = mock(PreparedStatement.class);
when(selectRs.next()).thenReturn(false);
when(insertRs.next()).thenReturn(true);
when(insertRs.getInt(1)).thenReturn(itemId);
when(selectPs.executeQuery()).thenReturn(selectRs);
when(insertPs.executeQuery()).thenReturn(insertRs);
when(insertItemPs.executeUpdate()).thenReturn(1);
when(connection.prepareStatement(contains("SELECT id FROM item_meta"))).thenReturn(selectPs);
when(connection.prepareStatement(contains("INSERT INTO item_meta"))).thenReturn(insertPs);
when(connection.prepareStatement(contains("INSERT INTO items"))).thenReturn(insertItemPs);
}
}
@@ -0,0 +1,373 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.util.List;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.library.items.NumberItem;
import org.openhab.core.library.items.SwitchItem;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.persistence.FilterCriteria;
import org.openhab.core.persistence.FilterCriteria.Ordering;
import org.openhab.core.persistence.HistoricItem;
/**
* Unit tests for {@link TimescaleDBQuery} using mocked JDBC connections.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
@SuppressWarnings("null")
class TimescaleDBQueryTest {
private Connection connection;
private PreparedStatement preparedStatement;
private ResultSet resultSet;
@BeforeEach
void setUp() throws Exception {
connection = mock(Connection.class);
preparedStatement = mock(PreparedStatement.class);
resultSet = mock(ResultSet.class);
when(connection.prepareStatement(anyString())).thenReturn(preparedStatement);
when(preparedStatement.executeQuery()).thenReturn(resultSet);
when(resultSet.next()).thenReturn(false); // empty result by default
}
// ------------------------------------------------------------------
// insert
// ------------------------------------------------------------------
@Test
void insertSetsallfiveparameters() throws Exception {
var row = new TimescaleDBMapper.Row(23.4, null, "°C");
ZonedDateTime now = ZonedDateTime.now();
TimescaleDBQuery.insert(connection, 5, now, row);
verify(preparedStatement).setTimestamp(eq(1), any(Timestamp.class));
verify(preparedStatement).setInt(2, 5);
verify(preparedStatement).setDouble(3, 23.4);
verify(preparedStatement).setString(4, null); // string
verify(preparedStatement).setString(5, "°C"); // unit
verify(preparedStatement).executeUpdate();
}
@Test
void insertSqlcontainsonconflictdoNothing() throws Exception {
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return preparedStatement;
});
TimescaleDBQuery.insert(connection, 1, ZonedDateTime.now(), new TimescaleDBMapper.Row(1.0, null, null));
String sql = capturedSql.get(0);
assertTrue(sql.contains("ON CONFLICT DO NOTHING"),
"INSERT must silently ignore duplicate writes via ON CONFLICT DO NOTHING");
assertFalse(sql.contains("ON CONFLICT ("),
"ON CONFLICT must NOT specify columns — TimescaleDB hypertables do not support "
+ "column-inference conflict targets and throw instead of silently dropping");
}
@Test
void insertNumericnullusessetnull() throws Exception {
var row = new TimescaleDBMapper.Row(null, "hello", null);
ZonedDateTime now = ZonedDateTime.now();
TimescaleDBQuery.insert(connection, 3, now, row);
verify(preparedStatement).setNull(eq(3), anyInt());
}
// ------------------------------------------------------------------
// getOrCreateItemId — existing item (SELECT path)
// ------------------------------------------------------------------
@Test
void getOrCreateItemIdExistingitemReturnsfromselect() throws Exception {
when(resultSet.next()).thenReturn(true);
when(resultSet.getInt(1)).thenReturn(42);
int id = TimescaleDBQuery.getOrCreateItemId(connection, "MySensor", "My Sensor Label");
assertEquals(42, id);
// Only one PreparedStatement needed (the SELECT)
verify(connection, times(1)).prepareStatement(contains("SELECT id FROM item_meta"));
}
@Test
void getOrCreateItemIdNewitemInsertsandreturns() throws Exception {
// First call (SELECT) returns no rows; second (INSERT) returns the new id
ResultSet selectRs = mock(ResultSet.class);
ResultSet insertRs = mock(ResultSet.class);
PreparedStatement selectPs = mock(PreparedStatement.class);
PreparedStatement insertPs = mock(PreparedStatement.class);
when(selectRs.next()).thenReturn(false);
when(insertRs.next()).thenReturn(true);
when(insertRs.getInt(1)).thenReturn(99);
when(selectPs.executeQuery()).thenReturn(selectRs);
when(insertPs.executeQuery()).thenReturn(insertRs);
when(connection.prepareStatement(contains("SELECT id FROM item_meta"))).thenReturn(selectPs);
when(connection.prepareStatement(contains("INSERT INTO item_meta"))).thenReturn(insertPs);
int id = TimescaleDBQuery.getOrCreateItemId(connection, "NewSensor", null);
assertEquals(99, id);
}
// ------------------------------------------------------------------
// query — result mapping
// ------------------------------------------------------------------
@Test
void queryEmptyresultReturnsemptylist() throws Exception {
when(resultSet.next()).thenReturn(false);
var filter = new FilterCriteria();
filter.setItemName("Sensor");
List<HistoricItem> result = TimescaleDBQuery.query(connection, new NumberItem("Sensor"), 1, filter);
assertTrue(result.isEmpty());
}
@Test
void querySinglerowReturnsoneitem() throws Exception {
Instant ts = Instant.parse("2024-06-01T12:00:00Z");
when(resultSet.next()).thenReturn(true, false);
when(resultSet.getTimestamp(1)).thenReturn(Timestamp.from(ts));
when(resultSet.getObject(2)).thenReturn(42.0); // value
when(resultSet.getString(3)).thenReturn(null); // string
when(resultSet.getString(4)).thenReturn(null); // unit
var filter = new FilterCriteria();
filter.setItemName("Sensor");
List<HistoricItem> result = TimescaleDBQuery.query(connection, new NumberItem("Sensor"), 1, filter);
assertEquals(1, result.size());
assertEquals(new DecimalType(42.0), result.get(0).getState());
assertEquals(ts, result.get(0).getTimestamp().toInstant());
}
@Test
void queryWithunitReturnsquantitytype() throws Exception {
when(resultSet.next()).thenReturn(true, false);
when(resultSet.getTimestamp(1)).thenReturn(Timestamp.from(Instant.now()));
when(resultSet.getObject(2)).thenReturn(22.5);
when(resultSet.getString(3)).thenReturn(null);
when(resultSet.getString(4)).thenReturn("°C");
var filter = new FilterCriteria();
filter.setItemName("TempSensor");
List<HistoricItem> result = TimescaleDBQuery.query(connection, new NumberItem("TempSensor"), 1, filter);
assertEquals(1, result.size());
assertEquals("22.5 °C", result.get(0).getState().toString());
}
@Test
void queryWithswitchitemReturnsonofftype() throws Exception {
when(resultSet.next()).thenReturn(true, false);
when(resultSet.getTimestamp(1)).thenReturn(Timestamp.from(Instant.now()));
when(resultSet.getObject(2)).thenReturn(1.0);
when(resultSet.getString(3)).thenReturn(null);
when(resultSet.getString(4)).thenReturn(null);
var filter = new FilterCriteria();
filter.setItemName("Switch1");
List<HistoricItem> result = TimescaleDBQuery.query(connection, new SwitchItem("Switch1"), 2, filter);
assertEquals(OnOffType.ON, result.get(0).getState());
}
// ------------------------------------------------------------------
// query — SQL construction
// ------------------------------------------------------------------
@Test
void queryWithdaterangeAddsbothdateparams() throws Exception {
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return preparedStatement;
});
var filter = new FilterCriteria();
filter.setItemName("Sensor");
filter.setBeginDate(ZonedDateTime.now().minusDays(7));
filter.setEndDate(ZonedDateTime.now());
TimescaleDBQuery.query(connection, new NumberItem("Sensor"), 1, filter);
String sql = capturedSql.get(0);
assertTrue(sql.contains("time >= ?"), "Should have begin date filter");
assertTrue(sql.contains("time <= ?"), "Should have end date filter");
}
@Test
void queryAscendingorderAppendsasc() throws Exception {
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return preparedStatement;
});
var filter = new FilterCriteria();
filter.setItemName("Sensor");
filter.setOrdering(Ordering.ASCENDING);
TimescaleDBQuery.query(connection, new NumberItem("Sensor"), 1, filter);
assertTrue(capturedSql.get(0).contains("ORDER BY time ASC"));
}
@Test
void queryDescendingorderAppendsdesc() throws Exception {
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return preparedStatement;
});
var filter = new FilterCriteria();
filter.setItemName("Sensor");
filter.setOrdering(Ordering.DESCENDING);
TimescaleDBQuery.query(connection, new NumberItem("Sensor"), 1, filter);
assertTrue(capturedSql.get(0).contains("ORDER BY time DESC"));
}
@Test
void queryWithpaginationAddslimitandoffset() throws Exception {
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return preparedStatement;
});
var filter = new FilterCriteria();
filter.setItemName("Sensor");
filter.setPageSize(50);
filter.setPageNumber(2);
TimescaleDBQuery.query(connection, new NumberItem("Sensor"), 1, filter);
String sql = capturedSql.get(0);
assertTrue(sql.contains("LIMIT ?"), "Should have LIMIT clause");
assertTrue(sql.contains("OFFSET ?"), "Should have OFFSET clause");
}
// ------------------------------------------------------------------
// findItemId — SELECT-only cache-miss fallback
// ------------------------------------------------------------------
@Test
void findItemIdKnownitemReturnsid() throws Exception {
when(resultSet.next()).thenReturn(true);
when(resultSet.getInt(1)).thenReturn(77);
var result = TimescaleDBQuery.findItemId(connection, "KnownSensor");
assertTrue(result.isPresent());
assertEquals(77, result.get());
// Must use the SELECT query — no INSERT
verify(connection).prepareStatement(contains("SELECT id FROM item_meta"));
verify(connection, never()).prepareStatement(contains("INSERT"));
}
@Test
void findItemIdUnknownitemReturnsempty() throws Exception {
when(resultSet.next()).thenReturn(false);
var result = TimescaleDBQuery.findItemId(connection, "UnknownSensor");
assertFalse(result.isPresent());
verify(connection).prepareStatement(contains("SELECT id FROM item_meta"));
verify(connection, never()).prepareStatement(contains("INSERT"));
}
@Test
void findItemIdSetsNameParameter() throws Exception {
when(resultSet.next()).thenReturn(false);
TimescaleDBQuery.findItemId(connection, "MySensor");
verify(preparedStatement).setString(1, "MySensor");
}
// ------------------------------------------------------------------
// remove
// ------------------------------------------------------------------
@Test
void removeNodatefilterOnlyfiltersonitemid() throws Exception {
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return preparedStatement;
});
when(preparedStatement.executeUpdate()).thenReturn(5);
var filter = new FilterCriteria();
filter.setItemName("Sensor");
int deleted = TimescaleDBQuery.remove(connection, 3, filter);
assertEquals(5, deleted);
assertFalse(capturedSql.get(0).contains("time >="), "Should not contain date filter");
}
@Test
void removeWithdaterangeAddsbothdateparams() throws Exception {
var capturedSql = new java.util.ArrayList<String>();
when(connection.prepareStatement(anyString())).thenAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return preparedStatement;
});
when(preparedStatement.executeUpdate()).thenReturn(3);
var filter = new FilterCriteria();
filter.setItemName("Sensor");
filter.setBeginDate(ZonedDateTime.now().minusDays(30));
filter.setEndDate(ZonedDateTime.now());
TimescaleDBQuery.remove(connection, 1, filter);
String sql = capturedSql.get(0);
assertTrue(sql.contains("time >= ?"));
assertTrue(sql.contains("time <= ?"));
}
}
@@ -0,0 +1,222 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.persistence.timescaledb.internal;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import org.eclipse.jdt.annotation.DefaultLocation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link TimescaleDBSchema} using mocked JDBC connections.
*
* @author René Ulbricht - Initial contribution
*/
@NonNullByDefault({ DefaultLocation.RETURN_TYPE, DefaultLocation.PARAMETER })
class TimescaleDBSchemaTest {
private Connection connection;
private Statement statement;
private PreparedStatement hypertablePs;
private ResultSet extensionResultSet;
@BeforeEach
void setUp() throws SQLException {
connection = mock(Connection.class);
statement = mock(Statement.class);
hypertablePs = mock(PreparedStatement.class);
extensionResultSet = mock(ResultSet.class);
when(connection.createStatement()).thenReturn(statement);
when(connection.prepareStatement(anyString())).thenReturn(hypertablePs);
when(statement.executeQuery(contains("pg_extension"))).thenReturn(extensionResultSet);
when(extensionResultSet.next()).thenReturn(true); // extension is present by default
}
@Test
void initializeExecutesallrequiredddl() throws SQLException {
TimescaleDBSchema.initialize(connection, "7 days", 0, 0);
// Must check TimescaleDB extension
verify(statement).executeQuery(contains("pg_extension"));
// Must create item_meta
verify(statement).execute(contains("CREATE TABLE IF NOT EXISTS item_meta"));
// Must create items table
verify(statement).execute(contains("CREATE TABLE IF NOT EXISTS items"));
// Must create hypertable via PreparedStatement (not raw Statement — avoids SQL injection on chunkInterval)
verify(connection).prepareStatement(contains("create_hypertable"));
verify(hypertablePs).execute();
// Must create index
verify(statement).execute(contains("CREATE INDEX IF NOT EXISTS items_item_id_time_idx"));
}
@Test
void initializeHypertablecontainsconfiguredchunkinterval() throws SQLException {
TimescaleDBSchema.initialize(connection, "14 days", 0, 0);
// The chunk interval must be passed as a PreparedStatement parameter, not interpolated into SQL
verify(hypertablePs).setString(1, "14 days");
verify(hypertablePs).execute();
}
@Test
void initializeHypertableUsespreparedstatementNotRawSql() throws SQLException {
// The SQL_CREATE_HYPERTABLE constant must use ?::INTERVAL — verify no string-formatted interval leaks
// into a raw Statement.execute() call
var capturedRawSql = new java.util.ArrayList<String>();
doAnswer(inv -> {
capturedRawSql.add(inv.getArgument(0));
return false;
}).when(statement).execute(anyString());
TimescaleDBSchema.initialize(connection, "3 days", 0, 0);
// No raw Statement should contain "create_hypertable" or the interval value
assertFalse(capturedRawSql.stream().anyMatch(s -> s.contains("create_hypertable")),
"create_hypertable must not be executed via raw Statement (SQL injection risk)");
assertFalse(capturedRawSql.stream().anyMatch(s -> s.contains("3 days")),
"chunkInterval value must not appear in any raw Statement SQL");
}
@Test
void initializeWithcompressionSendscompressionddl() throws SQLException {
var capturedSql = new java.util.ArrayList<String>();
doAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return false;
}).when(statement).execute(anyString());
TimescaleDBSchema.initialize(connection, "7 days", 30, 0);
boolean hasCompress = capturedSql.stream().anyMatch(s -> s.contains("timescaledb.compress"));
boolean hasPolicy = capturedSql.stream().anyMatch(s -> s.contains("add_compression_policy"));
assertTrue(hasCompress, "Should enable compression on table");
assertTrue(hasPolicy, "Should add compression policy");
// Policy should reference the configured number of days
assertTrue(capturedSql.stream().anyMatch(s -> s.contains("add_compression_policy") && s.contains("30")),
"Compression policy should reference 30 days");
}
@Test
void initializeWithoutcompressionNocompressionddl() throws SQLException {
var capturedSql = new java.util.ArrayList<String>();
doAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return false;
}).when(statement).execute(anyString());
TimescaleDBSchema.initialize(connection, "7 days", 0, 0);
boolean hasCompress = capturedSql.stream().anyMatch(s -> s.contains("add_compression_policy"));
assertFalse(hasCompress, "Should not add compression policy when compressionAfterDays=0");
}
@Test
void initializeWithretentionpolicySendsretentionddl() throws SQLException {
var capturedSql = new java.util.ArrayList<String>();
doAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return false;
}).when(statement).execute(anyString());
TimescaleDBSchema.initialize(connection, "7 days", 0, 365);
assertTrue(capturedSql.stream().anyMatch(s -> s.contains("add_retention_policy")),
"Should add retention policy");
assertTrue(capturedSql.stream().anyMatch(s -> s.contains("add_retention_policy") && s.contains("365")),
"Retention policy should reference 365 days");
}
@Test
void initializeWithoutretentionpolicyNoretentionddl() throws SQLException {
var capturedSql = new java.util.ArrayList<String>();
doAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return false;
}).when(statement).execute(anyString());
TimescaleDBSchema.initialize(connection, "7 days", 0, 0);
boolean hasRetention = capturedSql.stream().anyMatch(s -> s.contains("add_retention_policy"));
assertFalse(hasRetention, "Should not add retention policy when retentionDays=0");
}
@Test
void initializeMissingtimescaledbextensionThrowssqlexception() throws SQLException {
when(extensionResultSet.next()).thenReturn(false); // extension NOT present
SQLException ex = assertThrows(SQLException.class,
() -> TimescaleDBSchema.initialize(connection, "7 days", 0, 0));
String msg = ex.getMessage();
assertNotNull(msg, "Exception must have a message");
assertTrue(msg.contains("TimescaleDB extension"), "Error message should mention TimescaleDB extension");
}
@Test
void createTableDdlContainsdownsampledInUniqueconstraint() throws SQLException {
var capturedSql = new java.util.ArrayList<String>();
doAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return false;
}).when(statement).execute(anyString());
TimescaleDBSchema.initialize(connection, "7 days", 0, 0);
boolean hasConstraint = capturedSql.stream().anyMatch(
s -> s.contains("UNIQUE") && s.contains("time") && s.contains("item_id") && s.contains("downsampled"));
assertTrue(hasConstraint,
"CREATE TABLE must include UNIQUE(time, item_id, downsampled) — without the downsampled "
+ "column a downsampled row at the exact bucket-boundary timestamp would conflict "
+ "with the raw row at the same time");
}
@Test
void migrationDdlDropsOldconstraintAndAddsNewone() throws SQLException {
var capturedSql = new java.util.ArrayList<String>();
doAnswer(inv -> {
capturedSql.add(inv.getArgument(0));
return false;
}).when(statement).execute(anyString());
TimescaleDBSchema.initialize(connection, "7 days", 0, 0);
// The migration DO-block is the only DDL statement that contains DROP CONSTRAINT
java.util.Optional<String> migrationOpt = capturedSql.stream().filter(s -> s.contains("DROP CONSTRAINT"))
.findFirst();
assertTrue(migrationOpt.isPresent(), "Migration DO-block not found in executed DDL statements");
String migrationSql = migrationOpt.get();
assertTrue(migrationSql.contains("items_time_item_id_ukey"),
"Migration must drop the legacy items_time_item_id_ukey constraint");
assertTrue(migrationSql.contains("items_time_item_id_downsampled_ukey"),
"Migration must add the new items_time_item_id_downsampled_ukey constraint");
assertTrue(migrationSql.indexOf("DROP") < migrationSql.indexOf("ADD"),
"Migration must DROP the old constraint before ADD-ing the new one");
}
}
+1
View File
@@ -503,6 +503,7 @@
<module>org.openhab.persistence.mapdb</module>
<module>org.openhab.persistence.mongodb</module>
<module>org.openhab.persistence.rrd4j</module>
<module>org.openhab.persistence.timescaledb</module>
<!-- voice -->
<module>org.openhab.voice.googlestt</module>
<module>org.openhab.voice.googletts</module>