Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string>(),
rsvpVersionsByUserId = new Dictionary<string, int> { [userId] = 4 }
}, new PartitionKey("event-a"), cancellationToken: cancellationToken);
await container.CreateItemAsync(new
{
id = "unrelated", pk = "event-b", goingUserIds = Array.Empty<string>(),
rsvpVersionsByUserId = new Dictionary<string, int> { ["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<JObject> iterator = container.GetItemQueryIterator<JObject>(query,
requestOptions: new QueryRequestOptions { MaxItemCount = 1 });
var ids = new List<string>();
while (iterator.HasMoreResults)
{
FeedResponse<JObject> page = await iterator.ReadNextAsync(cancellationToken);
ids.AddRange(page.Select(item => item.Value<string>("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<JObject> quoted = container.GetItemQueryIterator<JObject>(new QueryDefinition(
"SELECT c[\"a\\\"b\"], c[\"a.b\"] FROM c WHERE c.id = @id")
.WithParameter("@id", "quoted"));
FeedResponse<JObject> quotedPage = await quoted.ReadNextAsync(cancellationToken);
await Assert.That(quotedPage.Single().Value<int>("a\"b")).IsEqualTo(7);
await Assert.That(quotedPage.Single().Value<int>("a.b")).IsEqualTo(8);
}
finally
{
await database.DeleteAsync(cancellationToken: cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,7 @@ private static boolean matches(List<?> compositePaths, List<String> 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. */
Expand Down
110 changes: 75 additions & 35 deletions src/main/java/io/floci/az/services/cosmos/CosmosQueryEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand Down Expand Up @@ -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);
Comment thread
thomhurst marked this conversation as resolved.
}
return path;
}

Object resolve(Map<String, Object> 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<String> 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<String> propertyNames(String path) {
String members = stripAlias(path);
Matcher member = PROPERTY_MEMBER.matcher(members);
List<String> 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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} 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<String, Object> {
Expand Down Expand Up @@ -564,6 +586,11 @@ private Map<String, Object> projectDoc(Map<String, Object> doc, List<String> fie
alias = field.substring(asIdx + 2).trim();
} else {
expr = field;
List<String> 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;
Expand Down Expand Up @@ -666,7 +693,7 @@ private String substituteParams(String sql, List<Map<String, Object>> 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())));
Expand All @@ -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);
}
Expand All @@ -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()));
}
Expand All @@ -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; }
Expand All @@ -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; }
Expand Down Expand Up @@ -800,7 +840,7 @@ List<String> 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++;
Expand Down Expand Up @@ -875,7 +915,7 @@ Object resolveExpr(Map<String, Object> 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; }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.<String, Object>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.<String, Object>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.<String, Object>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.<String, Object>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.<String, Object>of("id", "declined", "goingUserIds", List.of(),
"rsvpVersionsByUserId", Map.of(userId, 4)),
Map.<String, Object>of("id", "going", "goingUserIds", List.of(userId)),
Map.<String, Object>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.<String, Object>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.<String, Object>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.<String, Object>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\"]"));
}
}