diff --git a/.gitignore b/.gitignore index 57872d0..36c79f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /vendor/ +/.phpunit.* diff --git a/bin/paramcli b/bin/paramcli new file mode 100755 index 0000000..c0473af --- /dev/null +++ b/bin/paramcli @@ -0,0 +1,174 @@ +#!/usr/bin/env php + 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; +} + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f6025b6 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,26 @@ + + + + + tests + + + + + + src + + + diff --git a/src/Daemon.php b/src/Daemon.php index d8138de..f684a9f 100644 --- a/src/Daemon.php +++ b/src/Daemon.php @@ -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); } + } } diff --git a/src/Storage/Collection.php b/src/Storage/Collection.php index f1445f9..b747164 100644 --- a/src/Storage/Collection.php +++ b/src/Storage/Collection.php @@ -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)) { diff --git a/src/Storage/Database.php b/src/Storage/Database.php index a09a178..504e6dd 100644 --- a/src/Storage/Database.php +++ b/src/Storage/Database.php @@ -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"); diff --git a/tests/DaemonTest.php b/tests/DaemonTest.php new file mode 100644 index 0000000..1efef4d --- /dev/null +++ b/tests/DaemonTest.php @@ -0,0 +1,36 @@ +markTestSkipped(); + } + + public function testHandlingSetRequest() + { + $this->markTestSkipped(); + } + + public function testHandlingGetAllRequest() + { + $this->markTestSkipped(); + } + + public function testHandlingDeleteRequest() + { + $this->markTestSkipped(); + } + + public function testHandlingPurgeRequest() + { + $this->markTestSkipped(); + } + +} \ No newline at end of file diff --git a/tests/Storage/CollectionTest.php b/tests/Storage/CollectionTest.php new file mode 100644 index 0000000..b1152fd --- /dev/null +++ b/tests/Storage/CollectionTest.php @@ -0,0 +1,31 @@ +markTestSkipped(); + } + + public function testResolvingValues() + { + $this->markTestSkipped(); + } + + public function testProperSelectionOrderValues() + { + $this->markTestSkipped(); + } + + public function testDeletingValues() + { + $this->markTestSkipped(); + } + +} \ No newline at end of file diff --git a/tests/Storage/DatabaseTest.php b/tests/Storage/DatabaseTest.php new file mode 100644 index 0000000..434700c --- /dev/null +++ b/tests/Storage/DatabaseTest.php @@ -0,0 +1,36 @@ +markTestSkipped(); + } + + public function testCreatingCollections() + { + $this->markTestSkipped(); + } + + public function testOpeningCollections() + { + $this->markTestSkipped(); + } + + public function testPurgingCollection() + { + $this->markTestSkipped(); + } + + public function testExceptionsIfCollectionNotExists() + { + $this->markTestSkipped(); + } + +} \ No newline at end of file diff --git a/var/.gitignore b/var/.gitignore new file mode 100644 index 0000000..98e6ef6 --- /dev/null +++ b/var/.gitignore @@ -0,0 +1 @@ +*.db