From d28fb661e7209a7efafbb671d58eaef988744155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Andr=C3=A9n?= Date: Thu, 16 Jan 2025 08:33:12 +0100 Subject: [PATCH] docs: Refresh view query language section (#144) --------- Co-authored-by: Peter Vlugter <59895+pvlugter@users.noreply.github.com> --- .../java/akkajavasdk/ViewIntegrationTest.java | 85 ++++++++- .../components/views/AllTheTypesView.java | 37 ++++ .../java/partials/query-syntax-reference.adoc | 162 ++++++++++++------ docs/src/modules/reference/nav.adoc | 3 +- 4 files changed, 230 insertions(+), 57 deletions(-) diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/ViewIntegrationTest.java b/akka-javasdk-tests/src/test/java/akkajavasdk/ViewIntegrationTest.java index 574402622..587660ea2 100644 --- a/akka-javasdk-tests/src/test/java/akkajavasdk/ViewIntegrationTest.java +++ b/akka-javasdk-tests/src/test/java/akkajavasdk/ViewIntegrationTest.java @@ -34,6 +34,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import java.time.Instant; +import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; @@ -41,6 +42,7 @@ import java.util.concurrent.TimeUnit; +import static java.time.temporal.ChronoUnit.DAYS; import static java.time.temporal.ChronoUnit.HOURS; import static java.time.temporal.ChronoUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; @@ -173,7 +175,9 @@ public void verifyAllTheFieldTypesView() throws Exception { // see that we can persist and read a row with all fields, no indexed columns var id = newId(); var row = new AllTheTypesKvEntity.AllTheTypes(1, 2L, 3F, 4D, true, "text", 5, 6L, 7F, 8D, false, - Instant.now(), ZonedDateTime.now(), + Instant.now(), + // Note: we turn it into a timestamp internally, so the specific TZ is lost (but the exact point in time stays the same) + ZonedDateTime.now().withZoneSameInstant(ZoneId.of("Z")), Optional.of("optional"), List.of("text1", "text2"), new AllTheTypesKvEntity.ByEmail("test@example.com"), AllTheTypesKvEntity.AnEnum.THREE, new AllTheTypesKvEntity.Recursive(new AllTheTypesKvEntity.Recursive(null, "level2"), "level1")); @@ -221,6 +225,85 @@ public void verifyAllTheFieldTypesView() throws Exception { assertThat(rows).hasSize(1); }); + + Awaitility.await() + .ignoreExceptions() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + var result = await(componentClient.forView() + .method(AllTheTypesView::countRows) + .invokeAsync()); + + assertThat(result.count()).isEqualTo(1); + }); + + + Awaitility.await() + .ignoreExceptions() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + var rows = await(componentClient.forView() + .stream(AllTheTypesView::compareInstant) + .source(new AllTheTypesView.InstantRequest(Instant.now().minus(3, DAYS))) + .runWith(Sink.seq(), testKit.getMaterializer())); + + assertThat(rows).hasSize(1); + }); + + Awaitility.await() + .ignoreExceptions() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + var rows = await(componentClient.forView() + .stream(AllTheTypesView::groupQuery) + .source() + .runWith(Sink.seq(), testKit.getMaterializer())); + + assertThat(rows).hasSize(1); + assertThat(rows.getFirst().grouped()).hasSize(1); + assertThat(rows.getFirst().grouped().getFirst()).isEqualTo(row); + assertThat(rows.getFirst().totalCount()).isEqualTo(1L); + }); + + Awaitility.await() + .ignoreExceptions() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + var rows = await(componentClient.forView() + .stream(AllTheTypesView::projectedGroupQuery) + .source() + .runWith(Sink.seq(), testKit.getMaterializer())); + + assertThat(rows).hasSize(1); + assertThat(rows.getFirst().groupedStringValues()).hasSize(1); + assertThat(rows.getFirst().groupedStringValues().getFirst()).isEqualTo(row.stringValue()); + assertThat(rows.getFirst().totalCount()).isEqualTo(1L); + }); + + + Awaitility.await() + .ignoreExceptions() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + var rows = await(componentClient.forView() + .stream(AllTheTypesView::nullableQuery) + .source() + .runWith(Sink.seq(), testKit.getMaterializer())); + + assertThat(rows).hasSize(1); + }); + + Awaitility.await() + .ignoreExceptions() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + var page = await(componentClient.forView() + .method(AllTheTypesView::paging) + .invokeAsync(new AllTheTypesView.PageRequest(""))); + + assertThat(page.entries()).hasSize(1); + assertThat(page.hasMore()).isFalse(); + }); } @Disabled // pending primitive query parameters working diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/views/AllTheTypesView.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/views/AllTheTypesView.java index 938527b02..067aae4ef 100644 --- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/views/AllTheTypesView.java +++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/views/AllTheTypesView.java @@ -74,6 +74,43 @@ public QueryStreamEffect allRows() { return queryStreamResult(); } + public record CountResult(long count) {} + @Query("SELECT COUNT(*) FROM events") + public QueryEffect countRows() { + return queryResult(); + } + + public record InstantRequest(Instant instant) {} + @Query("SELECT * FROM events WHERE instant > :instant") + public QueryStreamEffect compareInstant(InstantRequest request) { return queryStreamResult(); } + + public record GroupResult(List grouped, long totalCount) {} + @Query("SELECT collect(*) AS grouped, total_count() FROM events GROUP BY intValue") + public QueryStreamEffect groupQuery() { return queryStreamResult(); } + + public record ProjectedGroupResult(int intValue, List groupedStringValues, long totalCount) {} + @Query("SELECT intValue, stringValue AS groupedStringValues, total_count() FROM events GROUP BY intValue") + public QueryStreamEffect projectedGroupQuery() { return queryStreamResult(); } + + + @Query("SELECT * FROM events WHERE optionalString IS NOT NULL AND nestedMessage.email IS NOT NULL") + public QueryStreamEffect nullableQuery() { + return queryStreamResult(); + } + + public record PageRequest(String pageToken) {} + public record Page(List entries, String nextPageToken, boolean hasMore) { } + + @Query(""" + SELECT * AS entries, next_page_token() AS nextPageToken, has_more() AS hasMore + FROM events + OFFSET page_token_offset(:pageToken) + LIMIT 10 + """) + public QueryEffect paging(PageRequest request) { + return queryResult(); + } + public record BeforeRequest(Instant instant) {} @Query("SELECT * FROM events WHERE zonedDateTime < :instant") diff --git a/docs/src/modules/java/partials/query-syntax-reference.adoc b/docs/src/modules/java/partials/query-syntax-reference.adoc index ba5b3e844..3710d2bf2 100644 --- a/docs/src/modules/java/partials/query-syntax-reference.adoc +++ b/docs/src/modules/java/partials/query-syntax-reference.adoc @@ -4,38 +4,38 @@ Define View queries in a language that is similar to SQL. The following examples === Retrieving -* All customers without any filtering conditions (no WHERE clause): +* All customers without any filtering conditions: + -[source,proto,indent=0] +[source,genericsql,indent=0] ---- SELECT * FROM customers ---- -* Customers with a name matching the `customer_name` property of the request message: +=== Filter predicates + +Use filter predicates in `WHERE` conditions to further refine results. + +* Customers with a name matching the `customerName` property of the request object: + -[source,proto,indent=0] +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE name = :customer_name +SELECT * FROM customers WHERE name = :customerName ---- -* Customers matching the `customer_name` AND `city` properties of the request message, with `city` being matched on a nested field: +* Customers matching the `customerName` AND `city` properties of the request object, with `city` being matched on a nested field: + -[source,proto,indent=0] +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE name = :customer_name AND address.city = :city +SELECT * FROM customers WHERE name = :customerName AND address.city = :city ---- * Customers in a city matching a literal value: + -[source,proto,indent=0] +[source,genericsql,indent=0] ---- SELECT * FROM customers WHERE address.city = 'New York' ---- -=== Filter predicates - -Use filter predicates in `WHERE` conditions to further refine results. - ==== Comparison operators The following comparison operators are supported: @@ -49,19 +49,9 @@ The following comparison operators are supported: ==== Logical operators -//// -Combine filter conditions with the `AND` and `OR` operators, and negate using the `NOT` operator. Group conditions using parentheses. Note that `AND` has precedence over `OR`. - -[source,proto,indent=0] ----- -SELECT * FROM customers WHERE - name = :customer_name AND address.city = 'New York' OR - NOT (name = :customer_name AND address.city = 'San Francisco') ----- -//// - Combine filter conditions with the `AND` or `OR` operators, and negate using the `NOT` operator. Group conditions using parentheses. +[source,genericsql,indent=0] ---- SELECT * FROM customers WHERE name = :customer_name AND NOT (address.city = 'New York' AND age > 65) @@ -69,30 +59,34 @@ SELECT * FROM customers WHERE ==== Array operators -Use `IN` or `= ANY` to check whether a value is contained in a group of values or in an array column or parameter (a `repeated` field in the Protobuf message). +Use `IN` or `= ANY` to check whether a value is contained in a group of values or in a `List` field. Use `IN` with a list of values or parameters: +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE email IN ('bob@example.com', :some_email) +SELECT * FROM customers WHERE email IN ('bob@example.com', :someEmail) ---- -Use `= ANY` to check against an array column (a `repeated` field in the Protobuf message): +Use `= ANY` to check against a `List` column: +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE :some_email = ANY(emails) +SELECT * FROM customers WHERE :someEmail = ANY(emails) ---- -Or use `= ANY` with a repeated field in the request parameters: +Or use `= ANY` with a `List` field in the request object: +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE email = ANY(:some_emails) +SELECT * FROM customers WHERE email = ANY(:someEmails) ---- ==== Pattern matching Use `LIKE` to pattern match on strings. The standard SQL `LIKE` patterns are supported, with `_` (underscore) matching a single character, and `%` (percent sign) matching any sequence of zero or more characters. +[source,genericsql,indent=0] ---- SELECT * FROM customers WHERE name LIKE 'Bob%' ---- @@ -103,6 +97,7 @@ NOTE: For index efficiency, the pattern must have a non-wildcard prefix or suffi Use the `text_search` function to search text values for words, with automatic tokenization and normalization based on language-specific configuration. The `text_search` function takes the text column to search, the query (as a parameter or literal string), and an optional language configuration. +[source,genericsql,indent=0] ---- text_search(, , []) ---- @@ -111,6 +106,7 @@ If the query contains multiple words, the text search will find values that cont The following text search language configurations are supported: `'danish'`, `'dutch'`, `'english'`, `'finnish'`, `'french'`, `'german'`, `'hungarian'`, `'italian'`, `'norwegian'`, `'portuguese'`, `'romanian'`, `'russian'`, `'simple'`, `'spanish'`, `'swedish'`, `'turkish'`. By default, a `'simple'` configuration will be used, without language-specific features. +[source,genericsql,indent=0] ---- SELECT * FROM customers WHERE text_search(profile, :search_words, 'english') ---- @@ -131,10 +127,10 @@ When modeling your queries, the following data types are supported: | Integer | `int` / `Integer` -| Long (Big Integer) +| Long | `long` / `Long` -| Float (Real) +| Float | `float` / `Float` | Double @@ -143,10 +139,7 @@ When modeling your queries, the following data types are supported: | Boolean | `boolean` / `Boolean` -| Byte String -| `ByteString` - -| Array +| Lists | `Collection` and derived | Timestamp @@ -164,24 +157,28 @@ Fields in a view type that were not given a value are handled as the default val However, in some use cases it is important to explicitly express that a value is missing, doing that in a view column can be done in two ways: * use one of the Java non-primitive types for the field (e.g. use `Integer` instead of `int`) +* Wrap the value in an `java.util.Optional` * make the field a part of another class and leave it uninitialized (i.e. `null`), for example `address.street` where the lack of an `address` message implies there is no `street` field. Optional fields with values present can be queried just like regular view fields: +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE phone_number = :number +SELECT * FROM customers WHERE phoneNumber = :number ---- Finding results with missing values can be done using `IS NULL`: +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE phone_number IS NULL +SELECT * FROM customers WHERE phoneNumber IS NULL ---- Finding entries with any value present can be queried using `IS NOT NULL`: +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE phone_number IS NOT NULL +SELECT * FROM customers WHERE phoneNumber IS NOT NULL ---- Optional fields in query requests messages are handled like normal fields if they have a value, however missing optional request parameters are seen as an invalid request and lead to a bad request response. @@ -192,11 +189,63 @@ Results for a view query can be sorted. Use `ORDER BY` with view columns to sort If no explicit ordering is specified in a view query, results will be returned in the natural index order, which is based on the filter predicates in the query. +[source,genericsql,indent=0] +---- +SELECT * FROM customers WHERE name = :name AND age > :minAge ORDER BY age DESC +---- + +NOTE: Some orderings may be rejected, if the view index cannot be efficiently ordered. Generally, to order by a field it should also appear in the `WHERE` conditions. + +=== Aggregation + +==== Grouping + +Grouping of results based on a field is supported using `collect(*)`. Each found key leads to one returned entry, where +all the entries for that key are collected into a `List` field. + +Given the view data structure and response type: + +[source,java,indent=0] +---- +record Product(String name, int popularity) {} +record GroupedProducts(int popularity, List products) {} +---- + +[source,genericsql,indent=0] +---- +SELECT popularity, collect(*) AS products + FROM all_products + GROUP BY popularity + ORDER BY popularity +---- + +This example query returns one `GroupedProducts` entry per found unique popularity value, with all the products with +that popularity in the `products` list. + +It is also possible to project individual fields in the grouped result. Given the previous `Product` view table type +and the following response type: + +[source,java,indent=0] +---- +record GroupedProductsNames(int popularity, List productNames) {} +---- + +[source,genericsql,indent=0] ---- -SELECT * FROM customers WHERE name = :name AND age > :min_age ORDER BY age DESC +SELECT popularity, name AS productNames + FROM all_products + GROUP BY popularity + ORDER BY popularity ---- -NOTE: Some orderings may be rejected, if the view index cannot be efficiently ordered. Generally, to order by a column it should also appear in the `WHERE` conditions. +==== Count + +Counting results matching a query can be done using `count(*)`. + +[source,genericsql,indent=0] +---- +SELECT count(*) FROM customers WHERE address.city = 'New York' +---- === Paging @@ -215,15 +264,15 @@ In both cases `OFFSET` and `LIMIT` are used. The values can either be static, defined up front in the query: -[source,proto,indent=0] +[source,genericsql,indent=0] ---- SELECT * FROM customers LIMIT 10 ---- Or come from fields in the request message: -[source,proto,indent=0] +[source,genericsql,indent=0] ---- -SELECT * FROM customers OFFSET :start_from LIMIT :max_customers +SELECT * FROM customers OFFSET :startFrom LIMIT :maxCustomers ---- Note: Using count based offsets can lead to missing or duplicated entries in the result if entries are added to or removed from the view between requests for the pages. @@ -238,30 +287,33 @@ When reading the first page, an empty token is provided to `page_token_offset`. The size of each page can optionally be specified using `LIMIT`, if it is not present a default page size of 100 is used. -With the query return type like this: +With the query request and response types like this: [source,java,indent=0] ---- -public record Response(List customers, String next_page_token) { } +public record Request(String pageToken) {} +public record Response(List customers, String nextPageToken) { } ---- A query such as the one below will allow for reading through the view in pages, each containing 10 customers: -[source,proto,indent=0] +[source,genericsql,indent=0] ---- -SELECT * AS customers, next_page_token() AS next_page_token +SELECT * AS customers, next_page_token() AS nextPageToken FROM customers -OFFSET page_token_offset(:page_token) +OFFSET page_token_offset(:pageToken) LIMIT 10 ---- -The token value is not meant to be parseable into any meaningful information other than being a token for reading the next page. +The page token value string is not meant to be parseable into any meaningful information other than being a token for reading the next page. + +Starting from the beginning of the pages is done by using empty string as request `pageToken` field value. ==== Total count of results -To get the total number of results that will be returned over all pages, use `COUNT(*)` in a query that projects its results into a field. The total count will be returned in the aliased field (using `AS`) or otherwise into a field named `count`. +To get the total number of results that will be returned over all pages, use `total_count()` in a query that projects its results into a field. The total count will be returned in the aliased field (using `AS`) or otherwise into a field named `totalCount`. ---- -SELECT * AS customers, COUNT(*) AS total, has_more() AS more FROM customers LIMIT 10 +SELECT * AS customers, total_count() AS total, has_more() AS more FROM customers LIMIT 10 ---- [#has-more] @@ -269,9 +321,9 @@ SELECT * AS customers, COUNT(*) AS total, has_more() AS more FROM customers LIMI To check if there are more pages left, you can use the function `has_more()` providing a boolean value for the result. This works both for the count and token based offset paging, and also when only using `LIMIT` without any `OFFSET`: -[source,proto,indent=0] +[source,genericsql,indent=0] ---- -SELECT * AS customers, has_more() AS more_customers FROM customers LIMIT 10 +SELECT * AS customers, has_more() AS moreCustomers FROM customers LIMIT 10 ---- -This query will return `more_customers = true` when the view contains more than 10 customers. +This query will return `moreCustomers = true` when the view contains more than 10 customers. diff --git a/docs/src/modules/reference/nav.adoc b/docs/src/modules/reference/nav.adoc index d7b7571f7..0495645f0 100644 --- a/docs/src/modules/reference/nav.adoc +++ b/docs/src/modules/reference/nav.adoc @@ -5,8 +5,9 @@ ** xref:glossary.adoc[Glossary of terms] ** xref:security-announcements/index.adoc[Security announcements] ** xref:release-notes.adoc[Release notes] -** xref:migration-guide.adoc[Migration Guide] +** xref:migration-guide.adoc[Migration guide] ** xref:api-docs.adoc[] +** xref:java:views.adoc#query[View query language] ** xref:cli/index.adoc[] *** xref:cli/installation.adoc[] *** xref:cli/using-cli.adoc[]