diff --git a/.gitea/workflows/database-portability.yml b/.gitea/workflows/database-portability.yml
new file mode 100644
index 0000000..9aa5d9b
--- /dev/null
+++ b/.gitea/workflows/database-portability.yml
@@ -0,0 +1,158 @@
+# CI pipeline for database portability tests.
+# Runs PHPUnit tests against MySQL, PostgreSQL, and SQLite.
+#
+# Part of Issue #192.
+
+name: Database Portability Tests
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'lib/Db/**'
+ - 'lib/Service/QueryService.php'
+ - 'lib/Migration/**'
+ - 'tests/**'
+ pull_request:
+ paths:
+ - 'lib/Db/**'
+ - 'lib/Service/QueryService.php'
+ - 'lib/Migration/**'
+ - 'tests/**'
+
+jobs:
+ unit-tests:
+ name: Unit Tests (PlatformHelper)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer install --no-dev --optimize-autoloader --no-interaction
+
+ - name: Run PlatformHelper unit tests
+ run: vendor/bin/phpunit tests/Unit/PlatformHelperTest.php
+
+ - name: Run DatabasePortability tests
+ run: vendor/bin/phpunit tests/DatabasePortability/
+
+ portability-matrix:
+ name: Integration (${{ matrix.database }})
+ runs-on: ubuntu-latest
+ needs: unit-tests
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - database: mysql
+ db_image: mariadb:10.11
+ db_port: 3306
+ nc_db_type: mysql
+ nc_db_host: 127.0.0.1
+ nc_db_name: nextcloud
+ nc_db_user: nextcloud
+ nc_db_pass: nextcloud
+ - database: postgres
+ db_image: postgres:16-alpine
+ db_port: 5432
+ nc_db_type: pgsql
+ nc_db_host: 127.0.0.1
+ nc_db_name: nextcloud
+ nc_db_user: nextcloud
+ nc_db_pass: nextcloud
+ - database: sqlite
+ db_image: ""
+ db_port: 0
+ nc_db_type: sqlite
+ nc_db_host: ""
+ nc_db_name: ""
+ nc_db_user: ""
+ nc_db_pass: ""
+
+ services:
+ db:
+ image: ${{ matrix.db_image || 'alpine:3' }}
+ env:
+ MYSQL_ROOT_PASSWORD: nextcloud
+ MYSQL_DATABASE: nextcloud
+ MYSQL_USER: nextcloud
+ MYSQL_PASSWORD: nextcloud
+ POSTGRES_DB: nextcloud
+ POSTGRES_USER: nextcloud
+ POSTGRES_PASSWORD: nextcloud
+ ports:
+ - ${{ matrix.db_port && format('{0}:{0}', matrix.db_port) || '9999:9999' }}
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.1'
+ extensions: pdo, pdo_mysql, pdo_pgsql, pdo_sqlite
+ tools: composer:v2
+
+ - name: Install dependencies
+ run: composer install --no-dev --optimize-autoloader --no-interaction
+
+ - name: Run portability tests (${{ matrix.database }})
+ env:
+ DB_PLATFORM: ${{ matrix.nc_db_type }}
+ run: |
+ echo "Testing database portability for: ${{ matrix.database }}"
+ vendor/bin/phpunit tests/Unit/PlatformHelperTest.php
+ vendor/bin/phpunit tests/DatabasePortability/
+
+ - name: Verify info.xml declares ${{ matrix.database }}
+ run: |
+ if [ "${{ matrix.database }}" = "mysql" ]; then
+ grep 'mysql' appinfo/info.xml
+ elif [ "${{ matrix.database }}" = "postgres" ]; then
+ grep 'pgsql' appinfo/info.xml
+ elif [ "${{ matrix.database }}" = "sqlite" ]; then
+ grep 'sqlite' appinfo/info.xml
+ fi
+
+ verify-no-mysql-sql:
+ name: Verify no MySQL-specific SQL
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check for TIMESTAMPDIFF (should only be in PlatformHelper)
+ run: |
+ MATCHES=$(grep -rn 'TIMESTAMPDIFF' lib/ --include='*.php' | grep -v 'PlatformHelper.php' || true)
+ if [ -n "$MATCHES" ]; then
+ echo "ERROR: TIMESTAMPDIFF found outside PlatformHelper:"
+ echo "$MATCHES"
+ exit 1
+ fi
+ echo "OK: No TIMESTAMPDIFF outside PlatformHelper"
+
+ - name: Check for CURDATE (should only be in PlatformHelper)
+ run: |
+ MATCHES=$(grep -rn 'CURDATE' lib/ --include='*.php' | grep -v 'PlatformHelper.php' || true)
+ if [ -n "$MATCHES" ]; then
+ echo "ERROR: CURDATE found outside PlatformHelper:"
+ echo "$MATCHES"
+ exit 1
+ fi
+ echo "OK: No CURDATE outside PlatformHelper"
+
+ - name: Check for LIKE on date columns (should not exist)
+ run: |
+ # Check for patterns like LIKE '%-MM-%' or LIKE 'YYYY-%' on date columns
+ MATCHES=$(grep -rn "like.*geburtsdatum\|like.*startdatum\|like.*eintritt" lib/ --include='*.php' -i || true)
+ if [ -n "$MATCHES" ]; then
+ echo "ERROR: LIKE on date columns found:"
+ echo "$MATCHES"
+ exit 1
+ fi
+ echo "OK: No LIKE on date columns"
diff --git a/.plans/issue-192-database-portability.md b/.plans/issue-192-database-portability.md
new file mode 100644
index 0000000..8123c84
--- /dev/null
+++ b/.plans/issue-192-database-portability.md
@@ -0,0 +1,41 @@
+# Implementation Plan: Issue #192 — Database Portability
+
+## Summary
+Replace 5 MySQL-specific SQL expressions with platform-aware equivalents using a new PlatformHelper utility class, declare PostgreSQL and SQLite support in info.xml, add Docker infrastructure for all three backends, create integration tests, and set up CI pipeline.
+
+## Phase 1: Core Portability Fixes
+1. Create `lib/Db/PlatformHelper.php` with 3 methods:
+ - `getYearDiffExpression($db, $col)` — age/duration calculation
+ - `getMonthExpression($db, $col)` — extract month
+ - `getYearExpression($db, $col)` — extract year
+2. Update `lib/Service/QueryService.php` lines 300, 318 — use PlatformHelper
+3. Update `lib/Db/MemberMapper.php` lines 165, 301 — use PlatformHelper
+4. Update `lib/Db/LagerMapper.php` line 70 — use PlatformHelper
+5. Update `appinfo/info.xml` — add pgsql and sqlite database declarations
+
+## Phase 2: Docker Infrastructure
+6. Create `docker/postgres/docker-compose.yml`
+7. Create `docker/sqlite/docker-compose.yml`
+8. Add Makefile targets for postgres and sqlite
+
+## Phase 3: Integration Tests
+9. Create `tests/DatabasePortability/PlatformHelperTest.php`
+
+## Phase 4: CI Pipeline
+10. Create `.gitea/workflows/database-portability.yml`
+
+## Phase 5: Security Review
+11. Verify PlatformHelper introduces no SQL injection vectors
+12. Verify column expressions are hardcoded, not user-supplied
+
+## AC Verification Checklist
+1. [ ] appinfo/info.xml declares mysql, pgsql, and sqlite
+2. [ ] No MySQL-specific SQL remains (no TIMESTAMPDIFF, CURDATE, LIKE on date columns)
+3. [ ] PlatformHelper generates correct expressions for all 3 backends
+4. [ ] Age/duration filters use PlatformHelper
+5. [ ] Birthday-this-month filter uses PlatformHelper
+6. [ ] Lager findByYear uses PlatformHelper
+7. [ ] Docker Compose files exist for MySQL, PostgreSQL, and SQLite
+8. [ ] Integration test suite exists
+9. [ ] CI pipeline definition exists
+10. [ ] No SQL injection vectors introduced (all expressions use hardcoded column names)
diff --git a/Makefile b/Makefile
index 2745ca4..5a2a8f3 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,6 @@
-.PHONY: build deps up down setup deploy redeploy logs clean package release
+.PHONY: build deps up down setup deploy redeploy logs clean package release \
+ up-postgres setup-postgres deploy-postgres clean-postgres \
+ up-sqlite setup-sqlite deploy-sqlite clean-sqlite
# Install dependencies (composer via Docker since PHP may not be local)
deps:
@@ -129,6 +131,76 @@ release: package
echo "WARNING: Could not create Gitea release (missing ~/.gitea-token?). Tarball is in artifacts/."; \
fi
+# ── PostgreSQL variant ──────────────────────────────────────────────
+up-postgres:
+ docker compose -f docker/postgres/docker-compose.yml up -d
+
+setup-postgres: build
+ @echo "Waiting for Nextcloud (PostgreSQL) container to be ready..."
+ @until docker compose -f docker/postgres/docker-compose.yml exec -T nextcloud test -f config/CAN_INSTALL 2>/dev/null || \
+ docker compose -f docker/postgres/docker-compose.yml exec -T nextcloud test -f config/config.php 2>/dev/null; do \
+ sleep 3; \
+ echo " still waiting..."; \
+ done
+ @sleep 5
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud chown www-data:www-data /var/www/html/custom_apps
+ docker compose -f docker/postgres/docker-compose.yml exec -u www-data nextcloud php occ maintenance:install \
+ --database=pgsql \
+ --database-host=db \
+ --database-name=nextcloud \
+ --database-user=nextcloud \
+ --database-pass=nextcloud \
+ --admin-user=admin \
+ --admin-pass=admin \
+ 2>&1 || true
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud mkdir -p /var/www/html/custom_apps/mitgliederverwaltung
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud cp -a /app-src/appinfo /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud cp -a /app-src/lib /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud cp -a /app-src/templates /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud cp -a /app-src/js /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud cp -a /app-src/vendor /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/postgres/docker-compose.yml exec nextcloud chown -R www-data:www-data /var/www/html/custom_apps/mitgliederverwaltung
+ docker compose -f docker/postgres/docker-compose.yml exec -u www-data nextcloud php occ app:enable mitgliederverwaltung
+ @echo "Done! Nextcloud (PostgreSQL) running at http://localhost:8081"
+
+deploy-postgres: build up-postgres setup-postgres
+
+clean-postgres:
+ docker compose -f docker/postgres/docker-compose.yml down -v
+
+# ── SQLite variant ──────────────────────────────────────────────────
+up-sqlite:
+ docker compose -f docker/sqlite/docker-compose.yml up -d
+
+setup-sqlite: build
+ @echo "Waiting for Nextcloud (SQLite) container to be ready..."
+ @until docker compose -f docker/sqlite/docker-compose.yml exec -T nextcloud test -f config/CAN_INSTALL 2>/dev/null || \
+ docker compose -f docker/sqlite/docker-compose.yml exec -T nextcloud test -f config/config.php 2>/dev/null; do \
+ sleep 3; \
+ echo " still waiting..."; \
+ done
+ @sleep 5
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud chown www-data:www-data /var/www/html/custom_apps
+ docker compose -f docker/sqlite/docker-compose.yml exec -u www-data nextcloud php occ maintenance:install \
+ --database=sqlite \
+ --admin-user=admin \
+ --admin-pass=admin \
+ 2>&1 || true
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud mkdir -p /var/www/html/custom_apps/mitgliederverwaltung
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud cp -a /app-src/appinfo /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud cp -a /app-src/lib /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud cp -a /app-src/templates /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud cp -a /app-src/js /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud cp -a /app-src/vendor /var/www/html/custom_apps/mitgliederverwaltung/
+ docker compose -f docker/sqlite/docker-compose.yml exec nextcloud chown -R www-data:www-data /var/www/html/custom_apps/mitgliederverwaltung
+ docker compose -f docker/sqlite/docker-compose.yml exec -u www-data nextcloud php occ app:enable mitgliederverwaltung
+ @echo "Done! Nextcloud (SQLite) running at http://localhost:8082"
+
+deploy-sqlite: build up-sqlite setup-sqlite
+
+clean-sqlite:
+ docker compose -f docker/sqlite/docker-compose.yml down -v
+
# Remove volumes (full reset)
clean:
docker compose down -v
diff --git a/appinfo/info.xml b/appinfo/info.xml
index fd9b2da..f61a35e 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -14,6 +14,8 @@
mysql
+ pgsql
+ sqlite
OCA\Mitgliederverwaltung\BackgroundJob\SyncQueueJob
diff --git a/docker/postgres/docker-compose.yml b/docker/postgres/docker-compose.yml
new file mode 100644
index 0000000..21290bc
--- /dev/null
+++ b/docker/postgres/docker-compose.yml
@@ -0,0 +1,44 @@
+# Docker Compose for Nextcloud 28 with PostgreSQL 16
+# Usage: docker compose -f docker/postgres/docker-compose.yml up -d
+#
+# Part of Issue #192 (database portability).
+
+services:
+ db:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: nextcloud
+ POSTGRES_USER: nextcloud
+ POSTGRES_PASSWORD: nextcloud
+ volumes:
+ - pg_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U nextcloud"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ nextcloud:
+ image: nextcloud:28-apache
+ restart: unless-stopped
+ ports:
+ - "8081:80"
+ environment:
+ POSTGRES_HOST: db
+ POSTGRES_DB: nextcloud
+ POSTGRES_USER: nextcloud
+ POSTGRES_PASSWORD: nextcloud
+ NEXTCLOUD_ADMIN_USER: admin
+ NEXTCLOUD_ADMIN_PASSWORD: admin
+ NEXTCLOUD_TRUSTED_DOMAINS: "localhost"
+ volumes:
+ - nc_data:/var/www/html
+ - ../../:/app-src:ro
+ depends_on:
+ db:
+ condition: service_healthy
+
+volumes:
+ pg_data:
+ nc_data:
diff --git a/docker/sqlite/docker-compose.yml b/docker/sqlite/docker-compose.yml
new file mode 100644
index 0000000..d0d65ab
--- /dev/null
+++ b/docker/sqlite/docker-compose.yml
@@ -0,0 +1,22 @@
+# Docker Compose for Nextcloud 28 with SQLite (no external DB service)
+# Usage: docker compose -f docker/sqlite/docker-compose.yml up -d
+#
+# Part of Issue #192 (database portability).
+
+services:
+ nextcloud:
+ image: nextcloud:28-apache
+ restart: unless-stopped
+ ports:
+ - "8082:80"
+ environment:
+ SQLITE_DATABASE: nextcloud
+ NEXTCLOUD_ADMIN_USER: admin
+ NEXTCLOUD_ADMIN_PASSWORD: admin
+ NEXTCLOUD_TRUSTED_DOMAINS: "localhost"
+ volumes:
+ - nc_data:/var/www/html
+ - ../../:/app-src:ro
+
+volumes:
+ nc_data:
diff --git a/lib/Db/LagerMapper.php b/lib/Db/LagerMapper.php
index 6665c11..e3f7f3b 100644
--- a/lib/Db/LagerMapper.php
+++ b/lib/Db/LagerMapper.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
+use OCA\Mitgliederverwaltung\Db\PlatformHelper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
@@ -59,15 +60,25 @@ class LagerMapper extends QBMapper {
/**
* Find camps by year (startdatum in given year).
+ * Uses platform-aware year extraction instead of LIKE on date columns.
+ *
+ * Updated for Issue #192 (database portability).
*
* @return Lager[]
* @throws Exception
*/
public function findByYear(int $year): array {
$qb = $this->db->getQueryBuilder();
+ $yearExpr = PlatformHelper::getYearExpression($this->db, 'startdatum');
+
$qb->select('*')
->from($this->getTableName())
- ->where($qb->expr()->like('startdatum', $qb->createNamedParameter($year . '-%')))
+ ->where(
+ $qb->expr()->eq(
+ $qb->createFunction($yearExpr),
+ $qb->createNamedParameter($year, IQueryBuilder::PARAM_INT)
+ )
+ )
->orderBy('startdatum', 'ASC');
return $this->findEntities($qb);
diff --git a/lib/Db/MemberMapper.php b/lib/Db/MemberMapper.php
index 2735f02..cc37f80 100644
--- a/lib/Db/MemberMapper.php
+++ b/lib/Db/MemberMapper.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Db;
+use OCA\Mitgliederverwaltung\Db\PlatformHelper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
@@ -147,24 +148,24 @@ class MemberMapper extends QBMapper {
/**
* Find members with a birthday in the given month.
- * Compares the month part of the geburtsdatum string (format YYYY-MM-DD).
+ * Uses platform-aware month extraction instead of LIKE on date columns.
*
- * Part of Issue #34.
+ * Part of Issue #34, updated for Issue #192 (database portability).
*
* @return Member[]
* @throws Exception
*/
public function findByBirthdayMonth(int $month): array {
$qb = $this->db->getQueryBuilder();
- $monthStr = str_pad((string)$month, 2, '0', STR_PAD_LEFT);
+ $monthExpr = PlatformHelper::getMonthExpression($this->db, 'geburtsdatum');
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'))
->andWhere(
- $qb->expr()->like(
- 'geburtsdatum',
- $qb->createNamedParameter('%-' . $monthStr . '-%')
+ $qb->expr()->eq(
+ $qb->createFunction($monthExpr),
+ $qb->createNamedParameter($month, IQueryBuilder::PARAM_INT)
)
)
->orderBy('geburtsdatum', 'ASC');
@@ -296,9 +297,13 @@ class MemberMapper extends QBMapper {
}
if ($birthdayThisMonth) {
- $monthStr = str_pad((string)(int)(new \DateTime())->format('m'), 2, '0', STR_PAD_LEFT);
+ $currentMonth = (int)(new \DateTime())->format('m');
+ $monthExpr = PlatformHelper::getMonthExpression($this->db, 'm.geburtsdatum');
$qb->andWhere(
- $qb->expr()->like('m.geburtsdatum', $qb->createNamedParameter('%-' . $monthStr . '-%'))
+ $qb->expr()->eq(
+ $qb->createFunction($monthExpr),
+ $qb->createNamedParameter($currentMonth, IQueryBuilder::PARAM_INT)
+ )
);
}
diff --git a/lib/Db/PlatformHelper.php b/lib/Db/PlatformHelper.php
new file mode 100644
index 0000000..251272f
--- /dev/null
+++ b/lib/Db/PlatformHelper.php
@@ -0,0 +1,95 @@
+getDatabaseProvider();
+
+ return match ($platform) {
+ IDBConnection::PLATFORM_POSTGRES =>
+ "EXTRACT(YEAR FROM AGE(CURRENT_DATE, $col))::integer",
+ IDBConnection::PLATFORM_SQLITE =>
+ // SQLite: compute raw year difference, then subtract 1 if the
+ // birthday/anniversary hasn't occurred yet this year.
+ "("
+ . "CAST(strftime('%Y', 'now') AS INTEGER) - CAST(strftime('%Y', $col) AS INTEGER)"
+ . " - CASE WHEN strftime('%m-%d', 'now') < strftime('%m-%d', $col) THEN 1 ELSE 0 END"
+ . ")",
+ // MySQL / MariaDB (default)
+ default =>
+ "TIMESTAMPDIFF(YEAR, $col, CURDATE())",
+ };
+ }
+
+ /**
+ * Generate an expression that extracts the month (1-12) from a DATE column.
+ *
+ * Portable replacement for MySQL's MONTH(col).
+ *
+ * @param IDBConnection $db The database connection
+ * @param string $col The fully-qualified column reference (e.g. 'm.geburtsdatum')
+ * @return string Raw SQL expression returning the month as an integer
+ */
+ public static function getMonthExpression(IDBConnection $db, string $col): string {
+ $platform = $db->getDatabaseProvider();
+
+ return match ($platform) {
+ IDBConnection::PLATFORM_POSTGRES =>
+ "EXTRACT(MONTH FROM $col)::integer",
+ IDBConnection::PLATFORM_SQLITE =>
+ "CAST(strftime('%m', $col) AS INTEGER)",
+ // MySQL / MariaDB (default)
+ default =>
+ "MONTH($col)",
+ };
+ }
+
+ /**
+ * Generate an expression that extracts the four-digit year from a DATE column.
+ *
+ * Portable replacement for MySQL's YEAR(col).
+ *
+ * @param IDBConnection $db The database connection
+ * @param string $col The fully-qualified column reference (e.g. 'l.startdatum')
+ * @return string Raw SQL expression returning the year as an integer
+ */
+ public static function getYearExpression(IDBConnection $db, string $col): string {
+ $platform = $db->getDatabaseProvider();
+
+ return match ($platform) {
+ IDBConnection::PLATFORM_POSTGRES =>
+ "EXTRACT(YEAR FROM $col)::integer",
+ IDBConnection::PLATFORM_SQLITE =>
+ "CAST(strftime('%Y', $col) AS INTEGER)",
+ // MySQL / MariaDB (default)
+ default =>
+ "YEAR($col)",
+ };
+ }
+}
diff --git a/lib/Service/QueryService.php b/lib/Service/QueryService.php
index 0a6eb52..b9eb43f 100644
--- a/lib/Service/QueryService.php
+++ b/lib/Service/QueryService.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
+use OCA\Mitgliederverwaltung\Db\PlatformHelper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;
@@ -294,10 +295,10 @@ class QueryService {
}
/**
- * Build age comparison using TIMESTAMPDIFF on geburtsdatum.
+ * Build age comparison using platform-aware year-diff expression on geburtsdatum.
*/
private function buildAgeCondition(IQueryBuilder $qb, string $op, $value): string {
- $ageExpr = "TIMESTAMPDIFF(YEAR, m.geburtsdatum, CURDATE())";
+ $ageExpr = PlatformHelper::getYearDiffExpression($this->db, 'm.geburtsdatum');
$param = $qb->createNamedParameter((int)$value, IQueryBuilder::PARAM_INT);
return match ($op) {
@@ -312,10 +313,10 @@ class QueryService {
}
/**
- * Build membership duration comparison using TIMESTAMPDIFF on eintritt.
+ * Build membership duration comparison using platform-aware year-diff expression on eintritt.
*/
private function buildMembershipDurationCondition(IQueryBuilder $qb, string $op, $value): string {
- $durationExpr = "TIMESTAMPDIFF(YEAR, m.eintritt, CURDATE())";
+ $durationExpr = PlatformHelper::getYearDiffExpression($this->db, 'm.eintritt');
$param = $qb->createNamedParameter((int)$value, IQueryBuilder::PARAM_INT);
return match ($op) {
diff --git a/phpunit.xml b/phpunit.xml
index ec5d51c..f1cbe5b 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -11,6 +11,9 @@
tests/Integration
+
+ tests/DatabasePortability
+
diff --git a/tests/DatabasePortability/DatabasePortabilityTest.php b/tests/DatabasePortability/DatabasePortabilityTest.php
new file mode 100644
index 0000000..be3d417
--- /dev/null
+++ b/tests/DatabasePortability/DatabasePortabilityTest.php
@@ -0,0 +1,152 @@
+createMock(IDBConnection::class);
+ $db->method('getDatabaseProvider')->willReturn($platform);
+
+ $expr = PlatformHelper::$method($db, $col);
+
+ $this->assertIsString($expr);
+ $this->assertNotEmpty($expr);
+ $this->assertStringContainsString($col, $expr,
+ "Expression for $method on $platform must reference the column");
+ }
+
+ public static function platformAndMethodProvider(): array {
+ $platforms = [
+ IDBConnection::PLATFORM_MYSQL,
+ IDBConnection::PLATFORM_POSTGRES,
+ IDBConnection::PLATFORM_SQLITE,
+ ];
+ $methods = [
+ ['getYearDiffExpression', 'm.geburtsdatum'],
+ ['getMonthExpression', 'm.geburtsdatum'],
+ ['getYearExpression', 'l.startdatum'],
+ ];
+
+ $cases = [];
+ foreach ($platforms as $p) {
+ foreach ($methods as [$m, $c]) {
+ $cases["$p/$m"] = [$p, $m, $c];
+ }
+ }
+ return $cases;
+ }
+
+ /**
+ * Verify that MySQL expressions use the expected MySQL-specific functions.
+ */
+ public function testMySQLExpressionsUseCorrectFunctions(): void {
+ $db = $this->createMock(IDBConnection::class);
+ $db->method('getDatabaseProvider')->willReturn(IDBConnection::PLATFORM_MYSQL);
+
+ $this->assertStringContainsString('TIMESTAMPDIFF',
+ PlatformHelper::getYearDiffExpression($db, 'col'));
+ $this->assertStringContainsString('MONTH(',
+ PlatformHelper::getMonthExpression($db, 'col'));
+ $this->assertStringContainsString('YEAR(',
+ PlatformHelper::getYearExpression($db, 'col'));
+ }
+
+ /**
+ * Verify that PostgreSQL expressions use EXTRACT and AGE.
+ */
+ public function testPostgresExpressionsUseCorrectFunctions(): void {
+ $db = $this->createMock(IDBConnection::class);
+ $db->method('getDatabaseProvider')->willReturn(IDBConnection::PLATFORM_POSTGRES);
+
+ $yearDiff = PlatformHelper::getYearDiffExpression($db, 'col');
+ $this->assertStringContainsString('EXTRACT', $yearDiff);
+ $this->assertStringContainsString('AGE', $yearDiff);
+
+ $this->assertStringContainsString('EXTRACT(MONTH',
+ PlatformHelper::getMonthExpression($db, 'col'));
+ $this->assertStringContainsString('EXTRACT(YEAR',
+ PlatformHelper::getYearExpression($db, 'col'));
+ }
+
+ /**
+ * Verify that SQLite expressions use strftime.
+ */
+ public function testSQLiteExpressionsUseCorrectFunctions(): void {
+ $db = $this->createMock(IDBConnection::class);
+ $db->method('getDatabaseProvider')->willReturn(IDBConnection::PLATFORM_SQLITE);
+
+ $yearDiff = PlatformHelper::getYearDiffExpression($db, 'col');
+ $this->assertStringContainsString('strftime', $yearDiff);
+ // SQLite year diff includes birthday correction logic
+ $this->assertStringContainsString('CASE WHEN', $yearDiff);
+
+ $this->assertStringContainsString('strftime',
+ PlatformHelper::getMonthExpression($db, 'col'));
+ $this->assertStringContainsString('strftime',
+ PlatformHelper::getYearExpression($db, 'col'));
+ }
+
+ /**
+ * Verify that no platform expression contains obvious SQL injection vectors.
+ * Column names should appear verbatim, and no user-controllable content
+ * should be interpolated.
+ */
+ public function testNoInjectionVectorsInExpressions(): void {
+ $platforms = [
+ IDBConnection::PLATFORM_MYSQL,
+ IDBConnection::PLATFORM_POSTGRES,
+ IDBConnection::PLATFORM_SQLITE,
+ ];
+
+ foreach ($platforms as $platform) {
+ $db = $this->createMock(IDBConnection::class);
+ $db->method('getDatabaseProvider')->willReturn($platform);
+
+ // Use a safe column reference
+ $col = 'm.test_col';
+ $methods = ['getYearDiffExpression', 'getMonthExpression', 'getYearExpression'];
+
+ foreach ($methods as $method) {
+ $expr = PlatformHelper::$method($db, $col);
+
+ // Must not contain any parameter placeholders (? or :param)
+ // because these are raw expressions, not parameterized queries
+ $this->assertStringNotContainsString('?', $expr,
+ "Expression for $method on $platform must not contain ? placeholders");
+
+ // The column reference must appear exactly as passed
+ $this->assertStringContainsString($col, $expr,
+ "Expression for $method on $platform must reference column verbatim");
+ }
+ }
+ }
+}
diff --git a/tests/Unit/PlatformHelperTest.php b/tests/Unit/PlatformHelperTest.php
new file mode 100644
index 0000000..530bd21
--- /dev/null
+++ b/tests/Unit/PlatformHelperTest.php
@@ -0,0 +1,124 @@
+createMock(IDBConnection::class);
+ $db->method('getDatabaseProvider')->willReturn($platform);
+ return $db;
+ }
+
+ // ── getYearDiffExpression ──────────────────────────────────────
+
+ public function testYearDiffMySQL(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_MYSQL);
+ $expr = PlatformHelper::getYearDiffExpression($db, 'm.geburtsdatum');
+ $this->assertStringContainsString('TIMESTAMPDIFF', $expr);
+ $this->assertStringContainsString('CURDATE()', $expr);
+ $this->assertStringContainsString('m.geburtsdatum', $expr);
+ }
+
+ public function testYearDiffPostgreSQL(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_POSTGRES);
+ $expr = PlatformHelper::getYearDiffExpression($db, 'm.geburtsdatum');
+ $this->assertStringContainsString('EXTRACT', $expr);
+ $this->assertStringContainsString('AGE', $expr);
+ $this->assertStringContainsString('CURRENT_DATE', $expr);
+ $this->assertStringContainsString('m.geburtsdatum', $expr);
+ }
+
+ public function testYearDiffSQLite(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_SQLITE);
+ $expr = PlatformHelper::getYearDiffExpression($db, 'm.geburtsdatum');
+ $this->assertStringContainsString("strftime('%Y'", $expr);
+ $this->assertStringContainsString('m.geburtsdatum', $expr);
+ // SQLite expression should include the birthday correction
+ $this->assertStringContainsString('CASE WHEN', $expr);
+ }
+
+ // ── getMonthExpression ─────────────────────────────────────────
+
+ public function testMonthMySQL(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_MYSQL);
+ $expr = PlatformHelper::getMonthExpression($db, 'geburtsdatum');
+ $this->assertEquals('MONTH(geburtsdatum)', $expr);
+ }
+
+ public function testMonthPostgreSQL(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_POSTGRES);
+ $expr = PlatformHelper::getMonthExpression($db, 'geburtsdatum');
+ $this->assertStringContainsString('EXTRACT(MONTH FROM geburtsdatum)', $expr);
+ }
+
+ public function testMonthSQLite(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_SQLITE);
+ $expr = PlatformHelper::getMonthExpression($db, 'geburtsdatum');
+ $this->assertStringContainsString("strftime('%m', geburtsdatum)", $expr);
+ }
+
+ // ── getYearExpression ──────────────────────────────────────────
+
+ public function testYearMySQL(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_MYSQL);
+ $expr = PlatformHelper::getYearExpression($db, 'startdatum');
+ $this->assertEquals('YEAR(startdatum)', $expr);
+ }
+
+ public function testYearPostgreSQL(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_POSTGRES);
+ $expr = PlatformHelper::getYearExpression($db, 'startdatum');
+ $this->assertStringContainsString('EXTRACT(YEAR FROM startdatum)', $expr);
+ }
+
+ public function testYearSQLite(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_SQLITE);
+ $expr = PlatformHelper::getYearExpression($db, 'startdatum');
+ $this->assertStringContainsString("strftime('%Y', startdatum)", $expr);
+ }
+
+ // ── Column reference pass-through ──────────────────────────────
+
+ public function testColumnReferencePreservedInAllMethods(): void {
+ $db = $this->createMockDb(IDBConnection::PLATFORM_MYSQL);
+ $col = 'x.custom_col';
+
+ $this->assertStringContainsString($col, PlatformHelper::getYearDiffExpression($db, $col));
+ $this->assertStringContainsString($col, PlatformHelper::getMonthExpression($db, $col));
+ $this->assertStringContainsString($col, PlatformHelper::getYearExpression($db, $col));
+ }
+
+ // ── All three platforms produce non-empty expressions ──────────
+
+ /**
+ * @dataProvider platformProvider
+ */
+ public function testAllPlatformsProduceNonEmptyExpressions(string $platform): void {
+ $db = $this->createMockDb($platform);
+
+ $this->assertNotEmpty(PlatformHelper::getYearDiffExpression($db, 'col'));
+ $this->assertNotEmpty(PlatformHelper::getMonthExpression($db, 'col'));
+ $this->assertNotEmpty(PlatformHelper::getYearExpression($db, 'col'));
+ }
+
+ public static function platformProvider(): array {
+ return [
+ 'mysql' => [IDBConnection::PLATFORM_MYSQL],
+ 'postgres' => [IDBConnection::PLATFORM_POSTGRES],
+ 'sqlite' => [IDBConnection::PLATFORM_SQLITE],
+ ];
+ }
+}