Merge pull request #3161 from markusgulden/master
BAEL-1336 Introduction to Hibernate Search
This commit is contained in:
commit
aadde5b526
|
@ -57,6 +57,11 @@
|
|||
<artifactId>jta</artifactId>
|
||||
<version>${jta.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate</groupId>
|
||||
<artifactId>hibernate-search-orm</artifactId>
|
||||
<version>${hibernatesearch.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.tomcat</groupId>
|
||||
|
@ -184,6 +189,7 @@
|
|||
|
||||
<!-- persistence -->
|
||||
<hibernate.version>5.2.10.Final</hibernate.version>
|
||||
<hibernatesearch.version>5.8.2.Final</hibernatesearch.version>
|
||||
<mysql-connector-java.version>8.0.7-dmr</mysql-connector-java.version>
|
||||
<tomcat-dbcp.version>9.0.0.M26</tomcat-dbcp.version>
|
||||
<jta.version>1.1</jta.version>
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
package com.baeldung.hibernatesearch;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
import org.apache.tomcat.dbcp.dbcp2.BasicDataSource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.PropertySource;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.orm.jpa.JpaTransactionManager;
|
||||
import org.springframework.orm.jpa.JpaVendorAdapter;
|
||||
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
|
||||
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
import javax.persistence.EntityManagerFactory;
|
||||
import javax.sql.DataSource;
|
||||
import java.util.Properties;
|
||||
|
||||
@EnableTransactionManagement
|
||||
@Configuration
|
||||
@PropertySource({ "classpath:persistence-h2.properties" })
|
||||
@EnableJpaRepositories(basePackages = { "com.baeldung.hibernatesearch" })
|
||||
@ComponentScan({ "com.baeldung.hibernatesearch" })
|
||||
public class HibernateSearchConfig {
|
||||
|
||||
@Autowired
|
||||
private Environment env;
|
||||
|
||||
@Bean
|
||||
public LocalContainerEntityManagerFactoryBean entityManagerFactory() {
|
||||
LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
|
||||
em.setDataSource(dataSource());
|
||||
em.setPackagesToScan(new String[] { "com.baeldung.hibernatesearch.model" });
|
||||
|
||||
JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
|
||||
em.setJpaVendorAdapter(vendorAdapter);
|
||||
em.setJpaProperties(additionalProperties());
|
||||
|
||||
return em;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DataSource dataSource() {
|
||||
final BasicDataSource dataSource = new BasicDataSource();
|
||||
dataSource.setDriverClassName(Preconditions.checkNotNull(env.getProperty("jdbc.driverClassName")));
|
||||
dataSource.setUrl(Preconditions.checkNotNull(env.getProperty("jdbc.url")));
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PlatformTransactionManager transactionManager(EntityManagerFactory emf) {
|
||||
JpaTransactionManager transactionManager = new JpaTransactionManager();
|
||||
transactionManager.setEntityManagerFactory(emf);
|
||||
|
||||
return transactionManager;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PersistenceExceptionTranslationPostProcessor exceptionTranslation() {
|
||||
return new PersistenceExceptionTranslationPostProcessor();
|
||||
}
|
||||
|
||||
Properties additionalProperties() {
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("hibernate.hbm2ddl.auto", Preconditions.checkNotNull(env.getProperty("hibernate.hbm2ddl.auto")));
|
||||
properties.setProperty("hibernate.dialect", Preconditions.checkNotNull(env.getProperty("hibernate.dialect")));
|
||||
return properties;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,195 @@
|
|||
package com.baeldung.hibernatesearch;
|
||||
|
||||
import com.baeldung.hibernatesearch.model.Product;
|
||||
import org.apache.lucene.search.Query;
|
||||
import org.hibernate.search.engine.ProjectionConstants;
|
||||
import org.hibernate.search.jpa.FullTextEntityManager;
|
||||
import org.hibernate.search.jpa.FullTextQuery;
|
||||
import org.hibernate.search.jpa.Search;
|
||||
import org.hibernate.search.query.dsl.QueryBuilder;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class ProductSearchDao {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
public List<Product> searchProductNameByKeywordQuery(String text) {
|
||||
|
||||
Query keywordQuery = getQueryBuilder()
|
||||
.keyword()
|
||||
.onField("productName")
|
||||
.matching(text)
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(keywordQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Product> searchProductNameByFuzzyQuery(String text) {
|
||||
|
||||
Query fuzzyQuery = getQueryBuilder()
|
||||
.keyword()
|
||||
.fuzzy()
|
||||
.withEditDistanceUpTo(2)
|
||||
.withPrefixLength(0)
|
||||
.onField("productName")
|
||||
.matching(text)
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(fuzzyQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Product> searchProductNameByWildcardQuery(String text) {
|
||||
|
||||
Query wildcardQuery = getQueryBuilder()
|
||||
.keyword()
|
||||
.wildcard()
|
||||
.onField("productName")
|
||||
.matching(text)
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(wildcardQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Product> searchProductDescriptionByPhraseQuery(String text) {
|
||||
|
||||
Query phraseQuery = getQueryBuilder()
|
||||
.phrase()
|
||||
.withSlop(1)
|
||||
.onField("description")
|
||||
.sentence(text)
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(phraseQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Product> searchProductNameAndDescriptionBySimpleQueryStringQuery(String text) {
|
||||
|
||||
Query simpleQueryStringQuery = getQueryBuilder()
|
||||
.simpleQueryString()
|
||||
.onFields("productName", "description")
|
||||
.matching(text)
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(simpleQueryStringQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Product> searchProductNameByRangeQuery(int low, int high) {
|
||||
|
||||
Query rangeQuery = getQueryBuilder()
|
||||
.range()
|
||||
.onField("memory")
|
||||
.from(low)
|
||||
.to(high)
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(rangeQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Object[]> searchProductNameByMoreLikeThisQuery(Product entity) {
|
||||
|
||||
Query moreLikeThisQuery = getQueryBuilder()
|
||||
.moreLikeThis()
|
||||
.comparingField("productName")
|
||||
.toEntity(entity)
|
||||
.createQuery();
|
||||
|
||||
List<Object[]> results = getJpaQuery(moreLikeThisQuery).setProjection(ProjectionConstants.THIS, ProjectionConstants.SCORE)
|
||||
.getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Product> searchProductNameAndDescriptionByKeywordQuery(String text) {
|
||||
|
||||
Query keywordQuery = getQueryBuilder()
|
||||
.keyword()
|
||||
.onFields("productName", "description")
|
||||
.matching(text)
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(keywordQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Object[]> searchProductNameAndDescriptionByMoreLikeThisQuery(Product entity) {
|
||||
|
||||
Query moreLikeThisQuery = getQueryBuilder()
|
||||
.moreLikeThis()
|
||||
.comparingField("productName")
|
||||
.toEntity(entity)
|
||||
.createQuery();
|
||||
|
||||
List<Object[]> results = getJpaQuery(moreLikeThisQuery).setProjection(ProjectionConstants.THIS, ProjectionConstants.SCORE)
|
||||
.getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public List<Product> searchProductNameAndDescriptionByCombinedQuery(String manufactorer, int memoryLow, int memoryTop, String extraFeature, String exclude) {
|
||||
|
||||
Query combinedQuery = getQueryBuilder()
|
||||
.bool()
|
||||
.must(getQueryBuilder().keyword()
|
||||
.onField("productName")
|
||||
.matching(manufactorer)
|
||||
.createQuery())
|
||||
.must(getQueryBuilder()
|
||||
.range()
|
||||
.onField("memory")
|
||||
.from(memoryLow)
|
||||
.to(memoryTop)
|
||||
.createQuery())
|
||||
.should(getQueryBuilder()
|
||||
.phrase()
|
||||
.onField("description")
|
||||
.sentence(extraFeature)
|
||||
.createQuery())
|
||||
.must(getQueryBuilder()
|
||||
.keyword()
|
||||
.onField("productName")
|
||||
.matching(exclude)
|
||||
.createQuery())
|
||||
.not()
|
||||
.createQuery();
|
||||
|
||||
List<Product> results = getJpaQuery(combinedQuery).getResultList();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private FullTextQuery getJpaQuery(org.apache.lucene.search.Query luceneQuery) {
|
||||
|
||||
FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager);
|
||||
|
||||
return fullTextEntityManager.createFullTextQuery(luceneQuery, Product.class);
|
||||
}
|
||||
|
||||
private QueryBuilder getQueryBuilder() {
|
||||
|
||||
FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager);
|
||||
|
||||
return fullTextEntityManager.getSearchFactory()
|
||||
.buildQueryBuilder()
|
||||
.forEntity(Product.class)
|
||||
.get();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package com.baeldung.hibernatesearch.model;
|
||||
|
||||
import org.hibernate.search.annotations.*;
|
||||
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Indexed
|
||||
@Table(name = "product")
|
||||
public class Product {
|
||||
|
||||
@Id
|
||||
private int id;
|
||||
|
||||
@Field(termVector = TermVector.YES)
|
||||
private String productName;
|
||||
|
||||
@Field(termVector = TermVector.YES)
|
||||
private String description;
|
||||
|
||||
@Field
|
||||
private int memory;
|
||||
|
||||
public Product(int id, String productName, int memory, String description) {
|
||||
this.id = id;
|
||||
this.productName = productName;
|
||||
this.memory = memory;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Product() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (!(o instanceof Product))
|
||||
return false;
|
||||
|
||||
Product product = (Product) o;
|
||||
|
||||
if (id != product.id)
|
||||
return false;
|
||||
if (memory != product.memory)
|
||||
return false;
|
||||
if (!productName.equals(product.productName))
|
||||
return false;
|
||||
return description.equals(product.description);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = id;
|
||||
result = 31 * result + productName.hashCode();
|
||||
result = 31 * result + memory;
|
||||
result = 31 * result + description.hashCode();
|
||||
return result;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(int id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getProductName() {
|
||||
return productName;
|
||||
}
|
||||
|
||||
public void setProductName(String productName) {
|
||||
this.productName = productName;
|
||||
}
|
||||
|
||||
public int getMemory() {
|
||||
return memory;
|
||||
}
|
||||
|
||||
public void setMemory(int memory) {
|
||||
this.memory = memory;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
|
@ -9,5 +9,9 @@ hibernate.dialect=org.hibernate.dialect.H2Dialect
|
|||
hibernate.show_sql=false
|
||||
hibernate.hbm2ddl.auto=create-drop
|
||||
|
||||
# hibernate.search.X
|
||||
hibernate.search.default.directory_provider = filesystem
|
||||
hibernate.search.default.indexBase = /data/index/default
|
||||
|
||||
# envers.X
|
||||
envers.audit_table_suffix=_audit_log
|
||||
|
|
|
@ -0,0 +1,187 @@
|
|||
package com.baeldung.hibernatesearch;
|
||||
|
||||
import com.baeldung.hibernatesearch.model.Product;
|
||||
|
||||
import static org.hamcrest.collection.IsIterableContainingInOrder.contains;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.hibernate.search.jpa.FullTextEntityManager;
|
||||
import org.hibernate.search.jpa.Search;
|
||||
import org.junit.Before;
|
||||
import org.junit.FixMethodOrder;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.junit.runners.MethodSorters;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.annotation.Commit;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
|
||||
import org.springframework.test.context.support.AnnotationConfigContextLoader;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder;
|
||||
|
||||
@RunWith(SpringJUnit4ClassRunner.class)
|
||||
@ContextConfiguration(classes = { HibernateSearchConfig.class }, loader = AnnotationConfigContextLoader.class)
|
||||
@Transactional
|
||||
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
|
||||
public class HibernateSearchIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
ProductSearchDao dao;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
private List<Product> products;
|
||||
|
||||
@Before
|
||||
public void setupTestData() {
|
||||
|
||||
products = Arrays.asList(new Product(1, "Apple iPhone X 256 GB", 256, "The current high-end smartphone from Apple, with lots of memory and also Face ID"),
|
||||
new Product(2, "Apple iPhone X 128 GB", 128, "The current high-end smartphone from Apple, with Face ID"), new Product(3, "Apple iPhone 8 128 GB", 128, "The latest smartphone from Apple within the regular iPhone line, supporting wireless charging"),
|
||||
new Product(4, "Samsung Galaxy S7 128 GB", 64, "A great Android smartphone"), new Product(5, "Microsoft Lumia 650 32 GB", 32, "A cheaper smartphone, coming with Windows Mobile"),
|
||||
new Product(6, "Microsoft Lumia 640 32 GB", 32, "A cheaper smartphone, coming with Windows Mobile"), new Product(7, "Microsoft Lumia 630 16 GB", 16, "A cheaper smartphone, coming with Windows Mobile"));
|
||||
}
|
||||
|
||||
@Commit
|
||||
@Test
|
||||
public void testA_whenInitialTestDataInserted_thenSuccess() {
|
||||
|
||||
for (int i = 0; i < products.size() - 1; i++) {
|
||||
entityManager.persist(products.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testB_whenIndexInitialized_thenCorrectIndexSize() throws InterruptedException {
|
||||
|
||||
FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager);
|
||||
fullTextEntityManager.createIndexer()
|
||||
.startAndWait();
|
||||
int indexSize = fullTextEntityManager.getSearchFactory()
|
||||
.getStatistics()
|
||||
.getNumberOfIndexedEntities(Product.class.getName());
|
||||
|
||||
assertEquals(products.size() - 1, indexSize);
|
||||
}
|
||||
|
||||
@Commit
|
||||
@Test
|
||||
public void testC_whenAdditionalTestDataInserted_thenSuccess() {
|
||||
|
||||
entityManager.persist(products.get(products.size() - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testD_whenAdditionalTestDataInserted_thenIndexUpdatedAutomatically() {
|
||||
|
||||
FullTextEntityManager fullTextEntityManager = Search.getFullTextEntityManager(entityManager);
|
||||
int indexSize = fullTextEntityManager.getSearchFactory()
|
||||
.getStatistics()
|
||||
.getNumberOfIndexedEntities(Product.class.getName());
|
||||
|
||||
assertEquals(products.size(), indexSize);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testE_whenKeywordSearchOnName_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(0), products.get(1), products.get(2));
|
||||
List<Product> results = dao.searchProductNameByKeywordQuery("iphone");
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testF_whenFuzzySearch_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(0), products.get(1), products.get(2));
|
||||
List<Product> results = dao.searchProductNameByFuzzyQuery("iPhaen");
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testG_whenWildcardSearch_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(4), products.get(5), products.get(6));
|
||||
List<Product> results = dao.searchProductNameByWildcardQuery("6*");
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testH_whenPhraseSearch_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(2));
|
||||
List<Product> results = dao.searchProductDescriptionByPhraseQuery("with wireless charging");
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testI_whenSimpleQueryStringSearch_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(0), products.get(1));
|
||||
List<Product> results = dao.searchProductNameAndDescriptionBySimpleQueryStringQuery("Aple~2 + \"iPhone X\" + (256 | 128)");
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testJ_whenRangeSearch_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(0), products.get(1), products.get(2), products.get(3));
|
||||
List<Product> results = dao.searchProductNameByRangeQuery(64, 256);
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testK_whenMoreLikeThisSearch_thenCorrectMatchesInOrder() {
|
||||
List<Product> expected = products;
|
||||
List<Object[]> resultsWithScore = dao.searchProductNameByMoreLikeThisQuery(products.get(0));
|
||||
List<Product> results = new LinkedList<Product>();
|
||||
|
||||
for (Object[] resultWithScore : resultsWithScore) {
|
||||
results.add((Product) resultWithScore[0]);
|
||||
}
|
||||
|
||||
assertThat(results, contains(expected.toArray()));
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testL_whenKeywordSearchOnNameAndDescription_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(0), products.get(1), products.get(2));
|
||||
List<Product> results = dao.searchProductNameAndDescriptionByKeywordQuery("iphone");
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testM_whenMoreLikeThisSearchOnProductNameAndDescription_thenCorrectMatchesInOrder() {
|
||||
List<Product> expected = products;
|
||||
List<Object[]> resultsWithScore = dao.searchProductNameAndDescriptionByMoreLikeThisQuery(products.get(0));
|
||||
List<Product> results = new LinkedList<Product>();
|
||||
|
||||
for (Object[] resultWithScore : resultsWithScore) {
|
||||
results.add((Product) resultWithScore[0]);
|
||||
}
|
||||
|
||||
assertThat(results, contains(expected.toArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testN_whenCombinedSearch_thenCorrectMatches() {
|
||||
List<Product> expected = Arrays.asList(products.get(1), products.get(2));
|
||||
List<Product> results = dao.searchProductNameAndDescriptionByCombinedQuery("apple", 64, 128, "face id", "samsung");
|
||||
|
||||
assertThat(results, containsInAnyOrder(expected.toArray()));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue