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], + ]; + } +}