Skip to content

How to handle Postgres JSONB? #954

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
sustained opened this issue Sep 10, 2019 · 19 comments
Closed

How to handle Postgres JSONB? #954

sustained opened this issue Sep 10, 2019 · 19 comments
Labels
discussion Requires input from multiple people

Comments

@sustained
Copy link

sustained commented Sep 10, 2019

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

  • Excessive JSON.parse and JSON.stringify calls when dealing with JSONB columns? 😦
  • Just using Hasura instead.
@lorado
Copy link
Collaborator

lorado commented Sep 10, 2019

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: "[email protected]")
     * }
     *
     * @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
Copy link
Collaborator

lorado commented Sep 10, 2019

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
Copy link
Author

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
Copy link
Collaborator

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
Copy link
Collaborator

lorado commented Sep 11, 2019

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
Copy link
Collaborator

lorado commented Sep 11, 2019

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
Copy link
Author

@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
Copy link
Collaborator

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
Copy link
Author

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
Copy link
Collaborator

lorado commented Sep 11, 2019

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
Copy link
Collaborator

Nice!

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

@DonovanChan
Copy link

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

@spawnia spawnia added the discussion Requires input from multiple people label Mar 12, 2020
@enzonotario
Copy link
Collaborator

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
Copy link

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
Copy link
Collaborator

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
Copy link

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
Copy link
Collaborator

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
Copy link

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
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion Requires input from multiple people
Projects
None yet
Development

No branches or pull requests

5 participants