Skip to content

Schema Auto-Generation

general_manager.api.graphql.GraphQL inspects manager interfaces and creates matching Graphene types and mutations. Classes without an Interface are ignored by interface and mutation registration.

Type mapping

For each manager class, GeneralManager:

  1. Registers a Graphene ObjectType with fields derived from interface attribute types.
  2. Creates resolvers that read values from the underlying manager or bucket.
  3. Adds fields for every @graph_ql_property method. Union types and optional hints are converted into GraphQL-friendly types.
  4. Registers measurement scalars (MeasurementScalar) and object wrappers so units stay intact.
  5. Adds a capabilities field only when the manager exposes GraphQL permission capability declarations.

Single-valued manager relations are exposed as object fields. Relation fields whose Python-side name ends with _list are exposed as paginated list fields with filtering, grouping, sorting, and pagination arguments.

Identity fields

ORM primary keys and raw scalar foreign-key references map to GraphQL ID, including integer, UUID, and string keys. For example, an actual primary key named code becomes code: ID, and the raw foreign-key alias owner_id becomes ownerId: ID. ID outputs serialize as JSON strings. Manager relations still expose their object or paginated object fields; mutation reference lists use [ID], and references to composite managers use structured inputs. Within a structured reference, an ORM constructor PK still uses ID; additional business inputs retain their native scalars. Optional calculation/request business inputs named id retain their declared nullability in generated CRUD arguments. Structured write references use the referenced manager's constructor for validation. For a to_field relation they resolve the actual target-field value; scalar to_field inputs continue to accept that raw referenced value.

The ORM descriptor records is_identifier, is_primary_key, and is_auto_primary_key metadata from the Django fields. Native Python types and scalar hints remain in that metadata. Names alone do not identify a field: ordinary fields such as external_id, and calculation/request composite business inputs named id, retain their native GraphQL scalars.

Native properties lose their source field's identity metadata. Declare that contract explicitly with @graph_ql_property(is_identifier=True): an int, str, or UUID result becomes ID, and a homogeneous list, tuple, or set of those values becomes [ID]. Optional values and elements remain nullable, as do ordinary manager property fields. Resolver, cache, database, and detached Bucket.values() / values_list() values keep their native types. Neither an ID-like name nor query_annotation=F("id") implies identity. Manager objects and output value objects retain their existing object shapes; do not mark them as native identifiers.

Equality, exact, and membership filters for identity fields use ID, ID, and [ID], respectively. Range comparisons retain their native scalar, including any graphql_scalar="bigint" hint. Server-side conversion preserves the Python and database identifier types. A UUID or string ORM PK still needs the corresponding explicit constructor Input(UUID) or Input(str) configuration; changing its GraphQL scalar does not configure that input.

This scalar change requires clients to update affected Int/String variable declarations to ID, their lists to [ID], and regenerate schema-derived types. Keep returned IDs as strings in equality checks, application state, and cache keys instead of converting them to numbers. See the identifier guide for configuration and migration steps.

Output-only value types

Use the package-root GraphQLType when a response needs a nested object shape but does not represent a manager with identity, lifecycle, or independent operations. Import the module that declares each output type during startup so the declaration is registered before the schema is built:

from __future__ import annotations

from dataclasses import field

from general_manager import GraphQLType
from general_manager.measurement import Measurement
from your_app.managers import Task, User  # application-specific registered managers


class ProjectHour(GraphQLType):
    user: list[User]
    total_hours: Measurement
    task: Task
    notes: str | None = None
    tags: list[str] = field(default_factory=list)

User and Task in this illustrative import are application-defined GeneralManager subclasses registered by your application; replace the import with the module used by your project. The user and task values in the property example below are likewise supplied by application-specific calculation logic. The GraphQLType declaration describes their output shape; it does not look them up or inject them.

Concrete subclasses automatically behave as frozen dataclasses; an explicit @dataclass decorator is not needed. Dataclass defaults and default_factory are supported, and ClassVar declarations are not exposed as fields. The generated Graphene object is named ProjectHourType.

Python annotations define nullability and collection element nullability:

Python annotation GraphQL field
str String!
str \| None String
list[User] [UserType!]!
list[User \| None] [UserType]!
list[User] \| None [UserType!]

For native identity fields, use the explicit dataclass field metadata rather than changing the Python annotation:

class RecordProjection(GraphQLType):
    record_key: int = field(metadata={"is_identifier": True})
    record_keys: list[int | None] | None = field(metadata={"is_identifier": True})
    quantity: int

Import field from dataclasses and GraphQLType from general_manager. The fields become recordKey: ID!, recordKeys: [ID], and quantity: Int!. Marked native int, str, and UUID identities and supported flat list, set, and variadic tuple collections serialize as strings at the GraphQL boundary. Python annotations and frozen values remain native. Required/optional annotations determine both nullability levels exactly as before. Missing/false metadata keeps ordinary scalars, including fields named id or external_id; names never imply identity. Other flag values raise a field-specific TypeError. Output field metadata does not provide manager identity, root operations, input objects, or search normalization.

For scalar fields, the strict allowlist is str, bool, int, float, Decimal, datetime, date, and subclasses of those types. Unknown scalar-like classes are rejected; output declarations do not use the legacy manager mapper's fallback to String. Other supported values are Measurement, registered GeneralManager classes, registered GraphQLType classes, and list[T], tuple[T, ...], or set[T] collections. Optional forms use T | None. Bare collections, every fixed-length tuple (including tuple[int, int]), Any, Annotated[...], unresolved references, and unions containing more than one non-null type are rejected during schema generation. The same strict shape rules apply to marked identities, with native UUID additionally accepted only for explicitly marked fields. Bool, float, manager, measurement, nested collection, and output-object identity annotations fail.

An output type can be returned by any @graph_ql_property using a direct, optional, or collection annotation:

class ProjectHoursSummary(GeneralManager):
    @graph_ql_property
    def project_hours(self) -> list[ProjectHour]:
        return [
            ProjectHour(
                user=[user],
                total_hours=Measurement(8, "h"),
                task=task,
            )
        ]

GraphQLType declarations add only schema object types. They do not create root queries, list queries, mutations, subscriptions, filters, capabilities, or manager lifecycle behavior. The owning manager controls access to the property. Nested manager fields retain that manager's normal read resolvers and permissions; plain scalar and nested output fields have no independent permission check.

Output declarations and generated output ObjectType classes use registries separate from manager declarations and generated manager types. Manager-only roots, filters, subscriptions, search, capabilities, and lifecycle tooling continue to consume the manager registries; an output declaration is never added to those manager operation paths.

For a complete declaration, property, startup, and query example, see the typed output-object recipe.

Relation annotation compatibility

Before mapping a relation, schema generation resolves its annotation to one registered GeneralManager class. The resolver accepts:

  • a concrete manager class such as User;
  • a generated or existing Django model class that carries the manager back-reference;
  • Bucket[User], list[User], tuple[User, ...], or set[User]; and
  • optional and union forms such as User | None, Optional[User], and their postponed string equivalents, including "Bucket[User]" and "typing.List[User]".

This lets annotations remain useful with from __future__ import annotations and with manager classes declared in a different order. A postponed annotation must name a manager that GeneralManager registers during startup. An ambiguous union containing more than one manager target is not treated as a manager relation, so use one manager target per generated relation field.

The same resolution controls generated object fields, relation-list fields, relation filters, sort options, mutation inputs, and subscription identifiers. For a task-oriented declaration pattern, see Expose managers via GraphQL; the GraphQL query cookbook shows the resulting schema, and the GraphQL API reference records the compatibility note for 0.64.1 and 0.64.2.

Mutations

Create, update, and delete mutations are added automatically when the interface overrides the base method or advertises the matching capability from Interface.get_capabilities(). If a mutation factory returns no class for a supported operation, that operation is skipped and later supported operations are still considered. Each mutation returns:

  • success: boolean indicating whether the operation completed.
  • errors: list of validation or permission errors when present.
  • A field with the manager name containing the affected object.

Generated create and update mutations expose writable, non-derived interface attributes and skip raw direct-relation ID aliases when the canonical relation field is already present. Raw many-relation ID-list aliases such as member_id_list are skipped when the canonical member_list relation exists. ORM create mutations omit auto-increment primary keys and accept editable manual primary keys as ID, including fields named id. A manual PK is required when it has no default and optional when the model supplies one. ORM update and delete mutations require a stable id: ID! target argument, even when the actual PK has another name. Writable fields cannot overwrite that target argument, and primary keys are excluded from update payload fields. Calculation/request composite target inputs preserve their business scalars; actual manager references retain their scalar or structured input shape.

Update mutations make every generated write field optional, filter out omitted Graphene NOT_PROVIDED values, and forward an explicit history comment as the manager history_comment. Create fields retain literal interface/model defaults. The NOT_PROVIDED sentinel and callable defaults do not appear as GraphQL defaults, and schema construction never evaluates those callables. The ORM evaluates a callable model default at write time when the create field is omitted. NOT_PROVIDED means there is no default and preserves the field's requiredness. This does not relax model or input validation. Required create fields without real defaults need non-null variable declarations; removing the NOT_PROVIDED schema default can make an old nullable declaration invalid even when a value is supplied. Update fields deliberately have no GraphQL argument default: omitting a field, including a nullable variable that was not supplied, removes it from the manager payload and preserves the stored value even when the model field has a default. An explicit null is different: it is forwarded as None and clears a nullable field or fails validation when the field does not accept null. With Graphene's default camel-casing, clients send historyComment; Python-side tests and helpers use history_comment. Delete mutations also accept optional history-comment metadata and forward it to delete(). Explicit GraphQL null is forwarded as history_comment=None; when the history comment argument is omitted, the resolver does not send a history_comment keyword at all.

For relation inputs, the GraphQL schema exposes the canonical manager-facing field names and normalizes them before calling the ORM mutation layer. A direct manager relation such as owner: User is exposed as owner in the mutation and is forwarded as owner_id. If metadata also contains owner_id, that raw alias is not exposed separately. A many-valued relation such as member_list: list[User] is represented by the Python argument key member_list, exposed to GraphQL clients as memberList with Graphene's default camel-casing, and forwarded as member_id_list. If metadata also contains member_id_list, that raw alias is not exposed separately.

Custom mutations use the @graph_ql_mutation decorator from general_manager.api.mutation. The decorator analyses the function signature to generate GraphQL input arguments and return types.

Relation input contract

Automatic GraphQL mutations accept relation inputs in the GraphQL-facing forms below and normalize them to the ORM mutation contract before persistence:

  • Single-valued relations: <field> or <field>Id
  • Many-valued relations: <field>List or <field>IdList

Internally, GeneralManager treats the canonical mutation payload as:

  • Single-valued relations: <field>_id
  • Many-valued relations: <field>_id_list

These public names assume Graphene's default auto_camelcase=True. If your schema disables auto-camelcase, the Python-side argument names remain available as <field>, <field>_id, <field>_list, and <field>_id_list.

This keeps GraphQL mutations compatible with Graphene field naming while preserving a predictable backend contract for ORM-backed interfaces.

Schema-generation expectations

Schema generation should remain resilient when interface metadata includes edge-case field types:

  • Measurement fields continue to map to MeasurementScalar / MeasurementType
  • Native large integer fields and identity range filters may use BigIntScalar through graphql_scalar="bigint"; identity outputs and equality filters use ID while preserving that hint in metadata.
  • Non-relational field types that do not map cleanly to a specific GraphQL scalar fall back to string-like handling instead of aborting schema construction

The intended behavior is that startup and schema registration remain reviewable and predictable even when a manager exposes less common field metadata.

Relation Filters

Generated root query fields use the same public camelCase convention as nested fields. For example, PartSoldType is exposed as partSoldType and partSoldTypeList, not partsoldtype or partsoldtypeList.

Generated list queries expose scalar filters and relation filters. Direct relations such as foreign keys and one-to-one fields use nested filter input:

query {
  changeRequestFeasibilityList(filter: {
    changeRequest: { title: "Primary" }
  }) {
    items { id score }
  }
}

Collection relations such as reverse foreign keys and many-to-many fields expose any and none:

query {
  changeRequestList(filter: {
    changeRequestFeasibilityList: {
      any: { score_Gte: 7 }
    }
  }) {
    items { id title }
  }
}

any keeps rows with at least one related object matching the nested filter. none removes rows that have a related object matching the nested filter.

The maximum relation nesting depth defaults to 1. Configure it with:

GENERAL_MANAGER = {
    "GRAPHQL_FILTER_RELATION_DEPTH": 2,
}

Buckets and pagination

For bucket-returning fields, the schema registers list fields and page types. PageInfo exposes total_count, current_page, total_pages, and optional page_size so clients can implement cursor-less pagination quickly.

Generated root and bucket relation fields have …List and …Groups siblings. Groups require groupBy and use a distinct grouped item type inside the normal page envelope. Grouped singular manager relations become …List / …Groups collections of original managers; these operations can be nested recursively. Plain List[GraphQLType] fields retain their output shape and gain no query arguments or grouping companions. Ordinary pages are named from their item type: a manager Project uses ProjectTypePage, and a relation to a manager named ProjectType uses ProjectTypeTypePage. This prevents entity and reference managers from sharing a page type; schema clients using relation fragments must use the canonical item-derived page name. See the grouping contract for aggregate values, relation cardinality, permissions, and migration.

Extending the schema

  • Override _map_field_to_graphene_read to customise how specific Python types map to GraphQL fields (for example, using Relay nodes).
  • Register additional scalars or enums by updating GraphQL.graphql_type_registry before building the schema.
  • Combine auto-generated queries with handcrafted ones by subclassing the generated query root and adding custom fields.
  • Register additional schema directives with GENERAL_MANAGER["GRAPHQL_DIRECTIVES"]:
from graphql import DirectiveLocation, GraphQLDirective

GENERAL_MANAGER = {
    "GRAPHQL_DIRECTIVES": [
        GraphQLDirective(
            name="scenario",
            locations=[DirectiveLocation.FIELD],
        )
    ]
}

This setting only adds directives to the generated schema. If a directive needs runtime behavior, implement that separately with Graphene middleware or a custom execution context.