diff --git a/compatibility-tests/sdk-test-dotnet/CosmosBracketPropertyCompatibilityTests.cs b/compatibility-tests/sdk-test-dotnet/CosmosBracketPropertyCompatibilityTests.cs new file mode 100644 index 00000000..0b0f0211 --- /dev/null +++ b/compatibility-tests/sdk-test-dotnet/CosmosBracketPropertyCompatibilityTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Azure.Cosmos; +using Newtonsoft.Json.Linq; + +namespace FlociAz.Compatibility; + +[NotInParallel] +public sealed class CosmosBracketPropertyCompatibilityTests +{ + [Test] + [Timeout(60_000)] + public async Task ErasureQueryFindsBracketIndexedUserReferences(CancellationToken cancellationToken) + { + string endpoint = Environment.GetEnvironmentVariable("FLOCI_AZ_ENDPOINT") ?? "http://localhost:4577"; + using var client = new CosmosClient($"{endpoint}/devstoreaccount1-cosmos/", + "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + new CosmosClientOptions { ConnectionMode = ConnectionMode.Gateway, LimitToEndpoint = true }); + Database database = await client.CreateDatabaseAsync( + $"dotnet-bracket-{Guid.NewGuid():N}", cancellationToken: cancellationToken); + try + { + Container container = await database.CreateContainerAsync( + "items", "/pk", cancellationToken: cancellationToken); + string userId = Guid.NewGuid().ToString(); + await container.CreateItemAsync(new + { + id = "declined", pk = "event-a", goingUserIds = Array.Empty(), + rsvpVersionsByUserId = new Dictionary { [userId] = 4 } + }, new PartitionKey("event-a"), cancellationToken: cancellationToken); + await container.CreateItemAsync(new + { + id = "unrelated", pk = "event-b", goingUserIds = Array.Empty(), + rsvpVersionsByUserId = new Dictionary { ["another-user"] = 2 } + }, new PartitionKey("event-b"), cancellationToken: cancellationToken); + + var query = new QueryDefinition( + "SELECT * FROM c WHERE ARRAY_CONTAINS(c.goingUserIds, @userId) " + + $"OR IS_DEFINED(c.rsvpVersionsByUserId[\"{userId}\"])") + .WithParameter("@userId", userId); + using FeedIterator iterator = container.GetItemQueryIterator(query, + requestOptions: new QueryRequestOptions { MaxItemCount = 1 }); + var ids = new List(); + while (iterator.HasMoreResults) + { + FeedResponse page = await iterator.ReadNextAsync(cancellationToken); + ids.AddRange(page.Select(item => item.Value("id")!)); + } + await Assert.That(ids).IsEquivalentTo(["declined"]); + await container.CreateItemAsync(new JObject + { + ["id"] = "quoted", ["pk"] = "event-c", ["a\"b"] = 7, ["a.b"] = 8 + }, new PartitionKey("event-c"), cancellationToken: cancellationToken); + using FeedIterator quoted = container.GetItemQueryIterator(new QueryDefinition( + "SELECT c[\"a\\\"b\"], c[\"a.b\"] FROM c WHERE c.id = @id") + .WithParameter("@id", "quoted")); + FeedResponse quotedPage = await quoted.ReadNextAsync(cancellationToken); + await Assert.That(quotedPage.Single().Value("a\"b")).IsEqualTo(7); + await Assert.That(quotedPage.Single().Value("a.b")).IsEqualTo(8); + } + finally + { + await database.DeleteAsync(cancellationToken: cancellationToken); + } + } +} diff --git a/src/main/java/io/floci/az/services/cosmos/CosmosIndexingPolicy.java b/src/main/java/io/floci/az/services/cosmos/CosmosIndexingPolicy.java index 14fc58a6..3d882d66 100644 --- a/src/main/java/io/floci/az/services/cosmos/CosmosIndexingPolicy.java +++ b/src/main/java/io/floci/az/services/cosmos/CosmosIndexingPolicy.java @@ -148,8 +148,7 @@ private static boolean matches(List compositePaths, List wantPaths, /** {@code "c.a.b"} → {@code "/a/b"} — strip the FROM alias, dots become slashes. */ static String normalizeOrderByPath(String expr) { - String path = CosmosQueryEngine.stripAlias(expr.trim()); - return "/" + String.join("/", path.split("\\.")); + return "/" + String.join("/", CosmosQueryEngine.propertyNames(expr.trim())); } /** {@code '/a/"b-c"/'} → {@code "/a/b-c"} — strip quote escapes and a trailing slash. */ diff --git a/src/main/java/io/floci/az/services/cosmos/CosmosQueryEngine.java b/src/main/java/io/floci/az/services/cosmos/CosmosQueryEngine.java index fe3bf556..b67a3dca 100644 --- a/src/main/java/io/floci/az/services/cosmos/CosmosQueryEngine.java +++ b/src/main/java/io/floci/az/services/cosmos/CosmosQueryEngine.java @@ -39,6 +39,7 @@ public class CosmosQueryEngine { private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String QUOTED_STRING = "'(?:\\\\.|''|[^'\\\\])*'|\"(?:\\\\.|\"\"|[^\"\\\\])*\""; public record OrderByField(String path, boolean asc) {} @@ -493,41 +494,62 @@ private boolean likeMatches(String value, String pattern) { // Field resolution // ----------------------------------------------------------------------- - /** - * Strip the FROM-alias prefix from a dotted path: {@code "c.field"} → {@code "field"}. - * Shared with the composite-index ORDER BY matcher so validation and - * execution always agree on what a property path is. - */ + private static final Pattern PROPERTY_ALIAS = Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*(?=[.\\[])"); + private static final Pattern PROPERTY_MEMBER = Pattern.compile( + "(?:^|\\.)([a-zA-Z_][a-zA-Z0-9_-]*)|\\[\\s*(\"(?:\\\\.|[^\"\\\\])*\"|'(?:\\\\.|''|[^'\\\\])*')\\s*]"); + + /** Strips the FROM alias, preserving quoted member names for property resolution. */ static String stripAlias(String path) { - if (path.contains(".")) { - String[] parts = path.split("\\.", 2); - if (parts[0].matches("[a-zA-Z_][a-zA-Z0-9_]{0,9}")) { - return parts[1]; - } + Matcher alias = PROPERTY_ALIAS.matcher(path); + if (alias.find()) { + int end = alias.end(); + return path.substring(path.charAt(end) == '.' ? end + 1 : end); } return path; } Object resolve(Map doc, String path) { - String[] segments = path.split("\\."); - Object current; - int startIndex; - if (doc instanceof QueryScope scope && scope.hasBinding(segments[0])) { - current = scope.binding(segments[0]); - startIndex = 1; - } else { - segments = stripAlias(path).split("\\."); - current = doc; - startIndex = 0; + if (doc instanceof QueryScope scope && scope.hasBinding(path)) { + return scope.binding(path); } - for (int i = startIndex; i < segments.length; i++) { - if (current instanceof Map map) { - current = map.get(segments[i]); - } else { + Matcher alias = PROPERTY_ALIAS.matcher(path); + Object current = doc; + if (alias.find() && doc instanceof QueryScope scope && scope.hasBinding(alias.group())) { + current = scope.binding(alias.group()); + } + List names = propertyNames(path); + for (String name : names) { + if (!(current instanceof Map map)) { return null; } + current = map.get(name); + } + return names.isEmpty() ? null : current; + } + + /** Shared by evaluation and composite-index validation so quoted dots stay within a member. */ + static List propertyNames(String path) { + String members = stripAlias(path); + Matcher member = PROPERTY_MEMBER.matcher(members); + List names = new ArrayList<>(); + int end = 0; + while (member.find()) { + if (member.start() != end) { + return List.of(); + } + String key = member.group(1); + if (key == null) { + String literal = member.group(2); + try { + key = literal.startsWith("\"") ? MAPPER.readValue(literal, String.class) : stripQuotes(literal); + } catch (JsonProcessingException e) { + return List.of(); + } + } + names.add(key); + end = member.end(); } - return current; + return end == members.length() ? names : List.of(); } private static final class QueryScope extends LinkedHashMap { @@ -564,6 +586,11 @@ private Map projectDoc(Map doc, List fie alias = field.substring(asIdx + 2).trim(); } else { expr = field; + List members = propertyNames(expr); + if (expr.contains("[") && !members.isEmpty()) { + result.put(members.getLast(), resolveExpr(doc, expr)); + continue; + } if (expr.contains("(")) { Matcher fm = Pattern.compile("(?i)(\\w+)\\s*\\(").matcher(expr); alias = fm.find() ? fm.group(1).toLowerCase() : expr; @@ -666,7 +693,7 @@ private String substituteParams(String sql, List> params) { } } // Match complete parameter tokens, never text within literals or replacement values. - Matcher tokens = Pattern.compile("'(?:(?:'')|[^'])*'|\"(?:(?:\"\")|[^\"])*\"|@[A-Za-z_][A-Za-z0-9_]*") + Matcher tokens = Pattern.compile(QUOTED_STRING + "|@[A-Za-z_][A-Za-z0-9_]*") .matcher(sql); return tokens.replaceAll(match -> Matcher.quoteReplacement( literals.getOrDefault(match.group(), match.group()))); @@ -688,7 +715,7 @@ private String toLiteral(Object value) { // re-enter string mode), so keyword detection — ORDER BY, AND/OR, IN — // is not swallowed by a value such as "Alice's". A backslash escape // ('\'') would leave the scanners stuck inside a phantom string. - if (value instanceof String s) return "'" + s.replace("'", "''") + "'"; + if (value instanceof String s) return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"; if (value instanceof Boolean b) return b.toString(); return String.valueOf(value); } @@ -710,18 +737,31 @@ Object parseLiteral(String s) { * escapes ({@code \'}, {@code \"}) for hand-written SQL. Inverse of the * escaping in {@link #toLiteral}. */ - private String stripQuotes(String s) { + private static String stripQuotes(String s) { if (s == null || s.length() < 2) return s; char f = s.charAt(0), l = s.charAt(s.length() - 1); if ((f == '\'' && l == '\'') || (f == '"' && l == '"')) { - return s.substring(1, s.length() - 1) - .replace("''", "'").replace("\\'", "'").replace("\\\"", "\""); + StringBuilder decoded = new StringBuilder(); + for (int i = 1; i < s.length() - 1; i++) { + char current = s.charAt(i); + if (i + 1 < s.length() - 1) { + char next = s.charAt(i + 1); + if ((current == f && next == f) + || (current == '\\' && (next == '\\' || next == '\'' || next == '"'))) { + decoded.append(next); + i++; + continue; + } + } + decoded.append(current); + } + return decoded.toString(); } return s; } private String normalizeWhitespace(String s) { - Matcher tokens = Pattern.compile("'(?:(?:'')|[^'])*'|\"(?:(?:\"\")|[^\"])*\"|\\s+").matcher(s.trim()); + Matcher tokens = Pattern.compile(QUOTED_STRING + "|\\s+").matcher(s.trim()); return tokens.replaceAll(match -> Matcher.quoteReplacement( Character.isWhitespace(match.group().charAt(0)) ? " " : match.group())); } @@ -739,7 +779,7 @@ int findTopLevelKeyword(String expr, String keyword) { for (int i = 0; i < expr.length(); i++) { char c = expr.charAt(i); if (inStr) { - if (c == strCh) inStr = false; + if (c == '\\') { i++; } else if (c == strCh) { inStr = false; } continue; } if (c == '\'' || c == '"') { inStr = true; strCh = c; continue; } @@ -764,7 +804,7 @@ private int indexOfKeyword(String upperSql, String keyword, int from) { for (int i = 0; i < upperSql.length(); i++) { char c = upperSql.charAt(i); if (inStr) { - if (c == strCh) inStr = false; + if (c == '\\') { i++; } else if (c == strCh) { inStr = false; } continue; } if (c == '\'' || c == '"') { inStr = true; strCh = c; continue; } @@ -800,7 +840,7 @@ List splitTopLevel(String s, char delim) { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (inStr) { - if (c == strCh) inStr = false; + if (c == '\\') { i++; } else if (c == strCh) { inStr = false; } } else if (c == '\'' || c == '"') { inStr = true; strCh = c; } else if (c == '(') { parenthesisDepth++; @@ -875,7 +915,7 @@ Object resolveExpr(Map doc, String expr) { char strCh = 0; for (int i = 0; i < expr.length(); i++) { char c = expr.charAt(i); - if (inStr) { if (c == strCh) inStr = false; continue; } + if (inStr) { if (c == '\\') { i++; } else if (c == strCh) { inStr = false; } continue; } if (c == '\'' || c == '"') { inStr = true; strCh = c; continue; } if (c == '(') { parenIdx = i; break; } } diff --git a/src/test/java/io/floci/az/services/cosmos/CosmosBracketPropertyTest.java b/src/test/java/io/floci/az/services/cosmos/CosmosBracketPropertyTest.java new file mode 100644 index 00000000..272aee93 --- /dev/null +++ b/src/test/java/io/floci/az/services/cosmos/CosmosBracketPropertyTest.java @@ -0,0 +1,98 @@ +package io.floci.az.services.cosmos; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class CosmosBracketPropertyTest { + private final CosmosQueryEngine engine = new CosmosQueryEngine(); + + @Test + void escapedKeyQueriesPreserveBackslashesInParameters() { + String value = "C:\\folder\\"; + var document = Map.of("a\"b", 7, "path", value); + assertEquals(List.of(7), engine.execute( + "SELECT VALUE c[\"a\\\"b\"] FROM c WHERE c.path = @path ORDER BY c.path", + List.of(Map.of("name", "@path", "value", value)), List.of(document)).items()); + } + + @Test + void bracketProjectionUsesTheDecodedPropertyName() { + var document = Map.of("a.b", 7, "map", Map.of("user-id", 8)); + assertEquals(List.of(Map.of("a.b", 7, "user-id", 8)), engine.execute( + "SELECT c[\"a.b\"], c.map[\"user-id\"] FROM c", List.of(), List.of(document)).items()); + } + + @Test + void escapedBracketKeysSurviveFullQueryScanning() { + var document = Map.of("a\"b", 7, "id", "item"); + assertEquals(List.of(7), engine.execute( + "SELECT VALUE c[\"a\\\"b\"] FROM c WHERE c.id = @id", + List.of(Map.of("name", "@id", "value", "item")), List.of(document)).items()); + assertEquals(List.of(Map.of("value", 7, "id", "item")), engine.execute( + "SELECT c[\"a\\\"b\"] AS value, c.id AS id FROM c WHERE c.id = 'item'", + List.of(), List.of(document)).items()); + } + + @Test + void correlatedAliasLongerThanTenCharactersKeepsItsBinding() { + var documents = List.of(Map.of("actions", List.of(Map.of("type", "rsvp")))); + assertEquals(documents, engine.execute( + "SELECT * FROM c WHERE EXISTS(SELECT VALUE actionEntry FROM actionEntry IN c.actions " + + "WHERE actionEntry[\"type\"] = 'rsvp')", List.of(), documents).items()); + } + + @Test + void orderByIndexPathsPreserveQuotedDots() { + assertEquals("/a.b", CosmosIndexingPolicy.normalizeOrderByPath("c[\"a.b\"]")); + assertEquals("/map/a.b/value", CosmosIndexingPolicy.normalizeOrderByPath("c.map['a.b'].value")); + } + + @Test + void erasureQueryFindsUsersPresentOnlyInTheRsvpVersionMap() { + String userId = "eec725e5-b24c-48c1-b336-e599da1e6711"; + var documents = List.of( + Map.of("id", "declined", "goingUserIds", List.of(), + "rsvpVersionsByUserId", Map.of(userId, 4)), + Map.of("id", "going", "goingUserIds", List.of(userId)), + Map.of("id", "unrelated", "rsvpVersionsByUserId", Map.of("other-user", 2))); + + assertEquals(List.of("declined", "going"), engine.execute( + "SELECT VALUE c.id FROM c WHERE ARRAY_CONTAINS(c.goingUserIds, @userId) " + + "OR IS_DEFINED(c.rsvpVersionsByUserId[\"" + userId + "\"])", + List.of(Map.of("name", "@userId", "value", userId)), documents).items()); + } + + @Test + void bracketMembersPreserveDotsSpacesAndEmptyKeys() { + var document = Map.of("map", Map.of("a.b", Map.of("", 7), "two spaces", 8)); + assertEquals(7, engine.resolve(document, "c[\"map\"][\"a.b\"][\"\"]")); + assertEquals(7, engine.resolve(document, "c.map['a.b']['']")); + assertEquals(List.of(8), engine.execute( + "SELECT VALUE c.map[\"two spaces\"] FROM c", List.of(), List.of(document)).items()); + } + + @Test + void mixedMemberAccessWorksInPredicatesAndProjections() { + var documents = List.of(Map.of("map", Map.of("user-id", Map.of("version", 4)))); + assertEquals(List.of(4), engine.execute( + "SELECT VALUE c[\"map\"]['user-id'].version FROM c " + + "WHERE c.map[\"user-id\"].version = 4", List.of(), documents).items()); + assertEquals(List.of(), engine.execute( + "SELECT * FROM c WHERE IS_DEFINED(c.map[\"missing\"])", List.of(), documents).items()); + } + + @Test + void bracketStringsDecodeEscapesWithoutSplittingTheMember() { + var document = Map.of("map", Map.of("a\"b", 1, "a\\b", 2, "Alice's", 3)); + assertEquals(1, engine.resolve(document, "c.map[\"a\\\"b\"]")); + assertEquals(2, engine.resolve(document, "c.map[\"a\\\\b\"]")); + assertEquals(3, engine.resolve(document, "c.map['Alice''s']")); + assertNull(engine.resolve(document, "c.map[\"missing\"].value")); + assertNull(engine.resolve(document, "c.map[\"a\"b\"]")); + } +}