Skip to content

Enhancements: SQLA Interface, Mutation, InputObjectType and auto schema generation #226

Closed
@maquino1985

Description

@maquino1985

Currently there are at least 3 critical features missing from the library:

Interface support, Mutation support, and InputObjectType support that integrate with SQLAlchemy models. A last, but nice to have, would be a type that automatically generates all graphene-sqlalchemy models and correctly assigns them the appropriate Interface type based on their model.

Interface is critically important for people using polymorphic database structures. The importance of auto-generated SQLAlchemy Mutations and InputObjectTypes is self explanatory.

a SQLAlchemyInterface should have as its meta fields a name and an SQLAlchemy model, e.g.

class BaseClassModelInterface(SQLAlchemyInterface):
    class Meta:
        name = 'BaseClassNode'
        model = BaseClassModel

Because it will act as a Node elsewhere, in my implementation I have it extend Node (but call super(AbstractNode) to specify it's meta rather than have it be overridden)

def exclude_autogenerated_sqla_columns(model: DeclarativeMeta) -> Tuple[str]:
    # always pull ids out to a separate argument
    autoexclude: List[str] = []
    for col in sqlalchemy.inspect(model).columns:
        if ((col.primary_key and col.autoincrement) or
                (isinstance(col.type, sqlalchemy.types.TIMESTAMP) and
                 col.server_default is not None)):
            autoexclude.append(col.name)
            assert isinstance(col.name, str)
    return tuple(autoexclude)

class SQLAlchemyInterfaceOptions(InterfaceOptions):
    model = None  #
    registry = None  #
    connection = None  #
    id = None  # type: str

class SQLAlchemyInterface(Node):
    @classmethod
    def __init_subclass_with_meta__(
            cls,
            model: DeclarativeMeta = None,
            registry: Registry = None,
            only_fields: Tuple[str] = (),
            exclude_fields: Tuple[str] = (),
            connection_field_factory: UnsortedSQLAlchemyConnectionField = default_connection_field_factory,
            **options
    ):
        _meta = SQLAlchemyInterfaceOptions(cls)
        _meta.name = f'{cls.__name__}Node'

        autoexclude_columns = exclude_autogenerated_sqla_columns(model=model)
        exclude_fields += autoexclude_columns

        assert is_mapped_class(model), (
            "You need to pass a valid SQLAlchemy Model in " '{}.Meta, received "{}".'
        ).format(cls.__name__, model)

        if not registry:
            registry = get_global_registry()

        assert isinstance(registry, Registry), (
            "The attribute registry in {} needs to be an instance of "
            'Registry, received "{}".'
        ).format(cls.__name__, registry)

        sqla_fields = yank_fields_from_attrs(
            construct_fields(
                obj_type=cls,
                model=model,
                registry=registry,
                only_fields=only_fields,
                exclude_fields=exclude_fields,
                connection_field_factory=connection_field_factory
            ),
            _as=Field
        )
        if not _meta:
            _meta = SQLAlchemyInterfaceOptions(cls)
        _meta.model = model
        _meta.registry = registry
        connection = Connection.create_type(
            "{}Connection".format(cls.__name__), node=cls)
        assert issubclass(connection, Connection), (
            "The connection must be a Connection. Received {}"
        ).format(connection.__name__)
        _meta.connection = connection
        if _meta.fields:
            _meta.fields.update(sqla_fields)
        else:
            _meta.fields = sqla_fields
        _meta.fields['id'] = graphene.GlobalID(cls, description="The ID of the object.")
        super(AbstractNode, cls).__init_subclass_with_meta__(_meta=_meta, **options)

    @classmethod
    def Field(cls, *args, **kwargs):  # noqa: N802
        return NodeField(cls, *args, **kwargs)

    @classmethod
    def node_resolver(cls, only_type, root, info, id):
        return cls.get_node_from_global_id(info, id, only_type=only_type)

    @classmethod
    def get_node_from_global_id(cls, info, global_id, only_type=None):
        try:
            node: DeclarativeMeta = info.context.get('session').query(cls._meta.model).filter_by(id=global_id).one_or_none()
            return node
        except Exception:
            return None

    @classmethod
    def from_global_id(cls, global_id):
        return global_id

    @classmethod
    def to_global_id(cls, type, id):
        return id

    @classmethod
    def resolve_type(cls, instance, info):
        if isinstance(instance, graphene.ObjectType):
            return type(instance)
        graphene_model = get_global_registry().get_type_for_model(type(instance))
        if graphene_model:
            return graphene_model
        else:
            raise ValueError(f'{instance} must be a SQLAlchemy model or graphene.ObjectType')

A mutation should take as its meta arguments the SQLAlchemy Model, it's CRUD operation . (Create Edit or Delete), and the graphene structure of its response (Output type)

class CreateFoos(SQLAlchemyMutation):
    class Arguments:
        foos = graphene.Argument(graphene.List(FooInput))

    class Meta:
        create = True
        model = FooModel
        structure = graphene.List
class SQLAlchemyMutation(graphene.Mutation):
    @classmethod
    def __init_subclass_with_meta__(cls, model=None, create=False,
                                    delete=False, registry=None,
                                    arguments=None, only_fields=(),
                                    structure: Type[Structure] = None,
                                    exclude_fields=(), **options):
        meta = SQLAlchemyMutationOptions(cls)
        meta.create = create
        meta.model = model
        meta.delete = delete

        if arguments is None and not hasattr(cls, "Arguments"):
            arguments = {}
            # don't include id argument on create
            if not meta.create:
                arguments['id'] = graphene.ID(required=True)

            # don't include input argument on delete
            if not meta.delete:
                inputMeta = type('Meta', (object,), {
                    'model': model,
                    'exclude_fields': exclude_fields,
                    'only_fields': only_fields
                })
                inputType = type(cls.__name__ + 'Input',
                                 (SQLAlchemyInputObjectType,),
                                 {'Meta': inputMeta})
                arguments = {'input': inputType(required=True)}
        if not registry:
            registry = get_global_registry()
        output_type: ObjectType = registry.get_type_for_model(model)
        if structure:
            output_type = structure(output_type)
        super(SQLAlchemyMutation, cls).__init_subclass_with_meta__(_meta=meta, output=output_type, arguments=arguments, **options)

    @classmethod
    def mutate(cls, info, **kwargs):
        session = get_session(info.context)
        with session.no_autoflush:
            meta = cls._meta
            model = None

            if meta.create:
                model = meta.model(**kwargs['input'])
                session.add(model)
            else:
                model = session.query(meta.model).filter(meta.model.id == kwargs['id']).first()

            if meta.delete:
                session.delete(model)
            else:

                def setModelAttributes(model, attrs):
                    relationships = model.__mapper__.relationships
                    for key, value in attrs.items():
                        if key in relationships:
                            if getattr(model, key) is None:
                                # instantiate class of the same type as
                                # the relationship target
                                setattr(model, key,
                                        relationships[key].mapper.entity())
                            setModelAttributes(getattr(model, key), value)
                        else:
                            setattr(model, key, value)

                setModelAttributes(model, kwargs['input'])
            session.commit()
            return model

    @classmethod
    def Field(cls, *args, **kwargs):
        return graphene.Field(cls._meta.output,
                              args=cls._meta.arguments,
                              resolver=cls._meta.resolver)

an SQLAlchemy InputObjectType should introspect the sqla model and autogenerate fields to select based upon and set the appropriate field data type:

e.g.

class Bar(SQLAlchemyInputObjectType):
    class Meta:
        model = BarModel
        exclude_fields = ( 'polymorphic_discriminator', 'active', 'visible_id')

```python
class SQLAlchemyInputObjectType(InputObjectType):
    @classmethod
    def __init_subclass_with_meta__(
            cls,
            model=None,
            registry=None,
            skip_registry=False,
            only_fields=(),
            exclude_fields=(),
            connection=None,
            connection_class=None,
            use_connection=None,
            interfaces=(),
            id=None,
            connection_field_factory=default_connection_field_factory,
            _meta=None,
            **options
    ):
        autoexclude = []

        # always pull ids out to a separate argument
        for col in sqlalchemy.inspect(model).columns:
            if ((col.primary_key and col.autoincrement) or
                    (isinstance(col.type, sqlalchemy.types.TIMESTAMP) and
                     col.server_default is not None)):
                autoexclude.append(col.name)

        if not registry:
            registry = get_global_registry()
        sqla_fields = yank_fields_from_attrs(
            construct_fields(cls, model, registry, only_fields, exclude_fields + tuple(autoexclude), connection_field_factory),
            _as=Field,
        )
        # create accessor for model to be retrieved for querying
        cls.sqla_model = model
        if use_connection is None and interfaces:
            use_connection = any(
                (issubclass(interface, Node) for interface in interfaces)
            )

        if use_connection and not connection:
            # We create the connection automatically
            if not connection_class:
                connection_class = Connection

            connection = connection_class.create_type(
                "{}Connection".format(cls.__name__), node=cls
            )

        if connection is not None:
            assert issubclass(connection, Connection), (
                "The connection must be a Connection. Received {}"
            ).format(connection.__name__)

        for key, value in sqla_fields.items():
            if not (isinstance(value, Dynamic) or hasattr(cls, key)):
                setattr(cls, key, value)

        super(SQLAlchemyInputObjectType, cls).__init_subclass_with_meta__(**options)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions