Newer
Older
dom-persist / source / dom_persist.d
module dom_persist;

import std.file;
import std.array;	//https://dlang.org/phobos/std_array.html
import std.conv;
import std.stdio;
import std.format;
import std.exception : enforce;
import std.traits;


import d2sqlite3;	// https://d2sqlite3.dpldocs.info/v1.0.0/d2sqlite3.database.Database.this.html
						// https://dlang-community.github.io/d2sqlite3/d2sqlite3.html

version(unittest){	

	//stuff only compiled for unittests
	string sqlite_filename = "dom_persist_test.db";

	void assertNDRecord( Row row, long id, string e_data, long pid, TreeNodeType tnt ){
	
		assert( id == row.peek!long(0) );
		assert( e_data == row.peek!string(1) );
		assert( pid == row.peek!long(2) );
		assert( tnt == getTreeNodeType( row.peek!int(3) ) );
	
	}
}

unittest {
	
	writeln( "Testing tree creation" );
	
	db_drop( sqlite_filename );
	assert( !db_exists( sqlite_filename ) );

	Database db = db_create( sqlite_filename, 1 );

	assert( db_exists( sqlite_filename ) );	
	assert(db.tableColumnMetadata("params", "ID") == TableColumnMetadata("INTEGER", "BINARY", false, true, true));

	Tree_Db.db_create_schema( db );
	assert(db.tableColumnMetadata("doctree", "ID") == TableColumnMetadata("INTEGER", "BINARY", false, true, true));

	db.close();

}

unittest{
	
	auto db = Database( sqlite_filename );
	Tree_Db tree = Tree_Db.createTree( db, "mytree" );

	TreeNode tree_node = tree.getTreeNode();
	NodeData nd = tree_node.node_data;
	
	assert( nd.pid == 0);
	assert( nd.e_data == "mytree");
	
	//bDebug_out = true;
	
	debug_out("tree_node-1: ", &tree_node);

	// flush the empty tree
	tree.flush();

	ResultRange results = db.execute( "select ID, e_data, p_id, t_id from doctree where id=1" );
	Row row = results.front();

	assertNDRecord( row, 1, "mytree", 0, TreeNodeType.tree );

	//bDebug_out = true;
	
	debug_out("tree_node-2: ", &tree_node);
	
	tree_node.appendChild( TreeNodeType.docType, "html" );
	auto tn_html = tree_node.appendChild( TreeNodeType.element, "html" );
	
	auto tn_head = tn_html.appendChild( TreeNodeType.element, "head" );
	tn_head.appendChild( TreeNodeType.comment, "This is my comment" );
	
	auto tn_body = tn_html.appendChild( TreeNodeType.element, "body" );
	
	tn_body.appendChild( TreeNodeType.text, "This is some text" );
	tn_body.appendChild( TreeNodeType.text, " with more text" );
	tn_body.appendChild( TreeNodeType.element, "input" );
	
	
	tree.flush();

	//bDebug_out = false;

	string html_out = tree.getTreeAsText( );	
	//writeln( html_out );
	assert( html_out == "<DOCTYPE html><html><head><!--This is my comment--></head><body>This is some text with more text<input/></body></html>");
	

}


unittest{

	writeln( "Testing DocOrderIterator" );

	auto db = Database( sqlite_filename );
	TreeNameID[] tree_list = Tree_Db.getTreeList( db );	
	Tree_Db tree = Tree_Db.loadTree( db, tree_list[0].tree_id );
	
	TreeNode tree_node = tree.getTreeNode();
	
	DocOrderIterator it = new DocOrderIterator( tree_node );
	int i=0;
	TreeNode nxt;
	while( (nxt=it.nextNode) !is null ){		
		switch(i){
		case 0:
			assert( nxt.node_data.type == TreeNodeType.tree );
			break;
			
		case 1:
			assert( nxt.node_data.type == TreeNodeType.docType );
			break;

		case 2,3,5,8:
			assert( nxt.node_data.type == TreeNodeType.element );
			break;

		case 4:
			assert( nxt.node_data.type == TreeNodeType.comment );
			break;

		case 6,7:
			assert( nxt.node_data.type == TreeNodeType.text );
			break;

		default:
		}
		i+=1;
	}
}

bool db_exists( string sqlite_filename ){
	return sqlite_filename.exists;
}

void db_drop( string sqlite_filename ){
	if( sqlite_filename.exists ){
		sqlite_filename.remove();
	}	
}

Database db_create( string sqlite_filename, int db_ver ){
	
	auto db = Database( sqlite_filename );
	db.run("CREATE TABLE \"Params\"(\"ID\" INTEGER, \"Name\"	TEXT NOT NULL UNIQUE, \"Val\"	TEXT,	PRIMARY KEY(\"ID\" AUTOINCREMENT))");
	db.run("insert into params(Name, Val) values('DB_VERSION','"~to!string(db_ver)~"')");
	return db;
}

enum TreeNodeType {
	nulltype=-2, // indicates a type read from the database which is not one of the recognised types
	tree=-1, docType, element, text, comment
}

TreeNodeType getTreeNodeType( int tip ){
	
	auto tnts = [EnumMembers!TreeNodeType];
	foreach( tnt; tnts ){
		if( tnt==tip ) return tnt;
	}
	return TreeNodeType.nulltype;
}

string get_openTag_commence( TreeNodeType nt, string e_data ){
	
	switch( nt ){
	
	case TreeNodeType.text:
		return e_data;
		
	case TreeNodeType.comment:
		return "<!--"~e_data;
		
	case TreeNodeType.docType:
		return "<DOCTYPE "~e_data;

	default:
		return "<"~e_data;
	}
}

string get_openTag_end( TreeNodeType nt, string e_data ){

	switch( nt ){
		
	case TreeNodeType.comment:
	case TreeNodeType.text:
		return "";
			
	default:
		switch(e_data){
		case "input":
		case "br":
			return "/>";
		default:
		}
		
		return ">";
	}
	
}

string get_closeTag( TreeNodeType nt, string e_data ){

	switch( nt ){
		
	case TreeNodeType.docType:
	case TreeNodeType.text:
		return "";
		
	case TreeNodeType.comment:
		return "-->";
		
	default:
		switch(e_data){
		case "input":
		case "br":
			return "";
		default:
		}

		return format("</%s>", e_data);
	}
	
}

struct NodeData {
	
	long ID;
	string e_data;
	long pid;
	TreeNodeType type;
	bool dirty = false;
	
	this( long ID, string e_data, long pid, TreeNodeType type){
		this.ID = ID;
		this.e_data = e_data;
		this.pid = pid;
		this.type = type;
	}
}

struct TreeNameID {
	long tree_id;
	string name;
}

/**
 * TreeNode is a class because we need the references to remain valid when we modify containers such as
 * the hashmap which indexes on ID.
 * 
 * If we use a struct, then we need to hold the TreeNode data somewhere in order to use a pointer to the data. If
 * we move the data, such as updating a map, then all the pointers need to change, or we need pointers to pointers.
 * Either is not ideal. If we use references, as provided by a class, then the GC will track the references and ensure
 * that they remain valid. We can move the references knowing that the data remains in place on the heap
 * 
 */
class TreeNode {
	
	private:
	
		Tree_Db owner_tree;

		/**
		 * Set the node ID of this treenode. Also sets the parent IDs of all child nodes and updates
		 * the owner_tree hashmap
		 */
		long setNodeId( long nnid ){
			
			long oldid = node_data.ID;
			
			node_data.ID = nnid;
			foreach(child; child_nodes ){
				child.node_data.pid = nnid;
			}
			
			owner_tree.all_nodes[nnid] = this;
			owner_tree.all_nodes.remove( oldid );
			
			return nnid;
		}
	
	
	// end private
	
	public:
	
		NodeData 		node_data;
		TreeNode[] 		child_nodes;
		bool				dirty;	// true indicates a change in child_nodes

		this( Tree_Db owner_tree, NodeData node_data){
			this.owner_tree = owner_tree;
			this.node_data = node_data;
		}
		
		/**
		 * Returns the parent node of this node or null if no parent exists (i.e. tree-root)
		 */
		TreeNode parentNode(){
			if(node_data.pid==0) return null;
			return owner_tree.getTreeNodeById( node_data.pid );
		}
		
		/**
		 * Returns the next sibling of this node or null if none exists
		 */
		TreeNode nextSibling(){
			if(node_data.pid==0) return null;
			
			//bDebug_out = true;
			TreeNode p_node = owner_tree.getTreeNodeById( node_data.pid );
			if(p_node is null){
				debug_out("(ID,e_data) = ", node_data.ID, node_data.e_data );
				throw new Exception("oops");
			}
			
			foreach( i, c_node; p_node.child_nodes){
				if(c_node==this){
					if( i == p_node.child_nodes.length-1) return null;
					return p_node.child_nodes[i+1];
				}
			}
			throw new Exception("Damaged tree, possibly incorrect parent id for a child.");
		}

		bool hasChildNodes(){
			return child_nodes.length>0;
		}

		TreeNode firstChild(){
			if( child_nodes.length==0 ) return null;
			return child_nodes[0];
		}

		/**
		 * Set the data for this node.
		 * 
		 * The data is interpreted using the type of node. For example, the text content of a text node is
		 * the data whereas for element types, the data is used to hold the element name.
		 */
		void setData( string nData ){
			node_data.e_data = nData;
			node_data.dirty = true;
		}
		
		/**
		 * Insert a new child node at the position indicated.
		 */
		TreeNode insertChild( TreeNodeType n_type, string e_data, int pos ){		
			return owner_tree.insertChild( this, n_type, e_data, pos );		
		}

		/**
		 * Append a new child node.
		 */
		TreeNode appendChild( TreeNodeType n_type, string e_data ){
			return owner_tree.insertChild( this, n_type, e_data, cast(int)(child_nodes.length) );
		}
	
	// end public
	
}



/**
 * An instance of this class contains access to a single tree. Tree operations are cached in RAM and only written to
 * disk during a save operation. This can be done safely because of the single user access to Sqlite.
 * 
 * Multiple database connections should work on the same thread provided each is using a different tree.
 * 
 * Instantiation of the tree involves only one database select.
 */
class Tree_Db {


	protected:
		
		long tree_id;
		Database* db;
		long nnid = 0;		
		
		TreeNode[long]	all_nodes;

		long getNextNodeId(){
			nnid -= 1;
			return nnid;
		}
		
	// end protected

	private:

		this( Database* db, long tid, string tree_name = null ){
			
			this.db = db;
			
			if(tid==0){
				//new tree
				tree_id = getNextNodeId();
				TreeNode tn = new TreeNode( 
					this, 
					NodeData( tree_id, tree_name, 0, TreeNodeType.tree)
				);
				this.all_nodes[ tree_id ] = tn;
				return;
			}
			
			tree_id = tid;
				
			//load the tree in one hit using the tree_id
			//also order by the parent_id so that we know all siblings are grouped together
			//and then by child order
		
			auto results = db.execute( format("select ID, e_data, p_id, t_id from doctree where tree_id=%d or id=%d order by p_id,c_order", tid, tid) );
			foreach (row; results){
				
				long id = row.peek!long(0);
				long p_id = row.peek!long(2);
				TreeNode tn = new TreeNode( this, NodeData(
					id,
					row.peek!string(1),
					p_id,
					getTreeNodeType( row.peek!int(3) )
				));
				all_nodes[id] = tn;
				if(p_id==0) continue;
				all_nodes[p_id].child_nodes ~= all_nodes[id];
			}
		}
	
	// end private
	

	public:
	
	static void db_create_schema( ref Database db ){		
		db.run("CREATE TABLE IF NOT EXISTS doctree (ID INTEGER, e_data	TEXT,p_id INTEGER,t_id INTEGER NOT NULL,tree_id INTEGER NOT NULL,	c_order INTEGER, PRIMARY KEY( ID AUTOINCREMENT))");
	}

	/**
	 * Return a list of all trees in the database with their ID's and names.
	 */
	static TreeNameID[] getTreeList( ref Database db ){

		TreeNameID[] tree_list;
		auto results = db.execute( format("select ID, e_data from doctree where p_id=0") );
		foreach (row; results){
			
			tree_list ~= TreeNameID (
				row.peek!long(0),
				row.peek!string(1)
			);			
		}
		return tree_list;		
	}
	
	/**
	 * Create a new tree in RAM. No database activity takes place at this time.
	 */
	static Tree_Db createTree( ref Database db, string tree_name ){
		return new Tree_Db( &db, 0, tree_name );		
	}

	/**
	 * Load a tree into RAM from database given the tree ID.
	 */
	static Tree_Db loadTree( ref Database db, long tree_id ){
		return new Tree_Db( &db, tree_id );		
	}	
	
	/**
	 * Returns the root node of this tree. This is special node holds the tree name and ID and is not
	 * usually part of the document but rather the parent container.
	 */
	TreeNode getTreeNode(){
		return all_nodes[tree_id];
	}

	/**
	 * Return any node given its ID. 
	 * 
	 * Note about IDs:
	 * 	positive IDs indicate that the record exists on the database and the ID is the database ID of that node.
	 * 	negative IDs indicate that the record is a new one existing in RAM only until such times as 'flush' is called.
	 * 
	 */
	TreeNode getTreeNodeById( long id ){
		
		TreeNode* ps = id in all_nodes;
		if(ps) return all_nodes[id];
		debug_out("getTreeNodeById: did not find key ", id );
		return null;
	}

	/**
	 * Return the tree as html text. This is the cached tree with all changes and not from the database.
	 */
	string getTreeAsText( ){
		return getTreeAsText_r( all_nodes[tree_id] );
	}


	/**
	 * Insert a new child of parent node (p_node) at the position (pos) indicated.
	 */
	TreeNode insertChild( TreeNode p_node, TreeNodeType n_type, string e_data, int pos ){
				
		/* Algorithm:
		add into the correct place in ram, assign a new id <=-1. 
		An ID is required to add to the map. Using negative IDs indicates that it is not a DB ID.
		Mark the TreeNode parent as dirty indicating that children need adding and re-ordering
		flush: insert (into db) all nodes first then set c_order column for those with dirty parents(!)
		*/
	
		long id = getNextNodeId();
		TreeNode tn = new TreeNode( 
			this, 
			NodeData( id, e_data, p_node.node_data.ID, n_type)
		);
		
		this.all_nodes[ id ] = tn;		
		p_node.child_nodes.insertInPlace( pos, this.all_nodes[ id ]);
		p_node.dirty = true;
		return tn;
	}


	/**
	 * Save all edits to the tree to the database. After this call, the database values will be in sync
	 * with the memory tree.
	 */
	void flush(){		
		
		// nodes must be written in document order so that a valid database ID is always available as a parent
		//ID for subsequent child writes. This is because the child update will write

		debug_out("flush():");

		long new_tree = tree_id<0;

		DocOrderIterator it = new DocOrderIterator( all_nodes[tree_id] );
		TreeNode tnode;
		while( (tnode=it.nextNode) !is null ){
			
			NodeData* nd = &tnode.node_data;
			debug_out("next node:", *nd );
				
			//first we check the ID<0 which implies it is a new node	
			if( nd.ID<0 ){

				if(new_tree) tree_id=0;
				
				db.run( format("insert into doctree(e_data, p_id, t_id, tree_id ) values( '%s', %d, %d, %d )", nd.e_data, nd.pid, nd.type, tree_id ) );

				//update the node id
				long newID = tnode.setNodeId( db.lastInsertRowid );
				if(new_tree) tree_id = newID;
				
			}else if( nd.dirty ){
				db.run( format("update doctree set e_data='%s' where id=%d", nd.e_data, nd.ID ) );
				nd.dirty = false;
			}
		}

		//Re-enumerate any children that have shifted positions (or are new) according to their array positions
		it.reset();
		while( (tnode=it.nextNode) !is null ){
			if( tnode.dirty ){
				foreach( i, cNode; tnode.child_nodes){
					db.run( format("update doctree set c_order=%d where id=%d", i, cNode.node_data.ID ) );
				}
				tnode.dirty = false;
			}
		}
	}

	protected:
	
	string getTreeAsText_r( ref TreeNode tn ){
		
		string strRtn = "";

		TreeNode[] children = tn.child_nodes;
		foreach( child; children){
			NodeData nd = child.node_data;
			strRtn ~= get_openTag_commence( nd.type, nd.e_data );
			// --> add attributes if required
			strRtn ~= get_openTag_end( nd.type, nd.e_data );
			strRtn ~= getTreeAsText_r( child );
			strRtn ~= get_closeTag( nd.type, nd.e_data );
		}
		
		return strRtn;
	}
	
}
/*
  
 Delete node (branch)
	If the id<=-1, then it was a new node, unsaved, can be removed entirely
	Otherwise, move the node and all children into a delete-map.	
	Mark the TreeNode parent as dirty indicating that children need removing. Re-ordering is
	not required but may be advantageous
	flush: Delete entries using the delete-map and clear the map.

 Move node
	Move the node and all children into new parent (same parent also works)
	Mark old parent TreeNode as dirty indicating a re-order is necessary, re-order ram children
	Mark new parent TreeNode as dirty indicating a re-order is necessary, re-order ram children
	update parent id of moved child
	
 */ 
 
unittest{

	writeln( "Testing tree loading" );

	auto db = Database( sqlite_filename );
		
	auto tree2 = Tree_Db.createTree( db, "AnotherTree" );
	tree2.flush();
	
	TreeNameID[] tree_list = Tree_Db.getTreeList( db );
	assert( tree_list.length==2 );
	
	Tree_Db tree = Tree_Db.loadTree( db, tree_list[0].tree_id );
	
	TreeNode tree_node = tree.getTreeNode();
	NodeData nd_t = tree_node.node_data;
	assert( nd_t.ID == tree_list[0].tree_id );
	assert( nd_t.e_data == tree_list[0].name );
	assert( nd_t.pid == 0 );
	assert( nd_t.type == TreeNodeType.tree );

	TreeNode html_node;
	
	int i=0;
	foreach( node_ptr; tree_node.child_nodes ){		

		NodeData c_node = node_ptr.node_data;
		
		switch(i){
		case 0:
			assert( c_node.ID == 2 );
			assert( c_node.e_data == "html" );
			assert( c_node.pid == tree_list[0].tree_id );
			assert( c_node.type == TreeNodeType.docType );
			break;

		case 1:
			html_node = node_ptr;
			assert( c_node.ID == 3 );
			assert( c_node.e_data == "html" );
			assert( c_node.pid == tree_list[0].tree_id );
			assert( c_node.type == TreeNodeType.element );
			break;
		
		default:
		}
	
		i+=1;
	}
	
	string html_out = tree.getTreeAsText( );	
	assert( html_out == "<DOCTYPE html><html><head><!--This is my comment--></head><body>This is some text with more text<input/></body></html>");

	writeln( "Testing tree element insertion" );

	//get head element
	TreeNode tn_head = html_node.child_nodes[0];	
	
	//add an element to the head at position zero
	tn_head.insertChild( TreeNodeType.element, "script", 0 );
	
	html_out = tree.getTreeAsText( );	
	assert( html_out == "<DOCTYPE html><html><head><script></script><!--This is my comment--></head><body>This is some text with more text<input/></body></html>");

	writeln( "Testing tree editing" );

	//edit the comment node (id=5)
	TreeNode tn = tree.getTreeNodeById( 5 );	
	tn.setData( "An edit took place");
	
	html_out = tree.getTreeAsText( );	
	assert( html_out == "<DOCTYPE html><html><head><script></script><!--An edit took place--></head><body>This is some text with more text<input/></body></html>");

	//check that the database entry is unchanged
	auto results = db.execute( "select e_data from doctree where id=5" );
	foreach (row; results){		
		assert( "This is my comment" == row.peek!string(0) );
	}
	
	// save to database	
	tree.flush();
		
	//check db contents using a new tree
	Tree_Db tree3 = Tree_Db.loadTree( db, tree_list[0].tree_id );
	html_out = tree3.getTreeAsText( );	
	assert( html_out == "<DOCTYPE html><html><head><script></script><!--An edit took place--></head><body>This is some text with more text<input/></body></html>");
	
}


/**
 * Iterator starting at any given TreeNode, traversing each of the descendent nodes, depth first and increasing child index.
 */
class DocOrderIterator {
	
	TreeNode start_node;
	TreeNode next_node;
	
	this( TreeNode n ){
		start_node = n;
		next_node = n;
	}
	
	void reset(){
		next_node = start_node;		
	}
	
	/**
	 * The initial TreeNode is the first node to be returned.
	 */
	TreeNode nextNode(){
	
		//the node we will return this time
		TreeNode rtnNode = next_node;
		if(rtnNode is null) return null;
			
		//now work out the node for the next call
				
		if( rtnNode.hasChildNodes() ){
			next_node = rtnNode.firstChild();			
			return rtnNode;
		}
		
		TreeNode anc_node = rtnNode;
		while( anc_node !is null && anc_node.nextSibling() is null){
			anc_node = anc_node.parentNode();
			if( anc_node == start_node ){
				anc_node=null;
				break;
			}
		}
		if(anc_node is null) {
			next_node=null;
			return rtnNode;
		}
		
		next_node = anc_node.nextSibling();
		return rtnNode;
	
	}
	
}


bool bDebug_out = false;
void debug_out(){ writeln(); }

void debug_out(T, A...)(T t, A a){
    if(!bDebug_out) return;
    import std.stdio;
    write(t);
    debug_out(a);
}