PHPCR

Content:

  1. Introduction
  2. What is JCR
  3. What is PHPCR
  4. Implementations
  5. Programming
  6. Example - code
  7. Example - setup
  8. Other technologies

Introduction:

PHP is the dominant technology in the CMS world. Other technologies are used: ASP.NET (Umbraco, SiteCore etc.), Java (Alfresco, Adobe Experience Manager, Jahia etc.), Python (Django etc.). But PHP is by far the most widely used: WordPress, Drupal, Typo3, Xoops, Magento etc..

Non the less the PHP CMS community has been looking into one Java CMS technology JCR (Java Content Repository) and created a PHP clone PHPCR (PHP Content Repository).

JCR is widely used in the Java CMS world, but PHPCR is not widely used in the PHP CMS world.

But I still consider it an interesting technology. And this an appetizer to show some of its potential.

What is JCR:

For more details see full JCR article.

What is PHPCR:

PHPCR API is a 1:1 translation of JCR API from Java to PHP.

Almost entirely same concept, same classes and same methods. Only a few features that did not make sense in PHP due to PHP being dynamic types (Java is static typed) was omitted.

The first work was done by Karsten Dambekalns from Typo3 in 2008-2011, but the project has since gotten significant community support.

As I understand it then Typo3 created PHPCR with the intention of using it in version 5, but eventually dropped the version and the idea.

For more details see PHPCR documentation and PHPCR API documentation.

Implementations:

A few implementations of PHPCR exist:

PHPCR repository Status Supported by Storage support
Jackalope - Jackrabbit Open source Symfony CMF Apache Jackrabbit server:
  • Relational databases (Oracle DB, MS SQLServer, MySQL/MariaDB, PostgreSQL)
  • File system
Jackalope - Doctrine DBAL Open source Symfony CMF Relational databases (Oracle DB, IBM DB2, MS SQLServer, MySQL/MariaDB, PostgreSQL)
Jackalope - MongoDB Open source (not active) MongoDB
Midgard2 Open source(not active) Symfony CMF Relational databases (MySQL/MariaDB, PostgreSQL)

The two most interesting implementations are Jackalope Jackrabbit and Jackalope Doctrine DBAL.

The argument for Jackalope Jackrabbit is that it is a more complete implementation than Jackalope Doctrine DBAL, which are missing some key features like versioning.

The argument for Jackalope Doctrine DBAL is that it is just PHP and a database, while Jackalope Jackrabbit requires PHP, Java, standalone Jackrabbit server and database (more complexity).

There is also a product Doctrine PHPCR-ODM that implement a normal ORM on top of PHPCR.

Honestly I do not understand the point in such a product. The PHPCR implementation eventually end up using a database anyway, so I do not see the value in putting PHPCR in between. But some very smart PHP people in Doctrine project have created the product. And some very smart PHP people in Symfony project support its use. They probably know something that I don't know.

The software stacks must look like:

PHPCR-ODM stacks

Programming:

Let us try and look at how one actually use the PHPCR API.

Get connection:

The way to get a connection to the repository via PHPCR API is to:

function getRepository($parameters) {
    $factory = new RepositoryFactoryXxxx();
    $repository = $factory->getRepository($parameters);
    return $repository;
}
...
$session = $repo->login(new SimpleCredentials($usr, $pwd));   

Work with nodes and tree structure:

In PHPCR nodes are identified with a path of node names. Example:

For the forum example we can use:

Node type Node path
Forum /F
Topic /F/topicname
Post /F/topicname/posttitle
Comment to post /F/topicname/posttitle/commentnumber
Comment to comment to post /F/topicname/posttitle/commentnumber/commentnumber
Comment to comment to comment to post /F/topicname/posttitle/commentnumber/commentnumber/commentnumber

If the PHPCR implementation does not support versioning, then it can be done explicit:

Node type Node path
Post - version n /F/topicname/posttitle/Vn
Comment to post - version n /F/topicname/posttitle/commentnumber/Vn

Get root node (top node):

$rootnode = $session->getRootNode();

Add node below existing node:

$child = $parent->addNode($nodename);
...
$session->save();

Get node below existing node:

$node = $abovenode->getNode($relativepath);

(relative path is just name if only 1 level below)

Nodes in PHPCR comes in two flavors:

For unstructured nodes the developer just define whatever properties desired on a per node basis.

For typed nodes the properties are defined.

All the discission and code below assume unstructured nodes.

For the forum example it may have been possible to define node types for forum, topic, post and comment. But my feeling is that unstructued is the most PHPCR'ish (even though I in general like strongly typed programming languages).

Set properties:

$node->setProperty('iprop', 123);
$node->setProperty('sprop', 'ABC');
...
$session->save();

Get properties:

$iv = $node->getProperty('iprop')->getLong();
$sv = $node->getProperty('sprop')->getString();

Queries:

PHPCR provides several options for querying (searching) the node tree:

QOM (Query Object Model) is a low level programmtic API.

SQL2 is as the name indicate a database SQL inspired language and pretty high level.

All example below will use SQL2.

The SQL syntax are like:

SELECT * FROM [nt:unstructured] WHERE ...

WHERE conditions can be:

Syntax Semantics
p = 123
p = 'ABC'
select nodes where the property has the specified value
p IS NOT NULL select nodes that do have property
p IS NULL select nodes that do not have property
CONTAINS(p, 'ABC') select nodes where the property contains the specified value

The CONTAINS search could be a very slow opertation if the PHPCR implementation implement it by iterating over every instance of the node property and do a simple string search in each.

A good PHPCR implementation use a full text search engine to make such queries fast.

Jackalope Jackrabbit pass the problem on to Apache Jackrabbit and it can use Lucene as full text search engine. Lucene is what Wikipedia is using, so it can handle really large data sizes very efficiently.

Jackalope Doctrine DBAL does not currently use a full text search engine but relies on SQL query and XPath query to find data, so be careful with search.

To execute a query:

$q = $session->getWorkspace()->getQueryManager()->createQuery($sqlstr, QueryInterface::JCR_SQL2);
$qr = $q->execute();
$nit = $qr->getNodes();
while($nit->valid()) {
    $n = $nit->current();
    ...
    $nit->next();
}

Versioning:

PHPCR uses a version manager and a check-in/check-out model to manager versions.

Code to make a node versionable (it is not per default):

$node->addMixin(NodeTypeInterface::MIX_VERSIONABLE);
...
$session->save();

Code to add new versions::

$vm = $session->getWorkspace()->getVersionManager();
$vm->checkout($absolutepath);
$session->save();
...
$node->setProperty('prop', $newval);
$session->save();
...
$vm->checkin($absolutepath);
$session->save();

Code to get all versions:

$vm = $session()->getWorkspace()->getVersionMAnager();
$vhist = $vm->getVersionHistory($n->getPath());
$vit = $vhist->getAllVersions();
while($vit->valid()) {
    $v = $vit->current();
    $vnit = $v->getNodes();
    while($vnit->valid()) {
        $vn = $vnit->current();
        ...
        $vnit->next();
    }
    $vit->next();
}

Example - code:

This is a complete example showing an implementation of previuously described forum using PHPCR.

Forum:

forum.php:

<?php

class Forum {
    private $topics;
    public function __construct() {
        $this->topics = array();
    }
    public function &getTopics() {
        return $this->topcis;
    }
}

class Topic {
    private $name;
    private $description;
    private $posts;
    public function __construct($name, $description) {
        $this->name = $name;
        $this->description = $description;
        $this->posts = array();
    }
    public function getName() {
        return $this->name;
    }
    public function getDescription() {
        return $this->description;
    }
    public function &getPosts() {
        return $this->posts;
    }
}

class Post {
    private $author;
    private $time;
    private $title;
    private $body;
    private $comments;
    private $versions;
    public function __construct($author, $time, $title, $body) {
        $this->author = $author;
        $this->time = $time;
        $this->title = $title;
        $this->body = $body;
        $this->comments = array();
        $this->versions = array();
    }
    public function getAuthor() {
        return $this->author;
    }
    public function getTime() {
        return $this->time;
    }
    public function getTitle() {
        return $this->title;
    }
    public function getBody() {
        return $this->body;
    }
    public function &getComments() {
        return $this->comments;
    }
    public function &getVersions() {
        return $this->versions;
    }
}

class Comment {
    private $author;
    private $time;
    private $number;
    private $text;
    private $comments;
    private $versions;
    public function __construct($author, $time, $number, $text) {
        $this->author = $author;
        $this->time = $time;
        $this->number = $number;
        $this->text = $text;
        $this->comments = array();
        $this->versions = array();
    }
    public function getAuthor() {
        return $this->author;
    }
    public function getTime() {
        return $this->time;
    }
    public function getNumber() {
        return $this->number;
    }
    public function getText() {
        return $this->text;
    }
    public function &getComments() {
        return $this->comments;
    }
    public function &getVersions() {
        return $this->versions;
    }
}

use PHPCR\SimpleCredentials;
use PHPCR\NodeType\NodeTypeInterface;
use PHPCR\Query\QueryInterface;

class ForumException extends Exception {
    public function __construct($msg) {
        parent::__construct($msg);    
    }
}

class ForumManager {
    // use dash instead of space and special characters in path parts
    static function nameFixUp($name) {
        return preg_replace('|[^A-Za-z0-9#-]|', '', preg_replace('|[ /]|', '-', $name));
    }
    // number formats:
    //   #n
    //   #n.m
    function getAutoNumber($parent) {
        $num = (string)(count($parent->getNodes('*')) + 1);
        if($this->isPost($parent)) {
            return '#' . $num;
        } else if($this->isComment($parent)) {
            return $parent->getProperty('number')->getString() . '.' . $num;
        } else {
            throw new ForumException('Comment parent is neither post nor comment');
        }
    }
    // check that can be used to avoid duplicates
    function alreadyHasChild($parent, $nodename) {
        return count($parent->getNodes($nodename)) > 0;
    }
    // a permanent open session per ForumManager instance
    private $session;
    // version support
    private $verssup;
    public function __construct($repo, $usr, $pwd, $verssup = true) {
        // login
        $this->session = $repo->login(new SimpleCredentials($usr, $pwd));   
        // ensure forum root exists
        if(!$this->session->getRootNode()->hasNode('F')) {
            $this->session->getRootNode()->addNode('F');
        }
        // version support
        $this->verssup = $verssup;
    }
    function getSession() {
        return $this->session;
    }
    function getVerssup() {
        return $this->verssup;
    }
    // test nodes
    function isTopic($n) {
        return $n->hasProperty('name') && $n->hasProperty('description');
    }
    function isPost($n) {
        return $n->hasProperty('title') && $n->hasProperty('body') && $n->hasProperty('author');
    }
    function isComment($n) {
        return $n->hasProperty('number') && $n->hasProperty('text') && $n->hasProperty('author');
    }
    // find nodes
    function getAnyNode($path) {
        return $this->session->getRootNode()->getNode(substr($path, 1));
    }
    // add nodes
    function addTopic($topicName, $topicDescription) {
        $parent = $this->getAnyNode('/F');
        $nodename = ForumManager::nameFixUp($topicName);
        if(!$this->alreadyHasChild($parent, $nodename)) {
            $child = $parent->addNode($nodename);
            $child->setProperty('name', $topicName);
            $child->setProperty('description', $topicDescription);
            $this->session->save();
            return '/F/' . $nodename;
        } else {
            return null;
        }
    }
    function addPost($path, $postTitle, $postBody, $postAuthor) {
        $parent = $this->getAnyNode($path);
        if(!$this->isTopic($parent)) {
            throw new ForumException('Trying to add post to non-topic');
        }
        $nodename = ForumManager::nameFixUp($postTitle);
        if(!$this->alreadyHasChild($parent, $nodename)) {
            $child = $parent->addNode($nodename);
            if($this->verssup) $child->addMixin(NodeTypeInterface::MIX_VERSIONABLE);
            $child->setProperty('title', $postTitle);
            $child->setProperty('body', $postBody);
            $child->setProperty('author', $postAuthor);
            $child->setProperty('time', time());
            $this->session->save();
            return $path . '/' . $nodename;
        } else {
            return null;
        }
    }
    function addComment($path, $commentNumber, $commentText, $commentAuthor) {
        $parent = $this->getAnyNode($path);
        if(!$this->isPost($parent) && !$this->isComment($parent)) {
            throw new ForumException('Trying to add comment to non-post and non-comment');
        }
        $nodename = ForumManager::nameFixUp($commentNumber);
        if(!$this->alreadyHasChild($parent, $nodename)) {
            $child = $parent->addNode($nodename);
            if($this->verssup) $child->addMixin(NodeTypeInterface::MIX_VERSIONABLE);
            $child->setProperty('number', $commentNumber);
            $child->setProperty('text', $commentText);
            $child->setProperty('author', $commentAuthor);
            $child->setProperty('time', time());
            $this->session->save();
            return $path . '/' . $nodename;
        } else {
            return null;
        }
    }
    function addCommentAutoNumber($path, $commentText, $commentAuthor) {
        $cn = $this->getAutoNumber($this->getAnyNode($path));
        return $this->addComment($path, $cn, $commentText, $commentAuthor);
    }
    // update nodes
    function updatePost($path, $newPostBody) {
       if(!$this->verssup) throw new ForumException('Versions not supported');
       $vm = $this->session->getWorkspace()->getVersionManager();
        // if first update save original version
        if(count($vm->getVersionHistory($path)->getAllVersions()) <= 1) {
            $vm->checkout($path);
            $this->session->save();
            $vm->checkin($path);
            $this->session->save();
        }
        // checkout
        $vm->checkout($path);
        $this->session->save();
        // update
        $node = $this->getAnyNode($path);
        if(!$this->isPost($node)) {
            throw new ForumException('Updating non-post as post');
        }
        $node->setProperty('body', $newPostBody);
        $node->setProperty('time', time());
        $this->session->save();
        // checkin
        $vm->checkin($path);
        $this->session->save();
    }
    function updateComment($path, $newCommentText) {
        if(!$this->verssup) throw new ForumException('Versions not supported');
        $vm = $this->session->getWorkspace()->getVersionManager();
        // if first update save original version
        if(count($vm->getVersionHistory($path)->getAllVersions()) <= 1) {
            $vm->checkout($path);
            $this->session->save();
            $vm->checkin($path);
            $this->session->save();
        }
        // checkout
        $vm->checkout($path);
        $this->session->save();
        // update
        $node = $this->getAnyNode($path);
        if(!$this->isComment($node)) {
            throw new ForumException('Updating non-comment as comment');
        }
        $node->setProperty('text', $newCommentText);
        $node->setProperty('time', time());
        $this->session->save();
        // checkin
        $vm->checkin($path);
        $this->session->save();
    }
}

class ModelMapper {
    private function nodeToTopic($n) {
        return new Topic($n->getProperty('name')->getString(),
                         $n->getProperty('description')->getString());
    }
    private function nodeToPost($n) {
        return new Post($n->getProperty('author')->getString(),
                        $n->getProperty('time')->getLong(),
                        $n->getProperty('title')->getString(),
                        $n->getProperty('body')->getString());
    }
    private function nodeToComment($n) {
        return new Comment($n->getProperty('author')->getString(),
                           $n->getProperty('time')->getLong(),
                           $n->getProperty('number')->getString(),
                           $n->getProperty('text')->getString());
    }
    private function loadTopics(&$topics, $recurse) {
        $it = $this->forummanager->getSession()->getRootNode()->getNode('F')->getNodes('*');
        while($it->valid()) {
            $n = $it->current();
            if($this->forummanager->isTopic($n)) {
                $t = $this->nodeToTopic($n);
                $topics[] = $t;
                if(recurse) {
                    $this->loadPosts($t->getPosts(), $n, true);
                }
            } else {
                throw new ForumException('Forum has non-topic child');
            }
            $it->next();
        }
    }
    private function loadPosts(&$posts, $parent, $recurse) {
        if(!$this->forummanager->isTopic($parent)) {
            throw new ForumException('Trying to get posts of non-topic');
        }
        $it = $parent->getNodes('*');
        while($it->valid()) {
            $n = $it->current();
            if($this->forummanager->isPost($n)) {
                $p = $this->nodeToPost($n);
                $posts[] = $p;
                if($this->forummanager->getVerssup()) {
                    $vm = $this->forummanager->getSession()->getWorkspace()->getVersionMAnager();
                    $vhist = $vm->getVersionHistory($n->getPath());
                    $vit = $vhist->getAllVersions();
                    while($vit->valid()) {
                        $v = $vit->current();
                        $vnit = $v->getNodes();
                        while($vnit->valid()) {
                            $vn = $vnit->current();
                            if($this->forummanager->isPost($vn)) {
                                $p->getVersions()[] = $this->nodeToPost($vn);
                            }
                            $vnit->next();
                        }
                        $vit->next();
                    }
                }
                if(recurse) {
                    $this->loadComments($p->getComments(), $n, true);
                }
            } else {
                throw new ForumException('Topic has non-post child');
            }
            $it->next();
        }
    }
    private function loadComments(&$comments, $parent, $recurse) {
        if(!$this->forummanager->isPost($parent) && !$this->forummanager->isComment($parent)) {
            throw new ForumException('Trying to get posts of non-post and non-comment');
        }
        $it = $parent->getNodes('*');
        while($it->valid()) {
            $n = $it->current();
            if($this->forummanager->isComment($n)) {
                $c = $this->nodeToComment($n);
                $comments[] = $c;
                if($this->forummanager->getVerssup()) {
                    $vm = $this->forummanager->getSession()->getWorkspace()->getVersionMAnager();
                    $vhist = $vm->getVersionHistory($n->getPath());
                    $vit = $vhist->getAllVersions();
                    while($vit->valid()) {
                        $v = $vit->current();
                        $vnit = $v->getNodes();
                        while($vnit->valid()) {
                            $vn = $vnit->current();
                            if($this->forummanager->isComment($vn)) {
                                $c->getVersions()[] = $this->nodeToComment($vn);
                            }
                            $vnit->next();
                        }
                        $vit->next();
                    }
                }
                if(recurse) {
                    $this->loadComments($c->getComments(), $n, true);
                }
            } else {
                throw new ForumException('Post/comment has non-comment child');
            }
            $it->next();
        }
    }
    // mapping between model and PHPCR
    private $forummanager;
    public function __construct($forummanager) {
        $this->forummanager = $forummanager;
    }
    // load
    public function loadForum() {
        $f = new Forum();
        $this->loadTopics($f->getTopics(), true);    
        return $f;
    }
    public function loadTopic($path, $recurse = false) {
        $n = $this->forummanager->getAnyNode($path);
        if($this->forummanager->isTopic($n)) {
            $t = $this->nodeToTopic($n);
            if($recurse) {
                $this->loadPosts($t->getPosts(), $n, true);
            }
            return $t;
        } else {
            throw new ForumException('Path is not a topic path: ' . $path);
        }
    }
    public function loadPost($path, $recurse = false) {
        $n = $this->forummanager->getAnyNode($path);
        if($this->forummanager->isPost($n)) {
            $p = $this->nodeToPost($n);
            if($recurse) {
                $this->loadComments($p->getComments(), $n, true);
            }
            return $p;
        } else {
            throw new ForumException('Path is not a post path: ' . $path);
        }
    }
    public function loadComment($path, $recurse = false) {
        $n = $this->forummanager->getAnyNode($path);
        if($this->forummanager->isComment($n)) {
            $c = $this->nodeToComment($n);
            if($recurse) {
                $this->loadComments($c->getComments(), $n, true);
            }
            return $c;
        } else {
            throw new ForumException('Path is not a comment path: ' . $path);
        }
    }
    function findPostByAuthor($author, $recurse = false) {
        $res = array();
        $q = $this->forummanager->getSession()->getWorkspace()->getQueryManager()->createQuery('SELECT * FROM [nt:unstructured] WHERE author = \'' . $author . '\' AND title IS NOT NULL AND number IS NULL', QueryInterface::JCR_SQL2);
        $qr = $q->execute();
        $nit = $qr->getNodes();
        while($nit->valid()) {
            $n = $nit->current();
            if($this->forummanager->isPost($n)) {
                $p = $this->nodeToPost($n);
                $res[] = $p;
                if($recurse) {
                    $this->loadComments($p->getComments(), $n, true);
                }
            }
            $nit->next();
        }
        return $res;
    }
    function findPostByContent($content, $recurse = false) {
        $res = array();
        $q = $this->forummanager->getSession()->getWorkspace()->getQueryManager()->createQuery('SELECT * FROM [nt:unstructured] WHERE CONTAINS(body,\'' . $content . '\') AND title IS NOT NULL AND number IS NULL', QueryInterface::JCR_SQL2);
        $qr = $q->execute();
        $nit = $qr->getNodes();
        while($nit->valid()) {
            $n = $nit->current();
            if($this->forummanager->isPost($n)) {
                $p = $this->nodeToPost($n);
                $res[] = $p;
                if($recurse) {
                    $this->loadComments($p->getComments(), $n, true);
                }
            }
            $nit->next();
        }
        return $res;
    }
    function findCommentByAuthor($author, $recurse = false) {
        $res = array();
        $q = $this->forummanager->getSession()->getWorkspace()->getQueryManager()->createQuery('SELECT * FROM [nt:unstructured] WHERE author = \'' . $author . '\' AND title IS NULL AND number IS NOT NULL', QueryInterface::JCR_SQL2);
        $qr = $q->execute();
        $nit = $qr->getNodes();
        while($nit->valid()) {
            $n = $nit->current();
            if($this->forummanager->isComment($n)) {
                $c = $this->nodeToComment($n);
                $res[] = $c;
                if($recurse) {
                    $this->loadComments($c->getComments(), $n, true);
                }
            }
            $nit->next();
        }
        return $res;
    }
    function findCommentByContent($content, $recurse = false) {
        $res = array();
        $q = $this->forummanager->getSession()->getWorkspace()->getQueryManager()->createQuery('SELECT * FROM [nt:unstructured] WHERE CONTAINS(text,\'' . $content . '\') AND title IS NULL AND number IS NOT NULL', QueryInterface::JCR_SQL2);
        $qr = $q->execute();
        $nit = $qr->getNodes();
        while($nit->valid()) {
            $n = $nit->current();
            if($this->forummanager->isComment($n)) {
                $c = $this->nodeToComment($n);
                $res[] = $c;
                if($recurse) {
                    $this->loadComments($c->getComments(), $n, true);
                }
            }
            $nit->next();
        }
        return $res;
    }
}

?>

Forum test:

forumtest.php:

<?php

include 'forum.php';

function getRepository($factory, $parameters) {
    $factory = new $factory();
    $repository = $factory->getRepository($parameters);
    return $repository;
}

function printForum($forum) {
    foreach($forum->getTopics() as $topic) {
        printTopic('', $topic);
    }
}

function printTopic($indent, $topic) {
    echo $indent . "Topic:\r\n";
    echo $indent . "Name = " . $topic->getName() . "\r\n";
    echo $indent . "Description = " . $topic->getDescription() . "\r\n";
    foreach($topic->getPosts() as $post) {
        printPost($indent . '  ', $post);
    }
}

function printPost($indent, $post) {
    echo $indent . "Post:\r\n";
    echo $indent . "Author = " . $post->getAuthor() . "\r\n";
    echo $indent . "Time = " . date('Y-m-d H:i:s', $post->getTime()) . "\r\n";
    echo $indent . "Title = " . $post->getTitle() . "\r\n";
    echo $indent . "Body = " . $post->getBody() . "\r\n";
    foreach($post->getVersions() as $v) {
        echo $indent . "Version = (" . date('Y-m-d H:i:s', $v->getTime()) . "," . $v->getBody() . ")\r\n";
    }
    foreach($post->getComments() as $comment) {
        printComment($indent . '  ', $comment);
    }
}

function printComment($indent, $comment) {
    echo $indent . "Comment:\r\n";
    echo $indent . "Author = " . $comment->getAuthor() . "\r\n";
    echo $indent . "Time = " . date('Y-m-d H:i:s', $comment->getTime()) . "\r\n";
    echo $indent . "Number = " . $comment->getNumber() . "\r\n";
    echo $indent . "Text = " . $comment->getText() . "\r\n";
    foreach($comment->getVersions() as $v) {
        echo $indent . "Version = (" . date('Y-m-d H:i:s', $v->getTime()) . "," . $v->getText() . ")\r\n";
    }
    foreach($comment->getComments() as $comment2) {
        printComment($indent . '  ', $comment2);
    }
}

function testForumLoad($forummanager) {
    $modelmapper = new ModelMapper($forummanager);
    printForum($modelmapper->loadForum());
}

function testTopicLoad($forummanager, $path) {
    echo 'Load: ' . $path . "\r\n";
    $modelmapper = new ModelMapper($forummanager);
    printTopic("", $modelmapper->loadTopic($path, true));
}

function testPostLoad($forummanager, $path) {
    echo 'Load: ' . $path . "\r\n";
    $modelmapper = new ModelMapper($forummanager);
    printPost("", $modelmapper->loadPost($path, true));
}

function testCommentLoad($forummanager, $path) {
    echo 'Load: ' . $path . "\r\n";
    $modelmapper = new ModelMapper($forummanager);
    printComment("", $modelmapper->loadComment($path, true));
}

function testPostAuthorSearch($forummanager, $author) {
    echo 'Post author search for ' . $author . ":\r\n";
    $modelmapper = new ModelMapper($forummanager);
    foreach($modelmapper->findPostByAuthor($author, true) as $p) {
        printPost('', $p);
    }
}
function testPostContentSearch($forummanager, $content) {
    echo 'Post content search for ' . $content . ":\r\n";
    $modelmapper = new ModelMapper($forummanager);
    foreach($modelmapper->findPostByContent($content, true) as $p) {
        printPost('', $p);
    }
}
function testCommentAuthorSearch($forummanager, $author) {
    echo 'Comment author search for ' . $author . ":\r\n";
    $modelmapper = new ModelMapper($forummanager);
    foreach($modelmapper->findCommentByAuthor($author, true) as $c) {
        printComment('', $c);
    }
}
function testCommentContentSearch($forummanager, $content) {
    echo 'Comment content search for '  . $content . ":\r\n";
    $modelmapper = new ModelMapper($forummanager);
    foreach($modelmapper->findCommentByContent($content, true) as $c) {
        printComment('', $c);
    }
}

function test($factory, $parameters, $usr, $pwd, $verssup = true) {
    $repo = getRepository($factory, $parameters);
    $forummanager = new ForumManager($repo, $usr, $pwd, $verssup);
    // add 2 topics
    $smart = $forummanager->addTopic('Smart questions', 'This is for smart questions');
    $dumb = $forummanager->addTopic('Dumb questions', 'This is for dumb questions');
    // add 3 posts
    $plus1 = $forummanager->addPost($dumb, 'What is 1+1?', 'I have a math question: what is 1+1?', 'arne');
    $plus2 = $forummanager->addPost($dumb, 'What is 2+2?', 'I have another math question: what is 2+2?', 'arne');
    $life = $forummanager->addPost($smart, 'What is the purpose of life?', 'I am in the philosophical mood. What is the purpose of life?', 'arne');
    // add 4 comments
    $plus1a = $forummanager->addCommentAutoNumber($plus1, '2', 'arne');
    $plus2a = $forummanager->addCommentAutoNumber($plus2, '4', 'arne');
    $lifea1 = $forummanager->addCommentAutoNumber($life, 'Good question.', 'arne');
    $lifea2 = $forummanager->addCommentAutoNumber($life, 'Please tell me if you find out.', 'arne');
    // add 2 comments to comments to comments
    $forummanager->addCommentAutoNumber($plus1a, 'Hmmm.', 'Arne');
    $forummanager->addCommentAutoNumber($plus2a, 'Hmmm.', 'Arne');
    // update 1 comment twice
    if($verssup) {
        sleep(1);
        $forummanager->updateComment($plus1a, 'Forget it.');
        $forummanager->updateComment($plus2a, 'Forget it.');
        sleep(1);
        $forummanager->updateComment($plus1a, 'Sorry.');
        $forummanager->updateComment($plus2a, 'Sorry.');
    }
    // test load
    testForumLoad($forummanager);
    testTopicLoad($forummanager, $smart);
    testPostLoad($forummanager, $life);
    testCommentLoad($forummanager, $lifea1);
    testCommentLoad($forummanager, $lifea2);
    // test search
    testPostAuthorSearch($forummanager, 'xxx');
    testPostAuthorSearch($forummanager, 'arne');
    testPostContentSearch($forummanager, 'math');
    testPostContentSearch($forummanager, 'foobar');
    testCommentAuthorSearch($forummanager, 'xxx');
    testCommentAuthorSearch($forummanager, 'arne');
    testCommentContentSearch($forummanager, 'good');
    testCommentContentSearch($forummanager, 'foobar');
}

?>

Example - setup:

Jackalope - Jackrabbit:

jackalope_jackrabbit.php:

<?php
error_reporting(E_ERROR | E_PARSE);

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});

include 'forumtest.php';

$saweb = array('jackalope.jackrabbit_uri' => 'http://localhost:10000/server');
test('Jackalope\RepositoryFactoryJackrabbit', $saweb, 'jcradmin', 'secret', true);
?>

repoconfig.xml:

<?xml version="1.0"?>
<!DOCTYPE Repository
          PUBLIC "-//The Apache Software Foundation//DTD Jackrabbit 2.0//EN"
          "http://jackrabbit.apache.org/dtd/repository-2.0.dtd">
<Repository>
    <FileSystem class="org.apache.jackrabbit.core.fs.db.DbFileSystem">
          <param name="url" value="jdbc:mysql://localhost/jrb_repo"/>
          <param name="user" value="root"/>
          <param name="password" value=""/>
          <param name="schema" value="mysql"/>
          <param name="schemaObjectPrefix" value="repository_"/>
    </FileSystem>
    <DataStore class="org.apache.jackrabbit.core.data.FileDataStore"/>
    <Security appName="Jackrabbit">
        <SecurityManager class="org.apache.jackrabbit.core.DefaultSecurityManager" workspaceName="security">
        </SecurityManager>
        <AccessManager class="org.apache.jackrabbit.core.security.DefaultAccessManager">
        </AccessManager>
        <LoginModule class="custom.JRBLoginModule">
           <param name="anonymousId" value="anonymous"/>
           <param name="adminId" value="jcradmin"/>
        </LoginModule>
    </Security>
    <Workspaces rootPath="${rep.home}/workspaces" defaultWorkspace="default"/>
    <Workspace name="${wsp.name}">
        <FileSystem class="org.apache.jackrabbit.core.fs.db.DbFileSystem">
          <param name="url" value="jdbc:mysql://localhost/jrb_repo"/>
          <param name="user" value="root"/>
          <param name="password" value=""/>
          <param name="schema" value="mysql"/>
          <param name="schemaObjectPrefix" value="${wsp.name}_"/>
        </FileSystem>
        <PersistenceManager class="org.apache.jackrabbit.core.persistence.pool.BundleDbPersistenceManager">
          <param name="url" value="jdbc:mysql://localhost/jrb_repo"/>
          <param name="user" value="root"/>
          <param name="password" value=""/>
          <param name="databaseType" value="mysql"/>
          <param name="schemaObjectPrefix" value="${wsp.name}_"/>
        </PersistenceManager>
        <SearchIndex class="org.apache.jackrabbit.core.query.lucene.SearchIndex">
            <param name="path" value="${wsp.home}/index"/>
        </SearchIndex>
    </Workspace>
    <Versioning rootPath="${rep.home}/version">
        <FileSystem class="org.apache.jackrabbit.core.fs.db.DbFileSystem">
          <param name="url" value="jdbc:mysql://localhost/jrb_repo"/>
          <param name="user" value="root"/>
          <param name="password" value=""/>
          <param name="schema" value="mysql"/>
          <param name="schemaObjectPrefix" value="version_"/>
        </FileSystem>
        <PersistenceManager class="org.apache.jackrabbit.core.persistence.pool.BundleDbPersistenceManager">
          <param name="url" value="jdbc:mysql://localhost/jrb_repo"/>
          <param name="user" value="root"/>
          <param name="password" value=""/>
          <param name="databaseType" value="mysql"/>
          <param name="schemaObjectPrefix" value="version_"/>
        </PersistenceManager>
    </Versioning>
</Repository>

For the custom login module see the full JCR article.

To start standalone server:

java -cp jackrabbit-standalone-2.18.3.jar;mysql-connector-java-5.1.36-bin.jar;. org.apache.jackrabbit.standalone.Main --port 10000 --repo repo --conf repoconfig.xml

Jackalope - Doctrine DBAL:

jackalope_doctrinedbal.php:

<?php
error_reporting(E_ERROR | E_PARSE);

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});

include 'forumtest.php';

use Doctrine\DBAL\DriverManager;
    
$con = DriverManager::getConnection(array('driver' => 'pdo_mysql', 'host' => 'localhost', 'user' => 'root', 'password' => '', 'dbname' => 'jlp_repo'));
$dbal = array('jackalope.doctrine_dbal_connection' => $con);
test('Jackalope\RepositoryFactoryDoctrineDBAL', $dbal, null, null, false);
?>

No external configuration is needed. Database connection is defined in code.

To create the database:

<?php

spl_autoload_register(function ($clznam) {
    include $clznam . '.php';
});

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Jackalope\Transport\DoctrineDBAL\RepositorySchema;

$connection = DriverManager::getConnection(array('driver' => 'pdo_mysql', 'host' => 'localhost', 'user' => 'root', 'password' => '', 'dbname' => 'jlp_repo'));

$schema = new RepositorySchema([], $connection);

foreach ($schema->toSql($connection->getDatabasePlatform()) as $sql) {
     $connection->exec($sql);
}

?>

Jackalope Doctrine DBAL does not support access control besides the database access control so no custom login module.

Other technologies:

JCR is pretty widely adopted among Java CMS'es.

A sligthgly different approach is CMIS (Content Management Interoperability Services). CMIS is an OASIS standard that defines technology neutral web service API for CMS'es. The standard defines bindings: SOAP, AtomPub and a RESTFul. CMIS has gained some acceptance in both Java, .NET and PHP world.

Read more about CMIS here.

Article history:

Version Date Description
1.0 January 3rd 2020 Initial version

Other articles:

See list of all articles here

Comments:

Please send comments to Arne Vajhøj