diff options
author | Robert Newson <rnewson@apache.org> | 2023-05-01 10:50:56 +0100 |
---|---|---|
committer | Robert Newson <rnewson@apache.org> | 2023-05-01 10:51:00 +0100 |
commit | 30096d3b9192d7c89b72b483f7b1b4d0e71df1fa (patch) | |
tree | 0b04ecffd6cbb636c83fb2fadbe83b97acf989fe | |
parent | fe445bb95c8fc6c0836f31017a98b41736200a71 (diff) | |
download | couchdb-30096d3b9192d7c89b72b483f7b1b4d0e71df1fa.tar.gz |
initial geo thoughts
think I'll fork StandardSyntaxParser.jj to make it tidier though
10 files changed, 366 insertions, 6 deletions
diff --git a/nouveau/src/main/java/org/apache/couchdb/nouveau/api/Field.java b/nouveau/src/main/java/org/apache/couchdb/nouveau/api/Field.java index 52d5b815f..98439d22d 100644 --- a/nouveau/src/main/java/org/apache/couchdb/nouveau/api/Field.java +++ b/nouveau/src/main/java/org/apache/couchdb/nouveau/api/Field.java @@ -28,9 +28,11 @@ import jakarta.validation.constraints.Pattern; property = "@type") @JsonSubTypes({ @JsonSubTypes.Type(value = DoubleField.class, name = "double"), + @JsonSubTypes.Type(value = LatLonField.class, name = "latlon"), @JsonSubTypes.Type(value = StoredField.class, name = "stored"), @JsonSubTypes.Type(value = StringField.class, name = "string"), @JsonSubTypes.Type(value = TextField.class, name = "text"), + @JsonSubTypes.Type(value = XYField.class, name = "xy"), }) public abstract class Field { diff --git a/nouveau/src/main/java/org/apache/couchdb/nouveau/api/LatLonField.java b/nouveau/src/main/java/org/apache/couchdb/nouveau/api/LatLonField.java new file mode 100644 index 000000000..e40f360aa --- /dev/null +++ b/nouveau/src/main/java/org/apache/couchdb/nouveau/api/LatLonField.java @@ -0,0 +1,43 @@ +// +// 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 org.apache.couchdb.nouveau.api; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public final class LatLonField extends Field { + + private final double lon; + + private final double lat; + + public LatLonField(@JsonProperty("name") final String name, @JsonProperty("lat") final double lat, + @JsonProperty("lon") final double lon) { + super(name); + this.lat = lat; + this.lon = lon; + } + + public double getLat() { + return lat; + } + + public double getLon() { + return lon; + } + + @Override + public String toString() { + return "LatLonField [name=" + name + ", lon=" + lon + ", lat=" + lat + "]"; + } + +} diff --git a/nouveau/src/main/java/org/apache/couchdb/nouveau/api/XYField.java b/nouveau/src/main/java/org/apache/couchdb/nouveau/api/XYField.java new file mode 100644 index 000000000..c4cacf033 --- /dev/null +++ b/nouveau/src/main/java/org/apache/couchdb/nouveau/api/XYField.java @@ -0,0 +1,43 @@ +// +// 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 org.apache.couchdb.nouveau.api; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public final class XYField extends Field { + + private final float x; + + private final float y; + + public XYField(@JsonProperty("name") final String name, @JsonProperty("x") final float x, + @JsonProperty("y") final float y) { + super(name); + this.x = x; + this.y = y; + } + + public float getX() { + return x; + } + + public float getY() { + return y; + } + + @Override + public String toString() { + return "XYField [name=" + name + ", x=" + x + ", y=" + y + "]"; + } + +} diff --git a/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/Lucene9Index.java b/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/Lucene9Index.java index 02818f41f..13ab650ca 100644 --- a/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/Lucene9Index.java +++ b/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/Lucene9Index.java @@ -37,12 +37,14 @@ import org.apache.couchdb.nouveau.api.DocumentUpdateRequest; import org.apache.couchdb.nouveau.api.DoubleField; import org.apache.couchdb.nouveau.api.DoubleRange; import org.apache.couchdb.nouveau.api.Field; +import org.apache.couchdb.nouveau.api.LatLonField; import org.apache.couchdb.nouveau.api.SearchHit; import org.apache.couchdb.nouveau.api.SearchRequest; import org.apache.couchdb.nouveau.api.SearchResults; import org.apache.couchdb.nouveau.api.StoredField; import org.apache.couchdb.nouveau.api.StringField; import org.apache.couchdb.nouveau.api.TextField; +import org.apache.couchdb.nouveau.api.XYField; import org.apache.couchdb.nouveau.core.IOUtils; import org.apache.couchdb.nouveau.core.Index; import org.apache.couchdb.nouveau.core.ser.ByteArrayWrapper; @@ -416,6 +418,14 @@ public class Lucene9Index extends Index { } else { throw new WebApplicationException(field + " is not valid", Status.BAD_REQUEST); } + } else if (field instanceof XYField) { + var f = (XYField) field; + result.add(new org.apache.lucene.document.XYPointField(f.getName(), f.getX(), f.getY())); + result.add(new org.apache.lucene.document.XYDocValuesField(f.getName(), f.getX(), f.getY())); + } else if (field instanceof LatLonField) { + var f = (LatLonField) field; + result.add(new org.apache.lucene.document.LatLonPoint(f.getName(), f.getLat(), f.getLon())); + result.add(new org.apache.lucene.document.LatLonDocValuesField(f.getName(), f.getLat(), f.getLon())); } else { throw new WebApplicationException(field + " is not valid", Status.BAD_REQUEST); } diff --git a/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/NouveauQueryParser.java b/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/NouveauQueryParser.java index 6aad65cd4..e6b159f0f 100644 --- a/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/NouveauQueryParser.java +++ b/nouveau/src/main/java/org/apache/couchdb/nouveau/lucene9/NouveauQueryParser.java @@ -16,24 +16,67 @@ package org.apache.couchdb.nouveau.lucene9; import java.text.NumberFormat; import java.text.ParseException; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.document.XYPointField; import org.apache.lucene.queryparser.flexible.core.QueryNodeException; import org.apache.lucene.queryparser.flexible.core.QueryParserHelper; +import org.apache.lucene.queryparser.flexible.core.builders.QueryTreeBuilder; +import org.apache.lucene.queryparser.flexible.core.nodes.BooleanQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.BoostQueryNode; import org.apache.lucene.queryparser.flexible.core.nodes.FieldQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.FieldableNode; +import org.apache.lucene.queryparser.flexible.core.nodes.FuzzyQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.GroupQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.MatchAllDocsQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.MatchNoDocsQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.ModifierQueryNode; import org.apache.lucene.queryparser.flexible.core.nodes.QueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.QueryNodeImpl; import org.apache.lucene.queryparser.flexible.core.nodes.RangeQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.SlopQueryNode; +import org.apache.lucene.queryparser.flexible.core.nodes.TokenizedPhraseQueryNode; +import org.apache.lucene.queryparser.flexible.core.parser.EscapeQuerySyntax; import org.apache.lucene.queryparser.flexible.core.processors.NoChildOptimizationQueryNodeProcessor; import org.apache.lucene.queryparser.flexible.core.processors.QueryNodeProcessorImpl; import org.apache.lucene.queryparser.flexible.core.processors.QueryNodeProcessorPipeline; import org.apache.lucene.queryparser.flexible.core.processors.RemoveDeletedQueryNodesProcessor; -import org.apache.lucene.queryparser.flexible.standard.builders.StandardQueryTreeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.BooleanQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.BoostQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.DummyQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.FieldQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.FuzzyQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.GroupQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.IntervalQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.MatchAllDocsQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.MatchNoDocsQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.MinShouldMatchNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.ModifierQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.MultiPhraseQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.PhraseQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.PointRangeQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.PrefixWildcardQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.RegexpQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.SlopQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.StandardQueryBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.SynonymQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.TermRangeQueryNodeBuilder; +import org.apache.lucene.queryparser.flexible.standard.builders.WildcardQueryNodeBuilder; import org.apache.lucene.queryparser.flexible.standard.config.PointsConfig; import org.apache.lucene.queryparser.flexible.standard.config.StandardQueryConfigHandler; import org.apache.lucene.queryparser.flexible.standard.config.StandardQueryConfigHandler.ConfigurationKeys; +import org.apache.lucene.queryparser.flexible.standard.nodes.IntervalQueryNode; +import org.apache.lucene.queryparser.flexible.standard.nodes.MinShouldMatchNode; +import org.apache.lucene.queryparser.flexible.standard.nodes.MultiPhraseQueryNode; import org.apache.lucene.queryparser.flexible.standard.nodes.PointQueryNode; import org.apache.lucene.queryparser.flexible.standard.nodes.PointRangeQueryNode; +import org.apache.lucene.queryparser.flexible.standard.nodes.PrefixWildcardQueryNode; +import org.apache.lucene.queryparser.flexible.standard.nodes.RegexpQueryNode; +import org.apache.lucene.queryparser.flexible.standard.nodes.SynonymQueryNode; import org.apache.lucene.queryparser.flexible.standard.nodes.TermRangeQueryNode; +import org.apache.lucene.queryparser.flexible.standard.nodes.WildcardQueryNode; import org.apache.lucene.queryparser.flexible.standard.parser.StandardSyntaxParser; import org.apache.lucene.queryparser.flexible.standard.processors.AllowLeadingWildcardProcessor; import org.apache.lucene.queryparser.flexible.standard.processors.AnalyzerQueryNodeProcessor; @@ -61,7 +104,7 @@ public final class NouveauQueryParser extends QueryParserHelper { new StandardQueryConfigHandler(), new StandardSyntaxParser(), new NouveauQueryNodeProcessorPipeline(), - new StandardQueryTreeBuilder()); + new NouveauQueryTreeBuilder()); getQueryConfigHandler().set(ConfigurationKeys.ENABLE_POSITION_INCREMENTS, true); getQueryConfigHandler().set(ConfigurationKeys.ANALYZER, analyzer); } @@ -76,7 +119,7 @@ public final class NouveauQueryParser extends QueryParserHelper { * PointQueryNodeProcessor and PointRangeQueryNodeProcessor for * NouveauPointProcessor below. */ - public static class NouveauQueryNodeProcessorPipeline extends QueryNodeProcessorPipeline { + private static class NouveauQueryNodeProcessorPipeline extends QueryNodeProcessorPipeline { public NouveauQueryNodeProcessorPipeline() { super(null); @@ -87,6 +130,7 @@ public final class NouveauQueryParser extends QueryParserHelper { add(new MatchAllDocsQueryNodeProcessor()); add(new OpenRangeQueryNodeProcessor()); add(new NouveauPointProcessor()); + add(new NouveauXYProcessor()); add(new TermRangeQueryNodeProcessor()); add(new AllowLeadingWildcardProcessor()); add(new AnalyzerQueryNodeProcessor()); @@ -103,10 +147,42 @@ public final class NouveauQueryParser extends QueryParserHelper { } } + private static class NouveauQueryTreeBuilder extends QueryTreeBuilder implements StandardQueryBuilder { + + public NouveauQueryTreeBuilder() { + setBuilder(GroupQueryNode.class, new GroupQueryNodeBuilder()); + setBuilder(FieldQueryNode.class, new FieldQueryNodeBuilder()); + setBuilder(BooleanQueryNode.class, new BooleanQueryNodeBuilder()); + setBuilder(FuzzyQueryNode.class, new FuzzyQueryNodeBuilder()); + setBuilder(PointQueryNode.class, new DummyQueryNodeBuilder()); + setBuilder(PointRangeQueryNode.class, new PointRangeQueryNodeBuilder()); + setBuilder(BoostQueryNode.class, new BoostQueryNodeBuilder()); + setBuilder(ModifierQueryNode.class, new ModifierQueryNodeBuilder()); + setBuilder(WildcardQueryNode.class, new WildcardQueryNodeBuilder()); + setBuilder(TokenizedPhraseQueryNode.class, new PhraseQueryNodeBuilder()); + setBuilder(MatchNoDocsQueryNode.class, new MatchNoDocsQueryNodeBuilder()); + setBuilder(PrefixWildcardQueryNode.class, new PrefixWildcardQueryNodeBuilder()); + setBuilder(TermRangeQueryNode.class, new TermRangeQueryNodeBuilder()); + setBuilder(RegexpQueryNode.class, new RegexpQueryNodeBuilder()); + setBuilder(SlopQueryNode.class, new SlopQueryNodeBuilder()); + setBuilder(SynonymQueryNode.class, new SynonymQueryNodeBuilder()); + setBuilder(MultiPhraseQueryNode.class, new MultiPhraseQueryNodeBuilder()); + setBuilder(MatchAllDocsQueryNode.class, new MatchAllDocsQueryNodeBuilder()); + setBuilder(MinShouldMatchNode.class, new MinShouldMatchNodeBuilder()); + setBuilder(IntervalQueryNode.class, new IntervalQueryNodeBuilder()); + setBuilder(XYBoxQueryNode.class, new XYBoxQueryNodeBuilder()); + } + + @Override + public Query build(QueryNode queryNode) throws QueryNodeException { + return (Query) super.build(queryNode); + } + } + /** * If it looks like a number, treat it as a number. */ - public static class NouveauPointProcessor extends QueryNodeProcessorImpl { + private static class NouveauPointProcessor extends QueryNodeProcessorImpl { @Override protected QueryNode postProcessNode(final QueryNode node) throws QueryNodeException { @@ -178,4 +254,103 @@ public final class NouveauQueryParser extends QueryParserHelper { } + private static class NouveauXYProcessor extends QueryNodeProcessorImpl { + + private final Pattern p = Pattern + .compile("%(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?),(\\d+(?:\\.\\d+)?)"); + + @Override + protected QueryNode postProcessNode(final QueryNode node) throws QueryNodeException { + if (node instanceof FieldQueryNode && !(node.getParent() instanceof RangeQueryNode)) { + final var fieldNode = (FieldQueryNode) node; + String text = fieldNode.getTextAsString(); + if (text.length() == 0) { + return node; + } + final Matcher m = p.matcher(text); + if (m.matches()) { + System.err.println("YAY"); + final float minX = Float.parseFloat(m.group(1)); + final float maxX = Float.parseFloat(m.group(2)); + final float minY = Float.parseFloat(m.group(3)); + final float maxY = Float.parseFloat(m.group(4)); + return new XYBoxQueryNode(fieldNode.getFieldAsString(), minX, maxX, minY, maxY); + } + } + return node; + } + + @Override + protected QueryNode preProcessNode(final QueryNode node) throws QueryNodeException { + return node; + } + + @Override + protected List<QueryNode> setChildrenOrder(final List<QueryNode> children) throws QueryNodeException { + return children; + } + + } + + private static class XYBoxQueryNode extends QueryNodeImpl implements FieldableNode { + + private CharSequence field; + + private final float minX, maxX, minY, maxY; + + public XYBoxQueryNode(String field, float minX, float maxX, float minY, float maxY) { + this.field = field; + this.minX = minX; + this.maxX = maxX; + this.minY = minY; + this.maxY = maxY; + } + + @Override + public CharSequence toQueryString(EscapeQuerySyntax escapeSyntaxParser) { + final String value = String.format("<%f,%f,%f,%f>", minX, maxX, minY, maxY); + if (isDefaultField(this.field)) { + return value; + } else { + return this.field + ":" + value; + } + } + + @Override + public CharSequence getField() { + return field; + } + + public float getMinX() { + return minX; + } + + public float getMinY() { + return minY; + } + + public float getMaxX() { + return maxX; + } + + public float getMaxY() { + return maxY; + } + + @Override + public void setField(CharSequence field) { + this.field = field; + } + } + + private static class XYBoxQueryNodeBuilder implements StandardQueryBuilder { + + @Override + public Query build(QueryNode queryNode) throws QueryNodeException { + var n = (XYBoxQueryNode) queryNode; + return XYPointField.newBoxQuery(n.getField().toString(), n.getMinX(), n.getMaxX(), n.getMinY(), + n.getMaxY()); + } + } + }
\ No newline at end of file diff --git a/nouveau/src/test/java/org/apache/couchdb/nouveau/lucene9/Lucene9IndexTest.java b/nouveau/src/test/java/org/apache/couchdb/nouveau/lucene9/Lucene9IndexTest.java index f6d47e61a..88792ba79 100644 --- a/nouveau/src/test/java/org/apache/couchdb/nouveau/lucene9/Lucene9IndexTest.java +++ b/nouveau/src/test/java/org/apache/couchdb/nouveau/lucene9/Lucene9IndexTest.java @@ -30,9 +30,12 @@ import org.apache.couchdb.nouveau.api.DoubleRange; import org.apache.couchdb.nouveau.api.Field; import org.apache.couchdb.nouveau.api.IndexDefinition; import org.apache.couchdb.nouveau.api.IndexInfo; +import org.apache.couchdb.nouveau.api.LatLonField; import org.apache.couchdb.nouveau.api.SearchRequest; import org.apache.couchdb.nouveau.api.SearchResults; +import org.apache.couchdb.nouveau.api.StoredField; import org.apache.couchdb.nouveau.api.StringField; +import org.apache.couchdb.nouveau.api.XYField; import org.apache.couchdb.nouveau.core.Index; import org.apache.couchdb.nouveau.core.IndexLoader; import org.apache.couchdb.nouveau.core.UpdatesOutOfOrderException; @@ -72,7 +75,14 @@ public class Lucene9IndexTest { try { final int count = 100; for (int i = 1; i <= count; i++) { - final Collection<Field> fields = List.of(new StringField("foo", "bar", false)); + final Collection<Field> fields = List.of( + new StringField("foo", "bar", true), + new DoubleField("bar", 12.0, true), + new StoredField("baz", "bar"), + new StoredField("foobar", 12.0), + new XYField("foobaz", 12.f, 12.0f), + new LatLonField("bazbar", 12.0, 12.0) + ); final DocumentUpdateRequest request = new DocumentUpdateRequest(i, null, fields); index.update("doc" + i, request); } diff --git a/nouveau/src/test/resources/fixtures/DocumentUpdateRequest.json b/nouveau/src/test/resources/fixtures/DocumentUpdateRequest.json index a22e322d4..3b0aafd20 100644 --- a/nouveau/src/test/resources/fixtures/DocumentUpdateRequest.json +++ b/nouveau/src/test/resources/fixtures/DocumentUpdateRequest.json @@ -17,6 +17,18 @@ "@type": "double", "name": "doublefoo", "value": 12 + }, + { + "@type": "xy", + "name": "xy", + "x": 12.0, + "y": 12.0 + }, + { + "@type": "latlon", + "name": "latlon", + "lajt": 12.0, + "lon": 12.0 } ] } diff --git a/share/server/nouveau.js b/share/server/nouveau.js index 8c75d4a25..f107d59e4 100644 --- a/share/server/nouveau.js +++ b/share/server/nouveau.js @@ -83,6 +83,30 @@ var Nouveau = (function () { 'value': value }); break; + case 'xy': + var x = arguments[2]; + var y = arguments[3]; + assertType('x', 'number', x); + assertType('y', 'number', y); + index_results.push({ + '@type': type, + 'name': name, + 'x': x, + 'y': y + }); + break; + case 'latlon': + var lat = arguments[2]; + assertType('lat', 'number', lat); + var lon = arguments[3]; + assertType('lon', 'number', lon); + index_results.push({ + '@type': type, + 'name': name, + 'lat': lat, + 'lon': lon + }); + break; default: throw ({ name: 'TypeError', message: type + ' not supported' }); } diff --git a/test/elixir/test/config/nouveau.elixir b/test/elixir/test/config/nouveau.elixir index 5c13aac2b..3dc22aafa 100644 --- a/test/elixir/test/config/nouveau.elixir +++ b/test/elixir/test/config/nouveau.elixir @@ -17,6 +17,7 @@ "mango search by string", "search GET (partitioned)", "search POST (partitioned)", - "mango (partitioned)" + "mango (partitioned)", + "geo search xy" ] } diff --git a/test/elixir/test/nouveau_test.exs b/test/elixir/test/nouveau_test.exs index 3bea874d9..93059194b 100644 --- a/test/elixir/test/nouveau_test.exs +++ b/test/elixir/test/nouveau_test.exs @@ -360,4 +360,44 @@ defmodule NouveauTest do assert ids == ["bar:doc3"] end + @tag :with_db + test "geo search xy", context do + db_name = context[:db_name] + + # create docs + resp = Couch.post("/#{db_name}/_bulk_docs", + headers: ["Content-Type": "application/json"], + body: %{:docs => [ + %{"_id" => "doc1", "x" => 12, "y" => 100}, + %{"_id" => "doc2", "x" => 100, "y" => 12}, + ]} + ) + assert resp.status_code in [201] + + # create geo ddoc + ddoc = %{ + nouveau: %{ + bar: %{ + default_analyzer: "standard", + index: """ + function (doc) { + index("xy", "position", doc.x, doc.y); + } + """ + } + } + } + resp = Couch.put("/#{db_name}/_design/foo", body: ddoc) + assert resp.status_code in [201] + assert Map.has_key?(resp.body, "ok") == true + + # search for it + url = "/#{db_name}/_design/foo/_nouveau/bar" + resp = Couch.get(url, query: %{q: "position: %11,13,99,101", include_docs: true}) + assert_status_code(resp, 200) + ids = get_ids(resp) + # nouveau sorts by _id as tie-breaker + assert ids == ["doc1"] + end + end |