JCR

Content:

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

Introduction:

JCR is practically a sub-culture within the Java world. Most Java developers have never heard about it. For the Java developers that actually work with CMS it is a super important technology in their everyday work.

I believe it is relevant to learn about what it is and what it can be used for.

Disclaimer: I think JCR is a very interesting technology, but I have never worked with it professionally hands on, so I am just a beginner at JCR myself. All the code below is tested to work, but some of it may not be considede best practice among JCR professionals.

What it is:

First question is obviously: what is JCR?

JCR is Java Content Repository - a standard Java API for content repositories.

Second question: what is a content repository?

A content repository is an abstract tree structure providing a core CMS engine.

A normal CMS application architecture looks like:

General CMS

and it contains:

JCR provide a standard API to the CMS engine decoupling the applications from the CMS engine:

JCR CMS

and it contains:

Now we see the benefits of the standard API:

That is pretty good.

Third question: what is the point in the tree structure?

In general a tree structure is much more convenient than a traditional relational database structure for a CMS.

To illustrate that let us look at a classic CMS usage: a forum.

Everybody knows how a forum works:

Object oriented it look like:

CMS class diagram

This is actually pretty easy to implement using a traditional relational database:

Topic
id(INTEGER),PK
name(VARCHAR),IX
description(VARCHAR)

Post
id(INTEGER),PK
title(VARCHAR),IX
body(VARCHAR)
author(VARCHAR),IX
time(DATETIME),IX
topic(INTEGER),FK->Topic,IX

Comment
id(INTEGER),PK
number(VARCHAR),IX
text(VARCHAR)
author(VARCHAR),IX
time(DATETIME),IX
post(INTEGER),FK->Post,IX

But now comes some additional requirements:

  1. The users want to be able to add comments to comments to make it easier to follow the discussion compared with all comments to a post just being a linear sequence.
  2. The users want to be able to edit posts and comments to fix typos, copy paste errors etc.. This requires support for multiple versions of posts and comments.
  3. The users want a powerful search capability to be able to locate posts/comments based in various search criteria.

Implementing #1 and #2 will cause the SQL to become absolute horrible. And implementing #3 will either be significant work or have to rely on database specific features.

Implementing #1 with a tree structure is much easier:

CMS tree

And after considering it a bit then one realize that #2 (versions) can also easily be solved by adding version nodes below the node itself in the tree.

So a tree structure is definitely what we want to work with - from API perspective.

JCR provide the standard API and the abstract tree structure.

JCR 1.0 was defined in JSR 170 in 2005.

JCR 2.0 was defined in JSR 283 in 2009.

Implementations:

Several implementations of JCR exist:

JCR repository Status Used by CMS products Storage support
Apache Jackrabbit Open source AEM (Adobe ExperienceManager)
Jahia
Hippo/BloomReach
Magnolia
Relational databases (Oracle DB, MS SQLServer, MySQL/MariaDB, PostgreSQL)
File system
JBoss ModeShape Open source JBoss/WildFly Relational databases (Oracle DB, MySQL/MariaDB, PostgreSQL)
NoSQL databases (MongoDB, Cassandra)
File system
Alfresco Open source &commercial Alfresco Relational databases (Oracle DB, IBM DB2, MS SQLServer, MySQL/MariaDB, PostgreSQL)
eXo Open source &commercial eXo Relational databases (Oracle DB, IBM DB2, MS SQLServer, MySQL/MariaDB, PostgreSQL, Sybase ASE)
File system
Apache Jackrabbit Oak Open source Relational databases (Oracle DB, IBM DB2, MS SQLServer, MySQL/MariaDB, PostgreSQL)
NoSQL databases (MongoDB)

Note that even when a JCR repository is stored in a relational database, then the data is not easily accessible outside the JCR API. The storage format in the database is not documented and usually very convoluted.

Programming:

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

Get connection:

The way to get a connection to the repository via JCR API in a repository independent way is to:

private static Repository getRepository(Map<String, String> parameters) throws RepositoryException {
    for (RepositoryFactory factory : ServiceLoader.load(RepositoryFactory.class)) {
        Repository repo = factory.getRepository(parameters);
        if(repo != null) {
            return repo;
        }
    }
    return null;
}
...
Repository repo = getRepository(parameters);
Session session = repo.login(new SimpleCredentials(usr, pwd.toCharArray()));

Work with nodes and tree structure:

In JCR 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

Get root node (top node):

Node rootnode = session.getRootNode();

Add node below existing node:

Node child = parent.addNode(name);
...
session.save();

Get node below existing node:

Node node = abovenode.getNode(relativepath);

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

Nodes in JCR 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 JCR'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:

long iv = node.getProperty("iprop").getLong();
String sv = node.getProperty("sprop").getString();

Queries:

JCR 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 JCR implementation implement it by iterating over every instance of the node property and do a simple string search in each.

Any good JCR implementation use a full text search engine to make such queries fast.

Both Apache Jackrabbit and JBoss ModeShape can use Lucene as full text search engine. Lucene is what Wikipedia is using, so it can handle really large data sizes very efficiently.

To execute a query:

Query q = session.getWorkspace().getQueryManager().createQuery(sqlstr, Query.JCR_SQL2);
QueryResult qr = q.execute();
NodeIterator nit = qr.getNodes();
while(nit.hasNext()) {
    Node n = nit.nextNode();
    ...
}

Versioning:

JCR 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(NodeType.MIX_VERSIONABLE);
...
session.save();

Code to add new versions::

VersionManager vm = session.getWorkspace().getVersionManager();
vm.checkout(absolutepath);
ses.save();
...
node.setProperty("prop", newval);
ses.save();
...
vm.checkin(path);
ses.save();

Code to get all versions:

VersionManager vm = session.getWorkspace().getVersionManager();
VersionHistory vhist = vm.getVersionHistory(n.getPath());
VersionIterator vit = vhist.getAllVersions();
while(vit.hasNext()) {
    Version v = vit.nextVersion();
    NodeIterator vnit = v.getNodes();
    while(vnit.hasNext()) {
        Node vn = vnit.nextNode();
        ...
    }
}

Example - code:

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

Basic JCR usage:

ForumManager.java:

package dk.vajhoej.forum;

import javax.jcr.Node;
import javax.jcr.PathNotFoundException;
import javax.jcr.Repository;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.SimpleCredentials;
import javax.jcr.ValueFormatException;
import javax.jcr.nodetype.NodeType;
import javax.jcr.version.VersionManager;

public class ForumManager {
    // use dash instead of space and special characters in path parts
    static String nameFixUp(String name) {
        return name.replaceAll("[ /]", "-").replaceAll("[^A-Za-z0-9#-]", "");
    }
    // number formats:
    //   #n
    //   #n.m
    private String getAutoNumber(Node parent) throws ValueFormatException, PathNotFoundException, RepositoryException, ForumException {
        String num = Long.toString(parent.getNodes("*").getSize() + 1);
        if(isPost(parent)) {
            return "#" + num;
        } else if(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
    private boolean alreadyHasChild(Node parent, String nodename) throws RepositoryException {
        return parent.getNodes(nodename).getSize() > 0;
    }
    // a permanent open session per ForumManager instance
    private Session ses;
    public ForumManager(Repository repo, String usr, String pwd) throws RepositoryException {
        // login
        this.ses = repo.login(new SimpleCredentials(usr, pwd.toCharArray()));
        // ensure forum root exists
        if(!ses.getRootNode().hasNode("F")) {
            ses.getRootNode().addNode("F");
        }
    }
    Session getSession() {
        return ses;
    }
    // test nodes
    boolean isTopic(Node n) throws RepositoryException {
        return n.hasProperty("name") && n.hasProperty("description");
    }
    boolean isPost(Node n) throws RepositoryException {
        return n.hasProperty("title") && n.hasProperty("body") && n.hasProperty("author");
    }
    boolean isComment(Node n) throws RepositoryException {
        return n.hasProperty("number") && n.hasProperty("text") && n.hasProperty("author");
    }
    // find nodes
    Node getAnyNode(String path) throws PathNotFoundException, RepositoryException {
        return ses.getRootNode().getNode(path.substring(1));
    }
    // add nodes
    public String addTopic(String topicName, String topicDescription) throws RepositoryException {
        Node parent = getAnyNode("/F");
        String nodename = nameFixUp(topicName);
        if(!alreadyHasChild(parent, nodename)) {
            Node child = parent.addNode(nodename);
            child.setProperty("name", topicName);
            child.setProperty("description", topicDescription);
            ses.save();
            return "/F/" + nodename;
        } else {
            return null;
        }
    }
    public String addPost(String path, String postTitle, String postBody, String postAuthor) throws RepositoryException, ForumException {
        Node parent = getAnyNode(path);
        if(!isTopic(parent)) {
            throw new ForumException("Trying to add post to non-topic");
        }
        String nodename = nameFixUp(postTitle);
        if(!alreadyHasChild(parent, nodename)) {
            Node child = parent.addNode(nodename);
            child.addMixin(NodeType.MIX_VERSIONABLE);
            child.setProperty("title", postTitle);
            child.setProperty("body", postBody);
            child.setProperty("author", postAuthor);
            child.setProperty("time", System.currentTimeMillis());
            ses.save();
            return path + "/" + nodename;
        } else {
            return null;
        }
    }
    public String addComment(String path, String commentNumber, String commentText, String commentAuthor) throws RepositoryException, ForumException {
        Node parent = getAnyNode(path);
        if(!isPost(parent) && !isComment(parent)) {
            throw new ForumException("Trying to add comment to non-post and non-comment");
        }
        String nodename = nameFixUp(commentNumber);
        if(!alreadyHasChild(parent, nodename)) {
            Node child = parent.addNode(nodename);
            child.addMixin(NodeType.MIX_VERSIONABLE);
            child.setProperty("number", commentNumber);
            child.setProperty("text", commentText);
            child.setProperty("author", commentAuthor);
            child.setProperty("time", System.currentTimeMillis());
            ses.save();
            return path + "/" + nodename;
        } else {
            return null;
        }
    }
    public String addCommentAutoNumber(String path, String commentText, String commentAuthor) throws RepositoryException, ForumException {
        String cn = getAutoNumber(getAnyNode(path));
        return addComment(path, cn, commentText, commentAuthor);
    }
    // update nodes
    public void updatePost(String path, String newPostBody) throws RepositoryException, ForumException {
        VersionManager vm = ses.getWorkspace().getVersionManager();
        // if first update save original version
        if(vm.getVersionHistory(path).getAllVersions().getSize() <= 1) {
            vm.checkout(path);
            ses.save();
            vm.checkin(path);
            ses.save();
        }
        // checkout
        vm.checkout(path);
        ses.save();
        // update
        Node node = getAnyNode(path);
        if(!isPost(node)) {
            throw new ForumException("Updating non-post as post");
        }
        node.setProperty("body", newPostBody);
        node.setProperty("time", System.currentTimeMillis());
        ses.save();
        // checkin
        vm.checkin(path);
        ses.save();
    }
    public void updateComment(String path, String newCommentText) throws RepositoryException, ForumException {
        VersionManager vm = ses.getWorkspace().getVersionManager();
        // if first update save original version
        if(vm.getVersionHistory(path).getAllVersions().getSize() <= 1) {
            vm.checkout(path);
            ses.save();
            vm.checkin(path);
            ses.save();
        }
        // checkout
        vm.checkout(path);
        ses.save();
        // update
        Node node = getAnyNode(path);
        if(!isComment(node)) {
            throw new ForumException("Updating non-comment as comment");
        }
        node.setProperty("text", newCommentText);
        node.setProperty("time", System.currentTimeMillis());
        ses.save();
        // checkin
        vm.checkin(path);
        ses.save();
    }
}

ForumException.java:

package dk.vajhoej.forum;

public class ForumException extends Exception {
    private static final long serialVersionUID = 1L;
    public ForumException(String msg) {
        super(msg);
    }
}

JCR and object model:

Forum.java:

package dk.vajhoej.forum.model;

import java.util.ArrayList;
import java.util.List;

public class Forum {
    private List<Topic> topics;
    public Forum() {
        this.topics = new ArrayList<Topic>();
    }
    public List<Topic> getTopics() {
        return topics;
    }
}

Topic.java:

package dk.vajhoej.forum.model;

import java.util.ArrayList;
import java.util.List;

public class Topic {
    private String name;
    private String description;
    private List<Post> posts;
    public Topic(String name, String description) {
        this.name = name;
        this.description = description;
        this.posts = new ArrayList<>();
    }
    public String getName() {
        return name;
    }
    public String getDescription() {
        return description;
    }
    public List<Post> getPosts() {
        return posts;
    }
}

Post.java:

package dk.vajhoej.forum.model;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class Post {
    private String author;
    private Date time;
    private String title;
    private String body;
    private List<Comment> comments;
    private List<Post> versions;
    public Post(String author, String title, String body) {
        this(author, new Date(), title, body);
    }
    public Post(String author, Date time, String title, String body) {
        this.author = author;
        this.time = time;
        this.title = title;
        this.body = body;
        this.comments = new ArrayList<Comment>();
        this.versions = new ArrayList<Post>();
    }
    public String getAuthor() {
        return author;
    }
    public Date getTime() {
        return time;
    }
    public String getTitle() {
        return title;
    }
    public String getBody() {
        return body;
    }
    public List<Comment> getComments() {
        return comments;
    }
    public List<Post> getVersions() {
        return versions;
    }
}

Comment.java:

package dk.vajhoej.forum.model;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

public class Comment {
    private String author;
    private Date time;
    private String number;
    private String text;
    private List<Comment> comments;
    private List<Comment> versions;
    public Comment(String author, String text) {
        this(author, null, text);
    }
    public Comment(String author, String number, String text) {
        this(author, new Date(), number, text);
    }
    public Comment(String author, Date time, String number, String text) {
        this.author = author;
        this.time = time;
        this.number = number;
        this.text = text;
        this.comments = new ArrayList<Comment>();
        this.versions = new ArrayList<Comment>();
    }
    public String getAuthor() {
        return author;
    }
    public Date getTime() {
        return time;
    }
    public String getNumber() {
        return number;
    }
    public String getText() {
        return text;
    }
    public List<Comment> getComments() {
        return comments;
    }
    public List<Comment> getVersions() {
        return versions;
    }
}

ModelMapper.java:

package dk.vajhoej.forum;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.PathNotFoundException;
import javax.jcr.RepositoryException;
import javax.jcr.ValueFormatException;
import javax.jcr.query.Query;
import javax.jcr.query.QueryResult;
import javax.jcr.version.Version;
import javax.jcr.version.VersionHistory;
import javax.jcr.version.VersionIterator;
import javax.jcr.version.VersionManager;

import dk.vajhoej.forum.model.Comment;
import dk.vajhoej.forum.model.Forum;
import dk.vajhoej.forum.model.Post;
import dk.vajhoej.forum.model.Topic;

public class ModelMapper {
    private Topic nodeToTopic(Node n) throws ValueFormatException, PathNotFoundException, RepositoryException {
        return new Topic(n.getProperty("name").getString(),
                         n.getProperty("description").getString());
    }
    private Post nodeToPost(Node n) throws ValueFormatException, PathNotFoundException, RepositoryException {
        return new Post(n.getProperty("author").getString(), 
                        new Date(n.getProperty("time").getLong()),
                        n.getProperty("title").getString(),
                        n.getProperty("body").getString());
    }
    private Comment nodeToComment(Node n) throws ValueFormatException, PathNotFoundException, RepositoryException {
        return new Comment(n.getProperty("author").getString(), 
                           new Date(n.getProperty("time").getLong()),
                           n.getProperty("number").getString(),
                           n.getProperty("text").getString());
    }
    private void loadTopics(List<Topic> topics, boolean recurse) throws RepositoryException, ForumException {
        NodeIterator it = fmgr.getSession().getRootNode().getNode("F").getNodes("*");
        while(it.hasNext()) {
            Node n = it.nextNode();
            if(fmgr.isTopic(n)) {
                Topic t = nodeToTopic(n);
                topics.add(t);
                if(recurse) {
                    loadPosts(t.getPosts(), n, true);
                }
            } else {
                throw new ForumException("Forum has non-topic child");
            }
        }
    }
    private void loadPosts(List<Post> posts, Node parent, boolean recurse) throws RepositoryException, ForumException {
        if(!fmgr.isTopic(parent)) {
            throw new ForumException("Trying to get posts of non-topic");
        }
        NodeIterator it = parent.getNodes("*");
        while(it.hasNext()) {
            Node n = it.nextNode();
            if(fmgr.isPost(n)) {
                Post p = nodeToPost(n);
                posts.add(p);
                VersionManager vm = fmgr.getSession().getWorkspace().getVersionManager();
                VersionHistory vhist = vm.getVersionHistory(n.getPath());
                VersionIterator vit = vhist.getAllVersions();
                while(vit.hasNext()) {
                    Version v = vit.nextVersion();
                    NodeIterator vnit = v.getNodes();
                    while(vnit.hasNext()) {
                        Node vn = vnit.nextNode();
                        if(fmgr.isPost(vn)) {
                            p.getVersions().add(nodeToPost(vn));
                        }
                    }
                }
                if(recurse) {
                    loadComments(p.getComments(), n, true);
                }
            } else {
                throw new ForumException("Topic has non-post child");
            }
        }
    }
    private void loadComments(List<Comment> comments, Node parent, boolean recurse) throws RepositoryException, ForumException {
        if(!fmgr.isPost(parent) && !fmgr.isComment(parent)) {
            throw new ForumException("Trying to get comemnts of non-topic and non-comment");
        }
        NodeIterator it = parent.getNodes("*");
        while(it.hasNext()) {
            Node n = it.nextNode();
            if(fmgr.isComment(n)) {
                Comment c = nodeToComment(n);
                comments.add(c);
                VersionManager vm = fmgr.getSession().getWorkspace().getVersionManager();
                VersionHistory vhist = vm.getVersionHistory(n.getPath());
                VersionIterator vit = vhist.getAllVersions();
                while(vit.hasNext()) {
                    Version v = vit.nextVersion();
                    NodeIterator vnit = v.getNodes();
                    while(vnit.hasNext()) {
                        Node vn = vnit.nextNode();
                        if(fmgr.isComment(vn)) {
                            c.getVersions().add(nodeToComment(vn));
                        }
                    }
                }
                if(recurse) {
                    loadComments(c.getComments(), n, true);
                }
            } else {
                throw new ForumException("Post/comment has non-comment child");
            }
        }
    }
    // mapping between model and JCR
    private ForumManager fmgr;
    public ModelMapper(ForumManager fmgr) {
        this.fmgr = fmgr;
    }
    // load
    public Forum loadForum() throws RepositoryException, ForumException {
        Forum f = new Forum();
        loadTopics(f.getTopics(), true);
        return f;
    }
    public Topic loadTopic(String path, boolean recurse) throws PathNotFoundException, RepositoryException, ForumException {
        Node n = fmgr.getAnyNode(path);
        if(fmgr.isTopic(n)) {
            Topic t = nodeToTopic(n);
            if(recurse) {
                loadPosts(t.getPosts(), n, true);
            }
            return t;
        } else {
            throw new ForumException("Path is not a topic path: " + path);
        }
    }
    public Topic loadTopic(String path) throws PathNotFoundException, RepositoryException, ForumException {
        return loadTopic(path, false);
    }
    public Post loadPost(String path, boolean recurse) throws PathNotFoundException, RepositoryException, ForumException {
        Node n = fmgr.getAnyNode(path);
        if(fmgr.isPost(n)) {
            Post p = nodeToPost(n);
            if(recurse) {
                loadComments(p.getComments(), n, true);
            }
            return p;
        } else {
            throw new ForumException("Path is not a post path: " + path);
        }
    }
    public Post loadPost(String path) throws PathNotFoundException, RepositoryException, ForumException {
        return loadPost(path, false);
    }
    public Comment loadComment(String path, boolean recurse) throws PathNotFoundException, RepositoryException, ForumException {
        Node n = fmgr.getAnyNode(path);
        if(fmgr.isComment(n)) {
            Comment c = nodeToComment(n);
            if(recurse) {
                loadComments(c.getComments(), n, true);
            }
            return c;
        } else {
            throw new ForumException("Path is not a comment path: " + path);
        }
    }
    public Comment loadComment(String path) throws PathNotFoundException, RepositoryException, ForumException {
        return loadComment(path, false);
    }
    // search
    public List<Post> findPostByAuthor(String author, boolean recurse) throws RepositoryException, ForumException {
        List<Post> res = new ArrayList<>();
        Query q = fmgr.getSession().getWorkspace().getQueryManager().createQuery("SELECT * FROM [nt:unstructured] WHERE author = $author AND title IS NOT NULL AND number IS NULL", Query.JCR_SQL2);
        q.bindValue("author", fmgr.getSession().getValueFactory().createValue(author));
        QueryResult qr = q.execute();
        NodeIterator nit = qr.getNodes();
        while(nit.hasNext()) {
            Node n = nit.nextNode();
            if(fmgr.isPost(n)) {
                Post p = nodeToPost(n);
                res.add(p);
                if(recurse) {
                    loadComments(p.getComments(), n, true);
                }
            }
        }
        return res;
    }
    public List<Post> findPostByAuthor(String author) throws RepositoryException, ForumException {
        return findPostByAuthor(author, false);
    }
    public List<Post> findPostByContent(String content, boolean recurse) throws RepositoryException, ForumException {
        List<Post> res = new ArrayList<>();
        Query q = fmgr.getSession().getWorkspace().getQueryManager().createQuery("SELECT * FROM [nt:unstructured] WHERE CONTAINS(body,$content) AND title IS NOT NULL AND number IS NULL", Query.JCR_SQL2);
        q.bindValue("content", fmgr.getSession().getValueFactory().createValue(content));
        QueryResult qr = q.execute();
        NodeIterator nit = qr.getNodes();
        while(nit.hasNext()) {
            Node n = nit.nextNode();
            if(fmgr.isPost(n)) {
                Post p = nodeToPost(n);
                res.add(p);
                if(recurse) {
                    loadComments(p.getComments(), n, true);
                }
            }
        }
        return res;
    }
    public List<Post> findPostByContent(String content) throws RepositoryException, ForumException {
        return findPostByContent(content, false);
    }
    public List<Comment> findCommentByAuthor(String author, boolean recurse) throws RepositoryException, ForumException {
        List<Comment> res = new ArrayList<>();
        Query q = fmgr.getSession().getWorkspace().getQueryManager().createQuery("SELECT * FROM [nt:unstructured] WHERE author = $author AND title IS NULL AND number IS NOT NULL", Query.JCR_SQL2);
        q.bindValue("author", fmgr.getSession().getValueFactory().createValue(author));
        QueryResult qr = q.execute();
        NodeIterator nit = qr.getNodes();
        while(nit.hasNext()) {
            Node n = nit.nextNode();
            if(fmgr.isComment(n)) {
                Comment c = nodeToComment(n);
                res.add(c);
                if(recurse) {
                    loadComments(c.getComments(), n, true);
                }
            }
        }
        return res;
    }
    public List<Comment> findCommentByAuthor(String author) throws RepositoryException, ForumException {
        return findCommentByAuthor(author, false);
    }
    public List<Comment> findCommentByContent(String content, boolean recurse) throws RepositoryException, ForumException {
        List<Comment> res = new ArrayList<>();
        Query q = fmgr.getSession().getWorkspace().getQueryManager().createQuery("SELECT * FROM [nt:unstructured] WHERE CONTAINS(text,$content) AND title IS NULL AND number IS NOT NULL", Query.JCR_SQL2);
        q.bindValue("content", fmgr.getSession().getValueFactory().createValue(content));
        QueryResult qr = q.execute();
        NodeIterator nit = qr.getNodes();
        while(nit.hasNext()) {
            Node n = nit.nextNode();
            if(fmgr.isComment(n)) {
                Comment c = nodeToComment(n);
                res.add(c);
                if(recurse) {
                    loadComments(c.getComments(), n, true);
                }
            }
        }
        return res;
    }
    public List<Comment> findCommentByContent(String content) throws RepositoryException, ForumException {
        return findCommentByContent(content, false);
    }
}

Test:

Test.java:

package dk.vajhoej.forum.test;

import java.util.Map;
import java.util.ServiceLoader;

import javax.jcr.PathNotFoundException;
import javax.jcr.Repository;
import javax.jcr.RepositoryException;
import javax.jcr.RepositoryFactory;

import dk.vajhoej.forum.ForumException;
import dk.vajhoej.forum.ForumManager;
import dk.vajhoej.forum.ModelMapper;
import dk.vajhoej.forum.model.Comment;
import dk.vajhoej.forum.model.Forum;
import dk.vajhoej.forum.model.Post;
import dk.vajhoej.forum.model.Topic;

public class Test {
    private static Repository getRepository(Map<String, String> parameters) throws RepositoryException {
        for (RepositoryFactory factory : ServiceLoader.load(RepositoryFactory.class)) {
            Repository repo = factory.getRepository(parameters);
            if(repo != null) {
                return repo;
            }
        }
        return null;
    }
    private static void printComment(String indent, Comment comment) {
        System.out.println(indent + "Comment:");
        System.out.println(indent + "Author = " + comment.getAuthor());
        System.out.println(indent + "Time = " + comment.getTime());
        System.out.println(indent + "Number = " + comment.getNumber());
        System.out.println(indent + "Text = " + comment.getText());
        for(Comment v : comment.getVersions()) {
            System.out.println(indent + "Version = (" + v.getTime() + "," + v.getText() + ")");
        }
        for(Comment c : comment.getComments()) {
            printComment(indent + "  ", c);
        }
    }
    private static void printPost(String indent, Post post) {
        System.out.println(indent + "Post:");
        System.out.println(indent + "Author = " + post.getAuthor());
        System.out.println(indent + "Time = " + post.getTime());
        System.out.println(indent + "Title = " + post.getTitle());
        System.out.println(indent + "Body = " + post.getBody());
        for(Post v : post.getVersions()) {
            System.out.println(indent + "Version = (" + v.getTime() + "," + v.getBody() + ")");
        }
        for(Comment c : post.getComments()) {
            printComment(indent + "  ", c);
        }
    }
    private static void printTopic(String indent, Topic topic) {
        System.out.println(indent + "Topic:");
        System.out.println(indent + "Name = " + topic.getName());
        System.out.println(indent + "Description = " + topic.getDescription());
        for(Post p : topic.getPosts()) {
            printPost(indent + "  ", p);
        }
    }
    private static void printForum(Forum forum) {
        for(Topic t : forum.getTopics()) {
            printTopic("", t);
        }
        
    }
    private static void testForumLoad(ForumManager fmgr) throws RepositoryException, ForumException {
        ModelMapper mm = new ModelMapper(fmgr);
        printForum(mm.loadForum());
    }
    private static void testTopicLoad(ForumManager fmgr, String path) throws PathNotFoundException, RepositoryException, ForumException {
        System.out.println("Load: " + path);
        ModelMapper mm = new ModelMapper(fmgr);
        printTopic("", mm.loadTopic(path, true));
    }
    private static void testPostLoad(ForumManager fmgr, String path) throws PathNotFoundException, RepositoryException, ForumException {
        System.out.println("Load: " + path);
        ModelMapper mm = new ModelMapper(fmgr);
        printPost("", mm.loadPost(path, true));
    }
    private static void testCommentLoad(ForumManager fmgr, String path) throws PathNotFoundException, RepositoryException, ForumException {
        System.out.println("Load: " + path);
        ModelMapper mm = new ModelMapper(fmgr);
        printComment("", mm.loadComment(path, true));
    }
    private static void testPostAuthorSearch(ForumManager fmgr, String author) throws RepositoryException, ForumException {
        System.out.println("Post author search for " + author + ":");
        ModelMapper mm = new ModelMapper(fmgr);
        for(Post p : mm.findPostByAuthor(author, true)) {
            printPost("", p);
        }
    }
    private static void testPostContentSearch(ForumManager fmgr, String content) throws RepositoryException, ForumException {
        System.out.println("Post content search for " + content + ":");
        ModelMapper mm = new ModelMapper(fmgr);
        for(Post p : mm.findPostByContent(content, true)) {
            printPost("", p);
        }
    }
    private static void testCommentAuthorSearch(ForumManager fmgr, String author) throws RepositoryException, ForumException {
        System.out.println("Comment author search for " + author + ":");
        ModelMapper mm = new ModelMapper(fmgr);
        for(Comment c : mm.findCommentByAuthor(author, true)) {
            printComment("", c);
        }
    }
    private static void testCommentContentSearch(ForumManager fmgr, String content) throws RepositoryException, ForumException {
        System.out.println("Comment content search for " + content + ":");
        ModelMapper mm = new ModelMapper(fmgr);
        for(Comment c :mm.findCommentByContent(content, true)) {
            printComment("", c);
        }
    }
    public static void test(Map<String, String> parameters, String usr, String pwd) throws RepositoryException, ForumException, InterruptedException {
        Repository repo = getRepository(parameters);
        ForumManager fmgr = new ForumManager(repo, usr, pwd);
        // add 2 topics
        String smart = fmgr.addTopic("Smart questions", "This is for smart questions");
        String dumb = fmgr.addTopic("Dumb questions", "This is for dumb questions");
        // add 3 posts
        String plus1 = fmgr.addPost(dumb, "What is 1+1?", "I have a math question: what is 1+1?","arne");
        String plus2 = fmgr.addPost(dumb, "What is 2+2?", "I have another math question: what is 2+2?", "arne");
        String life = fmgr.addPost(smart, "What is the purpose of life?", "I am in the philosophical mood. What is the purpose of life?", "arne");
        // add 4 comments
        String plus1a = fmgr.addCommentAutoNumber(plus1, "2", "arne");
        String plus2a = fmgr.addCommentAutoNumber(plus2, "4", "arne");
        String lifea1 = fmgr.addCommentAutoNumber(life, "Good question.", "arne");
        String lifea2 = fmgr.addCommentAutoNumber(life, "Please tell me if you find out.", "arne");
        // add 2 comments to comments to comments
        fmgr.addCommentAutoNumber(plus1a, "Hmmm.", "Arne");
        fmgr.addCommentAutoNumber(plus2a, "Hmmm.", "Arne");
        // update 1 comment twice
        Thread.sleep(1000);
        fmgr.updateComment(plus1a, "Forget it.");
        fmgr.updateComment(plus2a, "Forget it.");
        Thread.sleep(1000);
        fmgr.updateComment(plus1a, "Sorry.");
        fmgr.updateComment(plus2a, "Sorry.");
        // test load
        testForumLoad(fmgr);
        testTopicLoad(fmgr, smart);
        testPostLoad(fmgr, life);
        testCommentLoad(fmgr, lifea1);
        testCommentLoad(fmgr, lifea2);
        // test search
        testPostAuthorSearch(fmgr, "xxx");
        testPostAuthorSearch(fmgr, "arne");
        testPostContentSearch(fmgr, "math");
        testPostContentSearch(fmgr, "foobar");
        testCommentAuthorSearch(fmgr, "xxx");
        testCommentAuthorSearch(fmgr, "arne");
        testCommentContentSearch(fmgr, "good");
        testCommentContentSearch(fmgr, "foobar");
    }
}

Example - setup:

The examples use MySQL for storage and Lucene as search engine.

The exampels also have a custom login module.

Apache Jackrabbit:

Main.java:

package dk.vajhoej.forum.test;

import java.util.HashMap;
import java.util.Map;

public class Main extends Test {
    public static void main(String[] args) throws Exception {
        Map<String, String> local = new HashMap<>();
        local.put("org.apache.jackrabbit.repository.home", "/work/jcr/jackrabbit/repo");
        local.put("org.apache.jackrabbit.repository.conf", "/work/jcr/jackrabbit/repoconfig.xml");
        test(local, "jcradmin", "secret");
    }
}

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>

Classpath:

JRBLoginModule.java:

package custom;

import java.io.IOException;
import java.security.Principal;
import java.util.Map;

import javax.jcr.Credentials;
import javax.jcr.SimpleCredentials;
import javax.security.auth.Subject;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.login.LoginException;
import javax.security.auth.spi.LoginModule;

import org.apache.jackrabbit.core.security.authentication.CredentialsCallback;
import org.apache.jackrabbit.core.security.principal.AdminPrincipal;

public class JRBLoginModule implements LoginModule {
    private Subject subject;
    private CallbackHandler callbackHandler;
    private Principal principal;
    @Override
    public void initialize(Subject subject, CallbackHandler callbackHandler, Map<String, ?> sharedState, Map<String, ?> options) {
        this.subject = subject;
        this.callbackHandler = callbackHandler;
    }
    @Override
    public boolean login() throws LoginException {
        try {
            CredentialsCallback ccb = new CredentialsCallback();
            callbackHandler.handle(new Callback[] { ccb });
            Credentials cred = ccb.getCredentials();
            if(cred instanceof SimpleCredentials) {
                SimpleCredentials scred = (SimpleCredentials)cred;
                String usr = scred.getUserID();
                String pwd = new String(scred.getPassword());
                if(usr.equals("jcradmin") && pwd.equals("secret")) {
                    principal = new AdminPrincipal(usr);
                    return true;
                } else {
                    throw new LoginException();
                }
            } else {
                return false;
            }
        } catch (IOException e) {
            return false;
        } catch (UnsupportedCallbackException e) {
            return false;
        }
    }
    @Override
    public boolean commit() throws LoginException {
        subject.getPrincipals().add(principal);
        return true;
    }
    @Override
    public boolean abort() throws LoginException {
        return true;
    }
    @Override
    public boolean logout() throws LoginException {
        return true;
    }
}

JBoss ModeShape:

Main.java:

package dk.vajhoej.forum.test;

import java.util.HashMap;
import java.util.Map;

public class Main extends Test {
    public static void main(String[] args) throws Exception {
        Map<String, String> local = new HashMap<>();
        local.put("org.modeshape.jcr.URL", "file:///C:/work/jcr/modeshape/repoconfig.json");
        test(local, "jcradmin", "secret");
    }
}

repoconfig.json:

{
    "name" : "Demo",
    "storage" : {
       "persistence" : {
            "type" : "db",
            "driver" : "com.mysql.jdbc.Driver",
            "connectionUrl": "jdbc:mysql://localhost/ms_repo",
            "username" : "root",
            "password" : ""
        }
    },
    "security" : {
        "providers" : [
            {
                "classname" : "custom.MSLoginModule"
            }
        ]
    },
    "indexProviders" : {
        "lucene" : {
            "classname" : "lucene",
            "directory" : "/work/jcr/modeshape/target/indexes"
        }
    }
}

Classpath:

MSLoginModule.java:

package custom;

import java.util.Map;

import javax.jcr.Credentials;
import javax.jcr.SimpleCredentials;

import org.modeshape.jcr.ExecutionContext;
import org.modeshape.jcr.security.AuthenticationProvider;
import org.modeshape.jcr.security.SecurityContext;

public class MSLoginModule implements  AuthenticationProvider {
    public static class MSSecurityContext implements SecurityContext {
        private String usr;
        public MSSecurityContext(String usr) {
            this.usr = usr;
        }
        @Override
        public String getUserName() {
            return usr;
        }
        @Override
        public boolean hasRole(String roleName) {
            return true;
        }
        @Override
        public boolean isAnonymous() {
            return false;
        }
        @Override
        public void logout() {
        }
    }
    @Override
    public ExecutionContext authenticate(Credentials credentials, String repositoryName, String workspaceName, ExecutionContext repositoryContext, Map<String, Object> sessionAttributes) {
        if(credentials instanceof SimpleCredentials) {
            SimpleCredentials simpcred = (SimpleCredentials)credentials;
            String usr = simpcred.getUserID();
            String pwd = new String(simpcred.getPassword());
            if(usr.equals("jcradmin") && pwd.equals("secret")) {
                return repositoryContext.with(new MSSecurityContext(usr));
            } else {
                return null;
            }
        } else {
            return null;
        }
    }
}

Other technologies:

JCR is pretty widely adopted among Java CMS'es.

Similar concepts has not had the same success for other platforms.

Only major equivalent is PHP that has created the PHPCR standard with some implementations:

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 November 17th 2019 Initial version

Other articles:

See list of all articles here

Comments:

Please send comments to Arne Vajhøj