conduit_graphql 7.0.0
conduit_graphql: ^7.0.0 copied to clipboard
GraphQL HTTP transport for conduit. Mounts a GraphQLController over a hand-written GraphQLSchema; schema derivation, resolver framework, and cross-source dispatch are deferred to later phases.
conduit_graphql #
GraphQL HTTP transport for Conduit. Mounts a
GraphQLController over a GraphQLSchema — either hand-written or
derived from your ManagedDataModel — and implements the
GraphQL-over-HTTP spec
for POST (queries + mutations) and GET (queries only).
Status — G1–G5 evaluation plan complete #
All five phases of the GraphQL evaluation plan ship in this package:
| Phase | Scope | Ships in this package? |
|---|---|---|
| G1 | Controller, parse + validate + execute, JSON envelope, GET-of-mutation rejection, introspection | Yes |
| G2 | Derive a GraphQLSchema from ManagedDataModel (read-only) |
Yes |
| G3 | SQL resolvers + dataloader against Query<T> |
Yes |
| G4 | Graph schema derivation from GraphDataModel; resolvers against GraphQuery<N> (Neo4j) |
Yes |
| G5 | Cross-source dispatch (SchemaBuilder.fromPersistence) + field-level auth (@FieldAuthorize) |
Yes |
GraphQL subscriptions are out of scope for the entire plan; Conduit
has no WebSocket transport in core. See docs/persistence/graphql-cross-source.md
for the v0.6+ deferred-work list.
Install #
Add to your pubspec.yaml:
dependencies:
conduit_core: ^6.0.0
conduit_graphql: ^6.0.0
conduit_graphql re-exports the surfaces of graphql_schema2,
graphql_parser2, and graphql_server2 — you do not need to add those
packages to your own pubspec.yaml to assemble a hand-written schema.
Wire-up example #
import 'package:conduit_core/conduit_core.dart';
import 'package:conduit_graphql/conduit_graphql.dart';
final helloSchema = graphQLSchema(
queryType: objectType('Query', fields: [
field('hello', graphQLString, resolve: (_, _) => 'world'),
field(
'greet',
graphQLString.nonNullable(),
inputs: [GraphQLFieldInput('name', graphQLString.nonNullable())],
resolve: (_, args) => 'Hello, ${args['name']}!',
),
]),
);
class MyChannel extends ApplicationChannel {
@override
Controller get entryPoint {
final router = Router();
router.route('/graphql').link(() => GraphQLController(helloSchema));
return router;
}
}
Then:
$ curl -sX POST http://localhost:8888/graphql \
-H 'content-type: application/json' \
-d '{"query":"{ hello, greet(name: \"alice\") }"}'
{"data":{"hello":"world","greet":"Hello, alice!"}}
Wire format #
Request — POST /graphql with application/json #
{
"query": "<GraphQL document>",
"operationName": "<optional>",
"variables": { "<name>": <value>, ... },
"extensions": { ... }
}
extensions is accepted but ignored in G1.
Request — POST /graphql with application/graphql #
The body is the raw query document. No JSON envelope.
Request — GET /graphql #
GET /graphql?query=<doc>&operationName=<name>&variables=<JSON-encoded>
Per the spec, GET is restricted to query operations. Sending a
mutation over GET returns 405 Method Not Allowed.
Response #
Always JSON. Two media types are supported:
application/json— the legacy default; what you get unless you opt in.application/graphql-response+json— the modern spec-aligned media type. SendAccept: application/graphql-response+jsonto receive it. This package registers the codec with Conduit'sCodecRegistryon firstGraphQLControllerconstruction; you do not need to register it yourself.
{
"data": ...,
"errors": [
{
"message": "...",
"locations": [{ "line": 1, "column": 9 }],
"path": [...],
"extensions": { ... }
}
]
}
HTTP status codes #
200— request was processed. Field-resolver runtime errors land here, withdata: nulland a populatederrors[](per spec §7.1.2).400— malformed body, parse error, validation error, missingquery, malformed?variables=, or variable-coercion failure.405—GETof amutationorsubscription.
Field-existence validation is performed locally before execution to
work around an upstream gap in graphql_server2 v6.5.0 — see "Known
limitations" below.
Schema derivation (G2) #
SchemaBuilder.fromManagedDataModel(model) walks every ManagedEntity
in your data model and emits a read-only GraphQL schema:
import 'package:conduit_core/conduit_core.dart' hide SchemaBuilder;
import 'package:conduit_graphql/conduit_graphql.dart';
final dataModel = ManagedDataModel([User, Post, Comment]);
final schema = SchemaBuilder().fromManagedDataModel(dataModel);
// Mount as you would any hand-written schema.
router.route('/graphql').link(() => GraphQLController(schema));
Note the hide SchemaBuilder on conduit_core's import: Conduit has
two unrelated SchemaBuilder classes — the migration helper from
conduit_core and the GraphQL builder from this package. Hide whichever
one you don't need at the call site.
What the walker emits #
- One
GraphQLObjectTypeperManagedEntity. - Scalar columns lower per the table below.
- Relationships surface in both directions:
User.posts: [Post!]!andPost.author: User!are both present. - A
Queryroot with two fields per entity:<plural>: [<Entity>!]!(list-all),<singular>(<pk>: <pkType>!): <Entity>(find-by-pk).
- All field resolvers are
null— execution lands in G3. Schema introspection, validation, and SDL printing all work today.
Scalar mapping #
Conduit ManagedPropertyType |
GraphQL |
|---|---|
integer |
Int |
bigInteger |
String (default; Int if bigIntegerAsString: false) |
string |
String |
datetime |
DateTime (custom scalar) |
boolean |
Boolean |
doublePrecision |
Float |
document |
String (JSON-encoded) |
list |
[T!] (T mapped per this row) |
map |
String (JSON-encoded) |
enum-typed string |
String (raw enum name) |
Nullability rules #
| Source | GraphQL nullability |
|---|---|
| Primary key | non-null |
Attribute, isNullable: false, no defaultValue |
non-null |
Attribute, isNullable: true or has defaultValue |
nullable |
Output-side @Serialize transient |
nullable |
Input-only @Serialize transient |
excluded from schema |
belongsTo with Relate(isRequired: true) |
non-null |
belongsTo without isRequired |
nullable |
hasOne |
nullable |
hasMany |
[Type!]! (always) |
SQL resolvers + dataloader (G3) #
SqlResolverFactory lowers GraphQL queries against a derived schema
into Conduit Query<T> calls. Wire it through SchemaBuilder's
resolver-hook parameters and through GraphQLController's
dataLoaderRegistry ctor argument:
import 'package:conduit_core/conduit_core.dart' hide SchemaBuilder;
import 'package:conduit_graphql/conduit_graphql.dart';
final dataModel = ManagedDataModel([User, Post, Comment]);
final context = ManagedContext(dataModel, store);
final factory = SqlResolverFactory(context);
final schema = SchemaBuilder(
generateFilterArgs: true,
generateSortArgs: true,
generatePaginationArgs: true,
attributeResolver: factory.attributeResolverFor,
relationshipResolver: factory.relationshipResolverFor,
queryListResolver: factory.listResolverFor,
queryByPkResolver: factory.byPkResolverFor,
).fromManagedDataModel(dataModel);
router.route('/graphql').link(
() => GraphQLController(
schema,
dataLoaderRegistry: factory.newRegistry,
),
);
The result: nested queries fan out to a small number of batched SQL
round-trips. { users { posts { title } } } over 50 users with 5
posts each is exactly 2 SQL queries — one for the users, one
batched WHERE author_id IN (...) for the posts.
Generated arguments #
When the matching generate*Args flag is on, list-all Query-root
fields gain these arguments:
-
where: <Entity>Filter— one input field per attribute. Each field accepts a<Scalar>Predicateinput with these operators:GraphQL key Conduit matcher eq:whereEqualTo(value)ne:whereNotEqualTo(value)gt:whereGreaterThan(value)gte:whereGreaterThanEqualTo(value)lt:whereLessThan(value)lte:whereLessThanEqualTo(value)in:oneOf([values])notIn:not.oneOf([values])like:contains(value)(substring match; string scalars only)isNull:isNull() / isNotNull()Predicates within one field AND together. Predicates across multiple fields also AND. Cross-field OR is intentionally not exposed in v1.
-
orderBy: [<Entity>SortInput!]— sort precedence list. Each<Entity>SortInputcarriesfield: <Entity>SortField!(an enum with one value per non-transient attribute) anddirection: SortDirection!(ASC | DESC). First entry is the primary sort, subsequent entries are tiebreakers. -
limit: Int— caps the result set. Lowers toQuery.fetchLimit.0/ unset means no limit. -
offset: Int— skips the first N rows of the (sorted) result set. Lowers toQuery.offset.
N+1 mitigation #
The factory uses a per-request DataLoaderRegistry to batch
relationship fan-out into a single round-trip per relationship. Within
a single request:
- All
belongsToresolutions for a given relationship fold into oneWHERE id IN (...)against the destination table. - All
hasMany/hasOneresolutions for a given relationship fold into oneWHERE <fk> IN (...)against the inverse-side table.
The registry's lifetime is the request — the controller drops it in a
finally block at request end so no caches leak. Resolvers that need
to invalidate a key mid-request can call loader.clear(key).
Resolver shape note #
graphql_server2 v6.5.0 short-circuits resolveFieldValue when the
parent object is a Map: it does parent[fieldName] and skips the
resolver entirely. To keep our relationship resolvers reachable, the
list / by-pk resolvers return ManagedObject instances rather than
asMap() projections — ManagedObject is not a Map, so the executor
falls through to the resolver path. Each attribute therefore needs
its own resolver (attributeResolverFor provides one); the factory
handles this for you when you wire it through the SchemaBuilder
hooks.
If you pass bigIntegerAsString: false to SchemaBuilder (so big-int
columns surface as Int!), also set factory.stringifyBigInts = false so the attribute resolver doesn't return String values for
Int! fields.
G2 schema-derivation limitations #
- Naive pluralization. Singular is
entity.namelowercased (User -> user); plural appendss/es/iesper simple rules. Words likeMouse -> mouses,Octopus -> octopuseswill be wrong. A future@SchemaName('users')-style annotation will allow per- entity overrides; for now, work around the case by hand-authoring the affected types or contributing the override hook. Documentcolumns serialize as JSON strings, not nested object types. Apps that need typed access to subdocuments must define a parallel projection by hand.- No filter / sort / pagination arguments. List-all fields take
zero arguments today. G3 adds a
where:/orderBy:/limit:/offset:argument set lowering toQuery<T>predicates. - No mutations. The derived schema is read-only — there are no
generated input object types, and the controller will reject GET
but not POST mutations against the derived schema (because there is
no
mutationTypeto match against). - Many-to-many join tables surface as their own
ObjectTypeplus two lists (Post.tags: [PostTag!]!,Tag.posts: [PostTag!]!). We do not auto-flatten the join. Build a hand-written field for the flattened view if you need one in v1. - Enum columns surface as
String, notenum. Surfacing them as GraphQLenumtypes is straightforward but requires either a registry walk or a stable name for the Dart enum at runtime; tracked for a future minor. - bigInteger scalars default to
Stringto dodge GraphQLInt's signed-32-bit overflow risk. PassSchemaBuilder(bigIntegerAsString: false)to opt back intoIntif you know your big-int columns are 32-bit safe. - Custom scalars are reachable via field-level introspection but
not the global
__schema { types }list. This is agraphql_server2v6.5.0 limitation: its type-collection walker doesn't add bare scalars to the traversed set. Tools that introspect through field types (e.g. GraphiQL hovering over aDateTimefield) will see the scalar correctly; a__schema { types { name } }query will not list it. - Single-entity types (
objectTypeFor) build a one-off registry with empty stubs for any related entities outside the call. UsefromManagedDataModelfor full schemas where relationship destinations need their fields populated.
Graph schema derivation (G4) #
SchemaBuilder.fromGraphDataModel(model, config: ...) walks every
GraphNodeEntity and GraphEdgeEntity in your GraphDataModel and
emits a parallel-but-separate read-only schema. Wire it up alongside
fromManagedDataModel if you have both stores; pick one and pass its
output to GraphQLController.
import 'package:conduit_graph/conduit_graph.dart';
import 'package:conduit_graph_neo4j/conduit_graph_neo4j.dart';
import 'package:conduit_graphql/conduit_graphql.dart';
class MyChannel extends ApplicationChannel {
late GraphContext graphContext;
@override
Future<void> prepare() async {
final store = Neo4jPersistentStore(
Uri.parse('bolt://localhost:7687'),
username: 'neo4j',
password: 'testpass',
)
..registerNodeFactory<User>(User.new)
..registerNodeFactory<Post>(Post.new);
final dataModel = GraphDataModel()
..registerNode<User>()
..registerNode<Post>()
..registerEdge<Friend, User, User>()
..registerEdge<Authored, User, Post>();
graphContext = GraphContext(dataModel, store);
store.bindDataModel(dataModel);
}
@override
Controller get entryPoint {
final factory = GraphResolverFactory(graphContext)
..registerNodeType<User>()
..registerNodeType<Post>();
final config = GraphSchemaConfig(
nodes: {
User: const GraphNodeSchemaConfig(
properties: [
GraphPropertyDescriptor(name: 'name', type: GraphPropertyType.string),
GraphPropertyDescriptor(name: 'age', type: GraphPropertyType.integer, isNullable: true),
],
),
Post: const GraphNodeSchemaConfig(
hasSchemalessProperties: true,
properties: [
GraphPropertyDescriptor(name: 'title', type: GraphPropertyType.string),
],
),
},
edges: {
Friend: const GraphEdgeSchemaConfig(properties: [
GraphPropertyDescriptor(
name: 'since',
type: GraphPropertyType.datetime,
isNullable: true,
),
]),
},
);
final schema = SchemaBuilder().fromGraphDataModel(
graphContext.dataModel,
config: config,
resolverFactory: factory,
);
return Router()
..route('/graphql').link(() => GraphQLController(schema));
}
}
What the graph walker emits #
- One
GraphQLObjectTypeperGraphNodeEntity, withid: String!andlabels: [String!]!baked in plus the typed properties declared in the per-nodeGraphSchemaConfig. - One
GraphQLObjectTypeperGraphEdgeEntity, carryingid, the declared edge properties, andfrom: <FromType>!/to: <ToType>!endpoints — these are the edge-property connection types. - For every outgoing edge from a node, a destination-list traversal
field on that node (
User.posts: [Post!]!for aUser -[Authored]-> Postedge). When two outgoing edges land on the same destination type, the second is disambiguated asposts2,posts3, ... - When
GraphSchemaConfig.exposeGraphEdgesAsConnections == true, an additional edge-record list field per outgoing edge (User.authoreds: [Authored!]!) so clients can read edge properties in the same selection set. - When a
GraphNodeSchemaConfigdeclares extraunionLabels, the node type is wrapped in aGraphQLUnionTypewhose member object types share the same shape — multi-label nodes surface asUser | Accountfor clients to discriminate via... on User { ... }inline fragments. - When a node opts into
hasSchemalessProperties, aproperties: JSON!field appears alongside the typed ones, carrying the JSON-encoded property bag. - A
Queryroot with<plural>: [<NodeType>!]!,<singular>(id: String!): <NodeType>per node, plus<edgePlural>: [<EdgeType>!]!per edge. - Field resolvers are populated automatically when you pass
resolverFactory:to the builder; otherwise resolvers stay null (introspection works, execution is the caller's job).
Schemaless property handling — explicit opt-in #
Per the G4 plan: schemaless property handling is opt-in per
GraphNode subclass. Set hasSchemalessProperties: true on the
node's GraphNodeSchemaConfig to surface the entire dynamic property
bag as a properties: JSON! field. The JSON scalar is a single
string carrying the JSON-encoded bag; clients decode with their JSON
parser. Typed-only mode is the default — the derived schema is
inspectable and precise.
Cross-walker name conflicts #
Wiring fromManagedDataModel and fromGraphDataModel into the same
schema is not the G4 surface — it's G5's cross-source dispatch
problem. If both walkers emit the same Query-root field name, the
graph walker raises a StateError at schema build time. Rename one
side (or wait for G5) before mixing.
Graph-side scalar mapping #
GraphPropertyType |
GraphQL |
|---|---|
string |
String |
integer |
String (default; Int if bigIntegerAsString: false) |
double |
Float |
bool |
Boolean |
datetime |
DateTime (custom scalar) |
list |
[String!] (element type unknown at this layer) |
map |
JSON (custom scalar) |
Rendering SDL #
The package ships a minimal printSchema() for use in golden tests
and documentation pipelines:
import 'package:conduit_graphql/conduit_graphql.dart';
final sdl = printSchema(schema);
print(sdl);
The printer covers the surface SchemaBuilder emits today (object
types, scalars, lists, non-null wrappers, field arguments, descriptions).
Anything outside that surface (interfaces, unions, enums, directives,
mutations, input objects) is not yet supported and will throw — by
design, so adding e.g. mutation support can't accidentally rely on a
stale printer.
Cross-source dispatch + field auth (G5) #
G5 unifies the SQL and graph halves under one schema and adds field-level
authorization. Build a Persistence<G> umbrella, derive a unified schema
via SchemaBuilder.fromPersistence, and let @FieldAuthorize (paired
with a FieldAuthPolicy) enforce scope checks at field-resolution time.
final persistence = Persistence<GraphPersistentStore>(
sql: PostgreSQLPersistentStore.fromConnectionInfo(...),
graph: Neo4jPersistentStore(Uri.parse('bolt://localhost:7687')),
);
persistence.sqlContext = ManagedContext(sqlModel, persistence.sql);
persistence.graphContext = GraphContext(graphModel, persistence.graph);
final factory = PersistenceResolverFactory<GraphPersistentStore>(
sql: SqlResolverFactory(persistence.sqlContext!),
graph: GraphResolverFactory(persistence.graphContext! as GraphContext)
..registerNodeType<Profile>(),
);
final result = SchemaBuilder().fromPersistence(
persistence,
resolverFactory: factory,
graphConfig: graphSchemaConfig,
collisionPolicy: QueryRootCollisionPolicy.error,
authPolicy: MapFieldAuthPolicy({
sqlModel.entityForType(User).attributes['ssn']!:
const FieldAuthorize(scopes: ['pii:read']),
}),
);
router.route('/graphql').link(() => GraphQLController(result.schema));
PersistenceSchema (the return value) carries sqlObjectTypes,
graphObjectTypes, and sourceFor(type) so callers can introspect
which half emitted what.
Worked example #
docs/persistence/graphql-cross-source.md walks the cross-source
stitching pattern end to end with a runnable app under
examples/graphql_cross_source/. The app uses fake stores so it boots
without infrastructure; replace the fakes with PostgreSQLPersistentStore
and Neo4jPersistentStore to ship.
Asymmetry note: @FieldAuthorize on the graph side #
Because GraphNode does not preserve per-property annotations through
the data-model build, graph-side auth declarations live on the
GraphPropertyDescriptor directly:
GraphPropertyDescriptor(
name: 'phoneNumber',
type: GraphPropertyType.string,
isNullable: true,
auth: FieldAuthorize(scopes: ['pii:read']),
);
The runtime wrapping behavior is otherwise identical between sides.
What this package does NOT do (yet) #
- No mutations — the derived schema is read-only. Insert / update / delete mutations need their own input-type emission and resolver surface; deferred to v0.6+.
- No automatic cross-source joins —
fromPersistenceroutes per-field, never per-query. SQL ↔ graph joins are hand-written stitching resolvers (seedocs/persistence/graphql-cross-source.md). - No subscriptions — out of scope for the entire G1–G5 plan. Conduit core has no WebSocket transport.
- No
@FieldAuthorizeannotation scraping —dart:mirrorsis being deprecated, so G5 ships the annotation as documentation alongside an explicitFieldAuthPolicylookup. A futurebuild_runnertransformer can emit the policy from the source.
Known limitations (G1) #
graphql_server2v6.5.0 silently drops field selections that don't exist on the parent type. We work around this with a minimal pre-execution validator that rejects unknown root-and-nested fields with HTTP 400 /errors[]. Fragment spreads and inline fragments are not yet checked by the local validator and fall through to the upstream executor.- Field-resolver runtime errors are surfaced as a single error with the
thrown object's
toString()asmessage. Path information is not yet populated;graphql_server2does not attribute paths to its thrownGraphQLExceptions, and the path machinery lands in G3 alongside the dataloader.
License #
BSD-3-Clause, matching the rest of the Conduit framework.