Index: modules/join/src/java/org/apache/lucene/search/join/JoinQuery.java
===================================================================
--- modules/join/src/java/org/apache/lucene/search/join/JoinQuery.java	(revision )
+++ modules/join/src/java/org/apache/lucene/search/join/JoinQuery.java	(revision )
@@ -0,0 +1,389 @@
+package org.apache.lucene.search.join;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import org.apache.lucene.index.*;
+import org.apache.lucene.search.*;
+import org.apache.lucene.util.*;
+
+import java.io.IOException;
+import java.util.Set;
+
+/**
+ * A query that encapsulates another query and joins / links documents that are related to the documents that have
+ * matched to the encapsulated query with the same "from" and "to" indexed field values. The "to" documents are
+ * returned as result.
+ *
+ * This query uses a top level approach for matching from and to documents by indexed field values. The downside of this
+ * is that in a sharded environment not all documents might get joined / linked.
+ */
+public class JoinQuery extends Query {
+
+  public static class Builder {
+
+    private String fromField;
+    private String toField;
+    private Query actualQuery;
+
+    private Bits preComputedFromDocs;
+    private IndexSearcher toSearcher;
+
+    public JoinQuery build() {
+      if (fromField == null) {
+        throw new IllegalStateException("fromField cannot be null");
+      }
+
+      if (toField == null) {
+        throw new IllegalStateException("toField cannot be null");
+      }
+
+      if (actualQuery == null) {
+        throw new IllegalStateException("actualQuery cannot be null");
+      }
+
+      return new JoinQuery(fromField, toField, actualQuery, toSearcher, preComputedFromDocs);
+    }
+
+    public Builder setFromField(String fromField) {
+      this.fromField = fromField;
+      return this;
+    }
+
+    public Builder setToField(String toField) {
+      this.toField = toField;
+      return this;
+    }
+
+    public Builder setActualQuery(Query actualQuery) {
+      this.actualQuery = actualQuery;
+      return this;
+    }
+
+    public Builder setPreComputedFromDocs(Bits preComputedFromDocs) {
+      this.preComputedFromDocs = preComputedFromDocs;
+      return this;
+    }
+
+    public Builder setToSearcher(IndexSearcher toSearcher) {
+      this.toSearcher = toSearcher;
+      return this;
+    }
+  }
+
+
+  private final IndexSearcher toSearcher;
+  private final Query actualQuery;
+  private final Bits preComputedFromDocs;
+  private final String fromField;
+  private final String toField;
+
+  private JoinQuery(String fromField, String toField, Query actualQuery, IndexSearcher toSearcher, Bits preComputedFromDocs) {
+    this.preComputedFromDocs = preComputedFromDocs;
+    this.fromField = fromField;
+    this.toField = toField;
+    this.actualQuery = actualQuery;
+    this.toSearcher = toSearcher;
+  }
+
+  @Override
+  public Query rewrite(IndexReader reader) throws IOException {
+    Query newQ = actualQuery.rewrite(reader);
+    if (newQ == actualQuery) {
+      return this;
+    } else {
+      return new JoinQuery(fromField, toField, newQ, toSearcher, preComputedFromDocs);
+    }
+  }
+
+  @Override
+  public void extractTerms(Set<Term> terms) {
+    actualQuery.extractTerms(terms);
+  }
+
+  public Weight createWeight(IndexSearcher fromSearcher) throws IOException {
+    if (preComputedFromDocs == null) {
+      TopLevelFixedBitSetCollector bitSetCollector = new TopLevelFixedBitSetCollector(fromSearcher.getIndexReader().maxDoc());
+      fromSearcher.search(actualQuery, bitSetCollector);
+      return new JoinQueryWeight(bitSetCollector.bitSet, fromSearcher, toSearcher);
+    } else {
+      return new JoinQueryWeight(preComputedFromDocs, fromSearcher, toSearcher);
+    }
+  }
+
+  @Override
+  public String toString(String field) {
+    return "{!join from=" + fromField + " to=" + toField + " toSearcher=" + toSearcher + "}" + actualQuery.toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) return true;
+    if (o == null || getClass() != o.getClass()) return false;
+
+    JoinQuery query = (JoinQuery) o;
+
+    return actualQuery.equals(query.actualQuery)
+        && fromField.equals(query.fromField)
+        && fromField.equals(query.fromField)
+        && getBoost() == query.getBoost()
+        && (preComputedFromDocs != null ? preComputedFromDocs.equals(query.preComputedFromDocs) : query.preComputedFromDocs == null)
+        && (toSearcher != null ? toSearcher.equals(query.toSearcher) : query.toSearcher == null);
+  }
+
+  @Override
+  public int hashCode() {
+    int result = 31 * actualQuery.hashCode();
+    result = 31 * result + fromField.hashCode();
+    result = 31 * result + toField.hashCode();
+    result = 31 * result + (toSearcher != null ? toSearcher.hashCode() : 0);
+    result = 31 * result + (preComputedFromDocs != null ? preComputedFromDocs.hashCode() : 0);
+    return result;
+  }
+
+
+  protected class JoinQueryWeight extends Weight {
+
+    private float queryNorm;
+    private float queryWeight;
+    private final Bits fromDocs;
+    private final IndexSearcher fromSearcher;
+    private final IndexSearcher toSearcher;
+
+    private FixedBitSet joinResult;
+
+    public JoinQueryWeight(Bits fromDocs, IndexSearcher fromSearcher, IndexSearcher toSearcher) {
+      this.fromDocs = fromDocs;
+      this.fromSearcher = fromSearcher;
+      this.toSearcher = toSearcher == null ? fromSearcher : toSearcher;
+    }
+
+    @Override
+    public Explanation explain(IndexReader.AtomicReaderContext context, int doc) throws IOException {
+      Scorer scorer = scorer(context, true, false, context.reader.getLiveDocs());
+      boolean exists = scorer.advance(doc) == doc;
+
+      ComplexExplanation explanation = new ComplexExplanation();
+
+      if (exists) {
+        explanation.setDescription(this.toString() + " , product of:");
+        explanation.setValue(queryWeight);
+        explanation.setMatch(Boolean.TRUE);
+        explanation.addDetail(new Explanation(getBoost(), "boost"));
+        explanation.addDetail(new Explanation(queryNorm, "queryNorm"));
+      } else {
+        explanation.setDescription(this.toString() + " doesn't match id " + doc);
+        explanation.setValue(0);
+        explanation.setMatch(Boolean.FALSE);
+      }
+      return explanation;
+    }
+
+    @Override
+    public Query getQuery() {
+      return JoinQuery.this;
+    }
+
+    @Override
+    public float getValueForNormalization() throws IOException {
+      queryWeight = getBoost();
+      return queryWeight * queryWeight;
+    }
+
+    @Override
+    public void normalize(float norm, float topLevelBoost) {
+      this.queryNorm = norm * topLevelBoost;
+      queryWeight *= this.queryNorm;
+    }
+
+    @Override
+    public Scorer scorer(IndexReader.AtomicReaderContext context, boolean scoreDocsInOrder, boolean topScorer, Bits acceptDocs) throws IOException {
+      // We only need to synchronize this if segments are searched concurrently.
+      if (joinResult == null) {
+        joinResult = join(fromSearcher.getIndexReader(), toSearcher.getIndexReader());
+      }
+
+
+      DocIdSetIterator iterator;
+      if (context.isTopLevel) {
+        iterator = joinResult.iterator();
+      } else {
+        iterator = new AdjustedDocIdSetIterator(context, joinResult.iterator());
+      }
+      return new JoinScorer(this, iterator, getBoost());
+    }
+
+    protected FixedBitSet join(IndexReader fromIr, IndexReader toIr) throws IOException {
+      Bits fromLiveDocs = MultiFields.getLiveDocs(fromIr);
+      Bits toLiveDocs = fromIr == toIr ? fromLiveDocs : MultiFields.getLiveDocs(toIr);
+      TermsEnum fromTermsEnum = MultiFields.getTerms(fromIr, fromField).iterator(null);
+      TermsEnum toTermsEnum = MultiFields.getTerms(toIr, toField).iterator(null);
+      DocsEnum fromDocsEnum = null;
+      DocsEnum toDocsEnum = null;
+      FixedBitSet resultBitSet = new FixedBitSet(toIr.maxDoc());
+
+      for (BytesRef term = fromTermsEnum.next(); term != null; term = fromTermsEnum.next()) {
+        if (fromTermsEnum.docFreq() == 0) {
+          continue;
+        }
+
+        boolean matches = false;
+        fromDocsEnum = fromTermsEnum.docs(fromLiveDocs, fromDocsEnum);
+        DocsEnum.BulkReadResult fromBulkResult = fromDocsEnum.getBulkResult();
+        fromIterDocEnum:
+        for (int docs = fromDocsEnum.read(); docs != 0; docs = fromDocsEnum.read()) {
+          IntsRef postingList = fromBulkResult.docs;
+          int end = postingList.offset + docs;
+          for (int i = postingList.offset; i < end; i++) {
+            if (fromDocs.get(postingList.ints[i])) {
+              matches = true;
+              break fromIterDocEnum;
+            }
+          }
+        }
+
+        if (!matches) {
+          continue;
+        }
+
+        TermsEnum.SeekStatus seekStatus = toTermsEnum.seekCeil(term, true);
+        switch (seekStatus) {
+          case FOUND:
+            toDocsEnum = toTermsEnum.docs(toLiveDocs, toDocsEnum);
+            DocsEnum.BulkReadResult toBulkResult = toDocsEnum.getBulkResult();
+            for (int docs = toDocsEnum.read(); docs != 0; docs = toDocsEnum.read()) {
+              IntsRef postingList = toBulkResult.docs;
+              int end = postingList.offset + docs;
+              for (int i = postingList.offset; i < end; i++) {
+                resultBitSet.set(postingList.ints[i]);
+              }
+            }
+            break;
+          case END:
+          case NOT_FOUND:
+
+        }
+      }
+      return resultBitSet;
+    }
+
+  }
+
+  protected static class JoinScorer extends Scorer {
+
+    final DocIdSetIterator iter;
+    final float score;
+
+    public JoinScorer(Weight w, DocIdSetIterator iter, float score) throws IOException {
+      super(w);
+      this.score = score;
+      this.iter = iter == null ? DocIdSet.EMPTY_DOCIDSET.iterator() : iter;
+    }
+
+    public int nextDoc() throws IOException {
+      return iter.nextDoc();
+    }
+
+    public int docID() {
+      return iter.docID();
+    }
+
+    public float score() throws IOException {
+      return score;
+    }
+
+    public int advance(int target) throws IOException {
+      return iter.advance(target);
+    }
+
+  }
+
+  private static class TopLevelFixedBitSetCollector extends Collector {
+
+    private final FixedBitSet bitSet;
+    private IndexReader.AtomicReaderContext context;
+
+    private TopLevelFixedBitSetCollector(int maxDoc) {
+      this.bitSet = new FixedBitSet(maxDoc);
+    }
+
+    @Override
+    public void setScorer(Scorer scorer) throws IOException {
+    }
+
+    @Override
+    public void collect(int doc) throws IOException {
+      bitSet.set(doc + context.docBase);
+    }
+
+    @Override
+    public void setNextReader(IndexReader.AtomicReaderContext context) throws IOException {
+      this.context = context;
+    }
+
+    @Override
+    public boolean acceptsDocsOutOfOrder() {
+      return true;
+    }
+  }
+
+  private static class AdjustedDocIdSetIterator extends DocIdSetIterator {
+
+    private final int base;
+    private final int max;
+    private final DocIdSetIterator real;
+
+    private int adjustedDoc;
+    private int realDoc = -1;
+
+    private AdjustedDocIdSetIterator(IndexReader.AtomicReaderContext context, DocIdSetIterator real) throws IOException {
+      this.base = context.docBase;
+      this.max = context.docBase + context.reader.maxDoc();
+      this.real = real;
+      if (base != 0) {
+        this.realDoc = real.advance(base);
+      }
+    }
+
+    @Override
+    public int docID() {
+      return adjustedDoc;
+    }
+
+    @Override
+    public int nextDoc() throws IOException {
+      realDoc = realDoc == -1 ? real.nextDoc() : realDoc;
+      adjustedDoc = (realDoc >= base && realDoc < max) ? realDoc - base : NO_MORE_DOCS;
+      realDoc = -1;
+      return adjustedDoc;
+    }
+
+    @Override
+    public int advance(int target) throws IOException {
+      if (target == NO_MORE_DOCS) {
+        return adjustedDoc = NO_MORE_DOCS;
+      }
+
+      realDoc = real.advance(target + base);
+      adjustedDoc = (realDoc >= base && realDoc < max) ? realDoc - base : NO_MORE_DOCS;
+      realDoc = -1;
+      return adjustedDoc;
+    }
+
+  }
+
+}
Index: modules/join/src/test/org/apache/lucene/search/join/TestJoinQuery.java
===================================================================
--- modules/join/src/test/org/apache/lucene/search/join/TestJoinQuery.java	(revision )
+++ modules/join/src/test/org/apache/lucene/search/join/TestJoinQuery.java	(revision )
@@ -0,0 +1,120 @@
+package org.apache.lucene.search.join;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+import org.apache.lucene.analysis.MockAnalyzer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.*;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.LuceneTestCase;
+
+public class TestJoinQuery extends LuceneTestCase {
+
+  public void testSimple() throws Exception {
+    final String idField = "id";
+    final String toField = "productId";
+
+    Directory dir = newDirectory();
+    RandomIndexWriter w = new RandomIndexWriter(
+        random,
+        dir,
+        newIndexWriterConfig(TEST_VERSION_CURRENT,
+            new MockAnalyzer(random)).setMergePolicy(newLogMergePolicy()));
+
+    // 0
+    Document doc = new Document();
+    doc.add(new Field("description", "random text", TextField.TYPE_STORED));
+    doc.add(new Field("name", "name1", TextField.TYPE_STORED));
+    doc.add(new Field(idField, "1", TextField.TYPE_STORED));
+    w.addDocument(doc);
+
+    // 1
+    doc = new Document();
+    doc.add(new Field("price", "10.0", TextField.TYPE_STORED));
+    doc.add(new Field(idField, "2", TextField.TYPE_STORED));
+    doc.add(new Field(toField, "1", TextField.TYPE_STORED));
+    w.addDocument(doc);
+
+    // 2
+    doc = new Document();
+    doc.add(new Field("price", "20.0", TextField.TYPE_STORED));
+    doc.add(new Field(idField, "3", TextField.TYPE_STORED));
+    doc.add(new Field(toField, "1", TextField.TYPE_STORED));
+    w.addDocument(doc);
+
+    // 3
+    doc = new Document();
+    doc.add(new Field("description", "more random text", TextField.TYPE_STORED));
+    doc.add(new Field("name", "name2", TextField.TYPE_STORED));
+    doc.add(new Field(idField, "4", TextField.TYPE_STORED));
+    w.addDocument(doc);
+    w.commit();
+
+    // 4
+    doc = new Document();
+    doc.add(new Field("price", "10.0", TextField.TYPE_STORED));
+    doc.add(new Field(idField, "5", TextField.TYPE_STORED));
+    doc.add(new Field(toField, "4", TextField.TYPE_STORED));
+    w.addDocument(doc);
+
+    // 5
+    doc = new Document();
+    doc.add(new Field("price", "20.0", TextField.TYPE_STORED));
+    doc.add(new Field(idField, "6", TextField.TYPE_STORED));
+    doc.add(new Field(toField, "4", TextField.TYPE_STORED));
+    w.addDocument(doc);
+
+    IndexSearcher indexSearcher = new IndexSearcher(w.getReader());
+    w.close();
+
+    // Search for product
+    JoinQuery.Builder builder = new JoinQuery.Builder()
+        .setFromField(idField)
+        .setToField(toField)
+        .setActualQuery(new TermQuery(new Term("name", "name2")));
+
+    TopDocs result = indexSearcher.search(builder.build(), 10);
+    assertEquals(2, result.totalHits);
+    assertEquals(4, result.scoreDocs[0].doc);
+    assertEquals(5, result.scoreDocs[1].doc);
+
+    builder.setActualQuery(new TermQuery(new Term("name", "name1")));
+    result = indexSearcher.search(builder.build(), 10);
+    assertEquals(2, result.totalHits);
+    assertEquals(1, result.scoreDocs[0].doc);
+    assertEquals(2, result.scoreDocs[1].doc);
+
+    // Search for offer
+    builder = new JoinQuery.Builder()
+        .setFromField(toField)
+        .setToField(idField)
+        .setActualQuery(new TermQuery(new Term("id", "5")));
+
+    result = indexSearcher.search(builder.build(), 10);
+    assertEquals(1, result.totalHits);
+    assertEquals(3, result.scoreDocs[0].doc);
+
+    indexSearcher.getIndexReader().close();
+    dir.close();
+  }
+
+}
