» /rd/lib.framework/KRDB/KRDB.php

<?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=falseDB_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->namespacex2s($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])==&& 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->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->$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(":"$link2);
        if (! 
$path) {
            
$path $ns;
            
$ns null;
        }
        list(
$key$path) = \HB::explode("/"$path2);

        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::_rlself::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->= [];
        
$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 */ $keyDB_Parallel &$DB_Parallel null) {
        
$this->namespace $namespace;
        
$this->key $key;
        
$this->$this->namespace->load($key$DB_Parallel);

        
//THIS CODE MAKES MEMORY LEAK!
        //$this->K = $this;
        
$this->null;  // avoid circ references
        
$this->null;
        if (
$namespace->C())
            
$this->$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($namespaceDB_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($keyDB_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/10241);
        
$mem round(memory_get_usage(false)/1024/10241);
        
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($keyDB_Parallel &$DB_Parallel null) {  # parsed data

        $D $this->storage->load($this->namespace$key$DB_Parallel);

        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($namespaceDB_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(":"$path2);
        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);
    }
}