diff --git a/appengine/datastore/geo/pom.xml b/appengine/datastore/geo/pom.xml
new file mode 100644
index 00000000000..d647e4dbe36
--- /dev/null
+++ b/appengine/datastore/geo/pom.xml
@@ -0,0 +1,94 @@
+
+
+ 4.0.0
+ war
+ 1.0-SNAPSHOT
+ com.example.appengine
+ appengine-datastore-geo
+
+
+ com.google.cloud
+ doc-samples
+ 1.0.0
+ ../../..
+
+
+
+
+ com.google.appengine
+ appengine-api-1.0-sdk
+ ${appengine.sdk.version}
+
+
+ javax.servlet
+ servlet-api
+ jar
+ provided
+
+
+
+
+ junit
+ junit
+ 4.10
+ test
+
+
+ org.mockito
+ mockito-all
+ 1.10.19
+ test
+
+
+ com.google.appengine
+ appengine-testing
+ ${appengine.sdk.version}
+ test
+
+
+ com.google.appengine
+ appengine-api-stubs
+ ${appengine.sdk.version}
+ test
+
+
+ com.google.appengine
+ appengine-tools-sdk
+ ${appengine.sdk.version}
+ test
+
+
+ com.google.truth
+ truth
+ 0.28
+ test
+
+
+
+
+
+ ${project.build.directory}/${project.build.finalName}/WEB-INF/classes
+
+
+
+ com.google.appengine
+ appengine-maven-plugin
+ ${appengine.sdk.version}
+
+
+
+
diff --git a/appengine/datastore/geo/src/main/java/com/example/appengine/GeoServlet.java b/appengine/datastore/geo/src/main/java/com/example/appengine/GeoServlet.java
new file mode 100644
index 00000000000..694e2107ef9
--- /dev/null
+++ b/appengine/datastore/geo/src/main/java/com/example/appengine/GeoServlet.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2016 Google Inc. All Rights Reserved.
+ *
+ * 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.example.appengine;
+
+import com.google.appengine.api.datastore.DatastoreService;
+import com.google.appengine.api.datastore.DatastoreServiceFactory;
+import com.google.appengine.api.datastore.Entity;
+import com.google.appengine.api.datastore.FetchOptions;
+import com.google.appengine.api.datastore.GeoPt;
+import com.google.appengine.api.datastore.Query;
+import com.google.appengine.api.datastore.Query.CompositeFilterOperator;
+import com.google.appengine.api.datastore.Query.Filter;
+import com.google.appengine.api.datastore.Query.FilterOperator;
+import com.google.appengine.api.datastore.Query.FilterPredicate;
+import com.google.appengine.api.datastore.Query.GeoRegion.Circle;
+import com.google.appengine.api.datastore.Query.GeoRegion.Rectangle;
+import com.google.appengine.api.datastore.Query.StContainsFilter;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.List;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * A servlet to demonstrate the use of Cloud Datastore geospatial queries.
+ */
+public class GeoServlet extends HttpServlet {
+ static final String BRAND_PROPERTY = "brand";
+ static final String LOCATION_PROPERTY = "location";
+
+ static final String BRAND_PARAMETER = "brand";
+ static final String LATITUDE_PARAMETER = "lat";
+ static final String LONGITUDE_PARAMETER = "lon";
+ static final String RADIUS_PARAMETER = "r";
+
+ static final String BRAND_DEFAULT = "Ocean Ave Shell";
+ static final String LATITUDE_DEFAULT = "37.7895873";
+ static final String LONGITUDE_DEFAULT = "-122.3917317";
+ static final String RADIUS_DEFAULT = "1000.0";
+
+ // Number of meters (approximately) in 1 degree of latitude.
+ // http://gis.stackexchange.com/a/2964
+ private static final double DEGREE_METERS = 111111.0;
+
+ private final DatastoreService datastore;
+
+ public GeoServlet() {
+ datastore = DatastoreServiceFactory.getDatastoreService();
+ }
+
+ private static String getParameterOrDefault(
+ HttpServletRequest req, String parameter, String defaultValue) {
+ String value = req.getParameter(parameter);
+ if (value == null || value.isEmpty()) {
+ value = defaultValue;
+ }
+ return value;
+ }
+
+ private static GeoPt getOffsetPoint(
+ GeoPt original, double latOffsetMeters, double lonOffsetMeters) {
+ // Approximate the number of degrees to offset by meters.
+ // http://gis.stackexchange.com/a/2964
+ // How long (approximately) is one degree of longitude?
+ double lonDegreeMeters = DEGREE_METERS * Math.cos(original.getLatitude());
+ return new GeoPt(
+ (float) (original.getLatitude() + latOffsetMeters / DEGREE_METERS),
+ // This may cause errors if given points near the north or south pole.
+ (float) (original.getLongitude() + lonOffsetMeters / lonDegreeMeters));
+ }
+
+ @Override
+ public void doGet(HttpServletRequest req, HttpServletResponse resp)
+ throws IOException, ServletException {
+ resp.setContentType("text/plain");
+ resp.setCharacterEncoding("UTF-8");
+ PrintWriter out = resp.getWriter();
+
+ String brand = getParameterOrDefault(req, BRAND_PARAMETER, BRAND_DEFAULT);
+ String latStr = getParameterOrDefault(req, LATITUDE_PARAMETER, LATITUDE_DEFAULT);
+ String lonStr = getParameterOrDefault(req, LONGITUDE_PARAMETER, LONGITUDE_DEFAULT);
+ String radiusStr = getParameterOrDefault(req, RADIUS_PARAMETER, RADIUS_DEFAULT);
+
+ float latitude;
+ float longitude;
+ double r;
+ try {
+ latitude = Float.parseFloat(latStr);
+ longitude = Float.parseFloat(lonStr);
+ r = Double.parseDouble(radiusStr);
+ } catch (IllegalArgumentException e) {
+ resp.sendError(
+ HttpServletResponse.SC_BAD_REQUEST, String.format("Got bad value: %s", e.getMessage()));
+ return;
+ }
+
+ // Get lat/lon for rectangle.
+ GeoPt c = new GeoPt(latitude, longitude);
+ GeoPt ne = getOffsetPoint(c, r, r);
+ float neLat = ne.getLatitude();
+ float neLon = ne.getLongitude();
+ GeoPt sw = getOffsetPoint(c, -1 * r, -1 * r);
+ float swLat = sw.getLatitude();
+ float swLon = sw.getLongitude();
+
+ // [START geospatial_stcontainsfilter_examples]
+ // Testing for containment within a circle
+ GeoPt center = new GeoPt(latitude, longitude);
+ double radius = r; // Value is in meters.
+ Filter f1 = new StContainsFilter("location", new Circle(center, radius));
+ Query q1 = new Query("GasStation").setFilter(f1);
+
+ // Testing for containment within a rectangle
+ GeoPt southwest = new GeoPt(swLat, swLon);
+ GeoPt northeast = new GeoPt(neLat, neLon);
+ Filter f2 = new StContainsFilter("location", new Rectangle(southwest, northeast));
+ Query q2 = new Query("GasStation").setFilter(f2);
+ // [END geospatial_stcontainsfilter_examples]
+
+ List circleResults = datastore.prepare(q1).asList(FetchOptions.Builder.withDefaults());
+ out.printf("Got %d stations in %f meter radius circle.\n", circleResults.size(), radius);
+ printStations(out, circleResults);
+ out.println();
+
+ List rectResults = datastore.prepare(q2).asList(FetchOptions.Builder.withDefaults());
+ out.printf("Got %d stations in rectangle.\n", rectResults.size());
+ printStations(out, rectResults);
+ out.println();
+
+ List brandResults = getStationsWithBrand(center, radius, brand);
+ out.printf("Got %d stations in circle with brand %s.\n", brandResults.size(), brand);
+ printStations(out, brandResults);
+ out.println();
+ }
+
+ private void printStations(PrintWriter out, List stations) {
+ for (Entity station : stations) {
+ GeoPt location = (GeoPt) station.getProperty(LOCATION_PROPERTY);
+ out.printf(
+ "%s: @%f, %f\n",
+ (String) station.getProperty(BRAND_PROPERTY),
+ location.getLatitude(),
+ location.getLongitude());
+ }
+ }
+
+ private List getStationsWithBrand(GeoPt center, double radius, String value) {
+ // [START geospatial_containment_and_equality_combination]
+ Filter f =
+ CompositeFilterOperator.and(
+ new StContainsFilter("location", new Circle(center, radius)),
+ new FilterPredicate("brand", FilterOperator.EQUAL, value));
+ // [END geospatial_containment_and_equality_combination]
+ Query q = new Query("GasStation").setFilter(f);
+ return datastore.prepare(q).asList(FetchOptions.Builder.withDefaults());
+ }
+}
diff --git a/appengine/datastore/geo/src/main/java/com/example/appengine/StartupServlet.java b/appengine/datastore/geo/src/main/java/com/example/appengine/StartupServlet.java
new file mode 100644
index 00000000000..3a60b032730
--- /dev/null
+++ b/appengine/datastore/geo/src/main/java/com/example/appengine/StartupServlet.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2016 Google Inc. All Rights Reserved.
+ *
+ * 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.example.appengine;
+
+import com.google.appengine.api.datastore.DatastoreService;
+import com.google.appengine.api.datastore.DatastoreServiceFactory;
+import com.google.appengine.api.datastore.Entity;
+import com.google.appengine.api.datastore.EntityNotFoundException;
+import com.google.appengine.api.datastore.GeoPt;
+import com.google.appengine.api.datastore.Key;
+import com.google.appengine.api.datastore.KeyFactory;
+
+import java.io.IOException;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * A startup handler to populate the datastore with example entities.
+ */
+public class StartupServlet extends HttpServlet {
+ static final String IS_POPULATED_ENTITY = "IsPopulated";
+ static final String IS_POPULATED_KEY_NAME = "is-populated";
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp)
+ throws ServletException, IOException {
+ resp.setContentType("text/plain");
+ DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
+
+ Key isPopulatedKey = KeyFactory.createKey(IS_POPULATED_ENTITY, IS_POPULATED_KEY_NAME);
+ boolean isAlreadyPopulated;
+ try {
+ datastore.get(isPopulatedKey);
+ isAlreadyPopulated = true;
+ } catch (EntityNotFoundException expected) {
+ isAlreadyPopulated = false;
+ }
+ if (isAlreadyPopulated) {
+ resp.getWriter().println("ok");
+ return;
+ }
+
+ // [START create_entity_with_geopt_property]
+ Entity station = new Entity("GasStation");
+ station.setProperty("brand", "Ocean Ave Shell");
+ station.setProperty("location", new GeoPt(37.7913156f, -122.3926051f));
+ datastore.put(station);
+ // [END create_entity_with_geopt_property]
+
+ station = new Entity("GasStation");
+ station.setProperty("brand", "Charge Point Charging Station");
+ station.setProperty("location", new GeoPt(37.7909778f, -122.3929963f));
+ datastore.put(station);
+
+ station = new Entity("GasStation");
+ station.setProperty("brand", "76");
+ station.setProperty("location", new GeoPt(37.7860533f, -122.3940325f));
+ datastore.put(station);
+
+ datastore.put(new Entity(isPopulatedKey));
+ resp.getWriter().println("ok");
+ }
+}
diff --git a/appengine/datastore/geo/src/main/webapp/WEB-INF/appengine-web.xml b/appengine/datastore/geo/src/main/webapp/WEB-INF/appengine-web.xml
new file mode 100644
index 00000000000..e9d8b21cb8f
--- /dev/null
+++ b/appengine/datastore/geo/src/main/webapp/WEB-INF/appengine-web.xml
@@ -0,0 +1,21 @@
+
+
+
+ YOUR-PROJECT-ID
+ YOUR-VERSION-ID
+ true
+
diff --git a/appengine/datastore/geo/src/main/webapp/WEB-INF/datastore-indexes.xml b/appengine/datastore/geo/src/main/webapp/WEB-INF/datastore-indexes.xml
new file mode 100644
index 00000000000..513b883ed78
--- /dev/null
+++ b/appengine/datastore/geo/src/main/webapp/WEB-INF/datastore-indexes.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/appengine/datastore/geo/src/main/webapp/WEB-INF/web.xml b/appengine/datastore/geo/src/main/webapp/WEB-INF/web.xml
new file mode 100644
index 00000000000..12d26c6bcab
--- /dev/null
+++ b/appengine/datastore/geo/src/main/webapp/WEB-INF/web.xml
@@ -0,0 +1,47 @@
+
+
+
+
+ geo
+ com.example.appengine.GeoServlet
+
+
+ geo
+ /
+
+
+ startup
+ com.example.appengine.StartupServlet
+
+
+ startup
+ /_ah/start
+
+
+
+
+ profile
+ /*
+
+
+ CONFIDENTIAL
+
+
+
diff --git a/appengine/datastore/geo/src/test/java/com/example/appengine/GeoServletTest.java b/appengine/datastore/geo/src/test/java/com/example/appengine/GeoServletTest.java
new file mode 100644
index 00000000000..770126036bc
--- /dev/null
+++ b/appengine/datastore/geo/src/test/java/com/example/appengine/GeoServletTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2016 Google Inc. All Rights Reserved.
+ *
+ * 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.example.appengine;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Matchers.anyString;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
+import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Unit tests for {@link GeoServlet}.
+ */
+@RunWith(JUnit4.class)
+public class GeoServletTest {
+
+ private final LocalServiceTestHelper helper =
+ new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig());
+
+ @Mock private HttpServletRequest mockRequest;
+ @Mock private HttpServletResponse mockResponse;
+ private StringWriter responseWriter;
+ private GeoServlet servletUnderTest;
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+ helper.setUp();
+
+ // Set up a fake HTTP response.
+ responseWriter = new StringWriter();
+ when(mockResponse.getWriter()).thenReturn(new PrintWriter(responseWriter));
+
+ servletUnderTest = new GeoServlet();
+ }
+
+ @After
+ public void tearDown() {
+ helper.tearDown();
+ }
+
+ @Test
+ public void doGet_emptyDatastore_writesNoGasStations() throws Exception {
+ servletUnderTest.doGet(mockRequest, mockResponse);
+
+ assertThat(responseWriter.toString())
+ .named("GeoServlet response")
+ .contains("Got 0 stations");
+ }
+
+ @Test
+ public void doGet_badRadius_returnsError() throws Exception {
+ when(mockRequest.getParameter(GeoServlet.RADIUS_PARAMETER)).thenReturn("this-is-not-a-float");
+
+ servletUnderTest.doGet(mockRequest, mockResponse);
+
+ verify(mockResponse).sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString());
+ }
+}
diff --git a/appengine/datastore/geo/src/test/java/com/example/appengine/StartupServletTest.java b/appengine/datastore/geo/src/test/java/com/example/appengine/StartupServletTest.java
new file mode 100644
index 00000000000..d9c91af0efb
--- /dev/null
+++ b/appengine/datastore/geo/src/test/java/com/example/appengine/StartupServletTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2016 Google Inc. All Rights Reserved.
+ *
+ * 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.example.appengine;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.google.appengine.api.datastore.DatastoreService;
+import com.google.appengine.api.datastore.DatastoreServiceFactory;
+import com.google.appengine.api.datastore.Entity;
+import com.google.appengine.api.datastore.GeoPt;
+import com.google.appengine.api.datastore.Query;
+import com.google.appengine.api.datastore.Query.CompositeFilterOperator;
+import com.google.appengine.api.datastore.Query.Filter;
+import com.google.appengine.api.datastore.Query.FilterOperator;
+import com.google.appengine.api.datastore.Query.FilterPredicate;
+import com.google.appengine.api.datastore.Query.GeoRegion.Circle;
+import com.google.appengine.api.datastore.Query.StContainsFilter;
+import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
+import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Unit tests for {@link StartupServlet}.
+ */
+@RunWith(JUnit4.class)
+public class StartupServletTest {
+
+ private final LocalServiceTestHelper helper =
+ new LocalServiceTestHelper(
+ // Set no eventual consistency, that way queries return all results.
+ // https://cloud.google.com/appengine/docs/java/tools/localunittesting#Java_Writing_High_Replication_Datastore_tests
+ new LocalDatastoreServiceTestConfig()
+ .setDefaultHighRepJobPolicyUnappliedJobPercentage(0));
+
+ @Mock private HttpServletRequest mockRequest;
+ @Mock private HttpServletResponse mockResponse;
+ private StringWriter responseWriter;
+ private DatastoreService datastore;
+
+ private StartupServlet servletUnderTest;
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+ helper.setUp();
+ datastore = DatastoreServiceFactory.getDatastoreService();
+
+ // Set up a fake HTTP response.
+ responseWriter = new StringWriter();
+ when(mockResponse.getWriter()).thenReturn(new PrintWriter(responseWriter));
+
+ servletUnderTest = new StartupServlet();
+ }
+
+ @After
+ public void tearDown() {
+ helper.tearDown();
+ }
+
+ @Test
+ public void doGet_emptyDatastore_writesOkay() throws Exception {
+ servletUnderTest.doGet(mockRequest, mockResponse);
+ assertThat(responseWriter.toString()).named("StartupServlet response").isEqualTo("ok\n");
+ }
+
+ @Test
+ public void doGet_emptyDatastore_writesGasStation() throws Exception {
+ servletUnderTest.doGet(mockRequest, mockResponse);
+
+ GeoPt center = new GeoPt(37.7895873f, -122.3917317f);
+ double radius = 1000.0; // Radius in meters.
+ String value = "Ocean Ave Shell";
+ Filter f =
+ CompositeFilterOperator.and(
+ new StContainsFilter("location", new Circle(center, radius)),
+ new FilterPredicate("brand", FilterOperator.EQUAL, value));
+ Query q = new Query("GasStation").setFilter(f);
+ Entity result = datastore.prepare(q).asSingleEntity();
+ assertThat(result.getProperty("brand")).named("brand").isEqualTo("Ocean Ave Shell");
+ }
+
+ @Test
+ public void doGet_alreadyPopulated_writesOkay() throws Exception {
+ datastore.put(
+ new Entity(StartupServlet.IS_POPULATED_ENTITY, StartupServlet.IS_POPULATED_KEY_NAME));
+ servletUnderTest.doGet(mockRequest, mockResponse);
+ assertThat(responseWriter.toString()).named("StartupServlet response").isEqualTo("ok\n");
+ }
+}
diff --git a/pom.xml b/pom.xml
index 96369b9ed33..ba2de2f3ab9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -48,6 +48,7 @@
appengine/channel
appengine/cloudsql
appengine/datastore
+ appengine/datastore/geo
appengine/datastore/indexes
appengine/datastore/indexes-exploding
appengine/datastore/indexes-perfect
diff --git a/travis.sh b/travis.sh
index 7065ce69a80..5e546fb5cee 100755
--- a/travis.sh
+++ b/travis.sh
@@ -26,6 +26,7 @@ mvn --batch-mode clean verify -DskipTests=$SKIP_TESTS | egrep -v "(^\[INFO\] Dow
# Run tests using App Engine local devserver.
devserver_tests=(
+ appengine/datastore/geo
appengine/datastore/indexes
appengine/datastore/indexes-exploding
appengine/datastore/indexes-perfect