conduit_graphql 7.0.0 copy "conduit_graphql: ^7.0.0" to clipboard
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. Send Accept: application/graphql-response+json to receive it. This package registers the codec with Conduit's CodecRegistry on first GraphQLController construction; 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, with data: null and a populated errors[] (per spec §7.1.2).
  • 400 — malformed body, parse error, validation error, missing query, malformed ?variables=, or variable-coercion failure.
  • 405GET of a mutation or subscription.

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 GraphQLObjectType per ManagedEntity.
  • Scalar columns lower per the table below.
  • Relationships surface in both directions: User.posts: [Post!]! and Post.author: User! are both present.
  • A Query root 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>Predicate input 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>SortInput carries field: <Entity>SortField! (an enum with one value per non-transient attribute) and direction: SortDirection! (ASC | DESC). First entry is the primary sort, subsequent entries are tiebreakers.

  • limit: Int — caps the result set. Lowers to Query.fetchLimit. 0 / unset means no limit.

  • offset: Int — skips the first N rows of the (sorted) result set. Lowers to Query.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 belongsTo resolutions for a given relationship fold into one WHERE id IN (...) against the destination table.
  • All hasMany / hasOne resolutions for a given relationship fold into one WHERE <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.name lowercased (User -> user); plural appends s/es/ies per simple rules. Words like Mouse -> mouses, Octopus -> octopuses will 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.
  • Document columns 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 to Query<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 mutationType to match against).
  • Many-to-many join tables surface as their own ObjectType plus 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, not enum. Surfacing them as GraphQL enum types 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 String to dodge GraphQL Int's signed-32-bit overflow risk. Pass SchemaBuilder(bigIntegerAsString: false) to opt back into Int if 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 a graphql_server2 v6.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 a DateTime field) 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. Use fromManagedDataModel for 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 GraphQLObjectType per GraphNodeEntity, with id: String! and labels: [String!]! baked in plus the typed properties declared in the per-node GraphSchemaConfig.
  • One GraphQLObjectType per GraphEdgeEntity, carrying id, the declared edge properties, and from: <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 a User -[Authored]-> Post edge). When two outgoing edges land on the same destination type, the second is disambiguated as posts2, 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 GraphNodeSchemaConfig declares extra unionLabels, the node type is wrapped in a GraphQLUnionType whose member object types share the same shape — multi-label nodes surface as User | Account for clients to discriminate via ... on User { ... } inline fragments.
  • When a node opts into hasSchemalessProperties, a properties: JSON! field appears alongside the typed ones, carrying the JSON-encoded property bag.
  • A Query root 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 joinsfromPersistence routes per-field, never per-query. SQL ↔ graph joins are hand-written stitching resolvers (see docs/persistence/graphql-cross-source.md).
  • No subscriptions — out of scope for the entire G1–G5 plan. Conduit core has no WebSocket transport.
  • No @FieldAuthorize annotation scrapingdart:mirrors is being deprecated, so G5 ships the annotation as documentation alongside an explicit FieldAuthPolicy lookup. A future build_runner transformer can emit the policy from the source.

Known limitations (G1) #

  • graphql_server2 v6.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() as message. Path information is not yet populated; graphql_server2 does not attribute paths to its thrown GraphQLExceptions, and the path machinery lands in G3 alongside the dataloader.

License #

BSD-3-Clause, matching the rest of the Conduit framework.

0
likes
140
points
71
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

conduit_core, conduit_graph, graphql_parser2, graphql_schema2, graphql_server2

More

Packages that depend on conduit_graphql