Description
When a SELECT projects a column whose value is null for a given row, the two transports disagree on the result shape:
- HTTP
/api/v1/command/<db> returns the column with an explicit null: {"r": null}.
- gRPC
ExecuteQuery omits the column from GrpcRecord.properties entirely - the key is simply absent.
A client cannot tell, from the gRPC response alone, the difference between "the query did not project this column" and "the query projected it but the value was null." The column name is lost. Any client that addresses result columns by key sees a missing-key error on gRPC for a query that succeeds on HTTP.
sqrt(<negative>) is a convenient trigger (ArcadeDB returns null rather than raising), but the bug is general to any null-valued projection: ifnull fallbacks, coalesce over all-null inputs, a projected property that is unset on the record, etc.
Environment
- ArcadeDB Server
26.7.1-SNAPSHOT (build a721d414aa646a264b51ae9405cb70708046d801/1781933565482)
- gRPC
ExecuteQuery vs HTTP /api/v1/command/<db>, language sql
- Default database options
Root cause
ArcadeDbGrpcService.convertResultToGrpcRecord skips any property whose value is null when building the record's properties map:
grpcw/src/main/java/com/arcadedb/server/grpc/ArcadeDbGrpcService.java:3025-3043
// Iterate over ALL properties from the Result, including aliases
for (String propertyName : result.getPropertyNames()) {
Object value = result.getProperty(propertyName);
if (value != null) { // <-- null-valued columns never reach putProperties
...
GrpcValue gv = projectionConfig != null ?
toGrpcValue(value, projectionConfig) :
toGrpcValue(value);
...
builder.putProperties(propertyName, gv);
}
}
result.getPropertyNames() does include the projected column (the alias is present), but the if (value != null) guard drops it, so the key never lands in the proto map. This is the method on the ExecuteQuery path (ArcadeDbGrpcService.java:937 calls it).
This is not recoverable on the client side: the proto GrpcValue oneof already has an unset/None representation, so a null could be carried faithfully - but the server never emits an entry to carry it. The absence of the key, not a malformed value, is the bug.
By contrast, the HTTP path serializes through JSONObject, which writes "<alias>": null for a null value, preserving the column name.
Steps to reproduce
HTTP (shows the correct, key-preserving behavior):
curl -sS -u root:playwithdata http://localhost:2480/api/v1/server \
-H 'Content-Type: application/json' \
-d '{"command":"create database bugscratch"}'
# null-valued projection: sqrt of a negative yields null
curl -sS -u root:playwithdata http://localhost:2480/api/v1/command/bugscratch \
-H 'Content-Type: application/json' \
-d '{"language":"sql","command":"SELECT sqrt(-4) AS r"}'
# -> {"user":"root","result":[{"r":null}]} <-- key "r" present, value null
# control: a non-null value
curl -sS -u root:playwithdata http://localhost:2480/api/v1/command/bugscratch \
-H 'Content-Type: application/json' \
-d '{"language":"sql","command":"SELECT sqrt(16) AS r"}'
# -> {"user":"root","result":[{"r":4, ...}]}
gRPC (shows the bug) - add to grpcw/src/test/java/com/arcadedb/server/grpc/GrpcServerIT.java, mirroring executeQuerySelectsExistingData:
@Test
void executeQueryKeepsNullValuedProjectedColumn() {
ExecuteQueryRequest request = ExecuteQueryRequest.newBuilder()
.setDatabase(getDatabaseName())
.setCredentials(credentials())
.setQuery("SELECT sqrt(-4) AS r")
.build();
ExecuteQueryResponse response = authenticatedStub.executeQuery(request);
GrpcRecord record = response.getResultsList().get(0).getRecordsList().get(0);
// FAILS today: the key "r" is absent because the null value was skipped.
assertThat(record.getPropertiesMap()).containsKey("r");
}
Observed
- HTTP:
{"r": null} - the projected alias r is present with a null value.
- gRPC:
GrpcRecord.properties has no r entry; getPropertiesMap().containsKey("r") is false.
So the same query yields a column on HTTP and no column on gRPC.
Expected
The two transports should agree on the projected column set. A column that is projected but null should appear in the gRPC GrpcRecord.properties map with a null/unset GrpcValue, matching the HTTP serializer's "r": null. Clients should be able to address every projected alias by key regardless of transport.
Suggested fix
Pin the projected column set to getPropertyNames() and carry nulls explicitly, rather than dropping them. Two high-level options:
-
Emit an explicit null GrpcValue for null-valued columns (preferred - matches HTTP). Drop the if (value != null) guard in convertResultToGrpcRecord and let toGrpcValue(null) produce the unset/null-kind GrpcValue (the converter at GrpcTypeConverter.toGrpcValue already has an o == null branch). Every projected alias then appears in the map, and the Python/Java clients surface it as None/null.
for (String propertyName : result.getPropertyNames()) {
final Object value = result.getProperty(propertyName);
final GrpcValue gv = projectionConfig != null ? toGrpcValue(value, projectionConfig) : toGrpcValue(value);
builder.putProperties(propertyName, gv);
}
Verify toGrpcValue(null) yields a value whose WhichOneof("kind") is unset (or a dedicated null kind) so the wire stays compact.
-
Add an explicit null-kind to the GrpcValue oneof if a distinct "present-but-null" marker is wanted on the wire (heavier; only if option 1's unset-kind round-trip proves ambiguous against genuinely-absent keys elsewhere).
Option 1 is the right fix: it makes gRPC match the long-standing HTTP contract with a one-line guard removal, and the client-side null mapping already exists. The same if (value != null) pattern appears in the streaming/batch record builders (convertPropToGrpcValue call sites around ArcadeDbGrpcService.java:1328/1382/1448); audit those for the same drop so all ExecuteQuery-family paths agree.
Description
When a
SELECTprojects a column whose value isnullfor a given row, the two transports disagree on the result shape:/api/v1/command/<db>returns the column with an explicitnull:{"r": null}.ExecuteQueryomits the column fromGrpcRecord.propertiesentirely - the key is simply absent.A client cannot tell, from the gRPC response alone, the difference between "the query did not project this column" and "the query projected it but the value was null." The column name is lost. Any client that addresses result columns by key sees a missing-key error on gRPC for a query that succeeds on HTTP.
sqrt(<negative>)is a convenient trigger (ArcadeDB returnsnullrather than raising), but the bug is general to any null-valued projection:ifnullfallbacks,coalesceover all-null inputs, a projected property that is unset on the record, etc.Environment
26.7.1-SNAPSHOT(builda721d414aa646a264b51ae9405cb70708046d801/1781933565482)ExecuteQueryvs HTTP/api/v1/command/<db>, languagesqlRoot cause
ArcadeDbGrpcService.convertResultToGrpcRecordskips any property whose value isnullwhen building the record'spropertiesmap:grpcw/src/main/java/com/arcadedb/server/grpc/ArcadeDbGrpcService.java:3025-3043result.getPropertyNames()does include the projected column (the alias is present), but theif (value != null)guard drops it, so the key never lands in the proto map. This is the method on theExecuteQuerypath (ArcadeDbGrpcService.java:937calls it).This is not recoverable on the client side: the proto
GrpcValueoneof already has an unset/None representation, so a null could be carried faithfully - but the server never emits an entry to carry it. The absence of the key, not a malformed value, is the bug.By contrast, the HTTP path serializes through
JSONObject, which writes"<alias>": nullfor a null value, preserving the column name.Steps to reproduce
HTTP (shows the correct, key-preserving behavior):
gRPC (shows the bug) - add to
grpcw/src/test/java/com/arcadedb/server/grpc/GrpcServerIT.java, mirroringexecuteQuerySelectsExistingData:Observed
{"r": null}- the projected aliasris present with a null value.GrpcRecord.propertieshas norentry;getPropertiesMap().containsKey("r")isfalse.So the same query yields a column on HTTP and no column on gRPC.
Expected
The two transports should agree on the projected column set. A column that is projected but null should appear in the gRPC
GrpcRecord.propertiesmap with a null/unsetGrpcValue, matching the HTTP serializer's"r": null. Clients should be able to address every projected alias by key regardless of transport.Suggested fix
Pin the projected column set to
getPropertyNames()and carry nulls explicitly, rather than dropping them. Two high-level options:Emit an explicit null
GrpcValuefor null-valued columns (preferred - matches HTTP). Drop theif (value != null)guard inconvertResultToGrpcRecordand lettoGrpcValue(null)produce the unset/null-kindGrpcValue(the converter atGrpcTypeConverter.toGrpcValuealready has ano == nullbranch). Every projected alias then appears in the map, and the Python/Java clients surface it asNone/null.Verify
toGrpcValue(null)yields a value whoseWhichOneof("kind")is unset (or a dedicated null kind) so the wire stays compact.Add an explicit null-kind to the
GrpcValueoneof if a distinct "present-but-null" marker is wanted on the wire (heavier; only if option 1's unset-kind round-trip proves ambiguous against genuinely-absent keys elsewhere).Option 1 is the right fix: it makes gRPC match the long-standing HTTP contract with a one-line guard removal, and the client-side null mapping already exists. The same
if (value != null)pattern appears in the streaming/batch record builders (convertPropToGrpcValuecall sites aroundArcadeDbGrpcService.java:1328/1382/1448); audit those for the same drop so allExecuteQuery-family paths agree.