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