Skip to content
Merged
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
66 changes: 66 additions & 0 deletions client/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,63 @@ type GQLResult struct {
//
// It will be nil if any errors were raised during execution.
Data any `json:"data"`

// Extensions holds extra information about the request, such as warnings.
//
// It is nil when there is nothing to report, and is then left out of the response.
Extensions *GQLExtensions `json:"extensions,omitempty"`
}

// GQLExtensions sits next to data and errors in a response. It holds anything we want
// to tell the caller that is neither a result nor an error.
//
// Callers skip anything in here they do not recognise.
type GQLExtensions struct {
// Warnings holds things the caller should know about a request that worked.
Warnings []GQLWarning `json:"warnings,omitempty"`
}

// IsEmpty returns true if there is nothing to send. An empty value is left out of the
// response rather than sent as `{}`.
//
// It turns the value into JSON and looks at the result, so a field added later is
// covered without changing this. A value that cannot be turned into JSON counts as
// empty, so a bad warning is dropped instead of breaking the whole response.
func (e *GQLExtensions) IsEmpty() bool {
if e == nil {
return true
}

data, err := json.Marshal(e)
return err != nil || string(data) == "{}"
}

// GQLWarning describes something that happened during a request that still worked.
type GQLWarning struct {
// Code names the warning. Callers check this. It does not change once released.
Code string `json:"code"`

// Message explains the warning to a person. The wording can change, so do not
// read it in code.
Message string `json:"message"`

// Detail holds values that belong to this warning. Optional.
//
// It is sent to the caller and may be logged, so keep secrets and keys out of it.
// Be careful with counts too: saying how many documents were looked at can tell
// the caller about documents they are not allowed to see.
Detail map[string]any `json:"detail,omitempty"`
}

// Warning codes. Callers match on these, so they do not change once released.
const (
// WarningCodeVectorIndexUnused means a similarity query read the whole collection even though
// the field it scored has a vector index. The results are correct, but the query costs more as
// the collection grows. The `reason` detail says which part of the query shape ruled the index
// out.
WarningCodeVectorIndexUnused = "VECTOR_INDEX_UNUSED"
)

// gqlError represents an error that was encountered during a GQL request.
//
// This is only used for marshalling to keep our responses spec compliant.
Expand All @@ -439,6 +494,8 @@ type gqlResult struct {
Errors []gqlError `json:"errors,omitempty"`
// Data contains the result data
Data any `json:"data"`
// Extensions contains the result extensions
Extensions *GQLExtensions `json:"extensions,omitempty"`
}

func (res *GQLResult) UnmarshalJSON(data []byte) error {
Expand All @@ -449,6 +506,12 @@ func (res *GQLResult) UnmarshalJSON(data []byte) error {
return err
}
res.Data = out.Data
res.Extensions = out.Extensions
// A peer may send `"extensions":{}`, which decodes to a non nil empty value. Callers
// are told the field is nil when there is nothing to report, so make that true.
if res.Extensions.IsEmpty() {
res.Extensions = nil
}
res.Errors = make([]error, len(out.Errors))
for i, e := range out.Errors {
res.Errors[i] = ReviveError(e.Message)
Expand All @@ -458,6 +521,9 @@ func (res *GQLResult) UnmarshalJSON(data []byte) error {

func (res GQLResult) MarshalJSON() ([]byte, error) {
out := gqlResult{Data: res.Data}
if !res.Extensions.IsEmpty() {
out.Extensions = res.Extensions
}
out.Errors = make([]gqlError, len(res.Errors))
for i, e := range res.Errors {
out.Errors[i] = gqlError{Message: e.Error()}
Expand Down
180 changes: 180 additions & 0 deletions client/db_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright 2026 Democratized Data Foundation
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

package client

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"
)

// GQLResult does not use the default struct marshalling. It copies itself field by
// field through a private mirror, so a field can exist on the type and still be missing
// from the JSON. These tests catch that.
//
// It matters because the Go client never serializes anything. A half applied change
// passes there and fails on every other client.

func TestGQLResultMarshal_WithWarning_RoundTrips(t *testing.T) {
input := GQLResult{
Data: map[string]any{"Users": []any{}},
Extensions: &GQLExtensions{
Warnings: []GQLWarning{
{
Code: "test_warning",
Message: "something worth knowing happened",
Detail: map[string]any{"requested": 10, "returned": 6},
},
},
},
}

data, err := json.Marshal(input)
require.NoError(t, err)

var output GQLResult
err = json.Unmarshal(data, &output)
require.NoError(t, err)

require.NotNil(t, output.Extensions)
require.Len(t, output.Extensions.Warnings, 1)

warning := output.Extensions.Warnings[0]
require.Equal(t, "test_warning", warning.Code)
require.Equal(t, "something worth knowing happened", warning.Message)

// UnmarshalJSON calls dec.UseNumber, so numbers come back as json.Number, not
// float64. Same as everything under `data`.
require.Equal(t, json.Number("10"), warning.Detail["requested"])
require.Equal(t, json.Number("6"), warning.Detail["returned"])
}

func TestGQLResultMarshal_WithMultipleWarnings_PreservesOrder(t *testing.T) {
input := GQLResult{
Extensions: &GQLExtensions{
Warnings: []GQLWarning{
{Code: "first", Message: "one"},
{Code: "second", Message: "two"},
},
},
}

data, err := json.Marshal(input)
require.NoError(t, err)

var output GQLResult
err = json.Unmarshal(data, &output)
require.NoError(t, err)

require.Len(t, output.Extensions.Warnings, 2)
require.Equal(t, "first", output.Extensions.Warnings[0].Code)
require.Equal(t, "second", output.Extensions.Warnings[1].Code)
}

func TestGQLResultMarshal_WithoutExtensions_OmitsField(t *testing.T) {
input := GQLResult{Data: map[string]any{"Users": []any{}}}

data, err := json.Marshal(input)
require.NoError(t, err)

var raw map[string]json.RawMessage
err = json.Unmarshal(data, &raw)
require.NoError(t, err)

require.NotContains(t, raw, "extensions")
}

func TestGQLResultMarshal_WithEmptyExtensions_OmitsField(t *testing.T) {
// An empty value must not be sent as `"extensions":{}`. Otherwise every response
// changes shape as soon as anything allocates an accumulator.
input := GQLResult{
Data: map[string]any{"Users": []any{}},
Extensions: &GQLExtensions{},
}

data, err := json.Marshal(input)
require.NoError(t, err)

var raw map[string]json.RawMessage
err = json.Unmarshal(data, &raw)
require.NoError(t, err)

require.NotContains(t, raw, "extensions")
}

func TestGQLResultUnmarshal_WithUnknownExtensionKey_IsIgnored(t *testing.T) {
// An older client must ignore an entry it does not know about instead of failing
// the whole response.
data := []byte(`{
"data": null,
"extensions": {
"warnings": [{"code": "known", "message": "hi", "unknownField": 1}],
"unknownKey": {"anything": true}
}
}`)

var output GQLResult
err := json.Unmarshal(data, &output)
require.NoError(t, err)

require.NotNil(t, output.Extensions)
require.Len(t, output.Extensions.Warnings, 1)
require.Equal(t, "known", output.Extensions.Warnings[0].Code)
}

func TestGQLResultUnmarshal_WithoutExtensions_LeavesNil(t *testing.T) {
var output GQLResult
err := json.Unmarshal([]byte(`{"data": null}`), &output)
require.NoError(t, err)

require.Nil(t, output.Extensions)
require.True(t, output.Extensions.IsEmpty())
}

// The empty non nil slice is the case a field by field check gets wrong: the slice is
// not the zero value, but it still encodes to nothing.
func TestGQLExtensionsIsEmpty_WithEmptyWarningSlice_IsEmpty(t *testing.T) {
extensions := &GQLExtensions{Warnings: []GQLWarning{}}

require.True(t, extensions.IsEmpty())
}

func TestGQLExtensionsIsEmpty_WithNilReceiver_IsEmpty(t *testing.T) {
var extensions *GQLExtensions

require.True(t, extensions.IsEmpty())
}

func TestGQLExtensionsIsEmpty_WithAWarning_IsNotEmpty(t *testing.T) {
extensions := &GQLExtensions{Warnings: []GQLWarning{{Code: "test_warning"}}}

require.False(t, extensions.IsEmpty())
}

// A peer may send an empty extensions object. Callers are told the field is nil when
// there is nothing to report, so decoding must make that true rather than hand back a
// non nil value with nothing in it.
func TestGQLResultUnmarshal_WithEmptyExtensions_LeavesNil(t *testing.T) {
var output GQLResult
err := json.Unmarshal([]byte(`{"data": null, "extensions": {}}`), &output)
require.NoError(t, err)

require.Nil(t, output.Extensions)
}

func TestGQLResultUnmarshal_WithOnlyUnknownExtensionKeys_LeavesNil(t *testing.T) {
var output GQLResult
err := json.Unmarshal([]byte(`{"data": null, "extensions": {"unknownKey": 1}}`), &output)
require.NoError(t, err)

require.Nil(t, output.Extensions)
}
92 changes: 92 additions & 0 deletions docs/website/references/http/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2046,6 +2046,29 @@
"type": "object"
},
"type": "array"
},
"extensions": {
"properties": {
"warnings": {
"items": {
"properties": {
"code": {
"type": "string"
},
"detail": {
"additionalProperties": true,
"type": "object"
},
"message": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
}
},
"type": "object"
Expand Down Expand Up @@ -2073,6 +2096,29 @@
"type": "object"
},
"type": "array"
},
"extensions": {
"properties": {
"warnings": {
"items": {
"properties": {
"code": {
"type": "string"
},
"detail": {
"additionalProperties": true,
"type": "object"
},
"message": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
}
},
"type": "object"
Expand Down Expand Up @@ -2118,6 +2164,29 @@
"type": "object"
},
"type": "array"
},
"extensions": {
"properties": {
"warnings": {
"items": {
"properties": {
"code": {
"type": "string"
},
"detail": {
"additionalProperties": true,
"type": "object"
},
"message": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
}
},
"type": "object"
Expand Down Expand Up @@ -2145,6 +2214,29 @@
"type": "object"
},
"type": "array"
},
"extensions": {
"properties": {
"warnings": {
"items": {
"properties": {
"code": {
"type": "string"
},
"detail": {
"additionalProperties": true,
"type": "object"
},
"message": {
"type": "string"
}
},
"type": "object"
},
"type": "array"
}
},
"type": "object"
}
},
"type": "object"
Expand Down
Loading
Loading