<?php
/*
* RCMCardDAV - CardDAV plugin for Roundcube webmail
*
* Copyright (C) 2011-2022 Benjamin Schieder <rcmcarddav@wegwerf.anderdonau.de>,
* Michael Stilkerich <ms@mike2k.de>
*
* This file is part of RCMCardDAV.
*
* RCMCardDAV is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* RCMCardDAV is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with RCMCardDAV. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace MStilkerich\Tests\RCMCardDAV\Unit;
use PHPUnit\Framework\TestCase;
use MStilkerich\Tests\RCMCardDAV\TestInfrastructure;
use MStilkerich\RCMCardDAV\Db\{AbstractDatabase,DbAndCondition,DbOrCondition};
/**
* Implementation of the database access interface to the roundcube DB for unit tests.
*
* It emulates the DB operations based on an initial dataset read from a Json file.
*
* @psalm-type JsonDbSchema = array<string,string> Maps column names to a column definition
* @psalm-type JsonDbRow = array<string,?string> Maps column names to column value; all data is represented as string
* @psalm-type JsonDbColDef = array{
* nullable: bool,
* type: 'string'|'int'|'key',
* fktable: string, fkcolumn: string,
* hasdefault: bool, defaultval: ?string
* }
*
*
* @psalm-import-type DbConditions from AbstractDatabase
*/
class JsonDatabase extends AbstractDatabase
{
/** @var bool $inTransaction Indicates whether we are currently inside a transaction */
private $inTransaction = false;
/** @var array<string,JsonDbSchema> Maps DB table names to their schema definition */
private $schema;
/** @var array<string,list<JsonDbRow>> Maps DB table names to an array of their data rows */
private $data = [];
/**
* Initializes a JsonDatabase instance.
*
* @param list<string> $jsonDbData Paths to JSON files containing the initial data for the tables.
* @param string $jsonDbSchema Path to a JSON file defining the tables.
*/
public function __construct(
array $jsonDbData = [],
string $jsonDbSchema = "tests/Unit/data/jsonDb/schema.json"
) {
$schema = TestInfrastructure::readJsonArray($jsonDbSchema);
$this->validateSchema($schema);
$this->schema = $schema;
foreach (array_keys($this->schema) as $table) {
$this->data[$table] = [];
}
foreach ($jsonDbData as $dataFile) {
$this->importData($dataFile);
}
}
/**
* Validates the loaded database schema.
*
* It performs the following checks:
* - Schema has the expected structure / types
* - Foreign key references point to existing key columns
*
* @psalm-assert array<string,JsonDbSchema> $schema
*/
private function validateSchema(array $schema): void
{
foreach ($schema as $table => $tcols) {
TestCase::assertIsString($table, "Indexes of schema must be table names");
TestCase::assertIsArray($tcols, "Table schema must be array of column definitions");
foreach (array_keys($tcols) as $col) {
TestCase::assertIsString($col, "Indexes of column definitions must be column names");
TestCase::assertIsString($tcols[$col], "A column definitions must be a string");
$coldef = $this->parseColumnDef($tcols[$col]);
if (!empty($coldef["fktable"])) {
$fktable = $coldef["fktable"];
$fkcol = $coldef["fkcolumn"];
TestCase::assertTrue(
($schema[$fktable][$fkcol] ?? "") == "key",
"Invalid foreign key ref $table.$col -> $fktable.$fkcol"
);
}
}
}
}
/**
* Imports the data rows contained in the given JSON file to the corresponding tables.
*
* Data can be imported from external sources (currently from an external file) if the data item of a cell is not a
* string value but an array. The array has two members: A type (currently only "file") and a parameter (the
* filename, relative to the JSON file).
*
* The function uses the insert() function to add each imported row to the table, and therefore the data validation
* that is performed by insert() also applies to data imported from JSON files.
*
* @param string $dataFile Path to a JSON file containing the row definitions.
*/
public function importData(string $dataFile): void
{
$data = TestInfrastructure::readJsonArray($dataFile);
foreach ($data as $table => $rows) {
TestCase::assertIsString($table, "Indexes of import data must be table names");
TestCase::assertIsArray($rows, "Table data must be array of rows");
TestCase::assertArrayHasKey($table, $this->schema);
foreach (array_keys($rows) as $rowidx) {
TestCase::assertIsArray($rows[$rowidx], "Row data must be an array");
$row = $rows[$rowidx];
$cols = [];
$vals = [];
TestCase::assertEquals([], array_diff_key($this->schema[$table], $row), "Row lacks columns");
TestCase::assertEquals([], array_diff_key($row, $this->schema[$table]), "Row has non-schema columns");
foreach (array_keys($row) as $col) {
TestCase::assertIsString($col, "Column name must be string");
$cols[] = $col;
if (isset($row[$col])) {
if (is_array($row[$col])) {
[ $type, $param ] = $row[$col];
if ($type === "file") {
TestCase::assertIsString($param, "Parameter to file reference must be filename");
$vals[] = TestInfrastructure::readFileRelative($param, $dataFile);
} else {
throw new \Exception("Unknown data input type $type with param $param");
}
} else {
$vals[] = (string) $row[$col];
}
} else {
$vals[] = null;
}
}
$this->insert($table, $cols, [$vals]);
}
}
}
/**
* This function compares two rows of a table, possibly from different databases.
*
* It returns an integer that defines an order between the rows and therefore can also be used to sort table rows
* for easier comparison of two entire tables.
*
* The comparison considers that key values may differ in rows of different databases. The values of key columns are
* ignored in the comparison. For foreign key columns, this function is called recursively to compare the referenced
* rows to determine equality, not the value of the foreign key.
*
* @param string $table The table the given rows belong to.
* @param JsonDatabase $row2Db The JsonDatabase object $row2 belongs to ($row1 always belong to $this)
* @param JsonDbRow $row1 A row from the given table in $this JsonDatabase object
* @param JsonDbRow $row2 A row from the given table in $row2Db JsonDatabase object
* @return int Negative/Zero/Positive if $row1 is smaller/equal/greater than $row2
*/
public function compareRows(string $table, JsonDatabase $row2Db, array $row1, array $row2): int
{
// check parameters
TestCase::assertArrayHasKey($table, $this->schema, "compareRows of unknown table $table");
$tcols = $this->schema[$table];
TestCase::assertCount(count($tcols), $row1, "compareRows for row1 with columns not matching schema");
TestCase::assertCount(count($tcols), $row2, "compareRows for row2 with columns not matching schema");
// compare all the columns
foreach ($tcols as $col => $coldef) {
$coldef = $this->parseColumnDef($coldef);
TestCase::assertArrayHasKey($col, $row1, "compareRows: row1 lacks column $col");
TestCase::assertArrayHasKey($col, $row2, "compareRows: row2 lacks column $col");
if (!empty($coldef["fktable"])) {
$fktable = $coldef["fktable"];
$fkcol = $coldef["fkcolumn"];
$frow1 = $this->lookup([$fkcol => $row1[$col]], [], $fktable);
$frow2 = $row2Db->lookup([$fkcol => $row2[$col]], [], $fktable);
$result = $this->compareRows($fktable, $row2Db, $frow1, $frow2);
} elseif ($coldef["type"] === "key") {
$result = 0;
} else {
$result = self::strcmpNullable($row1[$col], $row2[$col]);
}
if ($result !== 0) {
return $result;
}
}
return 0;
}
/**
* Compares two tables of different databases with identical schema.
*
* The result is reported via JUnit assertions, i.e. a test calling this function should expect equality, therefore
* the test will fail in case of inequality of the two tables.
*
* @param string $table Name of the table to compare
* @param JsonDatabase $otherDb The other database whose table $table should be compared to the one in $this.
*/
public function compareTables(string $table, JsonDatabase $otherDb): void
{
// check parameters
TestCase::assertArrayHasKey($table, $this->schema, "compareTables of unknown table $table");
TestCase::assertArrayHasKey($table, $otherDb->schema, "compareTables of unknown table $table");
TestCase::assertSame($this->schema[$table], $otherDb->schema[$table], "Schema of table $table mismatch");
TestCase::assertArrayHasKey($table, $this->data, "compareTables of unknown table $table");
TestCase::assertArrayHasKey($table, $otherDb->data, "compareTables of unknown table $table");
$compareFn1 = function (array $row1, array $row2) use ($table): int {
/**
* @var JsonDbRow $row1
* @var JsonDbRow $row2
*/
return $this->compareRows($table, $this, $row1, $row2);
};
$compareFn2 = function (array $row1, array $row2) use ($table, $otherDb): int {
/**
* @var JsonDbRow $row1
* @var JsonDbRow $row2
*/
return $otherDb->compareRows($table, $otherDb, $row1, $row2);
};
$t1sorted = $this->data[$table];
$t2sorted = $otherDb->data[$table];
usort($t1sorted, $compareFn1);
usort($t2sorted, $compareFn2);
TestCase::assertCount(count($t1sorted), $t2sorted, "Compare tables $table with different number of rows");
for ($i = 0; $i < count($t1sorted); ++$i) {
$diff = $this->compareRows($table, $otherDb, $t1sorted[$i], $t2sorted[$i]);
if ($diff !== 0) {
TestCase::assertSame($t1sorted[$i], $t2sorted[$i], "$table row $i differs");
}
}
}
public function startTransaction(bool $readonly = true): void
{
if ($this->inTransaction) {
throw new \Exception("Cannot start nested transaction");
} else {
$this->inTransaction = true;
}
}
public function endTransaction(): void
{
if ($this->inTransaction) {
$this->inTransaction = false;
} else {
throw new \Exception("Attempt to commit a transaction while not within a transaction");
}
}
public function rollbackTransaction(): void
{
$this->inTransaction = false;
}
public function checkMigrations(string $dbPrefix, string $scriptDir): void
{
throw new \Exception("checkMigrations() is not implemented");
}
public function insert(string $table, array $cols, array $rows): string
{
// check parameters
TestCase::assertArrayHasKey($table, $this->schema, "Insert for unknown table $table");
$numCols = count($cols);
if (empty($rows)) {
throw new \Exception(__METHOD__ . " on $table called without rows to insert");
}
// insert rows
foreach ($rows as $row) {
if (count($row) != $numCols) {
throw new \Exception(__METHOD__ . " on $table: row given that does not match $numCols columns");
}
// build record from given data
$record = [];
for ($i = 0; $i < $numCols; ++$i) {
$col = $cols[$i];
TestCase::assertArrayHasKey($col, $this->schema[$table], "insert on $table with unknown column $col");
$record[$col] = $row[$i];
}
// check / complete record for insertion
$record = $this->validateRecord($table, $record);
$this->data[$table][] = $record;
$dbid = $record["id"] ?? "";
}
return $dbid;
}
public function update(
$conditions,
array $cols,
array $vals,
string $table = 'contacts'
): int {
// check parameters
TestCase::assertArrayHasKey($table, $this->data, "Update for unknown table $table");
TestCase::assertSame(count($cols), count($vals), __METHOD__ . " called with non-matching cols/vals");
$rowsAffected = 0;
foreach ($this->data[$table] as &$row) {
if ($this->checkRowMatch($conditions, $row)) {
++$rowsAffected;
foreach (array_combine($cols, $vals) as $col => $val) {
$this->validateValue($table, $col, $val, false);
$row[$col] = $val;
}
}
}
unset($row);
return $rowsAffected;
}
public function delete($conditions, string $table = 'contacts'): int
{
// check parameters
TestCase::assertArrayHasKey($table, $this->data, "Update for unknown table $table");
$rowsAffected = 0;
$newRows = [];
foreach ($this->data[$table] as $row) {
if ($this->checkRowMatch($conditions, $row)) {
++$rowsAffected;
} else {
$newRows[] = $row;
}
}
$this->data[$table] = $newRows;
return $rowsAffected;
}
/**
* @param JsonDbRow $record
* @return JsonDbRow $record
*/
private function validateRecord(string $table, array $record): array
{
foreach (array_keys($this->schema[$table]) as $col) {
if (key_exists($col, $record)) {
$this->validateValue($table, $col, $record[$col], true);
} else {
$record[$col] = $this->defaultValue($table, $col);
}
}
return $record;
}
private function validateValue(string $table, string $col, ?string $value, bool $newRecord): void
{
$coldef = $this->parseColumnDef($this->schema[$table][$col]);
if (!empty($coldef["fktable"])) {
$this->checkKey($coldef["fktable"], $coldef["fkcolumn"], $value, false);
} elseif ($coldef["type"] === "key") {
$this->checkKey($table, $col, $value, $newRecord);
} else {
if (!$coldef["nullable"]) {
TestCase::assertNotNull($value);
}
if (isset($value)) {
if ($coldef["type"] === "int") {
TestCase::assertStringMatchesFormat("%i", $value, "column $table.$col must be int ($value)");
}
}
}
}
/**
* Parses a column definition.
*
* @return JsonDbColDef Associative array with the column properties:
* nullable: true if nullable, false otherwise
* type: Type of the column (string, int, key)
* fktable: For a foreign key reference, table of the foreign key, empty string otherwise
* fkcolumn: For a foreign key reference, column name of the foreign key, empty string otherwise
* hasdefault: true if default value defined, false otherwise
* defaultval: Default value; only valid if hasdefault is true
*/
private function parseColumnDef(string $coldef): array
{
if (
preg_match(
'/^(\?)?' .
'(key(\[([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)\])?|int|string)' .
'(=?)(.*)?$/',
$coldef,
$matches
)
) {
/** key[fktable.fkcol] -> key
* @psalm-var 'key'|'int'|'string' $type
*/
$type = preg_replace('/^key\[.*/', 'key', $matches[2]);
$ret = [
'nullable' => (($matches[1] ?? "") === "?"),
'type' => $type,
'fktable' => $matches[4] ?? "",
'fkcolumn' => $matches[5] ?? "",
'hasdefault' => (($matches[6] ?? "") === "="),
'defaultval' => $matches[7] ?? null
];
return $ret;
} else {
throw new \Exception("Unparsable column definition $coldef");
}
}
private function checkKey(string $table, string $col, ?string $value, bool $newRecord): void
{
TestCase::assertNotNull($value, "key column $table.$col must not be null");
TestCase::assertStringMatchesFormat("%d", $value, "key column $table.$col must be unsigned int ($value)");
$usedKeys = array_column($this->data[$table], $col);
if ($newRecord) {
TestCase::assertNotContains($value, $usedKeys, "$table.$col=$value already exists for new record");
} else {
TestCase::assertContains($value, $usedKeys, "$table.$col=$value not found when expected");
}
}
private function defaultValue(string $table, string $col): ?string
{
$coldef = $this->parseColumnDef($this->schema[$table][$col]);
if (!empty($coldef["fktable"])) {
throw new \Exception("Default value for foreign key references not supported ($table.$col)");
} elseif ($coldef["type"] === "key") {
/** @psalm-var list<string> $usedKeys */
$usedKeys = array_column($this->data[$table], $col);
$defaultValue = (string) (empty($usedKeys) ? 1 : (intval(max($usedKeys)) + 1));
} else {
if ($coldef["hasdefault"]) {
$defaultValue = $coldef["defaultval"];
} elseif ($coldef["nullable"]) {
$defaultValue = null;
} else {
throw new \Exception("No default value for non-null column $table.$col");
}
}
return $defaultValue;
}
public function lookup($conditions, array $cols = [], string $table = 'contacts'): array
{
$rows = $this->get($conditions, $cols, $table);
$numRows = count($rows);
if ($numRows !== 1) {
throw new \Exception("Single-row query on $table with $numRows results: " . print_r($conditions, true));
}
return $rows[0];
}
public function get($conditions, array $cols = [], string $table = 'contacts', array $options = []): array
{
TestCase::assertArrayHasKey($table, $this->data, "Get for unknown table $table");
$columns = empty($cols) ? array_keys($this->schema[$table]) : $cols;
$filteredRows = [];
$unknownCols = array_diff($columns, array_keys($this->schema[$table]));
TestCase::assertCount(0, $unknownCols, "Get for unknown columns in table $table: " . join(', ', $unknownCols));
$columns = array_flip($columns);
foreach ($this->data[$table] as $row) {
if ($this->checkRowMatch($conditions, $row)) {
// append the full row to enable ordering on non-selected columns
$filteredRows[] = $row;
}
}
// ORDER
if (isset($options['order'])) {
$orderDef = [];
foreach ($options['order'] as $col) {
if ($col[0] === "!") {
$orderDef[] = [ substr($col, 1), -1 ];
} else {
$orderDef[] = [ $col, 1 ];
}
}
if (!empty($orderDef)) {
usort(
$filteredRows,
function (array $a, array $b) use ($orderDef): int {
/**
* @var JsonDbRow $a
* @var JsonDbRow $b
*/
$res = 0;
foreach ($orderDef as $od) {
[ $col, $orderAsc ] = $od;
$res = self::strcmpNullable($a[$col], $b[$col]) * $orderAsc;
if ($res !== 0) {
break;
}
}
return $res;
}
);
}
}
// Restrict to selected columns
foreach ($filteredRows as $idx => $row) {
$filteredRows[$idx] = array_intersect_key($row, $columns);
}
// COUNT
if ($options['count'] ?? false) {
$cntcols = empty($cols) ? ['*'] : $cols;
$result = [];
foreach ($cntcols as $col) {
if ($col === '*') {
$result['*'] = (string) count($filteredRows);
} else {
$nonNull = array_filter(
array_column($filteredRows, $col),
function (?string $v): bool {
return isset($v);
}
);
$result[$col] = (string) count($nonNull);
}
}
$filteredRows = [ $result ];
}
// LIMIT
if (isset($options['limit'])) {
$l = $options['limit'];
[ $offset, $limit ] = $l;
if ($offset >= 0 && $limit > 0) {
$filteredRows = array_slice($filteredRows, $offset, $limit);
} else {
$msg = "The limit option needs an array parameter of two unsigned integers [offset,limit]; got: ";
$msg .= print_r($l, true);
throw new \Exception($msg);
}
}
return $filteredRows;
}
/**
* Checks if the given row matches the given condition.
*
* @param DbOrCondition $orCond
* @param JsonDbRow $row The DB row to match with the condition
*
* @return bool Match result of condition against row
*
* @see DbOrCondition For a description on the format of field/value specifiers.
*/
private function checkRowMatchSingleCondition(DbOrCondition $orCond, array $row): bool
{
$invertCondition = false;
$ilike = false;
$field = $orCond->fieldSpec;
$value = $orCond->valueSpec;
if ($field[0] === "!") {
$field = substr($field, 1);
$invertCondition = true;
}
if ($field[0] === "%") {
$field = substr($field, 1);
$ilike = true;
}
TestCase::assertArrayHasKey($field, $row, "DB record lacks queried field $field");
if (!isset($value)) {
// match NULL / NOT NULL
return isset($row[$field]) == $invertCondition;
} elseif (is_array($value)) {
if (count($value) > 0) {
if ($ilike) {
throw new \Exception(__METHOD__ . " $field - ILIKE match only supported for single pattern");
}
return in_array($row[$field], $value) !== $invertCondition;
} else {
throw new \Exception(__METHOD__ . " $field - empty values array provided");
}
} else {
if ($ilike) {
if (isset($row[$field])) {
$matchPattern = str_replace('%', '.*', $value);
$match = preg_match("/^$matchPattern$/im", $row[$field]);
return $match != $invertCondition;
} else {
return false;
}
} else {
$equals = $row[$field] == $value;
return $equals != $invertCondition;
}
}
}
/**
* Checks if the given row matches all given conditions.
*
* @param DbConditions $conditions
* @param JsonDbRow $row The DB row to match with the condition
* @return bool Match result of conditions against row
*
* @see AbstractDatabase::normalizeConditions() for a description of $conditions
*/
private function checkRowMatch($conditions, array $row): bool
{
$conditions = $this->normalizeConditions($conditions);
foreach ($conditions as $andCond) {
$andCondMatched = false;
foreach ($andCond->orConditions as $orCond) {
if ($this->checkRowMatchSingleCondition($orCond, $row)) {
$andCondMatched = true;
break;
}
}
if ($andCondMatched === false) {
return false;
}
}
return true;
}
private static function strcmpNullable(?string $s1, ?string $s2): int
{
if (isset($s1) && isset($s2)) {
$result = strcmp($s1, $s2);
} elseif (isset($s1)) {
$result = 1;
} elseif (isset($s2)) {
$result = -1;
} else {
$result = 0;
}
return $result;
}
}
// vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120