Skip to content
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

FEATURE: DataObject scaffolding #20

Merged
merged 66 commits into from
Feb 2, 2017
Merged

FEATURE: DataObject scaffolding #20

merged 66 commits into from
Feb 2, 2017

Conversation

unclecheese
Copy link

Per #9

Copy link
Member

@chillu chillu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, massive effort - looking great! Thanks for building in all the sanity checks around YAML configuration, and for creating an expressive system with PHP interfaces. Ran out of steam after 25 comments, will have a second pass tomorrow :)

README.md Outdated
@@ -243,6 +243,305 @@ This will create a new member with an email address,
which you can pass in as query variables: `{"Email": "[email protected]"}`.
It'll return the new `ID` property of the created member.

## Scaffolding DataObjects into the Schema

Making a DataObject accessible through the GraphQL API involves quite a bit of boilerplate. In the above example, we can see that creating endpoints for a query and a mutation requires creating three new classes, along with an update to the configuration, and we haven't even dealt with data relations yet. For applications that require a lot of business logic and specific functionality, an architecture like this affords the developer a lot of control, but for developers who just want to make a given model accessible through GraphQL with some basic Create, Read, Update, and Delete operations, scaffolding them can save a lot of time and reduce the clutter in your project.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you keep markdown line lengths to ~120 chars? Makes diffs easier to read

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you capitalise "Create" etc here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I had just spent the previous eight hours defining ClassNames. ;-)

README.md Outdated

**Code**:
```php
class Post extends DataObject implements ScaffoldingProvider {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing namespace for Post

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was trying to keep things brief, as that was already shown, but yeah, probably better to clarify.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine, but from memory it was inconsistent - some of the example classes had the MyProject namespace

README.md Outdated
$scaffolder->dataObject(Post::class)
->addFields(['ID','Title','Content'])
->query('readPosts', function() {
return Post::get()->limit(10);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need to be updated once Will's pagination PR is merged in - might be follow-up work (not blocking this PR)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed

README.md Outdated
- My\Project\Post
```

### Scaffolding DataObjects through the Config layer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels a bit weird to describe the YAML-based confi here, without actually going into details ("scaffolding goes here"), then switching back to the provider-based docs. I'd prefer to start with YAML scaffolding, since that's what we'd assume is more frequently used?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, will reverse those two paragraphs.

README.md Outdated
])
->setResolver(function($obj, $args) {
$post = Post::get()->byID($args['ID']);
$post->Title = $args['NewTitle'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs canEdit() checks


foreach ($fields as $fieldName) {
$result = $instance->obj($fieldName);
if ($result instanceof DBField) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we deal with getters? Force $casting? Then we should throw an exception if the result is not an instance of DBField

Copy link
Author

@unclecheese unclecheese Dec 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we could try to be smart about it, seeing as there are only four GraphQL field types.

Have updated to:

        foreach ($fields as $fieldName) {
            $result = $instance->obj($fieldName);
            if ($result instanceof DBField) {
                $typeName = $result->config()->graphql_type;
                $fieldMap[$fieldName] = (new TypeParser($typeName))->toArray();
            } else if(is_bool($result)) {
            	$fieldMap[$fieldName] = ['type' => Type::boolean()];
            } else if(is_int($result)) {
            	$fieldMap[$fieldName] = ['type' => Type::int()];
            } else if(is_float($result)) {
            	$fieldMap[$fieldName] = ['type' => Type::float()];
            } else {
            	$fieldMap[$fieldName] = ['type' => Type::string()];
            }
        }

);

$this->setResolver(function ($object, array $args, $context, $info) {
if (singleton($this->dataObjectName)->canDelete()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be called on the actual object, not the singleton

public function __construct($dataObjectName)
{
$this->dataObjectName = $dataObjectName;
$this->args = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this accept a List of IDs by default? It would help with the current case of asset admin batch deletes, and my gut feel is that it's going to be a common requirement in web apps

$obj = DataList::create($this->dataObjectName)
->byID($args['ID']);

if ($obj) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should throw an exception if the object can't be found

$ops[] = constant($constStr);
} else {
throw new \Exception(
"Invalid operation: $op"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention the context here to make it debuggable (e.g. $dataObjectName)

@codecov-io
Copy link

codecov-io commented Dec 18, 2016

Codecov Report

Merging #20 into master will increase coverage by 3.23%.

@@            Coverage Diff             @@
##           master      #20      +/-   ##
==========================================
+ Coverage   87.27%   90.51%   +3.23%     
==========================================
  Files          18       36      +18     
  Lines         566     1402     +836     
==========================================
+ Hits          494     1269     +775     
- Misses         72      133      +61
Impacted Files Coverage Δ
tests/Fake/FakeResolver.php 0% <ø> (ø)
src/Scaffolding/Scaffolders/QueryScaffolder.php 100% <100%> (ø)
tests/Fake/RestrictedDataObjectFake.php 100% <100%> (ø)
src/Scaffolding/Traits/Chainable.php 100% <100%> (ø)
src/Scaffolding/Scaffolders/CRUD/Delete.php 100% <100%> (ø)
tests/Fake/MutationCreatorFake.php 100% <100%> (ø)
tests/Fake/PaginatedQueryFake.php 72.72% <100%> (ø)
src/Scaffolding/Scaffolders/CRUD/Create.php 100% <100%> (ø)
src/Scaffolding/Scaffolders/MutationScaffolder.php 100% <100%> (ø)
src/Scaffolding/Scaffolders/ArgumentScaffolder.php 100% <100%> (ø)
... and 34 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 637f634...fd5d41f. Read the comment docs.

@unclecheese unclecheese force-pushed the feature/scaffolding branch 2 times, most recently from d05717b to ea08f67 Compare December 23, 2016 03:07
}

return $comments;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Daaaaaamn! That's much easier to digest!

* The entry point for a GraphQL scaffolding definition. Holds DataObject type definitions,
* and their nested Mutation/Query definitions.
*/
class GraphQLScaffolder implements ManagerMutatorInterface
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is in the SilverStripe\GraphQL namespace the class should be called Scaffolder instead of GraphQLScaffolder - it's redundant information. Given this will be frequently used to reference constants, a shorter class name is good news as well

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I thought about that, but there's also DataObjectScaffolder, QueryScaffolder, OperationScaffolder, etc. I'm not sure I'm comfortable with it being Scaffolder in that context, but I agree that GraphQL is redundant.

SchemaScaffolder?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah SchemaScaffolder works for me.

namespace SilverStripe\GraphQL;

use SilverStripe\Dev\SapphireTest;
// use GraphQL\Type\Definition\Type;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests need to be commented in again

Copy link
Author

@unclecheese unclecheese Jan 10, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These were written before a massive change in architecture and API, so I commented them out just to get this to a reviewable state. I'm working on the new test suite right now.

README.md Outdated
For these examples, we'll imagine we have the following model:

```php
namespace MyProject\GraphQL;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ORM model classes shouldn't live in a MyProject\GraphQL namespace - in and of themselves, they have nothing to do with GraphQL, and it'll tend to confuse devs (by wrongly assuming there's something special about these models). I'd suggest putting them in a MyProject only (rather than a more verbose MyProject\Model\

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, have changed the namespace to MyProject for everything.

README.md Outdated

**Via YAML**:
```
SilverStripe\GraphQL:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs yaml markdown syntax marking

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

README.md Outdated
fields: [ID, Title, Content]
operations:
read: true
create: true
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't have update: true, yet has an updatePost() query example below

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have changed the example to CreatePost instead

Content
}
}
```
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At this point you probably should describe how the readPosts naming was generated (automatically inferred from singular_name and plural_name)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added an explanation

README.md Outdated
operations:
read:
args:
StartingWith: String
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StartingWith is a bit of a confusing argument example, particularly as a slight variation from the StartsWith ORM filter. Is there a reason you didn't just use Title as the arg name and perform a partial match?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, have changed to PartialMatch.

README.md Outdated
'StartingWith' => 'String'
])
->setResolver(function($obj, $args) {
$list = Post::get();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably shouldn't have any examples without canView() checks, I'd add at least a PHP comment like // ... canView checks. It's too easy for devs to assume that scaffolded resolvers will magically take care of this.

Copy link
Author

@unclecheese unclecheese Jan 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added a simple canView($context['currentMember']) check.

return $this;
}

if ($config['operations'] === '*') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README mentions all, but the code only accepts *.

README.md Outdated
operations: [CREATE, READ, UPDATE, DELETE]
```

> As shorthand, the expression `[CREATE, READ, UPDATE, DELETE]` can also be expressed as `operations: all`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is: Why isn't there a GraphQLScaffolder::ALL constant as well? Constants are a way to communicate type strictness and avoid misspelling. Also, the README mentions all, but the code only accepts *. Can we just make this GraphQLScaffolder::ALL?

Copy link
Member

@chillu chillu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another slew of nitpicks! ;)

@chillu
Copy link
Member

chillu commented Jan 10, 2017

As a general note, all GraphQL fields should start lowercase, to distinguish them from types starting with uppercase. This clashes with our SilverStripe conventions (DataObject properties start with uppercase). I've written https://github.com/silverstripe/silverstripe-graphql/blob/master/src/Util/CaseInsensitiveFieldAccessor.php for this purpose. Doesn't necessarily need to be solved in this PR, but we need to address it soon (for query args and query/type fields)

if (is_subclass_of($resolver, ResolverInterface::class)) {
$this->resolver = Injector::inst()->create($resolver);
} else {
var_dump($resolver);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug code

*/
public function __construct($rawArg)
{
if (!preg_match('/^([A-Za-z]+)(!?)(\s*=\s*(.*))?/', $rawArg, $matches)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Protip: You can use a non-capturing group in regexes to avoid leaving out $matches[3] here: ^([A-Za-z]+)(!?)(?:\s*=\s*(.*))?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I had been wondering if that was possible. Thanks.


// Todo: this is totally half baked
$this->setResolver(function ($object, array $args, $context, $info) {
if (singleton($this->dataObjectClass)->canCreate()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All can*() checks should pass in $context['currentMember'] - an attempt to decrease the dependence of globals throughout the framework (Session dependency in DataObject::can*()). At the moment it's just future proofing, but it's pretty much always clearer to pass in context explicitly

{
public function resolve($object, $args, $context, $info)
{
$post = Post::get()->byID($args['ID']);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs canEdit() check

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the CRUD operations now have permission checks.

README.md Outdated
->setResolver(MyResolver::class)
->end();
```
Or...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just add the second variation as an inline comment on the first code example, for brevity's sake

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK

README.md Outdated
```

```
mutation UpdatePost($ID: ID!, $Input: PostUpdateInputType!)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mention somewhere around here that permission constraints are enforced by the built-in resolvers

README.md Outdated

**GraphQL**
```
mutation updatePostTitle(ID: 123, NewTitle: 'Foo') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing $ in args

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

}

mutation createRedirectorPage {
RedirectorPageCreateInputType
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you missing those input types in your type list above?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Have added them.

parent::__construct($operationName, $this->typeName());

$this->setResolver(function ($object, array $args, $context, $info) {
$list = DataList::create($this->dataObjectClass);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this filter by canView()?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but you have to use filterByCallback if you want to be thorough. Otherwise, a simple check against a singleton could work.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This callback would need to comply with the OperationResolver interface right? $info would need a ResolveInfo type hint

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm... If you passed it a named class, or an instance, it has to implement OperationResolver, but AFAIK, there's no way to enforce an argument signature on a closure.

@unclecheese unclecheese force-pushed the feature/scaffolding branch 2 times, most recently from 08a9573 to db22c0d Compare January 11, 2017 01:23
src/Manager.php Outdated
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Error;
use GraphQL\Type\Definition\Type;
use SilverStripe\Security\Member;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Aaron - you'll need this back in again, it's used for the member methods at the bottom - I think this is why your tests are failing ATM

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have replaced this.

@unclecheese unclecheese force-pushed the feature/scaffolding branch 2 times, most recently from 40ea1f9 to e4be8ff Compare January 14, 2017 20:19
@chillu chillu force-pushed the feature/scaffolding branch from e4be8ff to 406b770 Compare January 15, 2017 09:38
@chillu chillu added this to the CMS 4.0.0-alpha5 milestone Jan 18, 2017
@tractorcow
Copy link
Contributor

Some weird rebasing going on here. :P

Copy link
Contributor

@tractorcow tractorcow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reviewed this from a code-style and ORM usage perspective. I'm going to assume that @chillu has / will covered the functional review and is happy with the graphql API usage.

@@ -12,3 +12,46 @@ SilverStripe\GraphQL:
authenticators:
- class: SilverStripe\GraphQL\Auth\BasicAuthAuthenticator
priority: 10
scaffolding_providers:
- My\Project\Post
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the significance of My\Project\Post?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in the examples/ directory, so this is just an example of how you would set up your config with registered scaffolding providers.

@@ -82,7 +82,7 @@ public function getAttributes()
$attributes['type'] = $type;
}

$resolver = $this->getResolver();
$resolver = $this->getResolver();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note: We need to run phpcbf over this project to fix all extraneous whitespaces and formatting errors.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have done.

/**
* @var string
*/
protected $template = 'GraphiQL';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From 4.0 onwards best practice is to namespace templates, please. :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even better, just make the template name GrahpiQLController.ss, put it in the correct folder (templates\SilverStripe\GraphQL), and you don't need to declare the config at all.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this feature was supposed to be deleted in this 4e3a325 but at some point a rebase got stuffed.

@@ -2,28 +2,18 @@

namespace SilverStripe\GraphQL;

use GraphQL\Type\Definition\InterfaceType;
use GraphQL\Type\Definition\InterfaceType as BaseInterfaceType;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alias isn't necessary here.

src/Manager.php Outdated
* Execute an arbitrary operation (mutation / query) on this schema
* @param string $query
* @param array $params
* @param null $schema
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalid phpdoc; Should be at least one non-null arg type.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, phpdoc description getting deleted.

/**
* OperationScaffolder constructor.
*
* @param null $operationName
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use a non-null param type descriptor (and elsewhere).

}
foreach($config['args'] as $argName => $argData) {
if(is_array($argData)) {
if(!isset($argData['type'])) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation is a bit weird.

* @param string $class
* @param null $resolver
*
* @return bool|QueryScaffolder
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't seem to return any bool does it?

*/
public static function typeNameForDataObject($class)
{
$typeName = Config::inst()->get($class, 'table_name', Config::UNINHERITED) ?:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. :)

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html lang="en">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed earlier, please move to namespaced folder matching the controller FQN.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't even be here. It's an artefact of the bad rebasing you were referring to.

->end()
->type('Page')
->addFields(['BackwardsTitle'])
->end();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be good to have this indented to retain some sanity ;)

@chillu chillu merged commit 3e77ff4 into master Feb 2, 2017
@chillu chillu deleted the feature/scaffolding branch February 2, 2017 10:29
@sminnee
Copy link
Member

sminnee commented Feb 2, 2017

Woohoo! Great to see this merged

unclecheese pushed a commit to open-sausages/silverstripe-graphql that referenced this pull request Jan 9, 2018
unclecheese pushed a commit to unclecheese/silverstripe-graphql that referenced this pull request Jan 27, 2021
…ocus-transform

Add an autoFocus property to the Form
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants