From 580a50b40fcabd425fcea32d3381e68709908293 Mon Sep 17 00:00:00 2001 From: Subhashni Balakrishnan Date: Thu, 20 Sep 2018 12:33:17 -0700 Subject: [PATCH] DATACOUCH-388 Add support for ANSI-JOIN across entities Motivation ---------- To support ANSI JOIN across associated entities Changes ------- N1qlJoin annotation on an associated entity field is discovered by the couchbaseTemplate, which uses N1qlJoinResolver to build and resolve the query. The query can be resolved eagerly or lazily based on the fetch type configuration. The retrieved results are then mapped to the associated entity. In the lazy resolver, a proxy is set on the property which resolves on the first access. Results ------- Verified by unit and integration tests. ANSI join is now possible across entities. Original PR: #174 --- .../couchbase/repository/join/Author.java | 59 ++++++ .../join/AuthorAndBookPopulatorListener.java | 50 ++++++ .../repository/join/AuthorRepository.java | 23 +++ .../data/couchbase/repository/join/Book.java | 48 +++++ .../repository/join/BookRepository.java | 23 +++ .../repository/join/N1qlJoinTests.java | 78 ++++++++ src/integration/resources/server.properties | 2 +- .../couchbase/core/CouchbaseTemplate.java | 20 +++ .../convert/MappingCouchbaseConverter.java | 5 +- .../core/convert/join/N1qlJoinResolver.java | 166 +++++++++++++++++ .../data/couchbase/core/query/FetchType.java | 34 ++++ .../data/couchbase/core/query/HashSide.java | 51 ++++++ .../data/couchbase/core/query/N1qlJoin.java | 75 ++++++++ .../convert/join/N1qlJoinResolverTest.java | 169 ++++++++++++++++++ 14 files changed, 801 insertions(+), 2 deletions(-) create mode 100644 src/integration/java/org/springframework/data/couchbase/repository/join/Author.java create mode 100644 src/integration/java/org/springframework/data/couchbase/repository/join/AuthorAndBookPopulatorListener.java create mode 100644 src/integration/java/org/springframework/data/couchbase/repository/join/AuthorRepository.java create mode 100644 src/integration/java/org/springframework/data/couchbase/repository/join/Book.java create mode 100644 src/integration/java/org/springframework/data/couchbase/repository/join/BookRepository.java create mode 100644 src/integration/java/org/springframework/data/couchbase/repository/join/N1qlJoinTests.java create mode 100644 src/main/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolver.java create mode 100644 src/main/java/org/springframework/data/couchbase/core/query/FetchType.java create mode 100644 src/main/java/org/springframework/data/couchbase/core/query/HashSide.java create mode 100644 src/main/java/org/springframework/data/couchbase/core/query/N1qlJoin.java create mode 100644 src/test/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolverTest.java diff --git a/src/integration/java/org/springframework/data/couchbase/repository/join/Author.java b/src/integration/java/org/springframework/data/couchbase/repository/join/Author.java new file mode 100644 index 00000000..f79592ed --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/join/Author.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2019 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 org.springframework.data.couchbase.repository.join; +import java.util.List; + +import com.couchbase.client.java.repository.annotation.Field; +import org.springframework.data.annotation.Id; +import org.springframework.data.couchbase.core.query.FetchType; +import org.springframework.data.couchbase.core.query.N1qlJoin; +import org.springframework.data.couchbase.core.query.N1qlPrimaryIndexed; + +/** + * Author test class for N1QL Join tests + */ +@N1qlPrimaryIndexed +public class Author { + @Id + String id; + + @Field("name") + String name; + + @N1qlJoin(on = "lks.name=rks.authorName", fetchType = FetchType.IMMEDIATE) + List books; + + public Author(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public void setBooks(List books) { + this.books = books; + } + + public List getBooks() { + return books; + } +} \ No newline at end of file diff --git a/src/integration/java/org/springframework/data/couchbase/repository/join/AuthorAndBookPopulatorListener.java b/src/integration/java/org/springframework/data/couchbase/repository/join/AuthorAndBookPopulatorListener.java new file mode 100644 index 00000000..20f1f363 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/join/AuthorAndBookPopulatorListener.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2019 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 org.springframework.data.couchbase.repository.join; + +import com.couchbase.client.java.Bucket; +import com.couchbase.client.java.cluster.ClusterInfo; +import org.springframework.data.couchbase.config.BeanNames; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.support.DependencyInjectionTestExecutionListener; + +/** + * Populates author and book documents for N1ql Join tests + * + */ +public class AuthorAndBookPopulatorListener extends DependencyInjectionTestExecutionListener { + + @Override + public void beforeTestClass(final TestContext testContext) throws Exception { + Bucket client = (Bucket) testContext.getApplicationContext().getBean(BeanNames.COUCHBASE_BUCKET); + ClusterInfo clusterInfo = (ClusterInfo) testContext.getApplicationContext().getBean(BeanNames.COUCHBASE_CLUSTER_INFO); + populateTestData(client, clusterInfo); + } + + void populateTestData(Bucket client, ClusterInfo clusterInfo) { + CouchbaseTemplate template = new CouchbaseTemplate(clusterInfo, client); + for(int i=0;i<5;i++) { + Author author = new Author("Author" + i,"foo"+ i); + template.save(author); + for (int j=0;j<5;j++) { + Book book = new Book("Book" + i+j, "foo"+i, ""); + template.save(book); + } + } + } + +} \ No newline at end of file diff --git a/src/integration/java/org/springframework/data/couchbase/repository/join/AuthorRepository.java b/src/integration/java/org/springframework/data/couchbase/repository/join/AuthorRepository.java new file mode 100644 index 00000000..ebdba3a2 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/join/AuthorRepository.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-2019 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 org.springframework.data.couchbase.repository.join; + +import org.springframework.data.couchbase.core.query.N1qlPrimaryIndexed; +import org.springframework.data.couchbase.repository.CouchbaseRepository; + +@N1qlPrimaryIndexed +public interface AuthorRepository extends CouchbaseRepository { +} diff --git a/src/integration/java/org/springframework/data/couchbase/repository/join/Book.java b/src/integration/java/org/springframework/data/couchbase/repository/join/Book.java new file mode 100644 index 00000000..c607c1ce --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/join/Book.java @@ -0,0 +1,48 @@ +/* + * Copyright 2012-2019 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 org.springframework.data.couchbase.repository.join; + +import org.springframework.data.annotation.Id; + +/** + * Book test class for N1QL Join tests + */ +public class Book { + @Id + String name; + + String authorName; + + String description; + + public Book(String name, String authorName, String description) { + this.name = name; + this.authorName = authorName; + this.description = description; + } + + public String getName() { + return this.name; + } + + public String getAuthorName() { + return this.authorName; + } + + public String getDescription() { + return this.description; + } +} \ No newline at end of file diff --git a/src/integration/java/org/springframework/data/couchbase/repository/join/BookRepository.java b/src/integration/java/org/springframework/data/couchbase/repository/join/BookRepository.java new file mode 100644 index 00000000..8d085fda --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/join/BookRepository.java @@ -0,0 +1,23 @@ +/* + * Copyright 2012-2019 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 org.springframework.data.couchbase.repository.join; + +import org.springframework.data.couchbase.core.query.N1qlSecondaryIndexed; +import org.springframework.data.couchbase.repository.CouchbaseRepository; + +@N1qlSecondaryIndexed(indexName = "bookIndex") +interface BookRepository extends CouchbaseRepository { +} \ No newline at end of file diff --git a/src/integration/java/org/springframework/data/couchbase/repository/join/N1qlJoinTests.java b/src/integration/java/org/springframework/data/couchbase/repository/join/N1qlJoinTests.java new file mode 100644 index 00000000..5d3516d9 --- /dev/null +++ b/src/integration/java/org/springframework/data/couchbase/repository/join/N1qlJoinTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2019 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 org.springframework.data.couchbase.repository.join; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.couchbase.ContainerResourceRunner; +import org.springframework.data.couchbase.IntegrationTestApplicationConfig; +import org.springframework.data.couchbase.repository.config.RepositoryOperationsMapping; +import org.springframework.data.couchbase.repository.support.CouchbaseRepositoryFactory; +import org.springframework.data.couchbase.repository.support.IndexManager; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestExecutionListeners; + +/** + * N1ql Join tests + * + * @author Subhashni Balakrishnan + */ +@RunWith(ContainerResourceRunner.class) +@ContextConfiguration(classes = IntegrationTestApplicationConfig.class) +@TestExecutionListeners(listeners = {AuthorAndBookPopulatorListener.class}) +public class N1qlJoinTests { + + @Autowired + private RepositoryOperationsMapping operationsMapping; + + @Autowired + private IndexManager indexManager; + + private BookRepository bookRepository; + + private AuthorRepository authorRepository; + + @Before + public void setup() throws Exception { + RepositoryFactorySupport factory = new CouchbaseRepositoryFactory(operationsMapping, indexManager); + bookRepository = factory.getRepository(BookRepository.class); + authorRepository = factory.getRepository(AuthorRepository.class); + } + + @Test + public void testN1qlJoin() { + Author a = authorRepository.findById("Author" + 1).get(); + assertTrue(a.books.size() == 5); + for(Book b:a.books) { + assertEquals("Join on author name mismatch", a.name, b.authorName); + } + } + + @Test + public void testN1qlJoinWithNoResults() { + final String name = "testN1qlJoinWithNoResults"; + Author a = new Author(name, name); + authorRepository.save(a); + + Author saveda = authorRepository.findById(name).get(); + assertTrue(saveda.books.isEmpty()); + } +} \ No newline at end of file diff --git a/src/integration/resources/server.properties b/src/integration/resources/server.properties index f430a57c..6a6b52d9 100644 --- a/src/integration/resources/server.properties +++ b/src/integration/resources/server.properties @@ -1,5 +1,5 @@ #Couchbase server versions 4.5 and above are supported -server.version=5.1.0 +server.version=6.0.0 #resource can be set to container or omitted #container just would require docker installed #omitted indicates that there is a local couchbase server running diff --git a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java index a119832a..b9a291d5 100644 --- a/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java +++ b/src/main/java/org/springframework/data/couchbase/core/CouchbaseTemplate.java @@ -50,12 +50,16 @@ import com.couchbase.client.java.view.ViewQuery; import com.couchbase.client.java.view.ViewResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.data.couchbase.core.convert.join.N1qlJoinResolver; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; import org.springframework.data.couchbase.core.mapping.CouchbaseStorable; import org.springframework.data.couchbase.core.mapping.KeySettings; +import org.springframework.data.couchbase.core.query.N1qlJoin; +import org.springframework.data.mapping.PropertyHandler; +import org.springframework.data.util.TypeInformation; import rx.Observable; import rx.functions.Func1; @@ -714,6 +718,22 @@ public class CouchbaseTemplate implements CouchbaseOperations, ApplicationEventP accessor.setProperty(persistentEntity.getVersionProperty(), data.cas()); } + persistentEntity.doWithProperties((PropertyHandler) prop -> { + if (prop.isAnnotationPresent(N1qlJoin.class)) { + N1qlJoin definition = prop.findAnnotation(N1qlJoin.class); + TypeInformation type = prop.getTypeInformation().getComponentType(); + Class clazz = type.getType(); + N1qlJoinResolver.N1qlJoinResolverParameters parameters = new N1qlJoinResolver.N1qlJoinResolverParameters(definition, id, persistentEntity.getTypeInformation(), type); + if (N1qlJoinResolver.isLazyJoin(definition)) { + N1qlJoinResolver.N1qlJoinProxy proxy = new N1qlJoinResolver.N1qlJoinProxy(this, parameters); + accessor.setProperty(prop, java.lang.reflect.Proxy.newProxyInstance(List.class.getClassLoader(), + new Class[]{List.class}, proxy)); + } else { + accessor.setProperty(prop, N1qlJoinResolver.doResolve(this, parameters, clazz)); + } + } + }); + return accessor.getBean(); } diff --git a/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java b/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java index 42557bce..bfeee22d 100644 --- a/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java +++ b/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java @@ -43,6 +43,7 @@ import org.springframework.data.couchbase.core.mapping.id.GeneratedValue; import org.springframework.data.couchbase.core.mapping.id.IdAttribute; import org.springframework.data.couchbase.core.mapping.id.IdPrefix; import org.springframework.data.couchbase.core.mapping.id.IdSuffix; +import org.springframework.data.couchbase.core.query.N1qlJoin; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.AssociationHandler; import org.springframework.data.mapping.MappingException; @@ -239,7 +240,7 @@ public class MappingCouchbaseConverter extends AbstractCouchbaseConverter entity.doWithProperties(new PropertyHandler() { @Override public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { - if (!doesPropertyExistInSource(prop) || entity.isConstructorArgument(prop) || isIdConstructionProperty(prop)) { + if (!doesPropertyExistInSource(prop) || entity.isConstructorArgument(prop) || isIdConstructionProperty(prop) || prop.isAnnotationPresent(N1qlJoin.class)) { return; } Object obj = prop.isIdProperty() ? source.getId() : getValueInternal(prop, source, instance); @@ -482,6 +483,8 @@ public class MappingCouchbaseConverter extends AbstractCouchbaseConverter return; } else if (enableStrictFieldChecking && !prop.isAnnotationPresent(Field.class)) { return; + } else if (prop.isAnnotationPresent(N1qlJoin.class)) { + return; } Object propertyObj = accessor.getProperty(prop, prop.getType()); diff --git a/src/main/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolver.java b/src/main/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolver.java new file mode 100644 index 00000000..09cefb44 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolver.java @@ -0,0 +1,166 @@ +/* + * Copyright 2018 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 org.springframework.data.couchbase.core.convert.join; + +import static org.springframework.data.couchbase.core.support.TemplateUtils.*; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.util.List; + +import com.couchbase.client.java.query.N1qlQuery; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.query.FetchType; +import org.springframework.data.couchbase.core.query.HashSide; +import org.springframework.data.couchbase.core.query.N1qlJoin; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.Assert; + +/** + * N1qlJoinResolver resolves by converting the join definition to query statement + * and executing using CouchbaseTemplate + * + * @author Subhashni Balakrishnan + */ +public class N1qlJoinResolver { + private static final Logger LOGGER = LoggerFactory.getLogger(N1qlJoinResolver.class); + + public static String buildQuery(CouchbaseTemplate template, N1qlJoinResolverParameters parameters) { + String joinType = "JOIN"; + String selectEntity = "SELECT META(rks).id AS " + SELECT_ID + + ", META(rks).cas AS " + SELECT_CAS + ", (rks).* "; + + StringBuilder useLKSBuilder = new StringBuilder(); + if (parameters.getJoinDefinition().index().length() > 0) { + useLKSBuilder.append("INDEX(" + parameters.getJoinDefinition().index() + ")"); + } + String useLKS = useLKSBuilder.length() > 0 ? "USE " + useLKSBuilder.toString() + " " : ""; + + String from = "FROM `" + template.getCouchbaseBucket().name() + "` lks " + useLKS + joinType + " " + template.getCouchbaseBucket().name() + " rks"; + String onLks = "lks." + template.getConverter().getTypeKey() + " = \""+ parameters.getEntityTypeInfo().getType().getName() + "\""; + String onRks = "rks." + template.getConverter().getTypeKey() + " = \"" + parameters.getAssociatedEntityTypeInfo().getType().getName() + "\""; + + + StringBuilder useRKSBuilder = new StringBuilder(); + if (parameters.getJoinDefinition().rightIndex().length() > 0) { + useRKSBuilder.append("INDEX(" + parameters.getJoinDefinition().rightIndex() + ")"); + } + if (!parameters.getJoinDefinition().hashside().equals(HashSide.NONE)) { + if (useRKSBuilder.length() > 0) useRKSBuilder.append(" "); + useRKSBuilder.append("HASH(" + parameters.getJoinDefinition().hashside().getValue() +")"); + } + if (parameters.getJoinDefinition().keys().length > 0) { + if (useRKSBuilder.length() > 0) useRKSBuilder.append(" "); + useRKSBuilder.append("KEYS ["); + String[] keys = parameters.getJoinDefinition().keys(); + + for(int i=0; i < keys.length;i++) { + if(i != 0) useRKSBuilder.append(","); + useRKSBuilder.append("\"" + keys[i] +"\""); + } + useRKSBuilder.append("]"); + } + + String on = "ON " + parameters.getJoinDefinition().on().concat(" AND " + onLks).concat(" AND " + onRks); + + String where = "WHERE META(lks).id=\"" + parameters.getLksId() + "\""; + where += ((parameters.getJoinDefinition().where().length() > 0) ? " AND " + parameters.getJoinDefinition().where() : ""); + + StringBuilder statementSb = new StringBuilder(); + statementSb.append(selectEntity); + statementSb.append(" " + from); + statementSb.append((useRKSBuilder.length() > 0? " USE "+ useRKSBuilder.toString() : "")); + statementSb.append(" " + on); + statementSb.append(" " + where); + return statementSb.toString(); + } + + public static List doResolve(CouchbaseTemplate template, + N1qlJoinResolverParameters parameters, + Class associatedEntityClass) { + String statement = buildQuery(template, parameters); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Join query executed " + statement); + } + + N1qlQuery query = N1qlQuery.simple(statement); + return template.findByN1QL(query, associatedEntityClass); + } + + public static boolean isLazyJoin(N1qlJoin joinDefinition) { + return joinDefinition.fetchType().equals(FetchType.LAZY); + } + + static public class N1qlJoinProxy implements InvocationHandler { + private final CouchbaseTemplate template; + private final N1qlJoinResolverParameters params; + private List resolved = null; + + public N1qlJoinProxy(CouchbaseTemplate template, N1qlJoinResolverParameters params) { + this.template = template; + this.params = params; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if(this.resolved == null) { + this.resolved = doResolve(this.template, this.params, this.params.associatedEntityTypeInfo.getType()); + } + return method.invoke(this.resolved, args); + } + } + + static public class N1qlJoinResolverParameters { + private N1qlJoin joinDefinition; + private String lksId; + private TypeInformation entityTypeInfo; + private TypeInformation associatedEntityTypeInfo; + + public N1qlJoinResolverParameters(N1qlJoin joinDefinition, + String lksId, + TypeInformation entityTypeInfo, + TypeInformation associatedEntityTypeInfo) { + Assert.notNull(joinDefinition, "The join definition is required"); + Assert.notNull(entityTypeInfo, "The entity type information is required"); + Assert.notNull(associatedEntityTypeInfo, "The associated entity type information is required"); + + this.joinDefinition = joinDefinition; + this.lksId = lksId; + this.entityTypeInfo = entityTypeInfo; + this.associatedEntityTypeInfo = associatedEntityTypeInfo; + } + + public N1qlJoin getJoinDefinition() { + return joinDefinition; + } + + public String getLksId() { + return lksId; + } + + public TypeInformation getEntityTypeInfo() { + return entityTypeInfo; + } + + public TypeInformation getAssociatedEntityTypeInfo() { + return associatedEntityTypeInfo; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/couchbase/core/query/FetchType.java b/src/main/java/org/springframework/data/couchbase/core/query/FetchType.java new file mode 100644 index 00000000..8d6af0c7 --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/query/FetchType.java @@ -0,0 +1,34 @@ +/* + * Copyright 2018 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 org.springframework.data.couchbase.core.query; + +/** + * Setting for specify when to fetch the associated entities + * @author Subhashni Balakrishnan + */ +public enum FetchType { + /** + * Immediately fetch the associated entities + */ + IMMEDIATE, + + /** + * Lazily fetch the associated entities on access, the + * fetch happens only once + */ + LAZY +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/couchbase/core/query/HashSide.java b/src/main/java/org/springframework/data/couchbase/core/query/HashSide.java new file mode 100644 index 00000000..3aa11c9b --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/query/HashSide.java @@ -0,0 +1,51 @@ +/* + * Copyright 2018 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 org.springframework.data.couchbase.core.query; + +/** + * Hash side to specify hash join. Here based on probe or build, the + * entity will be used to query or build the hash table. + * The smaller data set side should be used to build to fit in memory. + * + * @author Subhashni Balakrishnan + */ +public enum HashSide { + /** + * Hash join will not be used + */ + NONE("none"), + + /** + * Associated entity will be on the probe side of the hash table + */ + PROBE("probe"), + + /** + * Associated entity will be used to build the hash table for faster lookup + */ + BUILD("build"); + + private final String value; + + HashSide(String value) { + this.value=value; + } + + public String getValue() { + return this.value; + } +} \ No newline at end of file diff --git a/src/main/java/org/springframework/data/couchbase/core/query/N1qlJoin.java b/src/main/java/org/springframework/data/couchbase/core/query/N1qlJoin.java new file mode 100644 index 00000000..b7709d9e --- /dev/null +++ b/src/main/java/org/springframework/data/couchbase/core/query/N1qlJoin.java @@ -0,0 +1,75 @@ +/* + * Copyright 2018 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 org.springframework.data.couchbase.core.query; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is targeted for entity field which is a list of the + * associated entities fetched by ANSI Join across the entities available + * from Couchbase Server 5.5 + * + * @author Subhashni Balakrishnan + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface N1qlJoin { + /** + * Join Criteria can be a simple equi join or multiple conditions + * combined using AND or OR. Array based equi joins with unnest is + * also possible. To reference fields in entity use prefix "lks." + * (left key space) and for referencing fields in associated entities + * use "rks." (right key space) + */ + String on(); + + /** + * Fetch type specifies how the associated entities are fetched + * {@link FetchType} + */ + FetchType fetchType() default FetchType.IMMEDIATE; + + /** + * Where clause for the join. To reference fields in entity use + * prefix "lks." and for referencing fields in associated entities + * use "rks." + */ + String where() default ""; + + /** + * Hint index for entity for indexed nested loop join + */ + String index() default ""; + + /** + * Hint index for associated entity for indexed nested loop join + */ + String rightIndex() default ""; + + /** + * Hash side specification for the associated entity for hash join + * Note: Supported on enterprise edition only + */ + HashSide hashside() default HashSide.NONE; + + /** + * Use keys query hint + */ + String[] keys() default {}; +} \ No newline at end of file diff --git a/src/test/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolverTest.java b/src/test/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolverTest.java new file mode 100644 index 00000000..949d54ef --- /dev/null +++ b/src/test/java/org/springframework/data/couchbase/core/convert/join/N1qlJoinResolverTest.java @@ -0,0 +1,169 @@ +package org.springframework.data.couchbase.core.convert.join; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.*; + +import java.lang.annotation.Annotation; + +import com.couchbase.client.java.CouchbaseBucket; +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.data.couchbase.core.CouchbaseTemplate; +import org.springframework.data.couchbase.core.convert.CouchbaseConverter; +import org.springframework.data.couchbase.core.query.FetchType; +import org.springframework.data.couchbase.core.query.HashSide; +import org.springframework.data.couchbase.core.query.N1qlJoin; +import org.springframework.data.util.TypeInformation; +import org.springframework.data.couchbase.core.convert.join.N1qlJoinResolver.N1qlJoinResolverParameters; + +/** + * Unit tests for {@link N1qlJoinResolver} + */ +public class N1qlJoinResolverTest { + static CouchbaseTemplate template; + static TypeInformation entity; + static TypeInformation associatedEntity; + static String entityClassName; + + @BeforeClass + public static void setup() { + template = mock(CouchbaseTemplate.class); + CouchbaseBucket bucket = mock(CouchbaseBucket.class); + when(bucket.name()).thenReturn("B"); + when(template.getCouchbaseBucket()).thenReturn(bucket); + CouchbaseConverter converter = mock(CouchbaseConverter.class); + when(converter.getTypeKey()).thenReturn("_class"); + when(template.getConverter()).thenReturn(converter); + entity = mock(TypeInformation.class); + doReturn(Entity.class).when(entity).getType(); + associatedEntity = mock(TypeInformation.class); + doReturn(Entity.class).when(associatedEntity).getType(); + entityClassName = Entity.class.getName(); + } + + private static N1qlJoin createAnnotation(String on, String where, String index, String rightIndex, HashSide hashSide, String[] keys) { + N1qlJoin joinDefinition = new N1qlJoin() { + + @Override + public Class annotationType() { + return N1qlJoin.class; + } + + @Override + public String on() { + return on; + } + + @Override + public FetchType fetchType() { + return FetchType.IMMEDIATE; + } + + @Override + public String where() { + return where; + } + + @Override + public String index() { + return index; + } + + @Override + public String rightIndex() { + return rightIndex; + } + + @Override + public HashSide hashside() { + return hashSide; + } + + @Override + public String[] keys() { + return keys; + } + }; + return joinDefinition; + } + + static public class Entity { + } + + @Test + public void shouldBuildQueryWithIndex() { + N1qlJoin joinDefinition = createAnnotation("A=B", "", "leftIndex", "", HashSide.NONE, new String[0]); + N1qlJoinResolverParameters parameters = new N1qlJoinResolverParameters(joinDefinition, "mydoc", entity, associatedEntity); + String statement = N1qlJoinResolver.buildQuery(template, parameters); + String expected = "SELECT META(rks).id AS _ID, META(rks).cas AS _CAS, (rks).* FROM `B` lks USE INDEX(leftIndex) JOIN B rks ON A=B" + + " AND lks._class = \"" + entityClassName + "\"" + " AND " + + "rks._class = \"" + entityClassName + "\" WHERE META(lks).id=\"mydoc\""; + assertEquals(statement, expected); + } + + @Test + public void shouldBuildQueryWithRightIndex() { + N1qlJoin joinDefinition = createAnnotation("A=B", "", "", "rightIndex", HashSide.NONE, new String[0]); + N1qlJoinResolverParameters parameters = new N1qlJoinResolverParameters(joinDefinition, "mydoc", entity, associatedEntity); + String statement = N1qlJoinResolver.buildQuery(template, parameters); + String expected = "SELECT META(rks).id AS _ID, META(rks).cas AS _CAS, (rks).* FROM `B` lks JOIN B rks USE INDEX(rightIndex) ON A=B" + + " AND lks._class = \"" + entityClassName + "\"" + " AND " + + "rks._class = \"" + entityClassName + "\" WHERE META(lks).id=\"mydoc\""; + assertEquals(statement, expected); + } + + @Test + public void shouldBuildQueryWithHashProbe() { + N1qlJoin joinDefinition = createAnnotation("A=B", "", "", "", HashSide.PROBE, new String[0]); + N1qlJoinResolverParameters parameters = new N1qlJoinResolverParameters(joinDefinition, "mydoc", entity, associatedEntity); + String statement = N1qlJoinResolver.buildQuery(template, parameters); + String expected = "SELECT META(rks).id AS _ID, META(rks).cas AS _CAS, (rks).* FROM `B` lks JOIN B rks USE HASH(probe) ON A=B" + + " AND lks._class = \"" + entityClassName + "\"" + " AND " + + "rks._class = \"" + entityClassName + "\" WHERE META(lks).id=\"mydoc\""; + assertEquals(statement, expected); + } + + @Test + public void shouldBuildQueryWithHashBuild() { + N1qlJoin joinDefinition = createAnnotation("A=B", "", "", "", HashSide.BUILD, new String[0]); + N1qlJoinResolverParameters parameters = new N1qlJoinResolverParameters(joinDefinition, "mydoc", entity, associatedEntity); + String statement = N1qlJoinResolver.buildQuery(template, parameters); + String expected = "SELECT META(rks).id AS _ID, META(rks).cas AS _CAS, (rks).* FROM `B` lks JOIN B rks USE HASH(build) ON A=B" + + " AND lks._class = \"" + entityClassName + "\"" + " AND " + + "rks._class = \"" + entityClassName + "\" WHERE META(lks).id=\"mydoc\""; + assertEquals(statement, expected); + } + + @Test + public void shouldBuildQueryWithKeys() { + N1qlJoin joinDefinition = createAnnotation("A=B", "", "", "", HashSide.NONE, new String[]{"x", "y"}); + N1qlJoinResolverParameters parameters = new N1qlJoinResolverParameters(joinDefinition, "mydoc", entity, associatedEntity); + String statement = N1qlJoinResolver.buildQuery(template, parameters); + String expected = "SELECT META(rks).id AS _ID, META(rks).cas AS _CAS, (rks).* FROM `B` lks JOIN B rks USE KEYS [\"x\",\"y\"] ON A=B" + + " AND lks._class = \"" + entityClassName + "\"" + " AND " + + "rks._class = \"" + entityClassName + "\" WHERE META(lks).id=\"mydoc\""; + assertEquals(statement, expected); + } + + @Test + public void shouldBuildQueryWithWhere() { + N1qlJoin joinDefinition = createAnnotation("A=B", "C=D", "", "", HashSide.NONE, new String[0]); + N1qlJoinResolverParameters parameters = new N1qlJoinResolverParameters(joinDefinition, "mydoc", entity, associatedEntity); + String statement = N1qlJoinResolver.buildQuery(template, parameters); + String expected = "SELECT META(rks).id AS _ID, META(rks).cas AS _CAS, (rks).* FROM `B` lks JOIN B rks ON A=B" + + " AND lks._class = \"" + entityClassName + "\"" + " AND " + + "rks._class = \"" + entityClassName + "\" WHERE META(lks).id=\"mydoc\" AND C=D"; + assertEquals(statement, expected); + } + + @Test + public void shouldBuildQueryWithMultipleHints() { + N1qlJoin joinDefinition = createAnnotation("A=B", "", "leftIndex", "rightIndex", HashSide.BUILD, new String[]{"x"}); + N1qlJoinResolverParameters parameters = new N1qlJoinResolverParameters(joinDefinition, "mydoc", entity, associatedEntity); + String statement = N1qlJoinResolver.buildQuery(template, parameters); + String expected = "SELECT META(rks).id AS _ID, META(rks).cas AS _CAS, (rks).* FROM `B` lks USE INDEX(leftIndex) JOIN B rks USE INDEX(rightIndex)" + + " HASH(build) KEYS [\"x\"] ON A=B AND lks._class = \"" + entityClassName + "\"" + " AND " + + "rks._class = \"" + entityClassName + "\" WHERE META(lks).id=\"mydoc\""; + assertEquals(statement, expected); + } +}