Initial commit

This commit is contained in:
2024-09-26 23:16:12 +02:00
commit 72989b0aa8
10 changed files with 3182 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/vendor/

106
README.md Normal file
View File

@ -0,0 +1,106 @@
# ParamDB
* Values are serialized, so arrays and objects can be stored and restored.
* Not intended to be running as a public service, so there is no authentication, for now.
* Last value that matches is returned.
* Unique index on collection+name+validity.
## Future
* Metadata; store with key "meta" in value set post call, returned under a "meta" key in the
result list.
## Using
### Store values
```json
POST /businessinfo
Content-Type: application/json
{
"openhours": {
"value": "Mo-Fr 09-17, Sa-Su 12-02",
"valid": {
"from": "2024-10-01 00:00:00 +02:00",
"until": "2024-10-07 23:59:59 +02:00"
}
}
}
```
### Retrieve current values
```json
GET /businessinfo
{
"openhours": "Mo-Fr 09-17, Sa-Su 10-01"
}
```
Query params:
* `date=` - override date (default is now) in the format "Y-m-d H:i:s P"
### Retrieve all values
```json
GET /businessinfo/all
{
"keys": {
"openhours": [
{
"id": 13,
"value": "Mo-Fr 09-17, Sa-Su 10-01"
},
{
"id": 19,
"value": "Mo-Fr 09-17, Sa-Su 12-02",
"valid": {
"from": "2024-10-01 00:00:00 +02:00",
"until": "2024-10-07 23:59:59 +02:00"
}
}
]
}
}
```
Query params:
* `only=` - comma-separated list of keys to match
### Delete value
Delete by posting an array of IDs to delete.
```json
POST /businessinfo/delete
Content-Type: application/json
[ 19 ]
```
### Update a value
Update by including an existing id with the set request.
```json
POST /businessinfo
Content-Type: application/json
{
"openhours": {
"id": 19,
"value": "Mo-Fr 09-17, Sa-Su 12-03",
"valid": {
"from": "2024-10-01 00:00:00 +02:00",
"until": "2024-10-07 23:59:59 +02:00"
}
}
}
```

2
bin/paramdb Executable file
View File

@ -0,0 +1,2 @@
#!/usr/bin/env php
<?php require_once __DIR__."/../src/bootstrap.php";

29
composer.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "noccylabs/param-db",
"description": "A key-value/property store for lookup of static and temporal values",
"type": "application",
"license": "GPL-3.0-or-later",
"autoload": {
"psr-4": {
"NoccyLabs\\ParamDb\\": "src/"
}
},
"authors": [
{
"name": "Christopher Vagnetoft",
"email": "labs@noccy.com"
}
],
"require": {
"ext-sqlite3": "*",
"ext-pdo": "*",
"react/react": "^1.4"
},
"require-dev": {
"phpunit/phpunit": "^11.3",
"phpstan/phpstan": "^1.12"
},
"bin": [
"bin/paramdb"
]
}

2707
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

13
phpstan.neon Normal file
View File

@ -0,0 +1,13 @@
parameters:
level: 5
excludePaths:
- doc
- vendor
- var
- tests
# Paths to include in the analysis
paths:
- src

118
src/Daemon.php Normal file
View File

@ -0,0 +1,118 @@
<?php
namespace NoccyLabs\ParamDb;
use DateTime;
use NoccyLabs\ParamDb\Storage\Database;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use React\Socket\ServerInterface;
use React\Socket\TcpServer;
use React\Http\HttpServer;
use React\Http\Message\Response;
class Daemon
{
private ?ServerInterface $listener = null;
private ?HttpServer $http = null;
private Database $db;
public function start(): self
{
$this->db = new Database("/tmp/foo.db");
$this->listener = new TcpServer("0.0.0.0:8000");
$this->http = new HttpServer(
$this->errorHandlingMiddleware(...),
$this->onRequest(...)
);
$this->http->listen($this->listener);
return $this;
}
public function stop(): self
{
$this->listener->close();
$this->listener = null;
$this->http = null;
return $this;
}
private function errorHandlingMiddleware(ServerRequestInterface $request, callable $next): ResponseInterface
{
try {
return $next($request);
}
catch (\Throwable $t) {
return Response::plaintext($t)->withStatus(500);
}
}
private function onRequest(ServerRequestInterface $request): ResponseInterface
{
$paths = explode("/",trim($request->getUri()->getPath(),"/"));
$collectionName = array_shift($paths);
$collectionOp = array_shift($paths);
switch ($collectionOp) {
case null:
if ($request->getMethod() == 'GET') {
$result = $this->opCollectionGet($collectionName);
return Response::json($result);
} elseif ($request->getMethod() == 'POST') {
$this->opCollectionSet($request, $collectionName);
return Response::json(true);
}
break;
case 'all':
break;
}
return Response::json([ 'error'=>"Not Found" ])->withStatus(404);
}
private function opCollectionGet(string $collectionName): array
{
try {
$collection = $this->db->getCollection($collectionName);
$result = $collection->resolveValues();
}
catch (\Throwable $t) {
throw new \Exception("No such collection");
}
return $result;
}
private function opCollectionSet(ServerRequestInterface $request, string $collectionName): void
{
$body = (array)json_decode($request->getBody()->getContents());
foreach ($body as $change) {
$id = $change->id??null;
$name = $change->name;
$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;
try {
$collection = $this->db->getCollection($collectionName);
}
catch (\Throwable $t) {
$collection = $this->db->createCollection($collectionName);
}
$collection->setValue($name, $value, $validFrom, $validUntil, $id);
}
}
}

126
src/Storage/Collection.php Normal file
View File

@ -0,0 +1,126 @@
<?php
namespace NoccyLabs\ParamDb\Storage;
use DateTime;
use PDO;
class Collection
{
private ?array $keys = null;
public function __construct(private PDO $db, private int $id)
{
}
public function setValue(string $key, mixed $value, ?DateTime $validFrom = null, ?DateTime $validUntil = null, ?int $updateId = null)
{
$this->loadKeys();
if (!array_key_exists($key, $this->keys)) {
$this->db->prepare("INSERT INTO keys (collection_id,name) VALUES (:cid,:name)")
->execute([ 'cid' => $this->id, 'name' => $key ]);
$id = $this->db->lastInsertId('keys');
} else {
$id = $this->keys[$key];
}
$from = $validFrom ? $validFrom->format('Y-m-d H:i:s P') : null;
$until = $validUntil ? $validUntil->format('Y-m-d H:i:s P') : null;
if ($updateId) {
$this->db->prepare("UPDATE vals SET value=:value, valid_from=:from, valid_until=:until WHERE id=:vid")
->execute([ 'vid' => $updateId, 'value' => json_encode($value), 'from' => $from, 'until' => $until ]);
return;
}
if ($from && $until) {
$existQuery = $this->db->prepare("SELECT * FROM vals WHERE key_id=:id AND valid_from=:from AND valid_until=:until");
$existQuery->execute([ 'id' => $id, 'from' => $from, 'until' => $until ]);
} elseif ($from) {
$existQuery = $this->db->prepare("SELECT * FROM vals WHERE key_id=:id AND valid_from=:from AND valid_until IS NULL");
$existQuery->execute([ 'id' => $id, 'from' => $from ]);
} elseif ($until) {
$existQuery = $this->db->prepare("SELECT * FROM vals WHERE key_id=:id AND valid_from IS NULL AND valid_until=:until");
$existQuery->execute([ 'id' => $id, 'until' => $until ]);
} else {
$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")
->execute([ 'vid' => $vid, 'value' => json_encode($value), 'from' => $from, 'until' => $until ]);
} else {
$this->db->prepare("INSERT INTO vals (key_id,value,valid_from,valid_until) VALUES (:id,:value,:from,:until)")
->execute([ 'id' => $id, 'value' => json_encode($value), 'from' => $from, 'until' => $until ]);
}
}
private function loadKeys(): void
{
$query = $this->db->prepare("SELECT * FROM keys WHERE collection_id=:id");
$query->execute([ "id" => $this->id ]);
$this->keys = [];
while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
$valQuery = $this->db->prepare("SELECT * FROM vals WHERE key_id=:id");
$valQuery->execute([ "id" => $row['id'] ]);
$this->keys[$row['name']] = $row['id'];
}
}
public function resolveValues(?DateTime $when = null): array
{
$this->loadKeys();
$resolved = [];
$when = ($when ?? new DateTime());
foreach ($this->keys as $key=>$id) {
$valQuery = $this->db->prepare(
"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)))");
$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']);
}
return $resolved;
}
public function getAllValues(?array $onlyKeys = null): array
{
$this->loadKeys();
$values = [];
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->execute([ "id" => $id]);
$values[$key] = [];
while ($row = $valQuery->fetch(PDO::FETCH_ASSOC)) {
$vFrom = $row['valid_from'];
$vTo = $row['valid_until'];
$val = [
"id" => $row['id'],
"value" => json_decode($row['value'])
];
if ($vFrom || $vTo) {
$v = [];
if ($vFrom) $v['from'] = $vFrom;
if ($vTo) $v['until'] = $vTo;
$val['validity'] = $v;
}
$values[$key][] = $val;
}
}
return $values;
}
}

71
src/Storage/Database.php Normal file
View File

@ -0,0 +1,71 @@
<?php
namespace NoccyLabs\ParamDb\Storage;
use PDO;
class Database
{
private PDO $pdo;
public function __construct(string $filename)
{
$this->pdo = new PDO("sqlite:{$filename}");
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS collections (".
"id INTEGER PRIMARY KEY AUTOINCREMENT,".
"name VARCHAR UNIQUE NOT NULL".
")");
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS keys (".
"id INTEGER PRIMARY KEY AUTOINCREMENT,".
"collection_id INTEGER NOT NULL,".
"name VARCHAR UNIQUE NOT NULL,".
"FOREIGN KEY(collection_id) REFERENCES collections(id)".
")");
$this->pdo->exec(
"CREATE TABLE IF NOT EXISTS vals (".
"id INTEGER PRIMARY KEY AUTOINCREMENT,".
"key_id INTEGER NOT NULL,".
"value BLOB,".
"valid_from DATETIME NULL,".
"valid_until DATETIME NULL,".
"FOREIGN KEY(key_id) REFERENCES keys(id)".
")");
$this->pdo->exec(
"CREATE UNIQUE INDEX IF NOT EXISTS valkey ON vals(key_id,valid_from,valid_until)");
}
public function getCollection(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) {
throw new \Exception("No such collection");
}
$id = $row['id'];
return new Collection($this->pdo, $id);
}
public function createCollection(string $name, array $meta = []): Collection
{
$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));
}
public function deleteCollection(string $name): void
{
}
}

9
src/bootstrap.php Normal file
View File

@ -0,0 +1,9 @@
<?php
require_once __DIR__."/../vendor/autoload.php";
define("APP_ROOT", dirname(__DIR__));
define("APP_DATA", APP_ROOT."/var");
$daemon = new NoccyLabs\ParamDb\Daemon();
$daemon->start();