Description
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)