From 1c779dfd40746b62aaef3e2563d51c1573610727 Mon Sep 17 00:00:00 2001 From: Arnab Nandy Date: Sat, 18 Jul 2026 16:18:27 +0530 Subject: [PATCH] fix(spanner): preserve custom query ordering Signed-off-by: Arnab Nandy --- docs/src/main/asciidoc/spanner.adoc | 7 ++++--- .../query/SpannerStatementQueryExecutor.java | 10 +++++----- .../repository/query/SqlSpannerQueryTests.java | 12 +++--------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/docs/src/main/asciidoc/spanner.adoc b/docs/src/main/asciidoc/spanner.adoc index 492cb7a06a4..b05e67c8042 100644 --- a/docs/src/main/asciidoc/spanner.adoc +++ b/docs/src/main/asciidoc/spanner.adoc @@ -1170,13 +1170,14 @@ The SQL query for the method can be mapped to repository methods in one of two w The names of the tags of the SQL correspond to the `@Param` annotated names of the method parameters. Interleaved properties are loaded eagerly, unless they are annotated with `@Interleaved(lazy = true)`. +For a custom SQL query without a `Sort` or `Pageable` parameter, eager interleaved properties are loaded after the parent query so that the query's `ORDER BY`, `LIMIT`, and `OFFSET` clauses retain their Cloud Spanner semantics. +This may execute additional queries to load the interleaved properties. Custom SQL query methods can accept a single `Sort` or `Pageable` parameter that is applied on top of the specified custom query. -It is the recommended way to control the sort order of the results, which is not guaranteed by the `ORDER BY` clause in the SQL query. -This is due to the fact that the user-provided query is used as a sub-query, and Cloud Spanner doesn't preserve order in subquery results. +When supplied, it is the recommended way to control the final sort order because it is applied to the outer query. You might want to use `ORDER BY` with `LIMIT` to obtain the top records, according to a specified order. -However, to ensure the correct sort order of the final result set, sort options have to be passed in with a `Pageable`. +If a `Pageable` parameter is also used, include the desired sort options in the `Pageable`. [source, java] ---- diff --git a/spring-cloud-gcp-data-spanner/src/main/java/com/google/cloud/spring/data/spanner/repository/query/SpannerStatementQueryExecutor.java b/spring-cloud-gcp-data-spanner/src/main/java/com/google/cloud/spring/data/spanner/repository/query/SpannerStatementQueryExecutor.java index b4d38bd3837..45ac47203eb 100644 --- a/spring-cloud-gcp-data-spanner/src/main/java/com/google/cloud/spring/data/spanner/repository/query/SpannerStatementQueryExecutor.java +++ b/spring-cloud-gcp-data-spanner/src/main/java/com/google/cloud/spring/data/spanner/repository/query/SpannerStatementQueryExecutor.java @@ -172,6 +172,7 @@ public static List executeQuery( * eager-Interleaved lists with a single query. Please note, it doesn't make sense to pass it * as {@code true} when the {@code sql} already contains a complete lists of all * eager-Interleaved properties generated by the method {@link #getColumnsStringForSelect}. + * This option is ignored when no sorting or paging option requires wrapping the query. * @param the domain type. * @return the final SQL string with paging and sorting applied. * @see #getColumnsStringForSelect @@ -182,13 +183,12 @@ public static String applySortingPagingQueryOptions( String sql, SpannerMappingContext mappingContext, boolean fetchInterleaved) { - // Cloud Spanner does not preserve the order of derived tables so we must not wrap the - // derived table - // in SELECT * FROM () if there is no overriding pageable param. + // Cloud Spanner does not preserve the order of derived tables, so do not wrap a custom query + // solely to fetch interleaved properties. SpannerTemplate resolves properties that are absent + // from the result after mapping, which preserves ORDER BY, LIMIT, and OFFSET in the custom SQL. if ((options.getSort() == null || options.getSort().isUnsorted()) && options.getLimit() == null - && options.getOffset() == null - && !fetchInterleaved) { + && options.getOffset() == null) { return sql; } diff --git a/spring-cloud-gcp-data-spanner/src/test/java/com/google/cloud/spring/data/spanner/repository/query/SqlSpannerQueryTests.java b/spring-cloud-gcp-data-spanner/src/test/java/com/google/cloud/spring/data/spanner/repository/query/SqlSpannerQueryTests.java index 79e952362ba..b38e93015cb 100644 --- a/spring-cloud-gcp-data-spanner/src/test/java/com/google/cloud/spring/data/spanner/repository/query/SqlSpannerQueryTests.java +++ b/spring-cloud-gcp-data-spanner/src/test/java/com/google/cloud/spring/data/spanner/repository/query/SqlSpannerQueryTests.java @@ -156,15 +156,9 @@ private SqlSpannerQuery createQuery( void noPageableParamQueryTest(boolean useValueExpressionDelegate) throws NoSuchMethodException { String sql = "SELECT DISTINCT * FROM " - + ":com.google.cloud.spring.data.spanner.repository.query.SqlSpannerQueryTests$Trade:"; - // @formatter:off - String entityResolvedSql = - "SELECT *, ARRAY (SELECT AS STRUCT disabled, id, childId, value, ARRAY (SELECT AS STRUCT" - + " canceled, documentId, id, childId, content FROM documents WHERE (documents.id =" - + " children.id AND documents.childId = children.childId) AND (canceled = false)) AS" - + " documents FROM children WHERE (children.id = trades.id) AND (disabled = false)) AS" - + " children FROM (SELECT DISTINCT * FROM trades) trades"; - // @formatter:on + + ":com.google.cloud.spring.data.spanner.repository.query.SqlSpannerQueryTests$Trade:" + + " ORDER BY id DESC LIMIT 3"; + String entityResolvedSql = "SELECT DISTINCT * FROM trades ORDER BY id DESC LIMIT 3"; final Class toReturn = Trade.class; when(queryMethod.isCollectionQuery()).thenReturn(false);