Dashboard > db4o OpenDoc > ... > db4o7.0指南 > 04. Structured objects
db4o OpenDoc Log In   View a printable version of the current page.
04. Structured objects
Added by jeff jie, last edited by cleverpig on Dec 28, 2007  (view change)
Labels: 
(None)

4. Structured objects 结构化对象

It's time to extend our business domain with another class and see how db4o handles object interrelations. Let's give our pilot a vehicle.

现在到了用其它类来扩展我们的商业领域、观察db4o怎么处理对象间的相互关系的时候了!现在就让我们给赛车手一辆赛车吧!

package com.db4o.f1.chapter2;
public class Car {
    private String model;
    private Pilot pilot;

    public Car(String model) {
        this.model=model;
        this.pilot=null;
    }

    public Pilot getPilot() {
        return pilot;
    }

    public void setPilot(Pilot pilot) {
        this.pilot = pilot;
    }

    public String getModel() {
        return model;
    }

    public String toString() {
        return model+"["+pilot+"]";
    }
}

 

 

4.1. Storing structured objects 保存结构化对象

To store a car with its pilot, we just call set() on our top level object, the car. The pilot will be stored implicitly.

要保存一个有赛车手驾驶的赛车对象,我们只要在顶层对象(赛车)上调用set()方法。赛车手将被隐式地保存。

// storeFirstCar
Car car1=new Car("Ferrari");
Pilot pilot1=new Pilot("Michael Schumacher",100);
car1.setPilot(pilot1);
db.set(car1);

 

Of course, we need some competition here. This time we explicitly store the pilot before entering the car - this makes no difference.

当然,我们这里需要一些对比。这一次我们在赛车手进入赛车之前,先把他显式地保存起来 - 当然,这没有什么不同。

// storeSecondCar
Pilot pilot2=new Pilot("Rubens Barrichello",99);
db.set(pilot2);
Car car2=new Car("BMW");
car2.setPilot(pilot2);
db.set(car2);

 

4.2. Retrieving structured objects 检索结构化对象

4.2.1. QBE

To retrieve all cars, we simply provide a 'blank' prototype.

如果想检索所有的赛车,我们只需要提供一个'空白'的原型。

// retrieveAllCarsQBE
Car proto=new Car(null);
ObjectSet result=db.get(proto);
listResult(result);

OUTPUT:
2
BMW[Rubens Barrichello/99]
Ferrari[Michael Schumacher/100]

We can also query for all pilots, of course.

当然,我们也可以使用同样的方法查询所有的赛车手。

// retrieveAllPilotsQBE
Pilot proto=new Pilot(null,0);
ObjectSet result=db.get(proto);
listResult(result);

OUTPUT:
2
Rubens Barrichello/99
Michael Schumacher/100

Now let's initialize our prototype to specify all cars driven by Rubens Barrichello.

现在让我们初始化原型来指定由Rubens Barrichello(哈哈,巴里切罗是也)驾驶的所有赛车。

// retrieveCarByPilotQBE
Pilot pilotproto=new Pilot("Rubens Barrichello",0);
Car carproto=new Car(null);
carproto.setPilot(pilotproto);
ObjectSet result=db.get(carproto);
listResult(result);

OUTPUT:
1
BMW[Rubens Barrichello/99]

What about retrieving a pilot by car? We simply don't need that - if we already know the car, we can simply access the pilot field directly.

以赛车为条件来检索赛车手如何?我们不需要那么做 - 如果我们已经知道那个赛车,我们可以直接访问它的赛车手字段。

4.2.2. Native Queries 原生查询

Using native queries with constraints on deep structured objects is straightforward, you can do it just like you would in plain other code.

在深层结构的对象上,使用带条件的原生查询是非常直接的,你可以像你对待其它简单的代码一样轻松地完成它。

Let's constrain our query to only those cars driven by a Pilot with a specific name:

让我们来为查询增加一些条件,我们要用一个指定的名字的车手为条件找到它驾驶的赛车。

// retrieveCarsByPilotNameNative
final String pilotName = "Rubens Barrichello";
ObjectSet results = db.query(new Predicate() {
    public boolean match(Car car){
        return car.getPilot().getName().equals(pilotName);
    }
});
listResult(results);

OUTPUT:
1
BMW[Rubens Barrichello/99]

4.2.3. SODA Query API SODA查询API

In order to use SODA for querying for a car given its pilot's name we have to descend two levels into our query.

为了使用SODA来通过赛车手的名字查询赛车,我们必须在查询里访问更深的两层(>pilot>name)。

// retrieveCarByPilotNameQuery
Query query=db.query();
query.constrain(Car.class);
query.descend("pilot").descend("name")
        .constrain("Rubens Barrichello");
ObjectSet result=query.execute();
listResult(result);

OUTPUT:
1
BMW[Rubens Barrichello/99]

We can also constrain the pilot field with a prototype to achieve the same result.

我们也可以使用一个原型来约束赛车手字段,从而达到同样的结果。

// retrieveCarByPilotProtoQuery
Query query=db.query();
query.constrain(Car.class);
Pilot proto=new Pilot("Rubens Barrichello",0);
query.descend("pilot").constrain(proto);
ObjectSet result=query.execute();
listResult(result);

OUTPUT:
1
BMW[Rubens Barrichello/99]

We have seen that descending into a query provides us with another query. Starting out from a query root we can descend in multiple directions. In practice this is the same as ascending from one child to a parent and descending to another child. We can conclude that queries turn one-directional references in our objects into true relations. Here is an example that queries for "a Pilot that is being referenced by a Car, where the Car model is 'Ferrari'":

我们已经看到,在一个查询对象里进行深层访问(从而进一步缩小查询范围)的方式为我们提供了另一种查询方法。从查询根开始,我们能多方向地进行深层访问。在实践中,这和从孩子回溯到父亲并且下访到另一个孩子的过程是一致的。我们可以推断:查询将对象内单一方向的引用变成真实关系。这里提供一个例子:查询一个被赛车引用的赛车手,而赛车的型号是'Ferrari'":

// retrievePilotByCarModelQuery
Query carquery=db.query();
carquery.constrain(Car.class);
carquery.descend("model").constrain("Ferrari");
Query pilotquery=carquery.descend("pilot");
ObjectSet result=pilotquery.execute();
listResult(result);

 

OUTPUT:
1
Michael Schumacher/100

 

 

 

4.3. Updating structured objects 修改结构化对象

To update structured objects in db4o, we simply call set() on them again.

要在db4o里修改结构化对象,我们只需要对它们再次调用一下set()方法。

// updateCar
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
Car found=(Car)result.next();
found.setPilot(new Pilot("Somebody else",0));
db.set(found);
result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
listResult(result);

OUTPUT:
1
Ferrari[Somebody else/0]

Let's modify the pilot, too.

让我们把赛车手也修改了吧。

// updatePilotSingleSession
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
Car found=(Car)result.next();
found.getPilot().addPoints(1);
db.set(found);
result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
listResult(result);

OUTPUT:
1
Ferrari[Somebody else/1]

Nice and easy, isn't it? But wait, there's something evil lurking right behind the corner. Let's see what happens if we split this task in two separate db4o sessions: In the first we modify our pilot and update his car:

真是即好又简单,难道不是吗?但是先等一下,这里有些不幸潜藏在黑暗的角落里。让我们看看,如果把这个任务分成两个独立的db4o会话将发生什么:在第一个会话中我们修改了我们的赛车手和他的赛车:

// updatePilotSeparateSessionsPart1
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
Car found=(Car)result.next();
found.getPilot().addPoints(1);
db.set(found);

And in the second, we'll double-check our modification:

在第二个会话里,我们确认一下我们的修改:

// updatePilotSeparateSessionsPart2
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
listResult(result);

OUTPUT:
1
Ferrari[Somebody else/0]

Looks like we're in trouble: Why did the Pilot's points not change? What's happening here and what can we do to fix it?

看来我们遇到麻烦了:为什么赛车手的点没有改变呢?究竟发生了什么?我们要怎样做才能修复这个它呢?

4.3.1. Update depth 更新深度

Imagine a complex object with many members that have many members themselves. When updating this object, db4o would have to update all its children, grandchildren, etc. This poses a severe performance penalty and will not be necessary in most cases - sometimes, however, it will.

想象一个复杂的对象,它有许多成员,而那些成员自己又有许多成员。要修改这样的复杂对象,db4o必须修改它所有的子对象,乃至孙子对象等等。这会引起严重的性能损失,而且在多数情况下是不必要的 - 当然,有些时候是必要的。

So, in our previous update example, we were modifying the Pilot child of a Car object. When we saved the change, we told db4o to save our Car object and assumed that the modified Pilot would be updated. But we were modifying and saving in the same manner as we were in the first update sample, so why did it work before? The first time we made the modification, db4o never actually had to retreive the modified Pilot it returned the same one that was still in memory that we modified, but it never actually updated the database. The fact that we saw the modified value was, in fact, a bug. Restarting the application would show that the value was unchanged.

那么,在上一个例子中,我们修改了一个赛车对象的子对象赛车手。当我们保存修改的时候,我们告诉db4o那个被保存的赛车对象,并想当然地认为被修改的赛车手也会被修改。但是,我们在使用和上一个例子一样的方式来修改和保存时,却发现它不能正常工作了?我们第一次做修改的时候,db4o永远不会真的去读取被修改的赛车手(这也发生于在内存中被修改的赛车手),但是它从不真正地修改数据库。实际上我们看到的被修改的数据实际上是个bug。在重启应用程序后,显示值将并不会发生改变。

To be able to handle this dilemma as flexible as possible, db4o introduces the concept of update depth to control how deep an object's member tree will be traversed on update. The default update depth for all objects is 1, meaning that only primitive and String members will be updated, but changes in object members will not be
reflected.

为了能够尽可能灵活地处理这个两难问题,db4o引入了更新深度的概念:它在一个对象被修改时控制对象的成员树要被遍历的深度。所有对象的默认的修改深度是1,这意味着只有原始类型和String的成员才会修改,对象的成员的修改不会受影响。

db4o provides means to control update depth with very fine granularity. For our current problem we'll advise db4o to update the full graph for Car objects by setting
cascadeOnUpdate() for this class accordingly.

db4o提供了多种方法来控制修改的深度达到很细的粒度。对于我们当前的问题,我们会通过为这个类设置cascadeOnUpdate()来建议db4o修改赛车对象的完全图表。

// updatePilotSeparateSessionsImprovedPart1
Db4o.configure().objectClass("com.db4o.f1.chapter2.Car")
        .cascadeOnUpdate(true);


// updatePilotSeparateSessionsImprovedPart2
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
Car found=(Car)result.next();
found.getPilot().addPoints(1);
db.set(found);

 

// updatePilotSeparateSessionsImprovedPart3
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
listResult(result);

OUTPUT:
1
Ferrari[Somebody else/1]

This looks much better.

这样看起来好多了。

Note that container configuration must be set before the container is opened.

记住,容器的配置必须在容器被打开之前进行设定。

We'll cover update depth as well as other issues with complex object graphs and the respective db4o configuration options in more detail in a later chapter.

我们将在下一章更详细地讨论更新深度,以及关于复杂的对象树以及各种db4o配置选项的问题。

4.4. Deleting structured objects 删除结构化对象

As we have already seen, we call delete() on objects to get rid of them.

我们已经知道,我们可以调用delete()方法来删除它。

// deleteFlat
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("Ferrari");
    }
});
Car found=(Car)result.next();
db.delete(found);
result=db.get(new Car(null));
listResult(result);

OUTPUT:
1
BMW[Rubens Barrichello/99]

Fine, the car is gone. What about the pilots?

好的,赛车没了,那么赛车手呢?

// retrieveAllPilotsQBE
Pilot proto=new Pilot(null,0);
ObjectSet result=db.get(proto);
listResult(result);

OUTPUT:
3
Somebody else/1
Rubens Barrichello/99
Michael Schumacher/100

Ok, this is no real surprise - we don't expect a pilot to vanish when his car is disposed of in real life, too. But what if we want an object's children to be thrown away on deletion, too?

太棒了,其实也没有什么好奇怪的 - 在现实生活中,我们也不希望一个赛车手的赛车报废后,赛车手也跟着消失。但是如果我们希望一个被删除的对象的子对象也被扔掉或者删掉,该怎么办呢?

4.4.1. Recursive deletion 递归删除

You may already suspect that the problem of recursive deletion (and perhaps its solution, too) is quite similar to our little update problem, and you're right. Let's
configure db4o to delete a car's pilot, too, when the car is deleted.

也许,你已经想到了和我们上面遇到小修改问题很相似的递归删除问题(或者它的解决方案)。让我们来配置一下db4o,使得当赛车被删除的时候把赛车的赛车手也删除掉。

// deleteDeepPart1
Db4o.configure().objectClass("com.db4o.f1.chapter2.Car")
        .cascadeOnDelete(true);

// deleteDeepPart2
ObjectSet result=db.query(new Predicate() {
    public boolean match(Car car){
        return car.getModel().equals("BMW");
    }
});
Car found=(Car)result.next();
db.delete(found);
result=db.query(new Predicate() {
    public boolean match(Car car){
        return true;
    }
});
listResult(result);

OUTPUT:
0

Again: Note that all configuration must take place before the ObjectContainer is opened.

再次提醒:记住,所有的配置都必须发生在ObjectContainer打开之前。

Let's have a look at our pilots again.

让我们再看一下我们的赛车手:

// retrieveAllPilots
Pilot proto=new Pilot(null,0);
ObjectSet result=db.get(proto);
listResult(result);

OUTPUT:
2
Somebody else/1
Michael Schumacher/100

 

4.4.2. Recursive deletion revisited 递归删除重访问

But wait - what happens if the children of a removed object are still referenced by other objects?

但是,请等一下,如果一个被删除的对象的子对象仍然被其它对象所引用,那会发生什么事呢?

// deleteDeepRevisited
ObjectSet result=db.query(new Predicate() {
    public boolean match(Pilot pilot){
        return pilot.getName().equals("Michael Schumacher");
    }
});
if (!result.hasNext()) {
    System.out.println("Pilot not found!");
    return;
}
Pilot pilot=(Pilot)result.next();
Car car1=new Car("Ferrari");
Car car2=new Car("BMW");
car1.setPilot(pilot);
car2.setPilot(pilot);
db.set(car1);
db.set(car2);
db.delete(car2);
result=db.query(new Predicate() {
    public boolean match(Car car){
        return true;
    }
});
listResult(result);

OUTPUT:
1
Ferrari[Michael Schumacher/100]

// retrieveAllPilots
Pilot proto=new Pilot(null,0);
ObjectSet result=db.get(proto);
listResult(result);

OUTPUT:
1
Somebody else/1

Houston, we have a problem - and there's no simple solution at hand. Currently db4o does not check whether objects to be deleted are referenced anywhere else, so

please be very careful when using this feature.

我们遇到一个麻烦 - 而且手边没有简单的解决方案。目前db4o不会检查要删除的对象是否被其它的地方引用,所以当使用这个特性的时候请特别当心。

Let's clear our database for the next chapter.

为了下一章的练习,让我们清空我们的数据库。

// deleteAll
ObjectSet result=db.get(new Object());
while(result.hasNext()) {
    db.delete(result.next());
}

4.5. Conclusion 结论

So much for object associations: We can hook into a root object and climb down its reference graph to specify queries. But what about multi-valued objects like arrays and collections? We will cover this in the next chapter .

关于对象关系这里就讲这么多了:我们可以抓住一个根对象,然后沿着它的引用图表来做具体的查询。但是像数组和集合这些多值的对象怎么办呢?我们下一章讨论这些问题。

4.6. Full source 完整的源代码

package com.db4o.f1.chapter2;
import java.io.File;
import com.db4o.Db4o;
import com.db4o.ObjectContainer;
import com.db4o.ObjectSet;
import com.db4o.f1.Util;
import com.db4o.query.Predicate;
import com.db4o.query.Query;

public class StructuredExample extends Util {
    public static void main(String[] args) {
        new File(Util.DB4OFILENAME).delete();
        ObjectContainer db=Db4o.openFile(Util.DB4OFILENAME);
        try {
            storeFirstCar(db);
            storeSecondCar(db);
            retrieveAllCarsQBE(db);
            retrieveAllPilotsQBE(db);
            retrieveCarByPilotQBE(db);
            retrieveCarByPilotNameQuery(db);
            retrieveCarByPilotProtoQuery(db);
            retrievePilotByCarModelQuery(db);
            updateCar(db);
            updatePilotSingleSession(db);
            updatePilotSeparateSessionsPart1(db);
            db.close();
            db=Db4o.openFile(Util.DB4OFILENAME);
            updatePilotSeparateSessionsPart2(db);
            db.close();
            updatePilotSeparateSessionsImprovedPart1();
            db=Db4o.openFile(Util.DB4OFILENAME);
            updatePilotSeparateSessionsImprovedPart2(db);
            db.close();
            db=Db4o.openFile(Util.DB4OFILENAME);
            updatePilotSeparateSessionsImprovedPart3(db);
            deleteFlat(db);
            db.close();
            deleteDeepPart1();
            db=Db4o.openFile(Util.DB4OFILENAME);
            deleteDeepPart2(db);
            deleteDeepRevisited(db);
        }
        finally {
            db.close();
        }
    }

    public static void storeFirstCar(ObjectContainer db) {
        Car car1=new Car("Ferrari");
        Pilot pilot1=new Pilot("Michael Schumacher",100);
        car1.setPilot(pilot1);
        db.set(car1);
    }
    public static void storeSecondCar(ObjectContainer db) {
        Pilot pilot2=new Pilot("Rubens Barrichello",99);
        db.set(pilot2);
        Car car2=new Car("BMW");
        car2.setPilot(pilot2);
        db.set(car2);
    }
    public static void retrieveAllCarsQBE(ObjectContainer db) {
        Car proto=new Car(null);
        ObjectSet result=db.get(proto);
        listResult(result);
    }
    public static void retrieveAllPilotsQBE(ObjectContainer db) {
        Pilot proto=new Pilot(null,0);
        ObjectSet result=db.get(proto);
        listResult(result);
    }
    public static void retrieveAllPilots(ObjectContainer db) {
        ObjectSet result=db.get(Pilot.class);
        listResult(result);
    }
    public static void retrieveCarByPilotQBE(
            ObjectContainer db) {
        Pilot pilotproto=new Pilot("Rubens Barrichello",0);
        Car carproto=new Car(null);
        carproto.setPilot(pilotproto);
        ObjectSet result=db.get(carproto);
        listResult(result);
    }

    public static void retrieveCarByPilotNameQuery(
            ObjectContainer db) {
        Query query=db.query();
        query.constrain(Car.class);
        query.descend("pilot").descend("name")
                .constrain("Rubens Barrichello");
        ObjectSet result=query.execute();
        listResult(result);
    }
    public static void retrieveCarByPilotProtoQuery(
                ObjectContainer db) {
        Query query=db.query();
        query.constrain(Car.class);
        Pilot proto=new Pilot("Rubens Barrichello",0);
        query.descend("pilot").constrain(proto);
        ObjectSet result=query.execute();
        listResult(result);
    }

    public static void retrievePilotByCarModelQuery(ObjectContainer db) {
        Query carquery=db.query();
        carquery.constrain(Car.class);
        carquery.descend("model").constrain("Ferrari");
        Query pilotquery=carquery.descend("pilot");
        ObjectSet result=pilotquery.execute();
        listResult(result);
    }

    public static void retrieveAllPilotsNative(ObjectContainer db) {
        ObjectSet results = db.query(new Predicate() {
            public boolean match(Pilot pilot){
                return true;
            }
        });
        listResult(results);
    }

    public static void retrieveAllCars(ObjectContainer db) {
        ObjectSet results = db.get(Car.class);
        listResult(results);
    }

    public static void retrieveCarsByPilotNameNative(ObjectContainer db) {
        final String pilotName = "Rubens Barrichello";
        ObjectSet results = db.query(new Predicate() {
            public boolean match(Car car){
                return car.getPilot().getName().equals(pilotName);
            }
        });
        listResult(results);
    }

    public static void updateCar(ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        Car found=(Car)result.next();
        found.setPilot(new Pilot("Somebody else",0));
        db.set(found);
        result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        listResult(result);
    }
    public static void updatePilotSingleSession(
                ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        Car found=(Car)result.next();
        found.getPilot().addPoints(1);
        db.set(found);
        result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        listResult(result);
    }
    public static void updatePilotSeparateSessionsPart1(
            ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        Car found=(Car)result.next();
        found.getPilot().addPoints(1);
        db.set(found);
    }
    public static void updatePilotSeparateSessionsPart2(
                ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        listResult(result);
    }
    public static void updatePilotSeparateSessionsImprovedPart1() {
        Db4o.configure().objectClass("com.db4o.f1.chapter2.Car")
                .cascadeOnUpdate(true);
    }
    public static void updatePilotSeparateSessionsImprovedPart2(
                ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        Car found=(Car)result.next();
        found.getPilot().addPoints(1);
        db.set(found);
    }
    public static void updatePilotSeparateSessionsImprovedPart3(
                ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        listResult(result);
    }
    public static void deleteFlat(ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("Ferrari");
            }
        });
        Car found=(Car)result.next();
        db.delete(found);
        result=db.get(new Car(null));
        listResult(result);
    }

    public static void deleteDeepPart1() {
        Db4o.configure().objectClass("com.db4o.f1.chapter2.Car")
                .cascadeOnDelete(true);
    }
    public static void deleteDeepPart2(ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Car car){
                return car.getModel().equals("BMW");
            }
        });
        Car found=(Car)result.next();
        db.delete(found);
        result=db.query(new Predicate() {
            public boolean match(Car car){
                return true;
            }
        });
        listResult(result);
    }
    public static void deleteDeepRevisited(ObjectContainer db) {
        ObjectSet result=db.query(new Predicate() {
            public boolean match(Pilot pilot){
                return pilot.getName().equals("Michael Schumacher");
            }
        });
        if (!result.hasNext()) {
            System.out.println("Pilot not found!");
            return;
        }
        Pilot pilot=(Pilot)result.next();
        Car car1=new Car("Ferrari");
        Car car2=new Car("BMW");
        car1.setPilot(pilot);
        car2.setPilot(pilot);
        db.set(car1);
        db.set(car2);
        db.delete(car2);
        result=db.query(new Predicate() {
            public boolean match(Car car){
                return true;
            }
        });
        listResult(result);
    }

}

 

 

 

 

Powered by Atlassian Confluence, the Enterprise Wiki. (Version: 2.1.3 Build:#408 Jan 23, 2006) - Bug/feature request - Contact Administrators