nuwave / lighthouse

A framework for serving GraphQL from Laravel
https://lighthouse-php.com
MIT License
3.36k stars 437 forks source link

How to handle Postgres JSONB? #954

Closed sustained closed 5 years ago

sustained commented 5 years ago

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

lorado commented 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
}
lorado commented 5 years ago

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.

sustained commented 5 years ago

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?

enzonotario commented 5 years ago

There is a package that implements a few types (from one of Lighthouse's contributors, Spawnia): https://github.com/mll-lab/graphql-php-scalars

lorado commented 5 years ago

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

lorado commented 5 years ago

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?

sustained commented 5 years ago

@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.

image

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?

enzonotario commented 5 years ago

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.

sustained commented 5 years ago

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.

lorado commented 5 years ago

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

enzonotario commented 5 years ago

Nice!

Let me close this since it seems to be solved. Feel free to re open if needed!

DonovanChan commented 4 years ago

@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.

enzonotario commented 4 years ago

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;
        });
    }
}
DonovanChan commented 4 years ago

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
            }
          }
        }
      ]
    }
  }
}
enzonotario commented 4 years ago

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.

DonovanChan commented 4 years ago

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!

enzonotario commented 4 years ago

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?

DonovanChan commented 4 years ago

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");
DonovanChan commented 4 years ago

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;
            }
        );
    }
}