Add cli client, test stubs, fixes and improvements

This commit is contained in:
2024-09-27 12:11:22 +02:00
parent ff74faaa7f
commit 010911c684
10 changed files with 367 additions and 15 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
/vendor/
/.phpunit.*

174
bin/paramcli Executable file
View File

@ -0,0 +1,174 @@
#!/usr/bin/env php
<?php
require_once __DIR__."/../vendor/autoload.php";
$opts = (object)[
'help' => false,
'server' => '127.0.0.1:8000',
'args' => [],
'json' => false,
'from' => null,
'until' => null,
];
function parse_datetime(string $timestamp): ?DateTime {
if (strtotime($timestamp)) {
return new DateTime($timestamp);
}
return null;
}
function parse_options() {
global $argc,$argv,$opts;
$o = getopt("hs:f:u:j",["help","server:","from:","until:","json"],$optind);
foreach ($o as $opt=>$val) {
switch ($opt) {
case 'h':
case 'help':
$opts->help = true;
break;
case 's':
case 'server':
$opts->server = $val;
break;
case 'f':
case 'from':
$opts->from = parse_datetime($val);
break;
case 'u':
case 'until':
$opts->until = parse_datetime($val);
break;
case 'j':
case 'json':
$opts->json = true;
break;
}
}
$opts->args = array_slice($argv,$optind);
}
function print_help() {
global $argv;
printf("Syntax:\n %s [options] {action} [args...]\n\n", basename($argv[0]));
printf("Options:\n");
printf(" -h,--help Show this help\n");
printf(" -s,--server SERVER Specify the server (ip:port) to use\n");
printf(" -f,--from DATETIME Set the validity start timestamp\n");
printf(" -u,--until DATETIME Set the validity end timestamp\n");
printf(" -j,--json Assume json encoded values with set\n");
printf("\nActions:\n");
printf(" set {collection} {key}={value}*\n Set the keys to value, using the --from and --until values. Multiple\n key-value pairs can be specified. Use --json to pass values as json.\n");
printf(" get {collection} [{when}]\n Fetch the current values, or the values at {when}.\n");
printf(" show {collection}\n Show keys and all values, allowing to delete values.\n");
printf(" delete {collection} {key|id}*\n Delete a key or a value from a collection\n");
printf(" purge {collection}\n Delete an entire collection\n");
}
function exit_error(string $msg, int $code=1): never {
fwrite(STDERR, $msg."\n");
exit($code);
}
parse_options();
if ($opts->help) {
print_help();
exit(0);
}
$http = new React\Http\Browser();
function http_get(string $url, array $headers) {
global $opts,$http;
return $http->get("http://{$opts->server}/{$url}", $headers);
}
function http_post(string $url, array $headers, string $body) {
global $opts,$http;
return $http->post("http://{$opts->server}/{$url}", $headers, $body);
}
function action_get(object $opts) {
$collection = array_shift($opts->args);
if (!$collection) {
exit_error("Missing collection. Expected: get {collection}");
}
http_get($collection, [])->then(
function ($response) {
echo $response->getBody()->getContents();
},
function ($error) {
echo $error->getMessage()."\n";
}
);
}
function action_show(object $opts) {
$collection = array_shift($opts->args);
if (!$collection) {
exit_error("Missing collection. Expected: get {collection}");
}
http_get($collection."/all", [])->then(
function ($response) {
echo $response->getBody()->getContents();
},
function ($error) {
echo $error->getMessage()."\n";
}
);
}
function action_set(object $opts) {
$collection = array_shift($opts->args);
if (!$collection) {
exit_error("Missing collection. Expected: get {collection}");
}
$body = [];
while ($kvpair = array_shift($opts->args)) {
$op = [];
if (!str_contains($kvpair,"=")) {
exit_error("Invalid key-value pair: {$kvpair}");
}
[$key,$value] = explode("=", $kvpair, 2);
if (ctype_digit($key)) {
$op['id'] = $key;
} else {
$op['key'] = $key;
}
$op['value'] = $opts->json ? json_decode($value) : $value;
if ($opts->from || $opts->until) {
$vfrom = $opts->from ? [ 'from' => $opts->from->format('Y-m-d H:i:s P') ] : null;
$vuntil = $opts->until ? [ 'until' => $opts->until->format('Y-m-d H:i:s P') ] : null;
$op['validity'] = [ ...$vfrom, ...$vuntil ];
}
$body[$key] = $op;
}
http_post($collection, [ 'content-type'=>'application/json' ], json_encode($body))->then(
function ($response) {
echo $response->getBody()->getContents();
},
function ($error) {
echo $error->getMessage()."\n";
}
);
}
$action = array_shift($opts->args);
switch ($action) {
case 'get':
action_get($opts);
break;
case 'show':
action_show($opts);
break;
case 'set':
action_set($opts);
break;
}

26
phpunit.xml Normal file
View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
shortenArraysForExportThreshold="10"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View File

@ -72,9 +72,11 @@ class Daemon
$this->opCollectionSet($request, $collectionName);
return Response::json(true);
}
break;
throw new \Exception("Invalid request method");
case 'all':
break;
$result = $this->opCollectionGetAll($collectionName);
return Response::json($result);
}
return Response::json([ 'error'=>"Not Found" ])->withStatus(404);
@ -93,27 +95,41 @@ class Daemon
return $result;
}
private function opCollectionGetAll(string $collectionName): array
{
try {
$collection = $this->db->getCollection($collectionName);
$result = $collection->getAllValues();
}
catch (\Throwable $t) {
throw new \Exception("No such collection");
}
return $result;
}
private function opCollectionSet(ServerRequestInterface $request, string $collectionName): void
{
try {
$collection = $this->db->getCollection($collectionName);
}
catch (\Throwable $t) {
$collection = $this->db->createCollection($collectionName);
$collection = $this->db->getOrCreateCollection($collectionName);
$body = json_decode($request->getBody()->getContents());
// Allow passing a single value in a request, as well as array of values.
if (isset($body->key)) {
$body = [ $body ];
}
$body = (array)json_decode($request->getBody()->getContents());
foreach ($body as $change) {
foreach ((array)$body as $change) {
echo "Applying change (collection={$collectionName}): ".json_encode($change)."\n";
$id = $change->id??null;
$name = $change->name;
$name = $change->key;
$value = $change->value;
$validFrom = isset($change->validity->from) ? new DateTime($change->validity->from) : null;
$validUntil = isset($change->validity->until) ? new DateTime($change->validity->until) : null;
$collection->setValue($name, $value, $validFrom, $validUntil, $id);
}
}
}

View File

@ -16,14 +16,22 @@ class Collection
public function setValue(string $key, mixed $value, ?DateTime $validFrom = null, ?DateTime $validUntil = null, ?int $updateId = null)
{
// echo "setValue: {$key} = ".json_encode($value)."\n";
$this->loadKeys();
if (!array_key_exists($key, $this->keys)) {
//echo "setValue: new key, inserting to get new key id (cid={$this->id}, name={$key})\n";
try {
$this->db->prepare("INSERT INTO keys (collection_id,name) VALUES (:cid,:name)")
->execute([ 'cid' => $this->id, 'name' => $key ]);
}
catch (\PDOException $e) {
echo $e."\n";
}
$id = $this->db->lastInsertId('keys');
} else {
$id = $this->keys[$key];
}
//echo "key id={$id}\n";
$from = $validFrom ? $validFrom->format('Y-m-d H:i:s P') : null;
$until = $validUntil ? $validUntil->format('Y-m-d H:i:s P') : null;
@ -47,6 +55,7 @@ class Collection
$existQuery = $this->db->prepare("SELECT * FROM vals WHERE key_id=:id AND valid_from IS NULL AND valid_until IS NULL");
$existQuery->execute([ 'id' => $id ]);
}
if ($row = $existQuery->fetch()) {
$vid = $row['id'];
$this->db->prepare("UPDATE vals SET value=:value, valid_from=:from, valid_until=:until WHERE id=:vid")
@ -82,11 +91,13 @@ class Collection
"SELECT * FROM vals WHERE ".
"key_id=:id AND (".
"(valid_from IS NULL) OR (datetime(valid_from) < datetime(:now)) AND ".
"(valid_until IS NULL) OR (datetime(valid_until) > datetime(:now)))");
"(valid_until IS NULL) OR (datetime(valid_until) > datetime(:now))) ".
"ORDER BY valid_from ASC");
$valQuery->execute([ "id" => $id, "now" => $when->format('Y-m-d H:i:sP') ]);
$last = null;
while ($row = $valQuery->fetch(PDO::FETCH_ASSOC)) $last = $row;
$resolved[$key] = json_decode($last['value']);
if ($last)
$resolved[$key] = json_decode($last['value']);
}
return $resolved;
@ -100,7 +111,7 @@ class Collection
foreach ($this->keys as $key=>$id) {
if ($onlyKeys !== null && !in_array($key, $onlyKeys))
continue;
$valQuery = $this->db->prepare("SELECT * FROM vals WHERE key_id=:id");
$valQuery = $this->db->prepare("SELECT * FROM vals WHERE key_id=:id ORDER BY valid_from ASC");
$valQuery->execute([ "id" => $id]);
$values[$key] = [];
while ($row = $valQuery->fetch(PDO::FETCH_ASSOC)) {

View File

@ -22,9 +22,11 @@ class Database
"CREATE TABLE IF NOT EXISTS keys (".
"id INTEGER PRIMARY KEY AUTOINCREMENT,".
"collection_id INTEGER NOT NULL,".
"name VARCHAR UNIQUE NOT NULL,".
"name VARCHAR NOT NULL,".
"FOREIGN KEY(collection_id) REFERENCES collections(id)".
")");
$this->pdo->exec(
"CREATE UNIQUE INDEX IF NOT EXISTS keyname ON keys(collection_id,name)");
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS vals (".
"id INTEGER PRIMARY KEY AUTOINCREMENT,".
@ -38,6 +40,24 @@ class Database
"CREATE UNIQUE INDEX IF NOT EXISTS valkey ON vals(key_id,valid_from,valid_until)");
}
public function getOrCreateCollection(string $name): Collection
{
$query = $this->pdo->prepare("SELECT * FROM collections WHERE name=:name");
$query->execute([ 'name' => $name ]);
$row = $query->fetch(PDO::FETCH_ASSOC);
if (!$row) {
$query = $this->pdo->prepare("INSERT INTO collections (name) VALUES (:name)");
$query->execute([ 'name' => $name ]);
$id = $this->pdo->lastInsertId('collections');
return new Collection($this->pdo, intval($id));
}
$id = $row['id'];
return new Collection($this->pdo, $id);
}
public function getCollection(string $name): Collection
{
$query = $this->pdo->prepare("SELECT * FROM collections WHERE name=:name");

36
tests/DaemonTest.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace NoccyLabs\ParamDb;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Daemon::class)]
class DaemonTest extends \PHPUnit\Framework\TestCase
{
public function testHandlingGetRequest()
{
$this->markTestSkipped();
}
public function testHandlingSetRequest()
{
$this->markTestSkipped();
}
public function testHandlingGetAllRequest()
{
$this->markTestSkipped();
}
public function testHandlingDeleteRequest()
{
$this->markTestSkipped();
}
public function testHandlingPurgeRequest()
{
$this->markTestSkipped();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace NoccyLabs\ParamDb\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Collection::class)]
class CollectionTest extends \PHPUnit\Framework\TestCase
{
public function testInsertingValues()
{
$this->markTestSkipped();
}
public function testResolvingValues()
{
$this->markTestSkipped();
}
public function testProperSelectionOrderValues()
{
$this->markTestSkipped();
}
public function testDeletingValues()
{
$this->markTestSkipped();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace NoccyLabs\ParamDb\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Database::class)]
class DatabaseTest extends \PHPUnit\Framework\TestCase
{
public function testOpeningDatabases()
{
$this->markTestSkipped();
}
public function testCreatingCollections()
{
$this->markTestSkipped();
}
public function testOpeningCollections()
{
$this->markTestSkipped();
}
public function testPurgingCollection()
{
$this->markTestSkipped();
}
public function testExceptionsIfCollectionNotExists()
{
$this->markTestSkipped();
}
}

1
var/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.db