<?php
/**
*
* KRDB - Key-Record Database
*
* READ README.md for more info
* https://github.com/homebase/radaris/tree/master/lib.framework/KRDB
*
*/
/*
KRDB = TOP Level KRDB Section
*/
class KRDB extends KRDB_Section
{
// ------------ ------------ ------------ ------------
public $namespace; # KRDB_Namespace
public $key;
public $dirty; // need save if not null
protected $released = NULL; //if not NULL - not valid object and save is forbidden
public $allow_save_on_destructor = false; //if not false - allows to save changes in __destruct() method. Some kind of delayed save. Please use carefully.
/**
* @param string $namespace
* @param false $key
* @param DB_Parallel|null $DB_Parallel
* @return mixed|void
*/
static function i($namespace, $key=false, DB_Parallel &$DB_Parallel = null) { # Self instance | KRDB_Namespace
Profiler::in("KRDB::i($namespace)", x2s($key));
$N = KRDB_Namespace::i($namespace, $DB_Parallel);
if ($key === false) {
$I = $N;
} elseif (is_string($key) && strpos($key, ":") !== false) {
$I = $N[$key];
} else {
$I = $N->KRDB($key, $DB_Parallel);
}
Profiler::out();
return $I;
}
/**
* Default: check changed sections only
* @param bool $force (false - check recenly updated, true - check all)
* @return NULL | Exception
*/
function validate(bool $force = false /* default: check-new-only */) { # NULL | Exception
// TODO: once $DIRTY_SECTIONS implemented - check them ONLY - normal WAY
if (! $force) {
foreach ($this->_dirty as $s_name => $tm)
$this[$s_name]->validate($force);
} else {
foreach ($this->sections() as $s_name)
$this[$s_name]->validate($force);
}
}
// actual save
// save(true) will force save data (even when there is no D and block is not dirty)
PUBLIC function save($force=false) { # true | null - real save was performed
if ($this->released != NULL) {
$err = sprintf("KRDB[%s][%s] call save for object after release", $this->namespace, x2s($this->key));
\Log::notice($err);
return;
}
if (! $force) {
if (! $this->D)
return;
// Skip useless saves
if (! $this->dirty)
return;
}
// check schemas
$this->validate();
// remove empty added sections (inf depth)
if ($nn = count($this->new_node ?? [])) {
for ($i = $nn-1; $i >= 0; $i--) {
$n = $this->new_node[$i][1];
// unset new empty node
if (! $this->new_node[$i][0][$n]) {
unset($this->new_node[$i][0][$n]);
continue;
}
// unset new kinda-empty nodes with (_version property only)
if (count($this->new_node[$i][0][$n])==1 && isset($this->new_node[$i][0][$n]["_version"]))
unset($this->new_node[$i][0][$n]);
}
$this->new_node = [];
}
// remove empty
$this->remove_empty_sections(); // duplicate ??
$this->namespace->save($this);
// clean up dirty flags
$this->dirty = 0;
foreach ($this->_dirty as $section => $updated)
$this[$section]->_dirty = [];
$this->_dirty = [];
return true;
}
/*
Called on save.
Only top level sections are removed
sections with "_version", ".", "_updated", "_added" ONLY considered empty
*/
function remove_empty_sections() {
$td = []; // to_delete
foreach ($this->D as $s => $c) {
if ($s[0]!='.')
continue;
if (! is_array($c))
continue;
if (! $c) {
$td[] = $s;
continue;
}
$cnt = count($c);
if ($cnt>4)
continue;
if (isset($c["."]) && is_array($c["."]) && count($c["."])>0)
continue;
$cok = 0;
foreach (["_updated", "_added", "_version", "."] as $k)
$cok += (int) isset($c[$k]);
if ($cnt != $cok)
continue;
$td[] = $s;
}
foreach ($td as $s)
unset($this->D[$s]);
}
// throw out all changes, reload all data
PUBLIC function rollback() {
$this->dirty = 0;
$this->_resetSectionCache();
$this->D = $this->namespace->load($this->key);
}
// --------------------------------------------------------------------------
// CROSS-NAMESPACE LINK RESOLVING
//
// NEVER expose full pathes to end users
// - this may lead to serious **security** issues
//
// link:
// "$namespace:$key" << absolute link to KRDB
// "$namespace:$key/$path" << absolute link to Section
// "$key/$path" << relative link (default namespace used)
// "/$path" << relative link (default ns+record)
//
// DO NOT pass default_ns when $default_krdb available
static function section($link, /*KRDB_Namespace*/ $default_ns=null, /*KRDB*/ $default_krdb=null) { # KRDB_Section
list($ns, $path) = \HB::explode(":", $link, 2);
if (! $path) {
$path = $ns;
$ns = null;
}
list($key, $path) = \HB::explode("/", $path, 2);
if (! $key) // same KRDB link
return $default_krdb[$path];
if (! $ns) { // same NS link
if ($default_krdb)
$ns = $default_krdb->namespace;
if (! $ns)
$ns = $default_ns;
if (! $ns)
throw new Exception("can't resolve relative link:$link, no default_namespace");
if (is_a($ns, "KRDB_Namespace"))
return $path ? $ns->KRDB($key)[$path] : $ns->KRDB($key);
}
// absolute link
return $path ? KRDB::i($ns, $key)[$path] : KRDB::i($ns, $key);
}
// deep resolve link
static function resolve_link($link, /*KRDB_Namespace*/ $default_ns=null, /*KRDB*/ $default_krdb=null) { # hash
return self::_rl( self::section($link, $default_ns, $default_krdb) );
}
// link 2 link resolving
static protected function _rl(KRDB_Section $K) { # data
$r = $K->_D();
if (! isset($r["_link"]))
return $r;
$r["__link"] = $r["_link"];
unset($r["_link"]);
return $r + self::resolve_link($r['__link']);
}
// Delete itself, record will be deleted completely
// no soft-delete to itself
function delete($force=false) {
$this->notify();
$this->namespace->_delete($this->key);
$this->D = [];
$this->dirty = 0;
}
function release() {
$this->_resetSectionCache();
$this->released = true;
$this->dirty = 0;
}
/** call upgrade on all existing non-empty sections
* call upgrade on all virtual sections (sections stating with "v" from config)
* debug = true - show debug, otherwise - just show passed data in error stack trace when one of upgrade fails
*/
function upgradeAll($debug=false) { # $this
Profiler::in("KRDB:upgradeAll", $this->_name());
// exiting sections
foreach ($this->sections() as $section) {
if ($debug===true)
echo "upgrading $section\n";
$this[$section]; // init section, call upgrade for NON-empty sections
}
$C = $this->namespace->c();
$filter =
function($s) use ($C) {
if ($s[0]!='v')
return;
if (isset($C[$s]["class"])) // require class inside
return 1;
};
$virtual_sections = array_filter(array_keys($C), $filter);
foreach ($virtual_sections as $section) {
if ($debug===true)
echo "upgrading $section\n";
$this[$section]->doUpgrade(true); // call upgrade, even when section is empty
}
Profiler::out();
}
// --------------------------------------------------------------------------
// INTERNAL
// notification modification
// NEVER MODIFY from OUTSIDE
protected $_allow_write = 0; // read-only
function notify($time=0) {
$time = $this->_time($time);
if (! $time)
return;
if ($this->namespace->C("read-only") && ! $this->_allow_write) {
$this->dirty = 0; // throw out changes
throw new Exception("Can't modify read-only KRDB, use open() first");
}
if (empty($this->D["_added"]))
$this->D["_added"] = $time;
$this->D["_updated"] = $time; // will be overwriten by Storage Backend save()
$this->D["__updated"] = $time; // last KRDB notify call
$this->dirty = $time;
// debug notifies - find out who is changing KRDB
#if (once(1)) {
# $trace = Debug::trace(4, 8);
# \Log::notice("KRDB::notify\n".x2s($trace));
#}
}
// !! Never Call Directly
// use KRDB::i($ns, $id) or KRDB::i($ns)[$id]
function __construct(KRDB_Namespace $namespace, /* string */ $key, DB_Parallel &$DB_Parallel = null) {
$this->namespace = $namespace;
$this->key = $key;
$this->D = $this->namespace->load($key, $DB_Parallel);
//THIS CODE MAKES MEMORY LEAK!
//$this->K = $this;
$this->K = null; // avoid circ references
$this->P = null;
if ($namespace->C())
$this->C = $namespace->C()['Root'] ?? null;
$this->path = "";
$this->fields = $namespace->C("fields");
}
function getK(){
return $this;
}
function P() { # For KRDB PARENT SECTION is always null;
return null;
}
function K() { # KRDB
return $this;
}
// notify about unsaved data = this is developer error
function __destruct() {
if ($this->dirty > 0) { // skipping no changes && auto-changes
if ($this->allow_save_on_destructor) {
\Profiler::warn("KRDB: posponed save", $this->key);
$this->save();
/*
$err = sprintf("KRDB[%s][%s] allowed save, dirty: %s, dirty_sections: %s.",
$this->namespace,
x2s($this->key),
$this->dirty,
x2s(array_keys($this->_dirty))
);
\Log::notice($err);
*/
} elseif ($once=once()) {
$err = sprintf("KRDB[%s][%s] missing save, dirty: %s, dirty_sections: %s. // $once missed KRDB saves",
$this->namespace,
x2s($this->key),
$this->dirty,
x2s(array_keys($this->_dirty))
);
\Log::notice($err);
}
}
}
// -----------------------------------------------------------------------------
// DEEP INTERNALS
// new fake nodes kill-stack
// see _add_node
protected $new_node; // new empty nodes inserted : [REF, node-name]
// we create empty nodes on deep access
// we are checking & removing added nodes before save
// add reference to kill stack
/* private */ function _add_node(&$D, $name) {
$this->new_node[]=[&$D, $name];
}
} // KRDB
// -------------------- -------------------- -------------------- -------------------- --------------------
// KRDB_Namespace
//
// caches all KRDB objects
// connects KRDB to a physical storage via (load/save)
//
// instantiate via:
// KRDB::i($namespace)
//
/*
$N = KRDB::i($ns)
$N[$id] = get KRDB
$N["$id:Section"] - get Section
$N["$id:Section/Subsection/10/Comment/10@name"] - deep access
*/
class KRDB_Namespace implements ArrayAccess
{
public $namespace;
public $track_changes = 1; // 1: time() saved to "_added", "_updated", "_deleted" fields
// 0: "1" saved to "_added", "_updated", "_deleted" fields
// false: nothing is saved, dirty flag is not tracked at ALL!!
// ^^ avoid using unless you really understand !!
/* private */ public $storage; // iKRDB_Storage instance
static $CACHE; // Global NAMESPACE cache
protected $cache; // Specific Namespace KRDB cache
public $cache_cap = 100; // max number of KRDB elements in namespace cache
/**
* Use KRDB::i($namespace) - never instantiate directly
* @param string|bool $namespace special debug call: if namespace === false - uncache all items inside a namespace
* @param DB_Parallel|null $DB_Parallel
* @return mixed|void
* @throws Exception
*/
static function i($namespace, DB_Parallel &$DB_Parallel = null) { # Self instance
if ($namespace === false) {
Profiler::alert("KRDB::RESET");
self::$CACHE = [];
return;
}
if (isset(self::$CACHE[$namespace])) {
return self::$CACHE[$namespace];
}
if (!$config = CC("krdb.$namespace")) {
Log::alert("KRDB configuration missing C('krdb.$namespace')");
}
$class = $config["namespace-class"] ?? __CLASS__;
return self::$CACHE[$namespace] = new $class($namespace, $DB_Parallel);
}
# instantiate
function KRDB($key, DB_Parallel &$DB_Parallel = null) { # KRDB_Root
if (is_numeric($key))
$key = (int) $key; // mongo types enforce, safe for non-mongo
$k = is_array($key) ? json_encode($key) : $key;
if (isset($this->cache[$k]))
return $this->cache[$k];
$class = NVL($this->C("class"), "KRDB");
// OOM solution for spiders
if (count($this->cache) > $this->cache_cap)
$this->reset_cache();
$K = new $class($this, $key, $DB_Parallel);
if (!$DB_Parallel || $DB_Parallel->isComplete()) {
$this->cache[$k] = $K;
return $K;
}
}
// read-only joined representation of several KRDBs
function Union(array $keys, $class = 'KRDB_Union') { # KRDB_Union
$k = json_encode($keys);
if (isset($this->cache[$k]))
return $this->cache[$k];
$this->cache[$k] = new $class($this, $keys);
return $this->cache[$k];
}
// (1) reset namespace ITEM cache
// (2) reset cache for specific key
function reset_cache($key=false) {
$r_mem = round(memory_get_usage(true)/1024/1024, 1);
$mem = round(memory_get_usage(false)/1024/1024, 1);
Profiler::alert("KRDB_Namespace::reset_cache", ["ns" => $this->namespace, "key" => $key, "memM" => $mem, "rmemM" => $r_mem]);
if ($key) {
$this->cache[$key]->release();
unset($this->cache[$key]);
return;
}
// $this->cache = [] failed in some php 5.4.x
foreach ($this->cache as $i => $k) {
$this->cache[$i]->release();
unset($this->cache[$i]);
}
}
// save All unsaved items, reset item cache
function saveAll() {
foreach ($this->cache as $i => $K)
if ($i)
$K->save();
$this->reset_cache();
}
// completely delete key
function delete($key) {
$this[$key]->delete();
}
// --------------------------------------------------------------------------
// FOR OVERLOAD
// Physical Database integration
// actual load
PUBLIC function load($key, DB_Parallel &$DB_Parallel = null) { # parsed data
if (! $this->track_changes)
unset($D["_updated"]);
return $D;
}
// actual save
PUBLIC function save(/*KRDB*/ $K) {
return $this->storage->save($K);
}
// completely delete KEY
// never call directly - use KRDB::i("ns", $id)->delete();
function _delete($key) {
return $this->storage->delete($this->namespace, $key);
}
// --------------------------------------------------------------------------
function C($name=false) {
return CC("krdb.".$this->namespace.cs(".%s", $name));
}
protected function __construct($namespace, DB_Parallel &$DB_Parallel = null) {
$this->namespace = $namespace;
$sc = "KRDB_Storage_".NVL($this->C("storage"), "Mongo"); // storage class
$this->storage = new $sc($this); // WTF? Why we pass KRDB_Namespace instance to constructor that does not use it ( no constructor defined ? )
$this->cache = [];
#$this->cache = \hb\misc\CappedHash::i(["cap" => 100]); // 100 cached item limit
}
function cache() {
return $this->cache;
}
// adust cache cap, default cap is 1000
function set_cache_cap($cap) {
$this->cache_cap = $cap;
}
function __toString() {
return (string) $this->namespace;
}
// --------------------------------------------------------------------------
// ARRAY ACCESS
// Access to KRDB or Deep Section Access
// path "id:path"
function kp($path) { # [KRDB, path]
list ($key, $path) = \HB::explode(":", $path, 2);
if (! $path)
throw new Exception("bad argument, 'key:path' expected");
return [$this->KRDB($key), $path];
}
// Section Array Access
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value) {
list ($K, $path) = $this->kp($offset);
$K->offsetSet($path, $value);
}
public function offsetExists($offset) : bool {
list ($K, $path) = $this->kp($offset);
return $K->offsetExists($path);
}
#[\ReturnTypeWillChange]
public function offsetUnset($offset) {
\Log::alert("use delete");
}
// $N = KRDB::i($ns)
// $N[$id] = get KRDB
// $N["$id/Section"] - get Section
// $N["$id/Section/Subsection/10/Comment/10@name"] - deep access
#[\ReturnTypeWillChange]
public function offsetGet($offset) {
if (is_int($offset) || ctype_digit($offset))
return $this->KRDB($offset);
list ($K, $path) = $this->kp($offset);
return $K->offsetGet($path);
}
} // KRDB_Namespace
interface iKRDB_Storage {
PUBLIC function load($namespace, $key); # parsed data
PUBLIC function save(/*KRDB*/ $K);
PUBLIC function delete($namespace, $key);
}
abstract class aKRDB_Storage implements iKRDB_Storage{
public $serializer = 'zstd';// 'gz';
// Overload if you want another serializer
function _serialize($d) { # $d
$d = igbinary_serialize($d);
if($this->serializer == 'zstd'){
return zstd_compress($d,7);
}else{
return gzdeflate($d);
}
}
// Overload if you want another serializer
function _unserialize($d) { # $d
if(is_compress_zstd($d)){
$d = zstd_uncompress($d);
}else{
$d = gzinflate($d);
}
return igbinary_unserialize($d);
}
}