Create IndexFunctions on the fly for NoSQL secondary keys

The IndexFunction provides a way to construct a secondary row key
for an object instance, given some query that needs to scan along
that particular subset of fields.  Filtering for constants and
any nulls is done in Java prior to constructing the key, such that
these are omitted from the index.

This may differ from how a SQL implementation would sort the same
information in an ORDER BY clause, but we shouldn't need to be
sorting by nullable columns.

Change-Id: I9cfecfa58420c8629aee8053beec075c34a139a6
Signed-off-by: Shawn O. Pearce <sop@google.com>
diff --git a/src/main/java/com/google/gwtorm/nosql/IndexFunction.java b/src/main/java/com/google/gwtorm/nosql/IndexFunction.java
new file mode 100644
index 0000000..f13b050
--- /dev/null
+++ b/src/main/java/com/google/gwtorm/nosql/IndexFunction.java
@@ -0,0 +1,52 @@
+// Copyright 2010 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtorm.nosql;
+
+/**
+ * A function to produce a NoSQL secondary index key from an object.
+ * <p>
+ * An index function computes a row key for a secondary index table by appending
+ * the relevant values to builder's internal buffer in the order they are
+ * referenced in the query.
+ * <p>
+ * Typically an IndexFunction would be code generated at runtime by
+ * {@link IndexFunctionGen} as necessary by a NoSQL implementation.
+ *
+ * @param <T> type of the object the index record references.
+ */
+public abstract class IndexFunction<T> {
+  /** @return name of this index, should be unique within the relation. */
+  public abstract String getName();
+
+  /**
+   * Should this object exist in the index?
+   * <p>
+   * Objects that shouldn't appear in this index are skipped because field
+   * values are currently {@code null} or because one or more constants doesn't
+   * match as expected.
+   *
+   * @param object the object to read fields from.
+   * @return true if the object should be indexed by this index.
+   */
+  public abstract boolean includes(T object);
+
+  /**
+   * Encodes the current values from the object into the index buffer.
+   *
+   * @param dst the buffer to append the indexed field value(s) onto.
+   * @param object the object to read current field values from.
+   */
+  public abstract void encode(IndexKeyBuilder dst, T object);
+}
diff --git a/src/main/java/com/google/gwtorm/nosql/IndexFunctionGen.java b/src/main/java/com/google/gwtorm/nosql/IndexFunctionGen.java
new file mode 100644
index 0000000..206e238
--- /dev/null
+++ b/src/main/java/com/google/gwtorm/nosql/IndexFunctionGen.java
@@ -0,0 +1,491 @@
+// Copyright 2010 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtorm.nosql;
+
+import com.google.gwtorm.client.OrmException;
+import com.google.gwtorm.schema.ColumnModel;
+import com.google.gwtorm.schema.QueryModel;
+import com.google.gwtorm.schema.QueryParser;
+import com.google.gwtorm.schema.Util;
+import com.google.gwtorm.server.CodeGenSupport;
+import com.google.gwtorm.server.GeneratedClassLoader;
+
+import org.antlr.runtime.tree.Tree;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Label;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+/** Generates {@link IndexFunction} implementations. */
+class IndexFunctionGen<T> implements Opcodes {
+  private static final Type string = Type.getType(String.class);
+  private static final Type object = Type.getType(Object.class);
+  private static final Type indexKeyBuilder =
+      Type.getType(IndexKeyBuilder.class);
+
+  private final GeneratedClassLoader classLoader;
+  private final QueryModel query;
+  private final List<ColumnModel> myFields;
+  private final Class<T> pojo;
+  private final Type pojoType;
+
+  private ClassWriter cw;
+  private String superTypeName;
+  private String implClassName;
+  private String implTypeName;
+
+  public IndexFunctionGen(final GeneratedClassLoader loader,
+      final QueryModel qm, final Class<T> t) {
+    classLoader = loader;
+    query = qm;
+
+    myFields = new ArrayList<ColumnModel>();
+
+    // Only add each parameter column once, but in the order used.
+    // This avoids a range test on the same column from duplicating
+    // the data in the index record.
+    //
+    for (ColumnModel m : leaves(query.getParameters())) {
+      if (!myFields.contains(m)) {
+        myFields.add(m);
+      }
+    }
+
+    // Skip ORDER BY columns that match with the parameters, then
+    // add anything else onto the end.
+    //
+    int p = 0;
+    Iterator<ColumnModel> orderby = leaves(query.getOrderBy()).iterator();
+    while (p < myFields.size() && orderby.hasNext()) {
+      ColumnModel c = orderby.next();
+      if (!myFields.get(p).equals(c)) {
+        myFields.add(c);
+        break;
+      }
+      p++;
+    }
+    while (orderby.hasNext()) {
+      myFields.add(orderby.next());
+    }
+
+    pojo = t;
+    pojoType = Type.getType(pojo);
+  }
+
+  private List<ColumnModel> leaves(List<ColumnModel> in) {
+    ArrayList<ColumnModel> r = new ArrayList<ColumnModel>(in.size());
+    for (ColumnModel m : in) {
+      if (m.isNested()) {
+        r.addAll(m.getAllLeafColumns());
+      } else {
+        r.add(m);
+      }
+    }
+    return r;
+  }
+
+  public IndexFunction<T> create() throws OrmException {
+    init();
+    implementConstructor();
+    implementGetName();
+    implementIncludes();
+    implementEncode();
+    cw.visitEnd();
+    classLoader.defineClass(implClassName, cw.toByteArray());
+
+    try {
+      final Class<?> c = Class.forName(implClassName, true, classLoader);
+      return cast(c.newInstance());
+    } catch (InstantiationException e) {
+      throw new OrmException("Cannot create new encoder", e);
+    } catch (IllegalAccessException e) {
+      throw new OrmException("Cannot create new encoder", e);
+    } catch (ClassNotFoundException e) {
+      throw new OrmException("Cannot create new encoder", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private static <T> IndexFunction<T> cast(final Object c) {
+    return (IndexFunction<T>) c;
+  }
+
+  private void init() {
+    superTypeName = Type.getInternalName(IndexFunction.class);
+    implClassName =
+        pojo.getName() + "_IndexFunction_" + query.getName() + "_"
+            + Util.createRandomName();
+    implTypeName = implClassName.replace('.', '/');
+
+    cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
+    cw.visit(V1_3, ACC_PUBLIC | ACC_FINAL | ACC_SUPER, implTypeName, null,
+        superTypeName, new String[] {});
+  }
+
+  private void implementConstructor() {
+    final String consName = "<init>";
+    final String consDesc =
+        Type.getMethodDescriptor(Type.VOID_TYPE, new Type[] {});
+    final MethodVisitor mv =
+        cw.visitMethod(ACC_PUBLIC, consName, consDesc, null, null);
+    mv.visitCode();
+
+    mv.visitVarInsn(ALOAD, 0);
+    mv.visitMethodInsn(INVOKESPECIAL, superTypeName, consName, consDesc);
+
+    mv.visitInsn(RETURN);
+    mv.visitMaxs(-1, -1);
+    mv.visitEnd();
+  }
+
+  private void implementGetName() {
+    final MethodVisitor mv =
+        cw.visitMethod(ACC_PUBLIC | ACC_FINAL, "getName", Type
+            .getMethodDescriptor(Type.getType(String.class), new Type[] {}),
+            null, null);
+    mv.visitCode();
+    mv.visitLdcInsn(query.getName());
+    mv.visitInsn(ARETURN);
+    mv.visitMaxs(-1, -1);
+    mv.visitEnd();
+  }
+
+  private void implementIncludes() throws OrmException {
+    final MethodVisitor mv =
+        cw.visitMethod(ACC_PUBLIC, "includes", Type.getMethodDescriptor(
+            Type.BOOLEAN_TYPE, new Type[] {object}), null, null);
+    mv.visitCode();
+    final IncludeCGS cgs = new IncludeCGS(mv);
+    cgs.setEntityType(pojoType);
+
+    mv.visitVarInsn(ALOAD, 1);
+    mv.visitTypeInsn(CHECKCAST, pojoType.getInternalName());
+    mv.visitVarInsn(ASTORE, 1);
+
+    Set<ColumnModel> checked = new HashSet<ColumnModel>();
+    checkNotNullFields(myFields, checked, mv, cgs);
+
+    final Tree parseTree = query.getParseTree();
+    if (parseTree != null) {
+      checkConstants(parseTree, mv, cgs);
+    }
+
+    cgs.push(1);
+    mv.visitInsn(IRETURN);
+
+    mv.visitLabel(cgs.no);
+    cgs.push(0);
+    mv.visitInsn(IRETURN);
+
+    mv.visitMaxs(-1, -1);
+    mv.visitEnd();
+  }
+
+  private static void checkNotNullFields(Collection<ColumnModel> myFields,
+      Set<ColumnModel> checked, MethodVisitor mv, IncludeCGS cgs)
+      throws OrmException {
+    for (ColumnModel f : myFields) {
+      if (f.isNested()) {
+        checkNotNullFields(f.getNestedColumns(), checked, mv, cgs);
+      } else {
+        checkNotNullScalar(mv, checked, cgs, f);
+      }
+    }
+  }
+
+  private static void checkNotNullScalar(MethodVisitor mv,
+      Set<ColumnModel> checked, IncludeCGS cgs, ColumnModel f)
+      throws OrmException {
+    checkParentNotNull(f.getParent(), checked, mv, cgs);
+    cgs.setFieldReference(f);
+
+    switch (Type.getType(f.getPrimitiveType()).getSort()) {
+      case Type.BOOLEAN:
+      case Type.BYTE:
+      case Type.SHORT:
+      case Type.CHAR:
+      case Type.INT:
+      case Type.LONG:
+        break;
+
+      case Type.ARRAY:
+      case Type.OBJECT: {
+        if (f.getPrimitiveType() == byte[].class) {
+          cgs.pushFieldValue();
+          mv.visitJumpInsn(IFNULL, cgs.no);
+
+        } else if (f.getPrimitiveType() == String.class) {
+          cgs.pushFieldValue();
+          mv.visitJumpInsn(IFNULL, cgs.no);
+
+        } else if (f.getPrimitiveType() == java.sql.Timestamp.class
+            || f.getPrimitiveType() == java.util.Date.class
+            || f.getPrimitiveType() == java.sql.Date.class) {
+          cgs.pushFieldValue();
+          mv.visitJumpInsn(IFNULL, cgs.no);
+
+        } else {
+          throw new OrmException("Type " + f.getPrimitiveType()
+              + " not supported for field " + f.getPathToFieldName());
+        }
+        break;
+      }
+
+      default:
+        throw new OrmException("Type " + f.getPrimitiveType()
+            + " not supported for field " + f.getPathToFieldName());
+    }
+  }
+
+  private static void checkParentNotNull(ColumnModel f,
+      Set<ColumnModel> checked, MethodVisitor mv, IncludeCGS cgs) {
+    if (f != null && checked.add(f)) {
+      checkParentNotNull(f.getParent(), checked, mv, cgs);
+      cgs.setFieldReference(f);
+      cgs.pushFieldValue();
+      mv.visitJumpInsn(IFNULL, cgs.no);
+    }
+  }
+
+  private void checkConstants(Tree node, MethodVisitor mv, IncludeCGS cgs)
+      throws OrmException {
+    switch (node.getType()) {
+      // These don't impact the constant evaluation
+      case QueryParser.ORDER:
+      case QueryParser.LIMIT:
+        break;
+
+      case 0: // nil node used to join other nodes together
+      case QueryParser.WHERE:
+      case QueryParser.AND:
+        for (int i = 0; i < node.getChildCount(); i++) {
+          checkConstants(node.getChild(i), mv, cgs);
+        }
+        break;
+
+      case QueryParser.LT:
+      case QueryParser.LE:
+      case QueryParser.GT:
+      case QueryParser.GE:
+      case QueryParser.EQ: {
+        final Tree lhs = node.getChild(0);
+        final Tree rhs = node.getChild(1);
+        if (lhs.getType() != QueryParser.ID) {
+          throw new OrmException("Unsupported query token");
+        }
+
+        cgs.setFieldReference(((QueryParser.Column) lhs).getField());
+        switch (rhs.getType()) {
+          case QueryParser.PLACEHOLDER:
+            // Parameter evaluated at runtime
+            break;
+
+          case QueryParser.TRUE:
+            cgs.pushFieldValue();
+            mv.visitJumpInsn(IFEQ, cgs.no);
+            break;
+
+          case QueryParser.FALSE:
+            cgs.pushFieldValue();
+            mv.visitJumpInsn(IFNE, cgs.no);
+            break;
+
+          case QueryParser.CONSTANT_INTEGER:
+            cgs.pushFieldValue();
+            cgs.push(Integer.parseInt(rhs.getText()));
+            mv.visitJumpInsn(IF_ICMPNE, cgs.no);
+            break;
+
+          case QueryParser.CONSTANT_STRING:
+            if (cgs.getFieldReference().getPrimitiveType() == Character.TYPE) {
+              cgs.push(dequote(rhs.getText()).charAt(0));
+              cgs.pushFieldValue();
+              mv.visitJumpInsn(IF_ICMPNE, cgs.no);
+            } else {
+              mv.visitLdcInsn(dequote(rhs.getText()));
+              cgs.pushFieldValue();
+              mv.visitMethodInsn(INVOKEVIRTUAL, string.getInternalName(),
+                  "equals", Type.getMethodDescriptor(Type.BOOLEAN_TYPE,
+                      new Type[] {object}));
+              mv.visitJumpInsn(IFEQ, cgs.no);
+            }
+            break;
+        }
+        break;
+      }
+
+      default:
+        throw new OrmException("Unsupported query token " + node.toStringTree());
+    }
+  }
+
+  private static String dequote(String text) {
+    return text.substring(1, text.length() - 1);
+  }
+
+  private void implementEncode() throws OrmException {
+    final MethodVisitor mv =
+        cw.visitMethod(ACC_PUBLIC, "encode", Type.getMethodDescriptor(
+            Type.VOID_TYPE, new Type[] {indexKeyBuilder, object}), null, null);
+    mv.visitCode();
+    final EncodeCGS cgs = new EncodeCGS(mv);
+    cgs.setEntityType(pojoType);
+
+    mv.visitVarInsn(ALOAD, 2);
+    mv.visitTypeInsn(CHECKCAST, pojoType.getInternalName());
+    mv.visitVarInsn(ASTORE, 2);
+
+    encodeFields(myFields, mv, cgs);
+
+    mv.visitInsn(RETURN);
+    mv.visitMaxs(-1, -1);
+    mv.visitEnd();
+  }
+
+  private static void encodeFields(final Collection<ColumnModel> myFields,
+      final MethodVisitor mv, final EncodeCGS cgs) throws OrmException {
+    Iterator<ColumnModel> i = myFields.iterator();
+    while (i.hasNext()) {
+      ColumnModel f = i.next();
+      encodeScalar(f, mv, cgs);
+      if (i.hasNext()) {
+        cgs.delimiter();
+      }
+    }
+  }
+
+  static void encodeField(ColumnModel f, final MethodVisitor mv,
+      final EncodeCGS cgs) throws OrmException {
+    if (f.isNested()) {
+      encodeFields(f.getAllLeafColumns(), mv, cgs);
+    } else {
+      encodeScalar(f, mv, cgs);
+    }
+  }
+
+  private static void encodeScalar(final ColumnModel f, final MethodVisitor mv,
+      final EncodeCGS cgs) throws OrmException {
+    cgs.setFieldReference(f);
+
+    switch (Type.getType(f.getPrimitiveType()).getSort()) {
+      case Type.BOOLEAN:
+      case Type.BYTE:
+      case Type.SHORT:
+      case Type.CHAR:
+      case Type.INT:
+        cgs.pushBuilder();
+        cgs.pushFieldValue();
+        mv.visitInsn(I2L);
+        mv.visitMethodInsn(INVOKEVIRTUAL, indexKeyBuilder.getInternalName(),
+            "add", Type.getMethodDescriptor(Type.VOID_TYPE,
+                new Type[] {Type.LONG_TYPE}));
+        break;
+
+      case Type.LONG:
+        cgs.pushBuilder();
+        cgs.pushFieldValue();
+        mv.visitMethodInsn(INVOKEVIRTUAL, indexKeyBuilder.getInternalName(),
+            "add", Type.getMethodDescriptor(Type.VOID_TYPE,
+                new Type[] {Type.LONG_TYPE}));
+        break;
+
+      case Type.ARRAY:
+      case Type.OBJECT: {
+        if (f.getPrimitiveType() == byte[].class) {
+          cgs.pushBuilder();
+          cgs.pushFieldValue();
+          mv.visitMethodInsn(INVOKEVIRTUAL, indexKeyBuilder.getInternalName(),
+              "add", Type.getMethodDescriptor(Type.VOID_TYPE, new Type[] {Type
+                  .getType(byte[].class)}));
+
+        } else if (f.getPrimitiveType() == String.class) {
+          cgs.pushBuilder();
+          cgs.pushFieldValue();
+          mv.visitMethodInsn(INVOKEVIRTUAL, indexKeyBuilder.getInternalName(),
+              "add", Type.getMethodDescriptor(Type.VOID_TYPE,
+                  new Type[] {string}));
+
+        } else if (f.getPrimitiveType() == java.sql.Timestamp.class
+            || f.getPrimitiveType() == java.util.Date.class
+            || f.getPrimitiveType() == java.sql.Date.class) {
+          cgs.pushBuilder();
+          cgs.pushFieldValue();
+          String tsType = Type.getType(f.getPrimitiveType()).getInternalName();
+          mv.visitMethodInsn(INVOKEVIRTUAL, tsType, "getTime", Type
+              .getMethodDescriptor(Type.LONG_TYPE, new Type[] {}));
+          mv.visitMethodInsn(INVOKEVIRTUAL, indexKeyBuilder.getInternalName(),
+              "add", Type.getMethodDescriptor(Type.VOID_TYPE,
+                  new Type[] {Type.LONG_TYPE}));
+        } else {
+          throw new OrmException("Type " + f.getPrimitiveType()
+              + " not supported for field " + f.getPathToFieldName());
+        }
+        break;
+      }
+
+      default:
+        throw new OrmException("Type " + f.getPrimitiveType()
+            + " not supported for field " + f.getPathToFieldName());
+    }
+  }
+
+  private static final class IncludeCGS extends CodeGenSupport {
+    final Label no = new Label();
+
+    private IncludeCGS(MethodVisitor method) {
+      super(method);
+    }
+
+    @Override
+    public void pushEntity() {
+      mv.visitVarInsn(ALOAD, 1);
+    }
+  }
+
+  private static final class EncodeCGS extends CodeGenSupport {
+    private EncodeCGS(MethodVisitor method) {
+      super(method);
+    }
+
+    void infinity() {
+      pushBuilder();
+      mv.visitMethodInsn(INVOKEVIRTUAL, indexKeyBuilder.getInternalName(),
+          "infinity", Type.getMethodDescriptor(Type.VOID_TYPE, new Type[] {}));
+    }
+
+    void delimiter() {
+      pushBuilder();
+      mv.visitMethodInsn(INVOKEVIRTUAL, indexKeyBuilder.getInternalName(),
+          "delimiter", Type.getMethodDescriptor(Type.VOID_TYPE, new Type[] {}));
+    }
+
+    void pushBuilder() {
+      mv.visitVarInsn(ALOAD, 1);
+    }
+
+    @Override
+    public void pushEntity() {
+      mv.visitVarInsn(ALOAD, 2);
+    }
+  }
+}
diff --git a/src/main/java/com/google/gwtorm/schema/QueryModel.java b/src/main/java/com/google/gwtorm/schema/QueryModel.java
index 44447e3..b94e224 100644
--- a/src/main/java/com/google/gwtorm/schema/QueryModel.java
+++ b/src/main/java/com/google/gwtorm/schema/QueryModel.java
@@ -31,24 +31,31 @@
 public class QueryModel {
   private final RelationModel model;
   private final String name;
-  private final Query query;
   private final Tree parsedQuery;
 
   public QueryModel(final RelationModel rel, final String queryName,
       final Query q) throws OrmException {
+    this(rel, queryName, queryTextOf(queryName, q));
+  }
+
+  private static String queryTextOf(String queryName, Query q)
+      throws OrmException {
     if (q == null) {
       throw new OrmException("Query " + queryName + " is missing "
           + Query.class.getName() + " annotation");
     }
+    return q.value();
+  }
 
+  public QueryModel(final RelationModel rel, final String queryName,
+      final String queryText) throws OrmException {
     model = rel;
     name = queryName;
-    query = q;
 
     try {
-      parsedQuery = QueryParser.parse(model, q.value());
+      parsedQuery = QueryParser.parse(model, queryText);
     } catch (QueryParseException e) {
-      throw new OrmException("Cannot parse query " + q.value(), e);
+      throw new OrmException("Cannot parse query " + queryText, e);
     }
   }
 
@@ -68,6 +75,20 @@
     return r;
   }
 
+  public List<ColumnModel> getOrderBy() {
+    final ArrayList<ColumnModel> r = new ArrayList<ColumnModel>();
+    if (parsedQuery != null) {
+      Tree node = findOrderBy(parsedQuery);
+      if (node != null) {
+        for (int i = 0; i < node.getChildCount(); i++) {
+          final Tree id = node.getChild(i);
+          r.add(((QueryParser.Column) id).getField());
+        }
+      }
+    }
+    return r;
+  }
+
   private void findParameters(final List<ColumnModel> r, final Tree node) {
     switch (node.getType()) {
       case QueryParser.WHERE:
@@ -134,6 +155,24 @@
     }
   }
 
+  private Tree findOrderBy(final Tree node) {
+    if (node == null) {
+      return null;
+    }
+    switch (node.getType()) {
+      case QueryParser.ORDER:
+        return node;
+      default:
+        for (int i = 0; i < node.getChildCount(); i++) {
+          final Tree r = findOrderBy(node.getChild(i));
+          if (r != null) {
+            return r;
+          }
+        }
+        return null;
+    }
+  }
+
   public String getSelectSql(final SqlDialect dialect, final String tableAlias) {
     final StringBuilder buf = new StringBuilder();
     buf.append(model.getSelectSql(dialect, tableAlias));
@@ -254,7 +293,7 @@
 
   @Override
   public String toString() {
-    return "Query[" + name + " " + query.value() + "]";
+    return "Query[" + name + " " + getParseTree().toStringTree() + "]";
   }
 
   private Tree expand(final Tree node) {
diff --git a/src/main/java/com/google/gwtorm/schema/java/JavaSchemaModel.java b/src/main/java/com/google/gwtorm/schema/java/JavaSchemaModel.java
index 53eabc9..b8a0748 100644
--- a/src/main/java/com/google/gwtorm/schema/java/JavaSchemaModel.java
+++ b/src/main/java/com/google/gwtorm/schema/java/JavaSchemaModel.java
@@ -18,6 +18,7 @@
 import com.google.gwtorm.client.Relation;
 import com.google.gwtorm.client.Schema;
 import com.google.gwtorm.client.Sequence;
+import com.google.gwtorm.schema.RelationModel;
 import com.google.gwtorm.schema.SchemaModel;
 import com.google.gwtorm.schema.SequenceModel;
 
@@ -55,6 +56,15 @@
     }
   }
 
+  public RelationModel getRelation(String name) {
+    for (RelationModel m : getRelations()) {
+      if (m.getMethodName().equals(name)) {
+        return m;
+      }
+    }
+    throw new IllegalArgumentException("No relation named " + name);
+  }
+
   @Override
   public String getSchemaClassName() {
     return schema.getName();
diff --git a/src/test/java/com/google/gwtorm/nosql/IndexFunctionTest.java b/src/test/java/com/google/gwtorm/nosql/IndexFunctionTest.java
new file mode 100644
index 0000000..862456a
--- /dev/null
+++ b/src/test/java/com/google/gwtorm/nosql/IndexFunctionTest.java
@@ -0,0 +1,200 @@
+// Copyright 2010 Google Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gwtorm.nosql;
+
+import com.google.gwtorm.client.OrmException;
+import com.google.gwtorm.data.PhoneBookDb;
+import com.google.gwtorm.data.TestPerson;
+import com.google.gwtorm.schema.QueryModel;
+import com.google.gwtorm.schema.RelationModel;
+import com.google.gwtorm.schema.java.JavaSchemaModel;
+import com.google.gwtorm.server.GeneratedClassLoader;
+
+import junit.framework.TestCase;
+
+@SuppressWarnings("unchecked")
+public class IndexFunctionTest extends TestCase {
+  private JavaSchemaModel schema;
+  private RelationModel people;
+
+  @Override
+  protected void setUp() throws Exception {
+    super.setUp();
+    schema = new JavaSchemaModel(PhoneBookDb.class);
+    people = schema.getRelation("people");
+  }
+
+  public void testPersonByName() throws Exception {
+    IndexFunction<TestPerson> idx = index("testMyQuery", "WHERE name=?");
+    assertEquals("testMyQuery", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("bob"), 12);
+    assertTrue(idx.includes(p));
+    idx.encode(b, p);
+    assertEquals(new byte[] {'b', 'o', 'b'}, b);
+  }
+
+  public void testPersonByNameAge() throws Exception {
+    IndexFunction<TestPerson> idx = index("nameAge", "WHERE name=? AND age=?");
+    assertEquals("nameAge", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("hm"), 42);
+    assertTrue(idx.includes(p));
+    idx.encode(b, p);
+    assertEquals(new byte[] {'h', 'm', 0x00, 0x01, 0x01, 42}, b);
+
+    p = new TestPerson(new TestPerson.Key(null), 0);
+    assertFalse(idx.includes(p));
+
+    b = new IndexKeyBuilder();
+    assertFalse(idx.includes(p));
+  }
+
+  public void testPersonByNameAge_OrderByName() throws Exception {
+    IndexFunction<TestPerson> idx =
+        index("nameAge", "WHERE name=? AND age=? ORDER BY name");
+    assertEquals("nameAge", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("qy"), 42);
+    assertTrue(idx.includes(p));
+    idx.encode(b, p);
+    assertEquals(new byte[] {'q', 'y', 0x00, 0x01, 0x01, 42}, b);
+  }
+
+  public void testPersonByNameAge_OrderByRegistered() throws Exception {
+    IndexFunction<TestPerson> idx =
+        index("nameAge", "WHERE name=? AND age=? ORDER BY registered");
+    assertEquals("nameAge", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("q"), 42);
+    p.register();
+    assertTrue(idx.includes(p));
+    idx.encode(b, p);
+    assertEquals(new byte[] {'q', 0x00, 0x01, // name
+        0x01, 42, 0x00, 0x01, // age
+        0x01, 0x01 // registered
+        }, b);
+  }
+
+  public void testPersonByNameRange_OrderByName() throws Exception {
+    IndexFunction<TestPerson> idx =
+        index("nameSuggest", "WHERE name >= ? AND name <= ? ORDER BY name");
+    assertEquals("nameSuggest", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("q"), 42);
+    assertTrue(idx.includes(p));
+    idx.encode(b, p);
+    assertEquals(new byte[] {'q'}, b);
+  }
+
+  public void testOnlyRegistered() throws Exception {
+    IndexFunction<TestPerson> idx =
+        index("isregistered", "WHERE registered = true ORDER BY name");
+    assertEquals("isregistered", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("q"), 42);
+    assertFalse(idx.includes(p));
+    p.register();
+    assertTrue(idx.includes(p));
+
+    idx.encode(b, p);
+    assertEquals(new byte[] {'q'}, b);
+  }
+
+  public void testOnlyAge42() throws Exception {
+    IndexFunction<TestPerson> idx =
+        index("isOldEnough", "WHERE age = 42 ORDER BY name");
+    assertEquals("isOldEnough", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("q"), 32);
+    assertFalse(idx.includes(p));
+
+    p = new TestPerson(new TestPerson.Key("q"), 42);
+    assertTrue(idx.includes(p));
+
+    idx.encode(b, p);
+    assertEquals(new byte[] {'q'}, b);
+  }
+
+  public void testOnlyBob() throws Exception {
+    IndexFunction<TestPerson> idx = index("isbob", "WHERE name.name = 'bob'");
+    assertEquals("isbob", idx.getName());
+
+    IndexKeyBuilder b;
+    TestPerson p;
+
+    b = new IndexKeyBuilder();
+    p = new TestPerson(new TestPerson.Key("q"), 42);
+    assertFalse(idx.includes(p));
+
+    p = new TestPerson(new TestPerson.Key("bob"), 42);
+    assertTrue(idx.includes(p));
+
+    idx.encode(b, p);
+    assertEquals(new byte[] {}, b);
+  }
+
+  private IndexFunction<TestPerson> index(String name, String query)
+      throws OrmException {
+    final QueryModel qm = new QueryModel(people, name, query);
+    return new IndexFunctionGen(new GeneratedClassLoader(TestPerson.class
+        .getClassLoader()), qm, TestPerson.class).create();
+  }
+
+  private static void assertEquals(byte[] exp, IndexKeyBuilder ic) {
+    assertEquals(toString(exp), toString(ic.toByteArray()));
+  }
+
+  private static String toString(byte[] bin) {
+    StringBuilder dst = new StringBuilder(bin.length * 2);
+    for (byte b : bin) {
+      dst.append(hexchar[(b >>> 4) & 0x0f]);
+      dst.append(hexchar[b & 0x0f]);
+    }
+    return dst.toString();
+  }
+
+  private static final char[] hexchar =
+      {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', //
+          'a', 'b', 'c', 'd', 'e', 'f'};
+}