Skip to content

Commit 6fb11bb

Browse files
Copilotwravery
andauthored
Add @cppType directive for custom scalar C++ types (fixes #346)
Co-authored-by: wravery <6502881+wravery@users.noreply.github.com>
1 parent db4bd32 commit 6fb11bb

20 files changed

Lines changed: 1036 additions & 5 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ There are some more targeted documents in the [doc](./doc) directory:
232232
* [Field Resolvers](./doc/resolvers.md)
233233
* [Field Parameters](./doc/fieldparams.md)
234234
* [Directives](./doc/directives.md)
235+
* [Custom Scalars](./doc/scalars.md)
235236
* [Subscriptions](./doc/subscriptions.md)
236237

237238
### Samples

‎doc/directives.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,11 @@ The `schema` can also define custom `directives` which are valid on different
1818
elements of the `query`. The library does not handle them automatically, but it
1919
will pass them to the `getField` implementations through the optional
2020
`graphql::service::FieldParams` struct (see [fieldparams.md](fieldparams.md)
21-
for more information).
21+
for more information).
22+
23+
There is also one code-generation directive which `schemagen` consumes at build
24+
time and does not serve through introspection:
25+
26+
* `@cppType(name: String! header: String)`: Map a custom `scalar` type onto a
27+
custom C++ type instead of the default `response::Value`. See
28+
[Custom Scalars](./scalars.md) for details.

‎doc/scalars.md‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Custom Scalars
2+
3+
GraphQL lets a schema declare [custom scalar](https://spec.graphql.org/October2021/#sec-Scalars.Custom-Scalars)
4+
types, e.g.:
5+
6+
```graphql
7+
scalar DateTime
8+
scalar BigInt
9+
```
10+
11+
The wire representation of a scalar is always a `graphql::response::Value` (the JSON-like
12+
discriminated union described in [Query Responses](./responses.md)). By default `schemagen`
13+
generates the resolvers and argument accessors for every custom scalar in terms of
14+
`response::Value`, so you receive and return the value as generic JSON-like data:
15+
16+
```cpp
17+
service::AwaitableScalar<response::Value> getWhen(service::FieldParams&& params) const override;
18+
```
19+
20+
That is convenient, but it forces your implementation to store and manipulate the value as a
21+
`response::Value` even when a dedicated C++ type would be much more natural or efficient (for
22+
example a `std::chrono::system_clock::time_point` for `DateTime`, or a 64-bit integer for a
23+
`BigInt` that does not fit in the 32-bit `response::IntType`).
24+
25+
## The `@cppType` directive
26+
27+
You can map a custom scalar onto any C++ type using the `@cppType` directive on the `scalar`
28+
declaration:
29+
30+
```graphql
31+
scalar BigInt @cppType(
32+
name: "bigint::BigInt"
33+
header: "BigIntScalar.h"
34+
)
35+
```
36+
37+
* `name` (required) is the fully-qualified C++ type that `schemagen` uses everywhere the scalar
38+
appears in the generated code, instead of `response::Value`. It may be qualified with any
39+
namespace (`bigint::BigInt`, `my::project::BigInt`, ...).
40+
* `header` (optional) is added as an `#include` at the top of the generated schema header so the
41+
custom type and its conversion specializations are visible to the generated code.
42+
43+
With the directive above, the generated resolver interface changes to use your type directly:
44+
45+
```cpp
46+
service::AwaitableScalar<bigint::BigInt> getEcho(
47+
service::FieldParams&& params, bigint::BigInt&& valueArg) const override;
48+
```
49+
50+
The scalar is still declared to clients exactly as `scalar BigInt` in introspection; `@cppType`
51+
is a code-generation hint only and never appears in the served schema.
52+
53+
## Providing the conversions
54+
55+
Because `schemagen` no longer knows how to translate the scalar to and from the `response::Value`
56+
wire representation, you provide that translation by specializing three members of the
57+
`graphql::service` templates. These are the type-erased boundary between your C++ type and the
58+
JSON-like data on the wire:
59+
60+
```cpp
61+
namespace graphql::service {
62+
63+
// Deserialize an argument or input value into your custom type.
64+
template <>
65+
bigint::BigInt Argument<bigint::BigInt>::convert(const response::Value& value);
66+
67+
// Serialize a resolver result of your custom type back into a response::Value.
68+
template <>
69+
AwaitableResolver Result<bigint::BigInt>::convert(
70+
AwaitableScalar<bigint::BigInt> result, ResolverParams&& params);
71+
72+
// Validate a cached response::Value (returned directly by a resolver) for this scalar.
73+
template <>
74+
void Result<bigint::BigInt>::validateScalar(const response::Value& value);
75+
76+
} // namespace graphql::service
77+
```
78+
79+
`Result<T>::convert` is usually implemented with the `ModifiedResult<T>::resolve` helper, passing a
80+
callback that turns a `T&&` into a `response::Value`:
81+
82+
```cpp
83+
template <>
84+
AwaitableResolver Result<bigint::BigInt>::convert(
85+
AwaitableScalar<bigint::BigInt> result, ResolverParams&& params)
86+
{
87+
return ModifiedResult<bigint::BigInt>::resolve(std::move(result),
88+
std::move(params),
89+
[](bigint::BigInt&& value, const ResolverParams&) {
90+
return response::Value { std::to_string(value.value) };
91+
});
92+
}
93+
```
94+
95+
Declare these specializations in the header named by `@cppType`'s `header` argument so they are
96+
visible to the generated code, and define them in a matching `.cpp` which is compiled and linked
97+
into the schema library.
98+
99+
## Nullable scalar arguments
100+
101+
Nullable arguments of built-in scalar types are passed as `std::optional<T>`. So that your custom
102+
scalar behaves the same way (instead of being treated like a generated `INPUT_OBJECT` type, which
103+
would be wrapped in `std::unique_ptr<T>`), specialize the `CustomScalarArgument` trait and inherit
104+
from `std::true_type`:
105+
106+
```cpp
107+
namespace graphql::service {
108+
109+
template <>
110+
struct CustomScalarArgument<bigint::BigInt> : std::true_type
111+
{
112+
};
113+
114+
} // namespace graphql::service
115+
```
116+
117+
## Complete example
118+
119+
See [`samples/scalar`](../samples/scalar) for a complete, buildable example of a `BigInt` custom
120+
scalar whose C++ storage is a `std::int64_t` (serialized on the wire as a JSON `String` so it keeps
121+
full precision), along with the [`test/ScalarTests.cpp`](../test/ScalarTests.cpp) unit tests which
122+
exercise serializing, deserializing, nullable arguments, and error handling through live queries.

‎include/SchemaLoader.h‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,24 @@ using TypeNameMap = std::unordered_map<std::string_view, size_t>;
4343
// Scalar types are opaque to the generator, it's up to the service implementation
4444
// to handle parsing, validating, and serializing them. We just need to track which
4545
// scalar type names have been declared so we recognize the references.
46+
//
47+
// By default a scalar is represented in the generated C++ code with response::Value, but
48+
// the schema author can override that with the @cppType directive to store, serialize, and
49+
// deserialize the scalar as any custom C++ type (e.g. a BigInt):
50+
//
51+
// scalar BigInt @cppType(name: "bigint::BigInt" header: "BigIntScalar.h")
52+
//
53+
// The `name` argument is the fully-qualified C++ type used everywhere the scalar appears in
54+
// the generated resolvers and argument accessors. The optional `header` argument is added as
55+
// an #include in the generated schema header so that the custom type (and its
56+
// service::Argument/service::Result specializations) are visible to the generated code.
4657
struct [[nodiscard("unnecessary construction")]] ScalarType
4758
{
4859
std::string_view type;
4960
std::string_view description;
5061
std::string_view specifiedByURL {};
62+
std::string_view cppType {};
63+
std::string_view cppHeader {};
5164
};
5265

5366
using ScalarTypeList = std::vector<ScalarType>;

‎include/graphqlservice/GraphQLService.h‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,17 @@ struct Argument
629629
[[nodiscard("unnecessary conversion")]] static Type convert(const response::Value& value);
630630
};
631631

632+
// Custom scalar C++ types (e.g. those declared with the @cppType schema directive) are usually
633+
// represented with a class or struct rather than one of the built-in types. Specialize this trait
634+
// and inherit from std::true_type so that the custom type is treated as a scalar argument instead
635+
// of a generated INPUT_OBJECT type. That controls whether a nullable argument is wrapped in a
636+
// std::optional (scalar) or a std::unique_ptr (input object), matching the type which schemagen
637+
// declares for the resolver accessor.
638+
template <typename Type>
639+
struct CustomScalarArgument : std::false_type
640+
{
641+
};
642+
632643
#ifdef GRAPHQL_DLLEXPORTS
633644
// Export all of the built-in converters
634645
template <>
@@ -652,7 +663,8 @@ namespace {
652663
// These types are used as scalar arguments even though they are represented with a class.
653664
template <typename Type>
654665
concept ScalarArgumentClass = std::is_same_v<Type, std::string>
655-
|| std::is_same_v<Type, response::IdType> || std::is_same_v<Type, response::Value>;
666+
|| std::is_same_v<Type, response::IdType> || std::is_same_v<Type, response::Value>
667+
|| CustomScalarArgument<Type>::value;
656668

657669
// Any non-scalar class used in an argument is a generated INPUT_OBJECT type.
658670
template <typename Type>

‎samples/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ cmake_minimum_required(VERSION 3.15)
55

66
add_subdirectory(client)
77
add_subdirectory(learn)
8+
add_subdirectory(scalar)
89
add_subdirectory(today)
910
add_subdirectory(validation)
1011

‎samples/scalar/BigIntScalar.cpp‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
#include "BigIntScalar.h"
5+
6+
#include <charconv>
7+
#include <string>
8+
#include <string_view>
9+
10+
using namespace std::literals;
11+
12+
namespace {
13+
14+
using namespace graphql;
15+
16+
// Parse a std::int64_t out of the JSON-like response::Value wire representation. BigInt values are
17+
// serialized as Strings so they survive without the precision loss of a 32-bit response::IntType,
18+
// but we also accept a plain Int for convenience when the value is small enough.
19+
[[nodiscard]] std::int64_t parseBigInt(const response::Value& value)
20+
{
21+
switch (value.type())
22+
{
23+
case response::Type::Int:
24+
return static_cast<std::int64_t>(value.get<response::IntType>());
25+
26+
case response::Type::String:
27+
{
28+
const auto& str = value.get<response::StringType>();
29+
std::int64_t result = 0;
30+
const auto* first = str.data();
31+
const auto* last = first + str.size();
32+
const auto [ptr, ec] = std::from_chars(first, last, result);
33+
34+
if (ec != std::errc {} || ptr != last)
35+
{
36+
throw service::schema_exception { { "not a valid BigInt value"s } };
37+
}
38+
39+
return result;
40+
}
41+
42+
default:
43+
throw service::schema_exception { { "not a valid BigInt value"s } };
44+
}
45+
}
46+
47+
} // namespace
48+
49+
namespace graphql::service {
50+
51+
template <>
52+
bigint::BigInt Argument<bigint::BigInt>::convert(const response::Value& value)
53+
{
54+
return bigint::BigInt { parseBigInt(value) };
55+
}
56+
57+
template <>
58+
AwaitableResolver Result<bigint::BigInt>::convert(
59+
AwaitableScalar<bigint::BigInt> result, ResolverParams&& params)
60+
{
61+
return ModifiedResult<bigint::BigInt>::resolve(std::move(result),
62+
std::move(params),
63+
[](bigint::BigInt&& value, const ResolverParams&) {
64+
return response::Value { std::to_string(value.value) };
65+
});
66+
}
67+
68+
template <>
69+
void Result<bigint::BigInt>::validateScalar(const response::Value& value)
70+
{
71+
// Reuse the argument parser to confirm the cached wire representation is well formed.
72+
static_cast<void>(parseBigInt(value));
73+
}
74+
75+
} // namespace graphql::service

‎samples/scalar/BigIntScalar.h‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
#pragma once
5+
6+
#ifndef BIGINTSCALAR_H
7+
#define BIGINTSCALAR_H
8+
9+
#include "graphqlservice/GraphQLService.h"
10+
11+
#include <cstdint>
12+
13+
// This is the custom C++ type backing the GraphQL `BigInt` scalar. It is associated with the
14+
// scalar in schema.scalar.graphql using the `@cppType` directive:
15+
//
16+
// scalar BigInt @cppType(name: "bigint::BigInt" header: "BigIntScalar.h")
17+
//
18+
// A BigInt can hold values which do not fit in the 32-bit response::IntType, so instead of forcing
19+
// the field accessors to work with response::Value, schemagen generates the resolvers and argument
20+
// accessors in terms of bigint::BigInt. The service::Argument and service::Result specializations
21+
// below are the type-erased bridge that (de)serializes the value to and from the JSON-like
22+
// response::Value wire representation (here, a String so the full 64-bit precision survives).
23+
namespace bigint {
24+
25+
struct [[nodiscard("unnecessary construction")]] BigInt
26+
{
27+
BigInt() noexcept = default;
28+
29+
explicit BigInt(std::int64_t value) noexcept
30+
: value { value }
31+
{
32+
}
33+
34+
[[nodiscard]] bool operator==(const BigInt& rhs) const noexcept
35+
{
36+
return value == rhs.value;
37+
}
38+
39+
std::int64_t value = 0;
40+
};
41+
42+
} // namespace bigint
43+
44+
namespace graphql::service {
45+
46+
// Parse a BigInt argument from the request (a JSON String, or an Int for small values).
47+
template <>
48+
[[nodiscard("unnecessary conversion")]] bigint::BigInt Argument<bigint::BigInt>::convert(
49+
const response::Value& value);
50+
51+
// Treat bigint::BigInt as a scalar argument (wrapped in std::optional when nullable) rather than a
52+
// generated INPUT_OBJECT type (which would be wrapped in std::unique_ptr).
53+
template <>
54+
struct CustomScalarArgument<bigint::BigInt> : std::true_type
55+
{
56+
};
57+
58+
// Serialize a BigInt result back into a response::Value (a JSON String).
59+
template <>
60+
[[nodiscard("unnecessary conversion")]] AwaitableResolver Result<bigint::BigInt>::convert(
61+
AwaitableScalar<bigint::BigInt> result, ResolverParams&& params);
62+
63+
// Validate that a cached response::Value for this scalar is the expected wire representation.
64+
template <>
65+
void Result<bigint::BigInt>::validateScalar(const response::Value& value);
66+
67+
} // namespace graphql::service
68+
69+
#endif // BIGINTSCALAR_H

‎samples/scalar/CMakeLists.txt‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
cmake_minimum_required(VERSION 3.15)
5+
6+
# scalargraphql demonstrates a custom scalar (BigInt) whose C++ storage is a dedicated type
7+
# (bigint::BigInt) instead of response::Value. See schema.scalar.graphql and BigIntScalar.h. The
8+
# custom scalar (de)serialization in BigIntScalar.cpp is compiled into the scalar_schema library
9+
# (see schema/CMakeLists.txt), so this is just a friendly alias target for the sample.
10+
add_subdirectory(schema)
11+
12+
add_library(scalargraphql INTERFACE)
13+
target_link_libraries(scalargraphql INTERFACE scalar_schema)
14+
target_include_directories(scalargraphql INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
# This sample demonstrates a custom scalar whose C++ storage is NOT response::Value.
5+
#
6+
# The @cppType directive tells schemagen to use the bigint::BigInt C++ type (declared in
7+
# BigIntScalar.h) for this scalar everywhere it appears in the generated resolvers and argument
8+
# accessors, instead of the default response::Value. See samples/scalar/BigIntScalar.h for the
9+
# service::Argument/service::Result specializations which serialize and deserialize the value.
10+
#
11+
# BigInt values are too large to fit in the 32-bit response::IntType, so on the wire they are
12+
# represented as a JSON String, while the C++ side stores a std::int64_t.
13+
scalar BigInt @cppType(name: "bigint::BigInt" header: "BigIntScalar.h")
14+
15+
type Query {
16+
"Echo a BigInt argument straight back out, round-tripping through bigint::BigInt."
17+
echo(value: BigInt!): BigInt!
18+
19+
"Add two BigInt values together on the C++ side and return the result."
20+
add(left: BigInt! right: BigInt!): BigInt!
21+
22+
"Return an optional BigInt, or null when no value is provided."
23+
maybe(value: BigInt): BigInt
24+
}

0 commit comments

Comments
 (0)