Initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/vendor/
|
106
README.md
Normal file
106
README.md
Normal 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
2
bin/paramdb
Executable file
@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env php
|
||||
<?php require_once __DIR__."/../src/bootstrap.php";
|
29
composer.json
Normal file
29
composer.json
Normal 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
2707
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
phpstan.neon
Normal file
13
phpstan.neon
Normal 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
118
src/Daemon.php
Normal 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
126
src/Storage/Collection.php
Normal 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
71
src/Storage/Database.php
Normal 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
9
src/bootstrap.php
Normal 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();
|
Reference in New Issue
Block a user