From 54c590db6cda8a6953518fbf5bc79c06f2c880f9 Mon Sep 17 00:00:00 2001 From: Balazs Barabas Date: Fri, 8 Nov 2024 18:17:25 +0200 Subject: [PATCH 1/6] added mergefacets and facetsbyindex to multisearchfederation --- .../java/com/meilisearch/sdk/MergeFacets.java | 14 +++++++ .../sdk/MultiSearchFederation.java | 17 ++++++-- .../meilisearch/sdk/MultiSearchRequest.java | 1 - .../meilisearch/integration/SearchTest.java | 42 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/meilisearch/sdk/MergeFacets.java diff --git a/src/main/java/com/meilisearch/sdk/MergeFacets.java b/src/main/java/com/meilisearch/sdk/MergeFacets.java new file mode 100644 index 00000000..772ec7e8 --- /dev/null +++ b/src/main/java/com/meilisearch/sdk/MergeFacets.java @@ -0,0 +1,14 @@ +package com.meilisearch.sdk; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class MergeFacets { + private Integer maxValuesPerFacet; +} diff --git a/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java b/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java index ecaeabc6..6bff457a 100644 --- a/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java +++ b/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java @@ -1,11 +1,18 @@ package com.meilisearch.sdk; +import java.util.Map; + import org.json.JSONObject; +import lombok.Getter; + +@Getter public class MultiSearchFederation { private Integer limit; private Integer offset; + private MergeFacets mergeFacets; + private Map facetsByIndex; public MultiSearchFederation setLimit(Integer limit) { this.limit = limit; @@ -17,12 +24,14 @@ public MultiSearchFederation setOffset(Integer offset) { return this; } - public Integer getLimit() { - return this.limit; + public MultiSearchFederation setMergeFacets(MergeFacets mergeFacets) { + this.mergeFacets = mergeFacets; + return this; } - public Integer getOffset() { - return this.offset; + public MultiSearchFederation setFacetsByIndex(Map facetsByIndex) { + this.facetsByIndex = facetsByIndex; + return this; } /** diff --git a/src/main/java/com/meilisearch/sdk/MultiSearchRequest.java b/src/main/java/com/meilisearch/sdk/MultiSearchRequest.java index 3ce3ed8f..f22b4c2b 100644 --- a/src/main/java/com/meilisearch/sdk/MultiSearchRequest.java +++ b/src/main/java/com/meilisearch/sdk/MultiSearchRequest.java @@ -1,7 +1,6 @@ package com.meilisearch.sdk; import java.util.ArrayList; -import lombok.*; public class MultiSearchRequest { private ArrayList queries; diff --git a/src/test/java/com/meilisearch/integration/SearchTest.java b/src/test/java/com/meilisearch/integration/SearchTest.java index e4d8adaa..c3b963d1 100644 --- a/src/test/java/com/meilisearch/integration/SearchTest.java +++ b/src/test/java/com/meilisearch/integration/SearchTest.java @@ -20,6 +20,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; +import java.util.Map; + import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -931,6 +933,46 @@ public void testMultiSearchWithDistinct() throws Exception { } } + @Test + public void testMultiSearchWithMergeFacets() { + Index moviesIndex = client.index("movies"); + Index nestedMoviesIndex = client.index("nestedMovies"); + + TestData testData = this.getTestData(NESTED_MOVIES, Movie.class); + TaskInfo task1 = nestedMoviesIndex.addDocuments(testData.getRaw()); + + nestedMoviesIndex.waitForTask(task1.getTaskUid()); + + Settings settings = new Settings(); + settings.setFilterableAttributes(new String[] {"id", "title"}); + settings.setSortableAttributes(new String[]{"id"}); + + moviesIndex.waitForTask(moviesIndex.updateSettings(settings).getTaskUid()); + + TestData moviesTestData = this.getTestData(MOVIES_INDEX, Movie.class); + TaskInfo task2 = moviesIndex.addDocuments(moviesTestData.getRaw()); + + moviesIndex.waitForTask(task2.getTaskUid()); + + MultiSearchRequest search = new MultiSearchRequest(); + + search.addQuery(new IndexSearchRequest("movies").setQuery("Hobbit")); + search.addQuery(new IndexSearchRequest("nestedMovies").setQuery("Hobbit")); + + MultiSearchFederation federation = new MultiSearchFederation(); + federation.setLimit(20); + federation.setOffset(0); + federation.setMergeFacets(new MergeFacets(10)); + Map facetsByIndex = new HashMap(); + facetsByIndex.put("nestedMovies",new String[]{"title"}); + facetsByIndex.put("movies", new String[] {"title", "id"}); + federation.setFacetsByIndex(facetsByIndex); + + MultiSearchResult[] results = client.multiSearch(search).getResults(); + + assertThat(results.length, is(2)); + } + @Test public void testSimilarDocuments() throws Exception { HashMap features = new HashMap(); From 3ddfe349cc4e36fcb1102e51816335e19dbcb34f Mon Sep 17 00:00:00 2001 From: Balazs Barabas Date: Mon, 11 Nov 2024 15:14:56 +0200 Subject: [PATCH 2/6] cleaned up test, and prepped facetsbyindex implementation --- .../sdk/MultiSearchFederation.java | 4 +- .../sdk/model/FacetsByIndexInfo.java | 10 ++++ .../sdk/model/MultiSearchResult.java | 3 +- .../meilisearch/integration/SearchTest.java | 60 +++++++++++++------ src/test/resources/movies.json | 12 ++++ 5 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java diff --git a/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java b/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java index 6bff457a..67128dc2 100644 --- a/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java +++ b/src/main/java/com/meilisearch/sdk/MultiSearchFederation.java @@ -1,10 +1,8 @@ package com.meilisearch.sdk; import java.util.Map; - -import org.json.JSONObject; - import lombok.Getter; +import org.json.JSONObject; @Getter public class MultiSearchFederation { diff --git a/src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java b/src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java new file mode 100644 index 00000000..e3824e2b --- /dev/null +++ b/src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java @@ -0,0 +1,10 @@ +package com.meilisearch.sdk.model; + +import java.util.HashMap; +import lombok.Getter; + +@Getter +public class FacetsByIndexInfo { + private Object distribution; + private HashMap stats; +} diff --git a/src/main/java/com/meilisearch/sdk/model/MultiSearchResult.java b/src/main/java/com/meilisearch/sdk/model/MultiSearchResult.java index 57d5662c..66646754 100644 --- a/src/main/java/com/meilisearch/sdk/model/MultiSearchResult.java +++ b/src/main/java/com/meilisearch/sdk/model/MultiSearchResult.java @@ -15,13 +15,14 @@ public class MultiSearchResult implements Searchable { String indexUid; ArrayList> hits; - Object facetDistribution; + HashMap> facetDistribution; HashMap facetStats; int processingTimeMs; String query; int offset; int limit; int estimatedTotalHits; + HashMap facetsByIndex; public MultiSearchResult() {} } diff --git a/src/test/java/com/meilisearch/integration/SearchTest.java b/src/test/java/com/meilisearch/integration/SearchTest.java index c3b963d1..dfab407a 100644 --- a/src/test/java/com/meilisearch/integration/SearchTest.java +++ b/src/test/java/com/meilisearch/integration/SearchTest.java @@ -21,7 +21,6 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Map; - import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -935,42 +934,67 @@ public void testMultiSearchWithDistinct() throws Exception { @Test public void testMultiSearchWithMergeFacets() { - Index moviesIndex = client.index("movies"); - Index nestedMoviesIndex = client.index("nestedMovies"); + HashSet indexUids = new HashSet(); + indexUids.add("movies"); + indexUids.add("nestedMovies"); - TestData testData = this.getTestData(NESTED_MOVIES, Movie.class); - TaskInfo task1 = nestedMoviesIndex.addDocuments(testData.getRaw()); + for (String indexUid : indexUids) { - nestedMoviesIndex.waitForTask(task1.getTaskUid()); + Index index = client.index(indexUid); - Settings settings = new Settings(); - settings.setFilterableAttributes(new String[] {"id", "title"}); - settings.setSortableAttributes(new String[]{"id"}); + TestData nestedTestData = this.getTestData(NESTED_MOVIES, Movie.class); + TaskInfo task1 = index.addDocuments(nestedTestData.getRaw()); - moviesIndex.waitForTask(moviesIndex.updateSettings(settings).getTaskUid()); + index.waitForTask(task1.getTaskUid()); - TestData moviesTestData = this.getTestData(MOVIES_INDEX, Movie.class); - TaskInfo task2 = moviesIndex.addDocuments(moviesTestData.getRaw()); + Settings settings = new Settings(); + settings.setFilterableAttributes(new String[] {"id", "title"}); + settings.setSortableAttributes(new String[] {"id"}); - moviesIndex.waitForTask(task2.getTaskUid()); + index.waitForTask(index.updateSettings(settings).getTaskUid()); + + TestData moviesTestData = this.getTestData(MOVIES_INDEX, Movie.class); + TaskInfo task2 = index.addDocuments(moviesTestData.getRaw()); + + index.waitForTask(task2.getTaskUid()); + } MultiSearchRequest search = new MultiSearchRequest(); - search.addQuery(new IndexSearchRequest("movies").setQuery("Hobbit")); - search.addQuery(new IndexSearchRequest("nestedMovies").setQuery("Hobbit")); + for (String indexUid : indexUids) { + search.addQuery(new IndexSearchRequest(indexUid).setQuery("Hobbit")); + } MultiSearchFederation federation = new MultiSearchFederation(); federation.setLimit(20); federation.setOffset(0); federation.setMergeFacets(new MergeFacets(10)); Map facetsByIndex = new HashMap(); - facetsByIndex.put("nestedMovies",new String[]{"title"}); + facetsByIndex.put("nestedMovies", new String[] {"title"}); facetsByIndex.put("movies", new String[] {"title", "id"}); federation.setFacetsByIndex(facetsByIndex); - MultiSearchResult[] results = client.multiSearch(search).getResults(); + MultiSearchResult results = client.multiSearch(search, federation); - assertThat(results.length, is(2)); + assertThat(results.getHits().size(), is(4)); + + HashMap facetStats = results.getFacetStats(); + HashMap> facetDistribution = results.getFacetDistribution(); + + assertThat(facetDistribution, is(not(nullValue()))); + assertThat(facetStats, is(not(nullValue()))); + assertThat(results.getFacetsByIndex(), is(nullValue())); + + FacetRating idFacet = facetStats.get("id"); + + assertThat(idFacet.getMin(), is(equalTo(2.0))); + assertThat(idFacet.getMax(), is(equalTo(5.0))); + + assertThat(facetDistribution.get("id").get("2"), is(equalTo(1))); + assertThat(facetDistribution.get("id").get("5"), is(equalTo(1))); + + assertThat(facetDistribution.get("title").get("Hobbit"), is(equalTo(2))); + assertThat(facetDistribution.get("title").get("The Hobbit"), is(equalTo(2))); } @Test diff --git a/src/test/resources/movies.json b/src/test/resources/movies.json index 6371856f..a13d0ed0 100644 --- a/src/test/resources/movies.json +++ b/src/test/resources/movies.json @@ -396,5 +396,17 @@ "Thriller", "Drama" ] + }, + { + "id": 2, + "title": "Hobbit", + "poster": "https://www.imdb.com/title/tt0903624/mediaviewer/rm3577719808/?ref_=tt_ov_i", + "overview": "A reluctant Hobbit, Bilbo Baggins, sets out to the Lonely Mountain with a spirited group of dwarves to reclaim their mountain home and the gold within it from the dragon Smaug.", + "release_date": "2021-12-14", + "language": "en", + "genres": [ + "Adventure", + "Fantasy" + ] } ] From 91c8017794565249cc248357623e5fa21078a875 Mon Sep 17 00:00:00 2001 From: Balazs Barabas Date: Mon, 11 Nov 2024 18:18:22 +0200 Subject: [PATCH 3/6] added test for facetsbyindex and better typing for distribution --- .../sdk/model/FacetsByIndexInfo.java | 2 +- .../meilisearch/integration/SearchTest.java | 93 ++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java b/src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java index e3824e2b..06742f17 100644 --- a/src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java +++ b/src/main/java/com/meilisearch/sdk/model/FacetsByIndexInfo.java @@ -5,6 +5,6 @@ @Getter public class FacetsByIndexInfo { - private Object distribution; + private HashMap> distribution; private HashMap stats; } diff --git a/src/test/java/com/meilisearch/integration/SearchTest.java b/src/test/java/com/meilisearch/integration/SearchTest.java index dfab407a..3b2b8f6b 100644 --- a/src/test/java/com/meilisearch/integration/SearchTest.java +++ b/src/test/java/com/meilisearch/integration/SearchTest.java @@ -932,6 +932,96 @@ public void testMultiSearchWithDistinct() throws Exception { } } + @Test + public void testMultiSearchWithFacetsByIndex() { + HashSet indexUids = new HashSet(); + indexUids.add("movies"); + indexUids.add("nestedMovies"); + + for (String indexUid : indexUids) { + + Index index = client.index(indexUid); + + TestData nestedTestData = this.getTestData(NESTED_MOVIES, Movie.class); + TaskInfo task1 = index.addDocuments(nestedTestData.getRaw()); + + index.waitForTask(task1.getTaskUid()); + + Settings settings = new Settings(); + settings.setFilterableAttributes(new String[] {"id", "title"}); + settings.setSortableAttributes(new String[] {"id"}); + + index.waitForTask(index.updateSettings(settings).getTaskUid()); + + TestData moviesTestData = this.getTestData(MOVIES_INDEX, Movie.class); + TaskInfo task2 = index.addDocuments(moviesTestData.getRaw()); + + index.waitForTask(task2.getTaskUid()); + } + + MultiSearchRequest search = new MultiSearchRequest(); + + for (String indexUid : indexUids) { + search.addQuery(new IndexSearchRequest(indexUid).setQuery("Hobbit")); + } + + MultiSearchFederation federation = new MultiSearchFederation(); + federation.setLimit(20); + federation.setOffset(0); + Map facetsByIndex = new HashMap(); + facetsByIndex.put("nestedMovies", new String[] {"title"}); + facetsByIndex.put("movies", new String[] {"title", "id"}); + federation.setFacetsByIndex(facetsByIndex); + + MultiSearchResult results = client.multiSearch(search, federation); + + assertThat(results.getHits().size(), is(4)); + + HashMap facetStats = results.getFacetStats(); + HashMap> facetDistribution = + results.getFacetDistribution(); + + HashMap facetsByIndexInfo = results.getFacetsByIndex(); + + assertThat(facetDistribution, is(nullValue())); + assertThat(facetStats, is(nullValue())); + assertThat(facetsByIndexInfo, is(not(nullValue()))); + + for (String indexUid : indexUids) { + FacetsByIndexInfo indexInfo = facetsByIndexInfo.get(indexUid); + assertThat(indexInfo.getDistribution(), is(not(nullValue()))); + assertThat(indexInfo.getStats(), is(not(nullValue()))); + } + + HashMap> moviesIndexDistribution = + facetsByIndexInfo.get("movies").getDistribution(); + + assertThat(moviesIndexDistribution.get("id"), is(not(nullValue()))); + assertThat(moviesIndexDistribution.get("id").get("2"), is(equalTo(1))); + assertThat(moviesIndexDistribution.get("id").get("5"), is(equalTo(1))); + assertThat(moviesIndexDistribution.get("title"), is(not(nullValue()))); + assertThat(moviesIndexDistribution.get("title").get("Hobbit"), is(equalTo(1))); + assertThat(moviesIndexDistribution.get("title").get("The Hobbit"), is(equalTo(1))); + + HashMap moviesFacetRating = facetsByIndexInfo.get("movies").getStats(); + FacetRating idMoviesFacetRating = moviesFacetRating.get("id"); + + assertThat(idMoviesFacetRating, is(not(nullValue()))); + assertThat(idMoviesFacetRating.getMin(), is(equalTo(2.0))); + assertThat(idMoviesFacetRating.getMax(), is(equalTo(5.0))); + + HashMap> nestedMoviesIndexDistribution = + facetsByIndexInfo.get("nestedMovies").getDistribution(); + + assertThat(nestedMoviesIndexDistribution.get("title"), is(not(nullValue()))); + assertThat(nestedMoviesIndexDistribution.get("title").get("Hobbit"), is(equalTo(1))); + assertThat(nestedMoviesIndexDistribution.get("title").get("The Hobbit"), is(equalTo(1))); + + HashMap nestedMoviesFacetRating = + facetsByIndexInfo.get("nestedMovies").getStats(); + assertThat(nestedMoviesFacetRating.size(), is(equalTo((0)))); + } + @Test public void testMultiSearchWithMergeFacets() { HashSet indexUids = new HashSet(); @@ -979,7 +1069,8 @@ public void testMultiSearchWithMergeFacets() { assertThat(results.getHits().size(), is(4)); HashMap facetStats = results.getFacetStats(); - HashMap> facetDistribution = results.getFacetDistribution(); + HashMap> facetDistribution = + results.getFacetDistribution(); assertThat(facetDistribution, is(not(nullValue()))); assertThat(facetStats, is(not(nullValue()))); From a7f5162c6a1c87c79d2bad118fc3f6388658bcf4 Mon Sep 17 00:00:00 2001 From: Balazs Barabas Date: Mon, 11 Nov 2024 20:51:08 +0200 Subject: [PATCH 4/6] updated searchtests based on new data --- .../com/meilisearch/integration/SearchTest.java | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/test/java/com/meilisearch/integration/SearchTest.java b/src/test/java/com/meilisearch/integration/SearchTest.java index 3b2b8f6b..be4dbe17 100644 --- a/src/test/java/com/meilisearch/integration/SearchTest.java +++ b/src/test/java/com/meilisearch/integration/SearchTest.java @@ -686,12 +686,10 @@ public void testRawSearchSortWithPlaceHolder() throws Exception { Results resGson = jsonGson.decode(index.rawSearch(searchRequest), Results.class); assertThat(resGson.hits, is(arrayWithSize(20))); - assertThat(resGson.hits[0].getId(), is(equalTo("155"))); - assertThat(resGson.hits[0].getTitle(), is(equalTo("The Dark Knight"))); - assertThat(resGson.hits[1].getId(), is(equalTo("671"))); - assertThat( - resGson.hits[1].getTitle(), - is(equalTo("Harry Potter and the Philosopher's Stone"))); + assertThat(resGson.hits[0].getId(), is(equalTo("2"))); + assertThat(resGson.hits[0].getTitle(), is(equalTo("Hobbit"))); + assertThat(resGson.hits[1].getId(), is(equalTo("155"))); + assertThat(resGson.hits[1].getTitle(), is(equalTo("The Dark Knight"))); } /** Test search matches */ @@ -729,7 +727,7 @@ public void testSearchPage() throws Exception { assertThat(searchResult.getHits(), hasSize(20)); assertThat(searchResult.getPage(), is(equalTo(1))); assertThat(searchResult.getHitsPerPage(), is(equalTo(20))); - assertThat(searchResult.getTotalHits(), is(equalTo(30))); + assertThat(searchResult.getTotalHits(), is(equalTo(31))); assertThat(searchResult.getTotalPages(), is(equalTo(2))); } @@ -750,8 +748,8 @@ public void testSearchPagination() throws Exception { assertThat(searchResult.getHits(), hasSize(2)); assertThat(searchResult.getPage(), is(equalTo(2))); assertThat(searchResult.getHitsPerPage(), is(equalTo(2))); - assertThat(searchResult.getTotalHits(), is(equalTo(30))); - assertThat(searchResult.getTotalPages(), is(equalTo(15))); + assertThat(searchResult.getTotalHits(), is(equalTo(31))); + assertThat(searchResult.getTotalPages(), is(equalTo(16))); } /** Test place holder search */ From dd1a1090081d0588576ca7908e0716df69faaa9e Mon Sep 17 00:00:00 2001 From: Balazs Barabas Date: Tue, 12 Nov 2024 10:50:42 +0200 Subject: [PATCH 5/6] searchtest adjusted values --- src/test/java/com/meilisearch/integration/SearchTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/meilisearch/integration/SearchTest.java b/src/test/java/com/meilisearch/integration/SearchTest.java index be4dbe17..9c96f7a2 100644 --- a/src/test/java/com/meilisearch/integration/SearchTest.java +++ b/src/test/java/com/meilisearch/integration/SearchTest.java @@ -87,8 +87,8 @@ public void testSearchOffset() throws Exception { SearchRequest searchRequest = SearchRequest.builder().q("a").offset(20).build(); SearchResult searchResult = (SearchResult) index.search(searchRequest); - assertThat(searchResult.getHits(), hasSize(10)); - assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(30))); + assertThat(searchResult.getHits(), hasSize(11)); + assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(31))); } /** Test search limit */ @@ -106,7 +106,7 @@ public void testSearchLimit() throws Exception { SearchResult searchResult = (SearchResult) index.search(searchRequest); assertThat(searchResult.getHits(), hasSize(2)); - assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(30))); + assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(31))); } /** Test search attributesToRetrieve */ @@ -311,7 +311,7 @@ public void testSearchWithMatchingStrategy() throws Exception { SearchResult searchResult = (SearchResult) index.search(searchRequest); assertThat(searchResult.getHits(), hasSize(20)); - assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(21))); + assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(22))); } /** Test search with frequency matching strategy */ From bd9a723491f5eff24593d8536f462ba40812f43e Mon Sep 17 00:00:00 2001 From: Balazs Barabas Date: Tue, 12 Nov 2024 11:15:50 +0200 Subject: [PATCH 6/6] updated token test --- src/test/java/com/meilisearch/integration/TenantTokenTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/meilisearch/integration/TenantTokenTest.java b/src/test/java/com/meilisearch/integration/TenantTokenTest.java index a041140c..3147514d 100644 --- a/src/test/java/com/meilisearch/integration/TenantTokenTest.java +++ b/src/test/java/com/meilisearch/integration/TenantTokenTest.java @@ -108,7 +108,7 @@ public void testGenerateTenantTokenWithFilter() throws Exception { assertThat(searchResult.getHits().size(), is(equalTo(20))); assertThat(searchResult.getLimit(), is(equalTo(20))); - assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(30))); + assertThat(searchResult.getEstimatedTotalHits(), is(equalTo(31))); } /** Test Create Tenant Token with expiration date */