r6968 - in dumbhippo/trunk: openfire/src/plugins/hippo/src/java/com/dumbhippo/jive server/src/com/dumbhippo/dm server/src/com/dumbhippo/dm/annotations server/src/com/dumbhippo/dm/fetch server/src/com/dumbhippo/dm/parser server/src/com/dumbhippo/dm/schema server/src/com/dumbhippo/dm/store server/tests server/tests/com/dumbhippo/dm server/tests/com/dumbhippo/dm/dm server/tests/com/dumbhippo/dm/persistence



Author: otaylor
Date: 2007-12-04 23:14:19 -0600 (Tue, 04 Dec 2007)
New Revision: 6968

Added:
   dumbhippo/trunk/server/src/com/dumbhippo/dm/CachedFeed.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeed.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeedItem.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/FeedWrapper.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/FeedPropertyHolder.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/store/FeedLog.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/AbstractFetchTests.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/FeedTests.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryDMO.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryKey.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/persistence/TestBlogEntry.java
   dumbhippo/trunk/server/tests/feed-fetch-tests.xml
Modified:
   dumbhippo/trunk/openfire/src/plugins/hippo/src/java/com/dumbhippo/jive/XmppFetchVisitor.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotification.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotificationSet.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadOnlySession.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadWriteSession.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMProperty.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/PropertyType.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/Fetch.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchAttributeType.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchVisitor.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetch.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetchNode.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchLexer.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.g
   dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParserTokenTypes.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMPropertyHolder.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/ResourcePropertyHolder.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/store/DMStore.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/store/Registration.java
   dumbhippo/trunk/server/src/com/dumbhippo/dm/store/StoreNode.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/AllTests.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchParserTests.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultHandler.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultProperty.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultVisitor.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchTests.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/TestSupport.java
   dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java
Log:
FetchParserTests: Test 'property(max=N)'

DMFeed: Class representing a feed
DMFeedItem: Class representing a single feed item.

FeedWrapper: DMFeed wrapper that takes a raw feed and adds filtering and caching
CachedFeed: Class used for storing feed items in the data model cache

DMPropertyHolder FeedPropertyHolder: Add support for feed-valued properties

DMClassHolder: Add getFeedPropertyIndex(), getFeedPropertyCount(); used to 
 store information about feed-properties of the class in arrays.

DMSesssion ReadOnlySession ReadWriteSession DMClassHolder: Add createFeedWrapper(),
  use to implement wrapper getters for feed properties.

DMProperty DMPropertyHolder: add defaultMatchFetch attribute

Fetch PropertyFetch: Take property children and attributes into account when
  merging two fetches.

FetchAtttributeTypes: Add 'max' attribute
FetchParser.g: Fix parsing of integer attributes. Add 'max' attribute
PropertyFetchNode PropertyFetch: add 'max' attribute

Fetch: Track maximum values and timestamps when fetch-visiting feeds.

FeedLog: Class with log of changes to a feed so we can tell what items we
  need to send to which client

DMStore StoreNode Registration: Keep track of a per-feed-property log and
  and a per-feed-property-per-client tx-timestamp-of-the-last-fetch.

ReadWriteSession: Add feedChanged()

ClientNotificationSet ClientNotification ChangeNotificationSet ChangeNotification:
  track timestamps for feed changes

FetchVisitor FetchResultVisitor XmppFetchVisitor: add feedProperty() for
  adding feeds items to the fetch result.

FetchResultHandler FetchResultProperty: Support feeds.

AbstractFetchTests FetchTests: Move support infrastructure for tests of
  fetching into an abstract base class.

TestBlogEntry TestBlogEntryDMO TestBlogEntryKey TestUserDMO TestSupport:
  Add TestUser.blogEntries into our test data model to have a feed-valued
  properties.

FeedTests feed-fetch-tests.xml AllTests: Tests for feed functionality

Modified: dumbhippo/trunk/openfire/src/plugins/hippo/src/java/com/dumbhippo/jive/XmppFetchVisitor.java
===================================================================
--- dumbhippo/trunk/openfire/src/plugins/hippo/src/java/com/dumbhippo/jive/XmppFetchVisitor.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/openfire/src/plugins/hippo/src/java/com/dumbhippo/jive/XmppFetchVisitor.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -29,6 +29,7 @@
 	private static final QName RESOURCE_ID_QNAME = QName.get("resourceId", SYSTEM_NAMESPACE);
 	private static final QName FETCH_QNAME = QName.get("fetch", SYSTEM_NAMESPACE);
 	private static final QName INDIRECT_QNAME = QName.get("indirect", SYSTEM_NAMESPACE);
+	private static final QName TS_QNAME = QName.get("ts", SYSTEM_NAMESPACE);
 	private static final QName TYPE_QNAME = QName.get("type", SYSTEM_NAMESPACE);
 	private static final QName DEFAULT_CHILDREN_QNAME = QName.get("defaultChildren", SYSTEM_NAMESPACE);
 	private static final QName UPDATE_QNAME = QName.get("update", SYSTEM_NAMESPACE);
@@ -78,10 +79,10 @@
 		seenProperties.clear();
 	}
 	
-	private Element addPropertyElement(DMPropertyHolder propertyHolder) {
+	private Element addPropertyElement(DMPropertyHolder propertyHolder, boolean incremental) {
 		Element element = currentResourceElement.addElement(createQName(propertyHolder.getName(), propertyHolder.getNameSpace()));
 		
-		if (seenProperties.contains(propertyHolder)) {
+		if (incremental || seenProperties.contains(propertyHolder)) {
 			element.addAttribute(UPDATE_QNAME, "add");
 		} else {
 			element.addAttribute(TYPE_QNAME, propertyHolder.getTypeString());
@@ -98,18 +99,27 @@
 	}
 
 	public void plainProperty(PlainPropertyHolder propertyHolder, Object value) {
-		Element element = addPropertyElement(propertyHolder);
+		Element element = addPropertyElement(propertyHolder, false);
 			
 		element.addText(value.toString());
 	}
 
 	public <KP, TP extends DMObject<KP>> void resourceProperty(ResourcePropertyHolder<?, ?, KP, TP> propertyHolder, KP key) {
-		Element element = addPropertyElement(propertyHolder);
+		Element element = addPropertyElement(propertyHolder, false);
 
 		DMClassHolder<KP,TP> classHolder = propertyHolder.getResourceClassHolder();
 		element.addAttribute(RESOURCE_ID_QNAME, classHolder.makeRelativeId(key));
 	}
 
+
+	public <KP, TP extends DMObject<KP>> void feedProperty(ResourcePropertyHolder<?, ?, KP, TP> propertyHolder, KP key, long timestamp, boolean incremental) {
+		Element element = addPropertyElement(propertyHolder, incremental);
+
+		DMClassHolder<KP,TP> classHolder = propertyHolder.getResourceClassHolder();
+		element.addAttribute(RESOURCE_ID_QNAME, classHolder.makeRelativeId(key));
+		element.addAttribute(TS_QNAME, Long.toString(timestamp));
+	}
+	
 	public void emptyProperty(DMPropertyHolder propertyHolder) {
 		Element element = currentResourceElement.addElement(createQName(propertyHolder.getName(), propertyHolder.getNameSpace()));
 		

Added: dumbhippo/trunk/server/src/com/dumbhippo/dm/CachedFeed.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/CachedFeed.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/CachedFeed.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,127 @@
+package com.dumbhippo.dm;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ *  This class acts as a store for information about a single feed inside the
+ * data model cache.
+ * 
+ * The way that concurrency works is that as long as two different threads
+ * have the same view of what items are in the feed, and as long as items are
+ * always added from the most recent item back without gaps, then both threads
+ * can fill items into the cache at the same time and it will work fine ... duplicates
+ * will be eliminated and at any point, we'll see the cache having the first
+ * max(M,N) items filled ... where M is the number filled by thread 1, and N
+ * is the number filled by thread 2.
+ *  
+ * (The code here does support adding items out of order, and then sorts the items
+ * into order, but the concurrency will no longer work right, since a reader will
+ * think that the N items in the cache at any point of time are the *first*
+ * N items.)
+ */
+public class CachedFeed<K> {
+	private DMFeedItem items[] = new DMFeedItem[10];
+	private Map<K, Integer> itemMap = new HashMap<K, Integer>(); 
+	int itemCount = 0;
+	int maxFetched = 0;
+	
+	private DMFeedItem<K> removeItem(int pos) {
+		@SuppressWarnings("unchecked")
+		DMFeedItem<K> item  = items[pos];
+		
+		System.arraycopy(items, pos + 1, items, pos, itemCount - (pos + 1));
+		itemCount--;
+		
+		return item;
+	}
+	
+	/**
+	 * Add an item into the cache; the item is inserted at a position
+	 * corresponding to its timestmap. If the items is already in the cache 
+	 * it will be restacked with the new timestamp. (Restacking and out
+	 * of order insertion are not used currently, since we always start from
+	 * a an empty cache and fill it in order. If we did support mutating
+	 * the cache as the feed changed, then considerably more work would
+	 * have to be put into concurrency issues, possibly including locking
+	 * the cache across the entire cluster.)
+	 */
+	public synchronized void addItem(K value, long timestamp) {
+		Integer oldPos = itemMap.get(value);
+		DMFeedItem<K> item;
+		
+		if (oldPos != null) {
+			item = removeItem(oldPos);
+			item.time = timestamp;
+		} else {
+			item = new DMFeedItem<K>(value, timestamp);
+		}
+		
+		int pos = itemCount;
+		while (pos > 0 && timestamp >= items[pos -1].time)
+			pos--;
+		
+		if (pos >= items.length) {
+			int newLength = items.length * 2;
+			if (newLength < 0)
+				throw new OutOfMemoryError();
+			DMFeedItem newItems[] = new DMFeedItem[newLength];
+			System.arraycopy(items, 0, newItems, 0, itemCount);
+			items = newItems;
+		}
+		
+		if (pos < itemCount)
+			System.arraycopy(items, pos, items, pos + 1, itemCount - pos);
+		
+		items[pos] = new DMFeedItem<K>(value, timestamp);
+		itemCount++;
+		
+		itemMap.put(value, pos);
+	}
+
+	/**
+	 * Get an item from the cached feed store.
+	 * 
+	 * @param pos position at which to get the item. 0 is the most recent
+	 *   item in the feed.
+	 */
+	@SuppressWarnings("unchecked")
+	public synchronized DMFeedItem<K> getItem(int pos) {
+		return items[pos];
+	}
+	
+	/**
+	 * Get the total number of items in the store.
+	 */
+	public synchronized int size() {
+		return itemCount;
+	}
+	
+	/**
+	 * Get the number of items that would have been stored in the cached
+	 * feed store if they were present in feed.
+	 * 
+	 * We need to track this separately from the actual number of stored
+	 * items so that we don't repeatedly query the database for items that
+	 * aren't actually there.   
+	 */
+	public synchronized int getMaxFetched() {
+		return maxFetched;
+	}
+	
+	/**
+	 * Increase the value returned by getMaxFetched(). A thread fetching
+	 * items from the feed and caching them in the cached feed store would
+	 * call this, after storing all the items it fetched, with the number
+	 * of items from the feed that would have been stored if the feed
+	 * had all the items it asked for. 
+	 * 
+	 * @newMaxFetched the number of items that were attempted to be fetched
+	 *  (this includes previously fetched items, so if the thread fetched
+	 *  with start=15 and max=10, it would pass 25 for this parameter)
+	 */
+	public synchronized void addToMaxfetched(int newMaxFetched) {
+		if (newMaxFetched > maxFetched)
+			maxFetched = newMaxFetched;
+	}
+}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotification.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -6,6 +6,8 @@
 
 import com.dumbhippo.GlobalSetup;
 import com.dumbhippo.dm.schema.DMClassHolder;
+import com.dumbhippo.dm.schema.DMPropertyHolder;
+import com.dumbhippo.dm.schema.FeedPropertyHolder;
 
 /**
  * ChangeNotification represents pending notifications for a single resource.
@@ -18,18 +20,20 @@
 
 	private static Logger logger = GlobalSetup.getLogger(ChangeNotification.class);
 
-	private Class<T> clazz;
+	private DMClassHolder<K, T> classHolder;
 	private K key;
 	private long propertyMask; // bitset
 	private ClientMatcher matcher;
 
-	public ChangeNotification(Class<T> clazz, K key) {
-		this.clazz = clazz;
+	private long feedTimestamps[];
+
+	public ChangeNotification(DMClassHolder<K,T> classHolder, K key) {
+		this.classHolder = classHolder;
 		this.key = key;
 	}
 	
-	public ChangeNotification(Class<T> clazz, K key, ClientMatcher matcher) {
-		this.clazz = clazz;
+	public ChangeNotification(DMClassHolder<K,T> classHolder, K key, ClientMatcher matcher) {
+		this.classHolder = classHolder;
 		this.key = key;
 		this.matcher = matcher;
 	}
@@ -38,24 +42,58 @@
 		this.propertyMask |= 1 << propertyIndex;
 	}
 	
-	public long getPropertyMask() {
-		return propertyMask;
+	public void addProperty(String propertyName) {
+		int propertyIndex = classHolder.getPropertyIndex(propertyName);
+		if (propertyIndex < 0)
+			throw new RuntimeException("Class " + classHolder.getBaseClass().getName() + " has no property " + propertyName);
+		
+		DMPropertyHolder<K,T,?> property = classHolder.getProperty(propertyIndex);
+		if (property instanceof FeedPropertyHolder)
+			throw new RuntimeException("For feed-valued-properties, you must use session.feedChanged()");
+		
+		addProperty(propertyIndex);
 	}
 	
+	public void addFeedProperty(String propertyName, long itemTimestamp) {
+		int propertyIndex = classHolder.getPropertyIndex(propertyName);
+		if (propertyIndex < 0)
+			throw new RuntimeException("Class " + classHolder.getBaseClass().getName() + " has no property " + propertyName);
+		
+		addProperty(propertyIndex);
+		
+		DMPropertyHolder<K,T,?> property = classHolder.getProperty(propertyIndex);
+		if (!(property instanceof FeedPropertyHolder))
+			throw new RuntimeException("session.feedChanged() for a non-feed-valued property");
+
+		int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+		
+		if (feedTimestamps == null) {
+			feedTimestamps = new long[classHolder.getFeedPropertiesCount()];
+			for (int i = 0; i < feedTimestamps.length; i++)
+				feedTimestamps[i] = Long.MAX_VALUE; 
+		}
+		
+		if (feedTimestamps[feedPropertyIndex] > itemTimestamp)
+			feedTimestamps[feedPropertyIndex] = itemTimestamp;
+	}
+	
 	public void invalidate(DataModel model, long timestamp) {
-		@SuppressWarnings("unchecked")
-		DMClassHolder<K,T> classHolder = (DMClassHolder<K,T>)model.getClassHolder(clazz);
-		
 		long v = propertyMask;
 		int propertyIndex = 0;
 		while (v != 0) {
 			if ((v & 1) != 0) {
-				model.getStore().invalidate(classHolder, key, propertyIndex, timestamp);
+				DMPropertyHolder<K, T, ?> property = classHolder.getProperty(propertyIndex);
 				
+				if (property instanceof FeedPropertyHolder) {
+					int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+					model.getStore().invalidateFeed(classHolder, key, propertyIndex, timestamp, feedTimestamps[feedPropertyIndex]);
+				} else
+					model.getStore().invalidate(classHolder, key, propertyIndex, timestamp);
+				
 				logger.debug("Invalidated {}#{}.{}", new Object[] { 
 						classHolder.getBaseClass().getSimpleName(),
 						key, 
-						classHolder.getProperty(propertyIndex).getName() });
+						property.getName() });
 			}
 				
 			propertyIndex++;
@@ -64,9 +102,6 @@
 	}
 
 	public void resolveNotifications(DataModel model, ClientNotificationSet result) {
-		@SuppressWarnings("unchecked")
-		DMClassHolder<K,T> classHolder = (DMClassHolder<K,T>)model.getClassHolder(clazz);
-
 		model.getStore().resolveNotifications(classHolder, key, propertyMask, result, matcher);
 	}
 	
@@ -77,16 +112,16 @@
 		
 		ChangeNotification<?,?> other = (ChangeNotification<?,?>)o;
 		
-		return clazz == other.clazz && key.equals(other.key);
+		return classHolder == other.classHolder && key.equals(other.key);
 	}
 	
 	@Override
 	public int hashCode() {
-		return 11 * clazz.hashCode() + 17 * key.hashCode();
+		return 11 * classHolder.hashCode() + 17 * key.hashCode();
 	}
 	
 	@Override
 	public String toString() {
-		return clazz.getSimpleName() + "#" + key.toString();
+		return classHolder.getBaseClass().getSimpleName() + "#" + key.toString();
 	}
 }

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ChangeNotificationSet.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -33,13 +33,7 @@
 	public ChangeNotificationSet(DataModel model) {
 	}
 
-	public <K, T extends DMObject<K>> void changed(DataModel model, Class<T> clazz, K key, String propertyName, ClientMatcher matcher) {
-		@SuppressWarnings("unchecked")
-		DMClassHolder<K,T> classHolder = (DMClassHolder<K,T>)model.getClassHolder(clazz);
-		int propertyIndex = classHolder.getPropertyIndex(propertyName);
-		if (propertyIndex < 0)
-			throw new RuntimeException("Class " + clazz.getName() + " has no property " + propertyName);
-		
+	private <K, T extends DMObject<K>> ChangeNotification<K,T> getNotification(DMClassHolder<K,T> classHolder, K key, ClientMatcher matcher) {
 		if (key instanceof DMKey) {
 			@SuppressWarnings("unchecked")
 			K clonedKey = (K)((DMKey)key).clone(); 
@@ -50,26 +44,42 @@
 			if (matchedNotifications == null)
 				matchedNotifications = new ArrayList<ChangeNotification<?,?>>();
 			
-			ChangeNotification<?,?> notification = new ChangeNotification<K,T>(clazz, key, matcher);
-			notification.addProperty(propertyIndex);;
+			ChangeNotification<K,T> notification = new ChangeNotification<K,T>(classHolder, key, matcher);
 			matchedNotifications.add(notification);
+			return notification;
 		} else {
 			if (notifications == null)
 				notifications = new HashMap<ChangeNotification<?,?>, ChangeNotification<?,?>>();
 	
-			ChangeNotification<?,?> notification = new ChangeNotification<K,T>(clazz, key);
-			ChangeNotification<?,?> oldNotification = notifications.get(notification);
+			ChangeNotification<K,T> notification = new ChangeNotification<K,T>(classHolder, key);
+			@SuppressWarnings("unchecked")
+			ChangeNotification<K,T> oldNotification = (ChangeNotification<K,T>)notifications.get(notification);
 			if (oldNotification != null) {
-				oldNotification.addProperty(propertyIndex);
+				return oldNotification;
 			} else {
-				notification = new ChangeNotification<K,T>(clazz, key);
 				notifications.put(notification, notification);
-				notification.addProperty(propertyIndex);
+				return notification;
 			}
 		}
+		
 	}
-	
 
+	public <K, T extends DMObject<K>> void changed(DataModel model, Class<T> clazz, K key, String propertyName, ClientMatcher matcher) {
+		@SuppressWarnings("unchecked")
+		DMClassHolder<K,T> classHolder = (DMClassHolder<K,T>)model.getClassHolder(clazz);
+
+		ChangeNotification<K,T> notification = getNotification(classHolder, key, matcher);
+		notification.addProperty(propertyName);
+	}
+
+	public <K, T extends DMObject<K>> void feedChanged(DataModel model, Class<T> clazz, K key, String propertyName, long itemTimestamp) {
+		@SuppressWarnings("unchecked")
+		DMClassHolder<K,T> classHolder = (DMClassHolder<K,T>)model.getClassHolder(clazz);
+		
+		ChangeNotification<K,T> notification = getNotification(classHolder, key, null);
+		notification.addFeedProperty(propertyName, itemTimestamp);
+	}
+
 	public void setTimestamp(long timestamp) {
 		this.timestamp = timestamp;
 	}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotification.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotification.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotification.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -5,7 +5,9 @@
 
 import com.dumbhippo.dm.fetch.Fetch;
 import com.dumbhippo.dm.fetch.FetchVisitor;
+import com.dumbhippo.dm.schema.DMClassHolder;
 import com.dumbhippo.dm.schema.DMPropertyHolder;
+import com.dumbhippo.dm.schema.FeedPropertyHolder;
 import com.dumbhippo.dm.store.StoreClient;
 import com.dumbhippo.dm.store.StoreKey;
 
@@ -31,8 +33,8 @@
 		this.client = client;
 	}
 	
-	public <K, T extends DMObject<K>>void addObjectProperties(StoreKey<K,T> key, Fetch<K,? super T> fetch, long propertyMask, Fetch<?,?>[] childFetches) {
-		notifications.add(new ObjectNotification<K,T>(key, fetch, propertyMask, childFetches));
+	public <K, T extends DMObject<K>>void addObjectProperties(StoreKey<K,T> key, Fetch<K,? super T> fetch, long propertyMask, Fetch<?,?>[] childFetches, int[] maxes) {
+		notifications.add(new ObjectNotification<K,T>(key, fetch, propertyMask, childFetches, maxes));
 	}
 	
 	public StoreClient getClient() {
@@ -54,17 +56,32 @@
 		private Fetch<K, ? super T> fetch;
 		private long propertyMask;
 		private Fetch<?,?>[] childFetches;
+		private int[] maxes;
 
-		public ObjectNotification(StoreKey<K,T> key, Fetch<K, ? super T> fetch, long propertiesMask, Fetch<?,?>[] childFetches) {
+		public ObjectNotification(StoreKey<K,T> key, Fetch<K, ? super T> fetch, long propertiesMask, Fetch<?,?>[] childFetches, int[] maxes) {
 			this.key = key;
 			this.fetch = fetch;
 			this.propertyMask = propertiesMask;
 			this.childFetches = childFetches;
+			this.maxes = maxes;
 		}
 		
+		private int getMax(FeedPropertyHolder<K,T,?,?> property, int propertyIndex) {
+			int max = -1;
+			if (maxes != null)
+				max = maxes[propertyIndex];
+			
+			if (max < property.getDefaultMaxFetch())
+				max = property.getDefaultMaxFetch();
+			
+			return max;
+		}
+		
 		public void visitNotification(DMSession session, FetchVisitor visitor) {
 			T object = session.findUnchecked(key);
-			DMPropertyHolder<K,T,?>[] classProperties = key.getClassHolder().getProperties();
+			DMClassHolder<K,T> classHolder = key.getClassHolder();
+			DMPropertyHolder<K,T,?>[] classProperties = classHolder.getProperties();
+			long feedMinTimestamps[] = null;
 
 			long v;
 			int propertyIndex;
@@ -74,7 +91,21 @@
 				propertyIndex = 0;
 				while (v != 0) {
 					if ((v & 1) != 0 && childFetches[propertyIndex] != null) {
-						classProperties[propertyIndex].visitChildren(session, childFetches[propertyIndex], object, visitor);
+						if (classProperties[propertyIndex] instanceof FeedPropertyHolder) {
+							@SuppressWarnings("unchecked")
+							FeedPropertyHolder<K,T,?,?> property = (FeedPropertyHolder<K,T,?,?>)classProperties[propertyIndex];
+							
+							if (feedMinTimestamps == null) {
+								feedMinTimestamps = new long[key.getClassHolder().getFeedPropertiesCount()];
+								for (int i = 0; i < feedMinTimestamps.length; i++)
+									feedMinTimestamps[i] = -1;
+							}
+							
+							int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+							feedMinTimestamps[feedPropertyIndex] = property.visitFeedChildren(session, childFetches[propertyIndex], 0, getMax(property, propertyIndex), object, visitor, false);
+						} else {
+							classProperties[propertyIndex].visitChildren(session, childFetches[propertyIndex], object, visitor);
+						}
 					}
 
 					v >>= 1;
@@ -82,14 +113,28 @@
 				}
 			}
 			
-			visitor.beginResource(key.getClassHolder(), key.getKey(), fetch.getFetchString(key.getClassHolder()), false);
+			visitor.beginResource(key.getClassHolder(), key.getKey(), fetch.getFetchString(classHolder), false);
 			
 			v = propertyMask;
 			propertyIndex = 0;
 			while (v != 0) {
-				if ((v & 1) != 0)
-					classProperties[propertyIndex].visitProperty(session, object, visitor, true);
+				if ((v & 1) != 0) {
+					if (classProperties[propertyIndex] instanceof FeedPropertyHolder) {
+						@SuppressWarnings("unchecked")
+						FeedPropertyHolder<K,T,?,?> property = (FeedPropertyHolder<K,T,?,?>)classProperties[propertyIndex];
 
+						long minTimestamp = -1;
+						if (feedMinTimestamps != null) {
+							int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+							minTimestamp = feedMinTimestamps[feedPropertyIndex];
+						}
+
+						property.visitFeedProperty(session, 0, getMax(property, propertyIndex), object, visitor, minTimestamp);
+					} else {
+						classProperties[propertyIndex].visitProperty(session, object, visitor, true);
+					}
+				}
+
 				v >>= 1;
 				propertyIndex++;
 			}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotificationSet.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotificationSet.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ClientNotificationSet.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -25,7 +25,7 @@
 	
 	private Map<StoreClient, ClientNotification> notifications;
 	
-	public <K, T extends DMObject<K>> void addNotification(StoreClient client, StoreKey<K,T> key, Fetch<K,? super T> fetch, long propertyMask, Fetch<?,?>[] childFetches) {
+	public <K, T extends DMObject<K>> void addNotification(StoreClient client, StoreKey<K,T> key, Fetch<K,? super T> fetch, long propertyMask, Fetch<?,?>[] childFetches, int[] maxes) {
 		ClientNotification notification = null;
 		
 		if (notifications == null)
@@ -38,7 +38,7 @@
 			notifications.put(client, notification);
 		}
 		
-		notification.addObjectProperties(key, fetch, propertyMask, childFetches);
+		notification.addObjectProperties(key, fetch, propertyMask, childFetches, maxes);
 	}
 	
 	public Collection<ClientNotification> getNotifications() {

Added: dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeed.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeed.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeed.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,22 @@
+package com.dumbhippo.dm;
+
+import java.util.Iterator;
+
+/**
+ * This class represents a feed of items; each feed item has a
+ * resource value and a timestamp.
+ * 
+ * @param <T> the value type; this class is used for both lists of feed items
+ *   as seen by applications and for "dehydrated" feed item list with
+ *   just the keys for the resources, which is why it isn't 'T extends DMObject'.  
+ */
+public interface DMFeed<T> {
+	/**
+	 * Iterate the items in the feed
+	 * 
+	 * @param start start position for iteration (0 is the most recent item in the feed)
+	 * @param max maximum number of items to return
+	 * @param minTimestamp only iterate items with this timestamp or newer 
+	 */
+	public Iterator<DMFeedItem<T>> iterator(int start, int max, long minTimestamp);
+}

Added: dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeedItem.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeedItem.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/DMFeedItem.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,33 @@
+package com.dumbhippo.dm;
+
+import java.util.Date;
+
+/**
+ * This class represents a single item in a feed; each feed item has a
+ * resource value and a timestamp.
+ * 
+ * @param <T> the value type; this class is used for both feed items
+ *   as seen by applications and for "dehydrated" feed items with
+ *   just the key for the resource, which is why it isn't 'T extends DMObject'.  
+ */
+public class DMFeedItem<T> {
+	protected T value;
+	protected long time;
+	
+	public DMFeedItem(T value, long time) {
+		this.value = value;
+		this.time = time;
+	}
+	
+	public T getValue() {
+		return value;
+	}
+	
+	public long getTime() {
+		return time;
+	}
+	
+	public Date getDate() {
+		return new Date(time);
+	}
+}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/DMSession.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -161,6 +161,19 @@
 	 * @throws NotCachedException
 	 */
 	public abstract <K, T extends DMObject<K>> Object storeAndFilter(StoreKey<K,T> key, int propertyIndex, Object value);
+	
+	/**
+	 * Internal API: Create a feed wrapper that handles filtering and caching for for the raw feed object. 
+	 * 
+	 * @param <K>
+	 * @param <T>
+	 * @param <TI>
+	 * @param key
+	 * @param propertyIndex
+	 * @param rawFeed
+	 * @return
+	 */
+	public abstract <K, T extends DMObject<K>> DMFeed<?> createFeedWrapper(StoreKey<K,T> key, int propertyIndex, DMFeed<T> rawFeed);
 
 	/**
 	 * Finds the "raw" value for a particular object property. Raw values differ from the normally

Added: dumbhippo/trunk/server/src/com/dumbhippo/dm/FeedWrapper.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/FeedWrapper.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/FeedWrapper.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,188 @@
+package com.dumbhippo.dm;
+
+import java.util.Iterator;
+import java.util.NoSuchElementException;
+
+import org.slf4j.Logger;
+
+import com.dumbhippo.GlobalSetup;
+import com.dumbhippo.dm.filter.CompiledItemFilter;
+import com.dumbhippo.dm.schema.FeedPropertyHolder;
+
+/**
+ * This class takes the raw DMFeed item as returned from the DMO and adds caching and
+ * filtering to it. For feeds, unlike other property types, this is a dynamic process.
+ * As items are fetched from the feed, they are filtered and cached.
+ * 
+ * Note that the start/max positions passed to iterator() are positions in the
+ * *unfiltered* stream. If the all max items are filtered out, no items will be
+ * returned. While this is less than ideal, it keeps things much simpler and 
+ * more efficient than the alternative, since we know ahead of time how many
+ * items to fetch from the underlying feed to get max items, and we don't have to 
+ * worry about how many items were filtered before 'start'. As long as the fraction
+ * of filtered items is small, the user isn't going to notice if their page of 10
+ * items only has 8 on it. 
+ */
+public class FeedWrapper<K, T extends DMObject<K>, KI, TI extends DMObject<KI>> implements DMFeed<TI> {
+	@SuppressWarnings("unused")
+	static final private Logger logger = GlobalSetup.getLogger(FeedWrapper.class);
+
+	private K key;
+	private FeedPropertyHolder<K, T, KI, TI> property;
+	private CachedFeed<KI> cachedFeed;
+	private DMFeed<TI> rawFeed;
+	
+	/**
+	 * @param property the property holder object for this property
+	 * @param key the key of the object that has the feed as a property
+	 * @param rawFeed the DMFeed returned by the DMO; this will typically query the database when iterated.
+	 * @param cachedFeed cached feed object; this can be null if the current transaction is stale
+	 *    with respect to the invalidation timestamp in the cache. (Perhaps we should create a dummy CachedFeed 
+	 *    in this case? there is considerable inefficiency the way we do it currently since we'll requery
+	 *    the database each time we are iterated when cachedFeed is null. Still it's a rare case)
+	 */
+	public FeedWrapper(FeedPropertyHolder<K, T, KI,TI> property, K key, DMFeed<TI> rawFeed, CachedFeed<KI> cachedFeed) {
+		this.key = key;
+		this.property = property;
+		this.rawFeed = rawFeed;
+		this.cachedFeed = cachedFeed;
+	}
+	
+	public Iterator<DMFeedItem<TI>> iterator(int start, int max, long minTimestamp) {
+		return new FeedWrapperIterator(start, max, minTimestamp);
+	}
+	
+	private class FeedWrapperIterator implements Iterator<DMFeedItem<TI>> {
+		private int start;
+		private int max;
+		private long minTimestamp;
+		
+		// Because filtering will remove items that are there in the underlying
+		// feed, we need to fetch one item ahead to give an accurate value for
+		// hasNext().
+		private boolean fetchedNext = false;
+		private DMFeedItem<TI> nextItem = null;
+		
+		private int pos;
+		
+		private Iterator<DMFeedItem<TI>> sourceIterator;
+		
+		public FeedWrapperIterator(int start, int max, long minTimestamp) {
+			this.start = start;
+			this.max = max;
+			this.minTimestamp = minTimestamp;
+			
+			pos = start;
+		}
+		
+		private void fetchNext() {
+			DMSession session = property.getModel().currentSession();
+			CompiledItemFilter<K, T, KI, TI> itemFilter = property.getItemFilter();
+			
+			nextItem = null;
+			fetchedNext = true;
+			
+			if (sourceIterator == null && cachedFeed != null) {
+				/* We start off by fetching items from the cache
+				 */ 
+				while (pos < max && pos < cachedFeed.size()) {
+					DMFeedItem<KI> cachedItem = cachedFeed.getItem(pos);
+	
+					KI filteredKey;
+					if (itemFilter != null)
+						filteredKey = itemFilter.filterKey(session.getViewpoint(), key, cachedItem.getValue());
+					else
+						filteredKey = cachedItem.getValue();
+					
+					if (filteredKey != null) {
+						nextItem = new DMFeedItem<TI>(property.rehydrateDMO(filteredKey, session), cachedItem.getTime());
+						pos++;
+						return;
+					}
+					
+					pos++;
+				}
+			}
+			
+			if (pos == max)
+				return;
+
+			/* Check if the cachedFeed was the result of a fetch that asked for
+			 * as many or more items than the current fetch but only returned a limited
+			 * amount of items; in that case, we don't need to query the database again.
+			 */
+			if (cachedFeed != null && cachedFeed.getMaxFetched() >= start + max)
+				return;
+			
+			/*
+			 * If there are more items left to fetch once the cache is exhausted, we iterate 
+			 * from the raw source feed and cache those new items as we go along.  
+			 */
+			
+			if (sourceIterator == null) {
+				if (cachedFeed != null) {
+					/* We don't want to leave gaps in what is stored in cachedFeed, so we
+					 * may need to start source iterator before pos, and then skip over
+					 * some number of items 
+					 */
+					int skipPos = cachedFeed.size();
+					sourceIterator = rawFeed.iterator(skipPos, start + max - skipPos, minTimestamp);
+					
+					while (skipPos < pos && sourceIterator.hasNext()) {
+						DMFeedItem<TI> item = sourceIterator.next();
+						skipPos++;
+						cachedFeed.addItem(item.getValue().getKey(), item.getTime());
+					}
+				} else {
+					sourceIterator = rawFeed.iterator(pos, start + max - pos, minTimestamp);
+				}
+			}
+
+			while (sourceIterator.hasNext()) {
+				DMFeedItem<TI> item = sourceIterator.next();
+				pos++;
+			
+				if (cachedFeed != null)
+					cachedFeed.addItem(item.getValue().getKey(), item.getTime());
+				
+				TI filteredValue;
+				if (itemFilter != null)
+					filteredValue = itemFilter.filterObject(session.getViewpoint(), key, item.getValue());
+				else
+					filteredValue = item.getValue();
+				
+				if (filteredValue != null) {
+					nextItem = item;
+					return;
+				}
+			}
+	
+			if (cachedFeed != null)
+				cachedFeed.addToMaxfetched(start + max);
+		}
+		
+		public boolean hasNext() {
+			if (!fetchedNext)
+				fetchNext();
+			
+			return nextItem != null && nextItem.getTime() >= minTimestamp;
+		}
+
+		public DMFeedItem<TI> next() {
+			if (!fetchedNext)
+				fetchNext();
+			
+			DMFeedItem<TI> result = nextItem;
+			if (result == null || result.getTime() < minTimestamp)
+				throw new NoSuchElementException();
+			
+			nextItem = null;
+			fetchedNext = false;
+			return result;
+		}
+
+		public void remove() {
+			throw new UnsupportedOperationException();
+		}
+	}
+}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadOnlySession.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadOnlySession.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadOnlySession.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -4,6 +4,7 @@
 
 import com.dumbhippo.GlobalSetup;
 import com.dumbhippo.dm.schema.DMPropertyHolder;
+import com.dumbhippo.dm.schema.FeedPropertyHolder;
 import com.dumbhippo.dm.store.StoreKey;
 
 /**
@@ -51,8 +52,17 @@
 		else
 			return property.filter(getViewpoint(), key.getKey(), value);
 	}
-	
+
 	@Override
+	@SuppressWarnings("unchecked")
+	public <K, T extends DMObject<K>> DMFeed<?> createFeedWrapper(StoreKey<K, T> key, int propertyIndex, DMFeed<T> rawFeed) {
+		FeedPropertyHolder<K, T, ?, ?> feedProperty = (FeedPropertyHolder<K, T, ?, ?>)key.getClassHolder().getProperty(propertyIndex);
+		
+		CachedFeed cached = model.getStore().getOrCreateCachedFeed(key, propertyIndex, txTimestamp);
+		return new FeedWrapper(feedProperty, key.getKey(), rawFeed, cached);
+	}
+
+	@Override
 	public void afterCompletion(int status) {
 	}
 }

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadWriteSession.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadWriteSession.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/ReadWriteSession.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -6,6 +6,7 @@
 
 import com.dumbhippo.GlobalSetup;
 import com.dumbhippo.dm.schema.DMPropertyHolder;
+import com.dumbhippo.dm.schema.FeedPropertyHolder;
 import com.dumbhippo.dm.store.StoreKey;
 
 /**
@@ -40,11 +41,18 @@
 		return property.filter(getViewpoint(), key.getKey(), value);
 	}
 
+	@Override
+	@SuppressWarnings("unchecked")
+	public <K, T extends DMObject<K>> DMFeed<?> createFeedWrapper(StoreKey<K, T> key, int propertyIndex, DMFeed<T> rawFeed) {
+		FeedPropertyHolder<K, T, ?, ?> feedProperty = (FeedPropertyHolder<K, T, ?, ?>)key.getClassHolder().getProperty(propertyIndex); 
+		return new FeedWrapper(feedProperty, key.getKey(), rawFeed, null);
+	}
+
 	/**
 	 * Indicate that a resource property has changed; this invalidates any cached value for the
 	 * property and also triggers sending notifications to any clients that have registered
 	 * for notification on the property. Notifications will only be sent after the current
-	 * transaction commits succesfully.
+	 * transaction commits succesfully. For changes to feed-valued properties, see feedChanged().
 	 * 
 	 * @param <K>
 	 * @param <T>
@@ -57,6 +65,22 @@
 	}
 	
 	/**
+	 * Indicate that a feed-valued resource property has changed; This differs from changed()
+	 * in that the timestamp of the provided item is also provided.
+	 * 
+	 * @param <K>
+	 * @param <T>
+	 * @param clazz the class of the resource where the property changed
+	 * @param key the key of the resource  where the property changed
+	 * @param propertyName the name of the property that changed
+	 * @param itemTimestamp new timestamp of the item that was inserted or restacked, or -1 to indicate
+	 *    that an item was deleted.
+	 */
+	public <K, T extends DMObject<K>> void feedChanged(Class<T> clazz, K key, String propertyName, long itemTimestamp) {
+		notificationSet.feedChanged(model, clazz, key, propertyName, itemTimestamp);
+	}
+	
+	/**
 	 * Indicate that a resource property has changed; this invalidates any cached value for the
 	 * property and also triggers sending notifications to any clients that have registered
 	 * for notification on the property. Notifications will only be sent after the current

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMProperty.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMProperty.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/DMProperty.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -34,6 +34,17 @@
 	 * be specified for resource-typed properties, forces defaultInclude on) 
 	 */
 	String defaultChildren() default "";
+	
+	/**
+	 * For feed properties, the maximum number of feed entries to fetch
+	 * if no limit or a smaller limit is specified. (It might be more
+	 * appropriately called the minMaxFetch. There's no fundamental reason
+	 * we couldn't support a caller reducing the maxFetch from this value,
+	 * but it makes bookkeeping tricky, since the correct merge of two
+	 * fetch strings 'prop' and 'prop(max=30)' then depends on knowing what
+	 * the defaultMaxFetch for 'prop')
+	 */
+	int defaultMaxFetch() default 20;
 
 	/**
 	 * Whether the property should be cached or not. The main reason to avoid

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/PropertyType.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/PropertyType.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/annotations/PropertyType.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -8,6 +8,7 @@
 	FLOAT('f'),
 	STRING('s'),
 	RESOURCE('r'),
+	FEED('f'),
 	URL('u');
 	
 	private char typeChar;

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/Fetch.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/Fetch.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/Fetch.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -12,6 +12,7 @@
 import com.dumbhippo.dm.DMSession;
 import com.dumbhippo.dm.schema.DMClassHolder;
 import com.dumbhippo.dm.schema.DMPropertyHolder;
+import com.dumbhippo.dm.schema.FeedPropertyHolder;
 import com.dumbhippo.dm.store.StoreClient;
 import com.dumbhippo.dm.store.StoreKey;
 
@@ -44,9 +45,18 @@
 		return classIndex < classProperties.length ? classProperties[classIndex].getOrdering() : Long.MAX_VALUE;
 	}
 	
+	private long[] createFeedMinTimestamps(DMClassHolder<?,?> classHolder) {
+		long[] feedMinTimestamps = new long[classHolder.getFeedPropertiesCount()];
+		for (int i = 0; i < feedMinTimestamps.length; i++)
+			feedMinTimestamps[i] = -1;
+		
+		return feedMinTimestamps;
+	}
+	
 	public <U extends T> void visit(DMSession session, DMClassHolder<K,U> classHolder, U object, FetchVisitor visitor, boolean indirect) {
 		DMPropertyHolder<K,U,?>[] classProperties = classHolder.getProperties();
 		Fetch<K,? super U> oldFetch;
+		long[] feedMinTimestamps = null;
 		
 		StoreClient storeClient;
 		DMClient client = session.getClient();
@@ -105,8 +115,53 @@
 			if (newFetched && !oldFetched)
 				newAnyFetched = true;
 			
-			if (newChildren != null && (oldChildren == null || newChildren != oldChildren))
+			if ((oldFetched || newFetched) && classProperties[classIndex] instanceof FeedPropertyHolder) {
+				@SuppressWarnings("unchecked")
+				FeedPropertyHolder<K,T,?,?> property = (FeedPropertyHolder<K,T,?,?>)classProperties[classIndex];
+
+				/* The advantage of always fetching *at least* the default is that 
+				 * then we can merge to fetch specifications without knowing the
+				 * particular value of the max fetch.
+				 */
+				int oldMax = 0;
+				if (oldOrdering == classOrdering) {
+					oldMax = oldFetch.properties[newIndex].getMax();
+					if (oldMax < property.getDefaultMaxFetch())
+						oldMax = property.getDefaultMaxFetch();
+				}
+							
+				int newMax = 0;
+				if (newOrdering == classOrdering) {
+					newMax = properties[newIndex].getMax();
+					if (newMax < property.getDefaultMaxFetch())
+						newMax = property.getDefaultMaxFetch();
+				}
+				
+				if (newMax > oldMax && (newChildren != null || oldChildren != null)) {
+					Fetch<?,?> children;
+					
+					if (newChildren == null)
+						children = oldChildren;
+					else if (oldChildren == null)
+						children = newChildren;
+					else
+						children = newChildren.merge(oldChildren);
+					
+					if (feedMinTimestamps == null)
+						feedMinTimestamps = createFeedMinTimestamps(classHolder);
+					int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+									
+					feedMinTimestamps[feedPropertyIndex] = property.visitFeedChildren(session, children, oldMax, newMax - oldMax, object, visitor, true);
+				} else if (newChildren != null && (oldChildren == null || newChildren != oldChildren)) {
+					if (feedMinTimestamps == null)
+						feedMinTimestamps = createFeedMinTimestamps(classHolder);
+					int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+
+					feedMinTimestamps[feedPropertyIndex] = property.visitFeedChildren(session, newChildren, 0, oldMax, object, visitor, false);
+				}
+			} else if (newChildren != null && (oldChildren == null || newChildren != oldChildren)) {
 				classProperties[classIndex].visitChildren(session, newChildren, object, visitor);
+			}
 		}
 		
 		// If this resource is part of the direct result, we must always include the resource, 
@@ -161,11 +216,40 @@
 			while (newOrdering < classOrdering)
 				newOrdering = propertyOrdering(++newIndex);
 			
-			boolean oldFetched = oldOrdering == classOrdering || (oldFetch != null && oldFetch.includeDefault && classProperties[classIndex].getDefaultInclude());
-			boolean newFetched = newOrdering == classOrdering || (includeDefault && classProperties[classIndex].getDefaultInclude());
-			
-			if (newFetched && !oldFetched)
+			boolean oldFetched = (oldOrdering == classOrdering) || (oldFetch != null && oldFetch.includeDefault && classProperties[classIndex].getDefaultInclude());
+			boolean newFetched = (newOrdering == classOrdering) || (includeDefault && classProperties[classIndex].getDefaultInclude());
+
+			if ((oldFetched || newFetched) && classProperties[classIndex] instanceof FeedPropertyHolder) {
+				@SuppressWarnings("unchecked")
+				FeedPropertyHolder<K,T,?,?> property = (FeedPropertyHolder<K,T,?,?>)classProperties[classIndex];
+
+				int oldMax =0;
+				if (oldOrdering == classOrdering) {
+					oldMax = oldFetch.properties[newIndex].getMax();
+					if (oldMax < property.getDefaultMaxFetch())
+						oldMax = property.getDefaultMaxFetch();
+				}
+							
+				int newMax = 0;
+				if (newOrdering == classOrdering) {
+					newMax = properties[newIndex].getMax();
+					if (newMax < property.getDefaultMaxFetch())
+						newMax = property.getDefaultMaxFetch();
+				}
+				
+				long minTimestamp;
+				if (feedMinTimestamps != null && newMax <= oldMax) {
+					int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+					minTimestamp = feedMinTimestamps[feedPropertyIndex];
+				} else {
+					minTimestamp = 0;
+				}
+
+				if (newMax > oldMax)
+					property.visitFeedProperty(session, oldMax, newMax - oldMax, object, visitor, minTimestamp);
+			} else if (newFetched && !oldFetched) {
 				classProperties[classIndex].visitProperty(session, object, visitor, false);
+			}
 		}
 		
 		visitor.endResource();
@@ -240,8 +324,9 @@
 		return b.toString();
 	}
 
-	public <U extends T> Fetch<K,? super U> merge(Fetch<K,? super U> other) {
+	public Fetch<?,?> merge(Fetch<?,?> other) {
 		int newCount = this.properties.length;
+		boolean changedProperties = false;
 
 		// Count the total number of properties in the merge of the two fetches
 		
@@ -254,6 +339,8 @@
 				thisOrdering = this.propertyOrdering(++thisIndex);
 			} else if (thisOrdering == otherOrdering) {
 				// In both fetches
+				if (!this.properties[thisIndex].equals(other.properties[otherIndex]))
+					changedProperties = true;
 				thisOrdering = this.propertyOrdering(++thisIndex);
 				otherOrdering = other.propertyOrdering(++otherIndex);
 			} else {
@@ -264,7 +351,7 @@
 		}
 		
 		// If the other property is a subset of this one, we can just return this one
-		if (newCount == this.properties.length && (this.includeDefault || !other.includeDefault))
+		if (!changedProperties && newCount == this.properties.length && (this.includeDefault || !other.includeDefault))
 			return this;
 		
 		PropertyFetch[] newProperties = new PropertyFetch[newCount];
@@ -280,7 +367,7 @@
 				thisOrdering = this.propertyOrdering(++thisIndex);
 			} else if (thisOrdering == otherOrdering) {
 				// In both fetches
-				newProperties[newIndex++] = this.properties[thisIndex];
+				newProperties[newIndex++] = this.properties[thisIndex].merge(other.properties[otherIndex]);
 				thisOrdering = this.propertyOrdering(++thisIndex);
 				otherOrdering = other.propertyOrdering(++otherIndex);
 			} else {
@@ -291,13 +378,15 @@
 		}
 		
 		@SuppressWarnings("unchecked")
-		Fetch<K,? super U> newFetch = new Fetch(newProperties, this.includeDefault || other.includeDefault); 
+		Fetch<?, ?> newFetch = new Fetch(newProperties, this.includeDefault || other.includeDefault);
+		
 		return newFetch;
 	}
 
 	public void resolveNotifications(StoreClient client, StoreKey<K,? extends T> key, long propertyMask, ClientNotificationSet result) {
 		DMPropertyHolder<K,? extends T,?>[] classProperties = key.getClassHolder().getProperties();
 		Fetch<?,?>[] childFetches = null;
+		int[] maxes = null;
 		long notifiedMask = 0;
 		
 		long bit = 1;
@@ -313,11 +402,13 @@
 	
 				boolean notified = false;
 				Fetch<?,?> childFetch = null;
+				int max = -1;
 
 				if (propertyOrdering == classOrdering) {
 					if (properties[propertyIndex].getNotify()) {
 						notified = true;
 						childFetch = properties[propertyIndex].getChildren();
+						max = properties[propertyIndex].getMax();
 					}
 				} else if (includeDefault && classProperties[classIndex].getDefaultInclude()) {
 					notified = true;
@@ -332,6 +423,16 @@
 						childFetches = new Fetch[classProperties.length];
 					childFetches[classIndex] = childFetch;
 				}
+				
+				if (max >= 0) {
+					if (maxes == null) {
+						maxes = new int[classProperties.length];
+						for (int i = 0; i < classProperties.length; i++)
+							maxes[i] = -1; 
+					}
+					maxes[classIndex] = max;
+				}
+				
 			}
 			
 			propertyMask >>= 1;
@@ -340,7 +441,7 @@
 		}
 		
 		if (notifiedMask != 0)
-			result.addNotification(client, key, this, notifiedMask, childFetches);
+			result.addNotification(client, key, this, notifiedMask, childFetches, maxes);
 	}
 	
 	public <U extends T> String getFetchString(DMClassHolder<K,U> classHolder) {

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchAttributeType.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchAttributeType.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchAttributeType.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -1,6 +1,7 @@
 package com.dumbhippo.dm.fetch;
 
 public enum FetchAttributeType {
+	MAX("max"),
 	NOTIFY("notify");
 	
 	private String lowerName;

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchVisitor.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchVisitor.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/FetchVisitor.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -12,6 +12,7 @@
 	<K,T extends DMObject<K>> void beginResource(DMClassHolder<K,T> classHolder, K key, String fetchString, boolean indirect);
 	<K,T extends DMObject<K>> void plainProperty(PlainPropertyHolder<K, T, ?> propertyHolder, Object value);
 	<KP,TP extends DMObject<KP>> void resourceProperty(ResourcePropertyHolder<?,?,KP,TP> propertyHolder, KP key);
+	<KP,TP extends DMObject<KP>> void feedProperty(ResourcePropertyHolder<?,?,KP,TP> propertyHolder, KP key, long timestamp, boolean incremental);
 	<K,T extends DMObject<K>> void emptyProperty(DMPropertyHolder<K,T,?> propertyHolder);
 	void endResource();
 }

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetch.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetch.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetch.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -7,11 +7,13 @@
 	private DMPropertyHolder<?,?,?> property;
 	private Fetch<?,?> children;
 	private boolean notify;
+	private int max;
 	
-	public PropertyFetch(DMPropertyHolder<?,?,?> property, Fetch<?,?> children, boolean notify) {
+	public PropertyFetch(DMPropertyHolder<?,?,?> property, Fetch<?,?> children, boolean notify, int max) {
 		this.property = property;
 		this.children = children;
 		this.notify = notify;
+		this.max = max;
 	}
 
 	public Fetch<?,?> getChildren() {
@@ -22,10 +24,34 @@
 		return property;
 	}
 	
+	public int getMax() {
+		return max;
+	}
+	
 	public boolean getNotify() {
 		return notify;
 	}
 	
+
+	public PropertyFetch merge(PropertyFetch other) {
+		if (equals(other))
+			return this;
+		
+		Fetch<?, ?> newChildren;
+		
+		if (children == null)
+			newChildren = other.children;
+		else if (other.children == null)
+			newChildren = children;
+		else
+			newChildren = children.merge(other.children);
+			
+		boolean newNotify = notify || other.notify;
+		int newMax = Math.max(max, other.max);
+			
+		return new PropertyFetch(property, newChildren, newNotify, newMax);
+	}
+	
 	public int compareTo(PropertyFetch other) {
 		return property.compareTo(other.property);
 	}
@@ -42,6 +68,9 @@
 		if (notify != other.notify)
 			return false;
 		
+		if (max != other.max)
+			return false;
+		
 		if ((children == null && other.children != null) ||
 			(children != null && !children.equals(other.children)))
 			return false;
@@ -65,8 +94,19 @@
 		
 		b.append(property.getPropertyId());
 		
+		if (max >= 0 || !notify)
+			b.append('(');
+		
+		if (max >= 0) {
+			b.append("max=");
+			b.append(max);
+			if (!notify)
+				b.append(",");
+		}
 		if (!notify)
-			b.append("(notify=false)");
+			b.append("notify=false");
+		if (max >= 0 || !notify)
+			b.append(')');
 		
 		if (children != null) {
 			b.append(' ');

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetchNode.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetchNode.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/fetch/PropertyFetchNode.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -41,7 +41,7 @@
 		return null;
 	}
 
-	public <K,T extends DMObject<K>> void bindResourceProperty(ResourcePropertyHolder<?,?,K,T> resourceHolder, List<PropertyFetch> resultList, boolean maybeSkip, boolean notify) {
+	public <K,T extends DMObject<K>> void bindResourceProperty(ResourcePropertyHolder<?,?,K,T> resourceHolder, List<PropertyFetch> resultList, boolean maybeSkip, boolean notify, int max) {
 		Fetch<K,T> defaultChildren = resourceHolder.getDefaultChildren();
 		Fetch<K,T> boundChildren = null;
 		
@@ -59,12 +59,12 @@
 				return;
 		}
 		
-		resultList.add(new PropertyFetch(resourceHolder, boundChildren, notify));
+		resultList.add(new PropertyFetch(resourceHolder, boundChildren, notify, max));
 	}
 
-	public void bindPlainProperty(DMPropertyHolder<?,?,?> propertyHolder, List<PropertyFetch> resultList, boolean maybeSkip, boolean notify) {
+	public void bindPlainProperty(DMPropertyHolder<?,?,?> propertyHolder, List<PropertyFetch> resultList, boolean maybeSkip, boolean notify, int max) {
 		if (!maybeSkip)
-			resultList.add(new PropertyFetch(propertyHolder, null, notify));
+			resultList.add(new PropertyFetch(propertyHolder, null, notify, max));
 	}
 
 	/**
@@ -79,9 +79,18 @@
 	 * @param resultList list to append the results to 
 	 */
 	public void bind(DMClassHolder<?,?> classHolder, boolean skipDefault, List<PropertyFetch> resultList) {
+		int max = -1;
 		boolean notify = true;
 		for (FetchAttributeNode attribute : attributes) {
 			switch (attribute.getType()) {
+			case MAX:
+				// FIXME: We probably should make bind() throw an exception and make this fatal
+				if (!(attribute.getValue() instanceof Integer)) {
+					logger.warn("Ignoring non-integer max attribute");
+					continue;
+				}
+				max = ((Integer)(attribute.getValue()));
+				break;
 			case NOTIFY:
 				// FIXME: We probably should make bind() throw an exception and make this fatal
 				if (!(attribute.getValue() instanceof Boolean)) {
@@ -99,9 +108,9 @@
 			boolean maybeSkip = skipDefault && propertyHolder.getDefaultInclude(); 
 			
 			if (propertyHolder instanceof ResourcePropertyHolder) {
-				bindResourceProperty(propertyHolder.asResourcePropertyHolder(propertyHolder.getKeyClass()), resultList, maybeSkip, notify);
+				bindResourceProperty(propertyHolder.asResourcePropertyHolder(propertyHolder.getKeyClass()), resultList, maybeSkip, notify, max);
 			} else {
-				bindPlainProperty(propertyHolder, resultList, maybeSkip, notify);
+				bindPlainProperty(propertyHolder, resultList, maybeSkip, notify, max);
 			}
 		}
 	}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchLexer.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchLexer.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchLexer.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -3,9 +3,11 @@
 package com.dumbhippo.dm.parser;
 
 import java.io.StringReader;
+import java.util.Collections;
 import java.util.List;
 import java.util.ArrayList;
 import com.dumbhippo.dm.fetch.*;
+import com.dumbhippo.dm.parser.ParseException;
 import com.dumbhippo.GlobalSetup;
 import org.slf4j.Logger;
 
@@ -49,9 +51,10 @@
 	caseSensitiveLiterals = true;
 	setCaseSensitive(true);
 	literals = new Hashtable();
-	literals.put(new ANTLRHashString("true", this), new Integer(16));
-	literals.put(new ANTLRHashString("false", this), new Integer(17));
-	literals.put(new ANTLRHashString("notify", this), new Integer(14));
+	literals.put(new ANTLRHashString("true", this), new Integer(17));
+	literals.put(new ANTLRHashString("max", this), new Integer(14));
+	literals.put(new ANTLRHashString("false", this), new Integer(18));
+	literals.put(new ANTLRHashString("notify", this), new Integer(15));
 }
 
 public Token nextToken() throws TokenStreamException {
@@ -135,7 +138,7 @@
 				case '4':  case '5':  case '6':  case '7':
 				case '8':  case '9':
 				{
-					mINTEGER(true);
+					mDIGITS(true);
 					theRetToken=_returnToken;
 					break;
 				}
@@ -318,7 +321,7 @@
 		}
 		}
 		{
-		_loop378:
+		_loop33:
 		do {
 			switch ( LA(1)) {
 			case 'a':  case 'b':  case 'c':  case 'd':
@@ -357,7 +360,7 @@
 			}
 			default:
 			{
-				break _loop378;
+				break _loop33;
 			}
 			}
 		} while (true);
@@ -369,23 +372,23 @@
 		_returnToken = _token;
 	}
 	
-	public final void mINTEGER(boolean _createToken) throws RecognitionException, CharStreamException, TokenStreamException {
+	public final void mDIGITS(boolean _createToken) throws RecognitionException, CharStreamException, TokenStreamException {
 		int _ttype; Token _token=null; int _begin=text.length();
-		_ttype = INTEGER;
+		_ttype = DIGITS;
 		int _saveIndex;
 		
 		{
-		int _cnt381=0;
-		_loop381:
+		int _cnt36=0;
+		_loop36:
 		do {
 			if (((LA(1) >= '0' && LA(1) <= '9'))) {
 				matchRange('0','9');
 			}
 			else {
-				if ( _cnt381>=1 ) { break _loop381; } else {throw new NoViableAltForCharException((char)LA(1), getFilename(), getLine(), getColumn());}
+				if ( _cnt36>=1 ) { break _loop36; } else {throw new NoViableAltForCharException((char)LA(1), getFilename(), getLine(), getColumn());}
 			}
 			
-			_cnt381++;
+			_cnt36++;
 		} while (true);
 		}
 		if ( _createToken && _token==null && _ttype!=Token.SKIP ) {

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.g
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.g	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.g	2007-12-05 05:14:19 UTC (rev 6968)
@@ -113,7 +113,8 @@
 	;
 	
 attributeType returns [FetchAttributeType t]
-	:  "notify" { t = FetchAttributeType.NOTIFY; }
+	:  "max" { t = FetchAttributeType.MAX; }
+	 | "notify" { t = FetchAttributeType.NOTIFY; }
 	;	
 	
 positiveInteger returns [Integer i]
@@ -140,5 +141,5 @@
 SEMICOLON : ";" ;
 STAR : "*" ;
 NAME : ('a' .. 'z' | 'A' .. 'Z' | '_') ('a' .. 'z' | 'A' .. 'Z' | '0' .. '9' | '_')* ;
-INTEGER : ('0' .. '9')+ ;
+DIGITS : ('0' .. '9')+ ;
 WS : ( ' ' | '\t' | '\r' | '\n' | '\f' ) { $setType(Token.SKIP); } ;

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParser.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -111,7 +111,7 @@
 			p=propertyFetch();
 			props.add(p);
 			{
-			_loop83:
+			_loop5:
 			do {
 				if ((LA(1)==SEMICOLON)) {
 					match(SEMICOLON);
@@ -119,7 +119,7 @@
 					props.add(p);
 				}
 				else {
-					break _loop83;
+					break _loop5;
 				}
 				
 			} while (true);
@@ -266,12 +266,13 @@
 		match(LPAREN);
 		{
 		switch ( LA(1)) {
+		case LITERAL_max:
 		case LITERAL_notify:
 		{
 			a=attribute();
 			attrs.add(a);
 			{
-			_loop92:
+			_loop14:
 			do {
 				if ((LA(1)==COMMA)) {
 					match(COMMA);
@@ -279,7 +280,7 @@
 					attrs.add(a);
 				}
 				else {
-					break _loop92;
+					break _loop14;
 				}
 				
 			} while (true);
@@ -361,8 +362,24 @@
 		FetchAttributeType t;
 		
 		
-		match(LITERAL_notify);
-		t = FetchAttributeType.NOTIFY;
+		switch ( LA(1)) {
+		case LITERAL_max:
+		{
+			match(LITERAL_max);
+			t = FetchAttributeType.MAX;
+			break;
+		}
+		case LITERAL_notify:
+		{
+			match(LITERAL_notify);
+			t = FetchAttributeType.NOTIFY;
+			break;
+		}
+		default:
+		{
+			throw new NoViableAltException(LT(1), getFilename());
+		}
+		}
 		return t;
 	}
 	
@@ -418,11 +435,11 @@
 		"PLUS",
 		"STAR",
 		"EQUALS",
+		"\"max\"",
 		"\"notify\"",
 		"DIGITS",
 		"\"true\"",
 		"\"false\"",
-		"INTEGER",
 		"WS"
 	};
 	

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParserTokenTypes.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParserTokenTypes.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/parser/FetchParserTokenTypes.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -3,9 +3,11 @@
 package com.dumbhippo.dm.parser;
 
 import java.io.StringReader;
+import java.util.Collections;
 import java.util.List;
 import java.util.ArrayList;
 import com.dumbhippo.dm.fetch.*;
+import com.dumbhippo.dm.parser.ParseException;
 import com.dumbhippo.GlobalSetup;
 import org.slf4j.Logger;
 
@@ -13,19 +15,19 @@
 	int EOF = 1;
 	int NULL_TREE_LOOKAHEAD = 3;
 	int SEMICOLON = 4;
-	int LPAREN = 5;
-	int COMMA = 6;
-	int RPAREN = 7;
-	int LBRACKET = 8;
-	int RBRACKET = 9;
-	int PLUS = 10;
-	int STAR = 11;
-	int NAME = 12;
+	int LBRACKET = 5;
+	int RBRACKET = 6;
+	int LPAREN = 7;
+	int COMMA = 8;
+	int RPAREN = 9;
+	int NAME = 10;
+	int PLUS = 11;
+	int STAR = 12;
 	int EQUALS = 13;
-	int LITERAL_notify = 14;
-	int DIGITS = 15;
-	int LITERAL_true = 16;
-	int LITERAL_false = 17;
-	int INTEGER = 18;
+	int LITERAL_max = 14;
+	int LITERAL_notify = 15;
+	int DIGITS = 16;
+	int LITERAL_true = 17;
+	int LITERAL_false = 18;
 	int WS = 19;
 }

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMClassHolder.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -60,6 +60,7 @@
 	private DMPropertyHolder<K,T,?>[] properties;
 	private boolean[] mustQualify;
 	private Map<String, Integer> propertiesMap = new HashMap<String, Integer>();
+	private Map<String, Integer> feedPropertiesMap = new HashMap<String, Integer>();
 	private DMO annotation;
 
 	private Filter filter;
@@ -158,6 +159,26 @@
 		return index;
 	}
 	
+	/**
+	 * Get the index of the property with the specified name among feed-valued
+	 * properties of the class. Can be used along with getFeedPropertiesCount()
+	 * to store feed-specific data for a instance of this class in an array.
+	 */
+	public int getFeedPropertyIndex(String name) {
+		Integer index = feedPropertiesMap.get(name);
+		if (index == null)
+			return -1;
+		
+		return index;
+	}
+	
+	/**
+	 * see getFeedPropertyIndex(). 
+	 */
+	public int getFeedPropertiesCount() {
+		return feedPropertiesMap.size();
+	}
+	
 	public int getPropertyCount() {
 		return properties.length;
 	}
@@ -370,10 +391,13 @@
 		properties = tmpProperties;
 		mustQualify = new boolean[foundProperties.size()];
 
+		int feedPropertiesCount = 0;
 		for (int i = 0; i < properties.length; i++) {
 			DMPropertyHolder<?,?,?> property = properties[i];
 			
 			propertiesMap.put(property.getName(), i);
+			if (property instanceof FeedPropertyHolder)
+				feedPropertiesMap.put(property.getName(), feedPropertiesCount++);
 			mustQualify[i] = nameCount.get(property.getName()) > 1;
 		}
 	}
@@ -489,12 +513,34 @@
 		
 		wrapperCtClass.addMethod(method);
 	}
+
+	private void addFeedWrapperGetter(CtClass wrapperCtClass, DMPropertyHolder<?,?,?> property, int propertyIndex) throws CannotCompileException {
+		CtMethod wrapperMethod = new CtMethod(property.getCtClass(), property.getMethodName(), new CtClass[] {}, wrapperCtClass);
+
+		if (property.getGroup() >= 0)
+			throw new RuntimeException("Feed property '" + property.getName() + "' cannot be grouped.");
+		
+		Template body = new Template(
+				"{" +
+				"    if (!_dm_%propertyName%Initialized) {" +
+				"        _dm_init();" +
+				"        _dm_%propertyName% = _dm_session.createFeedWrapper(getStoreKey(), %propertyIndex%, super.%methodName%());" +
+				"        _dm_%propertyName%Initialized = true;" +
+				"    }" +
+				"    return _dm_%propertyName%;" +
+				"}");
+
+		body.setParameter("methodName", property.getMethodName());
+		body.setParameter("propertyName", property.getName());
+		body.setParameter("propertyIndex", Integer.toString(propertyIndex));
+		wrapperMethod.setBody(body.toString());
+		
+		wrapperCtClass.addMethod(wrapperMethod);
+	}
 	
 	private void addWrapperGetter(CtClass wrapperCtClass, DMPropertyHolder<?,?,?> property, int propertyIndex) throws CannotCompileException, NotFoundException {
 		CtMethod wrapperMethod = new CtMethod(property.getCtClass(), property.getMethodName(), new CtClass[] {}, wrapperCtClass);
 		
-		// TODO: Deal with primitive types, where we need to box/unbox
-		
 		String storeCommands;
 		int group = property.getGroup();
 		if (group < 0) {
@@ -553,7 +599,10 @@
 		for (int i = 0; i < properties.length; i++) {
 			DMPropertyHolder<?,?,?> property = properties[i];
 			
-			addWrapperGetter(wrapperCtClass, property, i);
+			if (property instanceof FeedPropertyHolder)
+				addFeedWrapperGetter(wrapperCtClass, property, i);
+			else
+				addWrapperGetter(wrapperCtClass, property, i);
 		}
 	}
 	

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMPropertyHolder.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMPropertyHolder.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/DMPropertyHolder.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -21,9 +21,11 @@
 import com.dumbhippo.GlobalSetup;
 import com.dumbhippo.StringUtils;
 import com.dumbhippo.dm.Cardinality;
+import com.dumbhippo.dm.DMFeed;
 import com.dumbhippo.dm.DMObject;
 import com.dumbhippo.dm.DMSession;
 import com.dumbhippo.dm.DMViewpoint;
+import com.dumbhippo.dm.DataModel;
 import com.dumbhippo.dm.annotations.DMFilter;
 import com.dumbhippo.dm.annotations.DMProperty;
 import com.dumbhippo.dm.annotations.PropertyType;
@@ -219,6 +221,10 @@
 		return annotation.group();
 	}
 	
+	public int getDefaultMaxFetch() {
+		return annotation.defaultMaxFetch();
+	}
+	
 	abstract public Object dehydrate(Object value);
 	abstract public Object rehydrate(DMViewpoint viewpoint, K key, Object value, DMSession session, boolean filter);
 	abstract public Object filter(DMViewpoint viewpoint, K key, Object value);
@@ -273,6 +279,7 @@
 		
 		boolean listValued = false;
 		boolean setValued = false;
+		boolean feedValued = false;
 		Class<?> elementType;
 		
 		if (genericType instanceof ParameterizedType) {
@@ -282,8 +289,10 @@
 				listValued = true;
 			else if (rawType == Set.class)
 				setValued = true;
+			else if (rawType == DMFeed.class)
+				feedValued = true;
 			else
-				throw new RuntimeException("List<?> and Set<?> are the only currently supported parameterized types");
+				throw new RuntimeException("List<?>, Set<?>, DMFeed<?> are the only currently supported parameterized types");
 				
 			if (paramType.getActualTypeArguments().length != 1)
 				throw new RuntimeException("Couldn't understand type arguments to parameterized return type");
@@ -301,14 +310,21 @@
 		DMClassInfo<?,? extends DMObject<?>> classInfo = DMClassInfo.getForClass(elementType);
 
 		if (classInfo != null) {
-			return createResourcePropertyHolder(classHolder, ctMethod, classInfo, property, filter, viewerDependent, listValued, setValued);
+			return createResourcePropertyHolder(classHolder, ctMethod, classInfo, property, filter, viewerDependent, listValued, setValued, feedValued);
 		} else if (elementType.isPrimitive() || (genericElementType == String.class) || (genericElementType == Date.class)) { 
+			if (feedValued)
+				throw new RuntimeException("Feed properties must be resource-valued");
+			
 			return createPlainPropertyHolder(classHolder, ctMethod, elementType, property, filter, viewerDependent, listValued, setValued);
 		} else {
 			throw new RuntimeException("Property type must be DMObject, primitive, Date, or String");
 		}
 	}
 	
+	public DataModel getModel() {
+		return declaringClassHolder.getModel();
+	}
+	
 	// this is somewhat silly, the unchecked is to avoid having type params on the constructor; eclipse 
 	// will let you put them on there in the way we do with most other cases like this (see below for the plain holders for example),
 	// but javac gets confused by that
@@ -335,6 +351,17 @@
 	// will let you put them on there in the way we do with most other cases like this (see below for the plain holders for example),
 	// but javac gets confused by that
 	@SuppressWarnings("unchecked")
+	private static <K, T extends DMObject<K>> FeedPropertyHolder<K,T,?,?>
+		newFeedPropertyHolderHack(DMClassHolder<K,T> classHolder, CtMethod ctMethod, DMClassInfo<?,? extends DMObject<?>> classInfo,
+			DMProperty property, DMFilter filter, ViewerDependent viewerDependent) {
+		return new FeedPropertyHolder(classHolder, ctMethod, classInfo,
+				property, filter, viewerDependent);
+	}
+	
+	// this is somewhat silly, the unchecked is to avoid having type params on the constructor; eclipse 
+	// will let you put them on there in the way we do with most other cases like this (see below for the plain holders for example),
+	// but javac gets confused by that
+	@SuppressWarnings("unchecked")
 	private static <K, T extends DMObject<K>> SingleResourcePropertyHolder<K,T,?,?>
 		newSingleResourcePropertyHolderHack(DMClassHolder<K,T> classHolder, CtMethod ctMethod, DMClassInfo<?,? extends DMObject<?>> classInfo,
 			DMProperty property, DMFilter filter, ViewerDependent viewerDependent) {
@@ -343,11 +370,13 @@
 	}
 	
 	private static <K, T extends DMObject<K>> DMPropertyHolder<K,T,?> createResourcePropertyHolder(DMClassHolder<K,T> classHolder, CtMethod ctMethod,
-			DMClassInfo<?,? extends DMObject<?>> classInfo, DMProperty property, DMFilter filter, ViewerDependent viewerDependent, boolean listValued, boolean setValued) {
+			DMClassInfo<?,? extends DMObject<?>> classInfo, DMProperty property, DMFilter filter, ViewerDependent viewerDependent, boolean listValued, boolean setValued, boolean feedValued) {
 		if (listValued)
 			return newListResourcePropertyHolderHack(classHolder, ctMethod, classInfo, property, filter, viewerDependent);
 		else if (setValued)
 			return newSetResourcePropertyHolderHack(classHolder, ctMethod, classInfo, property, filter, viewerDependent);
+		else if (feedValued)
+			return newFeedPropertyHolderHack(classHolder, ctMethod, classInfo, property, filter, viewerDependent);
 		else
 			return newSingleResourcePropertyHolderHack(classHolder, ctMethod, classInfo, property, filter, viewerDependent);
 	}
@@ -434,6 +463,17 @@
 	}
 	
 	public abstract void visitChildren(DMSession session, Fetch<?,?> children, T object, FetchVisitor visitor);
+	
+	/**
+	 * 
+	 * @param session
+	 * @param object
+	 * @param visitor
+	 * @param forceEmpty if True, call FetchVisitor.emptyProperty() on a missing property even if we
+	 *    would normally omit it. This is used for a single-valued resource property, where we normally
+	 *    don't emit any fetch result for missing properties, but must do so if we are notifying
+	 *    that the property has gone away.
+	 */
 	public abstract void visitProperty(DMSession session, T object, FetchVisitor visitor, boolean forceEmpty);
 
 	public abstract Fetch<?,?> getDefaultChildren();

Added: dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/FeedPropertyHolder.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/FeedPropertyHolder.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/FeedPropertyHolder.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,187 @@
+package com.dumbhippo.dm.schema;
+
+import java.util.Iterator;
+
+import javassist.CtMethod;
+
+import com.dumbhippo.dm.Cardinality;
+import com.dumbhippo.dm.DMClient;
+import com.dumbhippo.dm.DMFeed;
+import com.dumbhippo.dm.DMFeedItem;
+import com.dumbhippo.dm.DMObject;
+import com.dumbhippo.dm.DMSession;
+import com.dumbhippo.dm.DMViewpoint;
+import com.dumbhippo.dm.DataModel;
+import com.dumbhippo.dm.annotations.DMFilter;
+import com.dumbhippo.dm.annotations.DMProperty;
+import com.dumbhippo.dm.annotations.PropertyType;
+import com.dumbhippo.dm.annotations.ViewerDependent;
+import com.dumbhippo.dm.fetch.Fetch;
+import com.dumbhippo.dm.fetch.FetchVisitor;
+import com.dumbhippo.dm.filter.AndFilter;
+import com.dumbhippo.dm.filter.CompiledItemFilter;
+import com.dumbhippo.dm.filter.Filter;
+import com.dumbhippo.dm.filter.FilterCompiler;
+import com.dumbhippo.dm.store.DMStore;
+import com.dumbhippo.dm.store.StoreKey;
+
+public class FeedPropertyHolder<K, T extends DMObject<K>, KI, TI extends DMObject<KI>> extends ResourcePropertyHolder<K,T,KI,TI> {
+	private CompiledItemFilter<K,T,KI,TI> itemFilter;
+
+	public FeedPropertyHolder(DMClassHolder<K,T> declaringClassHolder, CtMethod ctMethod, DMClassInfo<KI,TI> classInfo, DMProperty annotation, DMFilter filter, ViewerDependent viewerDependent) {
+		super(declaringClassHolder, ctMethod, classInfo, annotation, filter, viewerDependent);
+	}
+	
+	@Override
+	public void complete() {
+		super.complete();
+		
+		Filter classFilter = getResourceClassHolder().getUncompiledItemFilter();
+		if (classFilter != null && propertyFilter == null) {
+			itemFilter = getResourceClassHolder().getItemFilter();
+		} else if (classFilter != null || propertyFilter != null) {
+			Filter toCompile;
+			if (classFilter != null)
+				toCompile = new AndFilter(classFilter, propertyFilter);
+			else
+				toCompile = propertyFilter;
+			
+			itemFilter = FilterCompiler.compileItemFilter(declaringClassHolder.getModel(), 
+													 	  declaringClassHolder.getKeyClass(), 
+														  keyType, toCompile);
+		}
+	}
+
+	@Override
+	protected PropertyType getType() {
+		return PropertyType.RESOURCE;
+	}
+
+	@Override
+	public String getUnboxPrefix() {
+		return "(com.dumbhippo.dm.DMFeed)";
+	}
+	
+	@Override
+	public String getUnboxSuffix() {
+		return "";
+	}
+
+	@Override
+	public Object dehydrate(Object value) {
+		throw new UnsupportedOperationException();
+	}
+	
+	@Override
+	public Object rehydrate(DMViewpoint viewpoint, K key, Object value, DMSession session, boolean filter) {
+		throw new UnsupportedOperationException();
+	}
+	
+	@Override
+	public Object filter(DMViewpoint viewpoint, K key, Object value) {
+		throw new UnsupportedOperationException();
+	}
+	
+	private long updateFeedTimestamp(DMSession session, T object) {
+		DMClient client = session.getClient();
+		
+		if (client != null) {
+			DMClassHolder classHolder = object.getClassHolder();
+			int feedPropertyIndex = classHolder.getFeedPropertyIndex(getName());
+			DataModel model = classHolder.getModel();
+			DMStore store = model.getStore();
+
+			@SuppressWarnings("unchecked")
+			StoreKey<K, T> storeKey = (StoreKey<K, T>)object.getStoreKey();
+		
+			return store.updateFeedTimestamp(storeKey, feedPropertyIndex, client.getStoreClient(), model.getTimestamp());
+		} else {
+			return -1;
+		}
+	}
+
+	/**
+	 * Like DMPropertyHolder.visitChildren(), but specialized for feeds. Recurses and visits
+	 * children of the feed.
+	 * 
+	 * @param session
+	 * @param children
+	 * @param start start position in feed to visit from 
+	 * @param max maximum of number of items to visit
+	 * @param object the object whose property to visit
+	 * @param visitor
+	 * @param forceAll if true, fetch everything specified by start and max, even if we have a timestamp
+	 *    indicating that we have already returned the current contents of the feed to the client
+	 */
+	@SuppressWarnings("unchecked")
+	public long visitFeedChildren(DMSession session, Fetch<?,?> children, int start, int max, T object, FetchVisitor visitor, boolean forceAll) {
+		long minTimestamp = updateFeedTimestamp(session, object);
+		if (forceAll)
+			minTimestamp = 0;
+		
+		Fetch<KI,TI> typedChildren = (Fetch<KI,TI>)children;
+		
+		Iterator<DMFeedItem<TI>> iter = ((DMFeed<TI>)getRawPropertyValue(object)).iterator(start, max, minTimestamp);
+		while (iter.hasNext()) {
+			DMFeedItem<TI> item = iter.next();
+			visitChild(session, typedChildren, item.getValue(), visitor);
+		}
+		
+		return minTimestamp;
+	}
+
+	/**
+	 * Like DMPropertyHolder.visitProperty(), but specialized for feeds. Calls the feedProperty()
+	 * method of the visitor for each visited value in the property.
+	 * 
+	 * @param session
+	 * @param start start position in feed to visit from 
+	 * @param max maximum of number of items to visit
+	 * @param object the object whose property to visit
+	 * @param visitor
+	 * @param minTimestamp minimum timestamp for items to fetch. A value of -1 means to compute
+	 *   this value from what is stored for this client and then update the stored information 
+	 *   for subsequent fetches. Any other value is used literally without updating the stored
+	 *   information. (So, '0' can be used to force fetch everything)
+	 */
+	@SuppressWarnings("unchecked")
+	public void visitFeedProperty(DMSession session, int start, int max, T object, FetchVisitor visitor, long minTimestamp) {
+		boolean seenAny = false;
+		
+		if (minTimestamp < 0)
+			minTimestamp = updateFeedTimestamp(session, object);
+		
+		boolean fetchingAll = start == 0 && minTimestamp <= 0;
+
+		Iterator<DMFeedItem<TI>> iter = ((DMFeed<TI>)getRawPropertyValue(object)).iterator(start, max, minTimestamp);
+		while (iter.hasNext()) {
+			DMFeedItem<TI> item = iter.next();
+			seenAny = true;
+			visitor.feedProperty(this, item.getValue().getKey(), item.getTime(), !fetchingAll);
+		}
+		
+		if (!seenAny && fetchingAll) {
+			visitor.emptyProperty(this);
+		}
+	}
+	
+
+	@Override
+	public void visitChildren(DMSession session, Fetch<?, ?> children, T object, FetchVisitor visitor) {
+		throw new UnsupportedOperationException("Must use visitFeedChildren");
+	}
+
+	@Override
+	public void visitProperty(DMSession session, T object, FetchVisitor visitor, boolean forceEmpty) {
+		throw new UnsupportedOperationException("Must use visitFeedProperty");
+	}
+	
+	@Override
+	public Cardinality getCardinality() {
+		return Cardinality.ANY;
+	}
+	
+	public CompiledItemFilter<K, T, KI, TI> getItemFilter() {
+		return itemFilter;
+	}
+}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/ResourcePropertyHolder.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/ResourcePropertyHolder.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/schema/ResourcePropertyHolder.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -100,7 +100,7 @@
 	}
 
 	@SuppressWarnings("unchecked")
-	protected TI rehydrateDMO(Object value, DMSession session) {
+	public TI rehydrateDMO(Object value, DMSession session) {
 		@SuppressWarnings("unchecked")
 		KI key = (KI)value;
 		return session.findUnchecked(objectType, key);

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/store/DMStore.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/store/DMStore.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/store/DMStore.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -7,6 +7,7 @@
 import org.slf4j.Logger;
 
 import com.dumbhippo.GlobalSetup;
+import com.dumbhippo.dm.CachedFeed;
 import com.dumbhippo.dm.ClientMatcher;
 import com.dumbhippo.dm.ClientNotificationSet;
 import com.dumbhippo.dm.DMClient;
@@ -103,6 +104,17 @@
 		// It's not a problem if the node is evicted; the data just won't be stored this time around
 		node.store(propertyIndex, value, timestamp);
 	}
+	
+	/**
+	 * Get a CachedFeed object for the specified property. If one doesn't already exist in the cache,
+	 * it will be created and stored there. However, if the passed-in-timestamp is older than
+	 * the invalidation timestamp for the property, null is returned. 
+	 */
+	public <K, T extends DMObject<K>> CachedFeed<?> getOrCreateCachedFeed(StoreKey<K,T> key, int propertyIndex, long timestamp) {
+		StoreNode<K,T> node = ensureNode(key);
+		
+		return node.getOrCreateCachedFeed(propertyIndex, timestamp);
+	}
 
 	public <K, T extends DMObject<K>> void invalidate(DMClassHolder<K,T> classHolder, K key, int propertyIndex, long timestamp) {
 		// We need to make sure that we store the timestamp to deal with in-flight 
@@ -129,6 +141,25 @@
 		} while (node.isEvicted());
 	}
 
+	/**
+	 * Invalidate the cache when a feed-valued property is changed. In addition to the normal actions
+	 * of clearing cached values and updating the stored invalidation timestamp, we also maintain
+	 * a log of the changed item timestamps by txTimestamp so we can tell which items must be resent
+	 * in the resulting notification. 
+	 * 
+	 * @param txTimestamp transaction timestamp *after* the read-write transaction that modified the feed
+	 * @param itemTimestamp minimum new feed-item timestamp for all the items modified in the feed; a value
+	 *   of -1 should be used if the change involved deleting items. 
+	 */
+	public <K, T extends DMObject<K>> void invalidateFeed(DMClassHolder<K,T> classHolder, K key, int propertyIndex, long txTimestamp, long itemTimestamp) {
+		// See comment above
+		StoreNode<K,T> node;
+		do {
+			node = ensureNode(classHolder, key);
+			node.invalidateFeed(propertyIndex, txTimestamp, itemTimestamp);
+		} while (node.isEvicted());
+	}
+	
 	public <K, T extends DMObject<K>>  void resolveNotifications(DMClassHolder<K,T> classHolder, K key, long propertyMask, ClientNotificationSet result, ClientMatcher matcher) {
 		StoreNode<K,T> node = getNode(classHolder, key);
 		if (node == null)
@@ -164,6 +195,18 @@
 		return oldFetch;
 	}
 	
+	public <K, T extends DMObject<K>> long updateFeedTimestamp(StoreKey<K, T> storeKey, int feedPropertyIndex, StoreClient client, long newTimestamp) {
+		StoreNode<K,T> node;
+		Registration<K,T> registration;
+		
+		do {
+			node = ensureNode(storeKey);
+			registration = node.createRegistration(client);
+		} while (registration == null);
+		
+		return registration.updateFeedTimestamp(feedPropertyIndex, newTimestamp);
+	}
+	
 	public <K, T extends DMObject<K>> void removeRegistration(DMClassHolder<K,T> classHolder, K key, StoreClient client) {
 		StoreNode<K,T> node = getNode(classHolder, key);
 		if (node == null)

Added: dumbhippo/trunk/server/src/com/dumbhippo/dm/store/FeedLog.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/store/FeedLog.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/store/FeedLog.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,79 @@
+package com.dumbhippo.dm.store;
+
+/**
+ * This class maintains a log that indicates the (minimum) item timestamp of
+ * an entry in a feed that changed for each transaction timestamp. This can
+ * be used to determine what portion of a feed needs to be resent to a consumer
+ * of the feed, if we know the txTimestamp of the transaction that was last
+ * used to update them on the feed state.
+ * 
+ * Note that because the way txTimestamps work the resending is conservative: we
+ * can tell what items might have changed after the start of the given transaction, 
+ * but we can't tell if they actually did or not.
+ * 
+ * An itemTimestamp of -1 in the log has a special significance: it means that
+ * the feed changed in some other way than inserting or restacking a feed item,
+ * so the entire feed will need to be resent from scratch. 
+ * 
+ * @author otaylor
+ */
+public class FeedLog {
+	private static final int INITIALIZE_SIZE = 10;
+
+	private long txTimestamps[];
+	private long itemTimestamps[];
+	private int entryCount = 0;
+	
+	public synchronized void addEntry(long txTimestamp, long itemTimestamp) {
+		int insertionPos = entryCount;
+		while (insertionPos > 0 && txTimestamps[insertionPos - 1] >= txTimestamp)
+			insertionPos--;
+		
+		/* If the txTimestamp is the same as one already in the log, we just replace
+		 * the item timestamp for that log entry.
+		 */
+		if (insertionPos < entryCount && txTimestamps[insertionPos] == txTimestamp) {
+			if (itemTimestamps[insertionPos] > itemTimestamp)
+				itemTimestamps[insertionPos] = itemTimestamp;
+			return;
+		}
+		
+		if (entryCount == 0) {
+			txTimestamps = new long[INITIALIZE_SIZE];
+			itemTimestamps = new long[INITIALIZE_SIZE];
+		} else if (entryCount == txTimestamps.length) {
+			int newSize = entryCount * 2;
+			if (newSize < 0)
+				throw new OutOfMemoryError();
+			
+			long newTxTimestamps[] = new long[newSize];
+			System.arraycopy(txTimestamps, 0, newTxTimestamps, 0, entryCount);
+			txTimestamps = newTxTimestamps;
+			
+			long newItemTimestamps[] = new long[newSize];
+			System.arraycopy(itemTimestamps, 0, newItemTimestamps, 0, entryCount);
+			itemTimestamps = newItemTimestamps;
+		}
+		
+		System.arraycopy(txTimestamps, insertionPos, txTimestamps, insertionPos + 1, entryCount - insertionPos);
+		System.arraycopy(itemTimestamps, insertionPos, itemTimestamps, insertionPos + 1, entryCount - insertionPos);
+		entryCount++;
+		
+		txTimestamps[insertionPos] = txTimestamp;
+		itemTimestamps[insertionPos] = itemTimestamp;
+	}
+	
+	public synchronized long getMinItemTimestamp(long txTimestamp) {
+		long minTimestamp = Long.MAX_VALUE;
+		
+		for (int i = entryCount - 1; i >= 0; i--) {
+			if (txTimestamps[i] < txTimestamp)
+				break;
+			
+			if (itemTimestamps[i] < minTimestamp)
+				minTimestamp = itemTimestamps[i];
+		}
+		
+		return minTimestamp;
+	}
+}

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/store/Registration.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/store/Registration.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/store/Registration.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -7,6 +7,7 @@
 	private StoreClient client;
 	private StoreNode<K,T> node;
 	private Fetch<K,? super T> fetch;
+	private long[] feedTxTimestamps;
 	
 	public Registration(StoreNode<K,T> node, StoreClient client) {
 		this.node = node;
@@ -25,7 +26,8 @@
 		Fetch<K,? super T> oldFetch = fetch;
 		
 		if (oldFetch != null) {
-			Fetch<K, ? super T> mergedFetch = oldFetch.merge(newFetch);
+			@SuppressWarnings("unchecked")
+			Fetch<K, ? super T> mergedFetch = (Fetch<K, ? super T>)oldFetch.merge(newFetch);
 			fetch = mergedFetch;
 		} else
 			fetch = newFetch;
@@ -40,4 +42,44 @@
 	public synchronized Fetch<K,? super T> getFetch() {
 		return fetch;
 	}
+
+	public long updateFeedTimestamp(int feedPropertyIndex, long newTimestamp) {
+		/* We can have the situation where:
+		 * 
+		 *   T1) Notification updates the timestamp and fetches new items
+		 *   T2) Fetch finds no new items with the updated timestamp
+		 *   T2) Fetch returns nothing
+		 *   T1) Notification is sent out
+		 *
+		 */
+		long oldTimestamp;
+		
+		// Synchronizing the entire method would risk a deadlock, because of the
+		// call to the synchronized StoreNode.getFeedLock() below
+		synchronized(this) {
+			if (feedTxTimestamps == null) {
+				feedTxTimestamps = new long[node.getClassHolder().getFeedPropertiesCount()];
+				for (int i = 0; i < feedTxTimestamps.length; i++)
+					feedTxTimestamps[i] = -1;
+			}
+			
+			oldTimestamp = feedTxTimestamps[feedPropertyIndex];
+			feedTxTimestamps[feedPropertyIndex] = newTimestamp;
+		}
+		
+		long result;
+		if (oldTimestamp == -1) {
+			// -1 is the "haven't previously updated the timestamp" timestamp, so we use 0
+			// to signal "everything"
+			result = 0;
+		} else {
+			FeedLog log = node.getFeedLog(feedPropertyIndex);
+			if (log != null)
+				result = log.getMinItemTimestamp(oldTimestamp);
+			else
+				result = Long.MAX_VALUE;
+		}
+		
+		return result;
+	}
 }

Modified: dumbhippo/trunk/server/src/com/dumbhippo/dm/store/StoreNode.java
===================================================================
--- dumbhippo/trunk/server/src/com/dumbhippo/dm/store/StoreNode.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/src/com/dumbhippo/dm/store/StoreNode.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -8,11 +8,13 @@
 import org.slf4j.Logger;
 
 import com.dumbhippo.GlobalSetup;
+import com.dumbhippo.dm.CachedFeed;
 import com.dumbhippo.dm.ClientMatcher;
 import com.dumbhippo.dm.ClientNotificationSet;
 import com.dumbhippo.dm.DMObject;
 import com.dumbhippo.dm.NotCachedException;
 import com.dumbhippo.dm.schema.DMClassHolder;
+import com.dumbhippo.dm.schema.DMPropertyHolder;
 
 public class StoreNode<K,T extends DMObject<K>> extends StoreKey<K,T> {
 	@SuppressWarnings("unused")
@@ -23,6 +25,7 @@
 	private long timestamp = -1;
 	private Object[] properties;
 	private List<Registration<K,T>> registrations;
+	private FeedLog[] feedLogs;
 	private boolean evicted;
 
 	StoreNode(DMClassHolder<K,T> classHolder, K key) {
@@ -46,11 +49,38 @@
 		properties[propertyIndex] = value != null ? value : nil;
 	}
 	
+	public synchronized CachedFeed<?> getOrCreateCachedFeed(int propertyIndex, long timestamp) {
+		if (this.timestamp > timestamp)
+			return null;
+		
+		if (properties[propertyIndex] == null) {
+			// Actual key type is irrelevant
+			properties[propertyIndex] = new CachedFeed<Object>();
+		}
+		
+		return (CachedFeed<?>)properties[propertyIndex];
+	}
+
 	public synchronized void invalidate(int propertyIndex, long timestamp) {
 		this.timestamp = timestamp;
 		properties[propertyIndex] = null;
 	}
 	
+	public synchronized void invalidateFeed(int propertyIndex, long txTimestamp, long itemTimestamp) {
+		invalidate(propertyIndex, timestamp);
+		
+		DMPropertyHolder<K, T, ?> property = classHolder.getProperty(propertyIndex);
+		int feedPropertyIndex = classHolder.getFeedPropertyIndex(property.getName());
+		
+		if (feedLogs == null)
+			feedLogs = new FeedLog[classHolder.getFeedPropertiesCount()];
+		
+		if (feedLogs[feedPropertyIndex] == null)
+			feedLogs[feedPropertyIndex] = new FeedLog();
+		
+		feedLogs[feedPropertyIndex].addEntry(txTimestamp, itemTimestamp);
+	}
+	
 	public synchronized Collection<Registration<K,T>> markEvicted() {
 		evicted = true;
 		return registrations;
@@ -118,4 +148,11 @@
 			registration.getFetch().resolveNotifications(registration.getClient(), this, properties, result);
 		}
 	}
+
+	public FeedLog getFeedLog(int feedPropertyIndex) {
+		if (feedLogs == null)
+			return null;
+		else
+			return feedLogs[feedPropertyIndex];
+	}
 }

Added: dumbhippo/trunk/server/tests/com/dumbhippo/dm/AbstractFetchTests.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/AbstractFetchTests.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/AbstractFetchTests.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,134 @@
+package com.dumbhippo.dm;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.slf4j.Logger;
+import org.xml.sax.SAXException;
+import org.xml.sax.SAXParseException;
+
+import com.dumbhippo.GlobalSetup;
+import com.dumbhippo.XmlBuilder;
+import com.dumbhippo.dm.fetch.Fetch;
+import com.dumbhippo.dm.fetch.FetchNode;
+import com.dumbhippo.dm.parser.FetchParser;
+import com.dumbhippo.dm.parser.ParseException;
+
+public class AbstractFetchTests extends AbstractSupportedTests {
+	static private final Logger logger = GlobalSetup.getLogger(AbstractFetchTests.class);
+
+	private Map<String, FetchResult> expectedResults;
+	private String filename;
+
+	protected AbstractFetchTests(String filename) {
+		this.filename = filename;
+	}
+	
+	@Override
+	protected void setUp() {
+		super.setUp();
+		
+		if (expectedResults == null)
+			readFetchResults();
+	}
+	
+	private void readFetchResults() {
+		expectedResults = new HashMap<String, FetchResult>();
+		
+		URL resource = this.getClass().getResource("/" + filename);
+		if (resource == null)
+			throw new RuntimeException("Cannot find " + filename);
+		
+		try {
+			InputStream input = resource.openStream();
+			for (FetchResult result : FetchResultHandler.parse(input)) {
+				expectedResults.put(result.getId(), result);
+			}
+			input.close();
+		} catch (IOException e) {
+			throw new RuntimeException("Error reading " + filename, e);
+		} catch (SAXException e) {
+			if (e instanceof SAXParseException) {
+				SAXParseException pe = (SAXParseException)e;
+				logger.error("{}:{}:{}: {}",
+						     new Object[] { filename, pe.getLineNumber(), pe.getColumnNumber(), pe.getMessage() });
+				throw new RuntimeException("Cannot parse " + filename, e);
+			}
+			
+			throw new RuntimeException("Error parsing " + filename, e);
+		}
+	}
+	
+	// Basic test of the test infrastructure; load all the fetch results, test
+	// that they can be converted to XML and back and that the result of the
+	// round-trip validates as the same thing as the original.
+	public void testRoundTrip() {
+		for (FetchResult result : expectedResults.values()) {
+			XmlBuilder builder = new XmlBuilder();
+			builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
+			builder.openElement("fetchResults");
+			result.writeToXmlBuilder(builder);
+			builder.closeElement();
+			
+			String asString = builder.toString();
+			
+			FetchResult roundTripped;
+			try {
+				InputStream input = new ByteArrayInputStream(asString.getBytes("UTF-8"));
+				List<FetchResult> results = FetchResultHandler.parse(input);
+				if (results.size() != 1)
+					throw new RuntimeException("Round-trip of " + result.getId() + " to FetchResult gave " + results.size() + " results!");
+				roundTripped = results.get(0);
+				input.close();
+				
+			} catch (IOException e) {
+				throw new RuntimeException("Error parsing recoverted " + result.getId(), e);
+			} catch (SAXException e) {
+				if (e instanceof SAXParseException) {
+					SAXParseException pe = (SAXParseException)e;
+					logger.error("<reconverted>:{}:{}: {}",
+							     new Object[] { pe.getLineNumber(), pe.getColumnNumber(), pe.getMessage() });
+					throw new RuntimeException("Cannot parse recoverted " + result.getId(), e);
+				}
+				
+				throw new RuntimeException("Error parsing recoverted " + result.getId(), e);
+			}
+			
+			try {
+				roundTripped.validateAgainst(result);
+			} catch (FetchValidationException e) {
+				throw new RuntimeException("Round-trip of " + result.getId() + " didn't validate " + e, e);
+			}
+		}
+	}
+	
+	protected FetchResult getExpected(String resultId, String... parameters) {
+		Map<String, String> parametersMap = new HashMap<String, String>();
+		for (int i = 0; i  < parameters.length; i += 2)
+			parametersMap.put(parameters[i], parameters[i + 1]);
+		
+		FetchResult raw = expectedResults.get(resultId);
+		if (raw == null)
+			throw new RuntimeException("No expected result set with id='" + resultId + "'");
+		
+		return raw.substitute(parametersMap);
+	}
+	
+	protected <K,T extends DMObject<K>> void doFetchTest(Class<K> keyClass, Class<T> objectClass, T object, String fetchString, String resultId, String... parameters) throws ParseException, FetchValidationException {
+		FetchNode fetchNode = FetchParser.parse(fetchString);
+		Fetch<K,T> fetch = fetchNode.bind(support.getModel().getClassHolder(keyClass, objectClass));
+		
+		FetchResultVisitor visitor = new FetchResultVisitor();
+		support.currentSessionRO().visitFetch(object, fetch, visitor);
+		
+		FetchResult expected = getExpected(resultId, parameters);
+		
+		logger.debug("Result for {} is {}", resultId, visitor.getResult());
+		visitor.getResult().validateAgainst(expected);
+	}
+}

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/AllTests.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/AllTests.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/AllTests.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -23,6 +23,8 @@
         suite.addTest(new TestSuite(ThreadingTests.class));
         suite.addTest(new TestSuite(FilterTests.class));
         
+        suite.addTest(new TestSuite(FeedTests.class));
+
         return suite;
     }
 }

Added: dumbhippo/trunk/server/tests/com/dumbhippo/dm/FeedTests.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/FeedTests.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/FeedTests.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,183 @@
+package com.dumbhippo.dm;
+
+import java.text.DateFormat;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Iterator;
+
+import javax.persistence.EntityManager;
+
+import org.slf4j.Logger;
+
+import com.dumbhippo.GlobalSetup;
+import com.dumbhippo.dm.dm.TestBlogEntryDMO;
+import com.dumbhippo.dm.dm.TestUserDMO;
+import com.dumbhippo.dm.persistence.TestBlogEntry;
+import com.dumbhippo.dm.persistence.TestUser;
+import com.dumbhippo.identity20.Guid;
+
+public class FeedTests extends AbstractFetchTests {
+	@SuppressWarnings("unused")
+	static private final Logger logger = GlobalSetup.getLogger(FeedTests.class);
+
+	private static DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z");
+	
+	private static Date parseDate(String str) {
+		try {
+			return DATE_FORMAT.parse(str);
+		} catch (ParseException e) {
+			throw new RuntimeException(e);
+		}
+	}
+	
+	private static Date DATE1 = parseDate("2006-06-23 16:01:32 -0400");
+	private static Date DATE2 = parseDate("2006-11-14 15:31:52 -0400");
+	private static Date DATE3 = parseDate("2007-03-23 09:44:04 -0400");
+	
+	public FeedTests() {
+		super("feed-fetch-tests.xml");
+	}
+
+	private void createData(Guid bobId) {
+		EntityManager em = support.beginTransaction();
+
+		TestUser bob = new TestUser("Bob");
+		bob.setId(bobId.toString());
+		em.persist(bob);
+		
+		TestBlogEntry entry1 = new TestBlogEntry(bob, 1, DATE1);
+		entry1.setTitle("My Life");
+		em.persist(entry1);
+
+		TestBlogEntry entry2 = new TestBlogEntry(bob, 2, DATE2);
+		entry2.setTitle("Stupid Alligator Tricks");
+		em.persist(entry2);
+		
+		em.getTransaction().commit();
+	}
+	
+	private void addBlogEntry(Guid bobId) {
+		EntityManager em = support.beginSessionRW(new TestViewpoint(bobId));
+
+		TestUser bob = em.find(TestUser.class, bobId.toString());
+		
+		TestBlogEntry entry3 = new TestBlogEntry(bob, 3, DATE3);
+		entry3.setTitle("My 9-fingered Life");
+		em.persist(entry3);
+		
+		support.currentSessionRW().feedChanged(TestUserDMO.class, bob.getGuid(), "blogEntries", DATE3.getTime());
+		
+		em.getTransaction().commit();
+	}
+	
+	public void testBasicFeed() throws Exception {
+		TestViewpoint viewpoint = new TestViewpoint(Guid.createNew());
+		EntityManager em;
+		ReadOnlySession session;
+		
+		/////////////////////////////////////////////////
+		// Setup
+
+		Guid bobId = Guid.createNew();
+		
+		createData(bobId);
+		
+		//////////////////////////////////////////////////
+		
+		em = support.beginSessionRO(viewpoint);
+		session = support.currentSessionRO();
+		
+		TestUserDMO bobDMO = session.find(TestUserDMO.class, bobId);
+		assertEquals("Bob", bobDMO.getName());
+		
+		DMFeed<TestBlogEntryDMO> blogEntries = bobDMO.getBlogEntries();
+		
+		Iterator<DMFeedItem<TestBlogEntryDMO>> iter;
+
+		// Fetch just one item
+		iter = blogEntries.iterator(0, 1, -1);
+		assertEquals(iter.next().getValue().getTimestamp(), DATE2.getTime());
+		assertFalse(iter.hasNext());
+		
+		// Fetch all the items, the first will be cached, the second newly fetched
+		iter = blogEntries.iterator(0, 10, -1);
+		assertEquals(iter.next().getValue().getTimestamp(), DATE2.getTime());
+		assertEquals(iter.next().getValue().getTimestamp(), DATE1.getTime());
+		assertFalse(iter.hasNext());
+
+		em.getTransaction().commit();
+		
+		//////////////////////////////////////////////////
+		
+		// Try adding another entry, check if we see the proper new contents
+		
+		addBlogEntry(bobId);
+
+		em = support.beginSessionRO(viewpoint);
+		session = support.currentSessionRO();
+		
+		bobDMO = session.find(TestUserDMO.class, bobId);
+		assertEquals("Bob", bobDMO.getName());
+		
+		blogEntries = bobDMO.getBlogEntries();
+		
+		iter = blogEntries.iterator(0, 10, -1);
+		assertEquals(iter.next().getValue().getTimestamp(), DATE3.getTime());
+		assertEquals(iter.next().getValue().getTimestamp(), DATE2.getTime());
+		assertEquals(iter.next().getValue().getTimestamp(), DATE1.getTime());
+		assertFalse(iter.hasNext());
+		
+		em.getTransaction().commit();
+	}
+	
+	public void testFeedFetch() throws Exception {
+		TestViewpoint viewpoint = new TestViewpoint(Guid.createNew());
+		TestDMClient client = new TestDMClient(support.getModel(), viewpoint.getViewerId());
+		EntityManager em;
+		ReadOnlySession session;
+		
+		/////////////////////////////////////////////////
+		// Setup
+
+		Guid bobId = Guid.createNew();
+		
+		createData(bobId);
+		
+		//////////////////////////////////////////////////
+		
+		em = support.beginSessionRO(client);
+		session = support.currentSessionRO();
+		
+		TestUserDMO bobDMO = session.find(TestUserDMO.class, bobId);
+		assertEquals("Bob", bobDMO.getName());
+		
+		// Fetch just one item (the defaultMaxFetch for this property)
+		doFetchTest(Guid.class, TestUserDMO.class, bobDMO, "blogEntries title", "bobsFirstFeed",
+					"bob", bobId.toString());
+		
+		// Fetch all available items
+		doFetchTest(Guid.class, TestUserDMO.class, bobDMO, "blogEntries(max=10) title", "bobsOlderFeed",
+					"bob", bobId.toString());
+		
+		// Fetching again with the same maximum should give us nothing. 
+		doFetchTest(Guid.class, TestUserDMO.class, bobDMO, "blogEntries(max=10) title", "bobsAlreadyFetchedFeed",
+					"bob", bobId.toString());
+
+		em.getTransaction().commit();
+		
+		
+		// Add an entry, see if we get the right notification
+		
+		addBlogEntry(bobId);
+
+		support.getModel().waitForAllNotifications();
+		
+		assertNotNull(client.getLastNotification());
+		
+		logger.debug("Notification from addition of feed entry is {}", client.getLastNotification());
+		
+		FetchResult expected = getExpected("bobsFeedNotification", "bob", bobId.toString());
+		client.getLastNotification().validateAgainst(expected);
+	}
+}

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchParserTests.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchParserTests.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchParserTests.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -17,6 +17,7 @@
 		expectIdentity("contact +");
 		expectIdentity("name;contact [name;photoUrl]");
 		expectIdentity("name(notify=false) [name;photoUrl(notify=false)]");
+		expectIdentity("blogEntries(max=10) [title;description]");
 		expectIdentity("a;b [a;b [a;b]]");
 		
 		expectSuccess("member()[]", "member");

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultHandler.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultHandler.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultHandler.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -161,18 +161,25 @@
 
 	private void startPropertyElement(String uri, String localName, Attributes attributes) throws SAXParseException {
 		String resourceId = null;
+		long timestamp = -1;
 		
 		for (int i = 0; i < attributes.getLength(); i++) {
 			if (MUGSHOT_SYSTEM_NS.equals(attributes.getURI(i))) {
 				String name = attributes.getLocalName(i);
 				if ("resourceId".equals(name))
 					resourceId = resolveResourceId(attributes.getValue(i));
+				else if ("ts".equals(name))
+					timestamp = Long.parseLong(attributes.getValue(i));
+				
 			} 
 		}
 		
-		if (resourceId != null)
-			currentProperty = FetchResultProperty.createResource(localName, uri, resourceId);
-		else {
+		if (resourceId != null) {
+			if (timestamp >= 0)
+				currentProperty = FetchResultProperty.createFeed(localName, uri, resourceId, timestamp);
+			else
+				currentProperty = FetchResultProperty.createResource(localName, uri, resourceId);
+		} else {
 			currentProperty = FetchResultProperty.createSimple(localName, uri);
 			currentChars = new StringBuilder();
 		}

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultProperty.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultProperty.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultProperty.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -10,21 +10,27 @@
 	private String propertyId;
 	private String value;
 	private boolean resource;
+	private long timestamp;
 
 	static public FetchResultProperty createSimple(String name, String namespace) {
-		return new FetchResultProperty(name, namespace, null, false);
+		return new FetchResultProperty(name, namespace, null, false, 1);
 	}
 	
 	static public FetchResultProperty createResource(String name, String namespace, String resourceId) {
-		return new FetchResultProperty(name, namespace, resourceId, true);
+		return new FetchResultProperty(name, namespace, resourceId, true, -1);
 	}
 	
-	private FetchResultProperty(String name, String namespace, String value, boolean resource) {
+	public static FetchResultProperty createFeed(String name, String namespace, String resourceId, long timestamp) {
+		return new FetchResultProperty(name, namespace, resourceId, true, timestamp);
+	}
+	
+	private FetchResultProperty(String name, String namespace, String value, boolean resource, long timestamp) {
 		this.name = name;
 		this.namespace = namespace;
 		this.propertyId = namespace + "#" + name;
 		this.value = value;
 		this.resource = resource;
+		this.timestamp = timestamp;
 	}
 
 	public String getName() {
@@ -47,10 +53,16 @@
 		return resource;
 	}
 	
+	public long getTimestamp() {
+		return timestamp;
+	}
+	
 	public void writeToXmlBuilder(XmlBuilder builder, String defaultNS) {
 		String ns = defaultNS.equals(namespace) ? null : namespace;
 		
-		if (resource)
+		if (resource && timestamp >= 0)
+			builder.appendEmptyNode(name, "m:resourceId", value, "m:ts", Long.toString(timestamp), "xmlns", ns);
+		else if (resource)
 			builder.appendEmptyNode(name, "m:resourceId", value, "xmlns", ns);
 		else
 			builder.appendTextNode(name, value, "xmlns", ns);
@@ -65,12 +77,18 @@
 
 		if (resource != other.resource)
 			throw new FetchValidationException("%s:%s expected isResource %s, got %s", context, propertyId,  other.resource, resource);
+		
+		if (timestamp != other.timestamp)
+			throw new FetchValidationException("%s:%s expected timestamp %ld, got %ld", context, propertyId, other.timestamp, timestamp);
 	}
 
 	public FetchResultProperty substitute(Map<String, String> parametersMap) {
 		if (resource) {
 			String newResourceId = FetchResultResource.substituteResourceId(value, parametersMap);
-			return createResource(name, namespace, newResourceId);
+			if (timestamp >= 0)
+				return createFeed(name, namespace, newResourceId, timestamp);
+			else
+				return createResource(name, namespace, newResourceId);
 		} else {
 			return this;
 		}

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultVisitor.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultVisitor.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchResultVisitor.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -41,6 +41,14 @@
 		currentResource.addProperty(property);
 	}
 	
+	public <KP, TP extends DMObject<KP>> void feedProperty(ResourcePropertyHolder<?, ?, KP, TP> propertyHolder, KP key, long timestamp, boolean incremental) {
+		DMClassHolder<KP,TP> classHolder = propertyHolder.getResourceClassHolder();
+		String resourceId = classHolder.makeResourceId(key);
+		
+		FetchResultProperty property = FetchResultProperty.createFeed(propertyHolder.getName(), propertyHolder.getNameSpace(), resourceId, timestamp);
+		currentResource.addProperty(property);
+	}
+	
 	public FetchResult getResult() {
 		return result;
 	}

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchTests.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchTests.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/FetchTests.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -1,144 +1,23 @@
 package com.dumbhippo.dm;
 
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
 import javax.persistence.EntityManager;
 
 import org.slf4j.Logger;
-import org.xml.sax.SAXException;
-import org.xml.sax.SAXParseException;
 
 import com.dumbhippo.GlobalSetup;
-import com.dumbhippo.XmlBuilder;
 import com.dumbhippo.dm.dm.TestGroupDMO;
-import com.dumbhippo.dm.fetch.Fetch;
-import com.dumbhippo.dm.fetch.FetchNode;
-import com.dumbhippo.dm.parser.FetchParser;
-import com.dumbhippo.dm.parser.ParseException;
 import com.dumbhippo.dm.persistence.TestGroup;
 import com.dumbhippo.dm.persistence.TestGroupMember;
 import com.dumbhippo.dm.persistence.TestUser;
-import com.dumbhippo.dm.schema.DMClassHolder;
-import com.dumbhippo.dm.schema.DMClassInfo;
 import com.dumbhippo.identity20.Guid;
 
-public class FetchTests extends AbstractSupportedTests {
+public class FetchTests extends AbstractFetchTests {
 	static private final Logger logger = GlobalSetup.getLogger(BasicTests.class);
-	private Map<String, FetchResult> expectedResults;
 	
-	@Override
-	protected void setUp() {
-		super.setUp();
-		
-		if (expectedResults == null) {
-			expectedResults = new HashMap<String, FetchResult>();
-			
-			URL resource = this.getClass().getResource("/fetch-tests.xml");
-			if (resource == null)
-				throw new RuntimeException("Cannot find fetch-tests.xml");
-			
-			try {
-				InputStream input = resource.openStream();
-				for (FetchResult result : FetchResultHandler.parse(input)) {
-					expectedResults.put(result.getId(), result);
-				}
-				input.close();
-			} catch (IOException e) {
-				throw new RuntimeException("Error reading fetch-tests.xml", e);
-			} catch (SAXException e) {
-				if (e instanceof SAXParseException) {
-					SAXParseException pe = (SAXParseException)e;
-					logger.error("fetch-tests.xml:{}:{}: {}",
-							     new Object[] { pe.getLineNumber(), pe.getColumnNumber(), pe.getMessage() });
-					throw new RuntimeException("Cannot parse fetch-tests.xml", e);
-				}
-				
-				throw new RuntimeException("Error parsing fetch-tests.xml", e);
-			}
-		}
+	public FetchTests() {
+		super("fetch-tests.xml");
 	}
 	
-	// Basic test of the test infrastructure; load all the fetch results, test
-	// that they can be converted to XML and back and that the result of the
-	// round-trip validates as the same thing as the original.
-	public void testRoundTrip() {
-		for (FetchResult result : expectedResults.values()) {
-			XmlBuilder builder = new XmlBuilder();
-			builder.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
-			builder.openElement("fetchResults");
-			result.writeToXmlBuilder(builder);
-			builder.closeElement();
-			
-			String asString = builder.toString();
-			
-			FetchResult roundTripped;
-			try {
-				InputStream input = new ByteArrayInputStream(asString.getBytes("UTF-8"));
-				List<FetchResult> results = FetchResultHandler.parse(input);
-				if (results.size() != 1)
-					throw new RuntimeException("Round-trip of " + result.getId() + " to FetchResult gave " + results.size() + " results!");
-				roundTripped = results.get(0);
-				input.close();
-				
-			} catch (IOException e) {
-				throw new RuntimeException("Error parsing recoverted " + result.getId(), e);
-			} catch (SAXException e) {
-				if (e instanceof SAXParseException) {
-					SAXParseException pe = (SAXParseException)e;
-					logger.error("fetch-tests.xml:{}:{}: {}",
-							     new Object[] { pe.getLineNumber(), pe.getColumnNumber(), pe.getMessage() });
-					throw new RuntimeException("Cannot parse recoverted " + result.getId(), e);
-				}
-				
-				throw new RuntimeException("Error parsing recoverted " + result.getId(), e);
-			}
-			
-			try {
-				roundTripped.validateAgainst(result);
-			} catch (FetchValidationException e) {
-				throw new RuntimeException("Round-trip of " + result.getId() + " didn't validate " + e, e);
-			}
-		}
-	}
-	
-	public FetchResult getExpected(String resultId, String... parameters) {
-		Map<String, String> parametersMap = new HashMap<String, String>();
-		for (int i = 0; i  < parameters.length; i += 2)
-			parametersMap.put(parameters[i], parameters[i + 1]);
-		
-		FetchResult raw = expectedResults.get(resultId);
-		if (raw == null)
-			throw new RuntimeException("No expected result set with id='" + resultId + "'");
-		
-		return raw.substitute(parametersMap);
-	}
-	
-	// Hack to work around generic system
-	public <K,T extends DMObject<K>> Fetch<?,?> bindToClass(FetchNode fetchNode, DMClassInfo<K,T> classInfo) {
-		@SuppressWarnings("unchecked")
-		DMClassHolder<K,T> classHolder = (DMClassHolder<K,T>) support.getModel().getClassHolder(classInfo.getObjectClass());
-		return fetchNode.bind(classHolder);
-	}
-	
-	public <K,T extends DMObject<K>> void doTest(Class<K> keyClass, Class<T> objectClass, T object, String fetchString, String resultId, String... parameters) throws ParseException, FetchValidationException {
-		FetchNode fetchNode = FetchParser.parse(fetchString);
-		Fetch<K,T> fetch = fetchNode.bind(support.getModel().getClassHolder(keyClass, objectClass));
-		
-		FetchResultVisitor visitor = new FetchResultVisitor();
-		support.currentSessionRO().visitFetch(object, fetch, visitor);
-		
-		FetchResult expected = getExpected(resultId, parameters);
-		
-		logger.debug("Result for {} is {}", resultId, visitor.getResult());
-		visitor.getResult().validateAgainst(expected);
-	}
-
 	private void createData(Guid bobId, Guid janeId, Guid groupId) {
 		EntityManager em;
 		
@@ -191,7 +70,7 @@
 		em = support.beginSessionRO(viewpoint);
 		
 		TestGroupDMO groupDMO = support.currentSessionRO().find(TestGroupDMO.class, groupId);
-		doTest(Guid.class, TestGroupDMO.class, groupDMO, "name;members member name", "bobAndJane",
+		doFetchTest(Guid.class, TestGroupDMO.class, groupDMO, "name;members member name", "bobAndJane",
 				"group", groupId.toString(),
 				"bob", bobId.toString(),
 				"jane", janeId.toString());
@@ -218,7 +97,7 @@
 		em = support.beginSessionRO(viewpoint);
 		
 		TestGroupDMO groupDMO = support.currentSessionRO().find(TestGroupDMO.class, groupId);
-		doTest(Guid.class, TestGroupDMO.class, groupDMO, "+;members +", "bobAndJane",
+		doFetchTest(Guid.class, TestGroupDMO.class, groupDMO, "+;members +", "bobAndJane",
 				"group", groupId.toString(),
 				"bob", bobId.toString(),
 				"jane", janeId.toString());
@@ -246,17 +125,17 @@
 		em = support.beginSessionRO(client);
 		
 		TestGroupDMO groupDMO = support.currentSessionRO().find(TestGroupDMO.class, groupId);
-		doTest(Guid.class, TestGroupDMO.class, groupDMO, "name", "bobAndJaneSmall",
+		doFetchTest(Guid.class, TestGroupDMO.class, groupDMO, "name", "bobAndJaneSmall",
 				"group", groupId.toString(),
 				"bob", bobId.toString(),
 				"jane", janeId.toString());
 		
-		doTest(Guid.class, TestGroupDMO.class, groupDMO, "+;members +", "bobAndJaneRemaining",
+		doFetchTest(Guid.class, TestGroupDMO.class, groupDMO, "+;members +", "bobAndJaneRemaining",
 				"group", groupId.toString(),
 				"bob", bobId.toString(),
 				"jane", janeId.toString());
 
-		doTest(Guid.class, TestGroupDMO.class, groupDMO, "members group", "bobAndJaneAddOn",
+		doFetchTest(Guid.class, TestGroupDMO.class, groupDMO, "members group", "bobAndJaneAddOn",
 				"group", groupId.toString(),
 				"bob", bobId.toString(),
 				"jane", janeId.toString());
@@ -284,7 +163,7 @@
 		em = support.beginSessionRO(client);
 		
 		TestGroupDMO groupDMO = support.currentSessionRO().find(TestGroupDMO.class, groupId);
-		doTest(Guid.class, TestGroupDMO.class, groupDMO, "+;members group +", "bobAndJaneLoop",
+		doFetchTest(Guid.class, TestGroupDMO.class, groupDMO, "+;members group +", "bobAndJaneLoop",
 				"group", groupId.toString(),
 				"bob", bobId.toString(),
 				"jane", janeId.toString());
@@ -313,7 +192,7 @@
 		em = support.beginSessionRO(client);
 		
 		TestGroupDMO groupDMO = support.currentSessionRO().find(TestGroupDMO.class, groupId);
-		doTest(Guid.class, TestGroupDMO.class, groupDMO, "name;members member name", "bobAndJane",
+		doFetchTest(Guid.class, TestGroupDMO.class, groupDMO, "name;members member name", "bobAndJane",
 				"group", groupId.toString(),
 				"bob", bobId.toString(),
 				"jane", janeId.toString());

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/TestSupport.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/TestSupport.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/TestSupport.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -10,6 +10,7 @@
 import org.hibernate.ejb.HibernateEntityManager;
 import org.hibernate.ejb.HibernateEntityManagerFactory;
 
+import com.dumbhippo.dm.dm.TestBlogEntryDMO;
 import com.dumbhippo.dm.dm.TestGroupDMO;
 import com.dumbhippo.dm.dm.TestGroupMemberDMO;
 import com.dumbhippo.dm.dm.TestUserDMO;
@@ -40,6 +41,7 @@
 		model.addDMClass(TestUserDMO.class);
 		model.addDMClass(TestGroupDMO.class);
 		model.addDMClass(TestGroupMemberDMO.class);
+		model.addDMClass(TestBlogEntryDMO.class);
 		model.completeDMClasses();
 	}
 	

Added: dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryDMO.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryDMO.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryDMO.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,62 @@
+package com.dumbhippo.dm.dm;
+
+import javax.persistence.EntityManager;
+import javax.persistence.Query;
+
+import org.slf4j.Logger;
+
+import com.dumbhippo.GlobalSetup;
+import com.dumbhippo.dm.DMObject;
+import com.dumbhippo.dm.DMSession;
+import com.dumbhippo.dm.annotations.DMO;
+import com.dumbhippo.dm.annotations.DMProperty;
+import com.dumbhippo.dm.annotations.Inject;
+import com.dumbhippo.dm.persistence.TestBlogEntry;
+import com.dumbhippo.server.NotFoundException;
+
+ DMO(classId="http://mugshot.org/p/o/test/blogEntry";, resourceBase="/o/test/blogEntry")
+public abstract class TestBlogEntryDMO extends DMObject<TestBlogEntryKey> {
+	@SuppressWarnings("unused")
+	static private final Logger logger = GlobalSetup.getLogger(TestBlogEntryDMO.class);
+
+	@Inject
+	private EntityManager em;
+	
+	@Inject
+	private DMSession session;
+	
+	private TestBlogEntry blogEntry;
+	
+	protected TestBlogEntryDMO(TestBlogEntryKey key) {
+		super(key);
+	}
+
+	@Override
+	protected void init() throws NotFoundException {
+		Query q = em.createQuery(
+				"SELECT entry from TestBlogEntry entry " +
+				" WHERE entry.user.id = :userId " +
+				"   AND entry.serial = :serial "
+				);
+		
+		q.setParameter("userId", getKey().getUserId().toString());
+		q.setParameter("serial", getKey().getSerial());
+		
+		blogEntry = (TestBlogEntry)q.getSingleResult();
+	}
+	
+	@DMProperty(defaultInclude=true)
+	public long getTimestamp() {
+		return blogEntry.getTimestamp();
+	}
+	
+	@DMProperty(defaultInclude=true)
+	public String getTitle() {
+		return blogEntry.getTitle();
+	}
+	
+	@DMProperty
+	public TestUserDMO getUser() {
+		return session.findUnchecked(TestUserDMO.class, blogEntry.getUser().getGuid());
+	}
+}

Added: dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryKey.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryKey.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestBlogEntryKey.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,76 @@
+package com.dumbhippo.dm.dm;
+
+import com.dumbhippo.dm.BadIdException;
+import com.dumbhippo.dm.DMKey;
+import com.dumbhippo.dm.persistence.TestBlogEntry;
+import com.dumbhippo.identity20.Guid;
+import com.dumbhippo.identity20.Guid.ParseException;
+
+public class TestBlogEntryKey implements DMKey {
+	private static final long serialVersionUID = -5724889995159821821L;
+
+	private Guid userId;
+	private long serial;
+	
+	public TestBlogEntryKey(Guid groupId, long serial) {
+		this.userId = groupId;
+		this.serial = serial;
+	}
+	
+	public TestBlogEntryKey(String keyString) throws BadIdException {
+		String[] strings = keyString.split("\\.");
+		if (strings.length != 2)
+			throw new BadIdException("Invalid blog entry key: " + keyString);
+		
+		try {
+			this.userId = new Guid(strings[0]);
+		} catch (ParseException e) {
+			throw new BadIdException("Invalid GUID in blog entry key");
+		}
+		
+		try {
+			this.serial = Long.parseLong(strings[1]);
+		} catch (NumberFormatException e) {
+			throw new BadIdException("Invalid serial in blog entry key");
+		}
+	}
+
+	public TestBlogEntryKey(TestBlogEntry blogEntry) {
+		this.userId = blogEntry.getUser().getGuid();
+		this.serial = blogEntry.getSerial();
+	}
+	
+
+	public Guid getUserId() {
+		return userId;
+	}
+
+	public long getSerial() {
+		return serial;
+	}
+
+	@Override
+	public TestBlogEntryKey clone() {
+		return this; // Immutable, nothing session-specific
+	}
+	
+	@Override
+	public int hashCode() {
+		return (int)(userId.hashCode() * 11 + serial * 17);  
+	}
+	
+	@Override
+	public boolean equals(Object o) {
+		if (!(o instanceof TestBlogEntryKey))
+			return false;
+		
+		TestBlogEntryKey other = (TestBlogEntryKey)o;
+		return other.userId.equals(userId) && other.serial == serial;
+		
+	}
+
+	@Override
+	public String toString() {
+		return userId.toString() + "." + Long.toString(serial);
+	}
+}

Modified: dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/dm/TestUserDMO.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -1,16 +1,27 @@
 package com.dumbhippo.dm.dm;
 
+import java.util.ArrayList;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Set;
 
 import javax.persistence.EntityManager;
+import javax.persistence.Query;
 
+import org.slf4j.Logger;
+
+import com.dumbhippo.GlobalSetup;
+import com.dumbhippo.TypeUtils;
+import com.dumbhippo.dm.DMFeed;
+import com.dumbhippo.dm.DMFeedItem;
 import com.dumbhippo.dm.DMObject;
 import com.dumbhippo.dm.DMSession;
 import com.dumbhippo.dm.annotations.DMFilter;
 import com.dumbhippo.dm.annotations.DMO;
 import com.dumbhippo.dm.annotations.DMProperty;
 import com.dumbhippo.dm.annotations.Inject;
+import com.dumbhippo.dm.persistence.TestBlogEntry;
 import com.dumbhippo.dm.persistence.TestGroupMember;
 import com.dumbhippo.dm.persistence.TestUser;
 import com.dumbhippo.identity20.Guid;
@@ -18,6 +29,9 @@
 
 @DMO(classId="http://mugshot.org/p/o/test/user";, resourceBase="/o/test/user")
 public abstract class TestUserDMO extends DMObject<Guid> {
+	@SuppressWarnings("unused")
+	static private final Logger logger = GlobalSetup.getLogger(TestUserDMO.class);
+
 	@Inject
 	EntityManager em;
 	
@@ -54,4 +68,39 @@
 		
 		return result;
 	}
+	
+	// The small defaultMaxFetch here is because we always fetch *at least* the defaultMaxFetch
+	// so a larger one makes testing max= in fetch strings hard.
+	@DMProperty(defaultMaxFetch=1)
+	public DMFeed<TestBlogEntryDMO> getBlogEntries() {
+		return new BlogEntryFeed();
+	}
+	
+	private class BlogEntryFeed implements DMFeed<TestBlogEntryDMO> {
+		public Iterator<DMFeedItem<TestBlogEntryDMO>> iterator(int start, int max, long minTimestamp) {
+			logger.debug("Querying blog entries from database, start={}, max={}, minTimestamp={}",
+					     new Object[] { start, max, minTimestamp });
+			
+			Query q = em.createQuery(
+					"SELECT entry from TestBlogEntry entry " +
+					" WHERE entry.user = :user " +
+					"   AND entry.timestamp >= :minTimestamp " +
+					" ORDER BY entry.timestamp DESC"
+					);
+
+			q.setParameter("user", user);
+			q.setParameter("minTimestamp", minTimestamp);
+			
+			q.setFirstResult(start);
+			q.setMaxResults(max);
+			
+			List<DMFeedItem<TestBlogEntryDMO>> items = new ArrayList<DMFeedItem<TestBlogEntryDMO>>(); 
+			for (TestBlogEntry entry : TypeUtils.castList(TestBlogEntry.class, q.getResultList())) {
+				TestBlogEntryDMO entryDMO = session.findUnchecked(TestBlogEntryDMO.class, new TestBlogEntryKey(entry));
+				items.add(new DMFeedItem<TestBlogEntryDMO>(entryDMO, entry.getTimestamp()));
+			}
+
+			return items.iterator();
+		}
+	}
 }

Added: dumbhippo/trunk/server/tests/com/dumbhippo/dm/persistence/TestBlogEntry.java
===================================================================
--- dumbhippo/trunk/server/tests/com/dumbhippo/dm/persistence/TestBlogEntry.java	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/com/dumbhippo/dm/persistence/TestBlogEntry.java	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,60 @@
+package com.dumbhippo.dm.persistence;
+
+import java.util.Date;
+
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.JoinColumn;
+import javax.persistence.ManyToOne;
+
+ Entity
+public class TestBlogEntry extends TestDBUnique {
+	private long serial;
+	private TestUser user;
+	private long timestamp;
+	private String title;
+	
+	public TestBlogEntry(TestUser user, long serial, Date timestamp) {
+		this.user = user;
+		this.serial = serial;
+		this.timestamp = timestamp.getTime();
+	}
+
+	protected TestBlogEntry() {
+	}
+
+	@Column(nullable=false)
+	public long getSerial() {
+		return this.serial;
+	}
+	
+	protected void setSerial(long serial) {
+		this.serial = serial;
+	}
+	
+	public long getTimestamp() {
+		return timestamp;
+	}
+
+	public void setTimestamp(long timestamp) {
+		this.timestamp = timestamp;
+	}
+
+	public String getTitle() {
+		return title;
+	}
+
+	public void setTitle(String title) {
+		this.title = title;
+	}
+
+	@ManyToOne
+	@JoinColumn(nullable=false)
+	public TestUser getUser() {
+		return user;
+	}
+
+	protected void setUser(TestUser user) {
+		this.user = user;
+	}
+}

Added: dumbhippo/trunk/server/tests/feed-fetch-tests.xml
===================================================================
--- dumbhippo/trunk/server/tests/feed-fetch-tests.xml	2007-12-05 01:23:39 UTC (rev 6967)
+++ dumbhippo/trunk/server/tests/feed-fetch-tests.xml	2007-12-05 05:14:19 UTC (rev 6968)
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<fetchResults
+	xmlns:m="http://mugshot.org/p/system";
+    xmlns:u="http://mugshot.org/p/o/test/user";
+	xmlns:be="http://mugshot.org/p/o/test/blogEntry";
+	m:resourceBase="http://mugshot.org/o/test/";>
+
+	<fetchResult id="bobsFirstFeed">
+		<be:resource m:resourceId="blogEntry/$(bob).2" m:indirect="true" m:fetch="title">
+			<be:title>Stupid Alligator Tricks</be:title>
+		</be:resource>
+		<u:resource m:resourceId="user/$(bob)" m:fetch="blogEntries">
+			<u:blogEntries m:resourceId="blogEntry/$(bob).2" m:ts="1163532712000"/>
+		</u:resource>
+	</fetchResult>
+	
+	<fetchResult id="bobsOlderFeed">
+		<be:resource m:resourceId="blogEntry/$(bob).1" m:indirect="true" m:fetch="title">
+			<be:title>My Life</be:title>
+		</be:resource>
+		<u:resource m:resourceId="user/$(bob)" m:fetch="blogEntries">
+			<u:blogEntries m:resourceId="blogEntry/$(bob).1" m:ts="1151092892000"/>
+		</u:resource>
+	</fetchResult>
+	
+	<fetchResult id="bobsAlreadyFetchedFeed">
+		<u:resource m:resourceId="user/$(bob)" m:fetch="blogEntries">
+		</u:resource>
+	</fetchResult>
+
+	<fetchResult id="bobsFeedNotification">
+		<be:resource m:resourceId="blogEntry/$(bob).3" m:indirect="true" m:fetch="title">
+			<be:title>My 9-fingered Life</be:title>
+		</be:resource>
+		<u:resource m:resourceId="user/$(bob)" m:fetch="blogEntries">
+			<u:blogEntries m:resourceId="blogEntry/$(bob).3" m:ts="1174657444000"/>
+		</u:resource>
+	</fetchResult>
+</fetchResults>



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]