Closed sustained closed 5 years ago
I am not 100% sure if this is the best implementation of JSON type, I actually don't even remember where do I have it from, but it works for me.
Just define custom scalar type:
<?php
namespace App\GraphQL\Scalars;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Language\AST\BooleanValueNode;
use GraphQL\Language\AST\FloatValueNode;
use GraphQL\Language\AST\IntValueNode;
use GraphQL\Language\AST\ListValueNode;
use GraphQL\Language\AST\ObjectValueNode;
use GraphQL\Language\AST\StringValueNode;
/**
* Read more about scalars here http://webonyx.github.io/graphql-php/type-system/scalar-types/
*/
class JSON extends ScalarType
{
public $description =
'The `JSON` scalar type represents JSON values as specified by
[ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).';
/**
* Serializes an internal value to include in a response.
*
* @param mixed $value
* @return mixed
*/
public function serialize($value)
{
// Assuming the internal representation of the value is always correct
return $value;
}
/**
* Parses an externally provided value (query variable) to use as an input
*
* @param mixed $value
* @return mixed
*/
public function parseValue($value)
{
return $value;
}
/**
* Parses an externally provided literal value (hardcoded in GraphQL query) to use as an input.
*
* E.g.
* {
* user(email: "user@example.com")
* }
*
* @param \GraphQL\Language\AST\Node $valueNode
* @param mixed[]|null $variables
* @return mixed
*/
public function parseLiteral($valueNode, ?array $variables = null)
{
switch ($valueNode) {
case ($valueNode instanceof StringValueNode):
case ($valueNode instanceof BooleanValueNode):
return $valueNode->value;
case ($valueNode instanceof IntValueNode):
case ($valueNode instanceof FloatValueNode):
return floatval($valueNode->value);
case ($valueNode instanceof ObjectValueNode): {
$value = [];
foreach ($valueNode->fields as $field) {
$value[$field->name->value] = $this->parseLiteral($field->value);
}
return $value;
}
case ($valueNode instanceof ListValueNode):
return array_map([$this, 'parseLiteral'], $valueNode->values);
default:
return null;
}
}
}
And you can use it in your schema:
type Model {
attribute: JSON
}
If I understand it right - hasura is a tool that generates schema.graphql from the postgres DB structure. I think it conflicts with the idea what lighthouse is. Lighthouse helps to write the schema, with useful helpers (directives) and it is actually built to work with eloquent models inside of laravel/lumen framework, instead of directly connect to DB.
Thanks for the prompt response and the code!
Yeah, Hasura basically generates a schema based on your existing database - it's truly fantastic! But this library seems really great too, so thanks guys and gals.
Okay, cool. I didn't realise we could create custom types. I'm still in the process of going through all of the documentation and I'm pretty new to GraphQL in general.
That seems really promising, though I'm confused why I don't see any json_encode/decode
there.
I'll have to give it a try.
Don't you think it could be good to have such a type built into Lighthouse?
There is a package that implements a few types (from one of Lighthouse's contributors, Spawnia): https://github.com/mll-lab/graphql-php-scalars
Okay, cool. I didn't realise we could create custom types.
That is the point. You are also able to create custom resolver, thus you have pretty much freedom, I guess much more in comparison to hasura.
That seems really promising, though I'm confused why I don't see any
json_encode/decode
there.
Yes, I was also confused about it. I think outgoing JSON is automatically serialized via eloquent, and that is why it works. Incoming JSON is kinda parsed...
I didn't know spawnia has such a repo. Well, @sustained you can also try spawnias implementation: https://github.com/mll-lab/graphql-php-scalars/blob/master/src/JSON.php
Don't you think it could be good to have such a type built into Lighthouse?
I think spawnia tries to keep lighthouse as light as possible. And JSON type/filed is actually not used in a simple projects - it is more special case, because you have to try to define your database without using JSON. In my case it is just a meta information, that may be very different, so it was easier to choose JSON type for particular field.
Anyway, may be it would be cool, if we collect implementations (like list of links) of scalar types in docs?
@lorado Okay, so here's the problem with your suggestion -
I changed serialize
to use json_encode
and now it works as I expected it to.
I don't specify the shape of the data
and yet the entire thing is returned (of course).
And that kind of goes against the whole GraphQL "the query structure matches the returned data structure" mantra, don't you think?
I imagine that with true JSONB support instead the query would look like this -
query {
course(id: 1) {
id
author {
name
}
levels {
title
words {
source,
target,
data {
form
type
}
}
}
}
}
Do you see what I mean?
I don't really know the JSONB type, but I think this can help you: I have created a directive to use with the spatie/laravel-schemaless-attributes
package (or even with just arrays or classes). Here it is:
<?php
namespace Hawk\LighthouseExtended\Schema\Directives\Fields;
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
use Nuwave\Lighthouse\Schema\AST\PartialParser;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldManipulator;
use Nuwave\Lighthouse\Support\Contracts\FieldResolver;
use Nuwave\Lighthouse\Exceptions\DirectiveException;
use Spatie\SchemalessAttributes\SchemalessAttributes;
class SchemalessAttributeDirective extends BaseDirective implements FieldResolver, FieldManipulator
{
public function name()
{
return 'schemalessAttribute';
}
public function manipulateSchema(FieldDefinitionNode $fieldDefinition, ObjectTypeDefinitionNode $parentType, DocumentAST $current)
{
$typeName = $this->getTypeName($fieldDefinition, $parentType);
$schema = PartialParser::objectTypeDefinition("
type $typeName {
schemalessAttribute (
select: String!,
): String
}
");
$current->setDefinition($schema);
$current->setDefinition($parentType);
return $current;
}
public function resolveField(FieldValue $value)
{
$select = $this->directiveArgValue('select', $value->getFieldName());
if (!$select) {
throw new DirectiveException('select is required');
}
$source = $this->directiveArgValue('source', 'extra_attributes');
return $value->setResolver(function ($root, array $args) use ($select, $source) {
if ($source) {
$schemalessAttributes = $root->{$source};
} else {
$schemalessAttributes = $root;
}
if ($schemalessAttributes instanceof SchemalessAttributes) {
$value = $schemalessAttributes->get($select);
} else {
$value = data_get($schemalessAttributes, $select);
}
return $value;
});
}
protected function getTypeName(FieldDefinitionNode $fieldDefinition, ObjectTypeDefinitionNode $parent)
{
return studly_case(
$parent->name->value
. $fieldDefinition->name->value
. '_SchemalessAttribute'
);
}
}
If it is more or less what you want, you will be able to query exactly the shape you want.
For example I have this:
type Reservation {
driver: Driver! @schemalessAttribute # driver is from a JSON column (in mysql)
}
type Driver {
firstname: String!
lastname: String!
...
}
So I can query just what I want.
That's really cool, I'll definitely have a play with that.
Also I need to look into the schemaless attributes library - also seems useful.
Thank you very much.
And that kind of goes against the whole GraphQL "the query structure matches the returned data structure" mantra, don't you think?
Yes, but if you want to query only the shape you want, you have to define it in schema. There is no other way. And I think it doesn't matter if it is a JSONB or JSON - in laravel (PHP) it is handled as associative array.
In enzonotario example his directive dinamically updates schema, and so you can achieve your goal ;)
Nice!
Let me close this since it seems to be solved. Feel free to re open if needed!
@enzonotario do you have a copy of this SchemalessAttributeDirective
that works with v4.9? I tried changing setDefinition()
to setTypeDefinition()
with no luck.
It seems that $select = $this->directiveArgValue('select', $fieldValue->getFieldName());
is returning the name of my schemaless attribute (e.g., extra_attributes) instead of the inner attribute.
Sure! It's more or less the same:
<?php
namespace Hawk\LighthouseExtended\Schema\Directives\Fields;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldResolver;
use Spatie\SchemalessAttributes\SchemalessAttributes;
class SchemalessAttributeDirective extends BaseDirective implements FieldResolver
{
/**
* Name of the directive.
*
* @return string
*/
public function name()
{
return 'schemalessAttribute';
}
/**
* Resolve the field directive.
*
* @param FieldValue $value
*
* @return FieldValue
*/
public function resolveField(FieldValue $value)
{
$select = $this->directiveArgValue('select', $value->getFieldName());
$source = $this->directiveArgValue('source', 'extra_attributes');
return $value->setResolver(function ($root, array $args) use ($select, $source) {
if ($source) {
$root = $root->{$source};
}
if ($root instanceof SchemalessAttributes) {
$value = $root->get($select);
} else {
$value = data_get($root, $select);
}
return $value;
});
}
}
Thanks! Am I using it correctly? The directive doesn't seem to receive a "select" argument so it ends up executing, in effect:
$value = $root->get('subgroup_details')
instead of
$value = $root->get('area')
Here are my configuration and results:
type Part @model {
id: ID! @globalId
sku: String!
subgroup_details: PartSubgroupDetails @schemalessAttribute(source: "subgroup_details")
}
type PartSubgroupDetails {
area: Float
height: Float
length: Float
width: Float
}
query Goods {
goods(first: 1, subgroup_id: 40) {
edges {
node {
id
part {
sku
subgroup_details {
area
}
}
}
}
}
}
{
"data": {
"goods": {
"edges": [
{
"node": {
"id": "R29vZDoyNjMzMA==",
"part": {
"sku": "AB12",
"subgroup_details": null
}
}
}
]
}
}
}
Did you try it using @schemalessAttribute(select: "subgroup_details")
?
If you use the default extra_attributes
as the source, you don't need to change the source.
subgroup_details: PartSubgroupDetails @schemalessAttribute(source: "subgroup_details", select: "subgroup_details")
=> No difference
subgroup_details: PartSubgroupDetails @schemalessAttribute(select: "subgroup_details")
=> No difference
subgroup_details: PartSubgroupDetails @schemalessAttribute(source: "subgroup_details", select: "area")
=> Adds "area" attribute to response but doesn't pass the value. $value
is getting the correct value in the resolver though.
{
"data": {
"goods": {
"edges": [
{
"node": {
"id": "R29vZDoyNjMzMA==",
"part": {
"sku": "GPF1",
"subgroup_details": {
"area": null
}
}
}
}
]
}
}
}
I can't wrap my head around how the resolver is supposed to retrieve the sub-selection (area
) in subgroup_details .
BTW, thanks for you help. Your laravel-websockets example was also a huge time-saver!
Are you using laravel-schemaless-attributes
?
This is a simple directive that just does something like it: $this->extra_attributes->get($select)
.
Can you show us your use case? How is your extra_attributes
structured?
I am using laravel-schemaless-attributes.
class Part extends Model
{
use HasSchemalessSubgroupDetails;
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'subgroup_details' => 'array',
];
}
trait HasSchemalessSubgroupDetails
{
public function getSubgroupDetailsAttribute(): SchemalessAttributes
{
return SchemalessAttributes::createForModel($this, 'subgroup_details');
}
public function scopeWithSubgroupDetails(): Builder
{
return SchemalessAttributes::scopeWithSchemalessAttributes('subgroup_details');
}
}
I can also see the resolver retrieving the correct value.
if ($root instanceof SchemalessAttributes) {
$value = $root->get($select);
} else {
$value = data_get($root, $select);
}
\Log::debug("\$value: $value");
Circling back to show what I ended up with:
/**
* Allows definition of schema from attributes in JSON column.
* This means you can filter JSON attributes in your queries like
* you would with normal attributes.
*
* @example
* type Part @model {
* # Option A: Access directly on model
* substrate: String @schemalessAttribute(source: "subgroup_details" select: "area")
* # Option B: Access via sub-selection
* subgroup_details: PartSubgroupDetails @schemalessAttribute(source: "area")
* }
*
* @package App\GraphQL\Directives
*
* @see https://github.com/nuwave/lighthouse/issues/954#issuecomment-598498843 Source
*/
class SchemalessAttributeDirective extends BaseDirective implements FieldResolver
{
/**
* Name of the directive.
*
* @return string
*/
public function name()
{
return 'schemalessAttribute';
}
/**
* Resolve the field directive.
*
* @param FieldValue $value
*
* @return FieldValue
*/
public function resolveField(FieldValue $value)
{
$select = $this->directiveArgValue('select');
$source = $this->directiveArgValue('source', 'extra_attributes');
return $value->setResolver(
function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($select, $source) {
if ($source) {
$root = $root->{$source};
}
// If no selection is specified in the directive, assume we're moving on to a sub-selection
if (empty($select)) {
return $root;
}
if ($root instanceof SchemalessAttributes) {
$value = $root->get($select);
} else {
$value = data_get($root, $select);
}
return $value;
}
);
}
}
Are you willing to provide a PR for this issue or aid in developing it?
I mean, in theory yes but PHP is really not my strong suit - I'm mostly a web dev.
Is your feature request related to a problem? Please describe.
I'm using PostgreSQL JSONB columns because they're fantastic but I have to type them as
String
in the GraphQL Schema.Describe the solution you'd like
It would be nice if we could at a minimum have some kind of
JSON
type or something and have the resolver automatically convert to/from strings/JSON for us, both for queries and mutations?But it could be taken much further, like what is possible using Hasura GraphQL engine.
Describe alternatives you've considered
JSON.parse
andJSON.stringify
calls when dealing with JSONB columns? :frowning: