From 2230b51a79e78474b2ca7305edaff694d41b690d Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Mon, 10 Jul 2017 10:51:05 +0200 Subject: [PATCH] DATAMONGO-1733 - Added support for projections on FluentMongoOperations. Interfaces based projections handed to queries built using the FluentMongoOperations APIs now get projected as expected and also apply querying optimizations so that only fields needed in the projection are read in the first place. Original pull request: #486. --- .../data/mongodb/core/MongoTemplate.java | 97 +++++++++- .../ExecutableFindOperationSupportTests.java | 165 +++++++++++++++--- .../mongodb/core/MongoTemplateUnitTests.java | 100 ++++++++++- src/main/asciidoc/reference/mongodb.adoc | 49 ++++++ 4 files changed, 375 insertions(+), 36 deletions(-) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java index 3037fe06b..d86671b8f 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java @@ -22,6 +22,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.RequiredArgsConstructor; +import java.beans.PropertyDescriptor; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; @@ -112,11 +113,14 @@ import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; import org.springframework.data.mongodb.util.MongoClientVersion; +import org.springframework.data.projection.ProjectionInformation; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.util.CloseableIterator; import org.springframework.data.util.Optionals; import org.springframework.data.util.Pair; import org.springframework.jca.cci.core.ConnectionCallback; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ResourceUtils; @@ -176,6 +180,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, private static final String ID_FIELD = "_id"; private static final WriteResultChecking DEFAULT_WRITE_RESULT_CHECKING = WriteResultChecking.NONE; private static final Collection ITERABLE_CLASSES; + public static final SpelAwareProxyProjectionFactory PROJECTION_FACTORY = new SpelAwareProxyProjectionFactory(); static { @@ -372,14 +377,14 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, MongoPersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entityType); - Document mappedFields = queryMapper.getMappedFields(query.getFieldsObject(), persistentEntity); + Document mappedFields = getMappedFieldsObject(query.getFieldsObject(), persistentEntity, returnType); Document mappedQuery = queryMapper.getMappedObject(query.getQueryObject(), persistentEntity); FindIterable cursor = new QueryCursorPreparer(query, entityType) .prepare(collection.find(mappedQuery).projection(mappedFields)); return new CloseableIterableCursorAdapter(cursor, exceptionTranslator, - new ReadDocumentCallback(mongoConverter, returnType, collectionName)); + new ProjectingReadCallback<>(mongoConverter, entityType, returnType, collectionName)); } }); } @@ -694,7 +699,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, results = results == null ? Collections.emptyList() : results; DocumentCallback> callback = new GeoNearResultDocumentCallback( - new ReadDocumentCallback(mongoConverter, returnType, collectionName), near.getMetric()); + new ProjectingReadCallback<>(mongoConverter, domainType, returnType, collectionName), near.getMetric()); List> result = new ArrayList>(results.size()); int index = 0; @@ -2037,7 +2042,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, MongoPersistentEntity entity = mappingContext.getRequiredPersistentEntity(sourceClass); - Document mappedFields = queryMapper.getMappedFields(fields, entity); + Document mappedFields = getMappedFieldsObject(fields, entity, targetClass); Document mappedQuery = queryMapper.getMappedObject(query, entity); if (LOGGER.isDebugEnabled()) { @@ -2046,7 +2051,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, } return executeFindMultiInternal(new FindCallback(mappedQuery, mappedFields), preparer, - new ReadDocumentCallback(mongoConverter, targetClass, collectionName), collectionName); + new ProjectingReadCallback<>(mongoConverter, sourceClass, targetClass, collectionName), collectionName); } protected Document convertToDocument(CollectionOptions collectionOptions) { @@ -2331,6 +2336,37 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, return queryMapper.getMappedSort(query.getSortObject(), mappingContext.getPersistentEntity(type)); } + private Document getMappedFieldsObject(Document fields, MongoPersistentEntity entity, Class targetType) { + return queryMapper.getMappedFields(addFieldsForProjection(fields, entity.getType(), targetType), entity); + } + + /** + * For cases where {@code fields} is {@literal null} or {@literal empty} add fields required for creating the + * projection (target) type if the {@code targetType} is a {@literal closed interface projection}. + * + * @param fields can be {@literal null}. + * @param domainType must not be {@literal null}. + * @param targetType must not be {@literal null}. + * @return {@link Document} with fields to be included. + */ + private Document addFieldsForProjection(Document fields, Class domainType, Class targetType) { + + if ((fields != null && !fields.isEmpty()) || !targetType.isInterface() + || ClassUtils.isAssignable(domainType, targetType)) { + return fields; + } + + ProjectionInformation projectionInformation = PROJECTION_FACTORY.getProjectionInformation(targetType); + if (projectionInformation.isClosed()) { + + for (PropertyDescriptor descriptor : projectionInformation.getInputProperties()) { + fields.append(descriptor.getName(), 1); + } + } + + return fields; + } + /** * Tries to convert the given {@link RuntimeException} into a {@link DataAccessException} but returns the original * exception if the conversation failed. Thus allows safe re-throwing of the return value. @@ -2568,6 +2604,57 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, } } + /** + * {@link DocumentCallback} transforming {@link Document} into the given {@code targetType} or decorating the + * {@code sourceType} with a {@literal projection} in case the {@code targetType} is an {@litera interface}. + * + * @param + * @param + * @since 2.0 + */ + class ProjectingReadCallback implements DocumentCallback { + + private final Class entityType; + private final Class targetType; + private final String collectionName; + private final EntityReader reader; + + ProjectingReadCallback(EntityReader reader, Class entityType, Class targetType, + String collectionName) { + + this.reader = reader; + this.entityType = entityType; + this.targetType = targetType; + this.collectionName = collectionName; + } + + public T doWith(Document object) { + + if (null != object) { + maybeEmitEvent(new AfterLoadEvent<>(object, targetType, collectionName)); + } + + T target = doRead(object, entityType, targetType); + + if (null != target) { + maybeEmitEvent(new AfterConvertEvent<>(object, target, collectionName)); + } + + return target; + } + + private T doRead(Document source, Class entityType, Class targetType) { + + if (targetType != entityType && targetType.isInterface()) { + + S target = (S) reader.read(entityType, source); + return (T) PROJECTION_FACTORY.createProjection(targetType, target); + } + + return (T) reader.read(targetType, source); + } + } + class UnwrapAndReadDocumentCallback extends ReadDocumentCallback { public UnwrapAndReadDocumentCallback(EntityReader reader, Class type, String collectionName) { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java index 369a753b7..889e4054b 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java @@ -26,10 +26,12 @@ import java.util.stream.Stream; import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.data.annotation.Id; import org.springframework.data.geo.GeoResults; import org.springframework.data.geo.Point; +import org.springframework.data.mongodb.core.ExecutableFindOperation.TerminatingFindOperation; import org.springframework.data.mongodb.core.index.GeoSpatialIndexType; import org.springframework.data.mongodb.core.index.GeospatialIndex; import org.springframework.data.mongodb.core.mapping.Field; @@ -47,27 +49,27 @@ import com.mongodb.MongoClient; public class ExecutableFindOperationSupportTests { private static final String STAR_WARS = "star-wars"; + private static final String STAR_WARS_PLANETS = "star-wars-universe"; MongoTemplate template; Person han; Person luke; + Planet alderan; + Planet dantooine; + @Before public void setUp() { template = new MongoTemplate(new SimpleMongoDbFactory(new MongoClient(), "ExecutableFindOperationSupportTests")); template.dropCollection(STAR_WARS); + template.dropCollection(STAR_WARS_PLANETS); - han = new Person(); - han.firstname = "han"; - han.id = "id-1"; - - luke = new Person(); - luke.firstname = "luke"; - luke.id = "id-2"; + template.indexOps(Planet.class).ensureIndex( + new GeospatialIndex("coordinates").typed(GeoSpatialIndexType.GEO_2DSPHERE).named("planet-coordinate-idx")); - template.save(han); - template.save(luke); + initPersons(); + initPlanets(); } @Test(expected = IllegalArgumentException.class) // DATAMONGO-1563 @@ -100,6 +102,13 @@ public class ExecutableFindOperationSupportTests { assertThat(template.query(Person.class).as(Jedi.class).all()).hasOnlyElementsOfType(Jedi.class).hasSize(2); } + @Test // DATAMONGO-1733 + public void findByReturningAllValuesAsClosedInterfaceProjection() { + + assertThat(template.query(Person.class).as(PersonProjection.class).all()) + .hasOnlyElementsOfTypes(PersonProjection.class); + } + @Test // DATAMONGO-1563 public void findAllBy() { @@ -166,6 +175,26 @@ public class ExecutableFindOperationSupportTests { .isIn(han, luke); } + @Test // DATAMONGO-1733 + public void findByReturningFirstValueAsClosedInterfaceProjection() { + + PersonProjection result = template.query(Person.class).as(PersonProjection.class) + .matching(query(where("firstname").is("han"))).firstValue(); + + assertThat(result).isInstanceOf(PersonProjection.class); + assertThat(result.getFirstname()).isEqualTo("han"); + } + + @Test // DATAMONGO-1733 + public void findByReturningFirstValueAsOpenInterfaceProjection() { + + PersonSpELProjection result = template.query(Person.class).as(PersonSpELProjection.class) + .matching(query(where("firstname").is("han"))).firstValue(); + + assertThat(result).isInstanceOf(PersonSpELProjection.class); + assertThat(result.getName()).isEqualTo("han"); + } + @Test // DATAMONGO-1563 public void streamAll() { @@ -190,6 +219,33 @@ public class ExecutableFindOperationSupportTests { } } + @Test // DATAMONGO-1733 + public void streamAllReturningResultsAsClosedInterfaceProjection() { + + TerminatingFindOperation operation = template.query(Person.class).as(PersonProjection.class); + + assertThat(operation.stream()) // + .hasSize(2) // + .allSatisfy(it -> { + assertThat(it).isInstanceOf(PersonProjection.class); + assertThat(it.getFirstname()).isNotBlank(); + }); + } + + @Test // DATAMONGO-1733 + public void streamAllReturningResultsAsOpenInterfaceProjection() { + + TerminatingFindOperation operation = template.query(Person.class) + .as(PersonSpELProjection.class); + + assertThat(operation.stream()) // + .hasSize(2) // + .allSatisfy(it -> { + assertThat(it).isInstanceOf(PersonSpELProjection.class); + assertThat(it.getName()).isNotBlank(); + }); + } + @Test // DATAMONGO-1563 public void streamAllBy() { @@ -201,15 +257,6 @@ public class ExecutableFindOperationSupportTests { @Test // DATAMONGO-1563 public void findAllNearBy() { - template.indexOps(Planet.class).ensureIndex( - new GeospatialIndex("coordinates").typed(GeoSpatialIndexType.GEO_2DSPHERE).named("planet-coordinate-idx")); - - Planet alderan = new Planet("alderan", new Point(-73.9836, 40.7538)); - Planet dantooine = new Planet("dantooine", new Point(-73.9928, 40.7193)); - - template.save(alderan); - template.save(dantooine); - GeoResults results = template.query(Planet.class).near(NearQuery.near(-73.9667, 40.78).spherical(true)) .all(); assertThat(results.getContent()).hasSize(2); @@ -219,16 +266,7 @@ public class ExecutableFindOperationSupportTests { @Test // DATAMONGO-1563 public void findAllNearByWithCollectionAndProjection() { - template.indexOps(Planet.class).ensureIndex( - new GeospatialIndex("coordinates").typed(GeoSpatialIndexType.GEO_2DSPHERE).named("planet-coordinate-idx")); - - Planet alderan = new Planet("alderan", new Point(-73.9836, 40.7538)); - Planet dantooine = new Planet("dantooine", new Point(-73.9928, 40.7193)); - - template.save(alderan); - template.save(dantooine); - - GeoResults results = template.query(Object.class).inCollection(STAR_WARS).as(Human.class) + GeoResults results = template.query(Object.class).inCollection(STAR_WARS_PLANETS).as(Human.class) .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all(); assertThat(results.getContent()).hasSize(2); @@ -237,6 +275,32 @@ public class ExecutableFindOperationSupportTests { assertThat(results.getContent().get(0).getContent().getId()).isEqualTo("alderan"); } + @Test // DATAMONGO-1733 + public void findAllNearByReturningGeoResultContentAsClosedInterfaceProjection() { + + GeoResults results = template.query(Planet.class).as(PlanetProjection.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all(); + + assertThat(results.getContent()).allSatisfy(it -> { + + assertThat(it.getContent()).isInstanceOf(PlanetProjection.class); + assertThat(it.getContent().getName()).isNotBlank(); + }); + } + + @Test // DATAMONGO-1733 + public void findAllNearByReturningGeoResultContentAsOpenInterfaceProjection() { + + GeoResults results = template.query(Planet.class).as(PlanetSpELProjection.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all(); + + assertThat(results.getContent()).allSatisfy(it -> { + + assertThat(it.getContent()).isInstanceOf(PlanetSpELProjection.class); + assertThat(it.getContent().getId()).isNotBlank(); + }); + } + @Test // DATAMONGO-1728 public void firstShouldReturnFirstEntryInCollection() { assertThat(template.query(Person.class).first()).isNotEmpty(); @@ -286,6 +350,16 @@ public class ExecutableFindOperationSupportTests { String firstname; } + interface PersonProjection { + String getFirstname(); + } + + public interface PersonSpELProjection { + + @Value("#{target.firstname}") + String getName(); + } + @Data static class Human { @Id String id; @@ -299,10 +373,43 @@ public class ExecutableFindOperationSupportTests { @Data @AllArgsConstructor - @org.springframework.data.mongodb.core.mapping.Document(collection = STAR_WARS) + @org.springframework.data.mongodb.core.mapping.Document(collection = STAR_WARS_PLANETS) static class Planet { @Id String name; Point coordinates; } + + interface PlanetProjection { + String getName(); + } + + interface PlanetSpELProjection { + + @Value("#{target.name}") + String getId(); + } + + private void initPersons() { + + han = new Person(); + han.firstname = "han"; + han.id = "id-1"; + + luke = new Person(); + luke.firstname = "luke"; + luke.id = "id-2"; + + template.save(han); + template.save(luke); + } + + private void initPlanets() { + + alderan = new Planet("alderan", new Point(-73.9836, 40.7538)); + dantooine = new Planet("dantooine", new Point(-73.9928, 40.7193)); + + template.save(alderan); + template.save(dantooine); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java index f4de63154..dedbbb056 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java @@ -22,6 +22,8 @@ import static org.mockito.Mockito.any; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; import static org.springframework.data.mongodb.test.util.IsBsonObject.*; +import lombok.Data; + import java.math.BigInteger; import java.util.Collections; import java.util.List; @@ -44,6 +46,7 @@ import org.mockito.Matchers; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.convert.converter.Converter; import org.springframework.dao.DataAccessException; @@ -61,6 +64,7 @@ import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.data.mongodb.core.convert.MongoCustomConversions; import org.springframework.data.mongodb.core.convert.QueryMapper; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator; +import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener; import org.springframework.data.mongodb.core.mapping.event.BeforeConvertEvent; @@ -782,12 +786,70 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { public void groupShouldUseCollationWhenPresent() { commandResultDocument.append("retval", Collections.emptySet()); - template.group("collection-1", GroupBy.key("id").reduceFunction("bar").collation(Collation.of("fr")), AutogenerateableId.class); + template.group("collection-1", GroupBy.key("id").reduceFunction("bar").collation(Collation.of("fr")), + AutogenerateableId.class); ArgumentCaptor cmd = ArgumentCaptor.forClass(Document.class); verify(db).runCommand(cmd.capture(), Mockito.any(Class.class)); - assertThat(cmd.getValue().get("group", Document.class).get("collation", Document.class), equalTo(new Document("locale", "fr"))); + assertThat(cmd.getValue().get("group", Document.class).get("collation", Document.class), + equalTo(new Document("locale", "fr"))); + } + + @Test // DATAMONGO-1733 + public void appliesFieldsWhenInterfaceProjectionIsClosedAndQueryDoesNotDefineFields() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonProjection.class, null); + + verify(findIterable).projection(eq(new Document("firstname", 1))); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenInterfaceProjectionIsClosedAndQueryDefinesFields() { + + template.doFind("star-wars", new Document(), new Document("bar", 1), Person.class, PersonProjection.class, null); + + verify(findIterable).projection(eq(new Document("bar", 1))); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenInterfaceProjectionIsOpen() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonSpELProjection.class, null); + + verify(findIterable, never()).projection(any()); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsToDtoProjection() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, Jedi.class, null); + + verify(findIterable, never()).projection(any()); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsToDtoProjectionWhenQueryDefinesFields() { + + template.doFind("star-wars", new Document(), new Document("bar", 1), Person.class, Jedi.class, null); + + verify(findIterable).projection(eq(new Document("bar", 1))); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenTargetIsNotAProjection() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, Person.class, null); + + verify(findIterable, never()).projection(any()); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenTargetExtendsDomainType() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonExtended.class, null); + + verify(findIterable, never()).projection(any()); } class AutogenerateableId { @@ -819,6 +881,40 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { } } + @Data + @org.springframework.data.mongodb.core.mapping.Document(collection = "star-wars") + static class Person { + + @Id String id; + String firstname; + } + + static class PersonExtended extends Person { + + String lastname; + } + + interface PersonProjection { + String getFirstname(); + } + + public interface PersonSpELProjection { + + @Value("#{target.firstname}") + String getName(); + } + + @Data + static class Human { + @Id String id; + } + + @Data + static class Jedi { + + @Field("firstname") String name; + } + class Wrapper { AutogenerateableId foo; diff --git a/src/main/asciidoc/reference/mongodb.adoc b/src/main/asciidoc/reference/mongodb.adoc index 5cd978ad5..2b14aa85c 100644 --- a/src/main/asciidoc/reference/mongodb.adoc +++ b/src/main/asciidoc/reference/mongodb.adoc @@ -1425,6 +1425,55 @@ AggregationResults results = template.aggregate(aggregation, "tags", T WARNING: Indexes are only used if the collation used for the operation and the index collation matches. +[[mongo.query.fluent-template-api]] +=== Fluent Template API + +The `MongoOperations` interface is one of the central components when it comes to more low level interaction with MongoDB. It offers a wide range of methods covering needs from collection / index creation and CRUD operations to more advanced functionality like map-reduce and aggregations. +One can find multiple overloads for each and every method. Most of them just cover optional / nullable parts of the API. + +`FluentMongoOperations` provide a more narrow interface for common methods of `MongoOperations` providing a more readable, fluent API. +The entry points `insert(…)`, `find(…)`, `update(…)`, etc. follow a natural naming schema based on the operation to execute. Moving on from the entry point the API is designed to only offer context dependent methods guiding towards a terminating method that invokes the actual `MongoOperations` counterpart. + +==== +[source,java] +---- +List all = ops.find(SWCharacter.class) + .inCollection("star-wars") <1> + .all(); +---- +<1> Skip this step if `SWCharacter` defines the collection via `@Document` or if using the class name as the collection name is just fine. +==== + +Sometimes a collection in MongoDB holds entities of different types. Like a `Jedi` within a collection of `SWCharacters`. +To use different types for `Query` and return value mapping one can use `as(Class targetType)` map results differently. + +==== +[source,java] +---- +List all = ops.find(SWCharacter.class) <1> + .as(Jedi.class) <2> + .matching(query(where("jedi").is(true))) + .all(); +---- +<1> The query fields are mapped against the `SWCharacter` type. +<2> Resulting documents are mapped into `Jedi`. +==== + +TIP: It is possible to directly apply <> to resulting documents by providing just the `interface` type via `as(Class)`. + +Switching between retrieving a single entity, multiple ones as `List` or `Stream` like is done via the terminating methods `first()`, `one()`, `all()` or `stream()`. + +When writing a geo-spatial query via `near(NearQuery)` the number of terminating methods is altered to just the ones valid for executing a `geoNear` command in MongoDB fetching entities as `GeoResult` within `GeoResults`. + +==== +[source,java] +---- +GeoResults results = mongoOps.query(SWCharacter.class) + .as(Jedi.class) + .near(alderaan) // NearQuery.near(-73.9667, 40.78).maxDis… + .all(); +---- +==== include::../{spring-data-commons-docs}/query-by-example.adoc[leveloffset=+1] include::query-by-example.adoc[leveloffset=+1]