diff --git a/mongodb/fluent-api/README.md b/mongodb/fluent-api/README.md new file mode 100644 index 00000000..274bc382 --- /dev/null +++ b/mongodb/fluent-api/README.md @@ -0,0 +1,63 @@ +# Spring Data MongoDB 2.0 - FluentMongoOperations Example + +This project contains usage samples of `FluentMongoOperations`. + +## Fluent API + +`FluentMongoOperations` provides a stripped down, more focused API alternative for classic `MongoOperations`. +The main entry points are typical tasks for finding and manipulating ``Document``s on a domain type base, while operations like creating indexes are left out. + +For convenience classic `MongoOperations` extend `FluentMongoOperations`, however for most cases it might be sufficient to just work with the reduced interface. +To get started just inject `FluentMongoOperations`. + +The entry point methods of `FluentMongoOperations` provide you with an immutable fluent API allowing only valid next steps while constructing the operation to execute. This allows to set up operations once and keep those in memory for multiple executions. + +### Query + +Looking at the following `query(SWCharacter.class)`. + +`SWCharacter` is used for mapping the query properties to the actual document field names. As the `SWCharacter` defines `@Field("firstname") name;` the `where` clause of the query is mapped to the MongoDB representation as `{ firstname : "luke" }`. + +Using `as(Jedi.class)` switches return type mapping from `SWCharacter` to `Jedi` which allows to map resulting documents to a different type than the one used for query mapping. + +So far no actual query execution has been invoked. Calling on of the terminating methods like `one()`, `first()`, `all()`,... triggers the query. + +```java +mongoOps.query(SWCharacter.class) + .inCollection("star-wars") + .as(Jedi.class) + .matching(query(where("name").is("luke"))) + .one(); + +``` + +Different stages in the command essembly process allow to seamlessly switch to different API paths. Using `near` instead of `matching` switches to the path for geo queries requireing the presence of a `NearQuery` while altering the command result type from `List` to `GeoResults` and limiting terminating operations to just `all()`. + +```java + +NearQuery alderaanWithin3Parsecs = NearQuery.near(-73.9667, 40.78) + .maxDistance(new Distance(3, MILES)) + .spherical(true); + +GeoResults results = mongoOps.query(SWCharacter.class) + .as(Jedi.class) + .near(alderaanWithin3Parsecs) + .all(); +``` + + +### Update + +Looking at the following `update(Jedi.class)`. + +`Jedi` already defines the `collection` via the `@Document` annotation, so there is no need to explicitly specify a collection name via `inCollection(String)`. The `Jedi` domain type is also used for query and update mapping. + +So far no actual query execution has been invoked. Calling on of the terminating methods like `all()`, `upsert()`, `findAndModify()`, etc. triggers the update. +```java + + +mongoOps.update(Jedi.class) + .matching(query(where("lastname").is("windu"))) + .apply(update("name", "mence")) + .upsert(); +``` diff --git a/mongodb/fluent-api/pom.xml b/mongodb/fluent-api/pom.xml new file mode 100644 index 00000000..f7053046 --- /dev/null +++ b/mongodb/fluent-api/pom.xml @@ -0,0 +1,15 @@ + + 4.0.0 + + spring-data-mongodb-fluent-api + + Spring Data MongoDB - Fluent API Example + + + org.springframework.data.examples + spring-data-mongodb-examples + 1.0.0.BUILD-SNAPSHOT + + + diff --git a/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Human.java b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Human.java new file mode 100644 index 00000000..28f9c552 --- /dev/null +++ b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Human.java @@ -0,0 +1,28 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 example.springdata.mongodb.fluent; + +import lombok.Data; + +/** + * @author Christoph Strobl + */ +@Data +class Human { + + final String firstname; + final String lastname; +} diff --git a/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Jedi.java b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Jedi.java new file mode 100644 index 00000000..5ed805b7 --- /dev/null +++ b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Jedi.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 example.springdata.mongodb.fluent; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +/** + * @author Christoph Strobl + */ +@Getter +@EqualsAndHashCode(callSuper = true) +@ToString +class Jedi extends SWCharacter { + + private final String lastname; + + Jedi(String name, String lastname) { + + super(name); + this.lastname = lastname; + } + +} diff --git a/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Planet.java b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Planet.java new file mode 100644 index 00000000..e4cf4755 --- /dev/null +++ b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Planet.java @@ -0,0 +1,32 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 example.springdata.mongodb.fluent; + +import lombok.Data; + +import org.springframework.data.annotation.Id; +import org.springframework.data.geo.Point; +import org.springframework.data.mongodb.core.index.GeoSpatialIndexed; + +/** + * @author Christoph Strobl + */ +@Data +public class Planet { + + final @Id String name; + final @GeoSpatialIndexed Point coordinates; +} diff --git a/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/SWCharacter.java b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/SWCharacter.java new file mode 100644 index 00000000..62d1b3db --- /dev/null +++ b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/SWCharacter.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 example.springdata.mongodb.fluent; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import lombok.Setter; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +/** + * @author Christoph Strobl + */ +@Document(collection = "star-wars") +@Getter +@Setter +@EqualsAndHashCode(of = "name") +class SWCharacter { + + private @Id String id; + private @Field("firstname") String name; + private Planet homePlanet; + + SWCharacter(String name) { + this.name = name; + } +} diff --git a/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Sith.java b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Sith.java new file mode 100644 index 00000000..0ce7ec9b --- /dev/null +++ b/mongodb/fluent-api/src/main/java/example/springdata/mongodb/fluent/Sith.java @@ -0,0 +1,27 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 example.springdata.mongodb.fluent; + +import org.springframework.beans.factory.annotation.Value; + +/** + * @author Christoph Strobl + */ +public interface Sith { + + @Value("#{target.name + ' ' + target.lastname}") + String getName(); +} diff --git a/mongodb/fluent-api/src/test/java/example/springdata/mongodb/fluent/ApplicationConfiguration.java b/mongodb/fluent-api/src/test/java/example/springdata/mongodb/fluent/ApplicationConfiguration.java new file mode 100644 index 00000000..da985a27 --- /dev/null +++ b/mongodb/fluent-api/src/test/java/example/springdata/mongodb/fluent/ApplicationConfiguration.java @@ -0,0 +1,75 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 example.springdata.mongodb.fluent; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.data.geo.Point; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.GeoSpatialIndexType; +import org.springframework.data.mongodb.core.index.GeospatialIndex; + +/** + * @author Christoph Strobl + */ +@SpringBootApplication +public class ApplicationConfiguration { + + static final String COLLECTION = "star-wars"; + + @Bean + CommandLineRunner init(MongoTemplate template) { + + return (args) -> { + + if (template.collectionExists(COLLECTION)) { + template.dropCollection(COLLECTION); + } + + GeospatialIndex index = new GeospatialIndex("homePlanet.coordinates") // + .typed(GeoSpatialIndexType.GEO_2DSPHERE) // + .named("planet-coordinate-idx"); + + template.createCollection(COLLECTION); + template.indexOps(SWCharacter.class).ensureIndex(index); + + Planet alderaan = new Planet("alderaan", new Point(-73.9667, 40.78)); + Planet stewjon = new Planet("stewjon", new Point(-73.9836, 40.7538)); + Planet tatooine = new Planet("tatooine", new Point(-73.9928, 40.7193)); + + Jedi anakin = new Jedi("anakin", "skywalker"); + anakin.setHomePlanet(tatooine); + + Jedi luke = new Jedi("luke", "skywalker"); + luke.setHomePlanet(tatooine); + + Jedi leia = new Jedi("leia", "organa"); + leia.setHomePlanet(alderaan); + + Jedi obiWan = new Jedi("obi-wan", "kenobi"); + obiWan.setHomePlanet(stewjon); + + Human han = new Human("han", "solo"); + + template.save(anakin, COLLECTION); + template.save(luke, COLLECTION); + template.save(leia, COLLECTION); + template.save(obiWan, COLLECTION); + template.save(han, COLLECTION); + }; + } +} diff --git a/mongodb/fluent-api/src/test/java/example/springdata/mongodb/fluent/FluentMongoApiTests.java b/mongodb/fluent-api/src/test/java/example/springdata/mongodb/fluent/FluentMongoApiTests.java new file mode 100644 index 00000000..2f123370 --- /dev/null +++ b/mongodb/fluent-api/src/test/java/example/springdata/mongodb/fluent/FluentMongoApiTests.java @@ -0,0 +1,256 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 example.springdata.mongodb.fluent; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.geo.Metrics.*; +import static org.springframework.data.mongodb.core.query.Criteria.*; +import static org.springframework.data.mongodb.core.query.Query.*; +import static org.springframework.data.mongodb.core.query.Update.*; + +import java.util.List; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.GeoResults; +import org.springframework.data.mongodb.core.ExecutableFindOperation.TerminatingFind; +import org.springframework.data.mongodb.core.FluentMongoOperations; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.NearQuery; +import org.springframework.test.context.junit4.SpringRunner; + +import com.mongodb.client.result.UpdateResult; + +/** + * Some tests showing usage and capabilities of {@link FluentMongoOperations}.
+ * Please note index and testdata setup in {@link ApplicationConfiguration} with + *
+ *
3 Planets
+ *
alderaan, stewjon, tatooine
+ *
4 Jedis
+ *
anakin, leia, luke, obi-wan
+ *
1 Human
+ *
han
+ *
+ * + * @author Christoph Strobl + */ +@RunWith(SpringRunner.class) +@SpringBootTest +public class FluentMongoApiTests { + + @Autowired FluentMongoOperations mongoOps; + + /** + * A predefined, reusable lookup method. + */ + TerminatingFind findLuke; + + final NearQuery alderaanWithin3Parsecs = NearQuery.near(-73.9667, 40.78).maxDistance(new Distance(3, MILES)) + .spherical(true); + + @Before + public void setUp() { + + findLuke = mongoOps.query(SWCharacter.class) // SWCharacter does only define the collection, id and name + .as(Jedi.class) // so we use Jedi as the desired return type to also map "lastname" + .matching(query(where("name").is("luke"))); // for all with a matching "name" that maps to "firstname". + } + + /** + * Use the predefined lookup method {@link #findLuke} to query the {@literal star-wars} collection derived from + * {@link SWCharacter}. The originating domain type {@link SWCharacter} is used for mapping the query to + * {@link org.bson.Document}s which results in { firstname : "luke" } as {@link SWCharacter#name} is + * mapped to {@literal firstname} via {@link org.springframework.data.mongodb.core.mapping.Field}.
+ * For return type mapping {@link Jedi} is used which allows also reading back the {@link Jedi#lastname} next to the + * {@literal firstname}.
+ * The samples below would read something like the following using classic {@link MongoOperations}. + * + *
+	 *     
+	 *         This is not possible with classic MongoOperations.
+	 *     
+	 * 
+ */ + @Test + public void usePredefinedFinder() { + assertThat(findLuke.one()).contains(new Jedi("luke", "skywalker")); + } + + /** + * Using {@code as(java.lang.Class)} allows to not only map resulting {@link org.bson.Document}s into a given class + * but also creating interfaces, limiting access to properties.
+ * {@link Sith#getName()} defines a SpEL expression for {@code firstname + " " + lastname} of the target object via + * {@link org.springframework.beans.factory.annotation.Value}.
+ * The samples below would read something like the following using classic {@link MongoOperations}. + * + *
+	 *     
+	 *         This is not possible with classic MongoOperations.
+	 *     
+	 * 
+ */ + @Test + public void fetchInterfaceProjection() { + + Sith anakin = mongoOps.query(SWCharacter.class) // SWCharacter does only define the collection, id and name + .as(Sith.class) // use an interface as return type to create a projection + .matching(query(where("firstname").is("anakin"))) // so properties are taken as is + .oneValue(); + + assertThat(anakin.getName()).isEqualTo("anakin skywalker"); + } + + /** + * The difference between the terminating methods {@link TerminatingFind#first()} and {@link TerminatingFind#one()} is + * that {@code fist()} returns the very first entry found or none at all. Therefore the query execution is limited to + * exactly {@code 1} element via {@link com.mongodb.client.FindIterable#limit(int)};
+ * {@code one()} on the other hand makes a hard assumption on cardinality of the query result and expects exactly one + * element to match the query or none at all. If more than one match is found {@code one()} raises an + * {@link IncorrectResultSizeDataAccessException}. Under the hood, the query execution is limited to {@code 2} + * elements.
+ * The samples below would read something like the following using classic {@link MongoOperations}. + * + *
+	 *     
+	 *         // find first();
+	 *         return template.find(query(where("lastname").is("skywalker")).limit(1), SWCharacter.class, "star-wars")
+	 *             .iterator()
+	 *             .next();
+	 *
+	 *         // find one();
+	 *         List result =  template.find(query(where("lastname").is("skywalker")).limit(2), SWCharacter.class, "star-wars");
+	 *		   if(result.size() > 1) {
+	 *		       throw new IncorrectResultSize...
+	 *		   }
+	 *		   return result.iterator().next();
+	 *     
+	 * 
+ */ + @Test + public void queryFirstVsOne() { + + mongoOps.query(SWCharacter.class) // SWCharacter does only define the collection, id and name + .matching(query(where("lastname").is("skywalker"))) // so properties are taken as is + .first(); // and we'll just get the first whatever entry if there is. + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) // + .isThrownBy(() -> { + + mongoOps.query(SWCharacter.class) // SWCharacter does only define the collection, id and name + .matching(query(where("lastname").is("skywalker"))) // so properties are taken as is + .one(); // and we expect there is only one entry matching. Not more! But obviously there is. + }); + } + + /** + * GeoNear operations can be executed via {@code near} which needs to be given a {@link NearQuery}. By doing so the + * API will from then on only provide methods suitable for executing a {@literal near} query. In this case options + * like {@code first()} or {@code one()} are no longer available. Even the return type for {@code all()} switches from + * {@link List} to {@link GeoResults}.
+ * Still it is possible to map the {@code content} of a single {@link org.springframework.data.geo.GeoResult} to a + * different type using {@code as(Class)}.
+ * The samples below would read something like the following using classic {@link MongoOperations}. + * + *
+	 *     
+	 *         template.geoNear(alderaanWithin3Parsecs, SWCharacter.class, "star-wars", Jedi.class);
+	 *     
+	 * 
+ */ + @Test + public void geoNearQuery() { + + GeoResults results = mongoOps.query(SWCharacter.class) // SWCharacter defines collection, id and name + .as(Jedi.class) // but we want to map the results to Jedi + .near(alderaanWithin3Parsecs) // and find those with home planet near alderaan + .all(); + + assertThat(results.getContent()).hasSize(2); + } + + /** + * In this case {@link Human} does not have an explicit {@link org.springframework.data.mongodb.core.mapping.Document} + * annotation which results in {@literal human} as the default collection name. Via {@code inCollection(String)} it is + * possible to override the default and set it to whatever collection should be queried.
+ * As there is no different return type declared via {@code as(String)}, the originating domain type {@link Human} is + * used for both query and result mapping.
+ * The sample below would read something like the following using classic {@link MongoOperations}. + * + *
+	 *     
+	 *         template.find(query(where("lastname").is("skywalker")), Human.class, "star-wars");
+	 *     
+	 * 
+ */ + @Test + public void querySpecificCollection() { + + List skywalkers = mongoOps.query(Human.class) // Human does not define a collection via @Document + .inCollection("star-wars") // so we set an explicit collection name + .matching(query(where("lastname").is("skywalker"))) // to find all documents with a matching "lastname" + .all(); + + assertThat(skywalkers).containsExactlyInAnyOrder(new Human("anakin", "skywalker"), new Human("luke", "skywalker")); + } + + /** + * Simple insert operation adding a new {@link Jedi} to the {@literal star-wars} collection. + */ + @Test + public void justInsertOne() { + + SWCharacter chewbacca = new SWCharacter("Chewbacca"); + + mongoOps.insert(SWCharacter.class).one(chewbacca); + + assertThat(chewbacca.getId()).isNotBlank(); + } + + /** + * {@link FluentMongoOperations#update(Class)} defines the entry point for performing modifications on potentially + * already existing document without replacing the entire document. The domain type is used for both mapping the query + * identifying the potential update candidates as well as the property mapping for the + * {@link org.springframework.data.mongodb.core.query.Update} itself.
+ * The sample below would read something like the following using classic {@link MongoOperations}. + * + *
+	 *     
+	 *         template.upsert(query(where("lastname").is("windu")), update("name", "mence"), Jedi.class, "star-wars");
+	 *     
+	 * 
+ */ + @Test + public void updateAndUpsert() { + + UpdateResult result = mongoOps.update(Jedi.class) // Jedi defines the collection and field mapping + .matching(query(where("lastname").is("windu"))) // so "last" maps to "lastname". + .apply(update("name", "mence")) // We'll update the "firstname" to "mence" + .upsert(); // and add a new document if it does not exist already. + + assertThat(result.getMatchedCount()).isEqualTo(0); + assertThat(result.getUpsertedId()).isNotNull(); + + assertThat( + mongoOps.query(Human.class).inCollection("star-wars").matching(query(where("firstname").is("mence"))).one()) + .contains(new Human("mence", "windu")); + } +} diff --git a/mongodb/pom.xml b/mongodb/pom.xml index 0b7b4857..c8229f5d 100644 --- a/mongodb/pom.xml +++ b/mongodb/pom.xml @@ -25,6 +25,7 @@ geo-json query-by-example reactive + fluent-api @@ -59,4 +60,4 @@ - \ No newline at end of file +