From 716f939362ee2816961a4a83c5d0d7b194b4d09a Mon Sep 17 00:00:00 2001 From: Darrell Warde <8117355+darrellwarde@users.noreply.github.com> Date: Tue, 10 Aug 2021 09:13:44 +0100 Subject: [PATCH] Merge 2.0.0 into master (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Relationship properties - connection fields with filtering and sorting (#205) * Schema generation and Cypher translation for basic relationship property queries using GraphQL cursor connections Somewhat TCK tested, but still requires integration tests * Connection fields now returned for both queries and mutations An array of new TCK tests validating the new filtering code and projections of connections * Relationship Properties Union Support (#215) * Schema generation and Cypher translation for basic relationship property queries using GraphQL cursor connections Somewhat TCK tested, but still requires integration tests * Connection fields now returned for both queries and mutations An array of new TCK tests validating the new filtering code and projections of connections * Union support * Few loose ends for unions with relationship properties (#218) * Feat/relationship-properties-create (#219) * test: add inital for creating rel properties * feat: add inital for creating rel properties * feat: get unions working with nested create * test: fix ogm tests to include nested create changes * Relationship property update operations (#222) * Schema generation and Cypher translation for basic relationship property queries using GraphQL cursor connections Somewhat TCK tested, but still requires integration tests * Connection fields now returned for both queries and mutations An array of new TCK tests validating the new filtering code and projections of connections * Union support * A couple of loose ends for unions - __resolveType and filtering * Merging 2.0.0 branch is for some reason reverting some of my changes, trying to fix * Make breaking schema changes and fix tests * Update relationship properties, validated by TCK tests * Integration tests for update operations * Feat/relationship-properties-connect (#224) * test: add cases for connecting with rel properties * feat: add logic for connecting with rel properties * fix: change properties set to use the new appointed name * refactor: only add update with when needed * test: update all breaking tests due to changes in rel properties connect * feat: add union connect support * test: union connect support * Feat/composite-where-on-delete-and-disconnect (#239) * test: add composite where without unions and auth Co-authored-by: Darrell Warde * feat: added union support for composite where Co-authored-by: Darrell Warde * config: add husky back Co-authored-by: Darrell Warde * Fix bug in code merged in from master * Update version and mark prerelease * Bring 2.0.0 in line with master (#250) * Update neo-push-server to enforce nodemon uses local ts-node install (force rerun cla check) * Fix typos * refactor: add more debug logs for get jwt Co-authored-by: Evan Reed Co-authored-by: Matt Murphy <63432827+mattmurph9@users.noreply.github.com> Co-authored-by: Daniel Starns * Documentation for 2.0.0 (#242) * Beginning of documentation for relationship properties * Basic migration guide (navigation a little broken) * Continued work on 2.0.0 docs * Fixed headers not working for new pages (page keys cannot begin with a number) * Remove node from where clause of connect * A couple of outstanding documentation tasks * Highlight that req must be passed into context, add lambda edge case * Clarify Point and CartesianPoint usage in docs * Relationship propery enum support * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * General 2.0.0 housekeeping (#255) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * Bring 2.0.0 in line with master (#264) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Fixed nullability of Query return type * Update code comment * refactor: remove neoSchema from OGM properties (#256) * feat: add cypherParams (#254) * Use upperFirst from graphql-compose in all cases (#262) * Remove upper-case-first and our own util function from @neo4j/graphql - use upperFirst from graphql-compose * Re-export graphql-compose upperFirst from @neo4j/graphql for use in @neo4j/graphql-ogm, remove upper-case-first dependency * Fix TCK tests broken from merge Co-authored-by: Daniel Starns * Version update * Update index.adoc Fix broken link * Update relationships.adoc Fix typo, relationship properties type should be interface * Update filtering.adoc Add missing word, and change phrasing * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * Add scalars earlier to fix DateTime relationship properties, and merge changes from master (#272) * Potential fix for the problem serializing Int/Float values * Fix bug in previous bugfix code related to this fix * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Fixed nullability of Query return type * Update code comment * refactor: remove neoSchema from OGM properties (#256) * feat: add cypherParams (#254) * Quick fix for custom type resolvers on unions. Prevents generated resolvers from being overwritten with __resolveType. * Use upperFirst from graphql-compose in all cases (#262) * Remove upper-case-first and our own util function from @neo4j/graphql - use upperFirst from graphql-compose * Re-export graphql-compose upperFirst from @neo4j/graphql for use in @neo4j/graphql-ogm, remove upper-case-first dependency * Fix TCK tests broken from merge * update: documentation * Update filtering.adoc Add missing word and change phrasing * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master Co-authored-by: Daniel Starns Co-authored-by: Dan Gerard Co-authored-by: dmoree * Version update * Fix exact match when checking for existing scalars “Date” would otherwise match “scalar DateTime”. * Add Date type and map it to Neo4j Date * Fix for projecting connection fields from relationship fields (#284) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * Fix projecting connection field from a relationship field * Feat/Connection Auth (#286) * test: add coverage for connections auth * feat: add connections auth * test: add coverage for auth where and user where * Pagination Cursors on Connections (Relay Specification) (#282) * skip and limit on connections * add pageinfo and pagecursor definitions * add totalCount, return connections * add skip, limit on unions, make all tests pass * add global node resolution, green on tests * add copyright header to connection.ts * minor cleanup * cleanup * remove before/last arguments; move pagination arguments into options * use one consistent node interface definition * remove cursor Scalar * use isInt on totalCount check in make-augmented-schema connection resolver * remove redundant arraySlice check in createConnectionWithEdge properties * remove Node global resolution from this pr * remove erroneous yalc stuff, formatting cleanup * hoist connection args to field level, fix tests from merge, simplify connection cursor function * integration test for pagination, fix skipLimitStr * add pagination helper tests, fix off-by-one error in cursor calculation * remove erroneous console * move pagination tests * Add union relationship where (#291) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * Add top-level where for union relationship fields * Add "node" level to connect where (#290) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * Remove seemingly redundant input type * Add "node level" to connect where * Update other tests, and documentation * Only return union members which have fragments in the selection set (#289) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * Only return union members of which a fragment is present in the selection set * Fix test broken from merge * Refactor/rename skip to offset (#294) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * Rename skip to offset * Nested update argument change (#295) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * Update structure of nested updates * Update documentation with new update structure * Merge most recent changes from master into 2.0.0 (#306) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * fix(jwt): req.cookies might be undefined this fix prevents the app from crashing id req.cookies is undefined * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * fix: use package json for useragent name and version (#271) * fix: use package json for useragent name and version * fix: add userAgent support for <=4.2 and >=4.3 drivers * config: remove codeowners (#277) * Version update * fix(login): avoid confusion caused by secondary button (#265) * fix: losing params while creating Auth Predicate (#281) * fix: loosing params while creating Auth Predicate * fix: typos * fix: typo * feat: add projection to top level cypher directive (#251) * feat: add projection to top level queries and mutations using cypher directive * fix: add missing cypherParams * Fix for loss of scalar and field level resolvers (#297) * wrapCustomResolvers removed in favour of schema level resolver auth injection * Add test cases for this fix * Mention double escaping for @cypher directive * Version update Co-authored-by: gaspard Co-authored-by: Oskar Hane Co-authored-by: Daniel Starns Co-authored-by: Neo Technology Build Agent Co-authored-by: Arnaud Gissinger <37625778+mathix420@users.noreply.github.com> * Refactor/union arguments (#304) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * fix(jwt): req.cookies might be undefined this fix prevents the app from crashing id req.cookies is undefined * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * fix: use package json for useragent name and version (#271) * fix: use package json for useragent name and version * fix: add userAgent support for <=4.2 and >=4.3 drivers * config: remove codeowners (#277) * Version update * fix(login): avoid confusion caused by secondary button (#265) * fix: losing params while creating Auth Predicate (#281) * fix: loosing params while creating Auth Predicate * fix: typos * fix: typo * feat: add projection to top level cypher directive (#251) * feat: add projection to top level queries and mutations using cypher directive * fix: add missing cypherParams * Fix for loss of scalar and field level resolvers (#297) * wrapCustomResolvers removed in favour of schema level resolver auth injection * Add test cases for this fix * Mention double escaping for @cypher directive * Version update * checkpoint: commit all changes to date - NOT WORKING * Committing before merging in 2.0.0 changes * Union connect and test needs fixing * Add .huskyrc back * Reformat schema TCK tests for better diff * Reorganise schema TCK for better diff * Create union input types in a map * Various work, including nested connects and disconnects for unions, fixing a variety of bugs * Documentation changes * Fix structure of nested creates for unions, and add tests for nested union mutations * Fix where input for nested update * Add integration tests for multiple union create/update Co-authored-by: gaspard Co-authored-by: Oskar Hane Co-authored-by: Daniel Starns Co-authored-by: Neo Technology Build Agent Co-authored-by: Arnaud Gissinger <37625778+mathix420@users.noreply.github.com> * Merge changes from master into 2.0.0 (#309) * Missing copyright headers * Point to prerelease documentation for 2.0.0 branch * General housekeeping before release * Update code comment * Fix TCK tests broken from merge * fix(jwt): req.cookies might be undefined this fix prevents the app from crashing id req.cookies is undefined * Add scalars earlier in schema augmentation for use in types and interfaces without throwing Error (fixes DateTime relationship properties) * Changes to accomodate merge from master * fix: use package json for useragent name and version (#271) * fix: use package json for useragent name and version * fix: add userAgent support for <=4.2 and >=4.3 drivers * config: remove codeowners (#277) * Version update * fix(login): avoid confusion caused by secondary button (#265) * fix: losing params while creating Auth Predicate (#281) * fix: loosing params while creating Auth Predicate * fix: typos * fix: typo * feat: add projection to top level cypher directive (#251) * feat: add projection to top level queries and mutations using cypher directive * fix: add missing cypherParams * Fix for loss of scalar and field level resolvers (#297) * wrapCustomResolvers removed in favour of schema level resolver auth injection * Add test cases for this fix * Mention double escaping for @cypher directive * Version update * Allows users to pass in decoded JWT (#303) * Allows users to pass in decoded JWT - needs more testing * More tests for decoded JWTs * Updates to auth documentation * Fix relationships documentation examples (#296) * Version update Co-authored-by: gaspard Co-authored-by: Oskar Hane Co-authored-by: Daniel Starns Co-authored-by: Neo Technology Build Agent Co-authored-by: Arnaud Gissinger <37625778+mathix420@users.noreply.github.com> * Selectively project union members * Replace instances of "properties" with "relationship" * Fix errors when no sorting available on related nodes * Add tests for #288 which previously didn't work due to this bug * Fixed tests broken after merge * Merge 1.2.1 changes from master into 2.0.0 (#319) * Fix for bug, caused by returning a union * Docs media (#310) * config: remove codeowners * docs: add some media links * docs: change links to list * Update README.md Capitalise NODES * docs: add wills talk to list Co-authored-by: Darrell Warde <8117355+darrellwarde@users.noreply.github.com> * Version update * Fixed tests broken after merge Co-authored-by: Daniel Starns Co-authored-by: Neo Technology Build Agent * refactor: change union where on connections (#317) * refactor: change union where on connections * Merge changes from 2.0.0 into union-where * test: add coverage for using relationship on union where * refactor: remove temp file * test: format and add relationship and node coverage * Jwt type (#321) * config: remove codeowners * refactor: type change to boolean * Refactor/tck test formatting (#323) * Fix for bug, caused by returning a union * Docs media (#310) * config: remove codeowners * docs: add some media links * docs: change links to list * Update README.md Capitalise NODES * docs: add wills talk to list Co-authored-by: Darrell Warde <8117355+darrellwarde@users.noreply.github.com> * Version update * Fixed tests broken after merge * Refactor headings and add markdownlint config to allow nested heading re-use * Use code block tags which allow formatting * Replace ```schema with ```graphql * Slight markdownlint config tweak * Merge branch '2.0.0' into refactor/tck-test-formatting Co-authored-by: Daniel Starns Co-authored-by: Neo Technology Build Agent * Version update * Add Connection where field to node where types Co-authored-by: Daniel Starns * Added more test cases * Ensure that all combinations of connection projections work without error * Remove commented code * Make startCursor and endCursor nullable * fix: relationship cypher params added for create * update: integration test for update -> create * fix: use correct property for proper translation * update: integration test for update -> update * fix: formatting * Feat/Count queries (#329) * test: add init coverage for count * feat: add count queries * test: changes to existing for count queries * docs: add count queries * docs: remove misleading comment on count * refactor: change to xCount over countX * feat: add count to ogm * Align naming of "issue tests" * Version update * Manually add schemaDirectives after resolvers * Add missing copyright header * Remove leftover comment, add some explanation to order * A new approach for deciding which union members to return! * Validation of Type Definitions (#300) * docs: add aura flag apoc * fix: temporarily disable validateTypeDefs Co-authored-by: Darrell Warde * docs: duplicate doc text * docs: remove auth aura flag * docs: add license to docs and readmes * docs: wrong licence link * fix: validate document * refactor: remove compose from validate document * test: add the correct issue test cases * refactor: remove checkNodeImplementsInterfaces * test: add case for github issue * Working solution for type definition validation * Allow for skipping of validation for some edge cases such as using our internally generated types * Add tests that demonstrate errors will be thrown if using "protected" types * Filter out internel input type names during validation so that they can be used without error * Add test to demonstrate that directives cannot be redefined * Make suggested changes from PR Co-authored-by: Daniel Starns Co-authored-by: Darrell Warde * Remove redundant comments * Rename points.md to point.md * Add missing types to where for relationship properties * Add missing Date filters * Test that Date and Boolean relationship property filters added to schema * Added Cypher TCK test for filtering by relationship property temporal values * Allow OGM selectionSet to be SelectionSetNode * Dependency patch upgrades * Upgrade graphql-compose to 8.1.0 * graphql-compose to 9.x and fix results of breaking changes * Fix/issue 369 (#370) * fix: issue #369 connections using cypher * test: coverage for issue #369 * Alias connections (#368) * fix: alias connections * test: alias connections * refactor: change to source and info * test: add a case with many alias * 2.0.0 documentation updates (#366) * Docs changes * Add unions page back to migration guide * Document union where changes in migration guide * Move custom resolvers page down * Ensure contents in type definitions map to underlying pages * Documentation changes from editorial comments, aligning frontpage with other products * Missing summaries in contents * Restructure following editorial comments * Split out each Mutation operation into separate chapters * Rewrite of getting started guide, with more examples and screenshots * Add type definitions basics page * Add documentation for all GraphQL types * Fix order of type definitions pages * Rewrite introduction page with features, interacting and deployment section * Change documentation for interfaces, highlighting that they are used for relationship properties * Fix references to pagination * Continued rewriting and restructuring * Rework auth content * Overhaul API references for both Neo4jGraphQL and OGM * Changes reflecting @danstarns comments * Version update * Remove duplicate sentence * Fix OGM contents * Remove ; and & * Filter out nested input types during validation * Change minimum database version to 4.1.5 * Fix validation of database versions using semver package * Don't list temporal and spatial types on filtering page - could fall out of date * Error auth relationship properties (#379) * feat: throw error when auth directive is used on relationship props * test: when auth directive is used on relationship props * Add a simple util file which can be called with ts-node to clear down database before/after running integration tests * Add comment explaining teardown file * Error relationship on relationship properties (#381) * feat: throw error when relationship directive is used on relationship props * test: when relationship directive is used on relationship props * Coerce Aura database version numbers * Refactor/unify relationship fields (#383) * feat: throw error when relationship directive is used on relationship props * test: when relationship directive is used on relationship props * refactor: unify relationship properties fields and reuse existing functions * test: add for cypher on interface * Rough find/replace attempt at passing last bookmark into GraphQL execution * Treat custom scalars, enums and primitives the same for custom Cypher fields * Also fix custom scalars for Query-level custom Cypher * Style guidance - remove instances of "whilst" * Remove "Step x" from Getting Started, change to second person * Attempt to change all wording to second person * refactor: change schema references from relationship to edge (#389) * refactor: change schema references from relationship to edge * docs: add note about relationship and edges * Update relationships.adoc Rephrase sentence to be passive * feat: export auth errors from index (#394) * Reserved properties (#396) * feat: throw error when using reserved names * test: error when using reserved names * docs: remove useage of Node type * refactor: changes from PR comments * Version update * Fix multiple connections being returned from custom Cypher * Remove _IN and _NOT_IN filters for relationships * Remove translation for surplus _IN and _NOT_IN * Remove prerelease marker * refactor: use alias for params and name for fields * add: int test for nested connections and aliasing * add: tck test for multiple aliasing on connections Co-authored-by: Daniel Starns Co-authored-by: Darrell Warde Co-authored-by: Neil Dewhurst Co-authored-by: Evan Reed Co-authored-by: Matt Murphy <63432827+mattmurph9@users.noreply.github.com> Co-authored-by: Neo Technology Build Agent Co-authored-by: Dan Gerard Co-authored-by: dmoree Co-authored-by: Oskar Hane Co-authored-by: nsethi Co-authored-by: gaspard Co-authored-by: Arnaud Gissinger <37625778+mathix420@users.noreply.github.com> Co-authored-by: dmoree Co-authored-by: Neo Technology Build Agent --- README.md | 2 +- docs/antora/antora.yml | 2 +- docs/antora/content-nav.adoc | 49 +- docs/asciidoc/api-reference.adoc | 41 - docs/asciidoc/api-reference/index.adoc | 5 + docs/asciidoc/api-reference/neo4jgraphql.adoc | 194 ++++ docs/asciidoc/api-reference/ogm.adoc | 4 + docs/asciidoc/auth/auth-directive.adoc | 84 ++ docs/asciidoc/auth/authentication.adoc | 85 +- docs/asciidoc/auth/authorization/allow.adoc | 50 +- docs/asciidoc/auth/authorization/bind.adoc | 49 +- docs/asciidoc/auth/authorization/index.adoc | 5 + docs/asciidoc/auth/authorization/roles.adoc | 15 +- docs/asciidoc/auth/authorization/where.adoc | 16 +- docs/asciidoc/auth/index.adoc | 14 +- docs/asciidoc/auth/setup.adoc | 84 +- docs/asciidoc/custom-resolvers.adoc | 34 +- docs/asciidoc/directives.adoc | 2 +- docs/asciidoc/driver-and-config.adoc | 93 -- docs/asciidoc/driver-configuration.adoc | 257 +++++ docs/asciidoc/{schema => }/filtering.adoc | 13 +- docs/asciidoc/getting-started.adoc | 208 +++- .../guides/2.0.0-migration/index.adoc | 20 + .../guides/2.0.0-migration/miscellaneous.adoc | 89 ++ .../guides/2.0.0-migration/mutations.adoc | 363 ++++++ .../guides/2.0.0-migration/unions.adoc | 134 +++ docs/asciidoc/guides/index.adoc | 1 + .../guides/migration-guide/mutations.adoc | 10 +- .../guides/migration-guide/queries.adoc | 4 +- .../guides/migration-guide/server.adoc | 2 +- .../migration-guide/type-definitions.adoc | 70 +- docs/asciidoc/index.adoc | 63 +- docs/asciidoc/introduction.adoc | 79 ++ docs/asciidoc/mutations.adoc | 4 - docs/asciidoc/mutations/create.adoc | 98 ++ docs/asciidoc/mutations/delete.adoc | 114 ++ docs/asciidoc/mutations/index.adoc | 18 + docs/asciidoc/mutations/update.adoc | 101 ++ docs/asciidoc/ogm/api-reference.adoc | 59 - docs/asciidoc/ogm/api-reference/index.adoc | 5 + .../ogm/api-reference/model/count.adoc | 35 + .../ogm/api-reference/model/create.adoc | 48 + .../ogm/api-reference/model/delete.adoc | 57 + .../ogm/api-reference/model/find.adoc | 62 + .../ogm/api-reference/model/index.adoc | 22 + .../ogm/api-reference/model/update.adoc | 76 ++ docs/asciidoc/ogm/api-reference/ogm.adoc | 48 + .../ogm/examples/custom-resolvers.adoc | 137 +++ docs/asciidoc/ogm/examples/index.adoc | 7 + docs/asciidoc/ogm/examples/rest-api.adoc | 85 ++ docs/asciidoc/ogm/getting-started.adoc | 66 -- docs/asciidoc/ogm/index.adoc | 30 +- docs/asciidoc/ogm/installation.adoc | 22 + docs/asciidoc/ogm/methods/create.adoc | 30 - docs/asciidoc/ogm/methods/delete.adoc | 29 - docs/asciidoc/ogm/methods/find.adoc | 56 - docs/asciidoc/ogm/methods/index.adoc | 11 - docs/asciidoc/ogm/methods/update.adoc | 45 - docs/asciidoc/ogm/private.adoc | 2 +- docs/asciidoc/ogm/selection-set.adoc | 97 +- docs/asciidoc/pagination/cursor-based.adoc | 89 ++ docs/asciidoc/pagination/index.adoc | 7 + .../offset-based.adoc} | 32 +- docs/asciidoc/queries.adoc | 92 +- docs/asciidoc/schema/index.adoc | 10 - docs/asciidoc/schema/mutations.adoc | 290 ----- docs/asciidoc/schema/queries.adoc | 69 -- docs/asciidoc/{schema => }/sorting.adoc | 6 +- docs/asciidoc/troubleshooting/faqs.adoc | 16 + .../index.adoc} | 5 +- docs/asciidoc/type-definitions/basics.adoc | 61 + docs/asciidoc/type-definitions/cypher.adoc | 10 +- docs/asciidoc/type-definitions/index.adoc | 38 +- .../type-definitions/relationships.adoc | 116 +- docs/asciidoc/type-definitions/types.adoc | 96 +- .../unions-and-interfaces.adoc | 147 ++- docs/docbook/content-map.xml | 147 ++- docs/images/apollo-server-landing-page.png | Bin 0 -> 89111 bytes docs/images/first-mutation.png | Bin 0 -> 81594 bytes docs/images/first-query.png | Bin 0 -> 64460 bytes docs/images/relationships.png | Bin 29839 -> 0 bytes docs/images/relationships.svg | 55 + examples/migration/package.json | 2 +- examples/neo-push/README.md | 23 +- .../neo-push/client/src/components/Blog.tsx | 12 +- .../client/src/components/Dashboard.tsx | 24 +- .../neo-push/client/src/components/Post.tsx | 12 +- examples/neo-push/client/src/queries.ts | 36 +- examples/neo-push/server/package.json | 4 +- examples/neo-push/server/src/seeder.ts | 6 +- .../integration/graphql/blog-auth.test.ts | 2 +- .../integration/graphql/comment-auth.test.ts | 4 +- .../integration/graphql/post-auth.test.ts | 6 +- .../integration/graphql/workflow.test.ts | 10 +- packages/graphql/README.md | 14 +- packages/graphql/package.json | 22 +- packages/graphql/src/classes/Neo4jGraphQL.ts | 32 +- packages/graphql/src/classes/Node.ts | 5 + packages/graphql/src/classes/Relationship.ts | 79 ++ packages/graphql/src/classes/index.ts | 1 + packages/graphql/src/constants.ts | 21 +- packages/graphql/src/index.ts | 7 +- .../check-node-implements-interface.test.ts | 144 --- .../check-node-implements-interfaces.ts | 74 -- packages/graphql/src/schema/get-auth.ts | 12 +- .../graphql/src/schema/get-obj-field-meta.ts | 42 +- .../src/schema/get-relationship-meta.ts | 10 +- .../graphql/src/schema/get-where-fields.ts | 104 ++ .../src/schema/make-augmented-schema.test.ts | 304 ++++- .../src/schema/make-augmented-schema.ts | 1003 ++++++++++++---- .../graphql/src/schema/pagination.test.ts | 151 +++ packages/graphql/src/schema/pagination.ts | 159 +++ packages/graphql/src/schema/point.ts | 95 +- .../ID.ts => resolvers/count.test.ts} | 36 +- .../graphql/src/schema/resolvers/count.ts | 45 + .../graphql/src/schema/resolvers/cypher.ts | 54 +- .../graphql/src/schema/resolvers/index.ts | 7 +- packages/graphql/src/schema/scalars/index.ts | 1 - packages/graphql/src/schema/to-compose.ts | 33 +- .../src/schema/validation/directives.ts | 4 + .../graphql/src/schema/validation/index.ts | 70 +- .../validation/validate-document.test.ts | 439 +++++++ .../schema/validation/validate-document.ts | 117 ++ .../create-connection-and-params.test.ts | 298 +++++ .../create-connection-and-params.ts | 418 +++++++ .../translate/create-auth-and-params.test.ts | 8 +- .../src/translate/create-auth-and-params.ts | 6 +- .../create-connect-and-params.test.ts | 27 +- .../translate/create-connect-and-params.ts | 89 +- .../src/translate/create-create-and-params.ts | 124 +- .../src/translate/create-delete-and-params.ts | 167 +-- .../create-disconnect-and-params.test.ts | 18 +- .../translate/create-disconnect-and-params.ts | 79 +- .../create-projection-and-params.test.ts | 1 + .../translate/create-projection-and-params.ts | 155 ++- ...-set-relationship-properties-and-params.ts | 85 ++ .../create-set-relationship-properties.ts | 80 ++ .../create-update-and-params.test.ts | 1 + .../src/translate/create-update-and-params.ts | 370 +++--- .../src/translate/create-where-and-params.ts | 180 +-- packages/graphql/src/translate/index.ts | 1 + .../elements/create-datetime-element.test.ts | 77 ++ .../elements/create-datetime-element.ts | 37 + .../elements/create-point-element.test.ts | 103 ++ .../elements/create-point-element.ts | 52 + ...eate-relationship-property-element.test.ts | 183 +++ .../create-relationship-property-element.ts | 48 + .../graphql/src/translate/translate-count.ts | 83 ++ .../graphql/src/translate/translate-create.ts | 48 +- .../graphql/src/translate/translate-delete.ts | 9 +- .../graphql/src/translate/translate-read.ts | 33 +- .../graphql/src/translate/translate-update.ts | 194 +++- .../create-connection-where-and-params.ts | 108 ++ .../src/translate/where/create-filter.test.ts | 83 ++ .../src/translate/where/create-filter.ts | 59 + .../where/create-node-where-and-params.ts | 237 ++++ .../create-relationship-where-and-params.ts | 181 +++ packages/graphql/src/types.ts | 39 +- .../src/utils/get-neo4j-resolve-tree.ts | 10 +- .../graphql/src/utils/verify-database.test.ts | 107 +- packages/graphql/src/utils/verify-database.ts | 18 +- .../advanced-filtering.int.test.ts | 667 ++++++++--- .../auth/allow-unauthenticated.int.test.ts | 106 +- .../tests/integration/auth/allow.int.test.ts | 194 +++- .../tests/integration/auth/bind.int.test.ts | 36 +- .../auth/custom-cypher.int.test.ts | 6 +- .../auth/is-authenticated.int.test.ts | 32 +- .../integration/auth/object-path.int.test.ts | 4 +- .../tests/integration/auth/roles.int.test.ts | 44 +- .../tests/integration/auth/where.int.test.ts | 208 +++- .../integration/autogenerate.int.test.ts | 6 +- .../integration/composite-where.int.test.ts | 200 ++++ .../connection-resolvers-int.test.ts | 417 +++++++ .../integration/connections/alias.int.test.ts | 1027 +++++++++++++++++ .../integration/connections/enums.int.test.ts | 138 +++ .../connections/nested.int.test.ts | 198 ++++ .../connections/unions.int.test.ts | 363 ++++++ .../tests/integration/count.int.test.ts | 279 +++++ .../tests/integration/create.int.test.ts | 24 +- .../integration/custom-directives.int.test.ts | 2 +- .../integration/custom-resolvers.int.test.ts | 12 +- .../tests/integration/cypher.int.test.ts | 119 +- .../integration/default-values.int.test.ts | 6 +- .../tests/integration/delete.int.test.ts | 73 +- .../tests/integration/enums.int.test.ts | 2 +- .../integration/field-filtering.int.test.ts | 115 ++ .../tests/integration/find.int.test.ts | 14 +- .../tests/integration/floats.int.test.ts | 8 +- .../integration/ignore-directive.int.test.ts | 2 +- .../tests/integration/issues/190.int.test.ts | 4 +- .../tests/integration/issues/207.int.test.ts | 2 +- .../tests/integration/issues/235.int.test.ts | 6 +- .../tests/integration/issues/247.int.test.ts | 2 +- .../tests/integration/issues/283.int.test.ts | 2 +- .../tests/integration/issues/288.int.test.ts | 108 ++ .../tests/integration/issues/315.int.test.ts | 118 +- .../tests/integration/issues/326.int.test.ts | 8 +- ...d-requests.int.test.ts => 330.int.test.ts} | 0 .../tests/integration/issues/349.int.test.ts | 215 ++++ .../tests/integration/issues/350.int.test.ts | 4 +- .../tests/integration/issues/360.int.test.ts | 12 +- .../tests/integration/issues/369.int.test.ts | 207 ++++ .../tests/integration/issues/387.int.test.ts | 151 +++ .../integration/nested-unions.int.test.ts | 518 +++++++++ .../integration/query-options.int.test.ts | 2 +- .../connect.int.test.ts | 393 +++++++ .../create.int.test.ts | 207 ++++ .../delete.int.test.ts | 201 ++++ .../disconnect.int.test.ts | 205 ++++ .../relationship-properties/read.int.test.ts | 577 +++++++++ .../update.int.test.ts | 374 ++++++ .../tests/integration/scalars.init.test.ts | 4 +- .../tests/integration/sort.int.test.ts | 4 +- .../graphql/tests/integration/teardown.ts | 44 + .../tests/integration/timestamps.int.test.ts | 12 +- .../integration/types/bigint.int.test.ts | 6 +- .../tests/integration/types/date.int.test.ts | 8 +- .../integration/types/datetime.int.test.ts | 10 +- .../types/point-cartesian.int.test.ts | 12 +- .../tests/integration/types/point.int.test.ts | 20 +- .../types/points-cartesian.int.test.ts | 12 +- .../integration/types/points.int.test.ts | 16 +- .../tests/integration/unions.int.test.ts | 327 +++++- .../tests/integration/update.int.test.ts | 288 ++++- .../tck/tck-test-files/.markdownlint.json | 5 + .../cypher/advanced-filtering.md | 288 +++-- .../tests/tck/tck-test-files/cypher/alias.md | 31 +- .../tests/tck/tck-test-files/cypher/arrays.md | 24 +- .../cypher/connections/alias.md | 148 +++ .../cypher/connections/filtering/composite.md | 100 ++ .../cypher/connections/filtering/node/and.md | 88 ++ .../connections/filtering/node/arrays.md | 242 ++++ .../connections/filtering/node/equality.md | 127 ++ .../connections/filtering/node/numerical.md | 248 ++++ .../cypher/connections/filtering/node/or.md | 88 ++ .../connections/filtering/node/points.md | 95 ++ .../connections/filtering/node/string.md | 391 +++++++ .../connections/filtering/relationship/and.md | 93 ++ .../filtering/relationship/arrays.md | 260 +++++ .../filtering/relationship/equality.md | 133 +++ .../filtering/relationship/numerical.md | 243 ++++ .../connections/filtering/relationship/or.md | 93 ++ .../filtering/relationship/points.md | 95 ++ .../filtering/relationship/string.md | 392 +++++++ .../filtering/relationship/temporal.md | 99 ++ .../cypher/connections/mixed-nesting.md | 243 ++++ .../cypher/connections/projections/create.md | 214 ++++ .../connections/projections/projections.md | 339 ++++++ .../cypher/connections/projections/update.md | 70 ++ .../connections/relationship-properties.md | 297 +++++ .../relationship_properties/connect.md | 311 +++++ .../relationship_properties/create.md | 100 ++ .../relationship_properties/update.md | 193 ++++ .../cypher/connections/unions.md | 348 ++++++ .../tests/tck/tck-test-files/cypher/count.md | 66 ++ .../cypher/directives/auth/arguments/allow.md | 365 +++--- .../cypher/directives/auth/arguments/bind.md | 210 ++-- .../auth/arguments/is-authenticated.md | 249 ++-- .../cypher/directives/auth/arguments/roles.md | 365 +++--- .../cypher/directives/auth/arguments/where.md | 727 ++++++++---- .../auth/projection-connection-union.md | 99 ++ .../directives/auth/projection-connection.md | 144 +++ .../cypher/directives/auth/projection.md | 32 +- .../cypher/directives/autogenerate.md | 24 +- .../cypher/directives/coalesce.md | 26 +- .../cypher/directives/cypher.md | 126 +- .../cypher/directives/relationship.md | 44 +- .../cypher/directives/timestamps.md | 24 +- .../tck/tck-test-files/cypher/issues/190.md | 41 +- .../tck/tck-test-files/cypher/issues/288.md | 99 ++ .../tck/tck-test-files/cypher/issues/324.md | 107 +- .../tck/tck-test-files/cypher/issues/360.md | 42 +- .../tck/tck-test-files/cypher/issues/387.md | 78 ++ .../tck-test-files/cypher/nested-unions.md | 200 ++++ .../tests/tck/tck-test-files/cypher/null.md | 44 +- .../cypher/operations/connect.md | 124 +- .../cypher/operations/create.md | 141 ++- .../cypher/operations/delete.md | 212 +++- .../cypher/operations/disconnect.md | 201 ++++ .../cypher/operations/update.md | 588 ++++++---- .../tck/tck-test-files/cypher/pagination.md | 99 +- .../tck/tck-test-files/cypher/pringles.md | 278 +++-- .../tck/tck-test-files/cypher/projection.md | 14 +- .../tests/tck/tck-test-files/cypher/simple.md | 38 +- .../tests/tck/tck-test-files/cypher/sort.md | 68 +- .../tck/tck-test-files/cypher/types/bigint.md | 38 +- .../tck/tck-test-files/cypher/types/date.md | 44 +- .../tck-test-files/cypher/types/datetime.md | 34 +- .../tck/tck-test-files/cypher/types/point.md | 238 ++-- .../tck/tck-test-files/cypher/types/points.md | 122 +- .../tests/tck/tck-test-files/cypher/union.md | 402 +++++-- .../tests/tck/tck-test-files/cypher/where.md | 88 +- .../tests/tck/tck-test-files/schema/arrays.md | 129 ++- .../tck/tck-test-files/schema/comments.md | 249 ++-- .../schema/connections/enums.md | 425 +++++++ .../tck-test-files/schema/connections/sort.md | 330 ++++++ .../schema/connections/unions.md | 695 +++++++++++ .../tck-test-files/schema/custom-mutations.md | 111 +- .../schema/directive-preserve.md | 125 +- .../schema/directives/access-directives.md | 143 +-- .../schema/directives/autogenerate.md | 115 +- .../schema/directives/cypher.md | 170 +-- .../schema/directives/default.md | 206 ++-- .../schema/directives/exclude.md | 781 +++++++------ .../schema/directives/ignore.md | 148 +-- .../schema/directives/private.md | 90 +- .../schema/directives/timestamps.md | 135 ++- .../tests/tck/tck-test-files/schema/enum.md | 93 +- .../tests/tck/tck-test-files/schema/extend.md | 122 +- .../tests/tck/tck-test-files/schema/inputs.md | 94 +- .../tck/tck-test-files/schema/interfaces.md | 278 +++-- .../tck/tck-test-files/schema/issues/162.md | 265 +++-- .../tck/tck-test-files/schema/issues/200.md | 166 +-- .../tests/tck/tck-test-files/schema/null.md | 346 +++--- .../schema/relationship-properties.md | 448 +++++++ .../tck/tck-test-files/schema/relationship.md | 717 +++++++----- .../tests/tck/tck-test-files/schema/scalar.md | 104 +- .../tests/tck/tck-test-files/schema/simple.md | 150 +-- .../tck/tck-test-files/schema/types/bigint.md | 114 +- .../tck/tck-test-files/schema/types/date.md | 119 +- .../tck-test-files/schema/types/datetime.md | 119 +- .../tck/tck-test-files/schema/types/point.md | 408 +++++++ .../tck/tck-test-files/schema/types/points.md | 370 ------ .../tests/tck/tck-test-files/schema/unions.md | 404 ++++--- packages/graphql/tests/tck/tck.test.ts | 58 +- .../generate-test-cases-from-md.utils.ts | 24 +- packages/ogm/README.md | 2 +- packages/ogm/package.json | 6 +- packages/ogm/src/classes/Model.ts | 36 +- .../ogm/tests/integration/ogm.int.test.ts | 96 +- yarn.lock | 433 ++++++- 331 files changed, 32218 insertions(+), 8059 deletions(-) delete mode 100644 docs/asciidoc/api-reference.adoc create mode 100644 docs/asciidoc/api-reference/index.adoc create mode 100644 docs/asciidoc/api-reference/neo4jgraphql.adoc create mode 100644 docs/asciidoc/api-reference/ogm.adoc create mode 100644 docs/asciidoc/auth/auth-directive.adoc delete mode 100644 docs/asciidoc/driver-and-config.adoc create mode 100644 docs/asciidoc/driver-configuration.adoc rename docs/asciidoc/{schema => }/filtering.adoc (78%) create mode 100644 docs/asciidoc/guides/2.0.0-migration/index.adoc create mode 100644 docs/asciidoc/guides/2.0.0-migration/miscellaneous.adoc create mode 100644 docs/asciidoc/guides/2.0.0-migration/mutations.adoc create mode 100644 docs/asciidoc/guides/2.0.0-migration/unions.adoc create mode 100644 docs/asciidoc/introduction.adoc delete mode 100644 docs/asciidoc/mutations.adoc create mode 100644 docs/asciidoc/mutations/create.adoc create mode 100644 docs/asciidoc/mutations/delete.adoc create mode 100644 docs/asciidoc/mutations/index.adoc create mode 100644 docs/asciidoc/mutations/update.adoc delete mode 100644 docs/asciidoc/ogm/api-reference.adoc create mode 100644 docs/asciidoc/ogm/api-reference/index.adoc create mode 100644 docs/asciidoc/ogm/api-reference/model/count.adoc create mode 100644 docs/asciidoc/ogm/api-reference/model/create.adoc create mode 100644 docs/asciidoc/ogm/api-reference/model/delete.adoc create mode 100644 docs/asciidoc/ogm/api-reference/model/find.adoc create mode 100644 docs/asciidoc/ogm/api-reference/model/index.adoc create mode 100644 docs/asciidoc/ogm/api-reference/model/update.adoc create mode 100644 docs/asciidoc/ogm/api-reference/ogm.adoc create mode 100644 docs/asciidoc/ogm/examples/custom-resolvers.adoc create mode 100644 docs/asciidoc/ogm/examples/index.adoc create mode 100644 docs/asciidoc/ogm/examples/rest-api.adoc delete mode 100644 docs/asciidoc/ogm/getting-started.adoc create mode 100644 docs/asciidoc/ogm/installation.adoc delete mode 100644 docs/asciidoc/ogm/methods/create.adoc delete mode 100644 docs/asciidoc/ogm/methods/delete.adoc delete mode 100644 docs/asciidoc/ogm/methods/find.adoc delete mode 100644 docs/asciidoc/ogm/methods/index.adoc delete mode 100644 docs/asciidoc/ogm/methods/update.adoc create mode 100644 docs/asciidoc/pagination/cursor-based.adoc create mode 100644 docs/asciidoc/pagination/index.adoc rename docs/asciidoc/{schema/pagination.adoc => pagination/offset-based.adoc} (50%) delete mode 100644 docs/asciidoc/schema/index.adoc delete mode 100644 docs/asciidoc/schema/mutations.adoc delete mode 100644 docs/asciidoc/schema/queries.adoc rename docs/asciidoc/{schema => }/sorting.adoc (92%) create mode 100644 docs/asciidoc/troubleshooting/faqs.adoc rename docs/asciidoc/{troubleshooting.adoc => troubleshooting/index.adoc} (88%) create mode 100644 docs/asciidoc/type-definitions/basics.adoc create mode 100644 docs/images/apollo-server-landing-page.png create mode 100644 docs/images/first-mutation.png create mode 100644 docs/images/first-query.png delete mode 100644 docs/images/relationships.png create mode 100644 docs/images/relationships.svg create mode 100644 packages/graphql/src/classes/Relationship.ts delete mode 100644 packages/graphql/src/schema/check-node-implements-interface.test.ts delete mode 100644 packages/graphql/src/schema/check-node-implements-interfaces.ts create mode 100644 packages/graphql/src/schema/get-where-fields.ts create mode 100644 packages/graphql/src/schema/pagination.test.ts create mode 100644 packages/graphql/src/schema/pagination.ts rename packages/graphql/src/schema/{scalars/ID.ts => resolvers/count.test.ts} (53%) create mode 100644 packages/graphql/src/schema/resolvers/count.ts create mode 100644 packages/graphql/src/schema/validation/validate-document.test.ts create mode 100644 packages/graphql/src/schema/validation/validate-document.ts create mode 100644 packages/graphql/src/translate/connection/create-connection-and-params.test.ts create mode 100644 packages/graphql/src/translate/connection/create-connection-and-params.ts create mode 100644 packages/graphql/src/translate/create-set-relationship-properties-and-params.ts create mode 100644 packages/graphql/src/translate/create-set-relationship-properties.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-datetime-element.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-point-element.test.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-point-element.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts create mode 100644 packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts create mode 100644 packages/graphql/src/translate/translate-count.ts create mode 100644 packages/graphql/src/translate/where/create-connection-where-and-params.ts create mode 100644 packages/graphql/src/translate/where/create-filter.test.ts create mode 100644 packages/graphql/src/translate/where/create-filter.ts create mode 100644 packages/graphql/src/translate/where/create-node-where-and-params.ts create mode 100644 packages/graphql/src/translate/where/create-relationship-where-and-params.ts create mode 100644 packages/graphql/tests/integration/composite-where.int.test.ts create mode 100644 packages/graphql/tests/integration/connection-resolvers-int.test.ts create mode 100644 packages/graphql/tests/integration/connections/alias.int.test.ts create mode 100644 packages/graphql/tests/integration/connections/enums.int.test.ts create mode 100644 packages/graphql/tests/integration/connections/nested.int.test.ts create mode 100644 packages/graphql/tests/integration/connections/unions.int.test.ts create mode 100644 packages/graphql/tests/integration/count.int.test.ts create mode 100644 packages/graphql/tests/integration/field-filtering.int.test.ts create mode 100644 packages/graphql/tests/integration/issues/288.int.test.ts rename packages/graphql/tests/integration/issues/{unauthenticated-requests.int.test.ts => 330.int.test.ts} (100%) create mode 100644 packages/graphql/tests/integration/issues/349.int.test.ts create mode 100644 packages/graphql/tests/integration/issues/369.int.test.ts create mode 100644 packages/graphql/tests/integration/issues/387.int.test.ts create mode 100644 packages/graphql/tests/integration/nested-unions.int.test.ts create mode 100644 packages/graphql/tests/integration/relationship-properties/connect.int.test.ts create mode 100644 packages/graphql/tests/integration/relationship-properties/create.int.test.ts create mode 100644 packages/graphql/tests/integration/relationship-properties/delete.int.test.ts create mode 100644 packages/graphql/tests/integration/relationship-properties/disconnect.int.test.ts create mode 100644 packages/graphql/tests/integration/relationship-properties/read.int.test.ts create mode 100644 packages/graphql/tests/integration/relationship-properties/update.int.test.ts create mode 100644 packages/graphql/tests/integration/teardown.ts create mode 100644 packages/graphql/tests/tck/tck-test-files/.markdownlint.json create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/alias.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/temporal.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/projections.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship-properties.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/connect.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/create.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/update.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/count.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection-union.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/issues/288.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/issues/387.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/nested-unions.md create mode 100644 packages/graphql/tests/tck/tck-test-files/cypher/operations/disconnect.md create mode 100644 packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md create mode 100644 packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md create mode 100644 packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md create mode 100644 packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md create mode 100644 packages/graphql/tests/tck/tck-test-files/schema/types/point.md delete mode 100644 packages/graphql/tests/tck/tck-test-files/schema/types/points.md diff --git a/README.md b/README.md index 4860243832..14893a2be6 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Want to contribute to `@neo4j/graphql`? See our [contributing guide](./docs/mark ## Links -1. [Documentation](https://neo4j.com/docs/graphql-manual/current/) +1. [Documentation](https://neo4j.com/docs/graphql-manual/2.0/) 2. [Discord](https://discord.gg/neo4j) 3. [Examples](./examples) diff --git a/docs/antora/antora.yml b/docs/antora/antora.yml index 574dbd84a8..9a891dfdbb 100644 --- a/docs/antora/antora.yml +++ b/docs/antora/antora.yml @@ -1,6 +1,6 @@ name: graphql-manual title: Neo4j GraphQL Library -version: '1.0' +version: '2.0' start_page: ROOT:index.adoc nav: - modules/ROOT/content-nav.adoc diff --git a/docs/antora/content-nav.adoc b/docs/antora/content-nav.adoc index 5614a4a60a..d6881f8f3f 100644 --- a/docs/antora/content-nav.adoc +++ b/docs/antora/content-nav.adoc @@ -1,6 +1,8 @@ * xref:index.adoc[] +** xref:introduction/index.adoc[] ** xref:getting-started/index.adoc[] ** xref:type-definitions/index.adoc[] +*** xref:type-definitions/basics/index.adoc[] *** xref:type-definitions/types/index.adoc[] *** xref:type-definitions/unions-and-interfaces/index.adoc[] *** xref:type-definitions/relationships/index.adoc[] @@ -8,40 +10,55 @@ *** xref:type-definitions/autogeneration/index.adoc[] *** xref:type-definitions/cypher/index.adoc[] *** xref:type-definitions/default-values/index.adoc[] -** xref:custom-resolvers/index.adoc[] -** xref:schema/index.adoc[] -*** xref:schema/queries/index.adoc[] -*** xref:schema/mutations/index.adoc[] -*** xref:schema/filtering/index.adoc[] -*** xref:schema/sorting/index.adoc[] -*** xref:schema/pagination/index.adoc[] ** xref:queries/index.adoc[] ** xref:mutations/index.adoc[] +*** xref:mutations/create/index.adoc[] +*** xref:mutations/update/index.adoc[] +*** xref:mutations/delete/index.adoc[] +** xref:filtering/index.adoc[] +** xref:sorting/index.adoc[] +** xref:pagination/index.adoc[] +*** xref:pagination/offset-based/index.adoc[] +*** xref:pagination/cursor-based/index.adoc[] +** xref:custom-resolvers/index.adoc[] ** xref:auth/index.adoc[] *** xref:auth/setup/index.adoc[] +*** xref:auth/auth-directive/index.adoc[] *** xref:auth/authentication/index.adoc[] *** xref:auth/authorization/index.adoc[] -**** xref:auth/authorization/roles/index.adoc[] **** xref:auth/authorization/allow/index.adoc[] -**** xref:auth/authorization/where/index.adoc[] **** xref:auth/authorization/bind/index.adoc[] +**** xref:auth/authorization/roles/index.adoc[] +**** xref:auth/authorization/where/index.adoc[] ** xref:directives/index.adoc[] ** xref:api-reference/index.adoc[] +*** xref:api-reference/neo4jgraphql/index.adoc[] +*** xref:api-reference/ogm/index.adoc[] ** xref:ogm/index.adoc[] -*** xref:ogm/getting-started/index.adoc[] -*** xref:ogm/methods/index.adoc[] -**** xref:ogm/methods/create/index.adoc[] -**** xref:ogm/methods/find/index.adoc[] -**** xref:ogm/methods/update/index.adoc[] -**** xref:ogm/methods/delete/index.adoc[] +*** xref:ogm/installation/index.adoc[] +*** xref:ogm/examples/index.adoc[] +**** xref:ogm/examples/custom-resolvers/index.adoc[] +**** xref:ogm/examples/rest-api/index.adoc[] *** xref:ogm/private/index.adoc[] *** xref:ogm/selection-set/index.adoc[] *** xref:ogm/api-reference/index.adoc[] -** xref:drivers-and-config/index.adoc[] +**** xref:ogm/api-reference/ogm/index.adoc[] +**** xref:ogm/api-reference/model/index.adoc[] +***** xref:ogm/api-reference/model/create/index.adoc[] +***** xref:ogm/api-reference/model/find/index.adoc[] +***** xref:ogm/api-reference/model/update/index.adoc[] +***** xref:ogm/api-reference/model/delete/index.adoc[] +***** xref:ogm/api-reference/model/count/index.adoc[] +** xref:driver-configuration/index.adoc[] ** xref:guides/index.adoc[] *** xref:guides/migration-guide/index.adoc[] **** xref:guides/migration-guide/server/index.adoc[] **** xref:guides/migration-guide/type-definitions/index.adoc[] **** xref:guides/migration-guide/queries/index.adoc[] **** xref:guides/migration-guide/mutations/index.adoc[] +*** xref:guides/v2-migration/index.adoc[] +**** xref:guides/v2-migration/mutations/index.adoc[] +**** xref:guides/v2-migration/unions/index.adoc[] +**** xref:guides/v2-migration/miscellaneous/index.adoc[] ** xref:troubleshooting/index.adoc[] +*** xref:troubleshooting/faqs/index.adoc[] diff --git a/docs/asciidoc/api-reference.adoc b/docs/asciidoc/api-reference.adoc deleted file mode 100644 index 6205816e1e..0000000000 --- a/docs/asciidoc/api-reference.adoc +++ /dev/null @@ -1,41 +0,0 @@ -[[api-reference]] -= API Reference - - -== `Neo4jGraphQL` -Main Entry to the library. Holds metadata about the GraphQL schema. - -=== Requiring -[source, javascript] ----- -const { Neo4jGraphQL } = require("@neo4j/graphql"); ----- - -=== Constructing - -[source, javascript] ----- -const neo4jGraphQL = new Neo4jGraphQL({ - typeDefs, - resolvers?, - schemaDirectives?, - driver?, - config?: { - driverConfig?, - enableRegex?, - jwt?: { - secret?, - noVerify?, - rolesPath?, - }, - }, -}); ----- - -=== Methods - -==== `checkNeo4jCompat` -Reference: <> - -== `OGM` -Reference: <> diff --git a/docs/asciidoc/api-reference/index.adoc b/docs/asciidoc/api-reference/index.adoc new file mode 100644 index 0000000000..b8b6f14d12 --- /dev/null +++ b/docs/asciidoc/api-reference/index.adoc @@ -0,0 +1,5 @@ +[[api-reference]] += API Reference + +- <> +- <> diff --git a/docs/asciidoc/api-reference/neo4jgraphql.adoc b/docs/asciidoc/api-reference/neo4jgraphql.adoc new file mode 100644 index 0000000000..77e775edb4 --- /dev/null +++ b/docs/asciidoc/api-reference/neo4jgraphql.adoc @@ -0,0 +1,194 @@ +[[api-reference-neo4jgraphql]] += `Neo4jGraphQL` + +== `constructor` + +Returns a `Neo4jGraphQL` instance. + +Takes an `input` object as a parameter, the supported fields of which are described below. + +=== Example + +[source, javascript] +---- +const neoSchema = new Neo4jGraphQL({ + typeDefs, +}); +---- + +[[api-reference-neo4jgraphql-input]] +=== Input + +Accepts all of the options from https://github.com/ardatan/graphql-tools/blob/%40graphql-tools/schema%407.1.5/website/docs/generate-schema.md#makeexecutableschemaoptions[`makeExecutableSchema`], plus the additional arguments below: + +|=== +|Name and Type |Description + +|`driver` + + + + Type: https://neo4j.com/docs/javascript-manual/current/[`Driver`] +|An instance of a Neo4j driver. + +|`config` + + + + Type: `Neo4jGraphQLConfig` +|Additional Neo4j GraphQL configuration options. +|=== + +[[api-reference-neo4jgraphql-input-neo4jgraphqlconfig]] +==== `Neo4jGraphQLConfig` + +|=== +|Name and Type |Description + +|`driverConfig` + + + + Type: <> +|Additional driver configuration options. + +|`enableRegex` + + + + Type: `boolean` +|Whether to enable RegEx filters, see <> for more information. + +|`jwt` + + + + Type: <> +|JWT options. + +|`queryOptions` + + + + Type: <> +|Cypher query options, see <> for more information. + +|`skipValidateTypeDefs` + + + + Type: `boolean` +|Can be used to disable strict type definition validation if you are encountering unexpected errors. +|=== + +[[api-reference-neo4jgraphql-input-neo4jgraphqlconfig-driverconfig]] +===== `DriverConfig` + +|=== +|Name and Type |Description + +|`database` + + + + Type: `string` +|The name of the database within the DBMS to connect to. + +|`bookmarks` + + + + Type: `string` or `Array` +|One or more bookmarks to use for the connection. +|=== + +[[api-reference-neo4jgraphql-input-neo4jgraphqlconfig-neo4jgraphqljwt]] +===== `Neo4jGraphQLJWT` + +|=== +|Name and Type |Description + +|`secret` + + + + Type: `string` +|The secret used to encode JWT tokens. + + + + *Required* unless passing in decoded tokens. + +|`noVerify` + + + + Type: `boolean` +|Disable verification of JWT signatures, only decode. + +|`rolesPath` + + + + Type: `string` +|Dot path of location of roles within JWT token. +|=== + +[[api-reference-neo4jgraphql-input-neo4jgraphqlconfig-cypherqueryoptions]] +===== `CypherQueryOptions` + +All options are enum types imported from `@neo4j/graphql`, for example: + +[source, javascript] +---- +const { CypherRuntime } = require("@neo4j/graphql"); +---- + +|=== +|Name and Type |Description + +|`runtime` + + + + Type: `CypherRuntime` +|Possible options: + + + + - `CypherRuntime.INTERPRETED` + + - `CypherRuntime.SLOTTED` + + - `CypherRuntime.PIPELINED` + +|`planner` + + + + Type: `CypherPlanner` +|Possible options: + + + + - `CypherPlanner.COST` + + - `CypherPlanner.IDP` + + - `CypherPlanner.DP` + +|`connectComponentsPlanner` + + + + Type: `CypherConnectComponentsPlanner` +|Possible options: + + + + - `CypherConnectComponentsPlanner.GREEDY` + + - `CypherConnectComponentsPlanner.IDP` + +|`updateStrategy` + + + + Type: `CypherUpdateStrategy` +|Possible options: + + + + - `CypherUpdateStrategy.DEFAULT` + + - `CypherUpdateStrategy.EAGER` + +|`expressionEngine` + + + + Type: `CypherExpressionEngine` +|Possible options: + + + + - `CypherExpressionEngine.DEFAULT` + + - `CypherExpressionEngine.INTERPRETED` + + - `CypherExpressionEngine.COMPILED` + +|`operatorEngine` + + + + Type: `CypherOperatorEngine` +|Possible options: + + + + - `CypherOperatorEngine.DEFAULT` + + - `CypherOperatorEngine.INTERPRETED` + + - `CypherOperatorEngine.COMPILED` + +|`interpretedPipesFallback` + + + + Type: `CypherInterpretedPipesFallback` +|Possible options: + + + + - `CypherInterpretedPipesFallback.DEFAULT` + + - `CypherInterpretedPipesFallback.DISABLED` + + - `CypherInterpretedPipesFallback.WHITELISTED_PLANS_ONLY` + + - `CypherInterpretedPipesFallback.ALL` + +|`replan` + + + + Type: `CypherReplanning` +|Possible options: + + + + - `CypherReplanning.DEFAULT` + + - `CypherReplanning.FORCE` + + - `CypherReplanning.SKIP` +|=== diff --git a/docs/asciidoc/api-reference/ogm.adoc b/docs/asciidoc/api-reference/ogm.adoc new file mode 100644 index 0000000000..de1f6345a6 --- /dev/null +++ b/docs/asciidoc/api-reference/ogm.adoc @@ -0,0 +1,4 @@ +[[api-reference-ogm]] += `@neo4j/graphql-ogm` + +See <>. diff --git a/docs/asciidoc/auth/auth-directive.adoc b/docs/asciidoc/auth/auth-directive.adoc new file mode 100644 index 0000000000..df489a5aac --- /dev/null +++ b/docs/asciidoc/auth/auth-directive.adoc @@ -0,0 +1,84 @@ +[[auth-directive]] += `@auth` directive + +The `@auth` directive definition is dynamically generated on runtime based on user type definitions. + +== `rules` + +You can have many rules for many operations. Each rule is fallen through until a match is found against the corresponding operation. If no match is found, an error is thrown. You can think of rules as a big `OR`. + +[source, graphql] +---- +@auth(rules: [ + { operations: [CREATE, UPDATE], ... }, ## or + { operations: [READ, UPDATE], ...}, ## or + { operations: [DELETE, UPDATE], ... } ## or +]) +---- + +== `operations` + +`operations` is an array which allows you to re-use the same rule for many operations. + +[source, graphql] +---- +@auth(rules: [ + { operations: [CREATE, UPDATE, DELETE, CONNECT, DISCONNECT] }, + { operations: [READ] } +]) +---- + +NOTE: Note that the absence of an `operations` argument will imply _all_ operations. + +Many different operations can be called at once, for example in the following Mutation: + +[source, graphql] +---- +mutation { + createPosts( + input: [ + { + content: "I like GraphQL", + creator: { connect: { where: { id: "user-01" } } } + } + ] + ) { + posts { + content + } + } +} +---- + +In the above example, there is a `CREATE` operation followed by a `CONNECT`, so the auth rule must allow a user to perform both of these operations. + +The full list of operations and how they related to Cypher clauses are: + +|=== +|Operation |Cypher clause(s) + +|`READ` +|`MATCH` + +|`CREATE` +|`CREATE` + +|`UPDATE` +|`SET` + +|`DELETE` +|`DELETE` + +|`CONNECT` +|`MATCH` and `MERGE` + +|`DISCONNECT` +|`MATCH` and `DELETE` +|=== + +== Auth Value Plucking + +When using the `@auth` directive, you use the following prefixes to substitute in their relevant values: + +- `$jwt.` - pulls value from JWT +- `$context.` - pulls value from context diff --git a/docs/asciidoc/auth/authentication.adoc b/docs/asciidoc/auth/authentication.adoc index 9bc32dadf6..84a71aaf06 100644 --- a/docs/asciidoc/auth/authentication.adoc +++ b/docs/asciidoc/auth/authentication.adoc @@ -1,87 +1,13 @@ [[auth-authentication]] = Authentication -Neo4j GraphQL will expect there to be an `authorization` header in the request object, which means you can authenticate users however you like. You could; Have a custom sign-in mutation, integrate with Auth0, or roll your own SSO server. The point here is that it’s just a JWT, in the library, we will decode it to make sure it’s valid - but it’s down to you to issue tokens. +The Neo4j GraphQL Library expects an `authorization` header in the request object, which means you can authenticate users however you like. You could have a custom sign-in mutation, integrate with Auth0, or roll your own SSO server. The point here is that it’s just a JWT which the library decodes to make sure it’s valid - but it’s down to the user to issue tokens. -== OGM - -Here we will use the <> to set up a hypothetical sign-in flow; - - -[source, javascript] ----- -const { Neo4jGraphQL } = require("@neo4j-graphql"); -const { createJWT, comparePassword } = require("./utils"); // example -const { ApolloServer } = require("apollo-server"); -const { OGM } = require("@neo4j-graphql-ogm"); - -const typeDefs = ` - type User { - id: ID @id - email: String! - password: String! - } - - type Mutation { - signIn(email: String!, password: String!): String ## token - } -`; - -const driver = neo4j.driver("bolt://localhost:7687", neo4j.auth.basic("admin", "password")); - -const ogm = new OGM({ - typeDefs, - driver, -}); - -const User = ogm.model("User"); - -const resolvers = { - Mutation: { - async signIn(root, { email, password }) { - const [existing] = await User.find({ - where: { - email, - }, - }); - - if (!existing) { - throw new Error("not found"); - } - - const equal = await comparePassword(password, existing.password); - if (!equal) { - throw new Error("bad password"); - } - - return createJWT({ - sub: user.id, - }); - }, - }, -}; - -const neoSchema = new Neo4jGraphQL({ - typeDefs, - resolvers, - driver, - config: { - jwt: { - secret - } - } -}); - -const server = new ApolloServer({ - schema: neoSchema.schema, - context: ({ req }) => ({ req }), -}); - -server.listen(4000).then(() => console.log("online")); ----- +> The example at <> demonstrates a hypothetical sign-up/sign-in flow using the <>, which will be a good starting point for inspiration. == `isAuthenticated` -This is the most basic of auth. Used to ensure that there is a valid decoded JWT in the request. The most basic of applications could look something like this; + +This is the most basic of authentication, used to ensure that there is a valid decoded JWT in the request. The most basic of type definitions could look something like the following, which states you must be authenticated to access `Todo` objects: [source, graphql] ---- @@ -94,9 +20,10 @@ extend type Todo @auth(rules: [{ isAuthenticated: true }]) ---- == `allowUnauthenticated` + In some cases, you may want to allow unauthenticated requests while also having auth-based rules. You can use the `allowUnauthenticated` parameter to avoid throwing an exception if no auth is present in the context. -In the example below, only the publisher can see his blog posts if it is not published yet. Once the blog post is published, anyone can see it. +In the example below, only the publisher can see his blog posts if it is not published yet. Once the blog post is published, anyone can see it: [source, graphql] ---- diff --git a/docs/asciidoc/auth/authorization/allow.adoc b/docs/asciidoc/auth/authorization/allow.adoc index 58ec5f113b..0e3d7ded0c 100644 --- a/docs/asciidoc/auth/authorization/allow.adoc +++ b/docs/asciidoc/auth/authorization/allow.adoc @@ -1,14 +1,16 @@ [[auth-authorization-allow]] = Allow -Use `allow` to ensure that on matched nodes, a connection exists between a value on the JWT and a property on each matched node. Taking a closer look, let's create two users in a hypothetical empty database; +Use `allow` to ensure that on matched nodes, there is equality between a value on the JWT and a property on each matched node. Taking a closer look, create two users in a hypothetical empty database: [source, cypher] ---- -CREATE (:User {id:"user1", name: "one"}) -CREATE (:User {id:"user2", name: "two"}) +CREATE (:User { id: "user1", name: "one" }) +CREATE (:User { id: "user2", name: "two" }) ---- +For the label and properties of the nodes created above, the corresponding GraphQL type definition would be: + [source, graphql] ---- type User { @@ -17,7 +19,7 @@ type User { } ---- -Now we have two users in our database, and given the above GraphQL type definitions - How can we restrict `user1` from accessing `user2`? This is where `allow` comes in; +Now that there are two users in the database, and a simple type definition - it might be desirable to restrict `user1` from accessing `user2`. This is where `allow` comes in: [source, graphql] ---- @@ -36,18 +38,20 @@ extend type User @auth( ) ---- -After we match the node, we validate that the property on the node is equal to the `jwt.sub` property. This validation is done in Cypher with two functions; `validatePredicate` and `validate`. +After a match is made against a node, it is validated that the property `id` on the node is equal to the `jwt.sub` property. + +Given `user1` has the following decoded JWT: -Given `user1` has the decoded JWT; [source, json] ---- { - "sub": "user1", - "iat": 1516239022 + "sub": "user1", + "iat": 1516239022 } ---- -With this JWT makes a GraphQL query to get `user2`; +If "user1" used this JWT in a request for "user2": + [source, graphql] ---- query { @@ -57,26 +61,26 @@ query { } ---- -The generated cypher for this query would look like the below and throw you out the operation. +The generated cypher for this query would look like the following and throw you out the operation: [source, cypher] ---- -MATCH (u:User {id: "user2"}) +MATCH (u:User { id: "user2" }) CALL apoc.util.validate(NOT(u.id = "user1"), "Forbidden") RETURN u ---- -Allow is used on the following operations; +Allow is available on the following operations: -1. read -2. update -3. connect -4. disconnect -5. delete +- `READ` +- `UPDATE` +- `CONNECT` +- `DISCONNECT` +- `DELETE` -== `allow` Across Relationships +== `allow` across relationships -There may be a reason where you need to traverse across relationships to satisfy your auth implementation. One example of this could be "Grant update access to all Moderators of a Post"; +There may be a reason where you need to traverse across relationships to satisfy your authorization implementation. One example use case could be "grant update access to all Moderators of a Post": [source, graphql] ---- @@ -95,9 +99,9 @@ extend type Post @auth(rules: [ ]) ---- -When you specify allow on a relationship you can select fields on the referenced node. It's worth pointing out that allow on a relationship will perform an `ANY` on the matched nodes; to see if there is a match. +When you specify allow on a relationship you can select fields on the referenced node. It's worth pointing out that allow on a relationship will perform an `ANY` on the matched nodes to see if there is a match. -Given the above example - There may be a time when you need to give update access to either the creator of a post or a moderator, you can use `OR` and `AND` inside allow; +Given the above example - There may be a time when you need to give update access to either the creator of a post or a moderator, you can use `OR` and `AND` inside `allow`: [source, graphql] ---- @@ -123,9 +127,9 @@ extend type Post ) ---- -== Field Level `allow` +== Field-level `allow` -Allow works the same as it does on Type Definitions although its context is the Field. So instead of enforcing auth rules when the node is matched and/or upserted, it would instead be called when the Field is selected or upserted. Given the following, it is hiding the password to only the user themselves; +`allow` works the same as it does on Types although its context is the Field. So instead of enforcing auth rules when the node is matched and/or modified, it would instead be called when the Field is match and/or modified. Given the following, it is hiding the password to all users but the user themselves: [source, graphql] ---- diff --git a/docs/asciidoc/auth/authorization/bind.adoc b/docs/asciidoc/auth/authorization/bind.adoc index c294d58f59..84b0986ac6 100644 --- a/docs/asciidoc/auth/authorization/bind.adoc +++ b/docs/asciidoc/auth/authorization/bind.adoc @@ -1,13 +1,15 @@ [[auth-authorization-bind]] = Bind -Use bind to ensure that on creating or updating nodes, a connection exists between a value on the JWT vs a property on a matched node. This validation is done after the operation but inside a transaction. Taking a closer look, let's put a user in our database; +Use bind to ensure that on creating or updating nodes, there is equality between a value on the JWT and a property on a matched node. This validation is done after the operation but inside a transaction. Taking a closer look, create a user in your database: [source, cypher] ---- -CREATE (:User {id:"user1", name: "one"}) +CREATE (:User { id:"user1", name: "one" }) ---- +For the label and properties of the node created above, the corresponding GraphQL type definitions would be: + [source, graphql] ---- type User { @@ -16,8 +18,7 @@ type User { } ---- - -Given the above GraphQL type definitions - How can we restrict `user1` from changing their ID? +Given the above GraphQL type definition - you could restrict `user1` from changing their own ID: [source, graphql] ---- @@ -36,20 +37,19 @@ extend type User @auth( ) ---- -After we update or create the node we validate that the property on the node is equal to the `jwt.sub` property. This validation is done in Cypher with function `apoc.util.validate` +After the update or creation of the node, it is validated that the property `id` on the node is equal to the `jwt.sub` property. -Given `user1` has the decoded JWT; +Given `user1` has the following decoded JWT: [source, json] ---- { - "sub": "user1", - "iat": 1516239022 + "sub": "user1", + "iat": 1516239022 } ---- -With this JWT makes a GraphQL mutation to update their ID to someone else; - +When the user makes a request using this JWT to change their ID: [source, graphql] ---- @@ -62,30 +62,27 @@ mutation { } ---- -The generated cypher for this query would look like the below, Throwing us out of the operation because the ids do not match. - +The generated cypher for this query would look like the below, throwing you out of the operation because the `id` property no longer matches. [source, cypher] ---- -MATCH (u:User {id: "user1"}) +MATCH (u:User { id: "user1" }) SET u.id = "user2" CALL apoc.util.validate(NOT(u.id = "user1"), "Forbidden") RETURN u ---- +Bind is available for the following operations; -Bind is used on the following operations; - -1. create -2. update -3. connect -4. disconnect -5. delete - +- `READ` +- `UPDATE` +- `CONNECT` +- `DISCONNECT` +- `DELETE` -== `bind` Across Relationships +== `bind` across relationships -There may be a reason where you need to traverse across relationships to satisfy your Auth implementation. One example of this could be "Ensure that users only create Posts related to themselves"; +There may be a reason where you need to traverse across relationships to satisfy your authorization implementation. One use case could be "ensure that users only create Posts related to themselves": [source, graphql] ---- @@ -104,11 +101,11 @@ extend type Post @auth(rules: [ ]) ---- -When you specify `bind` on a relationship you can select fields on the referenced node. It's worth pointing out that allow on a relationship will perform an `ALL` on the matched nodes; to see if there is a match. This means you can only use `bind` to enforce a single relationship to a single node. +When you specify `bind` on a relationship you can select fields on the related node. It's worth pointing out that `bind` on a relationship field will perform an `ALL` on the matched nodes to see if there is a match. This means you can only use `bind` to enforce a single relationship to a single node. -=== Field Level `bind` +=== Field-level `bind` -You can use bind on a field. The root is still considered the node. Taking the example at the start of this `bind` section; you could do the following; +You can use `bind` on a field, and the root is still considered the node itself. Taking the example at the start of this chapter, you could do the following to implement the same behaviour: [source, graphql] ---- diff --git a/docs/asciidoc/auth/authorization/index.adoc b/docs/asciidoc/auth/authorization/index.adoc index 4e1a01ff76..f33493f68c 100644 --- a/docs/asciidoc/auth/authorization/index.adoc +++ b/docs/asciidoc/auth/authorization/index.adoc @@ -2,3 +2,8 @@ = Authorization You specify authorization rules inside the `@auth` directive. This section looks at each option available and explains how to use it to implement authorization. + +- <> +- <> +- <> +- <> diff --git a/docs/asciidoc/auth/authorization/roles.adoc b/docs/asciidoc/auth/authorization/roles.adoc index 50f52d5cf7..2dd898ff3b 100644 --- a/docs/asciidoc/auth/authorization/roles.adoc +++ b/docs/asciidoc/auth/authorization/roles.adoc @@ -1,7 +1,9 @@ [[auth-authorization-roles]] = Roles -Use the roles property to specify the allowed roles for an operation. Use config `rolesPath` to specify a object path for JWT roles otherwise defaults to `jwt.roles` +Use the `roles` property to specify the allowed roles for an operation. Use the `Neo4jGraphQL` config option `rolesPath` to specify a object path for JWT roles otherwise defaults to `jwt.roles`. + +The following type definitions show that an admin role is required for all update operations against Users. [source, graphql] ---- @@ -13,19 +15,16 @@ type User { extend type User @auth(rules: [{ operations: [UPDATE], roles: ["admin"] }]) ---- -Above showing an admin role is required for all operations against Users. If you have multiple roles you can add more items to the array; +If there are multiple possible roles you can add more items to the array, of which users only need one to satisfy a rule: [source, graphql] ---- -extend type User @auth(rules: [{ roles: ["admin", "super-admin"] }]) +extend type User @auth(rules: [{ operations: [UPDATE], roles: ["admin", "super-admin"] }]) ---- - -> Users only need one of many roles to satisfy a rule. - == RBAC -Here is a RBAC example using `roles`; +Here is an example of RBAC (Role-Based Access Control) using `roles`: [source, graphql] ---- @@ -45,4 +44,4 @@ type Invoice @auth(rules: [{ operations: [READ], roles: ["read:invoice"] }]) { csv: String total: Int } ----- \ No newline at end of file +---- diff --git a/docs/asciidoc/auth/authorization/where.adoc b/docs/asciidoc/auth/authorization/where.adoc index db187bf685..8baf031d78 100644 --- a/docs/asciidoc/auth/authorization/where.adoc +++ b/docs/asciidoc/auth/authorization/where.adoc @@ -1,7 +1,7 @@ [[auth-authorization-where]] = Where -Use the `where` argument, on Node definitions, to conceptually append predicates to the Cypher `WHERE` clause. Given the current user ID is "123" and the following the schema; +Use the `where` argument on types to conceptually append predicates to the Cypher `WHERE` clause. Given the current user ID is "123" and the following schema: [source, graphql] ---- @@ -13,7 +13,7 @@ type User { extend type User @auth(rules: [{ where: { id: "$jwt.id" } }]) ---- -Then issues a GraphQL query for users; +Then the user executes a GraphQL query for all users: [source, graphql] ---- @@ -25,7 +25,7 @@ query { } ---- -Behind the scenes the user’s ID is **conceptually** prepended to the query; +Behind the scenes the user’s ID is conceptually added to the query: [source, graphql] ---- @@ -39,8 +39,8 @@ query { Where is used on the following operations; -1. read -2. update -3. connect -4. disconnect -5. delete +- `READ` +- `UPDATE` +- `CONNECT` +- `DISCONNECT` +- `DELETE` diff --git a/docs/asciidoc/auth/index.adoc b/docs/asciidoc/auth/index.adoc index 0578275d94..9b9f0feae2 100644 --- a/docs/asciidoc/auth/index.adoc +++ b/docs/asciidoc/auth/index.adoc @@ -1,10 +1,16 @@ [[auth]] = Auth -In this section you will learn more about how to secure your GraphQL API using Neo4j GraphQL's inbuilt auth mechanics. +In this chapter you will learn more about how to secure your GraphQL API using the Neo4j GraphQL Library's built-in auth mechanics. -== Preview +- <> +- <> +- <> +- <> +== Quickstart examples + +Only authenticated users can create Post nodes: [source, graphql] ---- @@ -15,7 +21,7 @@ type Post @auth(rules: [ } ---- -When you have production-style Auth the directive can get large and complicated. Use Extend to tackle this; +Use `extend` to avoid large and unwieldy type definitions: [source, graphql] ---- @@ -28,7 +34,7 @@ extend type Post @auth(rules: [ ]) ---- -You can use the directive on 'Type Definitions', as seen in the example above, you can also apply the directive on any field so as long as it's not a `@relationship`; +You can use the directive types as seen in the example above, but you can also apply the directive on any field so as long as it's not decorated with `@relationship`. In the following example, the password field is only accessible to users with role "admin", or the user themselves: [source, graphql] ---- diff --git a/docs/asciidoc/auth/setup.adoc b/docs/asciidoc/auth/setup.adoc index debfa0b109..e81825cb15 100644 --- a/docs/asciidoc/auth/setup.adoc +++ b/docs/asciidoc/auth/setup.adoc @@ -23,9 +23,10 @@ const neoSchema = new Neo4jGraphQL({ }); ---- -It is also possible to pass in JWTs which have already been decoded, in which case the `jwt` option is _not necessary_. This will be covered in the section <>. +It is also possible to pass in JWTs which have already been decoded, in which case the `jwt` option is _not necessary_. This is covered in the section <> below. === Auth Roles Object Paths + If you are using a 3rd party auth provider such as Auth0 you may find your roles property being nested inside an object: [source, json] @@ -55,7 +56,7 @@ const neoSchema = new Neo4jGraphQL({ [[auth-setup-passing-in]] == Passing in JWTs -If you wish to pass in an encoded JWT, this must be included in the `Authorization` header of your requests, in the format: +If you wish to pass in an encoded JWT, this must be included in the `authorization` header of your requests, in the format: [source] ---- @@ -111,74 +112,9 @@ const server = new ApolloServer({ }); ---- -== `@auth` directive - -=== `rules` - -You can have many rules for many operations. We fall through each rule, on the corresponding operation, until we find a match. On no match found, an error is thrown. You can think of rules as a big OR. - -[source, graphql] ----- -@auth(rules: [ - { operations: [CREATE, UPDATE], ... }, ## or - { operations: [READ, UPDATE], ...}, ## or - { operations: [DELETE, UPDATE], ... } ## or -]) ----- - -=== `operations` - -Operations is an array which allows you to re-use the same rule for many operations. - -[source, graphql] ----- -@auth(rules: [ - { operations: [CREATE, UPDATE, DELETE, CONNECT, DISCONNECT] }, - { operations: [READ] } -]) ----- - -NOTE: Note that the absence of an `operations` argument will imply _all_ operations. - -Many different operations can be called at once, for example in the following Mutation: - -[source, graphql] ----- -mutation { - createPosts( - input: [ - { - content: "I like GraphQL", - creator: { connect: { where: { id: "user-01" } } } - } - ] - ) { - posts { - content - } - } -} ----- - -In the above example, we perform a `CREATE` followed by a `CONNECT`, so our auth rule must allow our user to perform both of these operations. - -The full list of operations are: - -- read - `MATCH` -- create - `CREATE` -- update - `SET` -- delete - `DELETE` -- connect - `MATCH` & `MERGE` -- disconnect - `MATCH` & `DELETE` - -== Auth Value Plucking - -1. `$jwt.` - Pulls value from jsonwebtoken -2. `$context.` - Pulls value from context - -== Auth Custom Resolvers +== Auth and Custom Resolvers -You can't use the `@auth` directive on a custom resolver, however, we do make life easier by injecting the auth parameter into it. It will be available under the `context.auth` property. For example, the following custom resolver returns the `sub` field from the JWT: +You can't use the `@auth` directive on custom resolvers, however, the an auth parameter is injected into the context for use in them. It will be available under the `auth` property. For example, the following custom resolver returns the `sub` field from the JWT: [source, javascript] ---- @@ -190,16 +126,16 @@ const typeDefs = ` const resolvers = { Query: { - myId(root, args, context) { + myId(_source, _args, context) { return context.auth.jwt.sub } } }; ---- -== Auth on `@cypher` +== Auth and `@cypher` fields -You can put the `@auth` directive on a field with the `@cypher` directive. Functionality like `allow` and `bind` will not work but you can still utilize `isAuthenticated` and `roles`. Additionally, you don't need to specify operations for `@auth` directives on `@cypher` fields. +You can put the `@auth` directive on a field alongside the `@cypher` directive. Functionality like `allow` and `bind` will not work but you can still utilize `isAuthenticated` and `roles`. Additionally, you don't need to specify `operations` for `@auth` directives on `@cypher` fields. The following example uses the `isAuthenticated` rule to ensure a user is authenticated, before returning the `User` associated with the JWT: @@ -211,7 +147,9 @@ type User @exclude { } type Query { - me: User @cypher(statement: "MATCH (u:User { id: $auth.jwt.sub }) RETURN u") @auth(rules: [{ isAuthenticated: true }]) + me: User + @cypher(statement: "MATCH (u:User { id: $auth.jwt.sub }) RETURN u") + @auth(rules: [{ isAuthenticated: true }]) } ---- diff --git a/docs/asciidoc/custom-resolvers.adoc b/docs/asciidoc/custom-resolvers.adoc index d53a3f9291..3a30026d63 100644 --- a/docs/asciidoc/custom-resolvers.adoc +++ b/docs/asciidoc/custom-resolvers.adoc @@ -3,6 +3,10 @@ The library will autogenerate Query and Mutation resolvers, so you don’t need to implement those resolvers yourself. However, if you would like additional behaviours besides the autogenerated CRUD operations, you can specify custom resolvers for these scenarios. +*A note on custom resolvers* + +> Due to the nature of the Cypher generation in this library, you must query any fields used in a custom resolver. For example, in the first example below calculating `fullName`, `firstName` and `lastName` must be included in the selection set when querying `fullName`. Without this being the case, `firstName` and `lastName` will be undefined in the custom resolver. + == Custom object type field resolver If you would like to add a field to an object type which is resolved from existing values in the type, rather than storing new values, you should mark it with an <> directive and define a custom resolver for it. @@ -19,8 +23,8 @@ const typeDefs = ` const resolvers = { User: { - fullName(obj) { - return `${obj.firstName} ${obj.lastName}`; + fullName(source) { + return `${source.firstName} ${source.lastName}`; }, }, }; @@ -33,28 +37,4 @@ const neoSchema = new Neo4jGraphQL({ == Custom Query/Mutation type field resolver -You can define additional, custom Queries and Mutations in your type definitions and provide custom resolvers for them. A prime use case for this is using the <> to manipulate types and fields which are not available through the API. - -[source, javascript] ----- -const typeDefs = ` - type User { - userId: ID! - } - - type Query { - users: [User] - } -`; - -const resolvers = { - Query: { - users: () => // implement resolver here - } -}; - -const neoSchema = new Neo4jGraphQL({ - typeDefs, - resolvers, -}); ----- +You can define additional custom Query and Mutation fields in your type definitions and provide custom resolvers for them. A prime use case for this is using the <> to manipulate types and fields which are not available through the API. You can find an example of it being used in this capacity in the <> example. diff --git a/docs/asciidoc/directives.adoc b/docs/asciidoc/directives.adoc index 78cbb7de38..1a009d334e 100644 --- a/docs/asciidoc/directives.adoc +++ b/docs/asciidoc/directives.adoc @@ -5,7 +5,7 @@ The `@auth` directive is used to define complex fine-grained and role-based access control for object types and fields. -Reference: <> +Reference: <> == `@coalesce` diff --git a/docs/asciidoc/driver-and-config.adoc b/docs/asciidoc/driver-and-config.adoc deleted file mode 100644 index 593782b3ab..0000000000 --- a/docs/asciidoc/driver-and-config.adoc +++ /dev/null @@ -1,93 +0,0 @@ -[[drivers-and-config]] -= Driver Configuration - - -== Neo4j Driver -The https://github.com/neo4j/neo4j-javascript-driver[Neo4j javascript driver] must be present in either the context or construction of your `Neo4jGraphQL` API or at the construction of your `OGM`. - -=== `Neo4jGraphQL` -[source, javascript] ----- -const { Neo4jGraphQL } = require("@neo4j/graphql"); -const neo4j = require("neo4j-driver"); - -const driver = neo4j.driver( - "bolt://localhost:7687", - neo4j.auth.basic("neo4j", "letmein") -); - -const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); - -const server = new ApolloServer({ - schema: neoSchema.schema, - context: ({ req }) => ({ req }), -}); ----- - -Or you can specify the driver at runtime using the context; - -[source, javascript] ----- -const server = new ApolloServer({ - schema: neoSchema.schema, - context: ({ req }) => ({ req, context }), -}); ----- - -=== `OGM` - -[source, javascript] ----- -const express = require("express"); -const { OGM } = require("@neo4j/graphql"); -const neo4j = require("neo4j-driver"); - -const driver = neo4j.driver( - "bolt://localhost:7687", - neo4j.auth.basic("neo4j", "letmein") -); - -const ogm = new OGM({ typeDefs, driver }); ----- - -[[drivers-and-config-checkNeo4jCompat]] -== `checkNeo4jCompat` -Use the `checkNeo4jCompat` method available on either `Neo4jGraphQL` or the `OGM` to ensure the specified DBMS has the required; versions, functions and procedures. - -==== `Neo4jGraphQL` - -[source, javascript] ----- -const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); -await neoSchema.checkNeo4jCompat(); ----- - -==== `OGM` - -[source, javascript] ----- -const ogm = new OGM({ typeDefs, driver }); -await ogm.checkNeo4jCompat(); ----- - -== Specifying Neo4j Database -The Neo4j database may be added to the GraphQL context object; - -[source, javascript] ----- -const server = new ApolloServer({ - schema, - context: { driver, driverConfig: { database: "sanmateo" } } -}); ----- - -== Specifying Neo4j Bookmarks -A Neo4j driver bookmark may be added to the GraphQL context object; - -[source, javascript] ----- -const server = new ApolloServer({ - schema, - context: { driver, driverConfig: { bookmarks: ["my-bookmark"] } } -}); ----- diff --git a/docs/asciidoc/driver-configuration.adoc b/docs/asciidoc/driver-configuration.adoc new file mode 100644 index 0000000000..c1ad523374 --- /dev/null +++ b/docs/asciidoc/driver-configuration.adoc @@ -0,0 +1,257 @@ +[[driver-configuration]] += Driver Configuration + +== Neo4j Driver +An instance of the https://github.com/neo4j/neo4j-javascript-driver[Neo4j JavaScript driver] must be present in either the GraphQL request context, or construction of your `Neo4jGraphQL` instance (or alternatively, `OGM`). + +The examples in this chapter assume a Neo4j database running at "bolt://localhost:7687" with a username of "neo4j" and a password of "password". + +=== Neo4j GraphQL Library + +==== Driver in `Neo4jGraphQL` constructor + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + +const server = new ApolloServer({ + schema: neoSchema.schema, + context: ({ req }) => ({ req }), +}); +---- + +==== Driver in context + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + +const server = new ApolloServer({ + schema: neoSchema.schema, + context: ({ req }) => ({ req, driver }), +}); +---- + +=== OGM + +[source, javascript] +---- +const { OGM } = require("@neo4j/graphql-ogm"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const ogm = new OGM({ typeDefs, driver }); +---- + +[[driver-configuration-database-compatibility]] +== Database Compatibility + +Use the `checkNeo4jCompat` method available on either a `Neo4jGraphQL` or `OGM` instance to ensure the specified DBMS is of the required version, and has the necessary functions and procedures available. The `checkNeo4jCompat` will throw an `Error` if the DBMS is incompatible, with details of the incompatibilities. + +=== `Neo4jGraphQL` + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); +await neoSchema.checkNeo4jCompat(); +---- + +=== `OGM` + +[source, javascript] +---- +const { OGM } = require("@neo4j/graphql-ogm"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const ogm = new OGM({ typeDefs, driver }); +await ogm.checkNeo4jCompat(); +---- + +== Specifying Neo4j database + +There are two ways to specify which database within a DBMS should be used. + +=== Context + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + +const server = new ApolloServer({ + schema, + context: { driverConfig: { database: "my-database" } } +}); +---- + +=== `Neo4jGraphQL` constructor + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const neoSchema = new Neo4jGraphQL({ + typeDefs, + driver, + config: { + driverConfig: { + database: "my-database", + }, + }, +}); + +const server = new ApolloServer({ + schema, +}); +---- + +== Specifying Neo4j Bookmarks + +There are two ways to specify which database bookmarks to use. + +=== Context + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + +const server = new ApolloServer({ + schema, + context: { driverConfig: { bookmarks: ["my-bookmark"] } } +}); +---- + +=== `Neo4jGraphQL` constructor + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const neo4j = require("neo4j-driver"); + +const typeDefs = ` + type User { + name: String + } +`; + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const neoSchema = new Neo4jGraphQL({ + typeDefs, + driver, + config: { + driverConfig: { + bookmarks: ["my-bookmark"], + }, + }, +}); + +const server = new ApolloServer({ + schema, +}); +---- diff --git a/docs/asciidoc/schema/filtering.adoc b/docs/asciidoc/filtering.adoc similarity index 78% rename from docs/asciidoc/schema/filtering.adoc rename to docs/asciidoc/filtering.adoc index 8b1f2924ee..d6484e8195 100644 --- a/docs/asciidoc/schema/filtering.adoc +++ b/docs/asciidoc/filtering.adoc @@ -1,4 +1,4 @@ -[[schema-filtering]] +[[filtering]] = Filtering == Operators @@ -9,17 +9,17 @@ When querying for data, a number of operators are available for different types All types can be tested for either equality or non-equality. For the `Boolean` type, these are the only available comparison operators. -[[schema-filtering-numerical-operators]] +[[filtering-numerical-operators]] === Numerical operators -The following comparison operators are available for numeric types (`Int`, `Float`, <>), temporal types (<>) and spatial types (<>, <>): +The following comparison operators are available for numeric types (`Int`, `Float`, <>), <> and <>: * `_LT` * `_LTE` * `_GTE` * `_GT` -Filtering of spatial types is different to filtering of numerical types and offers an additional filter - see <>. +Filtering of spatial types is different to filtering of numerical types and also offers an additional filter - see <>. === String comparison @@ -32,12 +32,13 @@ The following case-sensitive comparison operators are only available for use on * `_CONTAINS` * `_NOT_CONTAINS` +[[filtering-regex]] ==== RegEx matching The filter `_MATCHES` is also available for comparison of `String` and `ID` types, which accepts a RegEx string as an argument and returns any matches. Note that RegEx matching filters are **disabled by default**. -We have added the ability to enable the inclusion of this filter by setting the config option `enableRegex` to `true`. +To enable the inclusion of this filter, set the config option `enableRegex` to `true`. > The nature of RegEx matching means that on an unprotected API, this could potentially be used to execute a ReDoS attack (https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS) against the backing Neo4j database. @@ -57,7 +58,7 @@ These four operators are available for all types apart from `Boolean`. == Usage -Using the type definitions from <>, below are some example of how filtering can be applied whilst querying for data. +Using the type definitions from <>, below are some example of how filtering can be applied when querying for data. === At the root of a Query diff --git a/docs/asciidoc/getting-started.adoc b/docs/asciidoc/getting-started.adoc index d6bff53064..a19df2fa54 100644 --- a/docs/asciidoc/getting-started.adoc +++ b/docs/asciidoc/getting-started.adoc @@ -1,79 +1,225 @@ [[getting-started]] = Getting Started -This section will help users get started with Neo4j GraphQL. Before starting we recommend readers have an understanding of the following **prerequisites;** +This tutorial walks you through: -1. https://developer.mozilla.org/en-US/docs/Web/JavaScript[JavaScript] -2. https://nodejs.org/en/[Node.js] -3. https://graphql.org/[GraphQL] -4. https://neo4j.com/[Neo4j] +- Installing the Neo4j GraphQL Library and its dependencies +- Defining type definitions that represent the structure of your graph database +- Instantiating an instance of the library, which will generate a GraphQL schema +- Running an instance of a server which will let you execute queries and mutations against your schema -== Installation +This tutorial assumes familiarity with the command line and JavaScript, and also that you have a recent version of Node.js installed. These examples will use the default `npm` package manager, but feel free to use your package manager of choice. +> This tutorial walks through creating a new project with the Neo4j GraphQL Library. If you are not familiar, it will be worthwhile reading up on https://neo4j.com/[Neo4j] and https://graphql.org/[GraphQL]. + +== Create a new project + +. Create a new directory and `cd` into it: ++ [source, bash] ---- -$ npm install @neo4j/graphql +mkdir neo4j-graphql-example +cd neo4j-graphql-example ---- ++ +. Create a new Node.js project: -`graphql` and `neo4j-driver` are **peerDependencies**, so unless the project already has them installed they need to be installed as well. - -== Requiring - -The Library is transpiled, from our TypeScript source, into https://nodejs.org/docs/latest/api/modules.html#modules_modules_commonjs_modules[Common JS] - This means you can use the `require` or the `import` syntax to access the library's exported members, depending on your environment and tooling setup; - -[source, javascript] +[source, bash] ---- -const { Neo4jGraphQL } = require("@neo4j/graphql"); +npm init --yes ---- -or +Whilst you're there, create an empty `index.js` file which will contain all of the code for this example: -[source, javascript] +[source, bash] ---- -import { Neo4jGraphQL } from "@neo4j/graphql"; +touch index.js ---- -== GraphQL API Quick Start +== Install dependencies + +The Neo4j GraphQL Library and it's dependencies must be installed: + +- `@neo4j/graphql` is the official Neo4j GraphQL Library package, which takes your GraphQL type definitions and generates a schema backed by a Neo4j database for you. +- `graphql` is the package used by the Neo4j GraphQL Library to generate a schema and execute queries and mutations. +- `neo4j-driver` is the official Neo4j Driver package for JavaScript, of which an instance must be passed into the Neo4j GraphQL Library. + +Additionally, you will need to install a GraphQL server package which will host your schema and allow you to execute queries and mutations against it. For this example, use the popular https://www.apollographql.com/docs/apollo-server/[Apollo Server] package: -This section demonstrates using https://www.apollographql.com/docs/apollo-server/[Apollo Server] alongside Neo4jGraphQL to spin up a GraphQL API. +- `apollo-server` is the default package for Apollo Server, which you will pass the Neo4j GraphQL Library generated schema into. [source, bash] ---- -$ npm install @neo4j/graphql graphql neo4j-driver apollo-server +npm install @neo4j/graphql graphql neo4j-driver apollo-server ---- +== Define your GraphQL type definitions + +The Neo4j GraphQL Library is primarily driven by type definitions which map to the nodes and relationships in your Neo4j database. To get started, use a simple example with two node types, one with label "Actor" and the other "Movie". + +Open up the previously created `index.js` in your editor of choice and write out your type definitions. You should also add all of the necessary package imports at this stage: + [source, javascript] ---- const { Neo4jGraphQL } = require("@neo4j/graphql"); +const { ApolloServer, gql } = require("apollo-server"); const neo4j = require("neo4j-driver"); -const { ApolloServer } = require("apollo-server"); -const typeDefs = ` +const typeDefs = gql` type Movie { title: String - year: Int - imdbRating: Float - genres: [Genre] @relationship(type: "IN_GENRE", direction: OUT) + actors: [Actor] @relationship(type: "ACTED_IN", direction: IN) } - type Genre { + type Actor { name: String - movies: [Movie] @relationship(type: "IN_GENRE", direction: IN) + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) } `; +---- +These type definitions are incredibly simple, defining the two previously described node labels, and a relationship "ACTED_IN" between the two. When generated, the schema will allow you to execute queries `actors` and `movies` to read data from the database. + +== Create an instance of `Neo4jGraphQL` + +Now that you have your type definitions, you need to create an instance of the Neo4j GraphQL Library. To do this, you also need a Neo4j driver to connect to your database. For a database located at "bolt://localhost:7687", with a username of "neo4j" and a password of "password", add the following to the bottom of your `index.js` file: + +[source, javascript] +---- const driver = neo4j.driver( "bolt://localhost:7687", - neo4j.auth.basic("neo4j", "letmein") + neo4j.auth.basic("neo4j", "password") ); const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); +---- + +== Create an instance of `ApolloServer` + +The final section of code you need to add is to instantiate an Apollo Server instance using the generated schema, which will allow you to execute queries against it. + +Add the following to the bottom of `index.js`: +[source, javascript] +---- const server = new ApolloServer({ schema: neoSchema.schema, - context: ({ req }) => ({ req }), }); -server.listen(4000).then(() => console.log("Online")); +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); ---- +== Start the server + +Finally, you're ready to start up your GraphQL server! Back in the command line, run the following command: + +[source, bash] +---- +node index.js +---- + +All going well, you should see the following output: + +[source, bash] +---- +🚀 Server ready at http://localhost:4000/ +---- + +Where http://localhost:4000/ is the default URL which Apollo Server starts at. + +== Create your first nodes in the database + +Now it's time to add some data to your Neo4j database using your GraphQL API! + +Visit http://localhost:4000/ in your web browser and you'll see the following landing page: + +image::apollo-server-landing-page.png[title="Apollo Server Landing Page"] + +Click "Query your server" which will open the Sandbox. + +image::first-mutation.png[title="First Mutation"] + +At the moment your database is empty! To get some data in there, you can create a movie and an actor in that movie, all in one Mutation. The Mutation in the screenshot above can also be found below: + +[source, graphql] +---- +mutation { + createMovies( + input: [ + { + title: "Forrest Gump" + actors: { create: [{ node: { name: "Tom Hanks" } }] } + } + ] + ) { + movies { + title + actors { + name + } + } + } +} +---- + +Put this Mutation into the Operations panel and hit the blue "Run" button in the top right. When you execute the Mutation, you'll receive the following response, confirmation that the data has been created in the database! + +[source, json] +---- +{ + "data": { + "createMovies": { + "movies": [ + { + "title": "Forrest Gump", + "actors": [ + { + "name": "Tom Hanks" + } + ] + } + ] + } + } +} +---- + +You can now go back and query the data which you just added: + +image::first-query.png[title="First Query"] + +The query in the screenshot above is querying for all movies and their actors in the database: + +[source, graphql] +---- +query { + movies { + title + actors { + name + } + } +} +---- + +Of course, you only have the one of each, so you will see the result below: + +[source, json] +---- +{ + "data": { + "movies": [ + { + "title": "Forrest Gump", + "actors": [ + { + "name": "Tom Hanks" + } + ] + } + ] + } +} +---- diff --git a/docs/asciidoc/guides/2.0.0-migration/index.adoc b/docs/asciidoc/guides/2.0.0-migration/index.adoc new file mode 100644 index 0000000000..362fac5816 --- /dev/null +++ b/docs/asciidoc/guides/2.0.0-migration/index.adoc @@ -0,0 +1,20 @@ + +[[v2-migration]] += 2.0.0 Migration + +Version 2.0.0 of `@neo4j/graphql` adds support for relationship properties, with some breaking changes to facilitate these new features. All of the required changes will be on the client side, and this guide will walk through what has changed. + +== How to Upgrade + +Simply update `@neo4j/graphql` using npm or your package manager of choice: + +[source, bash] +---- +npm update @neo4j/graphql +---- + +From this point on, it is primarily Mutations which will form the bulk of the migration: + +1. <> for how you need to change your Mutations to work with the new schema +2. <> for how querying union fields has changed in version 2.0.0 +3. <> for other changes in version 2.0.0 diff --git a/docs/asciidoc/guides/2.0.0-migration/miscellaneous.adoc b/docs/asciidoc/guides/2.0.0-migration/miscellaneous.adoc new file mode 100644 index 0000000000..db0257c992 --- /dev/null +++ b/docs/asciidoc/guides/2.0.0-migration/miscellaneous.adoc @@ -0,0 +1,89 @@ +[[v2-migration-miscellaneous]] += Miscellaneous + +== `skip` renamed to `offset` + +In the release of Apollo Client 3.0, it became a bit more opinionated about pagination, favouring `offset` and `limit` over `skip` and `limit`. Acknowledging that the majority of users will be using Apollo Client 3.0, the page-based pagination arguments have been updated to align with this change. + +For example, fetching page 3 of pages of 10 movies would have looked like the following in version `1.x`: + +[source, graphql] +---- +query { + movies(options: { skip: 20, limit: 10 }) { + title + } +} +---- + +This will now need to queried as follows: + +[source, graphql] +---- +query { + movies(options: { offset: 20, limit: 10 }) { + title + } +} +---- + +== Count queries + +Whilst not a necessary migration step, if you are using page-based pagination, it's important to note the addition of count queries in version 2.0.0. These will allow you to calculate the total number of pages for a particular filter, allowing you to implement much more effective pagination. + +== Schema validation + +In version 2.0.0, there are greater levels of schema validation. However, upon upgrading, you might find that validation is too strict (for example if using certain generated types in your definitions). You can temporarily disable this new validation on construction: + +[source, javascript] +---- +const neoSchema = new Neo4jGraphQL({ + typeDefs, + config: { + skipValidateTypeDefs: true, + }, +}) +---- + +If you need to do this, please report the scenario as an issue on GitHub. + +== `_IN` and `_NOT_IN` filters on relationships removed + +There were previously `_IN` and `_NOT_IN` filters for one-to-many and one-to-one relationships, but these were surplus to requirements, and didn't match for all cardinalities (many-to-many relationships don't have `_INCLUDES` and `_NOT_INCLUDES`). These may be added back in the future if and when we look more holistically at distinguishing between different relationship cardinalities. + +You can still achieve identical filters through different routes. For example, if you had the following schema: + +[source, graphql] +---- +type Movie { + title: String! + director: Director @relationship(type: "DIRECTED", direction: IN) +} + +type Director { + name: String! + movies: [Movie!]! @relationship(type: "DIRECTED", direction: OUT) +} +---- + +You would have been able to run the following query: + +[source, graphql] +---- +query { + movies(where: { director_IN: [{ name: "A" }, { name: "B" }] }) { + title + } +} +---- + +You can still achieve exactly the same filter with the following: + +[source, graphql] +---- +query { + movies(where: { director: { OR: [{ name: "A" }, { name: "B" }]} }) { + title + } +} +---- diff --git a/docs/asciidoc/guides/2.0.0-migration/mutations.adoc b/docs/asciidoc/guides/2.0.0-migration/mutations.adoc new file mode 100644 index 0000000000..6e5384ad01 --- /dev/null +++ b/docs/asciidoc/guides/2.0.0-migration/mutations.adoc @@ -0,0 +1,363 @@ +[[v2-migration-mutations]] += Mutations + +The most broadly affected area of functionality by the 2.0.0 upgrade are the nested operations of Mutations, to faciliate the mutation of and filtering on relationship properties. + +The examples in this section will be based off the following type definitions: + +[source, graphql] +---- +type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) +} + +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} +---- + +The theme that you will notice during this section is that as a general rule of thumb, a `node` field will need adding to your inputs where it will also be possible to filter on relationship properties. + +[[v2-migration-mutations-create]] +== Create + +Focussing on the `createMovies` Mutation, notice that the definition of the `createMovies` Mutation is unchanged: + +[source, graphql] +---- +input MovieCreateInput { + title: String! + actors: MovieActorsFieldInput +} + +type Mutation { + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! +} +---- + +There are no changes to any of the arguments or types at this level. However, within its nested operations, type modifications have taken place to allow for relationship properties. + +In practice, take a Mutation that creates the film "The Dark Knight" and then: + +* Creates a new actor "Heath Ledger" +* Connects to the existing actor "Christian Bale" + +In the previous version of the library, this would have looked like this: + +[source, graphql] +---- +mutation { + createMovies( + input: [ + { + title: "The Dark Knight" + actors: { + create: [ + { + name: "Heath Ledger" + } + ] + connect: [ + { + where: { + name: "Christian Bale" + } + } + ] + } + } + ] + ) { + movies { + title + } + } +} +---- + +This will now have to look like this in order to function in the same way: + +[source, graphql] +---- +mutation { + createMovies( + input: [ + { + title: "The Dark Knight" + actors: { + create: [ + { + node: { + name: "Heath Ledger" + } + } + ] + connect: [ + { + where: { + node: { + name: "Christian Bale" + } + } + } + ] + } + } + ] + ) { + movies { + title + } + } +} +---- + +Note the additional level "node" before specifying the actor name for the create operation and in the connect where. This additional level allows for the setting of relationship properties for the new relationship, and filtering on existing relationship properties when looking for the node to connect to. See the page <> for details on this. + +== Update + +Focussing on the `updateMovies` Mutation, notice that the definition of the `updateMovies` Mutation is unchanged: + +[source, graphql] +---- +type Mutation { + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} +---- + +The `create` and `connect` nested operations are primarily the same as in the `createMovies` Mutation, so please see the <> section for the difference for these operations. + +The `delete` nested operation is primarily the same as in the `deleteMovies` Mutation, so please see the <> section for that. + +=== Update + +For example, say that you accidentally misspelt Christian Bale's surname and wanted to fix that. In the previous version, you might have achieved that by: + +[source, graphql] +---- +mutation { + updateMovies( + where: { + title: "The Dark Knight" + } + update: { + actors: [ + { + where: { + name_ENDS_WITH: "Bail" + } + update: { + name: "Christian Bale" + } + } + ] + } + ) { + movies { + title + actors { + name + } + } + } +} +---- + +This will now have to look like this in order to function in the same way: + +[source, graphql] +---- +mutation { + updateMovies( + where: { + title: "The Dark Knight" + } + update: { + actors: [ + { + where: { + node: { + name_ENDS_WITH: "Bail" + } + } + update: { + node: { + name: "Christian Bale" + } + } + } + ] + } + ) { + movies { + title + actors { + name + } + } + } +} +---- + +Note the added layer of abstraction of `node` in both the `where` and `update` clauses. + +=== Disconnect + +For example, say you mistakenly put Ben Affleck as playing the role of Batman in "The Dark Knight", and you wanted to disconnect those nodes. In the previous version, this would have looked like: + +[source, graphql] +---- +mutation { + updateMovies( + where: { + title: "The Dark Knight" + } + disconnect: { + actors: [ + { + where: { + name: "Ben Affleck" + } + } + ] + } + ) { + movies { + title + actors { + name + } + } + } +} +---- + +This will now have to look like this in order to function in the same way: + +[source, graphql] +---- +mutation { + updateMovies( + where: { + title: "The Dark Knight" + } + disconnect: { + actors: [ + { + where: { + node: { + name: "Ben Affleck" + } + } + } + ] + } + ) { + movies { + title + actors { + name + } + } + } +} +---- + +[[v2-migration-mutations-delete]] +== Delete + +Focussing on the `deleteMovies` Mutation, notice that the definition of the `deleteMovies` Mutation is unchanged: + +[source, graphql] +---- +input MovieDeleteInput { + actors: [MovieActorsDeleteFieldInput!] +} + +type Mutation { + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! +} +---- + +There are no changes to any of the arguments or types at this level, but there are some details to note in the `MovieActorsDeleteFieldInput` type. + +Previously, you would have expected this to look like: + +[source, graphql] +---- +input MovieActorsDeleteFieldInput { + delete: ActorDeleteInput + where: ActorWhere +} +---- + +This allowed you to filter on fields of the `Actor` type and delete based on that. However, following this upgrade, you will find: + +[source, graphql] +---- +input MovieActorsDeleteFieldInput { + delete: ActorDeleteInput + where: MovieActorsConnectionWhere +} +---- + +This means that not only can you filter on node properties, but also relationship properties, in order to find and delete `Actor` nodes. + +In practice, a Mutation that deletes the film "The Dark Knight" and the related actor "Christian Bale" would have previously looked like this: + +[source, graphql] +---- +mutation { + deleteMovies( + where: { + title: "The Dark Knight" + } + delete: { + actors: { + where: { + name: "Christian Bale" + } + } + } + ) { + nodesDeleted + relationshipsDeleted + } +} +---- + +This will now have to look like this in order to function in the same way: + +[source, graphql] +---- +mutation { + deleteMovies( + where: { + title: "The Dark Knight" + } + delete: { + actors: { + where: { + node: { + name: "Christian Bale" + } + } + } + } + ) { + nodesDeleted + relationshipsDeleted + } +} +---- + +Note the additional level "node" before specifying the actor name. diff --git a/docs/asciidoc/guides/2.0.0-migration/unions.adoc b/docs/asciidoc/guides/2.0.0-migration/unions.adoc new file mode 100644 index 0000000000..a5fe7635f3 --- /dev/null +++ b/docs/asciidoc/guides/2.0.0-migration/unions.adoc @@ -0,0 +1,134 @@ +[[v2-migration-unions]] += Unions + +In this release, the decision was made to take the opportunity to overhaul the existing support for unions on relationship fields, laying down the foundations for adding top-level union support in the future. + +All examples in this section will be based off the following type definitions: + +[source, graphql] +---- +type Actor { + name: String! + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) +} + +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +type Series { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +union Production = Movie | Series +---- + +== Input types + +The structure of input types for union queries and mutations have been changed for user friendliness, and a more consistent API. + +Essentially, field names which were previously of template `${unionFieldName}_${concreteType}` (for example, "actedIn_Movie") are now an object, with the field name at the top, and the member types under it. + +For example, a Mutation which would have previously been: + +[source, graphql] +---- +mutation { + createActors( + input: [ + { + name: "Tom Hiddleston" + actedIn_Movie: { + create: [ + { + title: "The Avengers" + } + ] + } + actedIn_Series: { + create: [ + { + title: "Loki" + } + ] + } + } + ] + ) +} +---- + +Will now be: + +[source, graphql] +---- +mutation { + createActors( + input: [ + { + name: "Tom Hiddleston" + actedIn: { + Movie: { + create: [ + { + node: { + title: "The Avengers" + } + } + ] + } + Series: { + create: [ + { + node: { + title: "Loki" + } + } + ] + } + } + } + ] + ) +} +---- + +Note the change in structure for union input, but also the additional `node` level which enables the use of relationship properties. These changes are consistent across all operations, including `where`. + +== Filtering union fields + +There has been a slight change to how you filter union fields, adding a `where` level above each union member. For example, for a query which would have used to have looked like: + +[source, graphql] +---- +query { + actors { + name + actedIn(Movie: { "The Avengers" }) { + ... on Movie { + title + } + } + } +} +---- + +This will now be written like: + +[source, graphql] +---- +query { + actors { + name + actedIn(where: { Movie: { "The Avengers" }}) { + ... on Movie { + title + } + } + } +} +---- + +Furthermore, the where argument used now dictates which union members are returned from the database, to prevent overfetching. Please see <> for background and explanation of this decision. diff --git a/docs/asciidoc/guides/index.adoc b/docs/asciidoc/guides/index.adoc index 54e6f9f573..a052c2d189 100644 --- a/docs/asciidoc/guides/index.adoc +++ b/docs/asciidoc/guides/index.adoc @@ -4,3 +4,4 @@ Here you can find a selection of guides to help you work with the Neo4j GraphQL Library. 1. <> - migrating from `neo4j-graphql-js` to `@neo4j/graphql` +2. <> - migrating from version 1.* of `@neo4j/graphql` to version 2.* for relationship property support diff --git a/docs/asciidoc/guides/migration-guide/mutations.adoc b/docs/asciidoc/guides/migration-guide/mutations.adoc index 307308ed55..e0510dfd47 100644 --- a/docs/asciidoc/guides/migration-guide/mutations.adoc +++ b/docs/asciidoc/guides/migration-guide/mutations.adoc @@ -26,7 +26,7 @@ A summary of migration points is as follows: * The object(s) being mutated are returned as a nested field, to allow for metadata about the operation to be added in future * Mutation arguments are now commonly named between different types, but with different input types - such as `where` and `input` -> Note that <> in `@neo4j/graphql` are incredibly powerful, and it is well worthwhile reading about them in full. You might find that you can collapse multiple current mutations down into one! +> Note that <> in `@neo4j/graphql` are incredibly powerful, and it is well worthwhile reading about them in full. You might find that you can collapse multiple current mutations down into one! == Creating @@ -121,7 +121,7 @@ mutation { == Connecting -Using `neo4j-graphql-js`, connecting two of the nodes in our example would have been achieved by executing either the `AddMovieActors` or `AddActorMovies` Mutation. +Using `neo4j-graphql-js`, connecting two of the nodes in this example would have been achieved by executing either the `AddMovieActors` or `AddActorMovies` Mutation. In `@neo4j/graphql`, this is achieved by specifying the `connect` argument on either the `updateMovies` or `updateActors` Mutation. @@ -148,7 +148,7 @@ Would become the following using `@neo4j/graphql`: mutation { updateMovies( where: { title: "Forrest Gump" } - connect: { actors: { where: { name: "Tom Hanks" } } } + connect: { actors: { where: { node: { name: "Tom Hanks" } } } } ) { movies { title @@ -164,7 +164,7 @@ Note, there are many ways to achieve the same goal using the powerful Mutation a == Disconnecting -Similarly to connecting, using `neo4j-graphql-js`, disconnecting two of the nodes in our example would have been achieved by executing either the `RemoveMovieActors` or `RemoveActorMovies` Mutation. +Similarly to connecting, using `neo4j-graphql-js`, disconnecting two of the nodes in this example would have been achieved by executing either the `RemoveMovieActors` or `RemoveActorMovies` Mutation. In `@neo4j/graphql`, this is achieved by specifying the `disconnect` argument on either the `updateMovies` or `updateActors` Mutation. @@ -191,7 +191,7 @@ Would become the following using `@neo4j/graphql`: mutation { updateMovies( where: { title: "Forrest Gump" } - disconnect: { actors: { where: { name: "Tom Hanks" } } } + disconnect: { actors: { where: { node: { name: "Tom Hanks" } } } } ) { movies { title diff --git a/docs/asciidoc/guides/migration-guide/queries.adoc b/docs/asciidoc/guides/migration-guide/queries.adoc index f06ce06274..a5537cfde9 100644 --- a/docs/asciidoc/guides/migration-guide/queries.adoc +++ b/docs/asciidoc/guides/migration-guide/queries.adoc @@ -34,7 +34,7 @@ Changes to note for migration: * Query fields were previously named in the singular, and in _PascalCase_ - they are now pluralized and in _camelCase_ * Query return types were previously in nullable lists of nullable types - they are now non-nullable lists of non-nullable types, _e.g._ `[Movie]` is now `[Movie!]!`; ensuring either an array of defined `Movie` objects or an empty array. * In this example, the `_MovieFilter` type has essentially been renamed to `MovieWhere`, the `filter` arguments renamed to `where`, and the top-level field arguments `title` and `averageRating` removed - see <> below -* The `first`, `offset` and `orderBy` have been collapsed into the `MovieOptions` type and renamed `limit`, `skip` and `sort`, respectively - see <> below +* The `first`, `offset` and `orderBy` have been collapsed into the `MovieOptions` type and renamed `limit`, `offset` and `sort`, respectively - see <> below [[migration-guide-queries-filtering]] == Filtering (`where`) @@ -206,7 +206,7 @@ Using `@neo4j/graphql`, this will now be: [source, graphql] ---- query { - movies(options: { skip: 20, limit: 10 }) { + movies(options: { offset: 20, limit: 10 }) { title } } diff --git a/docs/asciidoc/guides/migration-guide/server.adoc b/docs/asciidoc/guides/migration-guide/server.adoc index 0b9a256126..2f1ebea082 100644 --- a/docs/asciidoc/guides/migration-guide/server.adoc +++ b/docs/asciidoc/guides/migration-guide/server.adoc @@ -84,4 +84,4 @@ server.listen().then(({ url }) => { }); ---- -Database bookmarks are also supported. See <> for more information. +Database bookmarks are also supported. See <> for more information. diff --git a/docs/asciidoc/guides/migration-guide/type-definitions.adoc b/docs/asciidoc/guides/migration-guide/type-definitions.adoc index 68760e8591..2f933a56de 100644 --- a/docs/asciidoc/guides/migration-guide/type-definitions.adoc +++ b/docs/asciidoc/guides/migration-guide/type-definitions.adoc @@ -3,8 +3,6 @@ This page will walk through what needs to change in your type definitions before you can pass them into `@neo4j/graphql`. -> An important discrepancy to note at this early stage is that relationship type definitions are not yet supported in `@neo4j/graphql`, and as such, neither are relationship properties. This is in the pipeline, so keep an eye out for any news relating to this! - == Directives Both `neo4j-graphql-js` and `@neo4j/graphql` are highly driven by GraphQL directives. Each heading in this section will address how/if one or many directives available in `neo4j-graphql-js` can be migrated to `@neo4j/graphql`. @@ -20,6 +18,55 @@ For example, `@relation(name: "ACTED_IN", direction: OUT)` becomes `@relationshi See <> for more information on relationships in `@neo4j/graphql`. +=== Relationship Properties + +If for instance using `neo4j-graphql-js`, you have the following type definitions defining an `ACTED_IN` relationship with a `roles` property: + +[source, graphql] +---- +type Actor { + movies: [ActedIn!]! +} + +type Movie { + actors: [ActedIn!]! +} + +type ActedIn @relation(name: "ACTED_IN") { + from: Actor + to: Movie + roles: [String!] +} +---- + +This will need to be refactored to the following in the new library: + +[source, graphql] +---- +type Actor { + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +type Movie { + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +interface ActedIn { + roles: [String!] +} +---- + +Note the following changes to the `ActedIn` type: + +* Changed from `type` to `interface` +* Removed `@relation` directive +* Removed `from` and `to` fields + +And note the following changes to the two node types: + +* Relationship field types changed from the relationship type to the neighbouring node type +* Normal `@relationship` directive added to each relationship field, with an additional `properties` argument pointing to the relationship properties interface + === `@cypher` No change. See <> for more details on this directive in `@neo4j/graphql`. @@ -55,7 +102,7 @@ Supported as you would expect, with additional <> === Temporal Types (`DateTime`, `Date`) -Temporal Types have been massively simplified in `@neo4j/graphql`, down to `DateTime` and `Date`, which uses ISO 8601 and "yyyy-mm-dd" strings respectively for parsing and serialization. +Temporal Types have been massively simplified in `@neo4j/graphql`, down to `DateTime` and `Date`, which use ISO 8601 and "yyyy-mm-dd" strings respectively for parsing and serialization. In terms of migrating from the old library, the `formatted` field of the old `DateTime` type now becomes the value itself. For example, used in a query: @@ -79,9 +126,9 @@ Has become: } ---- -Due to the move to ISO 8601 strings, input types are no longer necessary for temporal instances, so `_Neo4jDateTimeInput` has simply become `DateTime` for input. +Due to the move to ISO 8601 strings, input types are no longer necessary for temporal instances, so `_Neo4jDateTimeInput` has become `DateTime` and `_Neo4jDateInput` has become `Date` for input. -See <>. +See <>. === Spatial Types @@ -104,3 +151,16 @@ Interface Types are not yet supported in `@neo4j/graphql`. `neo4j-graphql-js` le === Union Types Supported, queryable using inline fragments as per `neo4j-graphql-js`, but can also be created using Nested Mutations. See <>. + +== Fields + +=== `_id` + +An `_id` field exposing the underlying node ID is not included in each type by default in `@neo4j/graphql` like it was in `neo4j-graphql-js`. If you require this functionality (however, it should be noted that underlying node IDs should not be relied on because they can be reused), you can include a field definition such as in the following type definition: + +[source, graphql] +---- +type ExampleType { + _id: ID! @cypher(statement: "RETURN ID(this)") +} +---- diff --git a/docs/asciidoc/index.adoc b/docs/asciidoc/index.adoc index 7922ddb23b..813a332375 100644 --- a/docs/asciidoc/index.adoc +++ b/docs/asciidoc/index.adoc @@ -18,44 +18,25 @@ ifdef::backend-pdf[] Documentation license: <> endif::[] - -Welcome to the official documentation for the Neo4j GraphQL Library. - -== Introduction - -In this section you will find; notes on background information, links and resources, dependencies and requirements, plus pointers on where to get support. If you are already familiar with Neo4j GraphQL, or just want to get started, then jump directly to <>. - -== Why Neo4j and GraphQL? - -Bringing the native graph storage of Neo4j to the GraphQL ecosystem has been a long time in the making. Translating your GraphQL queries to Cypher that the Neo4j database can understand means that we can easily eliminate the n+1 issue that plagues a lot of GraphQL implementations. - -== What is Neo4j GraphQL ? - -It is a GraphQL to Cypher query execution layer for Neo4j and **JavaScript** GraphQL implementations. Such an implementation makes it easier for developers to use Neo4j and GraphQL together. - -Taking a set of Type Definitions; we produce an executable GraphQL Schema, where a given GraphQL query is transformed into a single Cypher query. This translation means users can let the library handle all the database communications, and thus enabling users to focus on building great applications. - -Our GraphQL implementation exposes two products; - -1. `Neo4jGraphQL` - Used for GraphQL API's such as Apollo Server -2. <> - A Handy tool, to use in application code, driven by GraphQL Type Definitions. - -With a powerful feature set including; - -1. <> -2. Nested Mutations - -Overall anyone in the Javascript ecosystem, wanting to use either Neo4j and or GraphQL should find Neo4j GraphQL an indispensable tool for building great applications. - -== Requirements -1. https://neo4j.com/[Neo4j Database] 4.1.0 and above -2. https://neo4j.com/developer/neo4j-apoc/[APOC] 4.1.0 and above - -== Resources -1. https://github.com/neo4j/graphql[Github] -2. https://github.com/neo4j/graphql/issues[Bug Tracker] -3. https://www.npmjs.com/package/@neo4j/graphql[NPM] - -== Licence -1. Documentation: link:{common-license-page-uri}[Creative Commons 4.0] -2. Source: https://www.apache.org/licenses/LICENSE-2.0[Apache 2.0] +> This is the documentation for the Neo4j GraphQL Library version 2.0, authored by the Neo4j GraphQL Team. + +This documentation covers the following topics: + +- <> - Introduction to the Neo4j GraphQL Library. +- <> - Start here if you want to quickly get up and running with the library. +- <> - Define your nodes and relationships using type definitions as documented here. +- <> - GraphQL Queries allow you to read data in your Neo4j database. +- <> - GraphQL Mutations allow you to change data in your Neo4j database. +- <> - This chapter covers how to filter your data in Queries and Mutations. +- <> - This chapter covers how to sort the data being returned. +- <> - This chapter covers the pagination options offered by the Neo4j GraphQL Library. +- <> - Learn how to implement custom functionality accessible through your API. +- <> - Covers the authentication and authorisation options offered by this library. +- <> - An index of all of the directives offered by the Neo4j GraphQL Library. +- <> - API reference for constructing an instance of the library. +- <> - This chapter covers the OGM (Object Graph Mapper), a programmatic way of using your API. +- <> - How to configure a database driver for use with this library. +- <> - Guides for usage of the library, including migration guides for moving between versions. +- <> - Having problems with the library? See if your problem has been found and solved before. + +This manual is primarily written for software engineers building an API using the Neo4j GraphQL Library. diff --git a/docs/asciidoc/introduction.adoc b/docs/asciidoc/introduction.adoc new file mode 100644 index 0000000000..b105e70d28 --- /dev/null +++ b/docs/asciidoc/introduction.adoc @@ -0,0 +1,79 @@ +[[introduction]] += Introduction + +The Neo4j GraphQL Library is a highly flexible, low-code, open source JavaScript library that enables rapid API development for cross-platform and mobile applications by tapping into the power of connected data. + +With Neo4j as the graph database, the GraphQL Library makes it simple for applications to have application data treated as a graph natively from the front-end all the way to storage, avoiding duplicate schema work and ensuring flawless integration between front-end and backend developers. + +Written in TypeScript, the library's schema-first paradigm lets developers focus on the application data they need, while taking care of the heavy lifting involved in building the API. + +> Just want to get moving with the Neo4j GraphQL Library? Check out the <> guide! + +== How does it work? + +By supplying the Neo4j GraphQL Library with a set of type definitions describing the shape of your graph data, it can generate an entire executable schema with all of the additional types needed to execute queries and mutations to interact with your Neo4j database. + +For every query and mutation that is executed against this generated schema, the Neo4j GraphQL Library generates a single Cypher query which is executed against the database. This eliminates the infamous https://www.google.com/search?q=graphql+n%2B1[N+1 Problem] which can make GraphQL implementations slow and inefficient. + +== Features + +The Neo4j GraphQL Library presents a large feature set for interacting with a Neo4j database using GraphQL: + +- Automatic generation of <> and <> for CRUD interactions +- Various <>, including temporal and spatial types +- Support for both node and relationship properties +- Extensibility through the <> and/or <> +- Extensive <> and <> options +- Options for value <> and <> +- Multiple <> options +- Comprehensive authentication and authorisation options (<>), and additional <> options +- An <> (Object Graph Mapper) for programmatic interaction with your GraphQL API + +== Interaction + +In the <> guide, Apollo Server is used to host the GraphQL schema. This bundles Apollo Sandbox which can be used to interact directly with your GraphQL API with no front-end. + +There are a variety of front-end frameworks with clients for interacting with GraphQL APIs: + +- https://reactjs.org/[React] - support through https://www.apollographql.com/docs/react/[Apollo Client] +- https://vuejs.org/[Vue.js] - support through https://apollo.vuejs.org/[Vue Apollo] +- https://angularjs.org/[AngularJS] - support through https://apollo-angular.com/docs/[Apollo Angular] + +== Deployment + +There are a variety of methods for deploying GraphQL APIs, the details of which will not be in this documentation. + +However, Apollo has documented a subset in their https://www.apollographql.com/docs/apollo-server/deployment[Deployment] documentation, which will be a good starting point. + +== Versioning + +The Neo4j GraphQL Library uses https://semver.org/[Semantic Versioning]. Given a version number `MAJOR.MINOR.PATCH`, the increment is based on: + +- `MAJOR` - incompatible API changes compared to the previous `MAJOR` version, for which you will likely have to migrate. +- `MINOR` - new features have been added in a backwards compatible manner. +- `PATCH` - bug fixes have been added in a backwards compatible manner. + +Additionally, prerelease version numbers may have additional suffixes, for example `MAJOR.MINOR.PATCH-PRERELEASE.NUMBER`, where `PRERELEASE` is one of the following: + +- `alpha` - unstable prerelease artifacts, and the API may change between releases during this phase. +- `beta` - feature complete prerelease artifacts, which will be more stable than `alpha` releases but will likely still contain bugs. +- `rc` - release candidate release artifacts where each could be promoted to a stable release, in a last effort to find trailing bugs. + +`NUMBER` in the suffix is simply an incrementing release number in each phase. + +== Requirements + +1. https://neo4j.com/[Neo4j Database] 4.1.5+ +2. https://neo4j.com/developer/neo4j-apoc/[APOC] 4.1.0+ +3. https://nodejs.org/en/[Node.js] 12+ + +== Resources + +1. https://github.com/neo4j/graphql[GitHub] +2. https://github.com/neo4j/graphql/issues[Issue Tracker] +3. https://www.npmjs.com/package/@neo4j/graphql[npm package] + +== Licence + +1. Documentation: link:{common-license-page-uri}[Creative Commons 4.0] +2. Source: https://www.apache.org/licenses/LICENSE-2.0[Apache 2.0] diff --git a/docs/asciidoc/mutations.adoc b/docs/asciidoc/mutations.adoc deleted file mode 100644 index 222d77f889..0000000000 --- a/docs/asciidoc/mutations.adoc +++ /dev/null @@ -1,4 +0,0 @@ -[[mutations]] -= Mutations - -See <>. diff --git a/docs/asciidoc/mutations/create.adoc b/docs/asciidoc/mutations/create.adoc new file mode 100644 index 0000000000..af059dc8c0 --- /dev/null +++ b/docs/asciidoc/mutations/create.adoc @@ -0,0 +1,98 @@ +[[mutations-create]] += Create + +Using the following type definitions for these examples: + +[source, graphql] +---- +type Post { + id: ID! @id + content: String! + creator: User @relationship(type: "HAS_POST", direction: IN) +} + +type User { + id: ID! @id + name: String + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) +} +---- + +The following create Mutations and response types will be generated for the above type definitions: + +[source, graphql] +---- +type CreatePostsMutationResponse { + posts: [Post!]! +} + +type CreateUsersMutationResponse { + users: [User!]! +} + +type Mutation { + createPosts(input: [PostCreateInput!]!): CreatePostsMutationResponse! + createUsers(input: [UsersCreateInput!]!): CreateUsersMutationResponse! +} +---- + +The `CreateInput` types closely mirror the object type definitions, allowing you to create not only the type in question, but to recurse down and perform further operations on related types in the same Mutation. + +> The `id` field will be absent from both create input types as the <> directive has been used. + +== Single create + +A single user can be created by executing the following GraphQL statement: + +[source, graphql] +---- +mutation { + createUsers(input: [ + { + name: "John Doe" + } + ]) { + users { + id + name + } + } +} +---- + +This will create a User with name "John Doe", and that name plus the autogenerated ID will be returned. + +== Nested create + +A User and an initial Post can be created by executing the following: + +[source, graphql] +---- +mutation { + createUsers(input: [ + { + name: "John Doe" + posts: { + create: [ + { + node: { + content: "Hi, my name is John!" + } + } + ] + } + } + ]) { + users { + id + name + posts { + id + content + } + } + } +} +---- + +This will create a User with name "John Doe", an introductory Post, both of which will be returned with their autogenerated IDs. diff --git a/docs/asciidoc/mutations/delete.adoc b/docs/asciidoc/mutations/delete.adoc new file mode 100644 index 0000000000..2387af3a19 --- /dev/null +++ b/docs/asciidoc/mutations/delete.adoc @@ -0,0 +1,114 @@ +[[mutations-delete]] += Delete + +Using the following type definitions for these examples: + +[source, graphql] +---- +type Post { + id: ID! @id + content: String! + creator: User @relationship(type: "HAS_POST", direction: IN) +} + +type User { + id: ID! @id + name: String + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) +} +---- + +The following delete Mutations and response type will be generated for the above type definitions: + +[source, graphql] +---- +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Mutation { + deletePosts(where: PostWhere, delete: PostDeleteInput): DeleteInfo! + deleteUsers(where: UserWhere, delete: UserDeleteInput): DeleteInfo! +} +---- + +Note that the `DeleteInfo` type is the common return type for all delete Mutations. + +== Single Delete + +A single post can be deleted by executing the following GraphQL statement: + +[source, graphql] +---- +mutation { + deletePosts(where: [ + { + id: "6042E807-47AE-4857-B7FE-1AADF522DE8B" + } + ]) { + nodesDeleted + relationshipsDeleted + } +} +---- + +This will delete the post using the autogenerated ID that would have been returned after that post's creation. + +`nodesDeleted` would equal 1 (the post) and `relationshipsDeleted` would also equal equal 1 (the `HAS_POST` relationship between the Post and its author). + +== Nested Delete + +Say that if when you delete a User, you want to delete _all_ of their Posts as well. This can be achieved using a single nested delete operations: + +[source, graphql] +---- +mutation { + deleteUsers( + where: [ + { + name: "Jane Doe" + } + ], + delete: { + posts: [ + where: { } + ] + } + ) { + nodesDeleted + relationshipsDeleted + } +} +---- + +You may look at that empty `where` argument and wonder what that's doing. By the time the traversal has reached that argument, it has the context of only posts that were created by Jane Doe, as the traversals to those Post nodes were from her User node. Essentially, the above query is equivalent to: + +[source, graphql] +---- +mutation { + deleteUsers( + where: [ + { + name: "Jane Doe" + } + ], + delete: { + posts: [ + where: { + node: { + creator: { + name: "Jane Doe" + } + } + } + ] + } + ) { + nodesDeleted + relationshipsDeleted + } +} +---- + +Slightly easier to reason with, but the output Cypher statement will have a redundant `WHERE` clause! diff --git a/docs/asciidoc/mutations/index.adoc b/docs/asciidoc/mutations/index.adoc new file mode 100644 index 0000000000..f59102acf9 --- /dev/null +++ b/docs/asciidoc/mutations/index.adoc @@ -0,0 +1,18 @@ +[[mutations]] += Mutations + +Several Mutations are automatically generated for each type defined in type definitions, which are covered in the following chapters: + +- <> - create nodes, and recursively create or connect further nodes in the graph +- <> - update nodes, and recursively perform any operations from there +- <> - delete nodes, and recursively delete or disconnect further nodes in the graph + +== A note on nested Mutations + +You will see some basic examples of nested Mutations in this chapter, which barely scratch the surface of what can be achieved with them. It's encouraged to explore the power of what you can do with them! + +However, it has to be noted that in order to provide the abstractions available in these Mutations, the output Cypher can end up being extremely complex, which can result in your database throwing out-of-memory errors depending on its configuration. + +If out-of-memory errors are a regular occurrence, you can adjust the `dbms.memory.heap.max_size` parameter in the DBMS settings. + +If you need to perform major data migrations, it may be best to manually write the necessary Cypher and execute this directly in the database. diff --git a/docs/asciidoc/mutations/update.adoc b/docs/asciidoc/mutations/update.adoc new file mode 100644 index 0000000000..72b305a00d --- /dev/null +++ b/docs/asciidoc/mutations/update.adoc @@ -0,0 +1,101 @@ +[[mutations-update]] += Update + +Using the following type definitions for these examples: + +[source, graphql] +---- +type Post { + id: ID! @id + content: String! + creator: User @relationship(type: "HAS_POST", direction: IN) +} + +type User { + id: ID! @id + name: String + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) +} +---- + +The following update Mutations and response types will be generated for the above type definitions: + +[source, graphql] +---- +type UpdatePostsMutationResponse { + posts: [Post!]! +} + +type UpdateUsersMutationResponse { + users: [User!]! +} + +type Mutation { + updatePosts( + where: PostWhere + update: PostUpdateInput + connect: PostConnectInput + disconnect: PostDisconnectInput + create: PostCreateInput + delete: PostDeleteInput + ): UpdatePostsMutationResponse! + updateUsers( + where: UserWhere + update: UserUpdateInput + connect: UserConnectInput + disconnect: UserDisconnectInput + create: UserCreateInput + delete: UserDeleteInput + ): UpdateUsersMutationResponse! +} +---- + +> The `id` field not be update-able as the <> directive has been used. + +== Single update + +Say you wanted to edit the content of a Post: + +[source, graphql] +---- +mutation { + updatePosts( + where: { + id: "892CC104-A228-4BB3-8640-6ADC9F2C2A5F" + } + update: { + content: "Some new content for this Post!" + } + ) { + posts { + content + } + } +} +---- + +== Nested create + +Instead of creating a Post and connecting it to a User, you could update a User and create a Post as part of the Mutation: + +[source, graphql] +---- +mutation { + updateUsers( + where: { name: "John Doe" } + create: { + posts: [ + { node: { content: "An interesting way of adding a new Post!" } } + ] + } + ) { + users { + id + name + posts { + content + } + } + } +} +---- diff --git a/docs/asciidoc/ogm/api-reference.adoc b/docs/asciidoc/ogm/api-reference.adoc deleted file mode 100644 index be34ea7cbc..0000000000 --- a/docs/asciidoc/ogm/api-reference.adoc +++ /dev/null @@ -1,59 +0,0 @@ -[[ogm-api-reference]] -= API Reference - -[[ogm-api-reference-ogm]] -== `OGM` - -=== Requiring -[source, javascript] ----- -const { OGM } = require("@neo4j/graphql-ogm"); ----- - -=== Constructing - -[source, javascript] ----- -const ogm = new OGM({ - typeDefs, - resolvers?, -}); ----- - -=== Methods - -==== `model()` -Reference: <> - -[[ogm-api-reference-model]] -== `Model` - -=== Requiring -[source, typescript] ----- -import type { Model } from "@neo4j/graphql-ogm" ----- - -=== Constructing - -You construct a model from invoking the `.model` method on an <>. - -[source, javascript] ----- -const model = ogm.model("name") ----- - -=== Methods - -==== `find()` -Reference: <> - -==== `create()` -Reference: <> - -==== `update()` -Reference: <> - -==== `delete()` -Reference: <> - diff --git a/docs/asciidoc/ogm/api-reference/index.adoc b/docs/asciidoc/ogm/api-reference/index.adoc new file mode 100644 index 0000000000..1c6371241c --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/index.adoc @@ -0,0 +1,5 @@ +[[ogm-api-reference]] += API Reference + +- <> +- <> diff --git a/docs/asciidoc/ogm/api-reference/model/count.adoc b/docs/asciidoc/ogm/api-reference/model/count.adoc new file mode 100644 index 0000000000..43b4d6c099 --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/model/count.adoc @@ -0,0 +1,35 @@ +[[ogm-api-reference-model-count]] += `count` + +Returns a `Promise` that resolvers to the count of nodes based on the arguments passed in. + +== Example + +To query for all User nodes: + +[source, javascript] +---- +const User = ogm.model("User"); + +const usersCount = await User.count(); +---- + +To query for User nodes where name starts with the letter "D": + +[source, javascript] +---- +const User = ogm.model("User"); + +const usersCount = await User.count({ where: { name_STARTS_WITH: "D" }}); +---- + +== Arguments + +|=== +|Name and Type |Description + +|`where` + + + + Type: `GraphQLWhereArg` +|A JavaScript object representation of the GraphQL `where` input type used for <>. +|=== diff --git a/docs/asciidoc/ogm/api-reference/model/create.adoc b/docs/asciidoc/ogm/api-reference/model/create.adoc new file mode 100644 index 0000000000..dc4acf19ec --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/model/create.adoc @@ -0,0 +1,48 @@ +[[ogm-api-reference-model-create]] += `create` + +This method can be used to update nodes, and maps to the underlying <> Mutation. + +Returns a `Promise` that resolves to the equivalent of the Mutation response for this operation. + +== Example + +To create a Movie with title "The Matrix": + +[source, javascript] +---- +const Movie = ogm.model("Movie"); + +await Movie.create({ input: [{ title: "The Matrix" }] }) +---- + +== Arguments + +|=== +|Name and Type |Description + +|`input` + + + + Type: `any` +|JavaScript object representation of the GraphQL `input` input type used for <> mutations. + +|`selectionSet` + + + + Type: `string` or `DocumentNode` or `SelectionSetNode` +|Selection set for the Mutation, see <> for more information. + +|`args` + + + + Type: `any` +|The `args` value for the GraphQL Mutation. + +|`context` + + + + Type: `any` +|The `context` value for the GraphQL Mutation. + +|`rootValue` + + + + Type: `any` +|The `rootValue` value for the GraphQL Mutation. +|=== diff --git a/docs/asciidoc/ogm/api-reference/model/delete.adoc b/docs/asciidoc/ogm/api-reference/model/delete.adoc new file mode 100644 index 0000000000..ecca36d2d4 --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/model/delete.adoc @@ -0,0 +1,57 @@ +[[ogm-api-reference-model-delete]] += `delete` + +This method can be used to delete nodes, and maps to the underlying <> Mutation. + +Returns a `Promise` which resolvers to a `DeleteInfo` object: + +|=== +|Name and Type |Description + +|`nodesDeleted` + + + + Type: `number` +|The number of nodes deleted. + +|`relationshipsDeleted` + + + + Type: `number` +|The number of relationships deleted. +|=== + +== Example + +To delete all User nodes where the name is "Dan": + +[source, javascript] +---- +const User = ogm.model("User"); + +await User.delete({ where: { name: "Dan" }}); +---- + +== Arguments + +|=== +|Name and Type |Description + +|`where` + + + + Type: `GraphQLWhereArg` +|A JavaScript object representation of the GraphQL `where` input type used for <>. + +|`delete` + + + + Type: `string` or `DocumentNode` or `SelectionSetNode` +|A JavaScript object representation of the GraphQL `delete` input type used for <> Mutations. + +|`context` + + + + Type: `any` +|The `context` value for the GraphQL Mutation. + +|`rootValue` + + + + Type: `any` +|The `rootValue` value for the GraphQL Mutation. +|=== diff --git a/docs/asciidoc/ogm/api-reference/model/find.adoc b/docs/asciidoc/ogm/api-reference/model/find.adoc new file mode 100644 index 0000000000..5484a9604e --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/model/find.adoc @@ -0,0 +1,62 @@ +[[ogm-api-reference-model-find]] += `find` + +This method can be used to find nodes, and maps to the underlying schema <>. + +Returns a `Promise` which resolvers to an array of objects matching the type of the Model. + +== Example + +To find all user nodes in the database: + +[source, javascript] +---- +const User = ogm.model("User"); + +const users = await User.find(); +---- + +To find users with name "Jane Smith": + +[source, javascript] +---- +const User = ogm.model("User"); + +const users = await User.find({ where: { name: "Jane Smith" }}); +---- + +== Arguments + +|=== +|Name and Type |Description + +|`where` + + + + Type: `GraphQLWhereArg` +|A JavaScript object representation of the GraphQL `where` input type used for <>. + +|`options` + + + + Type: `GraphQLOptionsArg` +|A JavaScript object representation of the GraphQL `options` input type used for <> and <>. + +|`selectionSet` + + + + Type: `string` or `DocumentNode` or `SelectionSetNode` +|Selection set for the Mutation, see <> for more information. + +|`args` + + + + Type: `any` +|The `args` value for the GraphQL Mutation. + +|`context` + + + + Type: `any` +|The `context` value for the GraphQL Mutation. + +|`rootValue` + + + + Type: `any` +|The `rootValue` value for the GraphQL Mutation. +|=== diff --git a/docs/asciidoc/ogm/api-reference/model/index.adoc b/docs/asciidoc/ogm/api-reference/model/index.adoc new file mode 100644 index 0000000000..552c97c272 --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/model/index.adoc @@ -0,0 +1,22 @@ +[[ogm-api-reference-model]] += `Model` + +== `create` + +See <>. + +== `find` + +See <>. + +== `update` + +See <>. + +== `delete` + +See <>. + +== `count` + +See <>. diff --git a/docs/asciidoc/ogm/api-reference/model/update.adoc b/docs/asciidoc/ogm/api-reference/model/update.adoc new file mode 100644 index 0000000000..b35a2c2edb --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/model/update.adoc @@ -0,0 +1,76 @@ +[[ogm-api-reference-model-update]] += `update` + +This method can be used to update nodes, and maps to the underlying <> Mutation. + +Returns a `Promise` that resolves to the equivalent of the Mutation response for this operation. + +== Example + +For the User with name "John", update their name to be "Jane": + +[source, javascript] +---- +const User = ogm.model("User"); + +const { users } = await User.update({ + where: { name: "John" }, + update: { name: "Jane" }, +}); +---- + +== Arguments + +|=== +|Name and Type |Description + +|`where` + + + + Type: `GraphQLWhereArg` +|A JavaScript object representation of the GraphQL `where` input type used for <>. + +|`update` + + + + Type: `any` +|A JavaScript object representation of the GraphQL `update` input type used for <> Mutations. + +|`connect` + + + + Type: `any` +|A JavaScript object representation of the GraphQL `connect` input type used for <> Mutations. + +|`disconnect` + + + + Type: `any` +|A JavaScript object representation of the GraphQL `disconnect` input type used for <> Mutations. + +|`create` + + + + Type: `any` +|A JavaScript object representation of the GraphQL `create` input type used for <> Mutations. + +|`options` + + + + Type: `GraphQLOptionsArg` +|A JavaScript object representation of the GraphQL `options` input type used for <> and <>. + +|`selectionSet` + + + + Type: `string` or `DocumentNode` or `SelectionSetNode` +|Selection set for the Mutation, see <> for more information. + +|`args` + + + + Type: `any` +|The `args` value for the GraphQL Mutation. + +|`context` + + + + Type: `any` +|The `context` value for the GraphQL Mutation. + +|`rootValue` + + + + Type: `any` +|The `rootValue` value for the GraphQL Mutation. +|=== diff --git a/docs/asciidoc/ogm/api-reference/ogm.adoc b/docs/asciidoc/ogm/api-reference/ogm.adoc new file mode 100644 index 0000000000..73e761fafd --- /dev/null +++ b/docs/asciidoc/ogm/api-reference/ogm.adoc @@ -0,0 +1,48 @@ +[[ogm-api-reference-ogm]] += `OGM` + +== `constructor` + +Returns an `OGM` instance. + +Takes an `input` object as a parameter, which is then passed to the `Neo4jGraphQL` constructor. Supported options are listed in the documentation for <>. + +=== Example + +[source, javascript] +---- +const ogm = new OGM({ + typeDefs, +}); +---- + +== `model` + +Returns a `Model` instance matching the passed in name, or throws an `Error` if one can't be found. + +Accepts a single argument `name` of type `string`. + +=== Example + +For the following type definitions: + +[source, graphql] +---- +type User { + username: String! +} +---- + +The following would successfully return a `Model` instance: + +[source, javascript] +---- +const User = ogm.model("User"); +---- + +The following would throw an `Error`: + +[source, javascript] +---- +const User = ogm.model("NotFound"); +---- diff --git a/docs/asciidoc/ogm/examples/custom-resolvers.adoc b/docs/asciidoc/ogm/examples/custom-resolvers.adoc new file mode 100644 index 0000000000..684c93dcc4 --- /dev/null +++ b/docs/asciidoc/ogm/examples/custom-resolvers.adoc @@ -0,0 +1,137 @@ +[[ogm-examples-custom-resolvers]] += Custom Resolvers + +A common case for using the OGM will be within custom resolvers inside a Neo4j GraphQL instance (very meta!), due to the fact that it has access to some fields which the Neo4j GraphQL Library may not. A common use case might be to have a `password` field marked with directive `@private`, and a custom resolver for creating users with passwords. + +To get started with this example, create your example application directory, create a new project and also the file which will contain your application code: + +[source, bash] +---- +mkdir ogm-custom-resolvers-example +cd ogm-custom-resolvers-example +npm init --yes +touch index.js +---- + +Then you need to install your dependencies: + +[source, bash] +---- +npm install @neo4j/graphql-ogm graphql neo4j-driver apollo-server +---- + +Assuming a running Neo4j database at "bolt://localhost:7687" with username "neo4j" and password "password", in your empty `index.js` file, add the following code: + +[source, javascript] +---- +const { Neo4jGraphQL } = require("@neo4j/graphql"); +const { OGM } = require("@neo4j/graphql-ogm"); +const { ApolloServer } = require("apollo-server"); +const neo4j = require("neo4j-driver"); + +const { createJWT, comparePassword } = require("./utils"); // example util functions + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const typeDefs = ` + type User { + id: ID @id + username: String! + password: String! @private + } + + type Mutation { + signUp(username: String!, password: String!): String! ### JWT + signIn(username: String!, password: String!): String! ### JWT + } +`; + +const ogm = new OGM({ typeDefs, driver }); +const User = ogm.model("User"); + +const resolvers = { + Mutation: { + signUp: async (_source, { username, password }) => { + const [existing] = await User.find({ + where: { + username, + }, + }); + + if (existing) { + throw new Error(`User with username ${username} already exists!`); + } + + const [user] = await User.create({ + input: [ + { + username, + password, + } + ] + }); + + return createJWT({ sub: user.id }); + }, + signIn: async (_source, { email, password }) => { + const [user] = await User.find({ + where: { + username, + }, + }); + + if (!user) { + throw new Error(`User with username ${username} not found!`); + } + + const correctPassword = await comparePassword(password, user.password); + + if (!correctPassword) { + throw new Error(`Incorrect password for user with username ${username}!`); + } + + return createJWT({ sub: user.id }); + }, + }, +}; + +const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers, + config: { + jwt: { + secret: "secret", + }, + }, +}); + +const server = new ApolloServer({ + schema: neoSchema.schema, + context: ({ req }) => ({ req }), +}); + +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +---- + +It's important to note the JWT secret being passed into the `Neo4jGraphQL` constructor in this example. + +Back in the command line, run the following command to start your server: + +[source, bash] +---- +node index.js +---- + +You should see the following output: + +[source, bash] +---- +🚀 Server ready at http://localhost:4000/ +---- + +You can execute the `signUp` Mutation against this GraphQL API to sign up, but when you go to query the user through the same API, the password field will not be available. diff --git a/docs/asciidoc/ogm/examples/index.adoc b/docs/asciidoc/ogm/examples/index.adoc new file mode 100644 index 0000000000..0f6c42c589 --- /dev/null +++ b/docs/asciidoc/ogm/examples/index.adoc @@ -0,0 +1,7 @@ +[[ogm-examples]] += Examples + +This chapter runs through some examples of how you might take advantage of the OGM. + +- <> - using the OGM in custom resolvers within your Neo4j GraphQL API +- <> - exposing your Neo4j GraphQL API through a REST API, using the OGM diff --git a/docs/asciidoc/ogm/examples/rest-api.adoc b/docs/asciidoc/ogm/examples/rest-api.adoc new file mode 100644 index 0000000000..3079a9f1ad --- /dev/null +++ b/docs/asciidoc/ogm/examples/rest-api.adoc @@ -0,0 +1,85 @@ +[[ogm-examples-rest-api]] += REST API + +This example demonstrates how you might use the OGM without exposing a Neo4j GraphQL API endpoint. The example starts an https://expressjs.com/[Express] server and uses the OGM to interact with the Neo4j GraphQL Library, exposed over a REST endpoint. + +First, create your example application directory, create a new project and also the file which will contain yur application code: + +[source, bash] +---- +mkdir ogm-rest-example +cd ogm-rest-example +npm init --yes +touch index.js +---- + +Then you need to install your dependencies: + +[source, bash] +---- +npm install @neo4j/graphql-ogm graphql neo4j-driver express +---- + +Assuming a running Neo4j database at "bolt://localhost:7687" with username "neo4j" and password "password", in your empty `index.js` file, add the following code: + +[source, javascript] +---- +const express = require("express"); +const { OGM } = require("@neo4j/graphql-ogm"); +const neo4j = require("neo4j-driver"); + +const driver = neo4j.driver( + "bolt://localhost:7687", + neo4j.auth.basic("neo4j", "password") +); + +const typeDefs = ` + type User { + id: ID + name: String + } +`; + +const ogm = new OGM({ typeDefs, driver }); +const User = ogm.model("User"); + +const app = express(); + +app.get("/users", async (req, res) => { + const { search, offset, limit, sort } = req.query; + + const regex = search ? `(?i).*${search}.*` : null; + + const users = await User.find({ + where: { name_REGEX: regex }, + options: { + offset, + limit, + sort + } + }); + + return res.json(users).end(); +}); + +const port = 4000; +app.listen(port, () => { + console.log("Example app listening at http://localhost:${port}") +}); +---- + +In your application directory, you can run this application: + +[source, bash] +---- +node index.js +---- + +Which should output: + +[source, bash] +---- +Example app listening at http://localhost:4000 +---- + +The REST API should now be ready to accept requests at the URL logged. diff --git a/docs/asciidoc/ogm/getting-started.adoc b/docs/asciidoc/ogm/getting-started.adoc deleted file mode 100644 index dc651fc845..0000000000 --- a/docs/asciidoc/ogm/getting-started.adoc +++ /dev/null @@ -1,66 +0,0 @@ -[[ogm-getting-started]] -= Getting Started - -This section will help you get started with Neo4j GraphQL OGM. Before starting we recommend readers have an understanding of; - -* `Neo4jGraphQL` <> -* <> -* <> - -== Installation -[source, bash] ----- -npm install @neo4j/graphql-ogm ----- - -graphql and neo4j-driver are are peerDependencies. - -== Requiring -[source, javascript] ----- -const { OGM } = require("@neo4j/graphql-ogm"); ----- - -== REST API Quick Start -This section demonstrates how to use the OGM outside of a GraphQL API. The example exposes a https://expressjs.com/[Express] server and uses the OGM, in the endpoint, to interact with Neo4j. - -[source, javascript] ----- -const express = require("express"); -const { OGM } = require("@neo4j/graphql-ogm"); -const neo4j = require("neo4j-driver"); - -const driver = neo4j.driver( - "bolt://localhost:7687", - neo4j.auth.basic("neo4j", "letmein") -); - -const typeDefs = ` - type User { - id: ID - name: String - } -`; - -const ogm = new OGM({ typeDefs, driver }); -const User = ogm.model("User"); - -const app = express(); -app.get("/users", async (req, res) => { - const { search, skip, limit, sort } = req.query; - - const regex = search ? `(?i).*${search}.*` : null; - - const users = await User.find({ - where: { name_REGEX: regex }, - options: { - skip, - limit, - sort - } - }); - - return res.json(users).end(); -}); -app.listen(4000, () => console.log("started")); ----- diff --git a/docs/asciidoc/ogm/index.adoc b/docs/asciidoc/ogm/index.adoc index 45e200770d..31df89ef69 100644 --- a/docs/asciidoc/ogm/index.adoc +++ b/docs/asciidoc/ogm/index.adoc @@ -1,22 +1,26 @@ [[ogm]] = OGM -Common applications won't just expose a single API. On the same instance as the GraphQL API there may be; scheduled jobs, authentication, migrations and not to forget any custom logic in the resolvers themselves. We expose an OGM (Object Graph Mapper) on top of the pre-existing GraphQL work and abstractions. +Most applications won't just expose a single GraphQL API. There may also be scheduled jobs, authentication and migrations keeping an application ticking over. The OGM (Object Graph Mapper) can be used to programmatically interact with your Neo4j GraphQL API, which may help with achieving these goals. -* <> -* <> -* <> -* <> -* <> +- <> +- <> +- <> +- <> +- <> +> Before diving into the OGM, it's important to have a good understanding of the Neo4h GraphQL Library first. It's recommended to at least work through the <> guide. -== Directives -The following directives are excluded from the OGM's schema; +== Excluded directives -* `@auth` -* `@exclude` -* `@private` -* `@readonly` -* `@writeonly` +The following directives are excluded from the OGM's schema: + +- `@auth` +- `@exclude` +- `@private` +- `@readonly` +- `@writeonly` + +This is because the OGM is only ever used programmatically, as opposed to an exposed API which needs these security measures. See also: <> diff --git a/docs/asciidoc/ogm/installation.adoc b/docs/asciidoc/ogm/installation.adoc new file mode 100644 index 0000000000..5ff063f873 --- /dev/null +++ b/docs/asciidoc/ogm/installation.adoc @@ -0,0 +1,22 @@ +[[ogm-installation]] += Installation + +The OGM is very easy to install into a new or existing Node.js project. However it does have a couple of dependencies. The OGM depends on the Neo4j GraphQL Library, which will be installed when you install the OGM, so you will require the following dependencies: + +- `@neo4j/graphql-ogm` is the OGM package. +- `graphql` is the package used by the Neo4j GraphQL Library to generate a schema and execute queries and mutations. +- `neo4j-driver` is the official Neo4j Driver package for JavaScript, necessary for interacting with the database. + +[source, bash] +---- +npm install @neo4j/graphql-ogm graphql neo4j-driver +---- + +To use the OGM, it will need to be imported wherever you want to use it: + +[source, javascript] +---- +const { OGM } = require("@neo4j/graphql-ogm"); +---- + +It's recommended to check out the <> to see where you might go from here. diff --git a/docs/asciidoc/ogm/methods/create.adoc b/docs/asciidoc/ogm/methods/create.adoc deleted file mode 100644 index db71e8eaba..0000000000 --- a/docs/asciidoc/ogm/methods/create.adoc +++ /dev/null @@ -1,30 +0,0 @@ -[[ogm-methods-create]] -= Create - -Use to create many nodes. The `model.create` method maps to the underlying <> operation. - -== Usage -[source, javascript] ----- -const Movie = ogm.model("Movie"); - -await Movie.create({ input: [{ title: "The Matrix" }] }) ----- - -== Args - -=== `input` -JavaScript object representation of the GraphQL `input` input type, used for <>. - -=== `selectionSet` - -Reference: <> - -=== `args` -The arguments to the GraphQL Query. - -=== `context` -The `context` for the GraphQL Query. - -=== `rootValue` -The `rootValue` for the GraphQL Query. diff --git a/docs/asciidoc/ogm/methods/delete.adoc b/docs/asciidoc/ogm/methods/delete.adoc deleted file mode 100644 index e566fb4b43..0000000000 --- a/docs/asciidoc/ogm/methods/delete.adoc +++ /dev/null @@ -1,29 +0,0 @@ -[[ogm-methods-delete]] -= Delete - -Use to delete many nodes. The `model.delete` method maps to the underlying <> operation. - -== Usage -[source, javascript] ----- -const User = ogm.model("User"); - -await User.delete({ where: { name: "dan" }}); ----- - -== Args - -=== `where` -JavaScript object representation of the GraphQL `where` input type, used for <>. - -=== `delete` -JavaScript object representation of the GraphQL `delete` input type, used for <>. - -=== `args` -The arguments for the GraphQL Query. - -=== `context` -The `context` for the GraphQL Query. - -=== `rootValue` -The `rootValue` for the GraphQL Query. diff --git a/docs/asciidoc/ogm/methods/find.adoc b/docs/asciidoc/ogm/methods/find.adoc deleted file mode 100644 index 5af3383653..0000000000 --- a/docs/asciidoc/ogm/methods/find.adoc +++ /dev/null @@ -1,56 +0,0 @@ -[[ogm-methods-find]] -= Find - -Use to update many nodes. The `model.find` method maps to the underlying schema <>. - -== Usage - -=== Basic - -[source, javascript] ----- -const User = ogm.model("User"); - -const users = await User.find(); ----- - -== Relationships -Reference: <> - -[source, javascript] ----- -const User = ogm.model("User"); - -const selectionSet = ` - { - posts { - content - } - } -`; - -const users = await User.find({ - selectionSet, -}); ----- - -== Args - -=== `where` -JavaScript object representation of the GraphQL `where` input type, used for <>. - -=== `options` -JavaScript object representation of the GraphQL `options` input type, used for <>. - -=== `selectionSet` - -Reference: <> - -=== `args` -The arguments for the GraphQL Query. - -=== `context` -The `context` for the GraphQL Query. - -=== `rootValue` -The `rootValue` for the GraphQL Query. diff --git a/docs/asciidoc/ogm/methods/index.adoc b/docs/asciidoc/ogm/methods/index.adoc deleted file mode 100644 index 1bb1ba428a..0000000000 --- a/docs/asciidoc/ogm/methods/index.adoc +++ /dev/null @@ -1,11 +0,0 @@ -[[ogm-methods]] -= Methods - -You can call the following on a model; - -. <> -. <> -. <> -. <> - -Each method maps to the underlying generated Query or Mutation for that Model. diff --git a/docs/asciidoc/ogm/methods/update.adoc b/docs/asciidoc/ogm/methods/update.adoc deleted file mode 100644 index 46b7213099..0000000000 --- a/docs/asciidoc/ogm/methods/update.adoc +++ /dev/null @@ -1,45 +0,0 @@ -[[ogm-methods-update]] -= Update - -Use to update many nodes. The `model.update` method maps to the underlying <> operation. - -== Usage -[source, javascript] ----- -const User = ogm.model("User"); - -const { users } = await User.update({ - where: { name: "bob" }, - update: { name: "Bob" }, -}); ----- - -== Args - -=== `where` -JavaScript object representation of the GraphQL `where` input type, used for <>. - -=== `update` -JavaScript object representation of the GraphQL `update` input type, used for <>. - -=== `connect` -JavaScript object representation of the GraphQL `connect` input type, used for <>. - -=== `disconnect` -JavaScript object representation of the GraphQL `disconnect` input type, used for <>. - -=== `create` -JavaScript object representation of the GraphQL `create` input type, used for <>. - -=== `selectionSet` - -Reference: <> - -=== `args` -The arguments for the GraphQL Query. - -=== `context` -The `context` for the GraphQL Query. - -=== `rootValue` -The `rootValue` for the GraphQL Query. \ No newline at end of file diff --git a/docs/asciidoc/ogm/private.adoc b/docs/asciidoc/ogm/private.adoc index dee359f1a7..12d09d5e5a 100644 --- a/docs/asciidoc/ogm/private.adoc +++ b/docs/asciidoc/ogm/private.adoc @@ -23,7 +23,7 @@ type User { } ---- -Using the password field is a great example here. In your application, you would want to hash passwords & hide them from snoopers. You could have a custom resolver, using the OGM, to update and set passwords. This is more apparent when you want to use the same type definitions to drive a public-facing schema and an OGM; +Using the password field is a great example here. In your application, you would want to hash passwords and hide them from snoopers. You could have a custom resolver, using the OGM, to update and set passwords. This is more apparent when you want to use the same type definitions to drive a public-facing schema and an OGM: [source, javascript] ---- diff --git a/docs/asciidoc/ogm/selection-set.adoc b/docs/asciidoc/ogm/selection-set.adoc index 979e7c4250..2386b19f48 100644 --- a/docs/asciidoc/ogm/selection-set.adoc +++ b/docs/asciidoc/ogm/selection-set.adoc @@ -1,7 +1,7 @@ [[ogm-selection-set]] = Selection Set -This is a GraphQL specific term. When you preform a query you have the operation; +This is a GraphQL specific term. When you execute a query, you have the operation: [source, graphql] ---- @@ -10,32 +10,45 @@ query { } ---- -And you also have a Selection Set; +And you also have a selection set. For example, from the example below: [source, graphql] ---- query { myOperation { - # Selection Set start - id - name - } # Selection Set end + field1 + field2 + } } ---- -When using the OGM we do not want users providing a selections sets. Doing so would make the OGM feel more like querying the GraphQL Schema when the OGM is designed as an abstraction on top of it. To combat this we do Autogenerated Selection Sets. Given a Node; +The following snippet is the selection set: [source, graphql] ---- -type Node { +{ + field1 + field2 +} +---- + +When using the OGM, you do not have to provide a selection set by default. Doing so would make using the OGM feel more like querying the GraphQL schema directly, when the OGM is designed as an abstraction over it. This is achieved by automatically generated basic selection sets. Given the following type definition: + +[source, graphql] +---- +type Movie { id: ID name: String - relation: [Node] @relationship(...) - customCypher: [Node] @cypher(...) + genres: [Genre] @relationship(type: "IN_GENRE", direction: OUT) + customCypher: String! @cypher(statement: "RETURN someCustomData") +} + +type Genre { + name: String } ---- -We pre-generate a pre-defined selection set. We don't include any relationships or cypher fields, as they could be computationally expensive. Given the above Node the auto pre-defined selection set would be; +Neither relationship fields nor custom Cypher fields are included in the generated selection set, as they could be computationally expensive. So, given the type definition above, the generated selection set would be: [source, graphql] ---- @@ -45,9 +58,11 @@ We pre-generate a pre-defined selection set. We don't include any relationships } ---- -This means that by default, querying for Node(s), you would only get the `.id` and `.name` properties returned. If you want to select more you can either define a selection set at execution time or as a static on the Model; +This means that by default, querying for Node(s), you would only get the `.id` and `.name` properties returned. If you want to select more fields, you can either define a selection set at execution time or as a static on the Model, as described below. + +== Selection set at execution time -=== Selection set at execution time +Using this approach, you would pass in a selection set every time you interact with the OGM. This would be an appropriate approach if the selection set is going to be different every time you ask for data. A full example of this would be as follows: [source, javascript] ---- @@ -60,35 +75,40 @@ const driver = neo4j.driver( ); const typeDefs = ` - type Node { + type Movie { id: ID name: String - relation: [Node] @relationship(...) - customCypher: [Node] @cypher(...) + genres: [Genre] @relationship(type: "IN_GENRE", direction: OUT) + customCypher: String! @cypher(statement: "RETURN someCustomData") + } + + type Genre { + name: String } `; const ogm = new OGM({ typeDefs, driver }); -const Node = ogm.model("Node"); +const Movie = ogm.model("Movie"); const selectionSet = ` { id name - relation { - id - name - } - customCypher { - id + genres { name } + customCypher } `; -const nodes = await Node.find({ selectionSet }); + +const movies = await Movie.find({ selectionSet }); ---- -=== Selection set as a static +Note that the argument `selectionSet` is passed every invocation of the `Movie.find()` function. + +== Selection set as a static + +Using this approach, you can assign a selection set to a particular Model, so that whenever it is queried, it will always return those fields. This is useful if the default selection set doesn't quite give you enough data, but you don't need the selection set to be dynamic on each request. See a full example below: [source, javascript] ---- @@ -101,30 +121,35 @@ const driver = neo4j.driver( ); const typeDefs = ` - type Node { + type Movie { id: ID name: String - relation: [Node] @relationship(...) - customCypher: [Node] @cypher(...) + genres: [Genre] @relationship(type: "IN_GENRE", direction: OUT) + customCypher: String! @cypher(statement: "RETURN someCustomData") + } + + type Genre { + name: String } `; const ogm = new OGM({ typeDefs, driver }); -const Node = ogm.model("Node"); +const Movie = ogm.model("Movie"); const selectionSet = ` { id name - relation { - id - name - } - customCypher { - id + genres { name } + customCypher } `; -Node.setSelectionSet(selectionSet) + +Movie.setSelectionSet(selectionSet) + +const movies = await Movie.find(); ---- + +Note that despite not passing this selection set into `Movie.find()`, the requested fields will be returned on each request. diff --git a/docs/asciidoc/pagination/cursor-based.adoc b/docs/asciidoc/pagination/cursor-based.adoc new file mode 100644 index 0000000000..fcc1bcafcf --- /dev/null +++ b/docs/asciidoc/pagination/cursor-based.adoc @@ -0,0 +1,89 @@ +[[pagination-cursor-based]] += Cursor-based pagination + +On relationship fields, you are able to take advantage of cursor-based pagination, which is often associated with infinitely-scrolling applications. + +Using the following type definition: + +[source, graphql] +---- +type User { + name: String! + posts: [Post!]! @relationship(type: "HAS_POST", direction: OUT) +} + +type Post { + content: String! +} +---- + +If you wanted to fetch the posts of user "John Smith" 10 at a time, you would first fetch 10: + +[source, graphql] +---- +query { + users(where: { name: "John Smith" }) { + name + postsConnection(first: 10) { + edges { + node { + content + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +} +---- + +In the return value, if `hasNextPage` is `true`, you would pass `endCursor` into the next query of 10. You might do this using a variable as in the following example: + +[source, graphql] +---- +query Users($after: String) { + users(where: { name: "John Smith" }) { + name + postsConnection(first: 10, after: $after) { + edges { + node { + content + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +} +---- + +You would continue doing this until `hasNextPage` if `false` - this is when you have reached the end of the data. + +== `totalCount` + +The Connection fields also offer a `totalCount` field which can be used to calculate page numbers, which is useful if you want to page by cursors but use page number in your application. Using the example above, you would simply add the `totalCount` field which will return the total number of results matching the filter used, which in this example would just be all posts: + +[source, graphql] +---- +query Users($after: String) { + users(where: { name: "John Smith" }) { + name + postsConnection(first: 10) { + edges { + node { + content + } + } + pageInfo { + endCursor + hasNextPage + } + totalCount + } + } +} +---- diff --git a/docs/asciidoc/pagination/index.adoc b/docs/asciidoc/pagination/index.adoc new file mode 100644 index 0000000000..a4b09e1e28 --- /dev/null +++ b/docs/asciidoc/pagination/index.adoc @@ -0,0 +1,7 @@ +[[pagination]] += Pagination + +The Neo4j GraphQL Library offers two mechanisms for pagination: + +- <> - Pagination based on offsets, often associated with navigation via pages. +- <> - Pagination based on cursors, often associated with infinitely-scrolling applications. diff --git a/docs/asciidoc/schema/pagination.adoc b/docs/asciidoc/pagination/offset-based.adoc similarity index 50% rename from docs/asciidoc/schema/pagination.adoc rename to docs/asciidoc/pagination/offset-based.adoc index 2e3fcc8e6a..576411ecbb 100644 --- a/docs/asciidoc/schema/pagination.adoc +++ b/docs/asciidoc/pagination/offset-based.adoc @@ -1,9 +1,7 @@ -[[schema-pagination]] -= Pagination +[[pagination-offset-based]] += Offset-based pagination -== Page-based pagination - -Page-based pagination can be achieved through the use of the `skip` and `limit` options available whilst querying for data. +Offset-based pagination, often associated with navigation via pages, can be achieved through the use of the `offset` and `limit` options available when querying for data. Using the following type definition: @@ -27,14 +25,15 @@ query { } ---- -And then on subsequent calls, introduce the `skip` argument and increment it by 10 on each call. +And then on subsequent calls, introduce the `offset` argument and increment it by 10 on each call. + +*Page 2:* -Page 2: [source, graphql] ---- query { users(options: { - skip: 10 + offset: 10 limit: 10 }) { name @@ -42,12 +41,13 @@ query { } ---- -Page 3: +*Page 3:* + [source, graphql] ---- query { users(options: { - skip: 20 + offset: 20 limit: 10 }) { name @@ -55,7 +55,15 @@ query { } ---- -=== Paginating relationship fields +And so on, so forth. + +== Total number of pages + +You can fetch the total number of records for a certain type using its count query, and then divide that number by your entries per page in order to calculate the total number of pages. This will allow to to determine what the last page is, and whether there is a next page. + +See <> queries for details on how to execute these queries. + +== Paginating relationship fields Say that in addition to the `User` type above, there is also a `Post` type which a `User` has many of. You can also fetch a `User` and then paginate through their posts: @@ -67,7 +75,7 @@ query { }) { name posts(options: { - skip: 20 + offset: 20 limit: 10 }) { content diff --git a/docs/asciidoc/queries.adoc b/docs/asciidoc/queries.adoc index 83dd9e7109..f7d1d96329 100644 --- a/docs/asciidoc/queries.adoc +++ b/docs/asciidoc/queries.adoc @@ -1,4 +1,94 @@ [[queries]] = Queries -See <>. +Each node defined in type definitions will have two Query fields generated for it - one for querying data and one for counting data. + +The examples in this chapter will use the following type definitions: + +[source, graphql] +---- +type Post { + id: ID! @id + content: String! + creator: User! @relationship(type: "HAS_POST", direction: IN) +} + +type User { + id: ID! @id + name: String! + posts: [Post!]! @relationship(type: "HAS_POST", direction: OUT) +} +---- + +For which the following Query fields will be generated: + +[source, graphql] +---- +type Query { + posts(where: PostWhere, options: PostOptions): [Post!]! + postsCount(where: PostWhere): Int! + users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! +} +---- + +== Query + +Each field for querying data accepts two arguments: + +- `where` - used for <> data +- `options` - used to specify <> and <> options + +=== Querying for all User nodes + +The following Query will return all User nodes, returning their ID and name. + +[source, graphql] +---- +query { + users { + id + name + } +} +---- + +=== Query for user with name "Jane Smith" and their posts + +The following Query will return all Users, returning the content which they have posted. + +[source, graphql] +---- +query { + users(where: { name: "Jane Smith" }) { + id + name + posts { + content + } + } +} +---- + +[[queries-count]] +== Count + +=== Counting all User nodes + +The following Query will count all User nodes: + +[source, graphql] +---- +query { + usersCount +} +---- + +=== Counting User nodes where name starts with "J" + +[source, graphql] +---- +query { + usersCount(where: { name_STARTS_WITH: "J" }) +} +---- diff --git a/docs/asciidoc/schema/index.adoc b/docs/asciidoc/schema/index.adoc deleted file mode 100644 index 433eceed0f..0000000000 --- a/docs/asciidoc/schema/index.adoc +++ /dev/null @@ -1,10 +0,0 @@ -[[schema]] -= Schema - -In this section you will learn how to use the exposed GraphQL schema to interact with the Neo4j database. - -* <> -* <> -* <> -* <> -* <> diff --git a/docs/asciidoc/schema/mutations.adoc b/docs/asciidoc/schema/mutations.adoc deleted file mode 100644 index 77565150e6..0000000000 --- a/docs/asciidoc/schema/mutations.adoc +++ /dev/null @@ -1,290 +0,0 @@ -[[schema-mutations]] -= Mutations - -Mutations for create, update and delete operations are automatically generated for each type defined in type definitions. - -This section will briefly go through each Mutation generated, using the following type definitions as an example: - -[source, graphql] ----- -type Post { - id: ID! @id - content: String! - creator: User @relationship(type: "HAS_POST", direction: IN) -} - -type User { - id: ID! @id - name: String - posts: [Post] @relationship(type: "HAS_POST", direction: OUT) -} ----- - -*A note on nested Mutations* - -> You will see some basic examples of nested Mutations below, which barely scratch the surface of what can be achieved with them. We really encourage you to explore the power of what you can do with them! -> -> However, it has to be noted that in order to provide the abstractions available in these Mutations, the output Cypher can end up being extremely complex, which can result in your database throwing out-of-memory errors depending on its configuration. -> -> If out-of-memory errors are a regular occurrence, you can adjust the `dbms.memory.heap.max_size` parameter in the DBMS settings. -> -> If you need to perform major data migrations, it may be best to manually write the necessary Cypher and execute this directly in the database. - -[[schema-mutations-create]] -== Create - -The following create Mutations and response types will be generated for the above type definitions: - -[source, graphql] ----- -type CreatePostsMutationResponse { - posts: [Post!]! -} - -type CreateUsersMutationResponse { - users: [User!]! -} - -type Mutation { - createPosts(input: [PostCreateInput!]!): CreatePostsMutationResponse! - createUsers(input: [UsersCreateInput!]!): CreateUsersMutationResponse! -} ----- - -The `CreateInput` types closely mirror the object type definitions, allowing you to create not only the type in question, but to recurse down and perform further operations on related types in the same Mutation. - -> The `id` field will be absent from both create input types as the <> directive has been used. - -=== Single create - -A single user can be created by executing the following GraphQL statement: - -[source, graphql] ----- -mutation { - createUsers(input: [ - { - name: "John Doe" - } - ]) { - users { - id - name - } - } -} ----- - -This will create a User with name "John Doe", and that name plus the autogenerated ID will be returned. - -=== Nested create - -A User and an initial Post can be created by executing the following: - -[source, graphql] ----- -mutation { - createUsers(input: [ - { - name: "John Doe" - posts: { - create: [ - { - content: "Hi, my name is John!" - } - ] - } - } - ]) { - users { - id - name - posts { - id - content - } - } - } -} ----- - -This will create a User with name "John Doe", an introductory Post, both of which will be returned with their autogenerated IDs. - -[[schema-mutations-update]] -== Update - -[source, graphql] ----- -type UpdatePostsMutationResponse { - posts: [Post!]! -} - -type UpdateUsersMutationResponse { - users: [User!]! -} - -type Mutation { - updatePosts( - where: PostWhere - update: PostUpdateInput - connect: PostConnectInput - disconnect: PostDisconnectInput - create: PostCreateInput - delete: PostDeleteInput - ): UpdatePostsMutationResponse! - updateUsers( - where: UserWhere - update: UserUpdateInput - connect: UserConnectInput - disconnect: UserDisconnectInput - create: UserCreateInput - delete: UserDeleteInput - ): UpdateUsersMutationResponse! -} ----- - -> The `id` field not be update-able as the <> directive has been used. - -=== Single update - -Say we wanted to edit the content of a Post: - -[source, graphql] ----- -mutation { - updatePosts( - where: { - id: "892CC104-A228-4BB3-8640-6ADC9F2C2A5F" - } - update: { - content: "Some new content for this Post!" - } - ) { - posts { - content - } - } -} ----- - -=== Nested update - -Instead of creating a Post and connecting it to a User, you could update a User and create a Post as part of the Mutation: - -[source, graphql] ----- -mutation { - updateUsers( - where: { name: "John Doe" } - create: { - posts: [ - { content: "An interesting way of adding a new Post!" } - ] - } - ) { - users { - id - name - posts { - content - } - } - } -} ----- - -[[schema-mutations-delete]] -== Delete - -The following delete Mutations and response type will be generated for the above type definitions: - -[source, graphql] ----- -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! -} - -type Mutation { - deletePosts(where: PostWhere, delete: PostDeleteInput): DeleteInfo! - deleteUsers(where: UserWhere, delete: UserDeleteInput): DeleteInfo! -} ----- - -Note that the `DeleteInfo` type is the common return type for all delete Mutations. - -=== Single Delete - -A single post can be deleted by executing the following GraphQL statement: - -[source, graphql] ----- -mutation { - deletePosts(where: [ - { - id: "6042E807-47AE-4857-B7FE-1AADF522DE8B" - } - ]) { - nodesDeleted - relationshipsDeleted - } -} ----- - -This will delete the post using the autogenerated ID that would have been returned after that post's creation. - -We would see that `nodesDeleted` would equal 1 (the post) and `relationshipsDeleted` would also equal equal 1 (the `HAS_POST` relationship between the Post and its author). - -=== Nested Delete - -Say that if when we delete a User, we want to delete _all_ of their Posts as well. This can be achieved using a single nested delete operations: - -[source, graphql] ----- -mutation { - deleteUsers( - where: [ - { - name: "Jane Doe" - } - ], - delete: { - posts: [ - where: { } - ] - } - ) { - nodesDeleted - relationshipsDeleted - } -} ----- - -You may look at that empty `where` argument and wonder what that's doing. By the time we reach that argument, we are already only dealing with the posts that were created by Jane Doe, as we traversed the graph to those Post nodes from her User node. Essentially, the above query is equivalent to: - -[source, graphql] ----- -mutation { - deleteUsers( - where: [ - { - name: "Jane Doe" - } - ], - delete: { - posts: [ - where: { - creator: { - name: "Jane Doe" - } - } - ] - } - ) { - nodesDeleted - relationshipsDeleted - } -} ----- - -Slightly easier to reason with, but the output Cypher statement will have a redundant `WHERE` clause! diff --git a/docs/asciidoc/schema/queries.adoc b/docs/asciidoc/schema/queries.adoc deleted file mode 100644 index 4509362720..0000000000 --- a/docs/asciidoc/schema/queries.adoc +++ /dev/null @@ -1,69 +0,0 @@ -[[schema-queries]] -= Queries - -Each object defined in type definitions will have a Query field generated for it. Each Query field accepts two arguments: - -* `where`: Used to specify the <> which should be applied whilst querying the data -* `options`: Used to specify the options for <> and <> - -== Example - -Given the following type definitions: - -[source, graphql] ----- -type Post { - id: ID! @id - content: String! - creator: User @relationship(type: "HAS_POST", direction: IN) -} - -type User { - id: ID! @id - name: String - posts: [Post] @relationship(type: "HAS_POST", direction: OUT) -} ----- - -The following Query fields will be automatically generated: - -[source, graphql] ----- -type Query { - posts(where: PostWhere, options: PostOptions): [Post!]! - users(where: UserWhere, options: UserOptions): [User!]! -} ----- - -=== Querying for all Users - -The following Query will return all Users, returning their ID and name. - -[source, graphql] ----- -query { - users { - id - name - } -} ----- - -=== Query for all Users' Posts - -The following Query will return all Users, returning the content which they have posted. - -[source, graphql] ----- -query { - users { - posts { - content - } - } -} ----- - -=== Filtering - -See <> for details on how data can be filtered whilst querying. diff --git a/docs/asciidoc/schema/sorting.adoc b/docs/asciidoc/sorting.adoc similarity index 92% rename from docs/asciidoc/schema/sorting.adoc rename to docs/asciidoc/sorting.adoc index 5c5deaf3d0..a4ca5878dc 100644 --- a/docs/asciidoc/schema/sorting.adoc +++ b/docs/asciidoc/sorting.adoc @@ -1,4 +1,4 @@ -[[schema-sorting]] +[[sorting]] = Sorting A sorting input type is generated for every object type defined in your type definitions, allowing for Query results to be sorted by each individual field. @@ -36,7 +36,7 @@ input MovieOptions { """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" sort: [MovieSort] limit: Int - skip: Int + offset: Int } type Query { @@ -62,7 +62,7 @@ query { } ---- -Additionally, say there was a relationship between the `Movie` and an `Actor` type, sorting can also be applied whilst fetching the `actors` field: +Additionally, say there was a relationship between the `Movie` and an `Actor` type, sorting can also be applied when fetching the `actors` field: [source, graphql] ---- diff --git a/docs/asciidoc/troubleshooting/faqs.adoc b/docs/asciidoc/troubleshooting/faqs.adoc new file mode 100644 index 0000000000..36b539a67b --- /dev/null +++ b/docs/asciidoc/troubleshooting/faqs.adoc @@ -0,0 +1,16 @@ +[[troubleshooting-faqs]] += FAQs + +This chapter contains commonly asked questions and their solutions. + +== I've upgraded from <1.1.0 and my `DateTime` fields aren't sorting as expected + +Due to a bug in versions less than 1.1.0, there is a chance that your `DateTime` fields are stored in the database as strings instead of temporal values. You should perform a rewrite of those properties in your database using a Cypher query. For an example where the affected node has label "Movie" and the affected property is "timestamp", you can do this using the following Cypher: + +[source, javascript] +---- +MATCH (m:Movie) +WHERE apoc.meta.type(m.timestamp) = "STRING" +SET m.timestamp = datetime(m.timestamp) +RETURN m +---- diff --git a/docs/asciidoc/troubleshooting.adoc b/docs/asciidoc/troubleshooting/index.adoc similarity index 88% rename from docs/asciidoc/troubleshooting.adoc rename to docs/asciidoc/troubleshooting/index.adoc index 1abe182c9c..7d1966a1d1 100644 --- a/docs/asciidoc/troubleshooting.adoc +++ b/docs/asciidoc/troubleshooting/index.adoc @@ -1,9 +1,11 @@ [[troubleshooting]] = Troubleshooting +This chapter contains common troubleshooting steps. Additionally, there is a section for <> (Frequently Asked Questions) where you might find answers to your problems. + == Debug Logging -`@neo4j/graphql` uses the https://www.npmjs.com/package/debug[`debug`] library for debug-level logging. You can turn on all debug logging by setting the environment variable `DEBUG` to `@neo4j/graphql:*` whilst running. For example: +`@neo4j/graphql` uses the https://www.npmjs.com/package/debug[`debug`] library for debug-level logging. You can turn on all debug logging by setting the environment variable `DEBUG` to `@neo4j/graphql:*` when running. For example: [source, bash] ---- @@ -17,6 +19,7 @@ Alternatively, if you are debugging a particular functionality, you can specify 3. `@neo4j/graphql:graphql` - Logs the GraphQL query and variables 4. `@neo4j/graphql:execute` - Logs the Cypher and Cypher paramaters before execution, and summary of execution +[[troubleshooting-query-tuning]] == Query Tuning Hopefully you won't need to perform any query tuning, but if you do, the Neo4j GraphQL Library allows you to set the full array of query options on construction of the library. diff --git a/docs/asciidoc/type-definitions/basics.adoc b/docs/asciidoc/type-definitions/basics.adoc new file mode 100644 index 0000000000..58df4ab1ed --- /dev/null +++ b/docs/asciidoc/type-definitions/basics.adoc @@ -0,0 +1,61 @@ +[[type-definitions-basics]] += Basics + +Each type in your GraphQL type definitions can be mapped to an entity in your Neo4j database. + +== Nodes + +The most basic mapping is of GraphQL types to Neo4j nodes, where the GraphQL type name maps to the Neo4j node label. + +For example, to represent a node with label "Movie" and a single property "title" of type string: + +[source, graphql] +---- +type Movie { + title: String +} +---- + +== Relationships + +Relationships are represented by marking particular fields with a directive. This directive, `@relationship`, defines the relationship type in the database, as well as which direction that relationship goes in. + +Add a second node type, "Actor", and connect the two together: + +[source, graphql] +---- +type Movie { + title: String + actors: [Actor] @relationship(type: "ACTED_IN", direction: IN) +} + +type Actor { + name: String + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) +} +---- + +Note there is a directive on each "end" of the relationship in this case, but it is not essential. + +=== Relationship properties + +In order to add relationship properties to a relationship, you need to add a new type to your type definitions, but this time it will be of type `interface`. For example, for your "ACTED_IN" relationship, add a property "roles": + +[source, graphql] +---- +type Movie { + title: String + actors: [Actor] @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") +} + +type Actor { + name: String + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") +} + +interface ActedIn { + roles: [String] +} +---- + +Note that in addition to this new interface type, there is an added a key `properties` in the existing `@relationship` directives. diff --git a/docs/asciidoc/type-definitions/cypher.adoc b/docs/asciidoc/type-definitions/cypher.adoc index 90d570624d..2066182fe5 100644 --- a/docs/asciidoc/type-definitions/cypher.adoc +++ b/docs/asciidoc/type-definitions/cypher.adoc @@ -1,5 +1,5 @@ [[type-definitions-cypher]] -= `@cypher` Directive += `@cypher` directive The `@cypher` directive binds a GraphQL field to the result(s) of a Cypher query. @@ -16,7 +16,7 @@ directive @cypher( == Character Escaping -All double quotes must be _double escaped_ when used in a @cypher directive - once for GraphQL and once for the function in which we run the Cypher. For example, at its simplest: +All double quotes must be _double escaped_ when used in a @cypher directive - once for GraphQL and once for the function in which the Cypher is wrapped. For example, at its simplest: [source, graphql] ---- @@ -171,7 +171,7 @@ The downside of the latter approach is that you will need to adjust the return o [[type-definitions-cypher-object-usage]] === On an object type field -Below we add a field `similarMovies` to our Movie, which is bound to a Cypher query, to find other movies with an overlap of actors; +In the example below, a field `similarMovies` is bound to the `Movie` type, to find other movies with an overlap of actors: [source, graphql] ---- @@ -201,7 +201,7 @@ type Movie { === On a Query type field -Below we add a simple Query to return all of the actors in the database. +The example below demonstrates a simple Query to return all of the actors in the database: [source, graphql] ---- @@ -223,7 +223,7 @@ type Query { === On a Mutation type field -Below we add a simple Mutation using a Cypher query to insert a single actor with the specified name argument. +The example below demonstrates a simple Mutation using a Cypher query to insert a single actor with the specified name argument: [source, graphql] ---- diff --git a/docs/asciidoc/type-definitions/index.adoc b/docs/asciidoc/type-definitions/index.adoc index 93eb1127b8..ed02870ebe 100644 --- a/docs/asciidoc/type-definitions/index.adoc +++ b/docs/asciidoc/type-definitions/index.adoc @@ -1,36 +1,14 @@ [[type-definitions]] = Type Definitions -* <> -* <> -* <> -* <> -* <> +- <> - Learn how to define your nodes and relationships using GraphQL type definitions. +- <> - Learn about the various data types available in the Neo4j GraphQL Library. +- <> - Learn about two GraphQL concepts, unions and interfaces, and how they map to the Neo4j database. +- <> - Learn more about defining relationships using the Neo4j GraphQL Library. +- <> - Learn about how to restrict access to certain types or fields. +- <> - Learn about certain types which you can enable autogeneration of values for. +- <> - Learn about how to add custom Cypher to your type definitions. +- <> - Learn about different ways of setting default values for particular fields. -== Basics - -=== Nodes - -To represent a node in the GraphQL schema use the type definition; - -[source, graphql] ----- -type Node { - id: ID -} ----- - - -=== Relationships - -To represent a relationship between two nodes use the `@relationship` directive; - -[source, graphql] ----- -type Node { - id: ID - related: [Node] @relationship(type: "RELATED", direction: OUT) -} ----- diff --git a/docs/asciidoc/type-definitions/relationships.adoc b/docs/asciidoc/type-definitions/relationships.adoc index 6cce576c81..da1f7b71ae 100644 --- a/docs/asciidoc/type-definitions/relationships.adoc +++ b/docs/asciidoc/type-definitions/relationships.adoc @@ -5,13 +5,13 @@ Without relationships, your type definitions are simply a collection of disconne == Example graph -We will be using the following example graph, where a Person type has two different relationship types which can connect it to a Movie type. +The following graph will be used in this example, where a Person type has two different relationship types which can connect it to a Movie type. -image::relationships.png[title="Example graph"] +image::relationships.svg[title="Example graph"] == Type definitions -First, we should define the two distinct types in this model: +First, to define the nodes, you should define the two distinct types in this model: [source, graphql] ---- @@ -26,7 +26,7 @@ type Movie { } ---- -We can then connect these two types together using `@relationship` directives: +You can then connect these two types together using `@relationship` directives: [source, graphql] ---- @@ -45,15 +45,46 @@ type Movie { } ---- -The following should be noted about the fields we just added: +The following should be noted about the fields you just added: -* A Person can act in or direct multiple movies, and a Movie can have multiple actors. However, it is exceedingly rare for a Movie to have more than one director, and we can model this cardinality in our type definitions, to ensure accuracy of our data. -* A Movie isn't really a Movie without a director, and we have signified this by marking the `director` field as non-nullable, meaning that a Movie must have a `DIRECTED` relationship coming into it. +* A Person can act in or direct multiple movies, and a Movie can have multiple actors. However, it is exceedingly rare for a Movie to have more than one director, and you can model this cardinality in your type definitions, to ensure accuracy of your data. +* A Movie isn't really a Movie without a director, and this has been signified by marking the `director` field as non-nullable, meaning that a Movie must have a `DIRECTED` relationship coming into it. * To figure out whether the `direction` argument of the `@relationship` directive should be `IN` or `OUT`, visualise your relationships like in the diagram above, and model out the direction of the arrows. +* The @relationship directive is a reference to Neo4j relationships, whereas in the schema, the phrase edge(s) is used to be consistent with the general API language used by Relay. + +=== Relationship Properties + +Relationship properties can be added to the above type definitions in two steps: + +1. Add an interface definition containing the desired relationship properties +2. Add a `properties` argument to both "sides" of the `@relationship` directive which points to the newly defined interface + +For example, to distinguish which roles an actor played in a movie: + +[source, graphql] +---- +type Person { + name: String! + born: Int! + actedInMovies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + directedMovies: [Movie!]! @relationship(type: "DIRECTED", direction: OUT) +} + +type Movie { + title: String! + released: Int! + actors: [Person!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + director: Person! @relationship(type: "DIRECTED", direction: IN) +} + +interface ActedIn { + roles: [String!] +} +---- == Inserting data -Nested mutations mean that there are many ways in which we can insert data into our database through the GraphQL schema. We know that we can't create a Movie without adding a director, and we can do that by either creating the director first and then creating and connecting the movie, or we can create both the Movie and the director in the same mutation. Let's try the latter: +Nested mutations mean that there are many ways in which you can insert data into your database through the GraphQL schema. You can't create a Movie without adding a director, and you can do that by either creating the director first and then creating and connecting the movie, or you can create both the Movie and the director in the same mutation. With the latter approach: [source, graphql] ---- @@ -64,8 +95,10 @@ mutation CreateMovieAndDirector { released: 1994 director: { create: { - name: "Robert Zemeckis" - born: 1951 + node: { + name: "Robert Zemeckis" + born: 1951s + } } } } @@ -82,7 +115,7 @@ mutation CreateMovieAndDirector { } ---- -We then need to create the actor in our example, and connect them to the new Movie node: +You then need to create the actor in this example, and connect them to the new Movie node, also specifying which roles they played: [source, graphql] ---- @@ -94,7 +127,10 @@ mutation CreateActor { actedInMovies: { connect: { where: { - title: "Forrest Gump" + node: { title: "Forrest Gump" } + } + edge: { + roles: ["Forrest"] } } } @@ -107,16 +143,23 @@ mutation CreateActor { name born } - actors { - name - born + actorsConnection { + edges { + roles + node { + name + born + } + } } } } } ---- -As you can see, these nested mutations are very powerful, and in the second Mutation we ran, we were able to return the entire graph which was created in this example. In fact, these mutations can actually be compressed down into a single Mutation which inserts all of the data we need: +Note the selection of the `actorsConnection` field in order to query the `roles` relationship property. + +As you can see, these nested mutations are very powerful, and in the second Mutation you ran, you were able to return the entire graph which was created in this example. In fact, these mutations can actually be compressed down into a single Mutation which inserts all of the data needed: [source, graphql] ---- @@ -127,15 +170,22 @@ mutation CreateMovieDirectorAndActor { released: 1994 director: { create: { - name: "Robert Zemeckis" - born: 1951 + node: { + name: "Robert Zemeckis" + born: 1951 + } } } actors: { create: [ { - name: "Tom Hanks" - born: 1956 + node: { + name: "Tom Hanks" + born: 1956 + } + edge: { + roles: ["Forrest"] + } } ] } @@ -148,9 +198,14 @@ mutation CreateMovieDirectorAndActor { name born } - actors { - name - born + actorsConnection { + edges { + roles + node { + name + born + } + } } } } @@ -159,9 +214,9 @@ mutation CreateMovieDirectorAndActor { Once you get your head around this, you'll be creating giant sub-graphs in one Mutation in no time! -== Fetching our data +== Fetching your data -Now that we have our Movie information in the database, we can query all of the information which just inserted as follows: +Now that you have the Movie information in your database, you can query all of the information which you just inserted as follows: [source, graphql] ---- @@ -173,9 +228,14 @@ query { name born } - actors { - name - born + actorsConnection { + edges { + roles + node { + name + born + } + } } } } diff --git a/docs/asciidoc/type-definitions/types.adoc b/docs/asciidoc/type-definitions/types.adoc index cd3f517fd1..ae31a9a7c1 100644 --- a/docs/asciidoc/type-definitions/types.adoc +++ b/docs/asciidoc/type-definitions/types.adoc @@ -1,33 +1,32 @@ [[type-definitions-types]] = Types -Neo4j GraphQL supports all of the default GraphQL https://graphql.org/learn/schema/#scalar-types[scalar types] as well as the additional scalar and object types specified in this document. +Neo4j GraphQL supports all of the default GraphQL https://graphql.org/learn/schema/#scalar-types[scalar types] as well as additional scalar and object types specific to the Neo4j database. -[[type-definitions-types-datetime]] -== `DateTime` -ISO datetime string stored as a https://neo4j.com/docs/cypher-manual/current/functions/temporal/#functions-datetime[datetime] temporal type. +== Int -[source, graphql] ----- -type User { - createdAt: DateTime -} ----- +One of the default GraphQL scalar types. Supports up to 53-bit values - see <> for 64-bit value support. -[[type-definitions-types-date]] -== `Date` -"yyyy-mm-dd" date string stored as a https://neo4j.com/docs/cypher-manual/current/functions/temporal/#functions-date[date] temporal type. +== Float -[source, graphql] ----- -type Movie { - releaseDate: Date -} ----- +One of the default GraphQL scalar types. + +== String + +One of the default GraphQL scalar types. + +== Boolean + +One of the default GraphQL scalar types. + +== ID + +One of the default GraphQL scalar types. Stored as a string in the database and always returned as a string. [[type-definitions-types-bigint]] == `BigInt` -Supports up to 64 bit integers, serialized as strings in variables and in data responses. Shares the same <> as the other numeric types. + +Supports up to 64 bit integers, serialized as strings in variables and in data responses. Shares the same <> as the other numeric types. [source, graphql] ---- @@ -47,7 +46,32 @@ query { } ---- -[[type-definitions-types-spatial-types]] +[[type-definitions-types-temporal]] +== Temporal Types + +=== `DateTime` + +ISO datetime string stored as a https://neo4j.com/docs/cypher-manual/current/functions/temporal/#functions-datetime[datetime] temporal type. + +[source, graphql] +---- +type User { + createdAt: DateTime +} +---- + +=== `Date` + +"yyyy-mm-dd" date string stored as a https://neo4j.com/docs/cypher-manual/current/functions/temporal/#functions-date[date] temporal type. + +[source, graphql] +---- +type Movie { + releaseDate: Date +} +---- + +[[type-definitions-types-spatial]] == Spatial Types Neo4j GraphQL spatial types translate to spatial values stored using https://neo4j.com/docs/cypher-manual/current/syntax/spatial[point] in the database. The use of either of these types in a GraphQL schema will automatically introduce the types needed to run queries and mutations relevant to these spatial types. @@ -57,6 +81,19 @@ Neo4j GraphQL spatial types translate to spatial values stored using https://neo The `Point` type is used to describe the two https://neo4j.com/docs/cypher-manual/current/syntax/spatial/#cypher-spatial-crs-geographic[Geographic coordinate reference systems] supported by Neo4j. +In order to use it in your schema, you quite simply add a field with a type `Point` to any type or types in schema, like the following: + +[source, graphql] +---- +type TypeWithPoint { + location: Point! +} +---- + +Once this has been done, the `Point` type will be automatically added to your schema, in addition to all of the input and output types you will need to query and manipulate spatial types through your API. + +The rest of the documentation under this heading is documenting all of those automatically generated types and how to use them. + ==== Type definition [source, graphql] @@ -119,7 +156,7 @@ mutation CreateUsers($name: String!, $longitude: Float!, $latitude: Float!) { ==== Filtering -In addition to the <>, the `Point` type has an additional `_DISTANCE` filter. All of the filters take the following type as an argument: +In addition to the <>, the `Point` type has an additional `_DISTANCE` filter. All of the filters take the following type as an argument: [source, graphql] ---- @@ -157,6 +194,19 @@ query CloseByUsers($longitude: Float!, $latitude: Float!) { The `CartesianPoint` type is used to describe the two https://neo4j.com/docs/cypher-manual/current/syntax/spatial/#cypher-spatial-crs-cartesian[Cartesian coordinate reference systems] supported by Neo4j. +In order to use it in your schema, you quite simply add a field with a type `CartesianPoint` to any type or types in schema, like the following: + +[source, graphql] +---- +type TypeWithCartesianPoint { + location: CartesianPoint! +} +---- + +Once this has been done, the `CartesianPoint` type will be automatically added to your schema, in addition to all of the input and output types you will need to query and manipulate spatial types through your API. + +The rest of the documentation under this heading is documenting all of those automatically generated types and how to use them. + ==== Type definition [source, graphql] @@ -183,7 +233,7 @@ input CartesianPointInput { ==== Filtering -In addition to the <>, the `CartesianPoint` type has an additional `_DISTANCE` filter. All of the filters take the following type as an argument: +In addition to the <>, the `CartesianPoint` type has an additional `_DISTANCE` filter. All of the filters take the following type as an argument: [source, graphql] ---- diff --git a/docs/asciidoc/type-definitions/unions-and-interfaces.adoc b/docs/asciidoc/type-definitions/unions-and-interfaces.adoc index 8937c60d17..4f994ca518 100644 --- a/docs/asciidoc/type-definitions/unions-and-interfaces.adoc +++ b/docs/asciidoc/type-definitions/unions-and-interfaces.adoc @@ -5,79 +5,150 @@ Unions and interfaces are abstract GraphQL types that enable a schema field to r [[type-definitions-unions-and-interfaces-union-types]] == Union Types -Neo4j GraphQL supports the use of Unions on a `@relationship` field. -=== Example -The following schema defines a `User` type, that has a relationship(`HAS_CONTENT`), of type `[Content]`. `Content` is of type `union` representing either `Blog` or `Post`. +The Neo4j GraphQL Library supports the use of unions on relationship fields. For example, the following schema defines a `User` type, that has a relationship `HAS_CONTENT`, of type `[Content]`. `Content` is of type `union` representing either a `Blog` or a `Post`. [source, graphql] ---- union Content = Blog | Post type Blog { - title: String - posts: [Post] @relationship(type: "HAS_POST", direction: OUT) + title: String + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) } type Post { - content: String + content: String } type User { - name: String - content: [Content] @relationship(type: "HAS_CONTENT", direction: OUT) + name: String + content: [Content] @relationship(type: "HAS_CONTENT", direction: OUT) } ---- +Below you can find some examples of how queries and mutations work with this example. +[[type-definitions-unions-and-interfaces-union-types-querying]] === Querying a union -The below query gets the user and their content; + +Which union members are returned by a Query are dictated by the `where` filter applied. + +For example, the following will return all user content, and you will specifically get the title of each blog. [source, graphql] ---- -query GetUsersWithContent { - users { - name - content { - ... on Blog { - title - posts { - content +query GetUsersWithBlogs { + users { + name + content { + ... on Blog { + title } } - ... on Post { - content + } +} +---- + +Whilst the query below will only return blogs. You could for instance use a filter to check that the title is not null to essentially return all blogs: + +[source, graphql] +---- +query GetUsersWithAllContent { + users { + name + content(where: { Blog: { title_NOT: null }}) { + ... on Blog { + title + } } } - } +} +---- + +Conceptually, this maps to the `WHERE` clauses of the subquery unions in Cypher. Going back to the first example with no `where` argument, each subquery has a similar structure: + +[source, cypher] +---- +CALL { + WITH this + OPTIONAL MATCH (this)-[has_content:HAS_CONTENT]->(blog:Blog) + RETURN { __resolveType: "Blog", title: blog.title } +UNION + WITH this + OPTIONAL MATCH (this)-[has_content:HAS_CONTENT]->(journal:Post) + RETURN { __resolveType: "Post" } +} +---- + +Now if you were to leave both subqueries and add a `WHERE` clause for blogs, it would look like this: + +[source, cypher] +---- +CALL { + WITH this + OPTIONAL MATCH (this)-[has_content:HAS_CONTENT]->(blog:Blog) + WHERE blog.title IS NOT NULL + RETURN { __resolveType: "Blog", title: blog.title } +UNION + WITH this + OPTIONAL MATCH (this)-[has_content:HAS_CONTENT]->(journal:Post) + RETURN { __resolveType: "Post" } +} +---- + +As you can see, the subqueries are now "unbalanced", which could result in massive overfetching of `Post` nodes. + +So, when a `where` argument is passed in, only union members which are in the `where` object are fetched, so it is essentially acting as a logical OR gate, different from the rest of the `where` arguments in the schema: + +[source, cypher] +---- +CALL { + WITH this + OPTIONAL MATCH (this)-[has_content:HAS_CONTENT]->(blog:Blog) + WHERE blog.title IS NOT NULL + RETURN { __resolveType: "Blog", title: blog.title } } ---- === Creating a union -The below mutation creates the user and their content; + +The below mutation creates the user and their content: [source, graphql] ---- mutation CreateUserAndContent { - createUsers( - input: [ - { - name: "Dan" - content_Blog: { - create: [ + createUsers( + input: [ { - title: "My Cool Blog" - posts: { create: [{ content: "My Cool Post" }] } + name: "Dan" + content: { + Blog: { + create: [ + { + node: { + title: "My Cool Blog" + posts: { + create: [ + { + node: { + content: "My Cool Post" + } + } + ] + } + } + } + ] + } + } } - ] + ] + ) { + users { + name } - } - ] - ) { - users { - name } - } } ---- @@ -85,4 +156,6 @@ mutation CreateUserAndContent { == Interface Types -Using interface types will give you no real database support therefor no; query, update, delete, filter support. But instead used as a language feature to safeguard your schema. Great for when dealing with repetitive or large schemas you can essentially put "The side railings up". +At the moment, the only support that the Neo4j GraphQL Library offers for interfaces is in the definition of relationship properties. + +Beyond this, feel free to use them in your implementation of node types, however the library will offer no database support for these - it will essentially ignore them and focus only on the implementing GraphQL object types. diff --git a/docs/docbook/content-map.xml b/docs/docbook/content-map.xml index 09c0d4287e..c84b5f4c07 100644 --- a/docs/docbook/content-map.xml +++ b/docs/docbook/content-map.xml @@ -3,12 +3,19 @@ + + + + + + + @@ -32,35 +39,47 @@ - - + + - - - - - - - - - - + + + + + + + + - - + + - - + + - - + + - - + + + + + + + + + + + + + + + + @@ -68,6 +87,9 @@ + + + @@ -76,18 +98,18 @@ - - - - - - + + + + + + @@ -97,41 +119,67 @@ + + + + + + - - + + + - - - - - - - - - - + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + @@ -151,10 +199,25 @@ + + + + + + + + + + + + + + + diff --git a/docs/images/apollo-server-landing-page.png b/docs/images/apollo-server-landing-page.png new file mode 100644 index 0000000000000000000000000000000000000000..f4af49d8837f61c68427a69c61ce86399c7d2ba7 GIT binary patch literal 89111 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/images/relationships.png b/docs/images/relationships.png deleted file mode 100644 index 27aa3c9ef3e2cf8e68568636730fb9d3e69bf40a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29839 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 diff --git a/docs/images/relationships.svg b/docs/images/relationships.svg new file mode 100644 index 0000000000..0de9bcf93d --- /dev/null +++ b/docs/images/relationships.svg @@ -0,0 +1,55 @@ + + + + + + +L + + + +N2 + +Person + +name = 'Tom Hanks' +born = 1956 + + + +N3 + +Movie + +title = 'Forrest Gump' +released = 1994 + + + +N2->N3 + + +ACTED_IN +roles = ['Forrest'] + + + +N4 + +Person + +name = 'Robert Zemeckis' +born = 1951 + + + +N4->N3 + + +DIRECTED + + + diff --git a/examples/migration/package.json b/examples/migration/package.json index 800c026c5d..e0e883bd49 100644 --- a/examples/migration/package.json +++ b/examples/migration/package.json @@ -4,7 +4,7 @@ "start": "node src/index.js" }, "dependencies": { - "@neo4j/graphql": "^1.2.4", + "@neo4j/graphql": "^2.0.0-rc.2", "apollo-server": "^2.23.0", "graphql": "^15.0.0", "neo4j-driver": "^4.2.0" diff --git a/examples/neo-push/README.md b/examples/neo-push/README.md index 95bbfa36f6..584f2cc0a1 100644 --- a/examples/neo-push/README.md +++ b/examples/neo-push/README.md @@ -142,10 +142,10 @@ Once logged in users are directed to the dashboard page; ![dashboard](assets/dashboard.jpg) ```graphql -query myBlogs($id: ID, $skip: Int, $limit: Int, $hasNextBlogsSkip: Int) { +query myBlogs($id: ID, $offset: Int, $limit: Int, $hasNextBlogsoffset: Int) { myBlogs: blogs( where: { OR: [{ creator: { id: $id } }, { authors: { id: $id } }] } - options: { limit: $limit, skip: $skip, sort: { createdAt: DESC } } + options: { limit: $limit, offset: $offset, sort: { createdAt: DESC } } ) { id name @@ -159,7 +159,7 @@ query myBlogs($id: ID, $skip: Int, $limit: Int, $hasNextBlogsSkip: Int) { where: { OR: [{ creator: { id: $id } }, { authors: { id: $id } }] } options: { limit: 1 - skip: $hasNextBlogsSkip + offset: $hasNextBlogsoffset sort: { createdAt: DESC } } ) { @@ -186,7 +186,12 @@ From the dashboard you can create a blog. ```graphql mutation($name: String!, $sub: ID) { createBlogs( - input: [{ name: $name, creator: { connect: { where: { id: $sub } } } }] + input: [ + { + name: $name + creator: { connect: { where: { node: { id: $sub } } } } + } + ] ) { blogs { id @@ -223,7 +228,7 @@ If you are the creator of a blog you can assign other users as an author. You ca mutation assignBlogAuthor($blog: ID, $authorEmail: String) { updateBlogs( where: { id: $blog } - connect: { authors: { where: { email: $authorEmail } } } + connect: { authors: { where: { node: { email: $authorEmail } } } } ) { blogs { authors { @@ -288,8 +293,8 @@ mutation createPost($title: String!, $content: String!, $user: ID, $blog: ID) { { title: $title content: $content - blog: { connect: { where: { id: $blog } } } - author: { connect: { where: { id: $user } } } + blog: { connect: { where: { node: { id: $blog } } } } + author: { connect: { where: { node: { id: $user } } } } } ] ) { @@ -354,8 +359,8 @@ mutation commentOnPost($post: ID, $content: String!, $user: ID) { input: [ { content: $content - post: { connect: { where: { id: $post } } } - author: { connect: { where: { id: $user } } } + post: { connect: { where: { node: { id: $post } } } } + author: { connect: { where: { node: { id: $user } } } } } ] ) { diff --git a/examples/neo-push/client/src/components/Blog.tsx b/examples/neo-push/client/src/components/Blog.tsx index 6726fdde0e..56b72b2004 100644 --- a/examples/neo-push/client/src/components/Blog.tsx +++ b/examples/neo-push/client/src/components/Blog.tsx @@ -138,7 +138,7 @@ function CreatePost({ close, blog }: { close: () => void; blog: BlogInterface }) function BlogPosts({ blog }: { blog: BlogInterface }) { const { query } = useContext(graphql.Context); - const [skip, setSkip] = useState(0); + const [offset, setOffset] = useState(0); const [limit] = useState(10); const [hasMore, setHasMore] = useState(false); const [posts, setPosts] = useState([]); @@ -150,9 +150,9 @@ function BlogPosts({ blog }: { blog: BlogInterface }) { query: BLOG_POSTS, variables: { blog: blog.id, - skip: posts.length, + offset: posts.length, limit, - hasNextPostsSkip: posts.length === 0 ? limit : posts.length + 1, + hasNextPostsOffset: posts.length === 0 ? limit : posts.length + 1, }, }); @@ -161,11 +161,11 @@ function BlogPosts({ blog }: { blog: BlogInterface }) { } catch (e) {} setLoading(false); - }, [skip, posts]); + }, [offset, posts]); useEffect(() => { getPosts(); - }, [skip]); + }, [offset]); if (loading) { @@ -190,7 +190,7 @@ function BlogPosts({ blog }: { blog: BlogInterface }) { {hasMore && (
- +
)}
diff --git a/examples/neo-push/client/src/components/Dashboard.tsx b/examples/neo-push/client/src/components/Dashboard.tsx index c9d3198da6..57c5ea22ec 100644 --- a/examples/neo-push/client/src/components/Dashboard.tsx +++ b/examples/neo-push/client/src/components/Dashboard.tsx @@ -114,7 +114,7 @@ function BlogItem(props: { blog: any; updated?: boolean }) { function MyBlogs() { const { getId } = useContext(auth.Context); const { query } = useContext(graphql.Context); - const [skip, setSkip] = useState(0); + const [offset, setOffset] = useState(0); const [limit] = useState(10); const [myBlogsHasMore, setMyBlogsHasMore] = useState(false); const [blogs, setBlogs] = useState([]); @@ -128,9 +128,9 @@ function MyBlogs() { query: MY_BLOGS, variables: { id: getId(), - skip: blogs.length, + offset: blogs.length, limit, - hasNextBlogsSkip: blogs.length === 0 ? limit : blogs.length + 1, + hasNextBlogsOffset: blogs.length === 0 ? limit : blogs.length + 1, }, }); @@ -139,11 +139,11 @@ function MyBlogs() { } catch (e) {} setLoading(false); - }, [skip, blogs, limit]); + }, [offset, blogs, limit]); useEffect(() => { getBlogs(); - }, [skip]); + }, [offset]); if (loading) { @@ -163,7 +163,7 @@ function MyBlogs() { ))} {myBlogsHasMore && ( -
setSkip((s) => s + 1)}> +
setOffset((s) => s + 1)}>
)} @@ -174,7 +174,7 @@ function MyBlogs() { function RecentlyUpdatedBlogs() { const { query } = useContext(graphql.Context); const [limit] = useState(10); - const [skip, setSkip] = useState(0); + const [offset, setOffset] = useState(0); const [myBlogsHasMore, setMyBlogsHasMore] = useState(false); const [blogs, setBlogs] = useState([]); const [loading, setLoading] = useState(true); @@ -185,9 +185,9 @@ function RecentlyUpdatedBlogs() { const response = await query({ query: RECENTLY_UPDATED_BLOGS, variables: { - skip: blogs.length, + offset: blogs.length, limit, - hasNextBlogsSkip: blogs.length === 0 ? limit : blogs.length + 1, + hasNextBlogsOffset: blogs.length === 0 ? limit : blogs.length + 1, }, }); @@ -196,11 +196,11 @@ function RecentlyUpdatedBlogs() { } catch (e) {} setLoading(false); - }, [skip, blogs]); + }, [offset, blogs]); useEffect(() => { getBlogs(); - }, [skip]); + }, [offset]); if (loading) { @@ -220,7 +220,7 @@ function RecentlyUpdatedBlogs() { ))} {myBlogsHasMore && ( -
setSkip((s) => s + 1)}> +
setOffset((s) => s + 1)}>
)} diff --git a/examples/neo-push/client/src/components/Post.tsx b/examples/neo-push/client/src/components/Post.tsx index f31f23c99e..6f8dc4e938 100644 --- a/examples/neo-push/client/src/components/Post.tsx +++ b/examples/neo-push/client/src/components/Post.tsx @@ -396,7 +396,7 @@ function PostComments({ setComments: (cb: (comments: Comment[]) => any) => void; }) { const { query } = useContext(graphql.Context); - const [skip, setSkip] = useState(0); + const [offset, setOffset] = useState(0); const [limit] = useState(10); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); @@ -408,9 +408,9 @@ function PostComments({ query: POST_COMMENTS, variables: { post, - skip: comments.length, + offset: comments.length, limit, - hasNextCommentsSkip: comments.length === 0 ? limit : comments.length + 1, + hasNextCommentsOffset: comments.length === 0 ? limit : comments.length + 1, }, }); @@ -423,11 +423,11 @@ function PostComments({ } setLoading(false); - }, [skip]); + }, [offset]); useEffect(() => { getComments(); - }, [skip]); + }, [offset]); if (error) { return ( @@ -463,7 +463,7 @@ function PostComments({ ))} {hasMore && (
- +
)} diff --git a/examples/neo-push/client/src/queries.ts b/examples/neo-push/client/src/queries.ts index 6741aef38d..a01a0295d9 100644 --- a/examples/neo-push/client/src/queries.ts +++ b/examples/neo-push/client/src/queries.ts @@ -22,7 +22,7 @@ export const USER = gql` export const CREATE_BLOG = gql` mutation($name: String!, $sub: ID) { - createBlogs(input: [{ name: $name, creator: { connect: { where: { id: $sub } } } }]) { + createBlogs(input: [{ name: $name, creator: { connect: { where: { node: { id: $sub } } } } }]) { blogs { id name @@ -33,10 +33,10 @@ export const CREATE_BLOG = gql` `; export const MY_BLOGS = gql` - query myBlogs($id: ID, $skip: Int, $limit: Int, $hasNextBlogsSkip: Int) { + query myBlogs($id: ID, $offset: Int, $limit: Int, $hasNextBlogsOffset: Int) { myBlogs: blogs( where: { OR: [{ creator: { id: $id } }, { authors: { id: $id } }] } - options: { limit: $limit, skip: $skip, sort: { createdAt: DESC } } + options: { limit: $limit, offset: $offset, sort: { createdAt: DESC } } ) { id name @@ -48,7 +48,7 @@ export const MY_BLOGS = gql` } hasNextBlogs: blogs( where: { OR: [{ creator: { id: $id } }, { authors: { id: $id } }] } - options: { limit: 1, skip: $hasNextBlogsSkip, sort: { createdAt: DESC } } + options: { limit: 1, offset: $hasNextBlogsOffset, sort: { createdAt: DESC } } ) { id } @@ -56,8 +56,8 @@ export const MY_BLOGS = gql` `; export const RECENTLY_UPDATED_BLOGS = gql` - query recentlyUpdatedBlogs($skip: Int, $limit: Int, $hasNextBlogsSkip: Int) { - recentlyUpdatedBlogs: blogs(options: { limit: $limit, skip: $skip, sort: { updatedAt: DESC } }) { + query recentlyUpdatedBlogs($offset: Int, $limit: Int, $hasNextBlogsOffset: Int) { + recentlyUpdatedBlogs: blogs(options: { limit: $limit, offset: $offset, sort: { updatedAt: DESC } }) { id name creator { @@ -66,7 +66,7 @@ export const RECENTLY_UPDATED_BLOGS = gql` } updatedAt } - hasNextBlogs: blogs(options: { limit: 1, skip: $hasNextBlogsSkip, sort: { updatedAt: DESC } }) { + hasNextBlogs: blogs(options: { limit: 1, offset: $hasNextBlogsOffset, sort: { updatedAt: DESC } }) { id } } @@ -123,8 +123,8 @@ export const CREATE_POST = gql` { title: $title content: $content - blog: { connect: { where: { id: $blog } } } - author: { connect: { where: { id: $user } } } + blog: { connect: { where: { node: { id: $blog } } } } + author: { connect: { where: { node: { id: $user } } } } } ] ) { @@ -155,10 +155,10 @@ export const POST = gql` `; export const BLOG_POSTS = gql` - query blogPosts($blog: ID, $skip: Int, $limit: Int, $hasNextPostsSkip: Int) { + query blogPosts($blog: ID, $offset: Int, $limit: Int, $hasNextPostsOffset: Int) { blogPosts: posts( where: { blog: { id: $blog } } - options: { skip: $skip, limit: $limit, sort: { createdAt: DESC } } + options: { offset: $offset, limit: $limit, sort: { createdAt: DESC } } ) { id title @@ -169,7 +169,7 @@ export const BLOG_POSTS = gql` } hasNextPosts: posts( where: { blog: { id: $blog } } - options: { skip: $hasNextPostsSkip, limit: 1, sort: { createdAt: DESC } } + options: { offset: $hasNextPostsOffset, limit: 1, sort: { createdAt: DESC } } ) { id } @@ -182,8 +182,8 @@ export const COMMENT_ON_POST = gql` input: [ { content: $content - post: { connect: { where: { id: $post } } } - author: { connect: { where: { id: $user } } } + post: { connect: { where: { node: { id: $post } } } } + author: { connect: { where: { node: { id: $user } } } } } ] ) { @@ -201,10 +201,10 @@ export const COMMENT_ON_POST = gql` `; export const POST_COMMENTS = gql` - query postComments($post: ID, $skip: Int, $limit: Int, $hasNextCommentsSkip: Int) { + query postComments($post: ID, $offset: Int, $limit: Int, $hasNextCommentsOffset: Int) { postComments: comments( where: { post: { id: $post } } - options: { skip: $skip, limit: $limit, sort: { createdAt: ASC } } + options: { offset: $offset, limit: $limit, sort: { createdAt: ASC } } ) { id author { @@ -217,7 +217,7 @@ export const POST_COMMENTS = gql` } hasNextComments: comments( where: { post: { id: $post } } - options: { skip: $hasNextCommentsSkip, limit: 1, sort: { createdAt: ASC } } + options: { offset: $hasNextCommentsOffset, limit: 1, sort: { createdAt: ASC } } ) { id } @@ -265,7 +265,7 @@ export const DELETE_POST = gql` export const ASSIGN_BLOG_AUTHOR = gql` mutation assignBlogAuthor($blog: ID, $authorEmail: String) { - updateBlogs(where: { id: $blog }, connect: { authors: { where: { email: $authorEmail } } }) { + updateBlogs(where: { id: $blog }, connect: { authors: { where: { node: { email: $authorEmail } } } }) { blogs { authors { email diff --git a/examples/neo-push/server/package.json b/examples/neo-push/server/package.json index 9c235f560d..81e8ddb7cb 100644 --- a/examples/neo-push/server/package.json +++ b/examples/neo-push/server/package.json @@ -12,8 +12,8 @@ "author": "", "license": "ISC", "dependencies": { - "@neo4j/graphql": "^1.2.4", - "@neo4j/graphql-ogm": "^1.2.4", + "@neo4j/graphql": "^2.0.0-rc.2", + "@neo4j/graphql-ogm": "^2.0.0-rc.2", "apollo-server-express": "2.19.0", "bcrypt": "5.0.1", "debug": "4.3.1", diff --git a/examples/neo-push/server/src/seeder.ts b/examples/neo-push/server/src/seeder.ts index c895a5f2d8..f2b59a0bb8 100644 --- a/examples/neo-push/server/src/seeder.ts +++ b/examples/neo-push/server/src/seeder.ts @@ -40,14 +40,14 @@ async function main() { return { name: faker.lorem.word(), creator: { - connect: { where: { id: user.id } }, + connect: { where: { node: { id: user.id } } }, }, posts: { create: new Array(3).fill(null).map(() => ({ title: faker.lorem.word(), content: faker.lorem.paragraphs(4), author: { - connect: { where: { id: user.id } }, + connect: { where: { node: { id: user.id } } }, }, comments: { create: new Array(3).fill(null).map(() => { @@ -56,7 +56,7 @@ async function main() { return { content: faker.lorem.paragraph(), author: { - connect: { where: { id: u.id } }, + connect: { where: { node: { id: u.id } } }, }, }; }), diff --git a/examples/neo-push/server/tests/integration/graphql/blog-auth.test.ts b/examples/neo-push/server/tests/integration/graphql/blog-auth.test.ts index 3459a37739..dc6387ba6d 100644 --- a/examples/neo-push/server/tests/integration/graphql/blog-auth.test.ts +++ b/examples/neo-push/server/tests/integration/graphql/blog-auth.test.ts @@ -27,7 +27,7 @@ describe("blog-auth", () => { const mutation = gql` mutation { - createBlogs(input: [{ name: "test", creator: { connect: { where: { id: "invalid" } } } }]) { + createBlogs(input: [{ name: "test", creator: { connect: { where: { node: { id: "invalid" } } } } }]) { blogs { id } diff --git a/examples/neo-push/server/tests/integration/graphql/comment-auth.test.ts b/examples/neo-push/server/tests/integration/graphql/comment-auth.test.ts index 41703f1808..b92d32ceb7 100644 --- a/examples/neo-push/server/tests/integration/graphql/comment-auth.test.ts +++ b/examples/neo-push/server/tests/integration/graphql/comment-auth.test.ts @@ -27,7 +27,9 @@ describe("comment-auth", () => { const mutation = gql` mutation { - createComments(input: [{ content: "test", author: { connect: { where: { id: "invalid" } } } }]) { + createComments( + input: [{ content: "test", author: { connect: { where: { node: { id: "invalid" } } } } }] + ) { comments { id } diff --git a/examples/neo-push/server/tests/integration/graphql/post-auth.test.ts b/examples/neo-push/server/tests/integration/graphql/post-auth.test.ts index e6781fe7d4..be38e8202f 100644 --- a/examples/neo-push/server/tests/integration/graphql/post-auth.test.ts +++ b/examples/neo-push/server/tests/integration/graphql/post-auth.test.ts @@ -29,7 +29,11 @@ describe("post-auth", () => { mutation { createPosts( input: [ - { title: "some post", content: "content", author: { connect: { where: { id: "invalid" } } } } + { + title: "some post" + content: "content" + author: { connect: { where: { node: { id: "invalid" } } } } + } ] ) { posts { diff --git a/examples/neo-push/server/tests/integration/graphql/workflow.test.ts b/examples/neo-push/server/tests/integration/graphql/workflow.test.ts index a380774e1e..ceb9bf4c0a 100644 --- a/examples/neo-push/server/tests/integration/graphql/workflow.test.ts +++ b/examples/neo-push/server/tests/integration/graphql/workflow.test.ts @@ -96,7 +96,7 @@ describe("workflow", () => { name: "${blog.initialName}", creator: { connect: { - where: { id:"${user.id}" } + where: { node: { id:"${user.id}" } } } } } @@ -137,12 +137,12 @@ describe("workflow", () => { title: "${post.initialTitle}" author: { connect: { - where: { id: "${user.id}" } + where: { node: { id: "${user.id}" } } } } blog: { connect: { - where: { id: "${blog.id}" } + where: { node: { id: "${blog.id}" } } } } } @@ -182,12 +182,12 @@ describe("workflow", () => { content: "${comment.initialContent}", author: { connect: { - where: { id: "${user.id}" } + where: { node: { id: "${user.id}" } } } } post: { connect: { - where: { id: "${post.id}" } + where: { node: { id: "${post.id}" } } } } } diff --git a/packages/graphql/README.md b/packages/graphql/README.md index 454493b72b..4555803728 100644 --- a/packages/graphql/README.md +++ b/packages/graphql/README.md @@ -14,7 +14,7 @@ A GraphQL to Cypher query execution layer for Neo4j and JavaScript GraphQL implementations. -1. [Documentation](https://neo4j.com/docs/graphql-manual/current/) +1. [Documentation](https://neo4j.com/docs/graphql-manual/2.0/) ## Installation @@ -97,7 +97,11 @@ mutation { updateMovies( where: { title: "The Matrix" } connect: { - genres: { where: { OR: [{ name: "Sci-fi" }, { name: "Action" }] } } + genres: { + where: { + node: { OR: [{ name: "Sci-fi" }, { name: "Action" }] } + } + } } ) { movies { @@ -119,7 +123,11 @@ mutation { imdbRating: 8.7 genres: { connect: { - where: { AND: [{ name: "Sci-fi" }, { name: "Action" }] } + where: { + node: { + AND: [{ name: "Sci-fi" }, { name: "Action" }] + } + } } } } diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 0cc55e9967..8cd7ef978f 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@neo4j/graphql", - "version": "1.2.4", + "version": "2.0.0-rc.2", "description": "A GraphQL to Cypher query execution layer for Neo4j and JavaScript GraphQL implementations", "keywords": [ "neo4j", @@ -44,7 +44,8 @@ "@types/node": "14.0.27", "@types/pluralize": "0.0.29", "@types/randomstring": "1.1.6", - "apollo-server": "2.21.0", + "apollo-server": "^3.0.2", + "dedent": "^0.7.0", "faker": "5.2.0", "graphql-tag": "2.11.0", "is-uuid": "1.0.2", @@ -53,23 +54,24 @@ "npm-run-all": "4.1.5", "randomstring": "1.1.5", "rimraf": "3.0.2", - "semver": "7.3.5", "ts-jest": "26.1.4", "ts-node": "^10.0.0", "typescript": "3.9.7" }, "dependencies": { - "@graphql-tools/merge": "^6.2.13", - "@graphql-tools/schema": "^7.1.3", - "@graphql-tools/utils": "^7.7.3", + "@graphql-tools/merge": "6.2.14", + "@graphql-tools/schema": "^7.1.5", + "@graphql-tools/utils": "^7.10.0", "camelcase": "^6.2.0", - "debug": "^4.3.1", + "debug": "^4.3.2", "deep-equal": "^2.0.5", "dot-prop": "^6.0.1", - "graphql-compose": "^7.25.1", - "graphql-parse-resolve-info": "^4.11.0", + "graphql-compose": "^9.0.2", + "graphql-parse-resolve-info": "^4.12.0", + "graphql-relay": "^0.8.0", "jsonwebtoken": "^8.5.1", - "pluralize": "^8.0.0" + "pluralize": "^8.0.0", + "semver": "^7.3.5" }, "peerDependencies": { "graphql": "^15.0.0", diff --git a/packages/graphql/src/classes/Neo4jGraphQL.ts b/packages/graphql/src/classes/Neo4jGraphQL.ts index 8760f83f1b..af66715c76 100644 --- a/packages/graphql/src/classes/Neo4jGraphQL.ts +++ b/packages/graphql/src/classes/Neo4jGraphQL.ts @@ -21,9 +21,11 @@ import Debug from "debug"; import { Driver } from "neo4j-driver"; import { DocumentNode, GraphQLResolveInfo, GraphQLSchema, parse, printSchema, print } from "graphql"; import { addResolversToSchema, addSchemaLevelResolver, IExecutableSchemaDefinition } from "@graphql-tools/schema"; +import { SchemaDirectiveVisitor } from "@graphql-tools/utils"; import type { DriverConfig, CypherQueryOptions } from "../types"; import { makeAugmentedSchema } from "../schema"; import Node from "./Node"; +import Relationship from "./Relationship"; import { checkNeo4jCompat } from "../utils"; import { getJWT } from "../auth/index"; import { DEBUG_GRAPHQL } from "../constants"; @@ -34,7 +36,7 @@ const debug = Debug(DEBUG_GRAPHQL); export interface Neo4jGraphQLJWT { secret: string; - noVerify?: string; + noVerify?: boolean; rolesPath?: string; } @@ -42,12 +44,14 @@ export interface Neo4jGraphQLConfig { driverConfig?: DriverConfig; jwt?: Neo4jGraphQLJWT; enableRegex?: boolean; + skipValidateTypeDefs?: boolean; queryOptions?: CypherQueryOptions; } -export interface Neo4jGraphQLConstructor extends IExecutableSchemaDefinition { +export interface Neo4jGraphQLConstructor extends Omit { config?: Neo4jGraphQLConfig; driver?: Driver; + schemaDirectives?: Record; } class Neo4jGraphQL { @@ -55,6 +59,8 @@ class Neo4jGraphQL { public nodes: Node[]; + public relationships: Relationship[]; + public document: DocumentNode; private driver?: Driver; @@ -62,15 +68,26 @@ class Neo4jGraphQL { public config?: Neo4jGraphQLConfig; constructor(input: Neo4jGraphQLConstructor) { - const { config = {}, driver, resolvers, ...schemaDefinition } = input; - const { nodes, schema } = makeAugmentedSchema(schemaDefinition, { enableRegex: config.enableRegex }); + const { config = {}, driver, resolvers, schemaDirectives, ...schemaDefinition } = input; + const { nodes, relationships, schema } = makeAugmentedSchema(schemaDefinition, { + enableRegex: config.enableRegex, + skipValidateTypeDefs: config.skipValidateTypeDefs, + }); this.driver = driver; this.config = config; this.nodes = nodes; + this.relationships = relationships; this.schema = schema; + /* - addResolversToSchema must be first, so that custom resolvers also get schema level resolvers + Order must be: + + addResolversToSchema -> visitSchemaDirectives -> createWrappedSchema + + addResolversToSchema breaks schema directives added before it + + createWrappedSchema must come last so that all requests have context prepared correctly */ if (resolvers) { if (Array.isArray(resolvers)) { @@ -81,6 +98,11 @@ class Neo4jGraphQL { this.schema = addResolversToSchema(this.schema, resolvers); } } + + if (schemaDirectives) { + SchemaDirectiveVisitor.visitSchemaDirectives(this.schema, schemaDirectives); + } + this.schema = this.createWrappedSchema({ schema: this.schema, config }); this.document = parse(printSchema(schema)); } diff --git a/packages/graphql/src/classes/Node.ts b/packages/graphql/src/classes/Node.ts index 4253a24f62..4ffa57db15 100644 --- a/packages/graphql/src/classes/Node.ts +++ b/packages/graphql/src/classes/Node.ts @@ -20,6 +20,7 @@ import { DirectiveNode, NamedTypeNode } from "graphql"; import type { RelationField, + ConnectionField, CypherField, PrimitiveField, CustomEnumField, @@ -37,6 +38,7 @@ import Exclude from "./Exclude"; export interface NodeConstructor { name: string; relationFields: RelationField[]; + connectionFields: ConnectionField[]; cypherFields: CypherField[]; primitiveFields: PrimitiveField[]; scalarFields: CustomScalarField[]; @@ -59,6 +61,8 @@ class Node { public relationFields: RelationField[]; + public connectionFields: ConnectionField[]; + public cypherFields: CypherField[]; public primitiveFields: PrimitiveField[]; @@ -119,6 +123,7 @@ class Node { constructor(input: NodeConstructor) { this.name = input.name; this.relationFields = input.relationFields; + this.connectionFields = input.connectionFields; this.cypherFields = input.cypherFields; this.primitiveFields = input.primitiveFields; this.scalarFields = input.scalarFields; diff --git a/packages/graphql/src/classes/Relationship.ts b/packages/graphql/src/classes/Relationship.ts new file mode 100644 index 0000000000..6a1fb85925 --- /dev/null +++ b/packages/graphql/src/classes/Relationship.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + PrimitiveField, + DateTimeField, + PointField, + CustomEnumField, + CypherField, + CustomScalarField, + BaseField, +} from "../types"; + +export interface RelationshipConstructor { + name: string; + type: string; + description?: string; + properties?: string; + cypherFields?: CypherField[]; + primitiveFields?: PrimitiveField[]; + scalarFields?: CustomScalarField[]; + enumFields?: CustomEnumField[]; + dateTimeFields?: DateTimeField[]; + pointFields?: PointField[]; + ignoredFields?: BaseField[]; +} + +class Relationship { + public name: string; + + public type: string; + + public description?: string; + + public properties?: string; + + public primitiveFields: PrimitiveField[]; + + public scalarFields: CustomScalarField[]; + + public enumFields: CustomEnumField[]; + + public dateTimeFields: DateTimeField[]; + + public pointFields: PointField[]; + + public ignoredFields: BaseField[]; + + constructor(input: RelationshipConstructor) { + this.name = input.name; + this.type = input.type; + this.description = input.description; + this.properties = input.properties; + this.primitiveFields = input.primitiveFields || []; + this.scalarFields = input.scalarFields || []; + this.enumFields = input.enumFields || []; + this.dateTimeFields = input.dateTimeFields || []; + this.pointFields = input.pointFields || []; + this.ignoredFields = input.ignoredFields || []; + } +} + +export default Relationship; diff --git a/packages/graphql/src/classes/index.ts b/packages/graphql/src/classes/index.ts index 1004293c31..77861da872 100644 --- a/packages/graphql/src/classes/index.ts +++ b/packages/graphql/src/classes/index.ts @@ -18,6 +18,7 @@ */ export { default as Node, NodeConstructor } from "./Node"; +export { default as Relationship } from "./Relationship"; export { default as Exclude, ExcludeConstructor } from "./Exclude"; export { default as Neo4jGraphQL, Neo4jGraphQLConstructor, Neo4jGraphQLConfig } from "./Neo4jGraphQL"; export * from "./Error"; diff --git a/packages/graphql/src/constants.ts b/packages/graphql/src/constants.ts index 69c41560d5..7d3de39baf 100644 --- a/packages/graphql/src/constants.ts +++ b/packages/graphql/src/constants.ts @@ -21,7 +21,7 @@ const DEBUG_PREFIX = "@neo4j/graphql"; export const AUTH_FORBIDDEN_ERROR = "@neo4j/graphql/FORBIDDEN"; export const AUTH_UNAUTHENTICATED_ERROR = "@neo4j/graphql/UNAUTHENTICATED"; -export const MIN_NEO4J_VERSION = "4.1.0"; +export const MIN_NEO4J_VERSION = "4.1.5"; export const MIN_APOC_VERSION = "4.1.0"; export const REQUIRED_APOC_FUNCTIONS = [ "apoc.util.validatePredicate", @@ -34,3 +34,22 @@ export const REQUIRED_APOC_PROCEDURES = ["apoc.util.validate", "apoc.do.when", " export const DEBUG_AUTH = `${DEBUG_PREFIX}:auth`; export const DEBUG_GRAPHQL = `${DEBUG_PREFIX}:graphql`; export const DEBUG_EXECUTE = `${DEBUG_PREFIX}:execute`; + +// [0]Name [1]Error +export const RESERVED_TYPE_NAMES = [ + [ + "PageInfo", + "Type or Interface with name `PageInfo` reserved to support the pagination model of connections. See https://relay.dev/graphql/connections.htm#sec-Reserved-Types for more information.", + ], + [ + "Connection", + 'Type or Interface with name ending "Connection" are reserved to support the pagination model of connections. See https://relay.dev/graphql/connections.htm#sec-Reserved-Types for more information.', + ], + ["Node", "Type or Interface with name 'Node' reserved to support relay See https://relay.dev/graphql/"], +]; + +// [0]Field [1]Error +export const RESERVED_INTERFACE_FIELDS = [ + ["node", "Interface field name 'node' reserved to support relay See https://relay.dev/graphql/"], + ["cursor", "Interface field name 'cursor' reserved to support relay See https://relay.dev/graphql/"], +]; diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 9a57d6013d..f6addf47f9 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -35,4 +35,9 @@ export { CypherRuntime, CypherUpdateStrategy, } from "./types"; -export { Neo4jGraphQL, Neo4jGraphQLConstructor } from "./classes"; +export { + Neo4jGraphQL, + Neo4jGraphQLConstructor, + Neo4jGraphQLAuthenticationError, + Neo4jGraphQLForbiddenError, +} from "./classes"; diff --git a/packages/graphql/src/schema/check-node-implements-interface.test.ts b/packages/graphql/src/schema/check-node-implements-interface.test.ts deleted file mode 100644 index a7274f2704..0000000000 --- a/packages/graphql/src/schema/check-node-implements-interface.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InterfaceTypeDefinitionNode, ObjectTypeDefinitionNode, parse } from "graphql"; -import checkNodeImplementsInterfaces from "./check-node-implements-interfaces"; - -describe("checkNodeImplementsInterfaces", () => { - test("should throw incorrect with field", () => { - const typeDefs = ` - interface Node { - id: ID - } - - type Movie implements Node { - title: String! - } - `; - - const document = parse(typeDefs); - - const node = document.definitions.find((x) => x.kind === "ObjectTypeDefinition") as ObjectTypeDefinitionNode; - const inter = document.definitions.find( - (x) => x.kind === "InterfaceTypeDefinition" - ) as InterfaceTypeDefinitionNode; - - expect(() => checkNodeImplementsInterfaces(node, [inter])).toThrow( - "type Movie does not implement interface Node correctly" - ); - }); - - test("should throw incorrect with auth directive", () => { - const typeDefs = ` - interface Node @auth(rules: [{operations: [READ], allow: "*"}]) { - id: ID - } - - type Movie implements Node { - id: ID - title: String! - } - `; - - const document = parse(typeDefs); - - const node = document.definitions.find((x) => x.kind === "ObjectTypeDefinition") as ObjectTypeDefinitionNode; - const inter = document.definitions.find( - (x) => x.kind === "InterfaceTypeDefinition" - ) as InterfaceTypeDefinitionNode; - - expect(() => checkNodeImplementsInterfaces(node, [inter])).toThrow( - "type Movie does not implement interface Node correctly" - ); - }); - - test("should throw incorrect with relationship directive", () => { - const typeDefs = ` - interface Node { - relation: [Movie] @relationship(type: "SOME_TYPE", direction: "OUT") - } - - type Movie implements Node { - title: String! - } - `; - - const document = parse(typeDefs); - - const node = document.definitions.find((x) => x.kind === "ObjectTypeDefinition") as ObjectTypeDefinitionNode; - const inter = document.definitions.find( - (x) => x.kind === "InterfaceTypeDefinition" - ) as InterfaceTypeDefinitionNode; - - expect(() => checkNodeImplementsInterfaces(node, [inter])).toThrow( - "type Movie does not implement interface Node correctly" - ); - }); - - test("should throw incorrect with cypher directive", () => { - const typeDefs = ` - interface Node { - cypher: [Movie] @cypher(statement: "MATCH (a) RETURN a") - } - - type Movie implements Node { - title: String! - } - `; - - const document = parse(typeDefs); - - const node = document.definitions.find((x) => x.kind === "ObjectTypeDefinition") as ObjectTypeDefinitionNode; - const inter = document.definitions.find( - (x) => x.kind === "InterfaceTypeDefinition" - ) as InterfaceTypeDefinitionNode; - - expect(() => checkNodeImplementsInterfaces(node, [inter])).toThrow( - "type Movie does not implement interface Node correctly" - ); - }); - - test("should pass on correct implementation", () => { - const typeDefs = ` - interface Node @auth(rules: [{operations: [READ], allow: "*"}]) { - id: ID - relation: [Movie] @relationship(type: "SOME_TYPE", direction: "OUT") - cypher: [Movie] @cypher(statement: "MATCH (a) RETURN a") - } - - type Movie implements Node @auth(rules: [{operations: [READ], allow: "*"}]) { - id: ID - title: String! - relation: [Movie] @relationship(type: "SOME_TYPE", direction: "OUT") - cypher: [Movie] @cypher(statement: "MATCH (a) RETURN a") - } - `; - - const document = parse(typeDefs); - - const node = document.definitions.find((x) => x.kind === "ObjectTypeDefinition") as ObjectTypeDefinitionNode; - const inter = document.definitions.find( - (x) => x.kind === "InterfaceTypeDefinition" - ) as InterfaceTypeDefinitionNode; - - const result = checkNodeImplementsInterfaces(node, [inter]); - - expect(result).toBeUndefined(); - }); -}); diff --git a/packages/graphql/src/schema/check-node-implements-interfaces.ts b/packages/graphql/src/schema/check-node-implements-interfaces.ts deleted file mode 100644 index c675c80000..0000000000 --- a/packages/graphql/src/schema/check-node-implements-interfaces.ts +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) "Neo4j" - * Neo4j Sweden AB [http://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { InterfaceTypeDefinitionNode, ObjectTypeDefinitionNode } from "graphql"; -import equal from "deep-equal"; - -function stripLoc(obj: any) { - return JSON.parse( - JSON.stringify(obj, (key: string, value) => { - if (key === "loc") { - return undefined; - } - - return value; - }) - ); -} - -function checkNodeImplementsInterfaces(node: ObjectTypeDefinitionNode, interfaces: InterfaceTypeDefinitionNode[]) { - if (!node.interfaces?.length) { - return; - } - - node.interfaces.forEach((inter) => { - const error = new Error(`type ${node.name.value} does not implement interface ${inter.name.value} correctly`); - - const interDefinition = interfaces.find((x) => x.name.value === inter.name.value); - if (!interDefinition) { - throw error; - } - - interDefinition.directives?.forEach((interDirec) => { - const nodeDirec = node.directives?.find((x) => x.name.value === interDirec.name.value); - if (!nodeDirec) { - throw error; - } - - const isEqual = equal(stripLoc(nodeDirec), stripLoc(interDirec)); - if (!isEqual) { - throw error; - } - }); - - interDefinition.fields?.forEach((interField) => { - const nodeField = node.fields?.find((x) => x.name.value === interField.name.value); - if (!nodeField) { - throw error; - } - - const isEqual = equal(stripLoc(nodeField), stripLoc(interField)); - if (!isEqual) { - throw error; - } - }); - }); -} - -export default checkNodeImplementsInterfaces; diff --git a/packages/graphql/src/schema/get-auth.ts b/packages/graphql/src/schema/get-auth.ts index f29171dd41..2bb8f0e2d7 100644 --- a/packages/graphql/src/schema/get-auth.ts +++ b/packages/graphql/src/schema/get-auth.ts @@ -21,7 +21,17 @@ import { DirectiveNode, valueFromASTUntyped } from "graphql"; import { Auth, AuthRule, AuthOperations } from "../types"; const validOperations: AuthOperations[] = ["CREATE", "READ", "UPDATE", "DELETE", "CONNECT", "DISCONNECT"]; -const validFields = ["operations", "AND", "OR", "allow", "where", "bind", "isAuthenticated", "allowUnauthenticated", "roles"]; +const validFields = [ + "operations", + "AND", + "OR", + "allow", + "where", + "bind", + "isAuthenticated", + "allowUnauthenticated", + "roles", +]; function getAuth(directive: DirectiveNode): Auth { const auth: Auth = { rules: [], type: "JWT" }; diff --git a/packages/graphql/src/schema/get-obj-field-meta.ts b/packages/graphql/src/schema/get-obj-field-meta.ts index 34b6f45715..30d1d357d6 100644 --- a/packages/graphql/src/schema/get-obj-field-meta.ts +++ b/packages/graphql/src/schema/get-obj-field-meta.ts @@ -30,6 +30,7 @@ import { StringValueNode, UnionTypeDefinitionNode, } from "graphql"; +import { upperFirst } from "graphql-compose"; import getFieldTypeMeta from "./get-field-type-meta"; import getCypherMeta from "./get-cypher-meta"; import getAuth from "./get-auth"; @@ -47,11 +48,13 @@ import { DateTimeField, PointField, TimeStampOperations, + ConnectionField, } from "../types"; import parseValueNode from "./parse-value-node"; -interface ObjectFields { +export interface ObjectFields { relationFields: RelationField[]; + connectionFields: ConnectionField[]; primitiveFields: PrimitiveField[]; cypherFields: CypherField[]; scalarFields: CustomScalarField[]; @@ -162,6 +165,41 @@ function getObjFieldMeta({ } res.relationFields.push(relationField); + + if (obj.kind !== "InterfaceTypeDefinition") { + const connectionTypeName = `${obj.name.value}${upperFirst(`${baseField.fieldName}Connection`)}`; + const relationshipTypeName = `${obj.name.value}${upperFirst(`${baseField.fieldName}Relationship`)}`; + + const connectionField: ConnectionField = { + fieldName: `${baseField.fieldName}Connection`, + relationshipTypeName, + typeMeta: { + name: connectionTypeName, + required: true, + pretty: `${connectionTypeName}!`, + input: { + where: { + type: `${connectionTypeName}Where`, + pretty: `${connectionTypeName}Where`, + }, + create: { + type: "", + pretty: "", + }, + update: { + type: "", + pretty: "", + }, + }, + }, + otherDirectives: [], + arguments: [...(field.arguments || [])], + description: field.description?.value, + relationship: relationField, + }; + + res.connectionFields.push(connectionField); + } } else if (cypherMeta) { if (defaultDirective) { throw new Error("@default directive can only be used on primitive type fields"); @@ -194,6 +232,7 @@ function getObjFieldMeta({ } const enumField: CustomEnumField = { + kind: "Enum", ...baseField, }; res.enumFields.push(enumField); @@ -390,6 +429,7 @@ function getObjFieldMeta({ }, { relationFields: [], + connectionFields: [], primitiveFields: [], cypherFields: [], scalarFields: [], diff --git a/packages/graphql/src/schema/get-relationship-meta.ts b/packages/graphql/src/schema/get-relationship-meta.ts index a5df7dbce2..ea6b7b8e89 100644 --- a/packages/graphql/src/schema/get-relationship-meta.ts +++ b/packages/graphql/src/schema/get-relationship-meta.ts @@ -17,11 +17,12 @@ * limitations under the License. */ -import { FieldDefinitionNode } from "graphql"; +import { FieldDefinitionNode, StringValueNode } from "graphql"; type RelationshipMeta = { direction: "IN" | "OUT"; type: string; + properties?: string; }; function getRelationshipMeta(field: FieldDefinitionNode): RelationshipMeta | undefined { @@ -49,12 +50,19 @@ function getRelationshipMeta(field: FieldDefinitionNode): RelationshipMeta | und throw new Error("@relationship type not a string"); } + const propertiesArg = directive.arguments?.find((x) => x.name.value === "properties"); + if (propertiesArg && propertiesArg.value.kind !== "StringValue") { + throw new Error("@relationship properties not a string"); + } + const direction = directionArg.value.value as "IN" | "OUT"; const type = typeArg.value.value; + const properties = (propertiesArg?.value as StringValueNode)?.value; return { direction, type, + properties, }; } diff --git a/packages/graphql/src/schema/get-where-fields.ts b/packages/graphql/src/schema/get-where-fields.ts new file mode 100644 index 0000000000..02f2dea733 --- /dev/null +++ b/packages/graphql/src/schema/get-where-fields.ts @@ -0,0 +1,104 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CustomEnumField, CustomScalarField, DateTimeField, PointField, PrimitiveField } from "../types"; + +interface Fields { + scalarFields: CustomScalarField[]; + enumFields: CustomEnumField[]; + primitiveFields: PrimitiveField[]; + dateTimeFields: DateTimeField[]; + pointFields: PointField[]; +} + +function getWhereFields({ + typeName, + fields, + enableRegex, +}: { + typeName: string; + fields: Fields; + enableRegex?: boolean; +}) { + return { + OR: `[${typeName}Where!]`, + AND: `[${typeName}Where!]`, + // Custom scalar fields only support basic equality + ...fields.scalarFields.reduce((res, f) => { + res[f.fieldName] = f.typeMeta.array ? `[${f.typeMeta.name}]` : f.typeMeta.name; + return res; + }, {}), + ...[...fields.primitiveFields, ...fields.dateTimeFields, ...fields.enumFields, ...fields.pointFields].reduce( + (res, f) => { + res[f.fieldName] = f.typeMeta.input.where.pretty; + res[`${f.fieldName}_NOT`] = f.typeMeta.input.where.pretty; + + if (f.typeMeta.name === "Boolean") { + return res; + } + + if (f.typeMeta.array) { + res[`${f.fieldName}_INCLUDES`] = f.typeMeta.input.where.type; + res[`${f.fieldName}_NOT_INCLUDES`] = f.typeMeta.input.where.type; + return res; + } + + res[`${f.fieldName}_IN`] = `[${f.typeMeta.input.where.pretty}]`; + res[`${f.fieldName}_NOT_IN`] = `[${f.typeMeta.input.where.pretty}]`; + + if (["Float", "Int", "BigInt", "DateTime", "Date"].includes(f.typeMeta.name)) { + ["_LT", "_LTE", "_GT", "_GTE"].forEach((comparator) => { + res[`${f.fieldName}${comparator}`] = f.typeMeta.name; + }); + return res; + } + + if (["Point", "CartesianPoint"].includes(f.typeMeta.name)) { + ["_DISTANCE", "_LT", "_LTE", "_GT", "_GTE"].forEach((comparator) => { + res[`${f.fieldName}${comparator}`] = `${f.typeMeta.name}Distance`; + }); + return res; + } + + if (["String", "ID"].includes(f.typeMeta.name)) { + if (enableRegex) { + res[`${f.fieldName}_MATCHES`] = "String"; + } + + [ + "_CONTAINS", + "_NOT_CONTAINS", + "_STARTS_WITH", + "_NOT_STARTS_WITH", + "_ENDS_WITH", + "_NOT_ENDS_WITH", + ].forEach((comparator) => { + res[`${f.fieldName}${comparator}`] = f.typeMeta.name; + }); + return res; + } + + return res; + }, + {} + ), + }; +} + +export default getWhereFields; diff --git a/packages/graphql/src/schema/make-augmented-schema.test.ts b/packages/graphql/src/schema/make-augmented-schema.test.ts index d8bfa05f83..cfd1bb856b 100644 --- a/packages/graphql/src/schema/make-augmented-schema.test.ts +++ b/packages/graphql/src/schema/make-augmented-schema.test.ts @@ -30,6 +30,7 @@ import { import { pluralize } from "graphql-compose"; import makeAugmentedSchema from "./make-augmented-schema"; import { Node } from "../classes"; +import * as constants from "../constants"; describe("makeAugmentedSchema", () => { test("should be a function", () => { @@ -104,24 +105,6 @@ describe("makeAugmentedSchema", () => { expect(() => makeAugmentedSchema({ typeDefs })).toThrow("cannot have interface on relationship"); }); - test("should throw type X does not implement interface X correctly", () => { - const typeDefs = ` - interface Node @auth(rules: [{operations: [READ]}]) { - id: ID - relation: [Movie] @relationship(type: "SOME_TYPE", direction: OUT) - cypher: [Movie] @cypher(statement: "MATCH (a) RETURN a") - } - - type Movie implements Node { - title: String! - } - `; - - expect(() => makeAugmentedSchema({ typeDefs })).toThrow( - "type Movie does not implement interface Node correctly" - ); - }); - test("should throw cannot auto-generate a non ID field", () => { const typeDefs = ` type Movie { @@ -152,33 +135,10 @@ describe("makeAugmentedSchema", () => { expect(() => makeAugmentedSchema({ typeDefs })).toThrow("cannot auto-generate an array"); }); - /* - Removal of validateTypeDefs function - */ - // test("should throw timestamp operations must be an array", () => { - // const typeDefs = ` - // type Movie { - // name: DateTime @timestamp(operations: "read") - // } - // `; - - // expect(() => makeAugmentedSchema({ typeDefs })).toThrow('Argument "operations" has invalid value "read".'); - // }); - - // test("should throw timestamp operations[0] invalid", () => { - // const typeDefs = ` - // type Movie { - // name: DateTime @timestamp(operations: ["read"]) - // } - // `; - - // expect(() => makeAugmentedSchema({ typeDefs })).toThrow('Argument "operations" has invalid value ["read"].'); - // }); - test("should throw cannot have auth directive on a relationship", () => { const typeDefs = ` - type Node { - node: Node @relationship(type: "NODE", direction: OUT) @auth(rules: [{operations: [CREATE]}]) + type Movie { + movie: Movie @relationship(type: "NODE", direction: OUT) @auth(rules: [{operations: [CREATE]}]) } `; @@ -188,7 +148,7 @@ describe("makeAugmentedSchema", () => { describe("REGEX", () => { test("should remove the MATCHES filter by default", () => { const typeDefs = ` - type Node { + type Movie { name: String } `; @@ -198,7 +158,7 @@ describe("makeAugmentedSchema", () => { const document = parse(printSchema(neoSchema.schema)); const nodeWhereInput = document.definitions.find( - (x) => x.kind === "InputObjectTypeDefinition" && x.name.value === "NodeWhere" + (x) => x.kind === "InputObjectTypeDefinition" && x.name.value === "MovieWhere" ) as InputObjectTypeDefinitionNode; const matchesField = nodeWhereInput.fields?.find((x) => x.name.value.endsWith("_MATCHES")); @@ -208,7 +168,7 @@ describe("makeAugmentedSchema", () => { test("should add the MATCHES filter when NEO4J_GRAPHQL_ENABLE_REGEX is set", () => { const typeDefs = ` - type Node { + type User { name: String } `; @@ -218,7 +178,7 @@ describe("makeAugmentedSchema", () => { const document = parse(printSchema(neoSchema.schema)); const nodeWhereInput = document.definitions.find( - (x) => x.kind === "InputObjectTypeDefinition" && x.name.value === "NodeWhere" + (x) => x.kind === "InputObjectTypeDefinition" && x.name.value === "UserWhere" ) as InputObjectTypeDefinitionNode; const matchesField = nodeWhereInput.fields?.find((x) => x.name.value.endsWith("_MATCHES")); @@ -232,12 +192,12 @@ describe("makeAugmentedSchema", () => { // https://github.com/neo4j/graphql/issues/158 const typeDefs = ` - type Node { + type Movie { createdAt: DateTime } type Query { - nodes: [Node] @cypher(statement: "") + movies: [Movie] @cypher(statement: "") } `; @@ -249,4 +209,250 @@ describe("makeAugmentedSchema", () => { expect(document.kind).toEqual("Document"); }); }); + + test("should throw error if @auth is used on relationship properties interface", () => { + const typeDefs = ` + type Movie { + actors: Actor @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Actor { + name: String + } + + interface ActedIn @auth(rules: [{ operations: [CREATE], roles: ["admin"] }]) { + screenTime: Int + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + "Cannot have @auth directive on relationship properties interface" + ); + }); + + test("should throw error if @cypher is used on relationship properties interface", () => { + const typeDefs = ` + type Movie { + actors: Actor @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Actor { + name: String + } + + interface ActedIn @cypher(statement: "RETURN rand()") { + screenTime: Int + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow('Directive "@cypher" may not be used on INTERFACE.'); + }); + + test("should throw error if @auth is used on relationship property", () => { + const typeDefs = ` + type Movie { + actors: Actor @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Actor { + name: String + } + + interface ActedIn { + screenTime: Int @auth(rules: [{ operations: [CREATE], roles: ["admin"] }]) + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow("Cannot have @auth directive on relationship property"); + }); + + test("should throw error if @relationship is used on relationship property", () => { + const typeDefs = ` + type Movie { + actors: Actor @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Actor { + name: String + } + + interface ActedIn { + actors: Actor @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + "Cannot have @relationship directive on relationship property" + ); + }); + + test("should throw error if @cypher is used on relationship property", () => { + const typeDefs = ` + type Movie { + actors: Actor @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Actor { + name: String + } + + interface ActedIn { + id: ID @cypher(statement: "RETURN id(this)") + roles: [String] + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + "Cannot have @cypher directive on relationship property" + ); + }); + + describe("Reserved Names", () => { + describe("Node", () => { + test("should throw when using PageInfo as node name", () => { + const typeDefs = ` + type PageInfo { + id: ID + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_TYPE_NAMES.find((x) => x[0] === "PageInfo") as string[])[1] + ); + }); + + test("should throw when using Connection in a node name", () => { + const typeDefs = ` + type NodeConnection { + id: ID + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_TYPE_NAMES.find((x) => x[0] === "Connection") as string[])[1] + ); + }); + + test("should throw when using Node as node name", () => { + const typeDefs = ` + type Node { + id: ID + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_TYPE_NAMES.find((x) => x[0] === "Node") as string[])[1] + ); + }); + }); + + describe("Interface", () => { + test("should throw when using PageInfo as relationship properties interface name", () => { + const typeDefs = ` + type Movie { + id: ID + actors: [Actor] @relationship(type: "ACTED_IN", direction: OUT, properties: "PageInfo") + } + + interface PageInfo { + screenTime: Int + } + + type Actor { + name: String + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_TYPE_NAMES.find((x) => x[0] === "PageInfo") as string[])[1] + ); + }); + + test("should throw when using Connection in a properties interface name", () => { + const typeDefs = ` + type Movie { + id: ID + actors: [Actor] @relationship(type: "ACTED_IN", direction: OUT, properties: "NodeConnection") + } + + interface NodeConnection { + screenTime: Int + } + + type Actor { + name: String + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_TYPE_NAMES.find((x) => x[0] === "Connection") as string[])[1] + ); + }); + + test("should throw when using Node as relationship properties interface name", () => { + const typeDefs = ` + type Movie { + id: ID + actors: [Actor] @relationship(type: "ACTED_IN", direction: OUT, properties: "Node") + } + + interface Node { + screenTime: Int + } + + type Actor { + name: String + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_TYPE_NAMES.find((x) => x[0] === "Node") as string[])[1] + ); + }); + + describe("Fields", () => { + test("should throw when using 'node' as a relationship property", () => { + const typeDefs = ` + type Movie { + id: ID + actors: [Actor] @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + interface ActedIn { + node: ID + } + + type Actor { + name: String + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_INTERFACE_FIELDS.find((x) => x[0] === "node") as string[])[1] + ); + }); + + test("should throw when using 'cursor' as a relationship property", () => { + const typeDefs = ` + type Movie { + id: ID + actors: [Actor] @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + interface ActedIn { + cursor: ID + } + + type Actor { + name: String + } + `; + + expect(() => makeAugmentedSchema({ typeDefs })).toThrow( + (constants.RESERVED_INTERFACE_FIELDS.find((x) => x[0] === "cursor") as string[])[1] + ); + }); + }); + }); + }); }); diff --git a/packages/graphql/src/schema/make-augmented-schema.ts b/packages/graphql/src/schema/make-augmented-schema.ts index 3d59b24be8..bc3bfa1ef8 100644 --- a/packages/graphql/src/schema/make-augmented-schema.ts +++ b/packages/graphql/src/schema/make-augmented-schema.ts @@ -25,11 +25,15 @@ import { DefinitionNode, DirectiveDefinitionNode, EnumTypeDefinitionNode, + GraphQLInt, + GraphQLNonNull, + GraphQLResolveInfo, GraphQLSchema, InputObjectTypeDefinitionNode, InterfaceTypeDefinitionNode, NamedTypeNode, ObjectTypeDefinitionNode, + parse, print, ScalarTypeDefinitionNode, UnionTypeDefinitionNode, @@ -44,36 +48,37 @@ import { import pluralize from "pluralize"; import { Node, Exclude } from "../classes"; import getAuth from "./get-auth"; -import { PrimitiveField, Auth } from "../types"; +import { PrimitiveField, Auth, ConnectionQueryArgs } from "../types"; import { - findResolver, + countResolver, createResolver, - deleteResolver, cypherResolver, - updateResolver, defaultFieldResolver, + deleteResolver, + findResolver, + updateResolver, } from "./resolvers"; -import checkNodeImplementsInterfaces from "./check-node-implements-interfaces"; import * as Scalars from "./scalars"; import parseExcludeDirective from "./parse-exclude-directive"; import getCustomResolvers from "./get-custom-resolvers"; -import getObjFieldMeta from "./get-obj-field-meta"; +import getObjFieldMeta, { ObjectFields } from "./get-obj-field-meta"; import * as point from "./point"; import { graphqlDirectivesToCompose, objectFieldsToComposeFields } from "./to-compose"; -// import validateTypeDefs from "./validation"; +import Relationship from "../classes/Relationship"; +import getWhereFields from "./get-where-fields"; +import { connectionFieldResolver } from "./pagination"; +import { validateDocument } from "./validation"; +import * as constants from "../constants"; function makeAugmentedSchema( { typeDefs, ...schemaDefinition }: IExecutableSchemaDefinition, - { enableRegex }: { enableRegex?: boolean } = {} -): { schema: GraphQLSchema; nodes: Node[] } { + { enableRegex, skipValidateTypeDefs }: { enableRegex?: boolean; skipValidateTypeDefs?: boolean } = {} +): { schema: GraphQLSchema; nodes: Node[]; relationships: Relationship[] } { const document = mergeTypeDefs(Array.isArray(typeDefs) ? (typeDefs as string[]) : [typeDefs as string]); - /* - Issue caused by a combination of GraphQL Compose removing types and - that we are not adding Points to the validation schema. This should be a - temporary fix and does not detriment usability of the library. - */ - // validateTypeDefs(document); + if (!skipValidateTypeDefs) { + validateDocument(document); + } const composer = new SchemaComposer(); @@ -84,18 +89,20 @@ function makeAugmentedSchema( let pointInTypeDefs = false; let cartesianPointInTypeDefs = false; + const relationships: Relationship[] = []; + composer.createObjectTC({ name: "DeleteInfo", fields: { - nodesDeleted: "Int!", - relationshipsDeleted: "Int!", + nodesDeleted: new GraphQLNonNull(GraphQLInt), + relationshipsDeleted: new GraphQLNonNull(GraphQLInt), }, }); const queryOptions = composer.createInputTC({ name: "QueryOptions", fields: { - skip: "Int", + offset: "Int", limit: "Int", }, }); @@ -114,6 +121,17 @@ function makeAugmentedSchema( }, }); + composer.createObjectTC({ + name: "PageInfo", + description: "Pagination information (Relay)", + fields: { + hasNextPage: "Boolean!", + hasPreviousPage: "Boolean!", + startCursor: "String", + endCursor: "String", + }, + }); + const customResolvers = getCustomResolvers(document); const scalars = document.definitions.filter((x) => x.kind === "ScalarTypeDefinition") as ScalarTypeDefinitionNode[]; @@ -128,7 +146,7 @@ function makeAugmentedSchema( (x) => x.kind === "InputObjectTypeDefinition" ) as InputObjectTypeDefinitionNode[]; - const interfaces = document.definitions.filter( + let interfaces = document.definitions.filter( (x) => x.kind === "InterfaceTypeDefinition" ) as InterfaceTypeDefinitionNode[]; @@ -138,8 +156,42 @@ function makeAugmentedSchema( const unions = document.definitions.filter((x) => x.kind === "UnionTypeDefinition") as UnionTypeDefinitionNode[]; + const relationshipPropertyInterfaceNames = new Set(); + + const extraDefinitions = [ + ...enums, + ...scalars, + ...directives, + ...inputs, + ...unions, + ...([ + customResolvers.customQuery, + customResolvers.customMutation, + customResolvers.customSubscription, + ] as ObjectTypeDefinitionNode[]), + ].filter(Boolean) as DefinitionNode[]; + if (extraDefinitions.length) { + composer.addTypeDefs(print({ kind: "Document", definitions: extraDefinitions })); + } + + Object.keys(Scalars).forEach((scalar) => composer.addTypeDefs(`scalar ${scalar}`)); + const nodes = objectNodes.map((definition) => { - checkNodeImplementsInterfaces(definition, interfaces); + constants.RESERVED_TYPE_NAMES.forEach(([label, message]) => { + let toThrowError = false; + + if (label === "Connection" && definition.name.value.endsWith("Connection")) { + toThrowError = true; + } + + if (definition.name.value === label) { + toThrowError = true; + } + + if (toThrowError) { + throw new Error(message); + } + }); const otherDirectives = (definition.directives || []).filter( (x) => !["auth", "exclude"].includes(x.name.value) @@ -167,6 +219,25 @@ function makeAugmentedSchema( objects: objectNodes, }); + nodeFields.relationFields.forEach((relationship) => { + if (relationship.properties) { + const propertiesInterface = interfaces.find((i) => i.name.value === relationship.properties); + if (!propertiesInterface) { + throw new Error( + `Cannot find interface specified in ${definition.name.value}.${relationship.fieldName}` + ); + } + relationshipPropertyInterfaceNames.add(relationship.properties); + } + }); + + if (!pointInTypeDefs) { + pointInTypeDefs = nodeFields.pointFields.some((field) => field.typeMeta.name === "Point"); + } + if (!cartesianPointInTypeDefs) { + cartesianPointInTypeDefs = nodeFields.pointFields.some((field) => field.typeMeta.name === "CartesianPoint"); + } + const node = new Node({ name: definition.name.value, interfaces: nodeInterfaces, @@ -182,6 +253,179 @@ function makeAugmentedSchema( return node; }); + const relationshipProperties = interfaces.filter((i) => relationshipPropertyInterfaceNames.has(i.name.value)); + interfaces = interfaces.filter((i) => !relationshipPropertyInterfaceNames.has(i.name.value)); + + const relationshipFields = new Map(); + + relationshipProperties.forEach((relationship) => { + constants.RESERVED_TYPE_NAMES.forEach(([label, message]) => { + let toThrowError = false; + + if (label === "Connection" && relationship.name.value.endsWith("Connection")) { + toThrowError = true; + } + + if (relationship.name.value === label) { + toThrowError = true; + } + + if (toThrowError) { + throw new Error(message); + } + }); + + const authDirective = (relationship.directives || []).find((x) => x.name.value === "auth"); + if (authDirective) { + throw new Error("Cannot have @auth directive on relationship properties interface"); + } + + relationship.fields?.forEach((field) => { + constants.RESERVED_INTERFACE_FIELDS.forEach(([fieldName, message]) => { + if (field.name.value === fieldName) { + throw new Error(message); + } + }); + + const forbiddenDirectives = ["auth", "relationship", "cypher"]; + forbiddenDirectives.forEach((directive) => { + const found = (field.directives || []).find((x) => x.name.value === directive); + if (found) { + throw new Error(`Cannot have @${directive} directive on relationship property`); + } + }); + }); + + const relFields = getObjFieldMeta({ + enums, + interfaces, + objects: objectNodes, + scalars, + unions, + obj: relationship, + }); + + if (!pointInTypeDefs) { + pointInTypeDefs = relFields.pointFields.some((field) => field.typeMeta.name === "Point"); + } + if (!cartesianPointInTypeDefs) { + cartesianPointInTypeDefs = relFields.pointFields.some((field) => field.typeMeta.name === "CartesianPoint"); + } + + relationshipFields.set(relationship.name.value, relFields); + + const objectComposeFields = objectFieldsToComposeFields( + Object.values(relFields).reduce((acc, x) => [...acc, ...x], []) + ); + + const propertiesInterface = composer.createInterfaceTC({ + name: relationship.name.value, + fields: objectComposeFields, + }); + + composer.createInputTC({ + name: `${relationship.name.value}Sort`, + fields: propertiesInterface.getFieldNames().reduce((res, f) => { + return { ...res, [f]: "SortDirection" }; + }, {}), + }); + + composer.createInputTC({ + name: `${relationship.name.value}UpdateInput`, + fields: [ + ...relFields.primitiveFields, + ...relFields.scalarFields, + ...relFields.enumFields, + ...relFields.dateTimeFields.filter((x) => !x.timestamps), + ...relFields.pointFields, + ].reduce( + (res, f) => + f.readonly || (f as PrimitiveField)?.autogenerate + ? res + : { + ...res, + [f.fieldName]: f.typeMeta.input.update.pretty, + }, + {} + ), + }); + + const relationshipWhereFields = getWhereFields({ + typeName: relationship.name.value, + fields: { + scalarFields: relFields.scalarFields, + enumFields: relFields.enumFields, + dateTimeFields: relFields.dateTimeFields, + pointFields: relFields.pointFields, + primitiveFields: relFields.primitiveFields, + }, + enableRegex: enableRegex || false, + }); + + composer.createInputTC({ + name: `${relationship.name.value}Where`, + fields: relationshipWhereFields, + }); + + composer.createInputTC({ + name: `${relationship.name.value}CreateInput`, + // TODO - This reduce duplicated when creating node CreateInput - put into shared function? + fields: [ + ...relFields.primitiveFields, + ...relFields.scalarFields, + ...relFields.enumFields, + ...relFields.dateTimeFields.filter((x) => !x.timestamps), + ...relFields.pointFields, + ].reduce((res, f) => { + if ((f as PrimitiveField)?.autogenerate) { + return res; + } + + if ((f as PrimitiveField)?.defaultValue !== undefined) { + const field: InputTypeComposerFieldConfigAsObjectDefinition = { + type: f.typeMeta.input.create.pretty, + defaultValue: (f as PrimitiveField)?.defaultValue, + }; + res[f.fieldName] = field; + } else { + res[f.fieldName] = f.typeMeta.input.create.pretty; + } + + return res; + }, {}), + }); + }); + + if (pointInTypeDefs) { + // Every field (apart from CRS) in Point needs a custom resolver + // to deconstruct the point objects we fetch from the database + composer.createObjectTC(point.point); + composer.createInputTC(point.pointInput); + composer.createInputTC(point.pointDistance); + } + + if (cartesianPointInTypeDefs) { + // Every field (apart from CRS) in CartesianPoint needs a custom resolver + // to deconstruct the point objects we fetch from the database + composer.createObjectTC(point.cartesianPoint); + composer.createInputTC(point.cartesianPointInput); + composer.createInputTC(point.cartesianPointDistance); + } + + unions.forEach((union) => { + if (union.types && union.types.length) { + const fields = union.types.reduce((f, type) => { + f = { ...f, [type.name.value]: `${type.name.value}Where` }; + return f; + }, {}); + + composer.createInputTC({ + name: `${union.name.value}Where`, + fields, + }); + } + }); + nodes.forEach((node) => { const nodeFields = objectFieldsToComposeFields([ ...node.primitiveFields, @@ -200,9 +444,7 @@ function makeAugmentedSchema( name: node.name, fields: nodeFields, description: node.description, - extensions: { - directives: graphqlDirectivesToCompose(node.otherDirectives), - }, + directives: graphqlDirectivesToCompose(node.otherDirectives), interfaces: node.interfaces.map((x) => x.name.value), }); @@ -242,86 +484,27 @@ function makeAugmentedSchema( type: sortInput.List, }, limit: "Int", - skip: "Int", + offset: "Int", }, }); } else { composer.createInputTC({ name: `${node.name}Options`, - fields: { limit: "Int", skip: "Int" }, + fields: { limit: "Int", offset: "Int" }, }); } - const queryFields = { - OR: `[${node.name}Where!]`, - AND: `[${node.name}Where!]`, - // Custom scalar fields only support basic equality - ...node.scalarFields.reduce((res, f) => { - res[f.fieldName] = f.typeMeta.array ? `[${f.typeMeta.name}]` : f.typeMeta.name; - return res; - }, {}), - ...[...node.primitiveFields, ...node.dateTimeFields, ...node.enumFields, ...node.pointFields].reduce( - (res, f) => { - // This is the only sensible place to flag whether Point and CartesianPoint are used - if (f.typeMeta.name === "Point") { - pointInTypeDefs = true; - } else if (f.typeMeta.name === "CartesianPoint") { - cartesianPointInTypeDefs = true; - } - - res[f.fieldName] = f.typeMeta.input.where.pretty; - res[`${f.fieldName}_NOT`] = f.typeMeta.input.where.pretty; - - if (f.typeMeta.name === "Boolean") { - return res; - } - - if (f.typeMeta.array) { - res[`${f.fieldName}_INCLUDES`] = f.typeMeta.input.where.type; - res[`${f.fieldName}_NOT_INCLUDES`] = f.typeMeta.input.where.type; - return res; - } - - res[`${f.fieldName}_IN`] = `[${f.typeMeta.input.where.pretty}]`; - res[`${f.fieldName}_NOT_IN`] = `[${f.typeMeta.input.where.pretty}]`; - - if (["Float", "Int", "BigInt", "DateTime", "Date"].includes(f.typeMeta.name)) { - ["_LT", "_LTE", "_GT", "_GTE"].forEach((comparator) => { - res[`${f.fieldName}${comparator}`] = f.typeMeta.name; - }); - return res; - } - - if (["Point", "CartesianPoint"].includes(f.typeMeta.name)) { - ["_DISTANCE", "_LT", "_LTE", "_GT", "_GTE"].forEach((comparator) => { - res[`${f.fieldName}${comparator}`] = `${f.typeMeta.name}Distance`; - }); - return res; - } - - if (["String", "ID"].includes(f.typeMeta.name)) { - if (enableRegex) { - res[`${f.fieldName}_MATCHES`] = "String"; - } - - [ - "_CONTAINS", - "_NOT_CONTAINS", - "_STARTS_WITH", - "_NOT_STARTS_WITH", - "_ENDS_WITH", - "_NOT_ENDS_WITH", - ].forEach((comparator) => { - res[`${f.fieldName}${comparator}`] = f.typeMeta.name; - }); - return res; - } - - return res; - }, - {} - ), - }; + const queryFields = getWhereFields({ + typeName: node.name, + enableRegex, + fields: { + dateTimeFields: node.dateTimeFields, + enumFields: node.enumFields, + pointFields: node.pointFields, + primitiveFields: node.primitiveFields, + scalarFields: node.scalarFields, + }, + }); const whereInput = composer.createInputTC({ name: `${node.name}Where`, @@ -330,6 +513,7 @@ function makeAugmentedSchema( const nodeInput = composer.createInputTC({ name: `${node.name}CreateInput`, + // TODO - This reduce duplicated when creating relationship CreateInput - put into shared function? fields: [ ...node.primitiveFields, ...node.scalarFields, @@ -405,32 +589,6 @@ function makeAugmentedSchema( ); } - composer.createInputTC({ - name: `${node.name}ConnectFieldInput`, - fields: { - where: `${node.name}Where`, - ...(node.relationFields.length ? { connect: nodeConnectInput } : {}), - }, - }); - - composer.createInputTC({ - name: `${node.name}DisconnectFieldInput`, - fields: { - where: `${node.name}Where`, - ...(node.relationFields.length ? { disconnect: nodeDisconnectInput } : {}), - }, - }); - - if (!composer.has(`${node.name}DeleteFieldInput`)) { - composer.createInputTC({ - name: `${node.name}DeleteFieldInput`, - fields: { - where: `${node.name}Where`, - ...(node.relationFields.length ? { delete: nodeDeleteInput } : {}), - }, - }); - } - node.relationFields.forEach((rel) => { if (rel.union) { const refNodes = nodes.filter((x) => rel.union?.nodes?.includes(x.name)); @@ -440,122 +598,287 @@ function makeAugmentedSchema( type: rel.typeMeta.pretty, args: { options: queryOptions.getTypeName(), + where: `${rel.typeMeta.name}Where`, }, }, }); + const upperFieldName = upperFirst(rel.fieldName); + const upperNodeName = upperFirst(node.name); + const typePrefix = `${upperNodeName}${upperFieldName}`; + + const [ + unionConnectInput, + unionCreateInput, + unionDeleteInput, + unionDisconnectInput, + unionUpdateInput, + ] = ["Connect", "Create", "Delete", "Disconnect", "Update"].map((operation) => + composer.createInputTC({ + name: `${typePrefix}${operation}Input`, + fields: {}, + }) + ); + + const unionCreateFieldInput = composer.createInputTC({ + name: `${typePrefix}CreateFieldInput`, + fields: {}, + }); + refNodes.forEach((n) => { - const concatFieldName = `${rel.fieldName}_${n.name}`; - const createField = rel.typeMeta.array ? `[${n.name}CreateInput!]` : `${n.name}CreateInput`; + const unionPrefix = `${node.name}${upperFieldName}${n.name}`; const updateField = `${n.name}UpdateInput`; - const nodeFieldInputName = `${node.name}${upperFirst(rel.fieldName)}${n.name}FieldInput`; - const nodeFieldUpdateInputName = `${node.name}${upperFirst(rel.fieldName)}${ - n.name - }UpdateFieldInput`; - const nodeFieldDeleteInputName = `${node.name}${upperFirst(rel.fieldName)}${ - n.name - }DeleteFieldInput`; - - const connectField = rel.typeMeta.array - ? `[${n.name}ConnectFieldInput!]` - : `${n.name}ConnectFieldInput`; - const disconnectField = rel.typeMeta.array - ? `[${n.name}DisconnectFieldInput!]` - : `${n.name}DisconnectFieldInput`; - const deleteField = rel.typeMeta.array - ? `[${n.name}DeleteFieldInput!]` - : `${n.name}DeleteFieldInput`; - - composeNode.addFieldArgs(rel.fieldName, { - [n.name]: `${n.name}Where`, - }); + const nodeFieldInputName = `${unionPrefix}FieldInput`; + const whereName = `${unionPrefix}ConnectionWhere`; + + const deleteName = `${unionPrefix}DeleteFieldInput`; + const _delete = rel.typeMeta.array ? `[${deleteName}!]` : `${deleteName}`; + + const disconnectName = `${unionPrefix}DisconnectFieldInput`; + const disconnect = rel.typeMeta.array ? `[${disconnectName}!]` : `${disconnectName}`; + + const connectionUpdateInputName = `${unionPrefix}UpdateConnectionInput`; + + const createName = `${node.name}${upperFirst(rel.fieldName)}${n.name}CreateFieldInput`; + const create = rel.typeMeta.array ? `[${createName}!]` : createName; + if (!composer.has(createName)) { + composer.createInputTC({ + name: createName, + fields: { + node: `${n.name}CreateInput!`, + ...(rel.properties ? { edge: `${rel.properties}CreateInput!` } : {}), + }, + }); + + unionCreateInput.addFields({ + [n.name]: nodeFieldInputName, + }); + + unionCreateFieldInput.addFields({ + [n.name]: `[${createName}!]`, + }); + } + + const connectWhereName = `${n.name}ConnectWhere`; + if (!composer.has(connectWhereName)) { + composer.createInputTC({ + name: connectWhereName, + fields: { + node: `${n.name}Where!`, + }, + }); + } + + const connectName = `${unionPrefix}ConnectFieldInput`; + const connect = rel.typeMeta.array ? `[${connectName}!]` : `${connectName}`; + if (!composer.has(connectName)) { + composer.createInputTC({ + name: connectName, + fields: { + where: connectWhereName, + ...(n.relationFields.length + ? { + connect: rel.typeMeta.array + ? `[${n.name}ConnectInput!]` + : `${n.name}ConnectInput`, + } + : {}), + ...(rel.properties ? { edge: `${rel.properties}CreateInput!` } : {}), + }, + }); + + unionConnectInput.addFields({ + [n.name]: connect, + }); + } + + const updateName = `${unionPrefix}UpdateFieldInput`; + const update = rel.typeMeta.array ? `[${updateName}!]` : updateName; + if (!composer.has(updateName)) { + composer.createInputTC({ + name: updateName, + fields: { + where: whereName, + update: connectionUpdateInputName, + connect, + disconnect: rel.typeMeta.array ? `[${disconnectName}!]` : disconnectName, + create, + delete: rel.typeMeta.array ? `[${deleteName}!]` : deleteName, + }, + }); + + unionUpdateInput.addFields({ + [n.name]: update, + }); + } composer.createInputTC({ - name: nodeFieldUpdateInputName, + name: connectionUpdateInputName, fields: { - where: `${n.name}Where`, - update: updateField, - connect: connectField, - disconnect: disconnectField, - create: createField, - delete: deleteField, + ...(rel.properties ? { edge: `${rel.properties}UpdateInput` } : {}), + node: updateField, }, }); composer.createInputTC({ name: nodeFieldInputName, fields: { - create: createField, - connect: connectField, + create, + connect, }, }); composer.createInputTC({ - name: nodeFieldDeleteInputName, + name: whereName, fields: { - where: `${n.name}Where`, - ...(n.relationFields.length + node: `${n.name}Where`, + node_NOT: `${n.name}Where`, + AND: `[${whereName}!]`, + OR: `[${whereName}!]`, + ...(rel.properties ? { - delete: `${n.name}DeleteInput`, + edge: `${rel.properties}Where`, + edge_NOT: `${rel.properties}Where`, } : {}), }, }); - nodeRelationInput.addFields({ - [concatFieldName]: createField, - }); + if (!composer.has(deleteName)) { + composer.createInputTC({ + name: deleteName, + fields: { + where: whereName, + ...(n.relationFields.length + ? { + delete: `${n.name}DeleteInput`, + } + : {}), + }, + }); - nodeInput.addFields({ - [concatFieldName]: nodeFieldInputName, - }); + unionDeleteInput.addFields({ + [n.name]: _delete, + }); + } - nodeUpdateInput.addFields({ - [concatFieldName]: rel.typeMeta.array - ? `[${nodeFieldUpdateInputName}!]` - : nodeFieldUpdateInputName, - }); + if (!composer.has(disconnectName)) { + composer.createInputTC({ + name: disconnectName, + fields: { + where: whereName, + ...(n.relationFields.length + ? { + disconnect: `${n.name}DisconnectInput`, + } + : {}), + }, + }); - nodeDeleteInput.addFields({ - [concatFieldName]: rel.typeMeta.array - ? `[${nodeFieldDeleteInputName}!]` - : nodeFieldDeleteInputName, - }); + unionDisconnectInput.addFields({ + [n.name]: disconnect, + }); + } + }); - nodeConnectInput.addFields({ - [concatFieldName]: connectField, - }); + nodeInput.addFields({ + [rel.fieldName]: unionCreateInput, + }); - nodeDisconnectInput.addFields({ - [concatFieldName]: disconnectField, - }); + nodeRelationInput.addFields({ + [rel.fieldName]: unionCreateFieldInput, + }); + + nodeUpdateInput.addFields({ + [rel.fieldName]: unionUpdateInput, + }); + + nodeConnectInput.addFields({ + [rel.fieldName]: unionConnectInput, + }); + + nodeDeleteInput.addFields({ + [rel.fieldName]: unionDeleteInput, + }); + + nodeDisconnectInput.addFields({ + [rel.fieldName]: unionDisconnectInput, }); return; } const n = nodes.find((x) => x.name === rel.typeMeta.name) as Node; - const createField = rel.typeMeta.array ? `[${n.name}CreateInput!]` : `${n.name}CreateInput`; const updateField = `${n.name}UpdateInput`; + const nodeFieldInputName = `${node.name}${upperFirst(rel.fieldName)}FieldInput`; const nodeFieldUpdateInputName = `${node.name}${upperFirst(rel.fieldName)}UpdateFieldInput`; const nodeFieldDeleteInputName = `${node.name}${upperFirst(rel.fieldName)}DeleteFieldInput`; - const connectField = rel.typeMeta.array ? `[${n.name}ConnectFieldInput!]` : `${n.name}ConnectFieldInput`; - const disconnectField = rel.typeMeta.array - ? `[${n.name}DisconnectFieldInput!]` - : `${n.name}DisconnectFieldInput`; - const deleteField = rel.typeMeta.array ? `[${n.name}DeleteFieldInput!]` : `${n.name}DeleteFieldInput`; + const nodeFieldDisconnectInputName = `${node.name}${upperFirst(rel.fieldName)}DisconnectFieldInput`; + + const connectionUpdateInputName = `${node.name}${upperFirst(rel.fieldName)}UpdateConnectionInput`; whereInput.addFields({ ...{ [rel.fieldName]: `${n.name}Where`, [`${rel.fieldName}_NOT`]: `${n.name}Where` }, - ...(rel.typeMeta.array - ? {} - : { - [`${rel.fieldName}_IN`]: `[${n.name}Where!]`, - [`${rel.fieldName}_NOT_IN`]: `[${n.name}Where!]`, - }), }); + let anyNonNullRelProperties = false; + + if (rel.properties) { + const relFields = relationshipFields.get(rel.properties); + + if (relFields) { + anyNonNullRelProperties = [ + ...relFields.primitiveFields, + ...relFields.scalarFields, + ...relFields.enumFields, + ...relFields.dateTimeFields, + ...relFields.pointFields, + ].some((field) => field.typeMeta.required); + } + } + + const createName = `${node.name}${upperFirst(rel.fieldName)}CreateFieldInput`; + const create = rel.typeMeta.array ? `[${createName}!]` : createName; + if (!composer.has(createName)) { + composer.createInputTC({ + name: createName, + fields: { + node: `${n.name}CreateInput!`, + ...(rel.properties + ? { edge: `${rel.properties}CreateInput${anyNonNullRelProperties ? `!` : ""}` } + : {}), + }, + }); + } + + const connectWhereName = `${n.name}ConnectWhere`; + if (!composer.has(connectWhereName)) { + composer.createInputTC({ + name: connectWhereName, + fields: { + node: `${n.name}Where!`, + }, + }); + } + + const connectName = `${node.name}${upperFirst(rel.fieldName)}ConnectFieldInput`; + const connect = rel.typeMeta.array ? `[${connectName}!]` : connectName; + if (!composer.has(connectName)) { + composer.createInputTC({ + name: connectName, + fields: { + where: connectWhereName, + ...(n.relationFields.length + ? { connect: rel.typeMeta.array ? `[${n.name}ConnectInput!]` : `${n.name}ConnectInput` } + : {}), + ...(rel.properties + ? { edge: `${rel.properties}CreateInput${anyNonNullRelProperties ? `!` : ""}` } + : {}), + }, + }); + } + composeNode.addFields({ [rel.fieldName]: { type: rel.typeMeta.pretty, @@ -566,23 +889,33 @@ function makeAugmentedSchema( }, }); + composer.createInputTC({ + name: connectionUpdateInputName, + fields: { + node: updateField, + ...(rel.properties ? { edge: `${rel.properties}UpdateInput` } : {}), + }, + }); + composer.createInputTC({ name: nodeFieldUpdateInputName, fields: { - where: `${n.name}Where`, - update: updateField, - connect: connectField, - disconnect: disconnectField, - create: createField, - delete: deleteField, + where: `${node.name}${upperFirst(rel.fieldName)}ConnectionWhere`, + update: connectionUpdateInputName, + connect, + disconnect: rel.typeMeta.array + ? `[${nodeFieldDisconnectInputName}!]` + : nodeFieldDisconnectInputName, + create, + delete: rel.typeMeta.array ? `[${nodeFieldDeleteInputName}!]` : nodeFieldDeleteInputName, }, }); composer.createInputTC({ name: nodeFieldInputName, fields: { - create: createField, - connect: connectField, + create, + connect, }, }); @@ -590,18 +923,24 @@ function makeAugmentedSchema( composer.createInputTC({ name: nodeFieldDeleteInputName, fields: { - where: `${n.name}Where`, - ...(n.relationFields.length - ? { - delete: `${n.name}DeleteInput`, - } - : {}), + where: `${node.name}${upperFirst(rel.fieldName)}ConnectionWhere`, + ...(n.relationFields.length ? { delete: `${n.name}DeleteInput` } : {}), + }, + }); + } + + if (!composer.has(nodeFieldDisconnectInputName)) { + composer.createInputTC({ + name: nodeFieldDisconnectInputName, + fields: { + where: `${node.name}${upperFirst(rel.fieldName)}ConnectionWhere`, + ...(n.relationFields.length ? { disconnect: `${n.name}DisconnectInput` } : {}), }, }); } nodeRelationInput.addFields({ - [rel.fieldName]: createField, + [rel.fieldName]: create, }); nodeInput.addFields({ @@ -617,18 +956,202 @@ function makeAugmentedSchema( }); nodeConnectInput.addFields({ - [rel.fieldName]: connectField, + [rel.fieldName]: connect, }); nodeDisconnectInput.addFields({ - [rel.fieldName]: disconnectField, + [rel.fieldName]: rel.typeMeta.array + ? `[${nodeFieldDisconnectInputName}!]` + : nodeFieldDisconnectInputName, + }); + }); + + node.connectionFields.forEach((connectionField) => { + const relationship = composer.createObjectTC({ + name: connectionField.relationshipTypeName, + fields: { + cursor: "String!", + node: `${connectionField.relationship.typeMeta.name}!`, + }, + }); + + const connectionWhereName = `${connectionField.typeMeta.name}Where`; + + const connectionWhere = composer.createInputTC({ + name: connectionWhereName, + fields: {}, + }); + + if (!connectionField.relationship.union) { + connectionWhere.addFields({ + AND: `[${connectionWhereName}!]`, + OR: `[${connectionWhereName}!]`, + }); + } + + const connection = composer.createObjectTC({ + name: connectionField.typeMeta.name, + fields: { + edges: relationship.NonNull.List.NonNull, + totalCount: "Int!", + pageInfo: "PageInfo!", + }, + }); + + if (connectionField.relationship.properties && !connectionField.relationship.union) { + const propertiesInterface = composer.getIFTC(connectionField.relationship.properties); + relationship.addInterface(propertiesInterface); + relationship.addFields(propertiesInterface.getFields()); + + connectionWhere.addFields({ + edge: `${connectionField.relationship.properties}Where`, + edge_NOT: `${connectionField.relationship.properties}Where`, + }); + } + + whereInput.addFields({ + [connectionField.fieldName]: connectionWhere, + [`${connectionField.fieldName}_NOT`]: connectionWhere, + }); + + let composeNodeArgs: { + where: any; + sort?: any; + first?: any; + after?: any; + } = { + where: connectionWhere, + }; + + if (connectionField.relationship.union) { + const relatedNodes = nodes.filter((n) => connectionField.relationship.union?.nodes?.includes(n.name)); + + relatedNodes.forEach((n) => { + const unionWhereName = `${connectionField.typeMeta.name}${n.name}Where`; + const unionWhere = composer.createInputTC({ + name: unionWhereName, + fields: { + OR: `[${unionWhereName}]`, + AND: `[${unionWhereName}]`, + }, + }); + + unionWhere.addFields({ + node: `${n.name}Where`, + node_NOT: `${n.name}Where`, + }); + + if (connectionField.relationship.properties) { + const propertiesInterface = composer.getIFTC(connectionField.relationship.properties); + relationship.addInterface(propertiesInterface); + relationship.addFields(propertiesInterface.getFields()); + + unionWhere.addFields({ + edge: `${connectionField.relationship.properties}Where`, + edge_NOT: `${connectionField.relationship.properties}Where`, + }); + } + + connectionWhere.addFields({ + [n.name]: unionWhere, + }); + }); + } else { + const relatedNode = nodes.find((n) => n.name === connectionField.relationship.typeMeta.name) as Node; + + connectionWhere.addFields({ + node: `${connectionField.relationship.typeMeta.name}Where`, + node_NOT: `${connectionField.relationship.typeMeta.name}Where`, + }); + + const connectionSort = composer.createInputTC({ + name: `${connectionField.typeMeta.name}Sort`, + fields: {}, + }); + + const nodeSortFields = [ + ...relatedNode.primitiveFields, + ...relatedNode.enumFields, + ...relatedNode.scalarFields, + ...relatedNode.dateTimeFields, + ...relatedNode.pointFields, + ].filter((f) => !f.typeMeta.array); + + if (nodeSortFields.length) { + connectionSort.addFields({ + node: `${connectionField.relationship.typeMeta.name}Sort`, + }); + } + + if (connectionField.relationship.properties) { + connectionSort.addFields({ + edge: `${connectionField.relationship.properties}Sort`, + }); + } + + composeNodeArgs = { + ...composeNodeArgs, + first: { + type: "Int", + }, + after: { + type: "String", + }, + }; + + // If any sortable fields, add sort argument to connection field + if (nodeSortFields.length || connectionField.relationship.properties) { + composeNodeArgs = { + ...composeNodeArgs, + sort: connectionSort.NonNull.List, + }; + } + } + + composeNode.addFields({ + [connectionField.fieldName]: { + type: connection.NonNull, + args: composeNodeArgs, + resolve: (source, args: ConnectionQueryArgs, ctx, info: GraphQLResolveInfo) => { + return connectionFieldResolver({ + connectionField, + args, + info, + source, + }); + }, + }, + }); + + const relFields = connectionField.relationship.properties + ? relationshipFields.get(connectionField.relationship.properties) + : ({} as ObjectFields | undefined); + + const r = new Relationship({ + name: connectionField.relationshipTypeName, + type: connectionField.relationship.type, + properties: connectionField.relationship.properties, + ...(relFields + ? { + dateTimeFields: relFields.dateTimeFields, + scalarFields: relFields.scalarFields, + primitiveFields: relFields.primitiveFields, + pointFields: relFields.pointFields, + ignoredFields: relFields.ignoredFields, + } + : {}), }); + relationships.push(r); }); if (!node.exclude?.operations.includes("read")) { composer.Query.addFields({ [pluralize(camelCase(node.name))]: findResolver({ node }), }); + + composer.Query.addFields({ + [`${pluralize(camelCase(node.name))}Count`]: countResolver({ node }), + }); } if (!node.exclude?.operations.includes("create")) { @@ -691,22 +1214,6 @@ function makeAugmentedSchema( } }); - const extraDefinitions = [ - ...enums, - ...scalars, - ...directives, - ...inputs, - ...unions, - ...([ - customResolvers.customQuery, - customResolvers.customMutation, - customResolvers.customSubscription, - ] as ObjectTypeDefinitionNode[]), - ].filter(Boolean) as DefinitionNode[]; - if (extraDefinitions.length) { - composer.addTypeDefs(print({ kind: "Document", definitions: extraDefinitions })); - } - interfaces.forEach((inter) => { const objectFields = getObjFieldMeta({ obj: inter, scalars, enums, interfaces, unions, objects: objectNodes }); @@ -716,31 +1223,12 @@ function makeAugmentedSchema( composer.createInterfaceTC({ name: inter.name.value, + description: inter.description?.value, fields: objectComposeFields, - extensions: { - directives: graphqlDirectivesToCompose((inter.directives || []).filter((x) => x.name.value !== "auth")), - }, + directives: graphqlDirectivesToCompose((inter.directives || []).filter((x) => x.name.value !== "auth")), }); }); - Object.keys(Scalars).forEach((scalar) => composer.addTypeDefs(`scalar ${scalar}`)); - - if (pointInTypeDefs) { - // Every field (apart from CRS) in Point needs a custom resolver - // to deconstruct the point objects we fetch from the database - composer.createObjectTC(point.point); - composer.createInputTC(point.pointInput); - composer.createInputTC(point.pointDistance); - } - - if (cartesianPointInTypeDefs) { - // Every field (apart from CRS) in CartesianPoint needs a custom resolver - // to deconstruct the point objects we fetch from the database - composer.createObjectTC(point.cartesianPoint); - composer.createInputTC(point.cartesianPointInput); - composer.createInputTC(point.cartesianPointDistance); - } - if (!Object.values(composer.Mutation.getFields()).length) { composer.delete("Mutation"); } @@ -758,13 +1246,35 @@ function makeAugmentedSchema( unions.forEach((union) => { if (!generatedResolvers[union.name.value]) { + // eslint-disable-next-line no-underscore-dangle generatedResolvers[union.name.value] = { __resolveType: (root) => root.__resolveType }; } }); + const seen = {}; + let parsedDoc = parse(generatedTypeDefs); + parsedDoc = { + ...parsedDoc, + definitions: parsedDoc.definitions.filter((definition) => { + if (!("name" in definition)) { + return true; + } + + const n = definition.name?.value as string; + + if (seen[n]) { + return false; + } + + seen[n] = n; + + return true; + }), + }; + const schema = makeExecutableSchema({ ...schemaDefinition, - typeDefs: generatedTypeDefs, + typeDefs: parsedDoc, resolvers: generatedResolvers, }); @@ -778,6 +1288,7 @@ function makeAugmentedSchema( return { nodes, + relationships, schema, }; } diff --git a/packages/graphql/src/schema/pagination.test.ts b/packages/graphql/src/schema/pagination.test.ts new file mode 100644 index 0000000000..b70a5dc1c6 --- /dev/null +++ b/packages/graphql/src/schema/pagination.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { offsetToCursor } from "graphql-relay"; +import neo4j from "neo4j-driver"; +import { createOffsetLimitStr, createConnectionWithEdgeProperties } from "./pagination"; + +describe("cursor-pagination", () => { + describe("createOffsetLimitStr", () => { + test("it returns an empty string when no offset or limit is provided", () => { + const args = {}; + const result = createOffsetLimitStr(args); + + expect(result).toBe(""); + }); + + test("it returns a string with only the sliceStart when offset is provided but limit isn't", () => { + const args = { offset: 10 }; + const result = createOffsetLimitStr(args); + expect(result).toBe("[10..]"); + }); + + test("it returns a string with only the sliceStart when offset is provided and the limit is zero", () => { + const args = { offset: 10, limit: 0 }; + const result = createOffsetLimitStr(args); + expect(result).toBe("[10..]"); + }); + + test("it returns a string with only the sliceEnd when limit is provided but offset isn't", () => { + const args = { limit: 10 }; + const result = createOffsetLimitStr(args); + expect(result).toBe("[..10]"); + }); + + test("it returns a string with only the sliceEnd when limit is provided and the offset provided is zero", () => { + const args = { limit: 10, offset: 0 }; + const result = createOffsetLimitStr(args); + expect(result).toBe("[..10]"); + }); + + test("it returns a string with the full range of the slice when both offset and limit are provided", () => { + const args = { limit: 10, offset: 30 }; + const result = createOffsetLimitStr(args); + expect(result).toBe("[30..40]"); + }); + + test("it returns a string with the full range of the slice when Integers are passed for both ski and limit", () => { + const args = { limit: neo4j.int(10), offset: neo4j.int(30) }; + const result = createOffsetLimitStr(args); + expect(result).toBe("[30..40]"); + + const mixedArgs = { limit: neo4j.int(30), offset: 20 }; + const result2 = createOffsetLimitStr(mixedArgs); + expect(result2).toBe("[20..50]"); + }); + }); + + describe("createConnectionWithEdgeProperties", () => { + test("it should throw an error if first is less than 0", () => { + const arraySlice = [...Array(20).keys()].map((key) => ({ node: { id: key } })); + const args = { first: -1 }; + const totalCount = 50; + + expect(() => { + createConnectionWithEdgeProperties({ + source: { edges: arraySlice }, + args, + totalCount, + selectionSet: undefined, + }); + }).toThrow('Argument "first" must be a non-negative integer'); + }); + + test("it returns all elements if the cursors are invalid", () => { + const arraySlice = [...Array(20).keys()].map((key) => ({ node: { id: key } })); + const args = { after: "invalid" }; + const totalCount = 50; + const result = createConnectionWithEdgeProperties({ + source: { edges: arraySlice }, + args, + totalCount, + selectionSet: undefined, + }); + expect(result).toStrictEqual({ + edges: arraySlice.map((edge, index) => ({ ...edge, cursor: offsetToCursor(index) })), + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: offsetToCursor(0), + endCursor: offsetToCursor(19), + }, + }); + }); + + test("it should return cursors from 0 to the size of the array when no arguments provided", () => { + const arraySlice = [...Array(20).keys()].map((key) => ({ node: { id: key } })); + const args = {}; + const totalCount = 50; + const result = createConnectionWithEdgeProperties({ + source: { edges: arraySlice }, + args, + totalCount, + selectionSet: undefined, + }); + expect(result).toStrictEqual({ + edges: arraySlice.map((edge, index) => ({ ...edge, cursor: offsetToCursor(index) })), + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: offsetToCursor(0), + endCursor: offsetToCursor(19), + }, + }); + }); + test("it should return cursors from 11 to 31 when the after argument is provided and the array size is 20", () => { + const arraySlice = [...Array(20).keys()].map((key) => ({ node: { id: key } })); + const args = { after: offsetToCursor(10) }; + const totalCount = 50; + const result = createConnectionWithEdgeProperties({ + source: { edges: arraySlice }, + args, + totalCount, + selectionSet: undefined, + }); + expect(result).toStrictEqual({ + edges: arraySlice.map((edge, index) => ({ ...edge, cursor: offsetToCursor(index + 11) })), + pageInfo: { + hasNextPage: false, + hasPreviousPage: true, + startCursor: offsetToCursor(11), + endCursor: offsetToCursor(30), + }, + }); + }); + }); +}); diff --git a/packages/graphql/src/schema/pagination.ts b/packages/graphql/src/schema/pagination.ts new file mode 100644 index 0000000000..dcdc530126 --- /dev/null +++ b/packages/graphql/src/schema/pagination.ts @@ -0,0 +1,159 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FieldNode, GraphQLResolveInfo, SelectionSetNode } from "graphql"; +import { getOffsetWithDefault, offsetToCursor } from "graphql-relay/connection/arrayConnection"; +import { Integer, isInt } from "neo4j-driver"; +import { ConnectionField, ConnectionQueryArgs } from "../types"; + +function getAliasKey({ selectionSet, key }: { selectionSet: SelectionSetNode | undefined; key: string }): string { + const selection = (selectionSet?.selections || []).find( + (x) => x.kind === "Field" && x.name.value === key + ) as FieldNode; + + if (selection?.alias) { + return selection.alias.value; + } + + return key; +} + +export function connectionFieldResolver({ + connectionField, + source, + args, + info, +}: { + connectionField: ConnectionField; + source: any; + args: ConnectionQueryArgs; + info: GraphQLResolveInfo; +}) { + const firstField = info.fieldNodes[0]; + const selectionSet = firstField.selectionSet; + + let value = source[connectionField.fieldName]; + if (firstField.alias) { + value = source[firstField.alias.value]; + } + + const totalCountKey = getAliasKey({ selectionSet, key: "totalCount" }); + const totalCount = value.totalCount; + + return { + [totalCountKey]: isInt(totalCount) ? totalCount.toNumber() : totalCount, + ...createConnectionWithEdgeProperties({ source: value, selectionSet, args, totalCount }), + }; +} + +/** + * Adapted from graphql-relay-js ConnectionFromArraySlice + */ +export function createConnectionWithEdgeProperties({ + selectionSet, + source, + args = {}, + totalCount, +}: { + selectionSet: SelectionSetNode | undefined; + source: any; + args: { after?: string; first?: number }; + totalCount: number; +}) { + const { after, first } = args; + + if ((first as number) < 0) { + throw new Error('Argument "first" must be a non-negative integer'); + } + + // after returns the last cursor in the previous set or -1 if invalid + const lastEdgeCursor = getOffsetWithDefault(after, -1); + + // increment the last cursor position by one for the sliceStart + const sliceStart = lastEdgeCursor + 1; + + const edges = source?.edges || []; + + const selections = selectionSet?.selections || []; + + const edgesField = selections.find((x) => x.kind === "Field" && x.name.value === "edges") as FieldNode; + const cursorKey = getAliasKey({ selectionSet: edgesField?.selectionSet, key: "cursor" }); + const nodeKey = getAliasKey({ selectionSet: edgesField?.selectionSet, key: "node" }); + + const sliceEnd = sliceStart + ((first as number) || edges.length); + + const mappedEdges = edges.map((value, index) => { + return { + ...value, + ...(value.node ? { [nodeKey]: value.node } : {}), + [cursorKey]: offsetToCursor(sliceStart + index), + }; + }); + + const startCursor = mappedEdges[0]?.cursor; + const endCursor = mappedEdges[mappedEdges.length - 1]?.cursor; + + const pageInfoKey = getAliasKey({ selectionSet, key: "pageInfo" }); + const edgesKey = getAliasKey({ selectionSet, key: "edges" }); + const pageInfoField = selections.find((x) => x.kind === "Field" && x.name.value === "pageInfo") as FieldNode; + const pageInfoSelectionSet = pageInfoField?.selectionSet; + const startCursorKey = getAliasKey({ selectionSet: pageInfoSelectionSet, key: "startCursor" }); + const endCursorKey = getAliasKey({ selectionSet: pageInfoSelectionSet, key: "endCursor" }); + const hasPreviousPageKey = getAliasKey({ selectionSet: pageInfoSelectionSet, key: "hasPreviousPage" }); + const hasNextPageKey = getAliasKey({ selectionSet: pageInfoSelectionSet, key: "hasNextPage" }); + + return { + [edgesKey]: mappedEdges, + [pageInfoKey]: { + [startCursorKey]: startCursor, + [endCursorKey]: endCursor, + [hasPreviousPageKey]: lastEdgeCursor > 0, + [hasNextPageKey]: typeof first === "number" ? sliceEnd < totalCount : false, + }, + }; +} + +export function createOffsetLimitStr({ + offset, + limit, +}: { + offset?: number | Integer; + limit?: number | Integer; +}): string { + const hasOffset = typeof offset !== "undefined" && offset !== 0; + const hasLimit = typeof limit !== "undefined" && limit !== 0; + let offsetLimitStr = ""; + + if (hasOffset && !hasLimit) { + offsetLimitStr = `[${offset}..]`; + } + + if (hasLimit && !hasOffset) { + offsetLimitStr = `[..${limit}]`; + } + + if (hasLimit && hasOffset) { + const sliceStart = isInt(offset as Integer) ? (offset as Integer).toNumber() : offset; + const itemsToGrab = isInt(limit as Integer) ? (limit as Integer).toNumber() : limit; + const sliceEnd = (sliceStart as number) + (itemsToGrab as number); + offsetLimitStr = `[${offset}..${sliceEnd}]`; + } + + return offsetLimitStr; +} diff --git a/packages/graphql/src/schema/point.ts b/packages/graphql/src/schema/point.ts index a1854afd56..14108aa80f 100644 --- a/packages/graphql/src/schema/point.ts +++ b/packages/graphql/src/schema/point.ts @@ -17,103 +17,132 @@ * limitations under the License. */ -import { InputTypeComposerAsObjectDefinition, ObjectTypeComposerAsObjectDefinition } from "graphql-compose"; +import { + GraphQLFloat, + GraphQLInputObjectType, + GraphQLInt, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, +} from "graphql"; -export const point: ObjectTypeComposerAsObjectDefinition = { +export const point = new GraphQLObjectType({ name: "Point", fields: { longitude: { - type: "Float!", + type: new GraphQLNonNull(GraphQLFloat), resolve: (source) => { return source.point.x; }, }, latitude: { - type: "Float!", + type: new GraphQLNonNull(GraphQLFloat), resolve: (source) => { return source.point.y; }, }, height: { - type: "Float", + type: GraphQLFloat, resolve: (source) => { return source.point.z; }, }, - crs: "String!", + crs: { + type: new GraphQLNonNull(GraphQLString), + }, srid: { - type: "Int!", + type: new GraphQLNonNull(GraphQLInt), resolve: (source) => { return source.point.srid; }, }, }, -}; +}); -export const pointInput: InputTypeComposerAsObjectDefinition = { +export const pointInput = new GraphQLInputObjectType({ name: "PointInput", fields: { - longitude: "Float!", - latitude: "Float!", - height: "Float", + longitude: { + type: new GraphQLNonNull(GraphQLFloat), + }, + latitude: { + type: new GraphQLNonNull(GraphQLFloat), + }, + height: { + type: GraphQLFloat, + }, }, -}; +}); -export const pointDistance: InputTypeComposerAsObjectDefinition = { +export const pointDistance = new GraphQLInputObjectType({ name: "PointDistance", fields: { - point: "PointInput!", + point: { + type: new GraphQLNonNull(pointInput), + }, distance: { - type: "Float!", + type: new GraphQLNonNull(GraphQLFloat), description: "The distance in metres to be used when comparing two points", }, }, -}; +}); -export const cartesianPoint: ObjectTypeComposerAsObjectDefinition = { +export const cartesianPoint = new GraphQLObjectType({ name: "CartesianPoint", fields: { x: { - type: "Float!", + type: new GraphQLNonNull(GraphQLFloat), resolve: (source) => { return source.point.x; }, }, y: { - type: "Float!", + type: new GraphQLNonNull(GraphQLFloat), resolve: (source) => { return source.point.y; }, }, z: { - type: "Float", + type: GraphQLFloat, resolve: (source) => { return source.point.z; }, }, - crs: "String!", + crs: { + type: new GraphQLNonNull(GraphQLString), + }, srid: { - type: "Int!", + type: new GraphQLNonNull(GraphQLInt), resolve: (source) => { return source.point.srid; }, }, }, -}; +}); -export const cartesianPointInput: InputTypeComposerAsObjectDefinition = { +export const cartesianPointInput = new GraphQLInputObjectType({ name: "CartesianPointInput", fields: { - x: "Float!", - y: "Float!", - z: "Float", + x: { + type: new GraphQLNonNull(GraphQLFloat), + }, + y: { + type: new GraphQLNonNull(GraphQLFloat), + }, + z: { + type: GraphQLFloat, + }, }, -}; +}); -export const cartesianPointDistance: InputTypeComposerAsObjectDefinition = { +export const cartesianPointDistance = new GraphQLInputObjectType({ name: "CartesianPointDistance", fields: { - point: "CartesianPointInput!", - distance: "Float!", + point: { + type: new GraphQLNonNull(cartesianPointInput), + }, + distance: { + type: new GraphQLNonNull(GraphQLFloat), + }, }, -}; +}); diff --git a/packages/graphql/src/schema/scalars/ID.ts b/packages/graphql/src/schema/resolvers/count.test.ts similarity index 53% rename from packages/graphql/src/schema/scalars/ID.ts rename to packages/graphql/src/schema/resolvers/count.test.ts index 2f9411a3d0..3761cfe6ef 100644 --- a/packages/graphql/src/schema/scalars/ID.ts +++ b/packages/graphql/src/schema/resolvers/count.test.ts @@ -17,27 +17,21 @@ * limitations under the License. */ -import { GraphQLScalarType } from "graphql"; -import { Integer } from "neo4j-driver"; +import { Node } from "../../classes"; +import countResolver from "./count"; -/* - https://spec.graphql.org/June2018/#sec-ID - The ID type is serialized in the same way as a String -*/ -export default new GraphQLScalarType({ - name: "ID", - parseValue(value: string | number) { - if (typeof value === "string") { - return value; - } - - return value.toString(10); - }, - serialize(value: Integer | string | number) { - if (typeof value === "string") { - return value; - } +describe("Count resolver", () => { + test("should return the correct; type, args and resolve", () => { + // @ts-ignore + const node: Node = { + name: "Movie", + }; - return value.toString(10); - }, + const result = countResolver({ node }); + expect(result.type).toEqual("Int!"); + expect(result.resolve).toBeInstanceOf(Function); + expect(result.args).toMatchObject({ + where: "MovieWhere", + }); + }); }); diff --git a/packages/graphql/src/schema/resolvers/count.ts b/packages/graphql/src/schema/resolvers/count.ts new file mode 100644 index 0000000000..f199afc90f --- /dev/null +++ b/packages/graphql/src/schema/resolvers/count.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { execute } from "../../utils"; +import { translateCount } from "../../translate"; +import { Node } from "../../classes"; +import { Context } from "../../types"; + +export default function countResolver({ node }: { node: Node }) { + async function resolve(_root: any, _args: any, _context: unknown) { + const context = _context as Context; + const [cypher, params] = translateCount({ context, node }); + + const result = await execute({ + cypher, + params, + defaultAccessMode: "READ", + context, + raw: true, + }); + + return result.records[0]._fields[0].toNumber(); + } + + return { + type: `Int!`, + resolve, + args: { where: `${node.name}Where` }, + }; +} diff --git a/packages/graphql/src/schema/resolvers/cypher.ts b/packages/graphql/src/schema/resolvers/cypher.ts index b58a2d7302..1ccb8c3b18 100644 --- a/packages/graphql/src/schema/resolvers/cypher.ts +++ b/packages/graphql/src/schema/resolvers/cypher.ts @@ -19,12 +19,13 @@ import { isInt } from "neo4j-driver"; import { execute } from "../../utils"; -import { BaseField, Context } from "../../types"; +import { BaseField, ConnectionField, Context } from "../../types"; import { graphqlArgsToCompose } from "../to-compose"; import createAuthAndParams from "../../translate/create-auth-and-params"; import createAuthParam from "../../translate/create-auth-param"; import { AUTH_FORBIDDEN_ERROR } from "../../constants"; import createProjectionAndParams from "../../translate/create-projection-and-params"; +import createConnectionAndParams from "../../translate/connection/create-connection-and-params"; export default function cypherResolver({ field, @@ -37,16 +38,21 @@ export default function cypherResolver({ }) { async function resolve(_root: any, args: any, _context: unknown) { const context = _context as Context; - const { - resolveTree: { fieldsByTypeName }, - } = context; + const { resolveTree } = context; const cypherStrs: string[] = []; let params = { ...args, auth: createAuthParam({ context }), cypherParams: context.cypherParams }; let projectionStr = ""; + const connectionProjectionStrs: string[] = []; let projectionAuthStr = ""; const isPrimitive = ["ID", "String", "Boolean", "Float", "Int", "DateTime", "BigInt"].includes( field.typeMeta.name ); + const isEnum = context.neoSchema.document.definitions.find( + (x) => x.kind === "EnumTypeDefinition" && x.name.value === field.typeMeta.name + ); + const isScalar = context.neoSchema.document.definitions.find( + (x) => x.kind === "ScalarTypeDefinition" && x.name.value === field.typeMeta.name + ); const preAuth = createAuthAndParams({ entity: field, context }); if (preAuth[0]) { @@ -57,15 +63,35 @@ export default function cypherResolver({ const referenceNode = context.neoSchema.nodes.find((x) => x.name === field.typeMeta.name); if (referenceNode) { const recurse = createProjectionAndParams({ - fieldsByTypeName, + fieldsByTypeName: resolveTree.fieldsByTypeName, node: referenceNode, context, varName: `this`, }); - [projectionStr] = recurse; - params = { ...params, ...recurse[1] }; - if (recurse[2]?.authValidateStrs?.length) { - projectionAuthStr = recurse[2].authValidateStrs.join(" AND "); + const [str, p, meta] = recurse; + projectionStr = str; + params = { ...params, ...p }; + + if (meta?.authValidateStrs?.length) { + projectionAuthStr = meta.authValidateStrs.join(" AND "); + } + + if (meta?.connectionFields?.length) { + meta.connectionFields.forEach((connectionResolveTree) => { + const connectionField = referenceNode.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + + const nestedConnection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: "this", + }); + const [nestedStr, nestedP] = nestedConnection; + connectionProjectionStrs.push(nestedStr); + params = { ...params, ...nestedP }; + }); } } @@ -81,7 +107,7 @@ export default function cypherResolver({ ) as { strs: string[]; params: any }; const apocParamsStr = `{${apocParams.strs.length ? `${apocParams.strs.join(", ")}` : ""}}`; - const expectMultipleValues = field.typeMeta.array ? "true" : "false"; + const expectMultipleValues = !isPrimitive && !isScalar && !isEnum && field.typeMeta.array ? "true" : "false"; if (type === "Query") { cypherStrs.push(` WITH apoc.cypher.runFirstColumn("${statement}", ${apocParamsStr}, ${expectMultipleValues}) as x @@ -101,10 +127,12 @@ export default function cypherResolver({ ); } - if (!isPrimitive) { - cypherStrs.push(`RETURN this ${projectionStr} AS this`); - } else { + cypherStrs.push(connectionProjectionStrs.join("\n")); + + if (isPrimitive || isEnum || isScalar) { cypherStrs.push(`RETURN this`); + } else { + cypherStrs.push(`RETURN this ${projectionStr} AS this`); } const result = await execute({ diff --git a/packages/graphql/src/schema/resolvers/index.ts b/packages/graphql/src/schema/resolvers/index.ts index b6df70bdcd..36d09cce7e 100644 --- a/packages/graphql/src/schema/resolvers/index.ts +++ b/packages/graphql/src/schema/resolvers/index.ts @@ -17,9 +17,10 @@ * limitations under the License. */ +export { default as countResolver } from "./count"; export { default as createResolver } from "./create"; -export { default as findResolver } from "./read"; -export { default as updateResolver } from "./update"; -export { default as deleteResolver } from "./delete"; export { default as cypherResolver } from "./cypher"; export { default as defaultFieldResolver } from "./defaultField"; +export { default as deleteResolver } from "./delete"; +export { default as findResolver } from "./read"; +export { default as updateResolver } from "./update"; diff --git a/packages/graphql/src/schema/scalars/index.ts b/packages/graphql/src/schema/scalars/index.ts index ff69ac5a95..bcd2596101 100644 --- a/packages/graphql/src/schema/scalars/index.ts +++ b/packages/graphql/src/schema/scalars/index.ts @@ -20,4 +20,3 @@ export { default as BigInt } from "./BigInt"; export { default as DateTime } from "./DateTime"; export { default as Date } from "./Date"; -export { default as ID } from "./ID"; diff --git a/packages/graphql/src/schema/to-compose.ts b/packages/graphql/src/schema/to-compose.ts index a48a962a29..2c122dca4d 100644 --- a/packages/graphql/src/schema/to-compose.ts +++ b/packages/graphql/src/schema/to-compose.ts @@ -18,7 +18,7 @@ */ import { InputValueDefinitionNode, DirectiveNode } from "graphql"; -import { ExtensionsDirective, DirectiveArgs, ObjectTypeComposerFieldConfigAsObjectDefinition } from "graphql-compose"; +import { DirectiveArgs, ObjectTypeComposerFieldConfigAsObjectDefinition, Directive } from "graphql-compose"; import { isInt, Integer } from "neo4j-driver"; import getFieldTypeMeta from "./get-field-type-meta"; import parseValueNode from "./parse-value-node"; @@ -39,7 +39,7 @@ export function graphqlArgsToCompose(args: InputValueDefinitionNode[]) { }, {}); } -export function graphqlDirectivesToCompose(directives: DirectiveNode[]): ExtensionsDirective[] { +export function graphqlDirectivesToCompose(directives: DirectiveNode[]): Directive[] { return directives.map((directive) => ({ args: (directive.arguments || [])?.reduce( (r: DirectiveArgs, d) => ({ ...r, [d.name.value]: parseValueNode(d.value) }), @@ -64,19 +64,36 @@ export function objectFieldsToComposeFields( } as ObjectTypeComposerFieldConfigAsObjectDefinition; if (field.otherDirectives.length) { - newField.extensions = { - directives: graphqlDirectivesToCompose(field.otherDirectives), - }; + newField.directives = graphqlDirectivesToCompose(field.otherDirectives); } if (["Int", "Float"].includes(field.typeMeta.name)) { newField.resolve = (source) => { + const value = source[field.fieldName]; + + // @ts-ignore: outputValue is unknown, and to cast to object would be an antipattern + if (isInt(value)) { + return (value as Integer).toNumber(); + } + + return value; + }; + } + + if (field.typeMeta.name === "ID") { + newField.resolve = (source) => { + const value = source[field.fieldName]; + // @ts-ignore: outputValue is unknown, and to cast to object would be an antipattern - if (isInt(source[field.fieldName])) { - return (source[field.fieldName] as Integer).toNumber(); + if (isInt(value)) { + return (value as Integer).toNumber(); + } + + if (typeof value === "number") { + return value.toString(10); } - return source[field.fieldName]; + return value; }; } diff --git a/packages/graphql/src/schema/validation/directives.ts b/packages/graphql/src/schema/validation/directives.ts index dfba104006..56420bbe51 100644 --- a/packages/graphql/src/schema/validation/directives.ts +++ b/packages/graphql/src/schema/validation/directives.ts @@ -128,6 +128,10 @@ export const relationshipDirective = new GraphQLDirective({ direction: { type: new GraphQLNonNull(RelationshipDirectionEnum), }, + properties: { + type: GraphQLString, + description: "The name of the interface containing the properties for this relationship.", + }, }, }); diff --git a/packages/graphql/src/schema/validation/index.ts b/packages/graphql/src/schema/validation/index.ts index f1e1351839..e2da39dd73 100644 --- a/packages/graphql/src/schema/validation/index.ts +++ b/packages/graphql/src/schema/validation/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/prefer-default-export */ /* * Copyright (c) "Neo4j" * Neo4j Sweden AB [http://neo4j.com] @@ -17,71 +18,6 @@ * limitations under the License. */ -import { DefinitionNode, DocumentNode, print } from "graphql"; -import { makeExecutableSchema } from "@graphql-tools/schema"; -import { SchemaComposer, printDirective, printEnum, printScalar } from "graphql-compose"; -import * as scalars from "../scalars"; -import { ScalarType } from "./scalars"; -import * as enums from "./enums"; -import * as directives from "./directives"; +export { default as validateDocument } from "./validate-document"; -function filterDocument(document: DocumentNode) { - return { - ...document, - definitions: document.definitions.reduce((res: DefinitionNode[], def) => { - if (def.kind !== "ObjectTypeDefinition" && def.kind !== "InterfaceTypeDefinition") { - return [...res, def]; - } - - return [ - ...res, - { - ...def, - directives: def.directives?.filter((x) => !["auth"].includes(x.name.value)), - fields: def.fields?.map((f) => ({ - ...f, - directives: f.directives?.filter((x) => !["auth"].includes(x.name.value)), - })), - }, - ]; - }, []), - }; -} - -function validateSchema(document: DocumentNode): void { - const composer = new SchemaComposer(); - const doc = print(filterDocument(document)); - - composer.addTypeDefs(printScalar(ScalarType)); - Object.values(scalars).forEach((scalar) => { - composer.addTypeDefs(printScalar(scalar)); - }); - - Object.values(enums).forEach((e) => { - composer.addTypeDefs(printEnum(e)); - }); - - Object.values(directives).forEach((directive) => { - composer.addTypeDefs(printDirective(directive)); - }); - - composer.addTypeDefs(doc); - - // this is fake - composer.Query.addFields({ - fake: { - type: "Boolean", - resolve: () => false, - }, - }); - - // add directives to new document - // add fake query to document to make it a valid schema - - makeExecutableSchema({ - typeDefs: composer.toSDL(), - resolvers: composer.getResolveMethods(), - }); -} - -export default validateSchema; +/* eslint-enable import/prefer-default-export */ diff --git a/packages/graphql/src/schema/validation/validate-document.test.ts b/packages/graphql/src/schema/validation/validate-document.test.ts new file mode 100644 index 0000000000..419692e7e7 --- /dev/null +++ b/packages/graphql/src/schema/validation/validate-document.test.ts @@ -0,0 +1,439 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable jest/no-conditional-expect */ +/* eslint-disable jest/no-try-expect */ +/* ^ so we can use toContain on the errors */ + +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { parse } from "graphql"; +import validateDocument from "./validate-document"; + +describe("validateDocument", () => { + test("should throw an error if a directive is in the wrong location", () => { + const doc = parse(` + type User @coalesce { + name: String + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain('Directive "@coalesce" may not be used on OBJECT.'); + } + }); + + test("should throw an error if a directive is missing an argument", () => { + const doc = parse(` + type User { + name: String @coalesce + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain( + 'Directive "@coalesce" argument "value" of type "Scalar!" is required, but it was not provided.' + ); + } + }); + + test("should throw a missing scalar error", () => { + const doc = parse(` + type User { + name: Unknown + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain('Unknown type "Unknown".'); + } + }); + + test("should throw an error if a user tries to pass in their own Point definition", () => { + const doc = parse(` + type Point { + latitude: Float! + longitude: Float! + } + + type User { + location: Point + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain( + 'Type "Point" already exists in the schema. It cannot also be defined in this type definition.' + ); + } + }); + + test("should throw an error if a user tries to pass in their own DateTime definition", () => { + const doc = parse(` + scalar DateTime + + type User { + birthDateTime: DateTime + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain( + 'Type "DateTime" already exists in the schema. It cannot also be defined in this type definition.' + ); + } + }); + + test("should throw an error if a user tries to pass in their own PointInput definition", () => { + const doc = parse(` + input PointInput { + latitude: Float! + longitude: Float! + } + + type Query { + pointQuery(point: PointInput!): String + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain( + 'Type "PointInput" already exists in the schema. It cannot also be defined in this type definition.' + ); + } + }); + + test("should throw an error if an interface is incorrectly implemented", () => { + const doc = parse(` + interface UserInterface { + age: Int! + } + + type User implements UserInterface { + name: String! + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain("Interface field UserInterface.age expected but User does not provide it."); + } + }); + + test("should throw an error a user tries to redefine one of our directives", () => { + const doc = parse(` + directive @relationship on FIELD_DEFINITION + + type Movie { + title: String + } + `); + + try { + validateDocument(doc); + throw new Error(); + } catch (error) { + expect(error.message).toContain( + 'Directive "@relationship" already exists in the schema. It cannot be redefined.' + ); + } + }); + + test("should remove auth directive and pass validation", () => { + const doc = parse(` + type User @auth { + name: String @auth + } + `); + + const res = validateDocument(doc); + expect(res).toBeUndefined(); + }); + + test("should not throw error on use of internal input types", () => { + const doc = parse(` + type Mutation { + login: String + createPost(input: PostCreateInput!): Post! + @cypher( + statement: """ + CREATE (post:Post) + SET + post = $input, + post.datetime = datetime(), + post.id = randomUUID() + RETURN post + """ + ) + } + + type Post { + id: ID! @id + title: String! + datetime: DateTime @readonly @timestamp(operations: [CREATE]) + } + `); + + const res = validateDocument(doc); + expect(res).toBeUndefined(); + }); + + test("should not throw error on use of internal input types within input types", () => { + const doc = parse(` + type Salary { + salaryId: ID! + amount: Float + currency: String + frequency: String + eligibleForBonus: Boolean + bonusPercentage: Float + salaryReviewDate: DateTime + pays_salary: EmploymentRecord @relationship(type: "PAYS_SALARY", direction: IN) + } + + type EmploymentRecord { + employmentRecordId: ID! + pays_salary: [Salary] @relationship(type: "PAYS_SALARY", direction: OUT) + } + + input EmpRecord { + employmentRecordId: ID! + salary: SalaryCreateInput + startDate: Date + endDate: Date + } + + type Mutation { + mergeSalaries(salaries: [SalaryCreateInput!]): [Salary] + @cypher( + statement: """ + UNWIND $salaries as salary + MERGE (s:Salary {salaryId: salary.salaryId}) + ON CREATE SET s.amount = salary.amount, + s.currency = salary.currency, + s.frequency = salary.frequency, + s.eligibleForBonus = salary.eligibleForBonus, + s.bonusPercentage = salary.bonusPercentage, + s.salaryReviewDate = salary.salaryReviewDate + ON MATCH SET s.amount = salary.amount, + s.currency = salary.currency, + s.frequency = salary.frequency, + s.eligibleForBonus = salary.eligibleForBonus, + s.bonusPercentage = salary.bonusPercentage, + s.salaryReviewDate = salary.salaryReviewDate + RETURN s + """ + ) + + mergeEmploymentRecords(employmentRecords: [EmpRecord]): [EmploymentRecord] + @cypher( + statement: """ + UNWIND $employmentRecords as employmentRecord + MERGE (er:EmploymentRecord { + employmentRecordId: employmentRecord.employmentRecordId + }) + MERGE (s:Salary {salaryId: employmentRecord.salary.salaryId}) + ON CREATE SET s.amount = employmentRecord.salary.amount, + s.currency = employmentRecord.salary.currency, + s.frequency = employmentRecord.salary.frequency, + s.eligibleForBonus = employmentRecord.salary.eligibleForBonus, + s.bonusPercentage = employmentRecord.salary.bonusPercentage, + s.salaryReviewDate = employmentRecord.salary.salaryReviewDate + ON MATCH SET s.amount = employmentRecord.salary.amount, + s.currency = employmentRecord.salary.currency, + s.frequency = employmentRecord.salary.frequency, + s.eligibleForBonus = employmentRecord.salary.eligibleForBonus, + s.bonusPercentage = employmentRecord.salary.bonusPercentage, + s.salaryReviewDate = employmentRecord.salary.salaryReviewDate + + MERGE (er)-[:PAYS_SALARY]->(s) + RETURN er + """ + ) + } + `); + + const res = validateDocument(doc); + expect(res).toBeUndefined(); + }); + + describe("Github Issue 158", () => { + test("should not throw error on validation of schema", () => { + const doc = parse(` + type Node { + createdAt: DateTime + } + + type Query { + nodes: [Node] @cypher(statement: "") + } + `); + + const res = validateDocument(doc); + expect(res).toBeUndefined(); + }); + }); + + describe("Issue https://codesandbox.io/s/github/johnymontana/training-v3/tree/master/modules/graphql-apis/supplemental/code/03-graphql-apis-custom-logic/end?file=/schema.graphql:64-86", () => { + test("should not throw error on validation of schema", () => { + const doc = parse(` + type Order { + orderID: ID! @id + placedAt: DateTime @timestamp + shipTo: Address @relationship(type: "SHIPS_TO", direction: OUT) + customer: Customer @relationship(type: "PLACED", direction: IN) + books: [Book] @relationship(type: "CONTAINS", direction: OUT) + } + + extend type Order { + subTotal: Float @cypher(statement:"MATCH (this)-[:CONTAINS]->(b:Book) RETURN sum(b.price)") + shippingCost: Float @cypher(statement:"MATCH (this)-[:SHIPS_TO]->(a:Address) RETURN round(0.01 * distance(a.location, Point({latitude: 40.7128, longitude: -74.0060})) / 1000, 2)") + estimatedDelivery: DateTime @ignore + } + + type Customer { + username: String + orders: [Order] @relationship(type: "PLACED", direction: OUT) + reviews: [Review] @relationship(type: "WROTE", direction: OUT) + recommended(limit: Int = 3): [Book] @cypher(statement: "MATCH (this)-[:PLACED]->(:Order)-[:CONTAINS]->(:Book)<-[:CONTAINS]-(:Order)<-[:PLACED]-(c:Customer) MATCH (c)-[:PLACED]->(:Order)-[:CONTAINS]->(rec:Book) WHERE NOT EXISTS((this)-[:PLACED]->(:Order)-[:CONTAINS]->(rec)) RETURN rec LIMIT $limit") + } + + type Address { + address: String + location: Point + order: Order @relationship(type: "SHIPS_TO", direction: IN) + } + + extend type Address { + currentWeather: Weather @cypher(statement:"CALL apoc.load.json('https://www.7timer.info/bin/civil.php?lon=' + this.location.longitude + '&lat=' + this.location.latitude + '&ac=0&unit=metric&output=json&tzshift=0') YIELD value WITH value.dataseries[0] as weather RETURN {temperature: weather.temp2m, windSpeed: weather.wind10m.speed, windDirection: weather.wind10m.direction, precipitation: weather.prec_type, summary: weather.weather} AS conditions") + } + + type Weather { + temperature: Int + windSpeed: Int + windDirection: Int + precipitation: String + summary: String + } + + type Book { + isbn: ID! + title: String + price: Float + description: String + authors: [Author] @relationship(type: "AUTHOR_OF", direction: IN) + subjects: [Subject] @relationship(type: "ABOUT", direction: OUT) + reviews: [Review] @relationship(type: "REVIEWS", direction: IN) + } + + extend type Book { + similar: [Book] @cypher(statement: """ + MATCH (this)-[:ABOUT]->(s:Subject) + WITH this, COLLECT(id(s)) AS s1 + MATCH (b:Book)-[:ABOUT]->(s:Subject) WHERE b <> this + WITH this, b, s1, COLLECT(id(s)) AS s2 + WITH b, gds.alpha.similarity.jaccard(s2, s2) AS jaccard + ORDER BY jaccard DESC + RETURN b LIMIT 1 + """) + } + + type Review { + rating: Int + text: String + createdAt: DateTime @timestamp + book: Book @relationship(type: "REVIEWS", direction: OUT) + author: Customer @relationship(type: "WROTE", direction: IN) + } + + type Author { + name: String! + books: [Book] @relationship(type: "AUTHOR_OF", direction: OUT) + } + + type Subject { + name: String! + books: [Book] @relationship(type: "ABOUT", direction: IN) + } + + type Mutation { + mergeBookSubjects(subject: String!, bookTitles: [String!]!): Subject @cypher(statement: """ + MERGE (s:Subject {name: $subject}) + WITH s + UNWIND $bookTitles AS bookTitle + MATCH (t:Book {title: bookTitle}) + MERGE (t)-[:ABOUT]->(s) + RETURN s + """) + } + + type Query { + bookSearch(searchString: String!): [Book] @cypher(statement: """ + CALL db.index.fulltext.queryNodes('bookIndex', $searchString+'~') + YIELD node RETURN node + """) + } + `); + + const res = validateDocument(doc); + expect(res).toBeUndefined(); + }); + }); + + describe("Github Issue 213", () => { + test("should not throw error on validation of schema", () => { + const doc = parse(` + interface Vehicle { + id: ID! + color: String # NOTE: 'color' is optional on the interface + } + + type Car implements Vehicle { + id: ID! + color: String! # NOTE: 'color' is mandatory on the type, which should be okay + } + + type Query { + cars: [Vehicle!]! + } + `); + + const res = validateDocument(doc); + expect(res).toBeUndefined(); + }); + }); +}); diff --git a/packages/graphql/src/schema/validation/validate-document.ts b/packages/graphql/src/schema/validation/validate-document.ts new file mode 100644 index 0000000000..0bd0fc927a --- /dev/null +++ b/packages/graphql/src/schema/validation/validate-document.ts @@ -0,0 +1,117 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DefinitionNode, + DocumentNode, + GraphQLSchema, + extendSchema, + validateSchema, + ObjectTypeDefinitionNode, + InputValueDefinitionNode, +} from "graphql"; +import * as scalars from "../scalars"; +import * as enums from "./enums"; +import * as directives from "./directives"; +import * as point from "../point"; + +function filterDocument(document: DocumentNode): DocumentNode { + const nodeNames = document.definitions + .filter((definition) => { + if (definition.kind === "ObjectTypeDefinition") { + if (!["Query", "Mutation", "Subscription"].includes(definition.name.value)) { + return true; + } + } + return false; + }) + .map((definition) => (definition as ObjectTypeDefinitionNode).name.value); + + const filterInputTypes = (fields: readonly InputValueDefinitionNode[] | undefined) => { + return fields?.filter((f) => { + const getArgumentType = (type) => { + if (["NonNullType", "ListType"].includes(type.kind)) { + return getArgumentType(type.type); + } + return type.name.value; + }; + const type = getArgumentType(f.type); + const match = /(?.+)(?:CreateInput|Sort|UpdateInput|Where)/gm.exec(type); + if (match?.groups?.nodeName) { + if (nodeNames.includes(match.groups.nodeName)) { + return false; + } + } + return true; + }); + }; + + return { + ...document, + definitions: document.definitions.reduce((res: DefinitionNode[], def) => { + if (def.kind === "InputObjectTypeDefinition") { + return [ + ...res, + { + ...def, + fields: filterInputTypes(def.fields), + }, + ]; + } + + if (def.kind === "ObjectTypeDefinition" || def.kind === "InterfaceTypeDefinition") { + return [ + ...res, + { + ...def, + directives: def.directives?.filter((x) => !["auth"].includes(x.name.value)), + fields: def.fields?.map((f) => ({ + ...f, + arguments: filterInputTypes(f.arguments), + directives: f.directives?.filter((x) => !["auth"].includes(x.name.value)), + })), + }, + ]; + } + + return [...res, def]; + }, []), + }; +} + +function validateDocument(document: DocumentNode): void { + const doc = filterDocument(document); + + const schemaToExtend = new GraphQLSchema({ + directives: Object.values(directives), + types: [...Object.values(scalars), ...Object.values(enums), ...Object.values(point)], + }); + + const schema = extendSchema(schemaToExtend, doc); + + const errors = validateSchema(schema); + + const filteredErrors = errors.filter((e) => e.message !== "Query root type must be provided."); + + if (filteredErrors.length) { + throw new Error(filteredErrors.join("\n")); + } +} + +export default validateDocument; diff --git a/packages/graphql/src/translate/connection/create-connection-and-params.test.ts b/packages/graphql/src/translate/connection/create-connection-and-params.test.ts new file mode 100644 index 0000000000..6715f347e9 --- /dev/null +++ b/packages/graphql/src/translate/connection/create-connection-and-params.test.ts @@ -0,0 +1,298 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResolveTree } from "graphql-parse-resolve-info"; +import { offsetToCursor } from "graphql-relay"; +import dedent from "dedent"; +import { mocked } from "ts-jest/utils"; +import { ConnectionField, Context } from "../../types"; +import createConnectionAndParams from "./create-connection-and-params"; +import Neo4jGraphQL from "../../classes/Neo4jGraphQL"; + +jest.mock("../../classes/Neo4jGraphQL"); + +describe("createConnectionAndParams", () => { + test("Returns entry with no args", () => { + // @ts-ignore + const mockedNeo4jGraphQL = mocked(new Neo4jGraphQL(), true); + // @ts-ignore + mockedNeo4jGraphQL.nodes = [ + // @ts-ignore + { + name: "Actor", + }, + ]; + // @ts-ignore + mockedNeo4jGraphQL.relationships = [ + // @ts-ignore + { + name: "MovieActorsRelationship", + dateTimeFields: [], + enumFields: [], + ignoredFields: [], + pointFields: [], + primitiveFields: [], + scalarFields: [], + }, + ]; + + const resolveTree: ResolveTree = { + alias: "actorsConnection", + name: "actorsConnection", + args: {}, + fieldsByTypeName: { + MovieActorsConnection: { + edges: { + alias: "edges", + name: "edges", + args: {}, + fieldsByTypeName: { + MovieActorsRelationship: { + screenTime: { + alias: "screenTime", + name: "screenTime", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }, + }, + }, + }; + + // @ts-ignore + const field: ConnectionField = { + fieldName: "actorsConnection", + relationshipTypeName: "MovieActorsRelationship", + // @ts-ignore + typeMeta: { + name: "MovieActorsConnection", + required: true, + }, + otherDirectives: [], + // @ts-ignore + relationship: { + fieldName: "actors", + type: "ACTED_IN", + direction: "IN", + // @ts-ignore + typeMeta: { + name: "Actor", + }, + }, + }; + + // @ts-ignore + const context: Context = { neoSchema: mockedNeo4jGraphQL }; + + const entry = createConnectionAndParams({ resolveTree, field, context, nodeVariable: "this" }); + + expect(dedent(entry[0])).toEqual(dedent`CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection + }`); + }); + + test("Returns entry with sort arg", () => { + // @ts-ignore + const mockedNeo4jGraphQL = mocked(new Neo4jGraphQL(), true); + // @ts-ignore + mockedNeo4jGraphQL.nodes = [ + // @ts-ignore + { + name: "Actor", + }, + ]; + // @ts-ignore + mockedNeo4jGraphQL.relationships = [ + // @ts-ignore + { + name: "MovieActorsRelationship", + dateTimeFields: [], + enumFields: [], + ignoredFields: [], + pointFields: [], + primitiveFields: [], + scalarFields: [], + }, + ]; + + const resolveTree: ResolveTree = { + alias: "actorsConnection", + name: "actorsConnection", + args: { + sort: [ + { + node: { + name: "ASC", + }, + edge: { + screenTime: "DESC", + }, + }, + ], + }, + fieldsByTypeName: { + MovieActorsConnection: { + edges: { + alias: "edges", + name: "edges", + args: {}, + fieldsByTypeName: { + MovieActorsRelationship: { + screenTime: { + alias: "screenTime", + name: "screenTime", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }, + }, + }, + }; + + // @ts-ignore + const field: ConnectionField = { + fieldName: "actorsConnection", + relationshipTypeName: "MovieActorsRelationship", + // @ts-ignore + typeMeta: { + name: "MovieActorsConnection", + required: true, + }, + otherDirectives: [], + // @ts-ignore + relationship: { + fieldName: "actors", + type: "ACTED_IN", + direction: "IN", + // @ts-ignore + typeMeta: { + name: "Actor", + }, + }, + }; + + // @ts-ignore + const context: Context = { neoSchema: mockedNeo4jGraphQL }; + + const entry = createConnectionAndParams({ resolveTree, field, context, nodeVariable: "this" }); + + expect(dedent(entry[0])).toEqual(dedent`CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH this_acted_in, this_actor + ORDER BY this_acted_in.screenTime DESC, this_actor.name ASC + WITH collect({ screenTime: this_acted_in.screenTime }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection + }`); + }); + + test("Returns an entry with offset and limit args", () => { + // @ts-ignore + const mockedNeo4jGraphQL = mocked(new Neo4jGraphQL(), true); + // @ts-ignore + mockedNeo4jGraphQL.nodes = [ + // @ts-ignore + { + name: "Actor", + }, + ]; + // @ts-ignore + mockedNeo4jGraphQL.relationships = [ + // @ts-ignore + { + name: "MovieActorsRelationship", + dateTimeFields: [], + enumFields: [], + ignoredFields: [], + pointFields: [], + primitiveFields: [], + scalarFields: [], + }, + ]; + + const resolveTree: ResolveTree = { + alias: "actorsConnection", + name: "actorsConnection", + args: { + first: 10, + after: offsetToCursor(10), + }, + fieldsByTypeName: { + MovieActorsConnection: { + edges: { + alias: "edges", + name: "edges", + args: {}, + fieldsByTypeName: { + MovieActorsRelationship: { + screenTime: { + alias: "screenTime", + name: "screenTime", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }, + }, + }, + }; + + const field: ConnectionField = { + fieldName: "actorsConnection", + relationshipTypeName: "MovieActorsRelationship", + // @ts-ignore + typeMeta: { + name: "MovieActorsConnection", + required: true, + }, + otherDirectives: [], + // @ts-ignore + relationship: { + fieldName: "actors", + type: "ACTED_IN", + direction: "IN", + // @ts-ignore + typeMeta: { + name: "Actor", + }, + }, + }; + + // @ts-ignore + const context: Context = { neoSchema: mockedNeo4jGraphQL }; + + const entry = createConnectionAndParams({ resolveTree, field, context, nodeVariable: "this" }); + + expect(dedent(entry[0])).toEqual(dedent`CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime }) AS edges + WITH size(edges) AS totalCount, edges[11..21] AS limitedSelection + RETURN { edges: limitedSelection, totalCount: totalCount } AS actorsConnection + }`); + }); +}); diff --git a/packages/graphql/src/translate/connection/create-connection-and-params.ts b/packages/graphql/src/translate/connection/create-connection-and-params.ts new file mode 100644 index 0000000000..70944f0c5b --- /dev/null +++ b/packages/graphql/src/translate/connection/create-connection-and-params.ts @@ -0,0 +1,418 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FieldsByTypeName, ResolveTree } from "graphql-parse-resolve-info"; +import { cursorToOffset } from "graphql-relay"; +import { Integer } from "neo4j-driver"; +import { ConnectionField, ConnectionSortArg, ConnectionWhereArg, Context } from "../../types"; +import { Node } from "../../classes"; +// eslint-disable-next-line import/no-cycle +import createProjectionAndParams from "../create-projection-and-params"; +import Relationship from "../../classes/Relationship"; +import createRelationshipPropertyElement from "../projection/elements/create-relationship-property-element"; +import createConnectionWhereAndParams from "../where/create-connection-where-and-params"; +import createAuthAndParams from "../create-auth-and-params"; +import { AUTH_FORBIDDEN_ERROR } from "../../constants"; +import { createOffsetLimitStr } from "../../schema/pagination"; + +function createConnectionAndParams({ + resolveTree, + field, + context, + nodeVariable, + parameterPrefix, +}: { + resolveTree: ResolveTree; + field: ConnectionField; + context: Context; + nodeVariable: string; + parameterPrefix?: string; +}): [string, any] { + let globalParams = {}; + let nestedConnectionFieldParams; + + const subquery = ["CALL {", `WITH ${nodeVariable}`]; + + const sortInput = resolveTree.args.sort as ConnectionSortArg[]; + const afterInput = resolveTree.args.after; + const firstInput = resolveTree.args.first; + const whereInput = resolveTree.args.where as ConnectionWhereArg; + + const relationshipVariable = `${nodeVariable}_${field.relationship.type.toLowerCase()}`; + const relationship = context.neoSchema.relationships.find( + (r) => r.name === field.relationshipTypeName + ) as Relationship; + + const inStr = field.relationship.direction === "IN" ? "<-" : "-"; + const relTypeStr = `[${relationshipVariable}:${field.relationship.type}]`; + const outStr = field.relationship.direction === "OUT" ? "->" : "-"; + + let relationshipProperties: ResolveTree[] = []; + let node: ResolveTree | undefined; + + const connection = resolveTree.fieldsByTypeName[field.typeMeta.name]; + + if (connection.edges) { + const relationshipFieldsByTypeName = connection.edges.fieldsByTypeName[field.relationshipTypeName]; + + relationshipProperties = Object.values(relationshipFieldsByTypeName).filter((v) => v.name !== "node"); + node = Object.values(relationshipFieldsByTypeName).find((v) => v.name === "node") as ResolveTree; + } + + const elementsToCollect: string[] = []; + + if (relationshipProperties.length) { + const relationshipPropertyEntries = relationshipProperties + .filter((p) => p.name !== "cursor") + .map((v) => createRelationshipPropertyElement({ resolveTree: v, relationship, relationshipVariable })); + elementsToCollect.push(relationshipPropertyEntries.join(", ")); + } + + if (field.relationship.union) { + const unionNodes = context.neoSchema.nodes.filter((n) => field.relationship.union?.nodes?.includes(n.name)); + const unionSubqueries: string[] = []; + + unionNodes.forEach((n) => { + if (!whereInput || Object.prototype.hasOwnProperty.call(whereInput, n.name)) { + const relatedNodeVariable = `${nodeVariable}_${n.name}`; + const nodeOutStr = `(${relatedNodeVariable}:${n.name})`; + + const unionSubquery: string[] = []; + const unionSubqueryElementsToCollect = [...elementsToCollect]; + + const nestedSubqueries: string[] = []; + + if (node) { + const nodeFieldsByTypeName: FieldsByTypeName = { + [n.name]: { + ...node?.fieldsByTypeName[n.name], + ...node?.fieldsByTypeName[field.relationship.typeMeta.name], + }, + }; + + const nodeProjectionAndParams = createProjectionAndParams({ + fieldsByTypeName: nodeFieldsByTypeName, + node: n, + context, + varName: relatedNodeVariable, + literalElements: true, + resolveType: true, + }); + const [nodeProjection, nodeProjectionParams] = nodeProjectionAndParams; + unionSubqueryElementsToCollect.push(`node: ${nodeProjection}`); + globalParams = { + ...globalParams, + ...nodeProjectionParams, + }; + + if (nodeProjectionAndParams[2]?.connectionFields?.length) { + nodeProjectionAndParams[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = n.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const nestedConnection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: relatedNodeVariable, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.alias + }.edges.node`, + }); + nestedSubqueries.push(nestedConnection[0]); + + globalParams = { + ...globalParams, + ...Object.entries(nestedConnection[1]).reduce>( + (res, [k, v]) => { + if (k !== `${relatedNodeVariable}_${connectionResolveTree.alias}`) { + res[k] = v; + } + return res; + }, + {} + ), + }; + + if (nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.alias}`]) { + if (!nestedConnectionFieldParams) nestedConnectionFieldParams = {}; + nestedConnectionFieldParams = { + ...nestedConnectionFieldParams, + ...{ + [connectionResolveTree.alias]: + nestedConnection[1][ + `${relatedNodeVariable}_${connectionResolveTree.alias}` + ], + }, + }; + } + }); + } + } else { + // This ensures that totalCount calculation is accurate if edges not asked for + unionSubqueryElementsToCollect.push(`node: { __resolveType: "${n.name}" }`); + } + + unionSubquery.push(`WITH ${nodeVariable}`); + unionSubquery.push(`OPTIONAL MATCH (${nodeVariable})${inStr}${relTypeStr}${outStr}${nodeOutStr}`); + + const allowAndParams = createAuthAndParams({ + operation: "READ", + entity: n, + context, + allow: { + parentNode: n, + varName: relatedNodeVariable, + }, + }); + if (allowAndParams[0]) { + globalParams = { ...globalParams, ...allowAndParams[1] }; + unionSubquery.push( + `CALL apoc.util.validate(NOT(${allowAndParams[0]}), "${AUTH_FORBIDDEN_ERROR}", [0])` + ); + } + + const whereStrs: string[] = []; + const unionWhere = (whereInput || {})[n.name]; + if (unionWhere) { + const where = createConnectionWhereAndParams({ + whereInput: unionWhere, + node: n, + nodeVariable: relatedNodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.alias + }.args.where.${n.name}`, + }); + const [whereClause] = where; + if (whereClause) { + whereStrs.push(whereClause); + } + } + + const whereAuth = createAuthAndParams({ + operation: "READ", + entity: n, + context, + where: { varName: relatedNodeVariable, node: n }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + globalParams = { ...globalParams, ...whereAuth[1] }; + } + + if (whereStrs.length) { + unionSubquery.push(`WHERE ${whereStrs.join(" AND ")}`); + } + + if (nestedSubqueries.length) { + unionSubquery.push(nestedSubqueries.join("\n")); + } + + unionSubquery.push(`WITH { ${unionSubqueryElementsToCollect.join(", ")} } AS edge`); + unionSubquery.push("RETURN edge"); + + unionSubqueries.push(unionSubquery.join("\n")); + } + }); + + const unionSubqueryCypher = ["CALL {", unionSubqueries.join("\nUNION\n"), "}"]; + + const withValues: string[] = []; + if (!firstInput && !afterInput) { + if (connection.edges || connection.pageInfo) { + withValues.push("collect(edge) as edges"); + } + withValues.push("count(edge) as totalCount"); + unionSubqueryCypher.push(`WITH ${withValues.join(", ")}`); + } else { + const offsetLimitStr = createOffsetLimitStr({ + offset: typeof afterInput === "string" ? cursorToOffset(afterInput) + 1 : undefined, + limit: firstInput as Integer | number | undefined, + }); + unionSubqueryCypher.push("WITH collect(edge) AS allEdges"); + unionSubqueryCypher.push(`WITH allEdges, size(allEdges) as totalCount, allEdges${offsetLimitStr} AS edges`); + } + subquery.push(unionSubqueryCypher.join("\n")); + } else { + const relatedNodeVariable = `${nodeVariable}_${field.relationship.typeMeta.name.toLowerCase()}`; + const nodeOutStr = `(${relatedNodeVariable}:${field.relationship.typeMeta.name})`; + const relatedNode = context.neoSchema.nodes.find((x) => x.name === field.relationship.typeMeta.name) as Node; + + subquery.push(`MATCH (${nodeVariable})${inStr}${relTypeStr}${outStr}${nodeOutStr}`); + + const whereStrs: string[] = []; + + if (whereInput) { + const where = createConnectionWhereAndParams({ + whereInput, + node: relatedNode, + nodeVariable: relatedNodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.alias + }.args.where`, + }); + const [whereClause] = where; + whereStrs.push(`${whereClause}`); + } + + const whereAuth = createAuthAndParams({ + operation: "READ", + entity: relatedNode, + context, + where: { varName: relatedNodeVariable, node: relatedNode }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + globalParams = { ...globalParams, ...whereAuth[1] }; + } + + if (whereStrs.length) { + subquery.push(`WHERE ${whereStrs.join(" AND ")}`); + } + + const allowAndParams = createAuthAndParams({ + operation: "READ", + entity: relatedNode, + context, + allow: { + parentNode: relatedNode, + varName: relatedNodeVariable, + }, + }); + if (allowAndParams[0]) { + globalParams = { ...globalParams, ...allowAndParams[1] }; + subquery.push(`CALL apoc.util.validate(NOT(${allowAndParams[0]}), "${AUTH_FORBIDDEN_ERROR}", [0])`); + } + + if (sortInput && sortInput.length) { + const sort = sortInput.map((s) => + [ + ...Object.entries(s.edge || []).map( + ([f, direction]) => `${relationshipVariable}.${f} ${direction}` + ), + ...Object.entries(s.node || []).map(([f, direction]) => `${relatedNodeVariable}.${f} ${direction}`), + ].join(", ") + ); + subquery.push(`WITH ${relationshipVariable}, ${relatedNodeVariable}`); + subquery.push(`ORDER BY ${sort.join(", ")}`); + } + + const nestedSubqueries: string[] = []; + + if (node) { + const nodeProjectionAndParams = createProjectionAndParams({ + fieldsByTypeName: node?.fieldsByTypeName, + node: relatedNode, + context, + varName: relatedNodeVariable, + literalElements: true, + }); + const [nodeProjection, nodeProjectionParams, projectionMeta] = nodeProjectionAndParams; + elementsToCollect.push(`node: ${nodeProjection}`); + globalParams = { ...globalParams, ...nodeProjectionParams }; + + if (projectionMeta?.authValidateStrs?.length) { + subquery.push( + `CALL apoc.util.validate(NOT(${projectionMeta.authValidateStrs.join( + " AND " + )}), "${AUTH_FORBIDDEN_ERROR}", [0])` + ); + } + + if (projectionMeta?.connectionFields?.length) { + projectionMeta.connectionFields.forEach((connectionResolveTree) => { + const connectionField = relatedNode.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const nestedConnection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: relatedNodeVariable, + parameterPrefix: `${parameterPrefix ? `${parameterPrefix}.` : `${nodeVariable}_`}${ + resolveTree.alias + }.edges.node`, + }); + nestedSubqueries.push(nestedConnection[0]); + + globalParams = { + ...globalParams, + ...Object.entries(nestedConnection[1]).reduce>((res, [k, v]) => { + if (k !== `${relatedNodeVariable}_${connectionResolveTree.alias}`) { + res[k] = v; + } + return res; + }, {}), + }; + + if (nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.alias}`]) { + if (!nestedConnectionFieldParams) nestedConnectionFieldParams = {}; + nestedConnectionFieldParams = { + ...nestedConnectionFieldParams, + ...{ + [connectionResolveTree.alias]: + nestedConnection[1][`${relatedNodeVariable}_${connectionResolveTree.alias}`], + }, + }; + } + }); + } + } + + if (nestedSubqueries.length) subquery.push(nestedSubqueries.join("\n")); + subquery.push(`WITH collect({ ${elementsToCollect.join(", ")} }) AS edges`); + } + + const returnValues: string[] = []; + if (!firstInput && !afterInput) { + if (connection.edges || connection.pageInfo) { + returnValues.push("edges: edges"); + } + returnValues.push(`totalCount: ${field.relationship.union ? "totalCount" : "size(edges)"}`); + subquery.push(`RETURN { ${returnValues.join(", ")} } AS ${resolveTree.alias}`); + } else { + const offsetLimitStr = createOffsetLimitStr({ + offset: typeof afterInput === "string" ? cursorToOffset(afterInput) + 1 : undefined, + limit: firstInput as Integer | number | undefined, + }); + subquery.push(`WITH size(edges) AS totalCount, edges${offsetLimitStr} AS limitedSelection`); + subquery.push(`RETURN { edges: limitedSelection, totalCount: totalCount } AS ${resolveTree.alias}`); + } + subquery.push("}"); + + const params = { + ...globalParams, + ...((whereInput || nestedConnectionFieldParams) && { + [`${nodeVariable}_${resolveTree.alias}`]: { + ...(whereInput && { args: { where: whereInput } }), + ...(nestedConnectionFieldParams && { edges: { node: { ...nestedConnectionFieldParams } } }), + }, + }), + }; + + return [subquery.join("\n"), params]; +} + +export default createConnectionAndParams; diff --git a/packages/graphql/src/translate/create-auth-and-params.test.ts b/packages/graphql/src/translate/create-auth-and-params.test.ts index 8cfd5419a1..9e0ab6e2f7 100644 --- a/packages/graphql/src/translate/create-auth-and-params.test.ts +++ b/packages/graphql/src/translate/create-auth-and-params.test.ts @@ -751,7 +751,9 @@ describe("createAuthAndParams", () => { allow: { parentNode: node, varName: "this" }, }); - expect(trimmer(result[0])).toEqual(trimmer('false OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))')); + expect(trimmer(result[0])).toEqual( + trimmer('false OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))') + ); expect(result[1]).toEqual({}); }); @@ -820,7 +822,9 @@ describe("createAuthAndParams", () => { allow: { parentNode: node, varName: "this" }, }); - expect(trimmer(result[0])).toEqual(trimmer('false OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))')); + expect(trimmer(result[0])).toEqual( + trimmer('false OR ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))') + ); expect(result[1]).toEqual({}); }); }); diff --git a/packages/graphql/src/translate/create-auth-and-params.ts b/packages/graphql/src/translate/create-auth-and-params.ts index d7d25b2854..3f4fc8e143 100644 --- a/packages/graphql/src/translate/create-auth-and-params.ts +++ b/packages/graphql/src/translate/create-auth-and-params.ts @@ -78,7 +78,7 @@ function createAuthPredicate({ const authPredicate = createAuthPredicate({ rule: { [kind]: v, - allowUnauthenticated + allowUnauthenticated, } as AuthRule, varName, node, @@ -146,7 +146,7 @@ function createAuthPredicate({ varName: relationVarName, rule: { [kind]: { [k]: v }, - allowUnauthenticated + allowUnauthenticated, } as AuthRule, kind, }); @@ -207,7 +207,7 @@ function createAuthAndParams({ const authWhere = createAuthPredicate({ rule: { where: authRule.where, - allowUnauthenticated: authRule.allowUnauthenticated + allowUnauthenticated: authRule.allowUnauthenticated, }, context, node: where.node, diff --git a/packages/graphql/src/translate/create-connect-and-params.test.ts b/packages/graphql/src/translate/create-connect-and-params.test.ts index 21d721d733..320c626332 100644 --- a/packages/graphql/src/translate/create-connect-and-params.test.ts +++ b/packages/graphql/src/translate/create-connect-and-params.test.ts @@ -76,7 +76,12 @@ describe("createConnectAndParams", () => { const result = createConnectAndParams({ withVars: ["this"], - value: [{ where: { title: "abc" }, connect: { similarMovies: [{ where: { title: "cba" } }] } }], + value: [ + { + where: { node: { title: "abc" } }, + connect: { similarMovies: [{ where: { node: { title: "cba" } } }] }, + }, + ], varName: "this", relationField: node.relationFields[0], parentVar: "this", @@ -90,16 +95,16 @@ describe("createConnectAndParams", () => { WITH this CALL { WITH this - OPTIONAL MATCH (this0:Movie) - WHERE this0.title = $this0_title - FOREACH(_ IN CASE this0 WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:SIMILAR]->(this0) ) + OPTIONAL MATCH (this0_node:Movie) + WHERE this0_node.title = $this0_node_title + FOREACH(_ IN CASE this0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:SIMILAR]->(this0_node) ) - WITH this, this0 + WITH this, this0_node CALL { - WITH this, this0 - OPTIONAL MATCH (this0_similarMovies0:Movie) - WHERE this0_similarMovies0.title = $this0_similarMovies0_title - FOREACH(_ IN CASE this0_similarMovies0 WHEN NULL THEN [] ELSE [1] END | MERGE (this0)-[:SIMILAR]->(this0_similarMovies0) ) + WITH this, this0_node + OPTIONAL MATCH (this0_node_similarMovies0_node:Movie) + WHERE this0_node_similarMovies0_node.title = $this0_node_similarMovies0_node_title + FOREACH(_ IN CASE this0_node_similarMovies0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this0_node)-[:SIMILAR]->(this0_node_similarMovies0_node) ) RETURN count(*) } @@ -109,8 +114,8 @@ describe("createConnectAndParams", () => { ); expect(result[1]).toMatchObject({ - this0_title: "abc", - this0_similarMovies0_title: "cba", + this0_node_title: "abc", + this0_node_similarMovies0_node_title: "cba", }); }); }); diff --git a/packages/graphql/src/translate/create-connect-and-params.ts b/packages/graphql/src/translate/create-connect-and-params.ts index ffd12e39c5..fc21a56cce 100644 --- a/packages/graphql/src/translate/create-connect-and-params.ts +++ b/packages/graphql/src/translate/create-connect-and-params.ts @@ -17,11 +17,12 @@ * limitations under the License. */ -import { Node } from "../classes"; +import { Node, Relationship } from "../classes"; import { RelationField, Context } from "../types"; import createWhereAndParams from "./create-where-and-params"; import createAuthAndParams from "./create-auth-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createSetRelationshipPropertiesAndParams from "./create-set-relationship-properties-and-params"; interface Res { connects: string[]; @@ -54,10 +55,12 @@ function createConnectAndParams({ insideDoWhen?: boolean; }): [string, any] { function reducer(res: Res, connect: any, index): Res { - const _varName = `${varName}${index}`; + const baseName = `${varName}${index}`; + const nodeName = `${baseName}_node`; + const relationshipName = `${baseName}_relationship`; const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[:${relationField.type}]`; + const relTypeStr = `[${connect.edge ? relationshipName : ""}:${relationField.type}]`; if (parentNode.auth && !fromCreate) { const whereAuth = createAuthAndParams({ @@ -77,13 +80,13 @@ function createConnectAndParams({ res.connects.push("CALL {"); res.connects.push(`WITH ${withVars.join(", ")}`); - res.connects.push(`OPTIONAL MATCH (${_varName}:${labelOverride || relationField.typeMeta.name})`); + res.connects.push(`OPTIONAL MATCH (${nodeName}:${labelOverride || relationField.typeMeta.name})`); const whereStrs: string[] = []; if (connect.where) { const where = createWhereAndParams({ - varName: _varName, - whereInput: connect.where, + varName: nodeName, + whereInput: connect.where.node, node: refNode, context, recursing: true, @@ -98,7 +101,7 @@ function createConnectAndParams({ operation: "CONNECT", entity: refNode, context, - where: { varName: _varName, node: refNode }, + where: { varName: nodeName, node: refNode }, }); if (whereAuth[0]) { whereStrs.push(whereAuth[0]); @@ -121,7 +124,7 @@ function createConnectAndParams({ operation: "CONNECT", context, escapeQuotes: Boolean(insideDoWhen), - allow: { parentNode: node, varName: _varName, chainStr: `${_varName}${node.name}${i}_allow` }, + allow: { parentNode: node, varName: nodeName, chainStr: `${nodeName}${node.name}${i}_allow` }, }); if (!str) { @@ -138,7 +141,7 @@ function createConnectAndParams({ if (preAuth.connects.length) { const quote = insideDoWhen ? `\\"` : `"`; - res.connects.push(`WITH ${[...withVars, _varName].join(", ")}`); + res.connects.push(`WITH ${[...withVars, nodeName].join(", ")}`); res.connects.push( `CALL apoc.util.validate(NOT(${preAuth.connects.join( " AND " @@ -152,39 +155,59 @@ function createConnectAndParams({ Replace with subclauses https://neo4j.com/developer/kb/conditional-cypher-execution/ https://neo4j.slack.com/archives/C02PUHA7C/p1603458561099100 */ - res.connects.push(`FOREACH(_ IN CASE ${_varName} WHEN NULL THEN [] ELSE [1] END | `); - res.connects.push(`MERGE (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName})`); + res.connects.push(`FOREACH(_ IN CASE ${nodeName} WHEN NULL THEN [] ELSE [1] END | `); + res.connects.push(`MERGE (${parentVar})${inStr}${relTypeStr}${outStr}(${nodeName})`); + + if (connect.edge) { + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; + + const setA = createSetRelationshipPropertiesAndParams({ + properties: connect.edge, + varName: relationshipName, + relationship, + operation: "CREATE", + }); + res.connects.push(setA[0]); + res.params = { ...res.params, ...setA[1] }; + } + res.connects.push(`)`); // close FOREACH if (connect.connect) { const connects = (Array.isArray(connect.connect) ? connect.connect : [connect.connect]) as any[]; connects.forEach((c) => { const reduced = Object.entries(c).reduce( - (r: Res, [k, v]) => { - const relField = refNode.relationFields.find((x) => k.startsWith(x.fieldName)); - let newRefNode: Node; + (r: Res, [k, v]: [string, any]) => { + const relField = refNode.relationFields.find((x) => k === x.fieldName) as RelationField; + const newRefNodes: Node[] = []; - if (relationField.union) { - const [modelName] = k.split(`${relationField.fieldName}_`).join("").split("_"); - newRefNode = context.neoSchema.nodes.find((x) => x.name === modelName) as Node; + if (relField.union) { + Object.keys(v).forEach((modelName) => { + newRefNodes.push(context.neoSchema.nodes.find((x) => x.name === modelName) as Node); + }); } else { - newRefNode = context.neoSchema.nodes.find( - (x) => x.name === (relField as RelationField).typeMeta.name - ) as Node; + newRefNodes.push( + context.neoSchema.nodes.find((x) => x.name === relField.typeMeta.name) as Node + ); } - const recurse = createConnectAndParams({ - withVars: [...withVars, _varName], - value: v, - varName: `${_varName}_${k}`, - relationField: relField as RelationField, - parentVar: _varName, - context, - refNode: newRefNode, - parentNode: refNode, + newRefNodes.forEach((newRefNode) => { + const recurse = createConnectAndParams({ + withVars: [...withVars, nodeName], + value: relField.union ? v[newRefNode.name] : v, + varName: `${nodeName}_${k}${relField.union ? `_${newRefNode.name}` : ""}`, + relationField: relField, + parentVar: nodeName, + context, + refNode: newRefNode, + parentNode: refNode, + labelOverride: relField.union ? newRefNode.name : "", + }); + r.connects.push(recurse[0]); + r.params = { ...r.params, ...recurse[1] }; }); - r.connects.push(recurse[0]); - r.params = { ...r.params, ...recurse[1] }; return r; }, @@ -209,7 +232,7 @@ function createConnectAndParams({ escapeQuotes: Boolean(insideDoWhen), skipIsAuthenticated: true, skipRoles: true, - bind: { parentNode: node, varName: _varName, chainStr: `${_varName}${node.name}${i}_bind` }, + bind: { parentNode: node, varName: nodeName, chainStr: `${nodeName}${node.name}${i}_bind` }, }); if (!str) { @@ -226,7 +249,7 @@ function createConnectAndParams({ if (postAuth.connects.length) { const quote = insideDoWhen ? `\\"` : `"`; - res.connects.push(`WITH ${[...withVars, _varName].join(", ")}`); + res.connects.push(`WITH ${[...withVars, nodeName].join(", ")}`); res.connects.push( `CALL apoc.util.validate(NOT(${postAuth.connects.join( " AND " diff --git a/packages/graphql/src/translate/create-create-and-params.ts b/packages/graphql/src/translate/create-create-and-params.ts index 85983841b2..ac9f1e6913 100644 --- a/packages/graphql/src/translate/create-create-and-params.ts +++ b/packages/graphql/src/translate/create-create-and-params.ts @@ -17,11 +17,12 @@ * limitations under the License. */ -import { Node } from "../classes"; +import { Node, Relationship } from "../classes"; import { Context } from "../types"; import createConnectAndParams from "./create-connect-and-params"; import createAuthAndParams from "./create-auth-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createSetRelationshipPropertiesAndParams from "./create-set-relationship-properties-and-params"; interface Res { creates: string[]; @@ -50,60 +51,88 @@ function createCreateAndParams({ }): [string, any] { function reducer(res: Res, [key, value]: [string, any]): Res { const _varName = `${varName}_${key}`; - const relationField = node.relationFields.find((x) => key.startsWith(x.fieldName)); + const relationField = node.relationFields.find((x) => key === x.fieldName); const primitiveField = node.primitiveFields.find((x) => key === x.fieldName); - const pointField = node.pointFields.find((x) => key.startsWith(x.fieldName)); + const pointField = node.pointFields.find((x) => key === x.fieldName); if (relationField) { - let refNode: Node; - let unionTypeName = ""; + const refNodes: Node[] = []; + // let unionTypeName = ""; if (relationField.union) { - [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + // [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); + + Object.keys(value).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); + + // refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } - if (value.create) { - const creates = relationField.typeMeta.array ? value.create : [value.create]; - creates.forEach((create, index) => { - const innerVarName = `${_varName}${index}`; - res.creates.push(`\nWITH ${withVars.join(", ")}`); + refNodes.forEach((refNode) => { + const v = relationField.union ? value[refNode.name] : value; + const unionTypeName = relationField.union ? refNode.name : ""; + + if (v.create) { + const creates = relationField.typeMeta.array ? v.create : [v.create]; + creates.forEach((create, index) => { + res.creates.push(`\nWITH ${withVars.join(", ")}`); + + const baseName = `${_varName}${relationField.union ? "_" : ""}${unionTypeName}${index}`; + const nodeName = `${baseName}_node`; + const propertiesName = `${baseName}_relationship`; + + const recurse = createCreateAndParams({ + input: create.node, + context, + node: refNode, + varName: nodeName, + withVars: [...withVars, nodeName], + }); + res.creates.push(recurse[0]); + res.params = { ...res.params, ...recurse[1] }; + + const inStr = relationField.direction === "IN" ? "<-" : "-"; + const outStr = relationField.direction === "OUT" ? "->" : "-"; + const relTypeStr = `[${create.edge ? propertiesName : ""}:${relationField.type}]`; + res.creates.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${nodeName})`); + + if (create.edge) { + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; + + const setA = createSetRelationshipPropertiesAndParams({ + properties: create.edge, + varName: propertiesName, + relationship, + operation: "CREATE", + }); + res.creates.push(setA[0]); + res.params = { ...res.params, ...setA[1] }; + } + }); + } - const recurse = createCreateAndParams({ - input: create, + if (v.connect) { + const connectAndParams = createConnectAndParams({ + withVars, + value: v.connect, + varName: `${_varName}${relationField.union ? "_" : ""}${unionTypeName}_connect`, + parentVar: varName, + relationField, context, - node: refNode, - varName: innerVarName, - withVars: [...withVars, innerVarName], + refNode, + labelOverride: unionTypeName, + parentNode: node, + fromCreate: true, }); - res.creates.push(recurse[0]); - res.params = { ...res.params, ...recurse[1] }; - - const inStr = relationField.direction === "IN" ? "<-" : "-"; - const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[:${relationField.type}]`; - res.creates.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${innerVarName})`); - }); - } - - if (value.connect) { - const connectAndParams = createConnectAndParams({ - withVars, - value: value.connect, - varName: `${_varName}_connect`, - parentVar: varName, - relationField, - context, - refNode, - labelOverride: unionTypeName, - parentNode: node, - fromCreate: true, - }); - res.creates.push(connectAndParams[0]); - res.params = { ...res.params, ...connectAndParams[1] }; - } + res.creates.push(connectAndParams[0]); + res.params = { ...res.params, ...connectAndParams[1] }; + } + }); return res; } @@ -132,10 +161,13 @@ function createCreateAndParams({ } else { res.creates.push(`SET ${varName}.${key} = point($${_varName})`); } - } else { - res.creates.push(`SET ${varName}.${key} = $${_varName}`); + + res.params[_varName] = value; + + return res; } + res.creates.push(`SET ${varName}.${key} = $${_varName}`); res.params[_varName] = value; return res; diff --git a/packages/graphql/src/translate/create-delete-and-params.ts b/packages/graphql/src/translate/create-delete-and-params.ts index 31b66eff3c..70c0109d53 100644 --- a/packages/graphql/src/translate/create-delete-and-params.ts +++ b/packages/graphql/src/translate/create-delete-and-params.ts @@ -17,10 +17,10 @@ * limitations under the License. */ -import { Node } from "../classes"; +import { Node, Relationship } from "../classes"; import { Context } from "../types"; -import createWhereAndParams from "./create-where-and-params"; import createAuthAndParams from "./create-auth-and-params"; +import createConnectionWhereAndParams from "./where/create-connection-where-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; interface Res { @@ -37,6 +37,8 @@ function createDeleteAndParams({ withVars, context, insideDoWhen, + parameterPrefix, + recursing, }: { parentVar: string; deleteInput: any; @@ -46,98 +48,117 @@ function createDeleteAndParams({ withVars: string[]; context: Context; insideDoWhen?: boolean; + parameterPrefix: string; + recursing?: boolean; }): [string, any] { function reducer(res: Res, [key, value]: [string, any]) { - const relationField = node.relationFields.find((x) => key.startsWith(x.fieldName)); - let unionTypeName = ""; + const relationField = node.relationFields.find((x) => key === x.fieldName); if (relationField) { - let refNode: Node; + const refNodes: Node[] = []; + + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; if (relationField.union) { - [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + Object.keys(value).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[:${relationField.type}]`; - - const deletes = relationField.typeMeta.array ? value : [value]; - deletes.forEach((d, index) => { - const _varName = chainStr ? `${varName}${index}` : `${varName}_${key}${index}`; - - if (withVars) { - res.strs.push(`WITH ${withVars.join(", ")}`); - } - - res.strs.push( - `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` - ); - - const whereStrs: string[] = []; - if (d.where) { - const whereAndParams = createWhereAndParams({ - varName: _varName, - whereInput: d.where, - node: refNode, - context, - recursing: true, - }); - if (whereAndParams[0]) { - whereStrs.push(whereAndParams[0]); - res.params = { ...res.params, ...whereAndParams[1] }; + + refNodes.forEach((refNode) => { + const v = relationField.union ? value[refNode.name] : value; + const deletes = relationField.typeMeta.array ? v : [v]; + deletes.forEach((d, index) => { + const _varName = chainStr + ? `${varName}${index}` + : `${varName}_${key}${relationField.union ? `_${refNode.name}` : ""}${index}`; + const relationshipVariable = `${_varName}_relationship`; + const relTypeStr = `[${relationshipVariable}:${relationField.type}]`; + + if (withVars) { + res.strs.push(`WITH ${withVars.join(", ")}`); } - } - const whereAuth = createAuthAndParams({ - operation: "DELETE", - entity: refNode, - context, - where: { varName: _varName, node: refNode }, - }); - if (whereAuth[0]) { - whereStrs.push(whereAuth[0]); - res.params = { ...res.params, ...whereAuth[1] }; - } - if (whereStrs.length) { - res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); - } - - const allowAuth = createAuthAndParams({ - entity: refNode, - operation: "DELETE", - context, - escapeQuotes: Boolean(insideDoWhen), - allow: { parentNode: refNode, varName: _varName }, - }); - if (allowAuth[0]) { - const quote = insideDoWhen ? `\\"` : `"`; - res.strs.push(`WITH ${[...withVars, _varName].join(", ")}`); + res.strs.push( - `CALL apoc.util.validate(NOT(${allowAuth[0]}), ${quote}${AUTH_FORBIDDEN_ERROR}${quote}, [0])` + `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` ); - res.params = { ...res.params, ...allowAuth[1] }; - } - if (d.delete) { - const deleteAndParams = createDeleteAndParams({ + const whereStrs: string[] = []; + if (d.where) { + const whereAndParams = createConnectionWhereAndParams({ + nodeVariable: _varName, + whereInput: d.where, + node: refNode, + context, + relationshipVariable, + relationship, + parameterPrefix: `${parameterPrefix}${!recursing ? `.${key}` : ""}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ""}.where`, + }); + if (whereAndParams[0]) { + whereStrs.push(whereAndParams[0]); + } + } + const whereAuth = createAuthAndParams({ + operation: "DELETE", + entity: refNode, + context, + where: { varName: _varName, node: refNode }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + res.params = { ...res.params, ...whereAuth[1] }; + } + if (whereStrs.length) { + res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); + } + + const allowAuth = createAuthAndParams({ + entity: refNode, + operation: "DELETE", context, - node: refNode, - deleteInput: d.delete, - varName: _varName, - withVars: [...withVars, _varName], - parentVar: _varName, + escapeQuotes: Boolean(insideDoWhen), + allow: { parentNode: refNode, varName: _varName }, }); - res.strs.push(deleteAndParams[0]); - res.params = { ...res.params, ...deleteAndParams[1] }; - } + if (allowAuth[0]) { + const quote = insideDoWhen ? `\\"` : `"`; + res.strs.push(`WITH ${[...withVars, _varName].join(", ")}`); + res.strs.push( + `CALL apoc.util.validate(NOT(${allowAuth[0]}), ${quote}${AUTH_FORBIDDEN_ERROR}${quote}, [0])` + ); + res.params = { ...res.params, ...allowAuth[1] }; + } + + if (d.delete) { + const deleteAndParams = createDeleteAndParams({ + context, + node: refNode, + deleteInput: d.delete, + varName: _varName, + withVars: [...withVars, _varName], + parentVar: _varName, + parameterPrefix: `${parameterPrefix}${!recursing ? `.${key}` : ""}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ""}.delete`, + recursing: false, + }); + res.strs.push(deleteAndParams[0]); + res.params = { ...res.params, ...deleteAndParams[1] }; + } - res.strs.push(` + res.strs.push(` FOREACH(_ IN CASE ${_varName} WHEN NULL THEN [] ELSE [1] END | DETACH DELETE ${_varName} )`); + }); }); return res; diff --git a/packages/graphql/src/translate/create-disconnect-and-params.test.ts b/packages/graphql/src/translate/create-disconnect-and-params.test.ts index 75d4995281..63d39b6f10 100644 --- a/packages/graphql/src/translate/create-disconnect-and-params.test.ts +++ b/packages/graphql/src/translate/create-disconnect-and-params.test.ts @@ -72,6 +72,7 @@ describe("createDisconnectAndParams", () => { // @ts-ignore const neoSchema: Neo4jGraphQL = { nodes: [node], + relationships: [], }; // @ts-ignore @@ -79,35 +80,38 @@ describe("createDisconnectAndParams", () => { const result = createDisconnectAndParams({ withVars: ["this"], - value: [{ where: { title: "abc" }, disconnect: { similarMovies: [{ where: { title: "cba" } }] } }], + value: [ + { + where: { node: { title: "abc" } }, + disconnect: { similarMovies: [{ where: { node: { title: "cba" } } }] }, + }, + ], varName: "this", relationField: node.relationFields[0], parentVar: "this", context, refNode: node, parentNode: node, + parameterPrefix: "this", // TODO }); expect(trimmer(result[0])).toEqual( trimmer(` WITH this OPTIONAL MATCH (this)-[this0_rel:SIMILAR]->(this0:Movie) - WHERE this0.title = $this0_title + WHERE this0.title = $this[0].where.node.title FOREACH(_ IN CASE this0 WHEN NULL THEN [] ELSE [1] END | DELETE this0_rel ) WITH this, this0 OPTIONAL MATCH (this0)-[this0_similarMovies0_rel:SIMILAR]->(this0_similarMovies0:Movie) - WHERE this0_similarMovies0.title = $this0_similarMovies0_title + WHERE this0_similarMovies0.title = $this[0].disconnect.similarMovies[0].where.node.title FOREACH(_ IN CASE this0_similarMovies0 WHEN NULL THEN [] ELSE [1] END | DELETE this0_similarMovies0_rel ) `) ); - expect(result[1]).toMatchObject({ - this0_title: "abc", - this0_similarMovies0_title: "cba", - }); + expect(result[1]).toMatchObject({}); }); }); diff --git a/packages/graphql/src/translate/create-disconnect-and-params.ts b/packages/graphql/src/translate/create-disconnect-and-params.ts index 9a97c714de..1b21810da2 100644 --- a/packages/graphql/src/translate/create-disconnect-and-params.ts +++ b/packages/graphql/src/translate/create-disconnect-and-params.ts @@ -17,11 +17,11 @@ * limitations under the License. */ -import { Node } from "../classes"; +import { Node, Relationship } from "../classes"; import { RelationField, Context } from "../types"; -import createWhereAndParams from "./create-where-and-params"; import createAuthAndParams from "./create-auth-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createConnectionWhereAndParams from "./where/create-connection-where-and-params"; interface Res { disconnects: string[]; @@ -39,6 +39,7 @@ function createDisconnectAndParams({ labelOverride, parentNode, insideDoWhen, + parameterPrefix, }: { withVars: string[]; value: any; @@ -50,8 +51,9 @@ function createDisconnectAndParams({ labelOverride?: string; parentNode: Node; insideDoWhen?: boolean; + parameterPrefix: string; }): [string, any] { - function reducer(res: Res, disconnect: any, index): Res { + function reducer(res: Res, disconnect: { where: any; disconnect: any }, index): Res { const _varName = `${varName}${index}`; const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; @@ -81,19 +83,25 @@ function createDisconnectAndParams({ const whereStrs: string[] = []; + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; + if (disconnect.where) { - const where = createWhereAndParams({ - varName: _varName, + const whereAndParams = createConnectionWhereAndParams({ + nodeVariable: _varName, whereInput: disconnect.where, node: refNode, context, - recursing: true, + relationshipVariable: relVarName, + relationship, + parameterPrefix: `${parameterPrefix}${relationField.typeMeta.array ? `[${index}]` : ""}.where`, }); - if (where[0]) { - whereStrs.push(where[0]); - res.params = { ...res.params, ...where[1] }; + if (whereAndParams[0]) { + whereStrs.push(whereAndParams[0]); } } + if (refNode.auth) { const whereAuth = createAuthAndParams({ operation: "DISCONNECT", @@ -157,37 +165,42 @@ function createDisconnectAndParams({ res.disconnects.push(`)`); // close FOREACH if (disconnect.disconnect) { - const disconnects = (Array.isArray(disconnect.disconnect) - ? disconnect.disconnect - : [disconnect.disconnect]) as any[]; + const disconnects = Array.isArray(disconnect.disconnect) ? disconnect.disconnect : [disconnect.disconnect]; - disconnects.forEach((c) => { + disconnects.forEach((c, i) => { const reduced = Object.entries(c).reduce( - (r: Res, [k, v]) => { - const relField = refNode.relationFields.find((x) => k.startsWith(x.fieldName)); - let newRefNode: Node; + (r: Res, [k, v]: [string, any]) => { + const relField = refNode.relationFields.find((x) => k.startsWith(x.fieldName)) as RelationField; + const newRefNodes: Node[] = []; - if (relationField.union) { - const [modelName] = k.split(`${relationField.fieldName}_`).join("").split("_"); - newRefNode = context.neoSchema.nodes.find((x) => x.name === modelName) as Node; + if (relField.union) { + Object.keys(v).forEach((modelName) => { + newRefNodes.push(context.neoSchema.nodes.find((x) => x.name === modelName) as Node); + }); } else { - newRefNode = context.neoSchema.nodes.find( - (x) => x.name === (relField as RelationField).typeMeta.name - ) as Node; + newRefNodes.push( + context.neoSchema.nodes.find((x) => x.name === relField.typeMeta.name) as Node + ); } - const recurse = createDisconnectAndParams({ - withVars: [...withVars, _varName], - value: v, - varName: `${_varName}_${k}`, - relationField: relField as RelationField, - parentVar: _varName, - context, - refNode: newRefNode, - parentNode: refNode, + newRefNodes.forEach((newRefNode) => { + const recurse = createDisconnectAndParams({ + withVars: [...withVars, _varName], + value: relField.union ? v[newRefNode.name] : v, + varName: `${_varName}_${k}${relField.union ? `_${newRefNode.name}` : ""}`, + relationField: relField, + parentVar: _varName, + context, + refNode: newRefNode, + parentNode: refNode, + parameterPrefix: `${parameterPrefix}${ + relField.typeMeta.array ? `[${i}]` : "" + }.disconnect.${k}${relField.union ? `.${newRefNode.name}` : ""}`, + labelOverride: relField.union ? newRefNode.name : "", + }); + r.disconnects.push(recurse[0]); + r.params = { ...r.params, ...recurse[1] }; }); - r.disconnects.push(recurse[0]); - r.params = { ...r.params, ...recurse[1] }; return r; }, diff --git a/packages/graphql/src/translate/create-projection-and-params.test.ts b/packages/graphql/src/translate/create-projection-and-params.test.ts index 4e9e25a0e0..2d01e3279f 100644 --- a/packages/graphql/src/translate/create-projection-and-params.test.ts +++ b/packages/graphql/src/translate/create-projection-and-params.test.ts @@ -42,6 +42,7 @@ describe("createProjectionAndParams", () => { const node: Node = { name: "Movie", relationFields: [], + connectionFields: [], cypherFields: [], enumFields: [], unionFields: [], diff --git a/packages/graphql/src/translate/create-projection-and-params.ts b/packages/graphql/src/translate/create-projection-and-params.ts index 3601cf87f2..59ac3232c5 100644 --- a/packages/graphql/src/translate/create-projection-and-params.ts +++ b/packages/graphql/src/translate/create-projection-and-params.ts @@ -17,41 +17,27 @@ * limitations under the License. */ -import { FieldsByTypeName } from "graphql-parse-resolve-info"; +import { FieldsByTypeName, ResolveTree } from "graphql-parse-resolve-info"; import { Node } from "../classes"; import createWhereAndParams from "./create-where-and-params"; -import { GraphQLOptionsArg, GraphQLSortArg, GraphQLWhereArg, Context } from "../types"; +import { GraphQLOptionsArg, GraphQLSortArg, GraphQLWhereArg, Context, ConnectionField } from "../types"; import createAuthAndParams from "./create-auth-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createDatetimeElement from "./projection/elements/create-datetime-element"; +import createPointElement from "./projection/elements/create-point-element"; +// eslint-disable-next-line import/no-cycle +import createConnectionAndParams from "./connection/create-connection-and-params"; +import { createOffsetLimitStr } from "../schema/pagination"; interface Res { projection: string[]; params: any; - meta?: ProjectionMeta; + meta: ProjectionMeta; } interface ProjectionMeta { authValidateStrs?: string[]; -} - -function createSkipLimitStr({ skip, limit }: { skip?: number; limit?: number }): string { - const hasSkip = typeof skip !== "undefined"; - const hasLimit = typeof limit !== "undefined"; - let skipLimitStr = ""; - - if (hasSkip && !hasLimit) { - skipLimitStr = `[${skip}..]`; - } - - if (hasLimit && !hasSkip) { - skipLimitStr = `[..${limit}]`; - } - - if (hasLimit && hasSkip) { - skipLimitStr = `[${skip}..${limit}]`; - } - - return skipLimitStr; + connectionFields?: ResolveTree[]; } function createNodeWhereAndParams({ @@ -132,12 +118,18 @@ function createProjectionAndParams({ context, chainStr, varName, + literalElements, + resolveType, + inRelationshipProjection, }: { fieldsByTypeName: FieldsByTypeName; node: Node; context: Context; chainStr?: string; varName: string; + literalElements?: boolean; + resolveType?: boolean; + inRelationshipProjection?: boolean; }): [string, any, ProjectionMeta?] { function reducer(res: Res, [key, field]: [string, any]): Res { let param = ""; @@ -152,6 +144,7 @@ function createProjectionAndParams({ const fieldFields = (field.fieldsByTypeName as unknown) as FieldsByTypeName; const cypherField = node.cypherFields.find((x) => x.fieldName === field.name); const relationField = node.relationFields.find((x) => x.fieldName === field.name); + const connectionField = node.connectionFields.find((x) => x.fieldName === field.name); const pointField = node.pointFields.find((x) => x.fieldName === field.name); const dateTimeField = node.dateTimeFields.find((x) => x.fieldName === field.name); const authableField = node.authableFields.find((x) => x.fieldName === field.name); @@ -165,10 +158,10 @@ function createProjectionAndParams({ allow: { parentNode: node, varName, chainStr: param }, }); if (allowAndParams[0]) { - if (!res.meta) { - res.meta = { authValidateStrs: [] }; + if (!res.meta.authValidateStrs) { + res.meta.authValidateStrs = []; } - res.meta?.authValidateStrs?.push(allowAndParams[0]); + res.meta.authValidateStrs?.push(allowAndParams[0]); res.params = { ...res.params, ...allowAndParams[1] }; } } @@ -183,6 +176,9 @@ function createProjectionAndParams({ const isEnum = context.neoSchema.document.definitions.find( (x) => x.kind === "EnumTypeDefinition" && x.name.value === cypherField.typeMeta.name ); + const isScalar = context.neoSchema.document.definitions.find( + (x) => x.kind === "ScalarTypeDefinition" && x.name.value === cypherField.typeMeta.name + ); const referenceNode = context.neoSchema.nodes.find((x) => x.name === cypherField.typeMeta.name); if (referenceNode) { @@ -201,7 +197,7 @@ function createProjectionAndParams({ } const initApocParamsStrs = [ - "auth: $auth", + ...(context.auth ? ["auth: $auth"] : []), ...(context.cypherParams ? ["cypherParams: $cypherParams"] : []), ]; const apocParams = Object.entries(field.args).reduce( @@ -215,7 +211,11 @@ function createProjectionAndParams({ }, { strs: initApocParamsStrs, params: {} } ) as { strs: string[]; params: any }; - res.params = { ...res.params, ...apocParams.params, cypherParams: context.cypherParams }; + res.params = { + ...res.params, + ...apocParams.params, + ...(context.cypherParams ? { cypherParams: context.cypherParams } : {}), + }; const expectMultipleValues = referenceNode && cypherField.typeMeta.array ? "true" : "false"; const apocWhere = `${ @@ -226,13 +226,13 @@ function createProjectionAndParams({ const apocParamsStr = `{this: ${chainStr || varName}${ apocParams.strs.length ? `, ${apocParams.strs.join(", ")}` : "" }}`; - const apocStr = `${!isPrimitive && !isEnum ? `${param} IN` : ""} apoc.cypher.runFirstColumn("${ + const apocStr = `${!isPrimitive && !isEnum && !isScalar ? `${param} IN` : ""} apoc.cypher.runFirstColumn("${ cypherField.statement - }", ${apocParamsStr}, ${expectMultipleValues}) ${apocWhere} ${ - projectionStr ? `| ${param} ${projectionStr}` : "" + }", ${apocParamsStr}, ${expectMultipleValues})${apocWhere ? ` ${apocWhere}` : ""}${ + projectionStr ? ` | ${param} ${projectionStr}` : "" }`; - if (isPrimitive || isEnum) { + if (isPrimitive || isEnum || isScalar) { res.projection.push(`${key}: ${apocStr}`); return res; @@ -259,8 +259,10 @@ function createProjectionAndParams({ const isArray = relationField.typeMeta.array; if (relationField.union) { - const referenceNodes = context.neoSchema.nodes.filter((x) => - relationField.union?.nodes?.includes(x.name) + const referenceNodes = context.neoSchema.nodes.filter( + (x) => + relationField.union?.nodes?.includes(x.name) && + (!field.args.where || Object.prototype.hasOwnProperty.call(field.args.where, x.name)) ); const unionStrs: string[] = [ @@ -278,7 +280,6 @@ function createProjectionAndParams({ if (field.fieldsByTypeName[refNode.name]) { const recurse = createProjectionAndParams({ - // @ts-ignore fieldsByTypeName: field.fieldsByTypeName, node: refNode, context, @@ -286,7 +287,7 @@ function createProjectionAndParams({ }); const nodeWhereAndParams = createNodeWhereAndParams({ - whereInput: field.args[refNode.name], + whereInput: field.args.where ? field.args.where[refNode.name] : field.args.where, context, node: refNode, varName: param, @@ -317,9 +318,12 @@ function createProjectionAndParams({ unionStrs.push(") ]"); if (optionsInput) { - const skipLimit = createSkipLimitStr({ skip: optionsInput.skip, limit: optionsInput.limit }); - if (skipLimit) { - unionStrs.push(skipLimit); + const offsetLimit = createOffsetLimitStr({ + offset: optionsInput.offset, + limit: optionsInput.limit, + }); + if (offsetLimit) { + unionStrs.push(offsetLimit); } } @@ -336,6 +340,7 @@ function createProjectionAndParams({ context, varName: `${varName}_${key}`, chainStr: param, + inRelationshipProjection: true, }); [projectionStr] = recurse; res.params = { ...res.params, ...recurse[1] }; @@ -358,7 +363,7 @@ function createProjectionAndParams({ let nestedQuery; if (optionsInput) { - const skipLimit = createSkipLimitStr({ skip: optionsInput.skip, limit: optionsInput.limit }); + const offsetLimit = createOffsetLimitStr({ offset: optionsInput.offset, limit: optionsInput.limit }); if (optionsInput.sort) { const sorts = optionsInput.sort.reduce((s: string[], sort: GraphQLSortArg) => { @@ -374,9 +379,11 @@ function createProjectionAndParams({ ]; }, []); - nestedQuery = `${key}: apoc.coll.sortMulti([ ${innerStr} ], [${sorts.join(", ")}])${skipLimit}`; + nestedQuery = `${key}: apoc.coll.sortMulti([ ${innerStr} ], [${sorts.join(", ")}])${offsetLimit}`; } else { - nestedQuery = `${key}: ${!isArray ? "head(" : ""}[ ${innerStr} ]${skipLimit}${!isArray ? ")" : ""}`; + nestedQuery = `${key}: ${!isArray ? "head(" : ""}[ ${innerStr} ]${offsetLimit}${ + !isArray ? ")" : "" + }`; } } else { nestedQuery = `${key}: ${!isArray ? "head(" : ""}[ ${innerStr} ]${!isArray ? ")" : ""}`; @@ -387,37 +394,58 @@ function createProjectionAndParams({ return res; } - if (pointField) { - const isArray = pointField.typeMeta.array; + if (connectionField) { + if (!inRelationshipProjection) { + if (!res.meta.connectionFields) { + res.meta.connectionFields = []; + } - const { crs, ...point } = fieldFields[pointField.typeMeta.name]; - const fields: string[] = []; + const f = field as ResolveTree; - // Sadly need to select the whole point object due to the risk of height/z - // being selected on a 2D point, to which the database will throw an error - if (point) { - fields.push(isArray ? "point:p" : `point: ${varName}.${field.name}`); - } + res.meta.connectionFields.push(f); + res.projection.push(literalElements ? `${f.alias}: ${f.alias}` : `${f.alias}`); - if (crs) { - fields.push(isArray ? "crs: p.crs" : `crs: ${varName}.${field.name}.crs`); + return res; } + const matchedConnectionField = node.connectionFields.find( + (x) => x.fieldName === field.name + ) as ConnectionField; + const connection = createConnectionAndParams({ + resolveTree: field, + field: matchedConnectionField, + context, + nodeVariable: varName, + }); + + const connectionParamName = Object.keys(connection[1])[0]; + const runFirstColumnParams = connectionParamName + ? `{ ${chainStr}: ${chainStr}, ${connectionParamName}: $${connectionParamName} }` + : `{ ${chainStr}: ${chainStr} }`; + res.projection.push( - isArray - ? `${key}: [p in ${varName}.${field.name} | { ${fields.join(", ")} }]` - : `${key}: { ${fields.join(", ")} }` + `${field.name}: apoc.cypher.runFirstColumn("${connection[0]} RETURN ${field.name}", ${runFirstColumnParams}, false)` ); + res.params = { ...res.params, ...connection[1] }; + return res; + } + + if (pointField) { + res.projection.push(createPointElement({ resolveTree: field, field: pointField, variable: varName })); } else if (dateTimeField) { - res.projection.push( - dateTimeField.typeMeta.array - ? `${key}: [ dt in ${varName}.${field.name} | apoc.date.convertFormat(toString(dt), "iso_zoned_date_time", "iso_offset_date_time") ]` - : `${key}: apoc.date.convertFormat(toString(${varName}.${field.name}), "iso_zoned_date_time", "iso_offset_date_time")` - ); + res.projection.push(createDatetimeElement({ resolveTree: field, field: dateTimeField, variable: varName })); } else { // If field is aliased, rename projected field to alias and set to varName.fieldName // e.g. RETURN varname { .fieldName } -> RETURN varName { alias: varName.fieldName } - const aliasedProj = field.alias !== field.name ? `${field.alias}: ${varName}` : ""; + let aliasedProj: string; + + if (field.alias !== field.name) { + aliasedProj = `${field.alias}: ${varName}`; + } else if (literalElements) { + aliasedProj = `${key}: ${varName}`; + } else { + aliasedProj = ""; + } res.projection.push(`${aliasedProj}.${field.name}`); } @@ -428,8 +456,9 @@ function createProjectionAndParams({ const { projection, params, meta } = Object.entries(fieldsByTypeName[node.name] as { [k: string]: any }).reduce( reducer, { - projection: [], + projection: resolveType ? [`__resolveType: "${node.name}"`] : [], params: {}, + meta: {}, } ); diff --git a/packages/graphql/src/translate/create-set-relationship-properties-and-params.ts b/packages/graphql/src/translate/create-set-relationship-properties-and-params.ts new file mode 100644 index 0000000000..d6d347b8a8 --- /dev/null +++ b/packages/graphql/src/translate/create-set-relationship-properties-and-params.ts @@ -0,0 +1,85 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint-disable prefer-destructuring */ +import { Relationship } from "../classes"; + +/* + TODO - lets reuse this function for setting either node or rel properties. + This was not reused due to the large differences between node fields + - and relationship fields. +*/ +function createSetRelationshipPropertiesAndParams({ + properties, + varName, + relationship, + operation, +}: { + properties: Record; + varName: string; + relationship: Relationship; + operation: "CREATE" | "UPDATE"; +}): [string, any] { + const strs: string[] = []; + const params = {}; + + Object.entries(properties).forEach(([key, value]) => { + const paramName = `${varName}_${key}`; + + const dateTimeField = relationship.dateTimeFields.find((x) => x.fieldName === key); + if (dateTimeField && dateTimeField.timestamps?.length) { + (dateTimeField.timestamps || []).forEach((ts) => { + if (ts.includes(operation)) { + strs.push(`SET ${varName}.${key} = datetime()`); + } + }); + + return; + } + + const primitiveField = relationship.primitiveFields.find((x) => x.fieldName === key); + if (primitiveField?.autogenerate) { + strs.push(`SET ${varName}.${key} = randomUUID()`); + + return; + } + + const pointField = relationship.pointFields.find((x) => x.fieldName === key); + if (pointField) { + if (pointField.typeMeta.array) { + strs.push(`SET ${varName}.${key} = [p in $${paramName} | point(p)]`); + } else { + strs.push(`SET ${varName}.${key} = point($${paramName})`); + } + + params[paramName] = value; + + return; + } + + strs.push(`SET ${varName}.${key} = $${paramName}`); + params[paramName] = value; + }); + + return [strs.join("\n"), params]; +} + +export default createSetRelationshipPropertiesAndParams; + +/* eslint-enable prefer-destructuring */ diff --git a/packages/graphql/src/translate/create-set-relationship-properties.ts b/packages/graphql/src/translate/create-set-relationship-properties.ts new file mode 100644 index 0000000000..63341535f5 --- /dev/null +++ b/packages/graphql/src/translate/create-set-relationship-properties.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Relationship } from "../classes"; + +/* + TODO - lets reuse this function for setting either node or rel properties. + This was not reused due to the large differences between node fields + - and relationship fields. +*/ +function createSetRelationshipProperties({ + properties, + varName, + relationship, + operation, + parameterPrefix, +}: { + properties: Record; + varName: string; + relationship: Relationship; + operation: "CREATE" | "UPDATE"; + parameterPrefix: string; +}): string { + const strs: string[] = []; + + Object.entries(properties).forEach(([key]) => { + const paramName = `${parameterPrefix}.${key}`; + + const dateTimeField = relationship.dateTimeFields.find((x) => x.fieldName === key); + if (dateTimeField && dateTimeField.timestamps?.length) { + (dateTimeField.timestamps || []).forEach((ts) => { + if (ts.includes(operation)) { + strs.push(`SET ${varName}.${key} = datetime()`); + } + }); + + return; + } + + const primitiveField = relationship.primitiveFields.find((x) => x.fieldName === key); + if (primitiveField?.autogenerate) { + strs.push(`SET ${varName}.${key} = randomUUID()`); + + return; + } + + const pointField = relationship.pointFields.find((x) => x.fieldName === key); + if (pointField) { + if (pointField.typeMeta.array) { + strs.push(`SET ${varName}.${key} = [p in $${paramName} | point(p)]`); + } else { + strs.push(`SET ${varName}.${key} = point($${paramName})`); + } + + return; + } + + strs.push(`SET ${varName}.${key} = $${paramName}`); + }); + + return strs.join("\n"); +} + +export default createSetRelationshipProperties; diff --git a/packages/graphql/src/translate/create-update-and-params.test.ts b/packages/graphql/src/translate/create-update-and-params.test.ts index bac44271ef..723175f109 100644 --- a/packages/graphql/src/translate/create-update-and-params.test.ts +++ b/packages/graphql/src/translate/create-update-and-params.test.ts @@ -82,6 +82,7 @@ describe("createUpdateAndParams", () => { varName: "this", parentVar: "this", withVars: ["this"], + parameterPrefix: "this", }); expect(trimmer(result[0])).toEqual( diff --git a/packages/graphql/src/translate/create-update-and-params.ts b/packages/graphql/src/translate/create-update-and-params.ts index 4ca5fbabac..2b6c035aa7 100644 --- a/packages/graphql/src/translate/create-update-and-params.ts +++ b/packages/graphql/src/translate/create-update-and-params.ts @@ -17,16 +17,18 @@ * limitations under the License. */ -import { Node } from "../classes"; +import { Node, Relationship } from "../classes"; import { Context } from "../types"; import createConnectAndParams from "./create-connect-and-params"; import createDisconnectAndParams from "./create-disconnect-and-params"; -import createWhereAndParams from "./create-where-and-params"; import createCreateAndParams from "./create-create-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; import createDeleteAndParams from "./create-delete-and-params"; import createAuthParam from "./create-auth-param"; import createAuthAndParams from "./create-auth-and-params"; +import createSetRelationshipProperties from "./create-set-relationship-properties"; +import createConnectionWhereAndParams from "./where/create-connection-where-and-params"; +import createSetRelationshipPropertiesAndParams from "./create-set-relationship-properties-and-params"; interface Res { strs: string[]; @@ -48,6 +50,7 @@ function createUpdateAndParams({ insideDoWhen, withVars, context, + parameterPrefix, }: { parentVar: string; updateInput: any; @@ -57,6 +60,7 @@ function createUpdateAndParams({ withVars: string[]; insideDoWhen?: boolean; context: Context; + parameterPrefix: string; }): [string, any] { let hasAppliedTimeStamps = false; @@ -69,178 +73,248 @@ function createUpdateAndParams({ param = `${parentVar}_update_${key}`; } - const relationField = node.relationFields.find((x) => key.startsWith(x.fieldName)); - const pointField = node.pointFields.find((x) => key.startsWith(x.fieldName)); - let unionTypeName = ""; + const relationField = node.relationFields.find((x) => key === x.fieldName); + const pointField = node.pointFields.find((x) => key === x.fieldName); if (relationField) { - let refNode: Node; + const refNodes: Node[] = []; + + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; if (relationField.union) { - [unionTypeName] = key.split(`${relationField.fieldName}_`).join("").split("_"); - refNode = context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node; + Object.keys(value).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); } else { - refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); } const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[:${relationField.type}]`; - const updates = relationField.typeMeta.array ? value : [value]; - updates.forEach((update, index) => { - const _varName = `${varName}_${key}${index}`; + refNodes.forEach((refNode) => { + const v = relationField.union ? value[refNode.name] : value; + const updates = relationField.typeMeta.array ? v : [v]; + updates.forEach((update, index) => { + const relationshipVariable = `${varName}_${relationField.type.toLowerCase()}${index}_relationship`; + const relTypeStr = `[${relationshipVariable}:${relationField.type}]`; + const _varName = `${varName}_${key}${relationField.union ? `_${refNode.name}` : ""}${index}`; + + if (update.update) { + if (withVars) { + res.strs.push(`WITH ${withVars.join(", ")}`); + } - if (update.update) { - if (withVars) { - res.strs.push(`WITH ${withVars.join(", ")}`); - } + res.strs.push( + `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` + ); + + const whereStrs: string[] = []; + + if (update.where) { + const where = createConnectionWhereAndParams({ + whereInput: update.where, + node: refNode, + nodeVariable: _varName, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ``}.where`, + }); + const [whereClause] = where; + whereStrs.push(whereClause); + } - res.strs.push( - `OPTIONAL MATCH (${parentVar})${inStr}${relTypeStr}${outStr}(${_varName}:${refNode.name})` - ); + if (node.auth) { + const whereAuth = createAuthAndParams({ + operation: "UPDATE", + entity: refNode, + context, + where: { varName: _varName, node: refNode }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + res.params = { ...res.params, ...whereAuth[1] }; + } + } + if (whereStrs.length) { + res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); + } - const whereStrs: string[] = []; - if (update.where) { - const whereAndParams = createWhereAndParams({ - varName: _varName, - whereInput: update.where, - node: refNode, - context, - recursing: true, - }); - if (whereAndParams[0]) { - whereStrs.push(whereAndParams[0]); - res.params = { ...res.params, ...whereAndParams[1] }; + if (update.update.node) { + res.strs.push(`CALL apoc.do.when(${_varName} IS NOT NULL, ${insideDoWhen ? '\\"' : '"'}`); + + const auth = createAuthParam({ context }); + let innerApocParams = { auth }; + + const updateAndParams = createUpdateAndParams({ + context, + node: refNode, + updateInput: update.update.node, + varName: _varName, + withVars: [...withVars, _varName], + parentVar: _varName, + chainStr: `${param}${relationField.union ? `_${refNode.name}` : ""}${index}`, + insideDoWhen: true, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ``}.update.node`, + }); + res.params = { ...res.params, ...updateAndParams[1], auth }; + innerApocParams = { ...innerApocParams, ...updateAndParams[1] }; + + const updateStrs = [updateAndParams[0], "RETURN count(*)"]; + const apocArgs = `{${withVars.map((withVar) => `${withVar}:${withVar}`).join(", ")}, ${ + parameterPrefix?.split(".")[0] + }: $${parameterPrefix?.split(".")[0]}, ${_varName}:${_varName}REPLACE_ME}`; + + if (insideDoWhen) { + updateStrs.push(`\\", \\"\\", ${apocArgs})`); + } else { + updateStrs.push(`", "", ${apocArgs})`); + } + updateStrs.push("YIELD value as _"); + + const paramsString = Object.keys(innerApocParams) + .reduce((r: string[], k) => [...r, `${k}:$${k}`], []) + .join(","); + + const updateStr = updateStrs.join("\n").replace(/REPLACE_ME/g, `, ${paramsString}`); + res.strs.push(updateStr); } - } - if (node.auth) { - const whereAuth = createAuthAndParams({ - operation: "UPDATE", - entity: refNode, - context, - where: { varName: _varName, node: refNode }, - }); - if (whereAuth[0]) { - whereStrs.push(whereAuth[0]); - res.params = { ...res.params, ...whereAuth[1] }; + + if (update.update.edge) { + res.strs.push( + `CALL apoc.do.when(${relationshipVariable} IS NOT NULL, ${insideDoWhen ? '\\"' : '"'}` + ); + + const setProperties = createSetRelationshipProperties({ + properties: update.update.edge, + varName: relationshipVariable, + relationship, + operation: "UPDATE", + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }[${index}].update.edge`, + }); + + const updateStrs = [setProperties, "RETURN count(*)"]; + const apocArgs = `{${relationshipVariable}:${relationshipVariable}, ${ + parameterPrefix?.split(".")[0] + }: $${parameterPrefix?.split(".")[0]}}`; + + if (insideDoWhen) { + updateStrs.push(`\\", \\"\\", ${apocArgs})`); + } else { + updateStrs.push(`", "", ${apocArgs})`); + } + updateStrs.push(`YIELD value as ${relationshipVariable}_${key}${index}_edge`); + res.strs.push(updateStrs.join("\n")); } } - if (whereStrs.length) { - res.strs.push(`WHERE ${whereStrs.join(" AND ")}`); - } - res.strs.push(`CALL apoc.do.when(${_varName} IS NOT NULL, ${insideDoWhen ? '\\"' : '"'}`); - - const auth = createAuthParam({ context }); - let innerApocParams = { auth }; - - const updateAndParams = createUpdateAndParams({ - context, - node: refNode, - updateInput: update.update, - varName: _varName, - withVars: [...withVars, _varName], - parentVar: _varName, - chainStr: `${param}${index}`, - insideDoWhen: true, - }); - res.params = { ...res.params, ...updateAndParams[1], auth }; - innerApocParams = { ...innerApocParams, ...updateAndParams[1] }; - - const updateStrs = [updateAndParams[0], "RETURN count(*)"]; - const apocArgs = `{${withVars - .map((withVar) => `${withVar}:${withVar}`) - .join(", ")}, ${_varName}:${_varName}REPLACE_ME}`; - - if (insideDoWhen) { - updateStrs.push(`\\", \\"\\", ${apocArgs})`); - } else { - updateStrs.push(`", "", ${apocArgs})`); + if (update.disconnect) { + const disconnectAndParams = createDisconnectAndParams({ + context, + refNode, + value: update.disconnect, + varName: `${_varName}_disconnect`, + withVars, + parentVar, + relationField, + labelOverride: relationField.union ? refNode.name : "", + parentNode: node, + insideDoWhen, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.union ? `.${refNode.name}` : "" + }${relationField.typeMeta.array ? `[${index}]` : ""}.disconnect`, + }); + res.strs.push(disconnectAndParams[0]); + res.params = { ...res.params, ...disconnectAndParams[1] }; } - updateStrs.push("YIELD value as _"); - const paramsString = Object.keys(innerApocParams) - .reduce((r: string[], k) => [...r, `${k}:$${k}`], []) - .join(","); - - const updateStr = updateStrs.join("\n").replace(/REPLACE_ME/g, `, ${paramsString}`); - res.strs.push(updateStr); - } - - if (update.disconnect) { - const disconnectAndParams = createDisconnectAndParams({ - context, - refNode, - value: update.disconnect, - varName: `${_varName}_disconnect`, - withVars, - parentVar, - relationField, - labelOverride: unionTypeName, - parentNode: node, - insideDoWhen, - }); - res.strs.push(disconnectAndParams[0]); - res.params = { ...res.params, ...disconnectAndParams[1] }; - } - - if (update.connect) { - const connectAndParams = createConnectAndParams({ - context, - refNode, - value: update.connect, - varName: `${_varName}_connect`, - withVars, - parentVar, - relationField, - labelOverride: unionTypeName, - parentNode: node, - insideDoWhen, - }); - res.strs.push(connectAndParams[0]); - res.params = { ...res.params, ...connectAndParams[1] }; - } - - if (update.delete) { - const innerVarName = `${_varName}_delete`; - - const deleteAndParams = createDeleteAndParams({ - context, - node, - deleteInput: { [key]: update.delete }, - varName: innerVarName, - chainStr: innerVarName, - parentVar, - withVars, - insideDoWhen, - }); - res.strs.push(deleteAndParams[0]); - res.params = { ...res.params, ...deleteAndParams[1] }; - } - - if (update.create) { - if (withVars) { - res.strs.push(`WITH ${withVars.join(", ")}`); + if (update.connect) { + const connectAndParams = createConnectAndParams({ + context, + refNode, + value: update.connect, + varName: `${_varName}_connect`, + withVars, + parentVar, + relationField, + labelOverride: relationField.union ? refNode.name : "", + parentNode: node, + insideDoWhen, + }); + res.strs.push(connectAndParams[0]); + res.params = { ...res.params, ...connectAndParams[1] }; } - const creates = relationField.typeMeta.array ? update.create : [update.create]; - creates.forEach((create, i) => { - const innerVarName = `${_varName}_create${i}`; + if (update.delete) { + const innerVarName = `${_varName}_delete`; - const createAndParams = createCreateAndParams({ + const deleteAndParams = createDeleteAndParams({ context, - node: refNode, - input: create, + node, + deleteInput: { [key]: update.delete }, // OBJECT ENTIERS key reused twice varName: innerVarName, - withVars: [...withVars, innerVarName], + chainStr: innerVarName, + parentVar, + withVars, insideDoWhen, + parameterPrefix: `${parameterPrefix}.${key}${ + relationField.typeMeta.array ? `[${index}]` : `` + }.delete`, // its use here + recursing: true, }); - res.strs.push(createAndParams[0]); - res.params = { ...res.params, ...createAndParams[1] }; - res.strs.push(`MERGE (${parentVar})${inStr}${relTypeStr}${outStr}(${innerVarName})`); - }); - } + res.strs.push(deleteAndParams[0]); + res.params = { ...res.params, ...deleteAndParams[1] }; + } + + if (update.create) { + if (withVars) { + res.strs.push(`WITH ${withVars.join(", ")}`); + } + + const creates = relationField.typeMeta.array ? update.create : [update.create]; + creates.forEach((create, i) => { + const baseName = `${_varName}_create${i}`; + const nodeName = `${baseName}_node`; + const propertiesName = `${baseName}_relationship`; + + const createAndParams = createCreateAndParams({ + context, + node: refNode, + input: create.node, + varName: nodeName, + withVars: [...withVars, nodeName], + insideDoWhen, + }); + res.strs.push(createAndParams[0]); + res.params = { ...res.params, ...createAndParams[1] }; + res.strs.push( + `MERGE (${parentVar})${inStr}[${create.edge ? propertiesName : ""}:${ + relationField.type + }]${outStr}(${nodeName})` + ); + + if (create.edge) { + const setA = createSetRelationshipPropertiesAndParams({ + properties: create.edge, + varName: propertiesName, + relationship, + operation: "CREATE", + }); + res.strs.push(setA[0]); + res.params = { ...res.params, ...setA[1] }; + } + }); + } + }); }); return res; diff --git a/packages/graphql/src/translate/create-where-and-params.ts b/packages/graphql/src/translate/create-where-and-params.ts index 6037b2f9e1..cdb1cd8490 100644 --- a/packages/graphql/src/translate/create-where-and-params.ts +++ b/packages/graphql/src/translate/create-where-and-params.ts @@ -18,7 +18,8 @@ */ import { GraphQLWhereArg, Context } from "../types"; -import { Node } from "../classes"; +import { Node, Relationship } from "../classes"; +import createConnectionWhereAndParams from "./where/create-connection-where-and-params"; interface Res { clauses: string[]; @@ -57,6 +58,7 @@ function createWhereAndParams({ if (key.endsWith("_NOT")) { const [fieldName] = key.split("_NOT"); const relationField = node.relationFields.find((x) => fieldName === x.fieldName); + const connectionField = node.connectionFields.find((x) => fieldName === x.fieldName); const coalesceValue = [...node.primitiveFields, ...node.dateTimeFields].find( (f) => fieldName === f.fieldName @@ -100,6 +102,60 @@ function createWhereAndParams({ return res; } + if (connectionField) { + const refNode = context.neoSchema.nodes.find( + (x) => x.name === connectionField.relationship.typeMeta.name + ) as Node; + const relationship = context.neoSchema.relationships.find( + (x) => x.name === connectionField.relationshipTypeName + ) as Relationship; + + const relationshipVariable = `${param}_${connectionField.relationshipTypeName}`; + + const inStr = connectionField.relationship.direction === "IN" ? "<-" : "-"; + const outStr = connectionField.relationship.direction === "OUT" ? "->" : "-"; + + if (value === null) { + res.clauses.push( + `EXISTS((${varName})${inStr}[:${connectionField.relationship.type}]${outStr}(:${connectionField.relationship.typeMeta.name}))` + ); + return res; + } + + const collectedMap = `${param}_map`; + + let resultStr = [ + `EXISTS((${varName})${inStr}[:${connectionField.relationship.type}]${outStr}(:${connectionField.relationship.typeMeta.name}))`, + `AND NONE(${collectedMap} IN [(${varName})${inStr}[${relationshipVariable}:${connectionField.relationship.type}]${outStr}(${param}:${connectionField.relationship.typeMeta.name})`, + ` | { node: ${param}, relationship: ${relationshipVariable} } ] INNER_WHERE `, + ].join(" "); + + const connectionWhere = createConnectionWhereAndParams({ + whereInput: value, + context, + node: refNode, + nodeVariable: `${collectedMap}.node`, + relationship, + relationshipVariable: `${collectedMap}.relationship`, + parameterPrefix: `${varName}_${context.resolveTree.name}.where.${key}`, + }); + + resultStr += connectionWhere[0]; + resultStr += ")"; // close ALL + res.clauses.push(resultStr); + res.params = { + ...res.params, + ...(recursing + ? { + [`${varName}_${context.resolveTree.name}`]: { + where: { [`${connectionField.fieldName}_NOT`]: connectionWhere[1] }, + }, + } + : { [`${varName}_${context.resolveTree.name}`]: context.resolveTree.args }), + }; + return res; + } + if (value === null) { res.clauses.push(`${varName}.${fieldName} IS NOT NULL`); return res; @@ -121,7 +177,6 @@ function createWhereAndParams({ if (key.endsWith("_NOT_IN")) { const [fieldName] = key.split("_NOT_IN"); - const relationField = node.relationFields.find((x) => fieldName === x.fieldName); const coalesceValue = [...node.primitiveFields, ...node.dateTimeFields].find( (f) => fieldName === f.fieldName @@ -131,38 +186,7 @@ function createWhereAndParams({ ? `coalesce(${varName}.${fieldName}, ${coalesceValue})` : `${varName}.${fieldName}`; - if (relationField) { - const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; - const inStr = relationField.direction === "IN" ? "<-" : "-"; - const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[:${relationField.type}]`; - - let resultStr = [ - `EXISTS((${varName})${inStr}${relTypeStr}${outStr}(:${relationField.typeMeta.name}))`, - `AND ALL(${param} IN [(${varName})${inStr}${relTypeStr}${outStr}(${param}:${relationField.typeMeta.name}) | ${param}] INNER_WHERE NOT(`, - ].join(" "); - - const inner: string[] = []; - - (value as any[]).forEach((v, i) => { - const recurse = createWhereAndParams({ - whereInput: v, - varName: param, - chainStr: `${param}${i}`, - node: refNode, - context, - recursing: true, - }); - - inner.push(recurse[0]); - res.params = { ...res.params, ...recurse[1] }; - }); - - resultStr += inner.join(" OR "); - resultStr += ")"; // close NOT - resultStr += ")"; // close ALL - res.clauses.push(resultStr); - } else if (pointField) { + if (pointField) { res.clauses.push(`(NOT ${varName}.${fieldName} IN [p in $${param} | point(p)])`); res.params[param] = value; } else { @@ -175,7 +199,6 @@ function createWhereAndParams({ if (key.endsWith("_IN")) { const [fieldName] = key.split("_IN"); - const relationField = node.relationFields.find((x) => fieldName === x.fieldName); const coalesceValue = [...node.primitiveFields, ...node.dateTimeFields].find( (f) => fieldName === f.fieldName @@ -185,37 +208,7 @@ function createWhereAndParams({ ? `coalesce(${varName}.${fieldName}, ${coalesceValue})` : `${varName}.${fieldName}`; - if (relationField) { - const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; - const inStr = relationField.direction === "IN" ? "<-" : "-"; - const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[:${relationField.type}]`; - - let resultStr = [ - `EXISTS((${varName})${inStr}${relTypeStr}${outStr}(:${relationField.typeMeta.name}))`, - `AND ALL(${param} IN [(${varName})${inStr}${relTypeStr}${outStr}(${param}:${relationField.typeMeta.name}) | ${param}] INNER_WHERE `, - ].join(" "); - - const inner: string[] = []; - - (value as any[]).forEach((v, i) => { - const recurse = createWhereAndParams({ - whereInput: v, - varName: param, - chainStr: `${param}${i}`, - node: refNode, - context, - recursing: true, - }); - - inner.push(recurse[0]); - res.params = { ...res.params, ...recurse[1] }; - }); - - resultStr += inner.join(" OR "); - resultStr += ")"; // close ALL - res.clauses.push(resultStr); - } else if (pointField) { + if (pointField) { res.clauses.push(`${varName}.${fieldName} IN [p in $${param} | point(p)]`); res.params[param] = value; } else { @@ -305,6 +298,61 @@ function createWhereAndParams({ return res; } + const equalityConnection = node.connectionFields?.find((x) => key === x.fieldName); + if (equalityConnection) { + const refNode = context.neoSchema.nodes.find( + (x) => x.name === equalityConnection.relationship.typeMeta.name + ) as Node; + const relationship = context.neoSchema.relationships.find( + (x) => x.name === equalityConnection.relationshipTypeName + ) as Relationship; + + const relationshipVariable = `${param}_${equalityConnection.relationshipTypeName}`; + + const inStr = equalityConnection.relationship.direction === "IN" ? "<-" : "-"; + const outStr = equalityConnection.relationship.direction === "OUT" ? "->" : "-"; + + if (value === null) { + res.clauses.push( + `NOT EXISTS((${varName})${inStr}[:${equalityConnection.relationship.type}]${outStr}(:${equalityConnection.typeMeta.name}))` + ); + return res; + } + + const collectedMap = `${param}_map`; + + let resultStr = [ + `EXISTS((${varName})${inStr}[:${equalityConnection.relationship.type}]${outStr}(:${equalityConnection.relationship.typeMeta.name}))`, + `AND ANY(${collectedMap} IN [(${varName})${inStr}[${relationshipVariable}:${equalityConnection.relationship.type}]${outStr}(${param}:${equalityConnection.relationship.typeMeta.name})`, + ` | { node: ${param}, relationship: ${relationshipVariable} } ] INNER_WHERE `, + ].join(" "); + + const connectionWhere = createConnectionWhereAndParams({ + whereInput: value, + context, + node: refNode, + nodeVariable: `${collectedMap}.node`, + relationship, + relationshipVariable: `${collectedMap}.relationship`, + parameterPrefix: `${varName}_${context.resolveTree.name}.where.${key}`, + }); + + resultStr += connectionWhere[0]; + resultStr += ")"; // close ALL + res.clauses.push(resultStr); + res.params = { + ...res.params, + ...(recursing + ? { + [`${varName}_${context.resolveTree.name}`]: { + where: { [equalityConnection.fieldName]: connectionWhere[1] }, + }, + } + : { [`${varName}_${context.resolveTree.name}`]: context.resolveTree.args }), + }; + return res; + } + if (key.endsWith("_MATCHES")) { const [fieldName] = key.split("_MATCHES"); diff --git a/packages/graphql/src/translate/index.ts b/packages/graphql/src/translate/index.ts index 1eeb556c9d..c12e91f4d8 100644 --- a/packages/graphql/src/translate/index.ts +++ b/packages/graphql/src/translate/index.ts @@ -21,3 +21,4 @@ export { default as translateCreate } from "./translate-create"; export { default as translateRead } from "./translate-read"; export { default as translateUpdate } from "./translate-update"; export { default as translateDelete } from "./translate-delete"; +export { default as translateCount } from "./translate-count"; diff --git a/packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts b/packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts new file mode 100644 index 0000000000..be1dbd669b --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-datetime-element.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResolveTree } from "graphql-parse-resolve-info"; +import { DateTimeField } from "../../../types"; +import createDatetimeElement from "./create-datetime-element"; + +describe("createDatetimeElement", () => { + test("returns projection element for single datetime value", () => { + const resolveTree: ResolveTree = { + name: "datetime", + alias: "datetime", + args: {}, + fieldsByTypeName: {}, + }; + + const field: DateTimeField = { + // @ts-ignore + typeMeta: { + name: "Point", + }, + }; + + const element = createDatetimeElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual( + 'datetime: apoc.date.convertFormat(toString(this.datetime), "iso_zoned_date_time", "iso_offset_date_time")' + ); + }); + + test("returns projection element for array of datetime values", () => { + const resolveTree: ResolveTree = { + name: "datetimes", + alias: "datetimes", + args: {}, + fieldsByTypeName: {}, + }; + + const field: DateTimeField = { + // @ts-ignore + typeMeta: { + name: "Point", + array: true, + }, + }; + + const element = createDatetimeElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual( + 'datetimes: [ dt in this.datetimes | apoc.date.convertFormat(toString(dt), "iso_zoned_date_time", "iso_offset_date_time") ]' + ); + }); +}); diff --git a/packages/graphql/src/translate/projection/elements/create-datetime-element.ts b/packages/graphql/src/translate/projection/elements/create-datetime-element.ts new file mode 100644 index 0000000000..20e5facb6d --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-datetime-element.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResolveTree } from "graphql-parse-resolve-info"; +import { DateTimeField } from "../../../types"; + +function createDatetimeElement({ + resolveTree, + field, + variable, +}: { + resolveTree: ResolveTree; + field: DateTimeField; + variable: string; +}): string { + return field.typeMeta.array + ? `${resolveTree.alias}: [ dt in ${variable}.${resolveTree.name} | apoc.date.convertFormat(toString(dt), "iso_zoned_date_time", "iso_offset_date_time") ]` + : `${resolveTree.alias}: apoc.date.convertFormat(toString(${variable}.${resolveTree.name}), "iso_zoned_date_time", "iso_offset_date_time")`; +} + +export default createDatetimeElement; diff --git a/packages/graphql/src/translate/projection/elements/create-point-element.test.ts b/packages/graphql/src/translate/projection/elements/create-point-element.test.ts new file mode 100644 index 0000000000..3a0116f4fc --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-point-element.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResolveTree } from "graphql-parse-resolve-info"; +import { PointField } from "../../../types"; +import createPointElement from "./create-point-element"; + +describe("createPointElement", () => { + test("returns projection element for single point value", () => { + const resolveTree: ResolveTree = { + name: "point", + alias: "point", + args: {}, + fieldsByTypeName: { + Point: { + crs: { + alias: "crs", + name: "crs", + args: {}, + fieldsByTypeName: {}, + }, + point: { + alias: "point", + name: "point", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }; + + const field: PointField = { + // @ts-ignore + typeMeta: { + name: "Point", + }, + }; + + const element = createPointElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual("point: { point: this.point, crs: this.point.crs }"); + }); + + test("returns projection element for array of point values", () => { + const resolveTree: ResolveTree = { + name: "points", + alias: "points", + args: {}, + fieldsByTypeName: { + Point: { + crs: { + alias: "crs", + name: "crs", + args: {}, + fieldsByTypeName: {}, + }, + point: { + alias: "point", + name: "point", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }; + + const field: PointField = { + // @ts-ignore + typeMeta: { + name: "Point", + array: true, + }, + }; + + const element = createPointElement({ + resolveTree, + field, + variable: "this", + }); + + expect(element).toEqual("points: [p in this.points | { point:p, crs: p.crs }]"); + }); +}); diff --git a/packages/graphql/src/translate/projection/elements/create-point-element.ts b/packages/graphql/src/translate/projection/elements/create-point-element.ts new file mode 100644 index 0000000000..3bfe861de9 --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-point-element.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResolveTree } from "graphql-parse-resolve-info"; +import { PointField } from "../../../types"; + +function createPointElement({ + resolveTree, + field, + variable, +}: { + resolveTree: ResolveTree; + field: PointField; + variable: string; +}): string { + const isArray = field.typeMeta.array; + + const { crs, ...point } = resolveTree.fieldsByTypeName[field.typeMeta.name]; + const fields: string[] = []; + + // Sadly need to select the whole point object due to the risk of height/z + // being selected on a 2D point, to which the database will throw an error + if (point) { + fields.push(isArray ? "point:p" : `point: ${variable}.${resolveTree.name}`); + } + + if (crs) { + fields.push(isArray ? "crs: p.crs" : `crs: ${variable}.${resolveTree.name}.crs`); + } + + return isArray + ? `${resolveTree.alias}: [p in ${variable}.${resolveTree.name} | { ${fields.join(", ")} }]` + : `${resolveTree.alias}: { ${fields.join(", ")} }`; +} + +export default createPointElement; diff --git a/packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts new file mode 100644 index 0000000000..2f5659e74e --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResolveTree } from "graphql-parse-resolve-info"; +import Relationship from "../../../classes/Relationship"; +import { DateTimeField, PointField, PrimitiveField } from "../../../types"; +import createRelationshipPropertyElement from "./create-relationship-property-element"; + +describe("createRelationshipPropertyElement", () => { + let relationship: Relationship; + + beforeAll(() => { + relationship = new Relationship({ + name: "TestRelationship", + type: "TEST_RELATIONSHIP", + primitiveFields: [ + { + fieldName: "int", + typeMeta: { + name: "Int", + array: false, + required: true, + pretty: "Int!", + arrayTypePretty: "", + input: { + create: { + type: "Int", + pretty: "Int!", + }, + update: { + type: "Int", + pretty: "Int", + }, + where: { + type: "Int", + pretty: "Int", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + } as PrimitiveField, + ], + pointFields: [ + { + fieldName: "point", + typeMeta: { + name: "Point", + array: false, + required: true, + pretty: "Point!", + arrayTypePretty: "", + input: { + create: { + type: "Point", + pretty: "PointInput!", + }, + update: { + type: "Point", + pretty: "PointInput", + }, + where: { + type: "PointInput", + pretty: "PointInput", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + } as PointField, + ], + dateTimeFields: [ + { + fieldName: "datetime", + typeMeta: { + name: "DateTime", + array: false, + required: true, + pretty: "DateTime!", + arrayTypePretty: "", + input: { + create: { + type: "DateTime", + pretty: "DateTime!", + }, + update: { + type: "DateTime", + pretty: "DateTime", + }, + where: { + type: "DateTime", + pretty: "DateTime", + }, + }, + }, + otherDirectives: [], + arguments: [], + description: undefined, + readonly: false, + writeonly: false, + } as DateTimeField, + ], + }); + }); + + test("returns an element for a primitive property", () => { + const resolveTree: ResolveTree = { + alias: "int", + name: "int", + args: {}, + fieldsByTypeName: {}, + }; + + const element = createRelationshipPropertyElement({ resolveTree, relationship, relationshipVariable: "this" }); + + expect(element).toEqual("int: this.int"); + }); + + test("returns an element for a datetime property", () => { + const resolveTree: ResolveTree = { + alias: "datetime", + name: "datetime", + args: {}, + fieldsByTypeName: {}, + }; + + const element = createRelationshipPropertyElement({ resolveTree, relationship, relationshipVariable: "this" }); + + expect(element).toEqual( + 'datetime: apoc.date.convertFormat(toString(this.datetime), "iso_zoned_date_time", "iso_offset_date_time")' + ); + }); + + test("returns an element for a point property", () => { + const resolveTree: ResolveTree = { + name: "point", + alias: "point", + args: {}, + fieldsByTypeName: { + Point: { + crs: { + alias: "crs", + name: "crs", + args: {}, + fieldsByTypeName: {}, + }, + point: { + alias: "point", + name: "point", + args: {}, + fieldsByTypeName: {}, + }, + }, + }, + }; + + const element = createRelationshipPropertyElement({ resolveTree, relationship, relationshipVariable: "this" }); + + expect(element).toEqual("point: { point: this.point, crs: this.point.crs }"); + }); +}); diff --git a/packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts new file mode 100644 index 0000000000..4f2aed37bc --- /dev/null +++ b/packages/graphql/src/translate/projection/elements/create-relationship-property-element.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ResolveTree } from "graphql-parse-resolve-info"; +import Relationship from "../../../classes/Relationship"; +import createDatetimeElement from "./create-datetime-element"; +import createPointElement from "./create-point-element"; + +function createRelationshipPropertyElement({ + resolveTree, + relationship, + relationshipVariable, +}: { + resolveTree: ResolveTree; + relationship: Relationship; + relationshipVariable: string; +}): string { + const datetimeField = relationship.dateTimeFields.find((f) => f.fieldName === resolveTree.name); + const pointField = relationship.pointFields.find((f) => f.fieldName === resolveTree.name); + + if (datetimeField) { + return createDatetimeElement({ resolveTree, field: datetimeField, variable: relationshipVariable }); + } + + if (pointField) { + return createPointElement({ resolveTree, field: pointField, variable: relationshipVariable }); + } + + return `${resolveTree.alias}: ${relationshipVariable}.${resolveTree.name}`; +} + +export default createRelationshipPropertyElement; diff --git a/packages/graphql/src/translate/translate-count.ts b/packages/graphql/src/translate/translate-count.ts new file mode 100644 index 0000000000..813137ad8b --- /dev/null +++ b/packages/graphql/src/translate/translate-count.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Node } from "../classes"; +import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import { Context, GraphQLWhereArg } from "../types"; +import createAuthAndParams from "./create-auth-and-params"; +import createWhereAndParams from "./create-where-and-params"; + +function translateCount({ node, context }: { node: Node; context: Context }): [string, any] { + const whereInput = context.resolveTree.args.where as GraphQLWhereArg; + const varName = "this"; + let cypherParams: { [k: string]: any } = {}; + const whereStrs: string[] = []; + const cypherStrs: string[] = []; + + cypherStrs.push(`MATCH (${varName}:${node.name})`); + + if (whereInput) { + const where = createWhereAndParams({ + whereInput, + varName, + node, + context, + recursing: true, + }); + if (where[0]) { + whereStrs.push(where[0]); + cypherParams = { ...cypherParams, ...where[1] }; + } + } + + const whereAuth = createAuthAndParams({ + operation: "READ", + entity: node, + context, + where: { varName, node }, + }); + if (whereAuth[0]) { + whereStrs.push(whereAuth[0]); + cypherParams = { ...cypherParams, ...whereAuth[1] }; + } + + const allowAuth = createAuthAndParams({ + operation: "READ", + entity: node, + context, + allow: { + parentNode: node, + varName, + }, + }); + if (allowAuth[0]) { + cypherParams = { ...cypherParams, ...allowAuth[1] }; + cypherStrs.push(`CALL apoc.util.validate(NOT(${allowAuth[0]}), "${AUTH_FORBIDDEN_ERROR}", [0])`); + } + + if (whereStrs.length) { + cypherStrs.push(`WHERE ${whereStrs.join(" AND ")}`); + } + + cypherStrs.push(`RETURN count(${varName})`); + + return [cypherStrs.filter(Boolean).join("\n"), cypherParams]; +} + +export default translateCount; diff --git a/packages/graphql/src/translate/translate-create.ts b/packages/graphql/src/translate/translate-create.ts index b71c26bed6..2aca13503d 100644 --- a/packages/graphql/src/translate/translate-create.ts +++ b/packages/graphql/src/translate/translate-create.ts @@ -22,10 +22,14 @@ import pluralize from "pluralize"; import { Node } from "../classes"; import createProjectionAndParams from "./create-projection-and-params"; import createCreateAndParams from "./create-create-and-params"; -import { Context } from "../types"; +import { Context, ConnectionField } from "../types"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createConnectionAndParams from "./connection/create-connection-and-params"; function translateCreate({ context, node }: { context: Context; node: Node }): [string, any] { + const connectionStrs: string[] = []; + let connectionParams: any; + const { resolveTree } = context; // Due to potential aliasing of returned object in response we look through fields of CreateMutationResponse @@ -81,6 +85,44 @@ function translateCreate({ context, node }: { context: Context; node: Node }): [ return { ...res, [key.replace("REPLACE_ME", "projection")]: value }; }, {}); + if (projection[2]?.connectionFields?.length) { + projection[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = node.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const connection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: "REPLACE_ME", + }); + connectionStrs.push(connection[0]); + if (!connectionParams) connectionParams = {}; + connectionParams = { ...connectionParams, ...connection[1] }; + }); + } + + const replacedConnectionStrs = connectionStrs.length + ? createStrs.map((_, i) => { + return connectionStrs + .map((connectionStr) => { + return connectionStr.replace(/REPLACE_ME/g, `this${i}`); + }) + .join("\n"); + }) + : []; + + const replacedConnectionParams = connectionParams + ? createStrs.reduce((res1, _, i) => { + return { + ...res1, + ...Object.entries(connectionParams).reduce((res2, [key, value]) => { + return { ...res2, [key.replace("REPLACE_ME", `this${i}`)]: value }; + }, {}), + }; + }, {}) + : {}; + const projectionStr = createStrs .map( (_, i) => @@ -94,9 +136,9 @@ function translateCreate({ context, node }: { context: Context; node: Node }): [ .map((_, i) => projAuth.replace(/\$REPLACE_ME/g, "$projection").replace(/REPLACE_ME/g, `this${i}`)) .join("\n"); - const cypher = [`${createStrs.join("\n")}`, authCalls, `\nRETURN ${projectionStr}`]; + const cypher = [`${createStrs.join("\n")}`, authCalls, ...replacedConnectionStrs, `\nRETURN ${projectionStr}`]; - return [cypher.filter(Boolean).join("\n"), { ...params, ...replacedProjectionParams }]; + return [cypher.filter(Boolean).join("\n"), { ...params, ...replacedProjectionParams, ...replacedConnectionParams }]; } export default translateCreate; diff --git a/packages/graphql/src/translate/translate-delete.ts b/packages/graphql/src/translate/translate-delete.ts index 6bd95c3482..961eb0e4eb 100644 --- a/packages/graphql/src/translate/translate-delete.ts +++ b/packages/graphql/src/translate/translate-delete.ts @@ -87,9 +87,16 @@ function translateDelete({ context, node }: { context: Context; node: Node }): [ varName, parentVar: varName, withVars: [varName], + parameterPrefix: `${varName}_${resolveTree.name}.args.delete`, }); [deleteStr] = deleteAndParams; - cypherParams = { ...cypherParams, ...deleteAndParams[1] }; + cypherParams = { + ...cypherParams, + ...(deleteStr.includes(resolveTree.name) + ? { [`${varName}_${resolveTree.name}`]: { args: { delete: deleteInput } } } + : {}), + ...deleteAndParams[1], + }; } const cypher = [matchStr, whereStr, deleteStr, allowStr, `DETACH DELETE ${varName}`]; diff --git a/packages/graphql/src/translate/translate-read.ts b/packages/graphql/src/translate/translate-read.ts index 7440a651db..c9fe86fba9 100644 --- a/packages/graphql/src/translate/translate-read.ts +++ b/packages/graphql/src/translate/translate-read.ts @@ -20,9 +20,10 @@ import { Node } from "../classes"; import createWhereAndParams from "./create-where-and-params"; import createProjectionAndParams from "./create-projection-and-params"; -import { GraphQLWhereArg, GraphQLOptionsArg, GraphQLSortArg, Context } from "../types"; +import { GraphQLWhereArg, GraphQLOptionsArg, GraphQLSortArg, Context, ConnectionField } from "../types"; import createAuthAndParams from "./create-auth-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; +import createConnectionAndParams from "./connection/create-connection-and-params"; function translateRead({ node, context }: { context: Context; node: Node }): [string, any] { const { resolveTree } = context; @@ -34,13 +35,14 @@ function translateRead({ node, context }: { context: Context; node: Node }): [st const matchStr = `MATCH (${varName}:${node.name})`; let whereStr = ""; let authStr = ""; - let skipStr = ""; + let offsetStr = ""; let limitStr = ""; let sortStr = ""; let projAuth = ""; let projStr = ""; let cypherParams: { [k: string]: any } = {}; const whereStrs: string[] = []; + const connectionStrs: string[] = []; const projection = createProjectionAndParams({ node, @@ -56,6 +58,22 @@ function translateRead({ node, context }: { context: Context; node: Node }): [st )}), "${AUTH_FORBIDDEN_ERROR}", [0])`; } + if (projection[2]?.connectionFields?.length) { + projection[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = node.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const connection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: varName, + }); + connectionStrs.push(connection[0]); + cypherParams = { ...cypherParams, ...connection[1] }; + }); + } + if (whereInput) { const where = createWhereAndParams({ whereInput, @@ -100,12 +118,12 @@ function translateRead({ node, context }: { context: Context; node: Node }): [st } if (optionsInput) { - const hasSkip = Boolean(optionsInput.skip) || optionsInput.skip === 0; + const hasOffset = Boolean(optionsInput.offset) || optionsInput.offset === 0; const hasLimit = Boolean(optionsInput.limit) || optionsInput.limit === 0; - if (hasSkip) { - skipStr = `SKIP $${varName}_skip`; - cypherParams[`${varName}_skip`] = optionsInput.skip; + if (hasOffset) { + offsetStr = `SKIP $${varName}_offset`; + cypherParams[`${varName}_offset`] = optionsInput.offset; } if (hasLimit) { @@ -133,8 +151,9 @@ function translateRead({ node, context }: { context: Context; node: Node }): [st authStr, ...(sortStr ? [`WITH ${varName}`, sortStr] : []), ...(projAuth ? [`WITH ${varName}`, projAuth] : []), + ...connectionStrs, `RETURN ${varName} ${projStr} as ${varName}`, - skipStr, + offsetStr, limitStr, ]; diff --git a/packages/graphql/src/translate/translate-update.ts b/packages/graphql/src/translate/translate-update.ts index dbd009ef9d..d45a68aa53 100644 --- a/packages/graphql/src/translate/translate-update.ts +++ b/packages/graphql/src/translate/translate-update.ts @@ -19,8 +19,8 @@ import camelCase from "camelcase"; import pluralize from "pluralize"; -import { Node } from "../classes"; -import { Context, GraphQLWhereArg, RelationField } from "../types"; +import { Node, Relationship } from "../classes"; +import { Context, GraphQLWhereArg, RelationField, ConnectionField } from "../types"; import createWhereAndParams from "./create-where-and-params"; import createProjectionAndParams from "./create-projection-and-params"; import createCreateAndParams from "./create-create-and-params"; @@ -30,6 +30,8 @@ import createConnectAndParams from "./create-connect-and-params"; import createDisconnectAndParams from "./create-disconnect-and-params"; import { AUTH_FORBIDDEN_ERROR } from "../constants"; import createDeleteAndParams from "./create-delete-and-params"; +import createConnectionAndParams from "./connection/create-connection-and-params"; +import createSetRelationshipPropertiesAndParams from "./create-set-relationship-properties-and-params"; function translateUpdate({ node, context }: { node: Node; context: Context }): [string, any] { const { resolveTree } = context; @@ -51,6 +53,8 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ let projStr = ""; let cypherParams: { [k: string]: any } = {}; const whereStrs: string[] = []; + const connectionStrs: string[] = []; + let updateArgs = {}; // Due to potential aliasing of returned object in response we look through fields of UpdateMutationResponse // and find field where field.name ~ node.name which exists by construction @@ -96,73 +100,142 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ varName, parentVar: varName, withVars: [varName], + parameterPrefix: `${resolveTree.name}.args.update`, }); [updateStr] = updateAndParams; - cypherParams = { ...cypherParams, ...updateAndParams[1] }; + cypherParams = { + ...cypherParams, + ...updateAndParams[1], + }; + updateArgs = { + ...updateArgs, + ...(updateStr.includes(resolveTree.name) ? { update: updateInput } : {}), + }; } if (disconnectInput) { Object.entries(disconnectInput).forEach((entry) => { const relationField = node.relationFields.find((x) => x.fieldName === entry[0]) as RelationField; - const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + const refNodes: Node[] = []; - const disconnectAndParams = createDisconnectAndParams({ - context, - parentVar: varName, - refNode, - relationField, - value: entry[1], - varName: `${varName}_disconnect_${entry[0]}`, - withVars: [varName], - parentNode: node, + if (relationField.union) { + Object.keys(entry[1]).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); + } else { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); + } + refNodes.forEach((refNode) => { + const disconnectAndParams = createDisconnectAndParams({ + context, + parentVar: varName, + refNode, + relationField, + value: relationField.union ? entry[1][refNode.name] : entry[1], + varName: `${varName}_disconnect_${entry[0]}${relationField.union ? `_${refNode.name}` : ""}`, + withVars: [varName], + parentNode: node, + parameterPrefix: `${resolveTree.name}.args.disconnect.${entry[0]}${ + relationField.union ? `.${refNode.name}` : "" + }`, + labelOverride: relationField.union ? refNode.name : "", + }); + disconnectStrs.push(disconnectAndParams[0]); + cypherParams = { ...cypherParams, ...disconnectAndParams[1] }; }); - disconnectStrs.push(disconnectAndParams[0]); - cypherParams = { ...cypherParams, ...disconnectAndParams[1] }; }); + + updateArgs = { + ...updateArgs, + disconnect: disconnectInput, + }; } if (connectInput) { Object.entries(connectInput).forEach((entry) => { - const relationField = node.relationFields.find((x) => x.fieldName === entry[0]) as RelationField; - const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + const relationField = node.relationFields.find((x) => entry[0] === x.fieldName) as RelationField; - const connectAndParams = createConnectAndParams({ - context, - parentVar: varName, - refNode, - relationField, - value: entry[1], - varName: `${varName}_connect_${entry[0]}`, - withVars: [varName], - parentNode: node, + const refNodes: Node[] = []; + + if (relationField.union) { + Object.keys(entry[1]).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); + } else { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); + } + refNodes.forEach((refNode) => { + const connectAndParams = createConnectAndParams({ + context, + parentVar: varName, + refNode, + relationField, + value: relationField.union ? entry[1][refNode.name] : entry[1], + varName: `${varName}_connect_${entry[0]}${relationField.union ? `_${refNode.name}` : ""}`, + withVars: [varName], + parentNode: node, + labelOverride: relationField.union ? refNode.name : "", + }); + connectStrs.push(connectAndParams[0]); + cypherParams = { ...cypherParams, ...connectAndParams[1] }; }); - connectStrs.push(connectAndParams[0]); - cypherParams = { ...cypherParams, ...connectAndParams[1] }; }); } if (createInput) { Object.entries(createInput).forEach((entry) => { - const relationField = node.relationFields.find((x) => x.fieldName === entry[0]) as RelationField; - const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + const relationField = node.relationFields.find((x) => entry[0] === x.fieldName) as RelationField; + + const refNodes: Node[] = []; + + if (relationField.union) { + Object.keys(entry[1]).forEach((unionTypeName) => { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === unionTypeName) as Node); + }); + } else { + refNodes.push(context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node); + } + const inStr = relationField.direction === "IN" ? "<-" : "-"; const outStr = relationField.direction === "OUT" ? "->" : "-"; - const relTypeStr = `[:${relationField.type}]`; - const creates = relationField.typeMeta.array ? entry[1] : [entry[1]]; - creates.forEach((create, index) => { - const innerVarName = `${varName}_create_${entry[0]}${index}`; + refNodes.forEach((refNode) => { + const v = relationField.union ? entry[1][refNode.name] : entry[1]; + const creates = relationField.typeMeta.array ? v : [v]; + creates.forEach((create, index) => { + const baseName = `${varName}_create_${entry[0]}${ + relationField.union ? `_${refNode.name}` : "" + }${index}`; + const nodeName = `${baseName}_node`; + const propertiesName = `${baseName}_relationship`; + const relTypeStr = `[${create.edge ? propertiesName : ""}:${relationField.type}]`; - const createAndParams = createCreateAndParams({ - context, - node: refNode, - input: create, - varName: innerVarName, - withVars: [varName, innerVarName], + const createAndParams = createCreateAndParams({ + context, + node: refNode, + input: create.node, + varName: nodeName, + withVars: [varName, nodeName], + }); + createStrs.push(createAndParams[0]); + cypherParams = { ...cypherParams, ...createAndParams[1] }; + createStrs.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${nodeName})`); + + if (create.edge) { + const relationship = (context.neoSchema.relationships.find( + (x) => x.properties === relationField.properties + ) as unknown) as Relationship; + + const setA = createSetRelationshipPropertiesAndParams({ + properties: create.edge, + varName: propertiesName, + relationship, + operation: "CREATE", + }); + createStrs.push(setA[0]); + cypherParams = { ...cypherParams, ...setA[1] }; + } }); - createStrs.push(createAndParams[0]); - cypherParams = { ...cypherParams, ...createAndParams[1] }; - createStrs.push(`MERGE (${varName})${inStr}${relTypeStr}${outStr}(${innerVarName})`); }); }); } @@ -175,9 +248,17 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ varName: `${varName}_delete`, parentVar: varName, withVars: [varName], + parameterPrefix: `${resolveTree.name}.args.delete`, }); [deleteStr] = deleteAndParams; - cypherParams = { ...cypherParams, ...deleteAndParams[1] }; + cypherParams = { + ...cypherParams, + ...deleteAndParams[1], + }; + updateArgs = { + ...updateArgs, + ...(deleteStr.includes(resolveTree.name) ? { delete: deleteInput } : {}), + }; } const projection = createProjectionAndParams({ @@ -194,6 +275,22 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ )}), "${AUTH_FORBIDDEN_ERROR}", [0])`; } + if (projection[2]?.connectionFields?.length) { + projection[2].connectionFields.forEach((connectionResolveTree) => { + const connectionField = node.connectionFields.find( + (x) => x.fieldName === connectionResolveTree.name + ) as ConnectionField; + const connection = createConnectionAndParams({ + resolveTree: connectionResolveTree, + field: connectionField, + context, + nodeVariable: varName, + }); + connectionStrs.push(connection[0]); + cypherParams = { ...cypherParams, ...connection[1] }; + }); + } + const cypher = [ matchStr, whereStr, @@ -202,11 +299,16 @@ function translateUpdate({ node, context }: { node: Node; context: Context }): [ disconnectStrs.join("\n"), createStrs.join("\n"), deleteStr, - ...(projAuth ? [`WITH ${varName}`, projAuth] : []), + ...(connectionStrs.length || projAuth ? [`WITH ${varName}`] : []), // When FOREACH is the last line of update 'Neo4jError: WITH is required between FOREACH and CALL' + ...(projAuth ? [projAuth] : []), + ...connectionStrs, `RETURN ${varName} ${projStr} AS ${varName}`, ]; - return [cypher.filter(Boolean).join("\n"), cypherParams]; + return [ + cypher.filter(Boolean).join("\n"), + { ...cypherParams, ...(Object.keys(updateArgs).length ? { [resolveTree.name]: { args: updateArgs } } : {}) }, + ]; } export default translateUpdate; diff --git a/packages/graphql/src/translate/where/create-connection-where-and-params.ts b/packages/graphql/src/translate/where/create-connection-where-and-params.ts new file mode 100644 index 0000000000..a9286df309 --- /dev/null +++ b/packages/graphql/src/translate/where/create-connection-where-and-params.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Node, Relationship } from "../../classes"; +import { ConnectionWhereArg, Context } from "../../types"; +import createRelationshipWhereAndParams from "./create-relationship-where-and-params"; +import createNodeWhereAndParams from "./create-node-where-and-params"; + +function createConnectionWhereAndParams({ + whereInput, + context, + node, + nodeVariable, + relationship, + relationshipVariable, + parameterPrefix, +}: { + whereInput: ConnectionWhereArg; + context: Context; + node: Node; + nodeVariable: string; + relationship: Relationship; + relationshipVariable: string; + parameterPrefix: string; +}): [string, any] { + const reduced = Object.entries(whereInput).reduce<{ whereStrs: string[]; params: any }>( + (res, [k, v]) => { + if (["AND", "OR"].includes(k)) { + const innerClauses: string[] = []; + const innerParams: any[] = []; + + v.forEach((o, i) => { + const or = createConnectionWhereAndParams({ + whereInput: o, + node, + nodeVariable, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.${k}[${i}]`, + }); + + innerClauses.push(`${or[0]}`); + innerParams.push(or[1]); + }); + + const whereStrs = [...res.whereStrs, `(${innerClauses.filter((clause) => !!clause).join(` ${k} `)})`]; + const params = { ...res.params, [k]: innerParams }; + res = { whereStrs, params }; + return res; + } + + if (k.startsWith("edge")) { + const relationshipWhere = createRelationshipWhereAndParams({ + whereInput: v, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.${k}`, + }); + + const whereStrs = [ + ...res.whereStrs, + k === "edge_NOT" ? `(NOT ${relationshipWhere[0]})` : relationshipWhere[0], + ]; + const params = { ...res.params, [k]: relationshipWhere[1] }; + res = { whereStrs, params }; + return res; + } + + if (k.startsWith("node") || k.startsWith(node.name)) { + const nodeWhere = createNodeWhereAndParams({ + whereInput: v, + node, + nodeVariable, + context, + parameterPrefix: `${parameterPrefix}.${k}`, + }); + + const whereStrs = [...res.whereStrs, k.endsWith("_NOT") ? `(NOT ${nodeWhere[0]})` : nodeWhere[0]]; + const params = { ...res.params, [k]: nodeWhere[1] }; + res = { whereStrs, params }; + } + return res; + }, + { whereStrs: [], params: {} } + ); + + return [reduced.whereStrs.join(" AND "), reduced.params]; +} + +export default createConnectionWhereAndParams; diff --git a/packages/graphql/src/translate/where/create-filter.test.ts b/packages/graphql/src/translate/where/create-filter.test.ts new file mode 100644 index 0000000000..ea57776122 --- /dev/null +++ b/packages/graphql/src/translate/where/create-filter.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import createFilter, { Operator } from "./create-filter"; + +describe("createFilter", () => { + const left = "left"; + const right = "right"; + const notFilters = ["INCLUDES", "IN", "CONTAINS", "STARTS_WITH", "ENDS_WITH"]; + + const validFilters = [ + ...Object.entries(Operator).map(([k, v]) => ({ + input: { + left, + operator: k, + right, + }, + expected: `${left} ${v} ${right}`, + })), + ...Object.entries(Operator) + .filter(([k]) => notFilters.includes(k)) + .map(([k, v]) => ({ + input: { + left, + operator: k, + right, + not: true, + }, + expected: `(NOT ${left} ${v} ${right})`, + })), + ]; + + validFilters.forEach((valid) => { + test(`should create filter ${valid.input.operator}`, () => { + const filter = createFilter(valid.input); + expect(filter).toBe(valid.expected); + }); + }); + + const invalidFilters = [ + { + input: { + left, + operator: "UNKNOWN", + right, + }, + expectedErrorMessage: `Invalid filter operator UNKNOWN`, + }, + ...Object.keys(Operator) + .filter((k) => !notFilters.includes(k)) + .map((k) => ({ + input: { + left, + operator: k, + right, + not: true, + }, + expectedErrorMessage: `Invalid filter operator NOT_${k}`, + })), + ]; + + invalidFilters.forEach((invalid) => { + test(`should throw an error for filter ${invalid.input.operator}`, () => { + expect(() => createFilter(invalid.input)).toThrow(invalid.expectedErrorMessage); + }); + }); +}); diff --git a/packages/graphql/src/translate/where/create-filter.ts b/packages/graphql/src/translate/where/create-filter.ts new file mode 100644 index 0000000000..372797c1cf --- /dev/null +++ b/packages/graphql/src/translate/where/create-filter.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export enum Operator { + INCLUDES = "IN", + IN = "IN", + MATCHES = "=~", + CONTAINS = "CONTAINS", + STARTS_WITH = "STARTS WITH", + ENDS_WITH = "ENDS WITH", + LT = "<", + GT = ">", + GTE = ">=", + LTE = "<=", + DISTANCE = "=", +} + +function createFilter({ + left, + operator, + right, + not, +}: { + left: string; + operator: string; + right: string; + not?: boolean; +}): string { + if (!Operator[operator]) { + throw new Error(`Invalid filter operator ${operator}`); + } + + if (not && ["MATCHES", "LT", "GT", "GTE", "LTE", "DISTANCE"].includes(operator)) { + throw new Error(`Invalid filter operator NOT_${operator}`); + } + + let filter = `${left} ${Operator[operator]} ${right}`; + if (not) filter = `(NOT ${filter})`; + + return filter; +} + +export default createFilter; diff --git a/packages/graphql/src/translate/where/create-node-where-and-params.ts b/packages/graphql/src/translate/where/create-node-where-and-params.ts new file mode 100644 index 0000000000..50c1dca9f2 --- /dev/null +++ b/packages/graphql/src/translate/where/create-node-where-and-params.ts @@ -0,0 +1,237 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GraphQLWhereArg, Context } from "../../types"; +import { Node } from "../../classes"; +import createFilter from "./create-filter"; + +interface Res { + clauses: string[]; + params: any; +} + +function createNodeWhereAndParams({ + whereInput, + node, + nodeVariable, + context, + parameterPrefix, +}: { + whereInput: GraphQLWhereArg; + node: Node; + nodeVariable: string; + context: Context; + parameterPrefix: string; +}): [string, any] { + if (!Object.keys(whereInput).length) { + return ["", {}]; + } + + function reducer(res: Res, [key, value]: [string, GraphQLWhereArg]): Res { + const param = `${parameterPrefix}.${key}`; + + const operators = { + INCLUDES: "IN", + IN: "IN", + MATCHES: "=~", + CONTAINS: "CONTAINS", + STARTS_WITH: "STARTS WITH", + ENDS_WITH: "ENDS WITH", + LT: "<", + GT: ">", + GTE: ">=", + LTE: "<=", + DISTANCE: "=", + }; + + const re = /(?[_A-Za-z][_0-9A-Za-z]*?)(?:_(?NOT))?(?:_(?INCLUDES|IN|MATCHES|CONTAINS|STARTS_WITH|ENDS_WITH|LT|GT|GTE|LTE|DISTANCE))?$/gm; + + const match = re.exec(key); + + const fieldName = match?.groups?.field; + const not = !!match?.groups?.not; + const operator = match?.groups?.operator; + + const pointField = node.pointFields.find((x) => x.fieldName === fieldName); + + const coalesceValue = [...node.primitiveFields, ...node.dateTimeFields].find((f) => fieldName === f.fieldName) + ?.coalesceValue; + + const property = + coalesceValue !== undefined + ? `coalesce(${nodeVariable}.${fieldName}, ${coalesceValue})` + : `${nodeVariable}.${fieldName}`; + + if (fieldName && ["AND", "OR"].includes(fieldName)) { + const innerClauses: string[] = []; + const nestedParams: any[] = []; + + value.forEach((v: any, i) => { + const recurse = createNodeWhereAndParams({ + whereInput: v, + node, + nodeVariable, + context, + parameterPrefix: `${parameterPrefix}.${fieldName}[${i}]`, + }); + + innerClauses.push(`(${recurse[0]})`); + nestedParams.push(recurse[1]); + }); + + res.clauses.push(`(${innerClauses.join(` ${fieldName} `)})`); + res.params = { ...res.params, [fieldName]: nestedParams }; + + return res; + } + + // Equality/inequality + if (!operator) { + const relationField = node.relationFields.find((x) => fieldName === x.fieldName); + + if (relationField) { + const refNode = context.neoSchema.nodes.find((x) => x.name === relationField.typeMeta.name) as Node; + const inStr = relationField.direction === "IN" ? "<-" : "-"; + const outStr = relationField.direction === "OUT" ? "->" : "-"; + const relTypeStr = `[:${relationField.type}]`; + const relatedNodeVariable = `${nodeVariable}_${relationField.fieldName}`; + + if (value === null) { + let clause = `EXISTS((${nodeVariable})${inStr}${relTypeStr}${outStr}(:${relationField.typeMeta.name}))`; + if (!not) clause = `NOT ${clause}`; + res.clauses.push(clause); + return res; + } + + let resultStr = [ + `EXISTS((${nodeVariable})${inStr}${relTypeStr}${outStr}(:${relationField.typeMeta.name}))`, + `AND ${ + not ? "NONE" : "ANY" + }(${param} IN [(${nodeVariable})${inStr}${relTypeStr}${outStr}(${relatedNodeVariable}:${ + relationField.typeMeta.name + }) | ${param}] INNER_WHERE `, + ].join(" "); + + const recurse = createNodeWhereAndParams({ + whereInput: value, + node: refNode, + nodeVariable: relatedNodeVariable, + context, + parameterPrefix: `${parameterPrefix}.${fieldName}`, + }); + + resultStr += recurse[0]; + resultStr += ")"; // close NONE/ANY + res.clauses.push(resultStr); + res.params = { ...res.params, fieldName: recurse[1] }; + return res; + } + + if (value === null) { + res.clauses.push(not ? `${property} IS NOT NULL` : `${property} IS NULL`); + return res; + } + + if (pointField) { + if (pointField.typeMeta.array) { + let clause = `${property} = [p in $${param} | point(p)]`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } else { + let clause = `${property} = point($${param})`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + } else { + let clause = `${property} = $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + + res.params[key] = value; + return res; + } + + if (operator === "IN") { + const clause = createFilter({ + left: property, + operator, + right: pointField ? `[p in $${param} | point(p)]` : `$${param}`, + not, + }); + res.clauses.push(clause); + res.params[key] = value; + + return res; + } + + if (operator === "INCLUDES") { + let clause = pointField ? `point($${param}) IN ${property}` : `$${param} IN ${property}`; + + if (not) clause = `(NOT ${clause})`; + + res.clauses.push(clause); + res.params[key] = value; + + return res; + } + + if (key.endsWith("_MATCHES")) { + res.clauses.push(`${property} =~ $${param}`); + res.params[key] = value; + + return res; + } + + if (operator && ["CONTAINS", "STARTS_WITH", "ENDS_WITH"].includes(operator)) { + let clause = `${property} ${operators[operator]} $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + res.params[key] = value; + return res; + } + + if (operator && ["LT", "LTE", "GTE", "GT"].includes(operator)) { + res.clauses.push( + pointField + ? `distance(${property}, point($${param}.point)) ${operators[operator]} $${param}.distance` + : `${property} ${operators[operator]} $${param}` + ); + res.params[key] = value; + return res; + } + + if (key.endsWith("_DISTANCE")) { + res.clauses.push(`distance(${property}, point($${param}.point)) = $${param}.distance`); + res.params[key] = value; + + return res; + } + + // Necessary for TypeScript, but should never reach here + return res; + } + + const { clauses, params } = Object.entries(whereInput).reduce(reducer, { clauses: [], params: {} }); + const where = clauses.join(" AND ").replace(/INNER_WHERE/gi, "WHERE"); + + return [where, params]; +} + +export default createNodeWhereAndParams; diff --git a/packages/graphql/src/translate/where/create-relationship-where-and-params.ts b/packages/graphql/src/translate/where/create-relationship-where-and-params.ts new file mode 100644 index 0000000000..46f15d34b9 --- /dev/null +++ b/packages/graphql/src/translate/where/create-relationship-where-and-params.ts @@ -0,0 +1,181 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Relationship from "../../classes/Relationship"; +import { GraphQLWhereArg, Context, PrimitiveField } from "../../types"; +import createFilter from "./create-filter"; + +interface Res { + clauses: string[]; + params: any; +} + +function createRelationshipWhereAndParams({ + whereInput, + context, + relationship, + relationshipVariable, + parameterPrefix, +}: { + whereInput: GraphQLWhereArg; + context: Context; + relationship: Relationship; + relationshipVariable: string; + parameterPrefix: string; +}): [string, any] { + if (!Object.keys(whereInput).length) { + return ["", {}]; + } + + function reducer(res: Res, [key, value]: [string, GraphQLWhereArg]): Res { + const param = `${parameterPrefix}.${key}`; + + const re = /(?[_A-Za-z][_0-9A-Za-z]*?)(?:_(?NOT))?(?:_(?INCLUDES|IN|MATCHES|CONTAINS|STARTS_WITH|ENDS_WITH|LT|GT|GTE|LTE|DISTANCE))?$/gm; + + const match = re.exec(key); + + const fieldName = match?.groups?.field; + const not = !!match?.groups?.not; + const operator = match?.groups?.operator; + + const pointField = relationship.pointFields.find((f) => f.fieldName === fieldName); + + const coalesceValue = ([ + ...relationship.dateTimeFields, + ...relationship.dateTimeFields, + ...relationship.enumFields, + ...relationship.scalarFields, + ...relationship.primitiveFields, + ].find((f) => f.fieldName === fieldName && "coalesce" in f) as PrimitiveField)?.coalesceValue; + + const property = + coalesceValue !== undefined + ? `coalesce(${relationshipVariable}.${fieldName}, ${coalesceValue})` + : `${relationshipVariable}.${fieldName}`; + + if (fieldName && ["AND", "OR"].includes(fieldName)) { + const innerClauses: string[] = []; + const nestedParams: any[] = []; + + value.forEach((v: any, i) => { + const recurse = createRelationshipWhereAndParams({ + whereInput: v, + relationship, + relationshipVariable, + context, + parameterPrefix: `${parameterPrefix}.${fieldName}[${i}]`, + }); + + innerClauses.push(`(${recurse[0]})`); + nestedParams.push(recurse[1]); + }); + + res.clauses.push(`(${innerClauses.join(` ${fieldName} `)})`); + res.params = { ...res.params, [fieldName]: nestedParams }; + + return res; + } + + // Equality/inequality + if (!operator) { + if (value === null) { + res.clauses.push(not ? `${property} IS NOT NULL` : `${property} IS NULL`); + return res; + } + + if (pointField) { + if (pointField.typeMeta.array) { + let clause = `${property} = [p in $${param} | point(p)]`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } else { + let clause = `${property} = point($${param})`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + } else { + let clause = `${property} = $${param}`; + if (not) clause = `(NOT ${clause})`; + res.clauses.push(clause); + } + + res.params[key] = value; + return res; + } + + if (operator === "IN") { + const clause = createFilter({ + left: property, + operator, + right: pointField ? `[p in $${param} | point(p)]` : `$${param}`, + not, + }); + res.clauses.push(clause); + res.params[key] = value; + + return res; + } + + if (operator === "INCLUDES") { + const clause = createFilter({ + left: pointField ? `point($${param})` : `$${param}`, + operator, + right: property, + not, + }); + res.clauses.push(clause); + res.params[key] = value; + + return res; + } + + if (operator && ["MATCHES", "CONTAINS", "STARTS_WITH", "ENDS_WITH"].includes(operator)) { + const clause = createFilter({ + left: property, + operator, + right: `$${param}`, + not, + }); + res.clauses.push(clause); + res.params[key] = value; + return res; + } + + if (operator && ["DISTANCE", "LT", "LTE", "GTE", "GT"].includes(operator)) { + const clause = createFilter({ + left: pointField ? `distance(${property}, point($${param}.point))` : property, + operator, + right: pointField ? `$${param}.distance` : `$${param}`, + }); + res.clauses.push(clause); + res.params[key] = value; + return res; + } + + // Necessary for TypeScript, but should never reach here + return res; + } + + const { clauses, params } = Object.entries(whereInput).reduce(reducer, { clauses: [], params: {} }); + const where = clauses.join(" AND ").replace(/INNER_WHERE/gi, "WHERE"); + + return [where, params]; +} + +export default createRelationshipWhereAndParams; diff --git a/packages/graphql/src/types.ts b/packages/graphql/src/types.ts index e536d163b9..06c6256628 100644 --- a/packages/graphql/src/types.ts +++ b/packages/graphql/src/types.ts @@ -20,7 +20,7 @@ import { InputValueDefinitionNode, DirectiveNode } from "graphql"; import { ResolveTree } from "graphql-parse-resolve-info"; import { JwtPayload } from "jsonwebtoken"; -import { Driver } from "neo4j-driver"; +import { Driver, Integer } from "neo4j-driver"; import { Neo4jGraphQL } from "./classes"; export type DriverConfig = { @@ -103,6 +103,7 @@ export interface BaseField { description?: string; readonly?: boolean; writeonly?: boolean; + ignored?: boolean; } /** @@ -111,9 +112,15 @@ export interface BaseField { export interface RelationField extends BaseField { direction: "OUT" | "IN"; type: string; + properties?: string; union?: UnionField; } +export interface ConnectionField extends BaseField { + relationship: RelationField; + relationshipTypeName: string; +} + /** * Representation of the `@cypher` directive and its meta. */ @@ -134,7 +141,10 @@ export interface PrimitiveField extends BaseField { export type CustomScalarField = BaseField; -export type CustomEnumField = BaseField; +export interface CustomEnumField extends BaseField { + // TODO Must be "Enum" - really needs refactoring into classes + kind: string; +} export interface UnionField extends BaseField { nodes?: string[]; @@ -156,13 +166,25 @@ export interface GraphQLSortArg { [field: string]: SortDirection; } +export interface ConnectionSortArg { + node?: GraphQLSortArg; + edge?: GraphQLSortArg; +} + +export interface ConnectionQueryArgs { + where?: ConnectionWhereArg; + first?: number; + after?: string; + sort?: ConnectionSortArg[]; +} + /** * Representation of the options arg * passed to resolvers. */ export interface GraphQLOptionsArg { - limit?: number; - skip?: number; + limit?: number | Integer; + offset?: number | Integer; sort?: GraphQLSortArg[]; } @@ -176,6 +198,15 @@ export interface GraphQLWhereArg { OR?: GraphQLWhereArg[]; } +export interface ConnectionWhereArg { + node?: GraphQLWhereArg; + node_NOT?: GraphQLWhereArg; + edge?: GraphQLWhereArg; + edge_NOT?: GraphQLWhereArg; + AND?: ConnectionWhereArg[]; + OR?: ConnectionWhereArg[]; +} + export type AuthOperations = "CREATE" | "READ" | "UPDATE" | "DELETE" | "CONNECT" | "DISCONNECT"; export type AuthOrders = "pre" | "post"; diff --git a/packages/graphql/src/utils/get-neo4j-resolve-tree.ts b/packages/graphql/src/utils/get-neo4j-resolve-tree.ts index bf72168daf..775c719e77 100644 --- a/packages/graphql/src/utils/get-neo4j-resolve-tree.ts +++ b/packages/graphql/src/utils/get-neo4j-resolve-tree.ts @@ -65,7 +65,15 @@ function getNeo4jArgumentValue({ argument, type }: { argument: unknown | unknown } if (type instanceof GraphQLScalarType) { - return type.name === "Int" ? neo4j.int(argument as number) : argument; + if (type.name === "Int") { + return neo4j.int(argument as number); + } + + if (type.name === "ID") { + if (typeof argument === "number") { + return argument.toString(10); + } + } } return argument; diff --git a/packages/graphql/src/utils/verify-database.test.ts b/packages/graphql/src/utils/verify-database.test.ts index 09ca73acf5..0eeeb4d829 100644 --- a/packages/graphql/src/utils/verify-database.test.ts +++ b/packages/graphql/src/utils/verify-database.test.ts @@ -60,7 +60,7 @@ describe("checkNeo4jCompat", () => { verifyConnectivity: () => undefined, }; - await checkNeo4jCompat({ driver: fakeDriver, driverConfig }); + await expect(checkNeo4jCompat({ driver: fakeDriver, driverConfig })).resolves.not.toThrow(); }); test("should throw expected Neo4j version", async () => { @@ -70,7 +70,16 @@ describe("checkNeo4jCompat", () => { const fakeSession: Session = { // @ts-ignore run: () => ({ - records: [{ toObject: () => ({ version: invalidVersion }) }], + records: [ + { + toObject: () => ({ + version: invalidVersion, + apocVersion: MIN_APOC_VERSION, + functions: REQUIRED_APOC_FUNCTIONS, + procedures: REQUIRED_APOC_PROCEDURES, + }), + }, + ], }), // @ts-ignore close: () => undefined, @@ -85,10 +94,72 @@ describe("checkNeo4jCompat", () => { }; await expect(checkNeo4jCompat({ driver: fakeDriver })).rejects.toThrow( - `Expected minimum Neo4j version: '${MIN_NEO4J_VERSION}' received: '${invalidVersion}'` + `Encountered the following DBMS compatiblility issues:\nExpected minimum Neo4j version: '${MIN_NEO4J_VERSION}' received: '${invalidVersion}'` ); }); + test("should not throw Error that 4.1.10 is less than 4.1.5", async () => { + // @ts-ignore + const fakeSession: Session = { + // @ts-ignore + run: () => ({ + records: [ + { + toObject: () => ({ + version: "4.1.10", + apocVersion: MIN_APOC_VERSION, + functions: REQUIRED_APOC_FUNCTIONS, + procedures: REQUIRED_APOC_PROCEDURES, + }), + }, + ], + }), + // @ts-ignore + close: () => undefined, + }; + + // @ts-ignore + const fakeDriver: Driver = { + // @ts-ignore + session: () => fakeSession, + // @ts-ignore + verifyConnectivity: () => undefined, + }; + + await expect(checkNeo4jCompat({ driver: fakeDriver })).resolves.not.toThrow(); + }); + + test("should not throw Error for Aura version numbers", async () => { + // @ts-ignore + const fakeSession: Session = { + // @ts-ignore + run: () => ({ + records: [ + { + toObject: () => ({ + version: "4.2-aura", + apocVersion: MIN_APOC_VERSION, + functions: REQUIRED_APOC_FUNCTIONS, + procedures: REQUIRED_APOC_PROCEDURES, + }), + }, + ], + }), + // @ts-ignore + close: () => undefined, + }; + + // @ts-ignore + const fakeDriver: Driver = { + // @ts-ignore + session: () => fakeSession, + // @ts-ignore + verifyConnectivity: () => undefined, + }; + + await expect(checkNeo4jCompat({ driver: fakeDriver })).resolves.not.toThrow(); + }); + test("should throw expected APOC version", async () => { const invalidApocVersion = "2.3.1"; @@ -96,7 +167,16 @@ describe("checkNeo4jCompat", () => { const fakeSession: Session = { // @ts-ignore run: () => ({ - records: [{ toObject: () => ({ version: MIN_NEO4J_VERSION, apocVersion: invalidApocVersion }) }], + records: [ + { + toObject: () => ({ + version: MIN_NEO4J_VERSION, + apocVersion: invalidApocVersion, + functions: REQUIRED_APOC_FUNCTIONS, + procedures: REQUIRED_APOC_PROCEDURES, + }), + }, + ], }), // @ts-ignore close: () => undefined, @@ -111,7 +191,7 @@ describe("checkNeo4jCompat", () => { }; await expect(checkNeo4jCompat({ driver: fakeDriver })).rejects.toThrow( - `Expected minimum APOC version: '${MIN_APOC_VERSION}' received: '${invalidApocVersion}'` + `Encountered the following DBMS compatiblility issues:\nExpected minimum APOC version: '${MIN_APOC_VERSION}' received: '${invalidApocVersion}'` ); }); @@ -126,6 +206,7 @@ describe("checkNeo4jCompat", () => { version: MIN_NEO4J_VERSION, apocVersion: MIN_APOC_VERSION, functions: [], + procedures: REQUIRED_APOC_PROCEDURES, }), }, ], @@ -143,7 +224,9 @@ describe("checkNeo4jCompat", () => { }; await expect(checkNeo4jCompat({ driver: fakeDriver })).rejects.toThrow( - `Missing APOC functions: [ ${REQUIRED_APOC_FUNCTIONS.join(", ")} ]` + `Encountered the following DBMS compatiblility issues:\nMissing APOC functions: [ ${REQUIRED_APOC_FUNCTIONS.join( + ", " + )} ]` ); }); @@ -176,7 +259,9 @@ describe("checkNeo4jCompat", () => { }; await expect(checkNeo4jCompat({ driver: fakeDriver })).rejects.toThrow( - `Missing APOC procedures: [ ${REQUIRED_APOC_PROCEDURES.join(", ")} ]` + `Encountered the following DBMS compatiblility issues:\nMissing APOC procedures: [ ${REQUIRED_APOC_PROCEDURES.join( + ", " + )} ]` ); }); @@ -208,7 +293,7 @@ describe("checkNeo4jCompat", () => { verifyConnectivity: () => undefined, }; - expect(await checkNeo4jCompat({ driver: fakeDriver })).toBeUndefined(); + await expect(checkNeo4jCompat({ driver: fakeDriver })).resolves.not.toThrow(); }); test("should throw no errors with valid DB (greater versions)", async () => { @@ -219,8 +304,8 @@ describe("checkNeo4jCompat", () => { records: [ { toObject: () => ({ - version: Number(MIN_NEO4J_VERSION) + Math.random() * 10, - apocVersion: Number(MIN_APOC_VERSION) + Math.random() * 10, + version: "20.1.1", + apocVersion: "20.1.0.0", functions: REQUIRED_APOC_FUNCTIONS, procedures: REQUIRED_APOC_PROCEDURES, }), @@ -239,6 +324,6 @@ describe("checkNeo4jCompat", () => { verifyConnectivity: () => undefined, }; - expect(await checkNeo4jCompat({ driver: fakeDriver })).toBeUndefined(); + await expect(checkNeo4jCompat({ driver: fakeDriver })).resolves.not.toThrow(); }); }); diff --git a/packages/graphql/src/utils/verify-database.ts b/packages/graphql/src/utils/verify-database.ts index ab233c3640..7254912922 100644 --- a/packages/graphql/src/utils/verify-database.ts +++ b/packages/graphql/src/utils/verify-database.ts @@ -18,6 +18,7 @@ */ import { Driver } from "neo4j-driver"; +import semver from "semver"; import { MIN_NEO4J_VERSION, MIN_APOC_VERSION, REQUIRED_APOC_FUNCTIONS, REQUIRED_APOC_PROCEDURES } from "../constants"; import { DriverConfig } from "../types"; @@ -66,23 +67,28 @@ async function checkNeo4jCompat({ driver, driverConfig }: { driver: Driver; driv try { const result = await session.run(cypher); const info = result.records[0].toObject() as DBInfo; + const errors: string[] = []; - if (info.version < MIN_NEO4J_VERSION) { - throw new Error(`Expected minimum Neo4j version: '${MIN_NEO4J_VERSION}' received: '${info.version}'`); + if (semver.lt(semver.coerce(info.version), MIN_NEO4J_VERSION)) { + errors.push(`Expected minimum Neo4j version: '${MIN_NEO4J_VERSION}' received: '${info.version}'`); } - if (info.apocVersion < MIN_APOC_VERSION) { - throw new Error(`Expected minimum APOC version: '${MIN_APOC_VERSION}' received: '${info.apocVersion}'`); + if (semver.lt(semver.coerce(info.apocVersion), MIN_APOC_VERSION)) { + errors.push(`Expected minimum APOC version: '${MIN_APOC_VERSION}' received: '${info.apocVersion}'`); } const missingFunctions = REQUIRED_APOC_FUNCTIONS.filter((f) => !info.functions.includes(f)); if (missingFunctions.length) { - throw new Error(`Missing APOC functions: [ ${missingFunctions.join(", ")} ]`); + errors.push(`Missing APOC functions: [ ${missingFunctions.join(", ")} ]`); } const missingProcedures = REQUIRED_APOC_PROCEDURES.filter((p) => !info.procedures.includes(p)); if (missingProcedures.length) { - throw new Error(`Missing APOC procedures: [ ${missingProcedures.join(", ")} ]`); + errors.push(`Missing APOC procedures: [ ${missingProcedures.join(", ")} ]`); + } + + if (errors.length) { + throw new Error(`Encountered the following DBMS compatiblility issues:\n${errors.join("\n")}`); } } finally { await session.close(); diff --git a/packages/graphql/tests/integration/advanced-filtering.int.test.ts b/packages/graphql/tests/integration/advanced-filtering.int.test.ts index 7e32eb5ba8..7f2d628538 100644 --- a/packages/graphql/tests/integration/advanced-filtering.int.test.ts +++ b/packages/graphql/tests/integration/advanced-filtering.int.test.ts @@ -86,7 +86,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -148,7 +148,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -216,7 +216,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -290,7 +290,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -356,7 +356,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -425,7 +425,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -491,7 +491,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -562,7 +562,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -631,7 +631,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -700,7 +700,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -775,7 +775,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -855,7 +855,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -937,7 +937,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -1004,7 +1004,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -1071,7 +1071,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -1137,7 +1137,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -1204,7 +1204,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -1261,7 +1261,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -1314,7 +1314,7 @@ describe("Advanced Filtering", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (gqlResult.errors) { @@ -1331,175 +1331,560 @@ describe("Advanced Filtering", () => { }); describe("Relationship Filtering", () => { - test("should find relationship equality", async () => { - const session = driver.session(); + describe("equality", () => { + test("should find using relationship equality on node", async () => { + const session = driver.session(); - const randomType1 = `${generate({ - charset: "alphabetic", - })}Movie`; + const randomType1 = `${generate({ + charset: "alphabetic", + })}Movie`; - const randomType2 = `${generate({ - charset: "alphabetic", - })}Genre`; + const randomType2 = `${generate({ + charset: "alphabetic", + })}Genre`; - const pluralRandomType1 = pluralize(camelCase(randomType1)); - const pluralRandomType2 = pluralize(camelCase(randomType2)); + const pluralRandomType1 = pluralize(camelCase(randomType1)); + const pluralRandomType2 = pluralize(camelCase(randomType2)); - const typeDefs = ` - type ${randomType1} { - id: ID - ${pluralRandomType2}: [${randomType2}] @relationship(type: "IN_GENRE", direction: OUT) - } + const typeDefs = ` + type ${randomType1} { + id: ID + ${pluralRandomType2}: [${randomType2}] @relationship(type: "IN_GENRE", direction: OUT) + } - type ${randomType2} { - id: ID + type ${randomType2} { + id: ID + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const rootId = generate({ + charset: "alphabetic", + }); + + const relationId = generate({ + charset: "alphabetic", + }); + + const randomId = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (root:${randomType1} {id: $rootId}) + CREATE (:${randomType1} {id: $randomId}) + CREATE (relation:${randomType2} {id: $relationId}) + CREATE (:${randomType2} {id: $randomId}) + MERGE (root)-[:IN_GENRE]->(relation) + `, + { rootId, relationId, randomId } + ); + + const query = ` + { + ${pluralRandomType1}(where: { ${pluralRandomType2}: { id: "${relationId}" } }) { + id + ${pluralRandomType2} { + id + } + } + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); } - `; - const neoSchema = new Neo4jGraphQL({ typeDefs }); + expect(gqlResult.errors).toBeUndefined(); - const rootId = generate({ - charset: "alphabetic", + expect((gqlResult.data as any)[pluralRandomType1]).toHaveLength(1); + expect((gqlResult.data as any)[pluralRandomType1][0]).toMatchObject({ + id: rootId, + [pluralRandomType2]: [{ id: relationId }], + }); + } finally { + await session.close(); + } }); - const relationId = generate({ - charset: "alphabetic", - }); + test("should find using equality on node using connection", async () => { + const session = driver.session(); + const typeDefs = ` + type Movie { + id: ID + genres: [Genre] @relationship(type: "IN_GENRE", direction: OUT) + } - const randomId = generate({ - charset: "alphabetic", - }); + type Genre { + id: ID + } + `; - try { - await session.run( - ` - CREATE (root:${randomType1} {id: $rootId}) - CREATE (:${randomType1} {id: $randomId}) - CREATE (relation:${randomType2} {id: $relationId}) - CREATE (:${randomType2} {id: $randomId}) - MERGE (root)-[:IN_GENRE]->(relation) + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieId = generate({ + charset: "alphabetic", + }); + + const genreId = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (movie:Movie {id: $movieId})-[:IN_GENRE]->(:Genre {id:$genreId}) `, - { rootId, relationId, randomId } - ); + { movieId, genreId } + ); - const query = ` - { - ${pluralRandomType1}(where: { ${pluralRandomType2}: { id: "${relationId}" } }) { - id - ${pluralRandomType2} { + const query = ` + { + movies(where: { genresConnection: { node: { id: "${genreId}" } } }) { id + genres { + id + } } } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).movies).toHaveLength(1); + expect((gqlResult.data as any).movies[0]).toMatchObject({ + id: movieId, + genres: [{ id: genreId }], + }); + } finally { + await session.close(); + } + }); + + test("should find using equality on relationship using connection", async () => { + const session = driver.session(); + const typeDefs = ` + type Movie { + id: ID + genres: [Genre] @relationship(type: "IN_GENRE", direction: OUT, properties: "ActedIn") + } + + type Genre { + id: ID + } + + interface ActedIn { + id: String + } `; - const gqlResult = await graphql({ - schema: neoSchema.schema, - source: query, - contextValue: { driver }, + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieId = generate({ + charset: "alphabetic", }); - if (gqlResult.errors) { - console.log(JSON.stringify(gqlResult.errors, null, 2)); + const genreId = generate({ + charset: "alphabetic", + }); + + const actedInId = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (movie:Movie {id: $movieId})-[:IN_GENRE {id:$actedInId}]->(:Genre {id:$genreId}) + `, + { movieId, genreId, actedInId } + ); + + const query = ` + { + movies(where: { genresConnection: { edge: { id: "${actedInId}" } } }) { + id + genres { + id + } + } + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).movies).toHaveLength(1); + expect((gqlResult.data as any).movies[0]).toMatchObject({ + id: movieId, + genres: [{ id: genreId }], + }); + } finally { + await session.close(); } + }); - expect(gqlResult.errors).toBeUndefined(); + test("should find relationship and node property equality using connection", async () => { + const session = driver.session(); + const typeDefs = ` + type Movie { + id: ID + genres: [Genre] @relationship(type: "IN_GENRE", direction: OUT, properties: "ActedIn") + } - expect((gqlResult.data as any)[pluralRandomType1]).toHaveLength(1); - expect((gqlResult.data as any)[pluralRandomType1][0]).toMatchObject({ - id: rootId, - [pluralRandomType2]: [{ id: relationId }], + type Genre { + id: ID + } + + interface ActedIn { + id: String + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieId = generate({ + charset: "alphabetic", }); - } finally { - await session.close(); - } - }); - test("should find relationship_NOT", async () => { - const session = driver.session(); + const genreId = generate({ + charset: "alphabetic", + }); - const randomType1 = `${generate({ - charset: "alphabetic", - })}Movie`; + const actedInId = generate({ + charset: "alphabetic", + }); - const randomType2 = `${generate({ - charset: "alphabetic", - })}Genre`; + try { + await session.run( + ` + CREATE (movie:Movie {id: $movieId})-[:IN_GENRE {id:$actedInId}]->(:Genre {id:$genreId}) + `, + { movieId, genreId, actedInId } + ); - const pluralRandomType1 = pluralize(camelCase(randomType1)); - const pluralRandomType2 = pluralize(camelCase(randomType2)); + const query = ` + { + movies(where: { genresConnection: { node: { id: "${genreId}" } edge: { id: "${actedInId}" } } }) { + id + genres { + id + } + } + } + `; - const typeDefs = ` - type ${randomType1} { - id: ID - ${pluralRandomType2}: [${randomType2}] @relationship(type: "IN_GENRE", direction: OUT) - } + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); - type ${randomType2} { - id: ID + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); } - `; - const neoSchema = new Neo4jGraphQL({ typeDefs }); + expect(gqlResult.errors).toBeUndefined(); - const rootId1 = generate({ - charset: "alphabetic", - }); - const rootId2 = generate({ - charset: "alphabetic", + expect((gqlResult.data as any).movies).toHaveLength(1); + expect((gqlResult.data as any).movies[0]).toMatchObject({ + id: movieId, + genres: [{ id: genreId }], + }); + } finally { + await session.close(); + } }); + }); - const relationId1 = generate({ - charset: "alphabetic", - }); - const relationId2 = generate({ - charset: "alphabetic", - }); + describe("NOT", () => { + test("should find using NOT on relationship", async () => { + const session = driver.session(); - try { - await session.run( - ` - CREATE (root1:${randomType1} {id: $rootId1}) - CREATE (root2:${randomType1} {id: $rootId2}) - CREATE (relation1:${randomType2} {id: $relationId1}) - CREATE (relation2:${randomType2} {id: $relationId2}) - MERGE (root1)-[:IN_GENRE]->(relation1) - MERGE (root2)-[:IN_GENRE]->(relation2) - `, - { rootId1, rootId2, relationId1, relationId2 } - ); + const randomType1 = `${generate({ + charset: "alphabetic", + })}Movie`; - const query = ` - { - ${pluralRandomType1}(where: { ${pluralRandomType2}_NOT: { id: "${relationId2}" } }) { - id - ${pluralRandomType2} { + const randomType2 = `${generate({ + charset: "alphabetic", + })}Genre`; + + const pluralRandomType1 = pluralize(camelCase(randomType1)); + const pluralRandomType2 = pluralize(camelCase(randomType2)); + + const typeDefs = ` + type ${randomType1} { + id: ID + ${pluralRandomType2}: [${randomType2}] @relationship(type: "IN_GENRE", direction: OUT) + } + + type ${randomType2} { + id: ID + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const rootId1 = generate({ + charset: "alphabetic", + }); + const rootId2 = generate({ + charset: "alphabetic", + }); + + const relationId1 = generate({ + charset: "alphabetic", + }); + const relationId2 = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (root1:${randomType1} {id: $rootId1}) + CREATE (root2:${randomType1} {id: $rootId2}) + CREATE (relation1:${randomType2} {id: $relationId1}) + CREATE (relation2:${randomType2} {id: $relationId2}) + MERGE (root1)-[:IN_GENRE]->(relation1) + MERGE (root2)-[:IN_GENRE]->(relation2) + `, + { rootId1, rootId2, relationId1, relationId2 } + ); + + const query = ` + { + ${pluralRandomType1}(where: { ${pluralRandomType2}_NOT: { id: "${relationId2}" } }) { id + ${pluralRandomType2} { + id + } } } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any)[pluralRandomType1]).toHaveLength(1); + expect((gqlResult.data as any)[pluralRandomType1][0]).toMatchObject({ + id: rootId1, + [pluralRandomType2]: [{ id: relationId1 }], + }); + } finally { + await session.close(); + } + }); + + test("should find using NOT on connections", async () => { + const session = driver.session(); + + const randomType1 = `${generate({ + charset: "alphabetic", + })}Movie`; + + const randomType2 = `${generate({ + charset: "alphabetic", + })}Genre`; + + const pluralRandomType1 = pluralize(camelCase(randomType1)); + const pluralRandomType2 = pluralize(camelCase(randomType2)); + + const typeDefs = ` + type ${randomType1} { + id: ID + ${pluralRandomType2}: [${randomType2}] @relationship(type: "IN_GENRE", direction: OUT) + } + + type ${randomType2} { + id: ID + } `; - const gqlResult = await graphql({ - schema: neoSchema.schema, - source: query, - contextValue: { driver }, + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const rootId1 = generate({ + charset: "alphabetic", + }); + const rootId2 = generate({ + charset: "alphabetic", }); - if (gqlResult.errors) { - console.log(JSON.stringify(gqlResult.errors, null, 2)); + const relationId1 = generate({ + charset: "alphabetic", + }); + const relationId2 = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (root1:${randomType1} {id: $rootId1})-[:IN_GENRE]->(relation1:${randomType2} {id: $relationId1}) + CREATE (root2:${randomType1} {id: $rootId2})-[:IN_GENRE]->(relation2:${randomType2} {id: $relationId2}) + `, + { rootId1, rootId2, relationId1, relationId2 } + ); + + const query = ` + { + ${pluralRandomType1}(where: { ${pluralRandomType2}Connection_NOT: { node: { id: "${relationId2}" } } }) { + id + ${pluralRandomType2} { + id + } + } + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any)[pluralRandomType1]).toHaveLength(1); + expect((gqlResult.data as any)[pluralRandomType1][0]).toMatchObject({ + id: rootId1, + [pluralRandomType2]: [{ id: relationId1 }], + }); + } finally { + await session.close(); } + }); - expect(gqlResult.errors).toBeUndefined(); + test("should find using relationship properties and connections", async () => { + const session = driver.session(); + + const randomType1 = `${generate({ + charset: "alphabetic", + })}Movie`; + + const randomType2 = `${generate({ + charset: "alphabetic", + })}Genre`; + + const pluralRandomType1 = pluralize(camelCase(randomType1)); + const pluralRandomType2 = pluralize(camelCase(randomType2)); - expect((gqlResult.data as any)[pluralRandomType1]).toHaveLength(1); - expect((gqlResult.data as any)[pluralRandomType1][0]).toMatchObject({ - id: rootId1, - [pluralRandomType2]: [{ id: relationId1 }], + const typeDefs = ` + type ${randomType1} { + id: ID + ${pluralRandomType2}: [${randomType2}] @relationship(type: "IN_GENRE", direction: OUT, properties: "ActedIn") + } + + type ${randomType2} { + id: ID + } + + interface ActedIn { + id: ID + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const rootId1 = generate({ + charset: "alphabetic", }); - } finally { - await session.close(); - } + const rootId2 = generate({ + charset: "alphabetic", + }); + + const relationId1 = generate({ + charset: "alphabetic", + }); + const relationId2 = generate({ + charset: "alphabetic", + }); + const actedInId = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (:${randomType1} {id: $rootId1})-[:IN_GENRE {id: $actedInId}]->(:${randomType2} {id: $relationId1}) + CREATE (:${randomType1} {id: $rootId2})-[:IN_GENRE {id: randomUUID()}]->(:${randomType2} {id: $relationId2}) + `, + { rootId1, rootId2, relationId1, relationId2, actedInId } + ); + + const query = ` + { + ${pluralRandomType1}(where: { ${pluralRandomType2}Connection_NOT: { edge: { id: "${actedInId}" } } }) { + id + ${pluralRandomType2} { + id + } + } + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any)[pluralRandomType1]).toHaveLength(1); + expect((gqlResult.data as any)[pluralRandomType1][0]).toMatchObject({ + id: rootId2, + [pluralRandomType2]: [{ id: relationId2 }], + }); + } finally { + await session.close(); + } + }); }); test("should test for not null", async () => { @@ -1566,7 +1951,7 @@ describe("Advanced Filtering", () => { const nullResult = await graphql({ schema: neoSchema.schema, source: nullQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (nullResult.errors) { @@ -1593,7 +1978,7 @@ describe("Advanced Filtering", () => { const notNullResult = await graphql({ schema: neoSchema.schema, source: notNullQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (notNullResult.errors) { @@ -1668,7 +2053,7 @@ describe("Advanced Filtering", () => { const nullResult = await graphql({ schema: neoSchema.schema, source: nullQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (nullResult.errors) { @@ -1694,7 +2079,7 @@ describe("Advanced Filtering", () => { const notNullResult = await graphql({ schema: neoSchema.schema, source: notNullQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); if (notNullResult.errors) { diff --git a/packages/graphql/tests/integration/auth/allow-unauthenticated.int.test.ts b/packages/graphql/tests/integration/auth/allow-unauthenticated.int.test.ts index 63ae1c08f3..ff7a75ffc6 100644 --- a/packages/graphql/tests/integration/auth/allow-unauthenticated.int.test.ts +++ b/packages/graphql/tests/integration/auth/allow-unauthenticated.int.test.ts @@ -48,7 +48,7 @@ describe("auth/allow-unauthenticated", () => { publisher: String! published: Boolean! } - + extend type Post @auth(rules: [ { allow: { OR: [ { publisher: "$jwt.sub" }, @@ -56,30 +56,30 @@ describe("auth/allow-unauthenticated", () => { ] }, allowUnauthenticated: true } ]) `; - + const postId = generate({ charset: "alphabetic" }); - + const query = ` { - posts(where: { id: ${postId} }) { + posts(where: { id: "${postId}" }) { id } } `; - + const secret = "secret"; const session = driver.session({ defaultAccessMode: "WRITE" }); const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); - + await session.run(` CREATE (:Post {id: "${postId}", publisher: "nop", published: true}) `); - + const socket = new Socket({ readable: true }); const req = new IncomingMessage(socket); - + const gqlResult = await graphql({ - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, schema: neoSchema.schema, source: query, }); @@ -98,7 +98,7 @@ describe("auth/allow-unauthenticated", () => { publisher: String! published: Boolean! } - + extend type Post @auth(rules: [ { allow: { OR: [ { publisher: "$jwt.sub" }, @@ -106,38 +106,38 @@ describe("auth/allow-unauthenticated", () => { ] }, allowUnauthenticated: true } ]) `; - + const postId = generate({ charset: "alphabetic" }); - + const query = ` { - posts(where: { id: ${postId} }) { + posts(where: { id: "${postId}" }) { id } } `; - + const secret = "secret"; const session = driver.session({ defaultAccessMode: "WRITE" }); const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); - + await session.run(` CREATE (:Post {id: "${postId}", publisher: "nop", published: false}) `); - + const socket = new Socket({ readable: true }); const req = new IncomingMessage(socket); - + const gqlResult = await graphql({ - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, schema: neoSchema.schema, source: query, }); - + // Check that a Forbidden error have been throwed expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); - expect((gqlResult.errors as any[])).toHaveLength(1); - + expect(gqlResult.errors as any[]).toHaveLength(1); + // Check if returned data is what we really want expect(gqlResult.data?.posts).toBeUndefined(); }); @@ -163,7 +163,7 @@ describe("auth/allow-unauthenticated", () => { const query = ` { - posts(where: { OR: [{id: ${postId}}, {id: ${postId2}}] }) { + posts(where: { OR: [{id: "${postId}"}, {id: "${postId2}"}] }) { id } } @@ -182,14 +182,14 @@ describe("auth/allow-unauthenticated", () => { const req = new IncomingMessage(socket); const gqlResult = await graphql({ - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, schema: neoSchema.schema, source: query, }); // Check that a Forbidden error have been throwed expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); - expect((gqlResult.errors as any[])).toHaveLength(1); + expect(gqlResult.errors as any[]).toHaveLength(1); // Check if returned data is what we really want expect(gqlResult.data?.posts).toBeUndefined(); @@ -204,7 +204,7 @@ describe("auth/allow-unauthenticated", () => { publisher: String! published: Boolean! } - + extend type Post @auth(rules: [ { where: { OR: [ { publisher: "$jwt.sub" }, @@ -212,30 +212,30 @@ describe("auth/allow-unauthenticated", () => { ] }, allowUnauthenticated: true } ]) `; - + const postId = generate({ charset: "alphabetic" }); - + const query = ` { - posts(where: { id: ${postId} }) { + posts(where: { id: "${postId}" }) { id } } `; - + const secret = "secret"; const session = driver.session({ defaultAccessMode: "WRITE" }); const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); - + await session.run(` CREATE (:Post {id: "${postId}", publisher: "nop", published: true}) `); - + const socket = new Socket({ readable: true }); const req = new IncomingMessage(socket); - + const gqlResult = await graphql({ - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, schema: neoSchema.schema, source: query, }); @@ -254,7 +254,7 @@ describe("auth/allow-unauthenticated", () => { publisher: String! published: Boolean! } - + extend type Post @auth(rules: [ { where: { OR: [ { publisher: "$jwt.sub" }, @@ -262,37 +262,37 @@ describe("auth/allow-unauthenticated", () => { ] }, allowUnauthenticated: true } ]) `; - + const postId = generate({ charset: "alphabetic" }); - + const query = ` { - posts(where: { id: ${postId} }) { + posts(where: { id: "${postId}" }) { id } } `; - + const secret = "secret"; const session = driver.session({ defaultAccessMode: "WRITE" }); const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); - + await session.run(` CREATE (:Post {id: "${postId}", publisher: "nop", published: false}) `); - + const socket = new Socket({ readable: true }); const req = new IncomingMessage(socket); - + const gqlResult = await graphql({ - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, schema: neoSchema.schema, source: query, }); - + // Check that no errors have been throwed expect(gqlResult.errors).toBeUndefined(); - + // Check if returned data is what we really want expect(gqlResult.data?.posts).toStrictEqual([]); }); @@ -318,7 +318,7 @@ describe("auth/allow-unauthenticated", () => { const query = ` { - posts(where: { OR: [{id: ${postId}}, {id: ${postId2}}] }) { + posts(where: { OR: [{id: "${postId}"}, {id: "${postId2}"}] }) { id } } @@ -337,7 +337,7 @@ describe("auth/allow-unauthenticated", () => { const req = new IncomingMessage(socket); const gqlResult = await graphql({ - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, schema: neoSchema.schema, source: query, }); @@ -357,14 +357,14 @@ describe("auth/allow-unauthenticated", () => { type User { id: ID } - - extend type User @auth(rules: [{ + + extend type User @auth(rules: [{ operations: [CREATE], bind: { id: "$jwt.sub" }, allowUnauthenticated: true }]) `; - + const query = ` mutation { createUsers(input: [{id: "not bound"}]) { @@ -377,10 +377,10 @@ describe("auth/allow-unauthenticated", () => { const secret = "secret"; const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); - + const socket = new Socket({ readable: true }); const req = new IncomingMessage(socket); - + const gqlResult = await graphql({ contextValue: { driver, req }, schema: neoSchema.schema, @@ -389,8 +389,8 @@ describe("auth/allow-unauthenticated", () => { // Check that a Forbidden error have been throwed expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); - expect((gqlResult.errors as any[])).toHaveLength(1); - + expect(gqlResult.errors as any[]).toHaveLength(1); + // Check if returned data is what we really want expect(gqlResult.data?.posts).toBeUndefined(); }); diff --git a/packages/graphql/tests/integration/auth/allow.int.test.ts b/packages/graphql/tests/integration/auth/allow.int.test.ts index 2b31aed4fe..3ae2d80b48 100644 --- a/packages/graphql/tests/integration/auth/allow.int.test.ts +++ b/packages/graphql/tests/integration/auth/allow.int.test.ts @@ -85,7 +85,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -143,7 +143,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -212,7 +212,80 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); + } finally { + await session.close(); + } + }); + + test("should throw forbidden when reading a nested property with invalid allow (using connections)", async () => { + const session = driver.session({ defaultAccessMode: "WRITE" }); + + const typeDefs = ` + type Post { + id: ID + creator: User @relationship(type: "HAS_POST", direction: IN) + } + + type User { + id: ID + } + + extend type User { + password: String @auth(rules: [{ operations: [READ], allow: { id: "$jwt.sub" } }]) + } + `; + + const postId = generate({ + charset: "alphabetic", + }); + + const userId = generate({ + charset: "alphabetic", + }); + + const query = ` + { + posts(where: {id: "${postId}"}) { + creatorConnection { + edges { + node { + password + } + } + } + } + } + `; + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: "invalid", + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run(` + CREATE (:Post {id: "${postId}"})<-[:HAS_POST]-(:User {id: "${userId}", password: "letmein"}) + `); + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -283,7 +356,82 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); + } finally { + await session.close(); + } + }); + + test("should throw forbidden when reading a node with invalid allow (across a single relationship)(using connections)", async () => { + const session = driver.session({ defaultAccessMode: "WRITE" }); + + const typeDefs = ` + type Post { + content: String + creator: User @relationship(type: "HAS_POST", direction: IN) + } + + type User { + id: ID + name: String + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) + } + + extend type Post + @auth(rules: [{ operations: [READ], allow: { creator: { id: "$jwt.sub" } } }]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const postId = generate({ + charset: "alphabetic", + }); + + const query = ` + { + users(where: {id: "${userId}"}) { + id + postsConnection { + edges { + node { + content + } + } + } + } + } + `; + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: "invalid", + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run(` + CREATE (:User {id: "${userId}"})-[:HAS_POST]->(:Post {id: "${postId}"}) + `); + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -368,7 +516,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -429,7 +577,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -490,7 +638,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -528,7 +676,7 @@ describe("auth/allow", () => { mutation { updatePosts( where: { id: "${postId}" } - update: { creator: { update: { id: "new-id" } } } + update: { creator: { update: { node: { id: "new-id" } } } } ) { posts { id @@ -561,7 +709,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -601,7 +749,7 @@ describe("auth/allow", () => { mutation { updatePosts( where: { id: "${postId}" } - update: { creator: { update: { password: "new-password" } } } + update: { creator: { update: { node: { password: "new-password" } } } } ) { posts { id @@ -634,7 +782,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -694,7 +842,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -736,7 +884,9 @@ describe("auth/allow", () => { delete: { posts: { where: { - id: "${postId}" + node: { + id: "${postId}" + } } } } @@ -770,7 +920,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -810,7 +960,7 @@ describe("auth/allow", () => { mutation { updateUsers( where: { id: "${userId}" } - disconnect: { posts: { where: { id: "${postId}" } } } + disconnect: { posts: { where: { node: { id: "${postId}" } } } } ) { users { id @@ -843,7 +993,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -897,7 +1047,7 @@ describe("auth/allow", () => { disconnect: { disconnect: { creator: { - where: { id: "${userId}" } + where: { node: { id: "${userId}" } } } } } @@ -937,7 +1087,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -977,7 +1127,7 @@ describe("auth/allow", () => { mutation { updateUsers( where: { id: "${userId}" } - connect: { posts: { where: { id: "${postId}" } } } + connect: { posts: { where: { node: { id: "${postId}" } } } } ) { users { id @@ -1011,7 +1161,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -1065,7 +1215,7 @@ describe("auth/allow", () => { connect: { connect: { creator: { - where: { id: "${userId}" } + where: { node: { id: "${userId}" } } } } } @@ -1104,7 +1254,7 @@ describe("auth/allow", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); diff --git a/packages/graphql/tests/integration/auth/bind.int.test.ts b/packages/graphql/tests/integration/auth/bind.int.test.ts index f9680bdc58..759de9d2b7 100644 --- a/packages/graphql/tests/integration/auth/bind.int.test.ts +++ b/packages/graphql/tests/integration/auth/bind.int.test.ts @@ -83,7 +83,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -119,9 +119,11 @@ describe("auth/bind", () => { id: "${userId}", posts: { create: [{ - id: "post-id-1", - creator: { - create: { id: "not valid" } + node: { + id: "post-id-1", + creator: { + create: { node: {id: "not valid"} } + } } }] } @@ -153,7 +155,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -193,7 +195,7 @@ describe("auth/bind", () => { where: { id: "${postId}" } update: { creator: { - create: { id: "not bound" } + create: { node: { id: "not bound" } } } } ) { @@ -228,7 +230,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -288,7 +290,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -328,9 +330,11 @@ describe("auth/bind", () => { where: { id: "${userId}" }, update: { posts: { - where: { id: "${postId}" }, + where: { node: { id: "${postId}" } }, update: { - creator: { update: { id: "not bound" } } + node: { + creator: { update: { node: { id: "not bound" } } } + } } } } @@ -366,7 +370,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -429,7 +433,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -470,7 +474,7 @@ describe("auth/bind", () => { where: { id: "${postId}" }, connect: { creator: { - where: { id: "not bound" } + where: { node: { id: "not bound" } } } } ) { @@ -505,7 +509,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -546,7 +550,7 @@ describe("auth/bind", () => { where: { id: "${postId}" }, disconnect: { creator: { - where: { id: "${userId}" } + where: { node: { id: "${userId}" } } } } ) { @@ -581,7 +585,7 @@ describe("auth/bind", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); diff --git a/packages/graphql/tests/integration/auth/custom-cypher.int.test.ts b/packages/graphql/tests/integration/auth/custom-cypher.int.test.ts index feed393657..570c6a4d15 100644 --- a/packages/graphql/tests/integration/auth/custom-cypher.int.test.ts +++ b/packages/graphql/tests/integration/auth/custom-cypher.int.test.ts @@ -72,7 +72,7 @@ describe("should inject the auth into cypher directive", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query as string, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -166,7 +166,7 @@ describe("should inject the auth into cypher directive", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query as string, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -268,7 +268,7 @@ describe("should inject the auth into cypher directive", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); diff --git a/packages/graphql/tests/integration/auth/is-authenticated.int.test.ts b/packages/graphql/tests/integration/auth/is-authenticated.int.test.ts index 74bf2df982..d7b4c11a44 100644 --- a/packages/graphql/tests/integration/auth/is-authenticated.int.test.ts +++ b/packages/graphql/tests/integration/auth/is-authenticated.int.test.ts @@ -70,7 +70,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -109,7 +109,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -155,7 +155,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -199,7 +199,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -245,7 +245,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -289,7 +289,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -341,7 +341,7 @@ describe("auth/is-authenticated", () => { const query = ` mutation { - updateUsers(where: { id: "${userId}" }, connect: { posts: { where: { id: "${postId}" } } }) { + updateUsers(where: { id: "${userId}" }, connect: { posts: { where: { node: { id: "${postId}" } } } }) { users { id } @@ -365,7 +365,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -417,7 +417,7 @@ describe("auth/is-authenticated", () => { const query = ` mutation { - updateUsers(where: { id: "${userId}" }, disconnect: { posts: { where: { id: "${postId}" } } }) { + updateUsers(where: { id: "${userId}" }, disconnect: { posts: { where: { node: { id: "${postId}" } } } }) { users { id } @@ -441,7 +441,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -485,7 +485,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -525,7 +525,7 @@ describe("auth/is-authenticated", () => { const query = ` mutation { - deleteUsers(where: {id: "${userId}"}, delete:{posts: {where:{id: "${postId}"}}}) { + deleteUsers(where: {id: "${userId}"}, delete:{posts: {where:{node: { id: "${postId}"}}} }) { nodesDeleted } } @@ -545,7 +545,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -590,7 +590,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -633,7 +633,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); @@ -680,7 +680,7 @@ describe("auth/is-authenticated", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Unauthenticated"); diff --git a/packages/graphql/tests/integration/auth/object-path.int.test.ts b/packages/graphql/tests/integration/auth/object-path.int.test.ts index 133a67359a..ab6193149a 100644 --- a/packages/graphql/tests/integration/auth/object-path.int.test.ts +++ b/packages/graphql/tests/integration/auth/object-path.int.test.ts @@ -90,7 +90,7 @@ describe("auth/object-path", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -218,7 +218,7 @@ describe("auth/object-path", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); diff --git a/packages/graphql/tests/integration/auth/roles.int.test.ts b/packages/graphql/tests/integration/auth/roles.int.test.ts index 4ffcbe896d..c44c021a57 100644 --- a/packages/graphql/tests/integration/auth/roles.int.test.ts +++ b/packages/graphql/tests/integration/auth/roles.int.test.ts @@ -71,7 +71,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -110,7 +110,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -156,7 +156,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -200,7 +200,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -246,7 +246,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -290,7 +290,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -340,7 +340,7 @@ describe("auth/roles", () => { const query = ` mutation { - updateUsers(update: { id: "${userId}" }, connect: { posts: { where: { id: "${postId}" } } }) { + updateUsers(update: { id: "${userId}" }, connect: { posts: { where: { node: { id: "${postId}" } } } }) { users { id } @@ -366,7 +366,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -428,7 +428,9 @@ describe("auth/roles", () => { update: { post: { update: { - creator: { connect: { where: { id: "${userId}" } } } + node: { + creator: { connect: { where: { node: { id: "${userId}" } } } } + } } } } @@ -457,7 +459,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -507,7 +509,7 @@ describe("auth/roles", () => { const query = ` mutation { - updateUsers(update: { id: "${userId}" }, disconnect: { posts: { where: { id: "${postId}" } } }) { + updateUsers(update: { id: "${userId}" }, disconnect: { posts: { where: { node: { id: "${postId}" } } } }) { users { id } @@ -533,7 +535,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -595,7 +597,9 @@ describe("auth/roles", () => { update: { post: { update: { - creator: { disconnect: { where: { id: "${userId}" } } } + node: { + creator: { disconnect: { where: { node: { id: "${userId}" } } } } + } } } } @@ -623,7 +627,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -667,7 +671,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -705,7 +709,7 @@ describe("auth/roles", () => { const query = ` mutation { - deleteUsers(where: {id: "${userId}"}, delete:{posts: {where:{id: "${postId}"}}}) { + deleteUsers(where: {id: "${userId}"}, delete:{posts: {where:{node: { id: "${postId}"}}}}) { nodesDeleted } } @@ -726,7 +730,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -771,7 +775,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -814,7 +818,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -861,7 +865,7 @@ describe("auth/roles", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); diff --git a/packages/graphql/tests/integration/auth/where.int.test.ts b/packages/graphql/tests/integration/auth/where.int.test.ts index 6642f347f7..eeab7e360c 100644 --- a/packages/graphql/tests/integration/auth/where.int.test.ts +++ b/packages/graphql/tests/integration/auth/where.int.test.ts @@ -85,7 +85,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -161,7 +161,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -177,6 +177,96 @@ describe("auth/where", () => { } }); + test("should add $jwt.id to where on a relationship(using connection)", async () => { + const session = driver.session({ defaultAccessMode: "WRITE" }); + + const typeDefs = ` + type User { + id: ID + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) + } + + type Post { + id: ID + creator: User @relationship(type: "HAS_POST", direction: IN) + } + + extend type Post @auth(rules: [{ operations: [READ], where: { creator: { id: "$jwt.sub" } } }]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const postId1 = generate({ + charset: "alphabetic", + }); + const postId2 = generate({ + charset: "alphabetic", + }); + const randomPostId = generate({ + charset: "alphabetic", + }); + + const query = ` + { + users(where: { id: "${userId}" }) { + postsConnection { + edges { + node { + id + } + } + } + } + } + `; + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: userId, + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run(` + CREATE (u:User {id: "${userId}"}) + CREATE (p1:Post {id: "${postId1}"}) + CREATE (p2:Post {id: "${postId2}"}) + CREATE (:Post {id: "${randomPostId}"}) + MERGE (u)-[:HAS_POST]->(p1) + MERGE (u)-[:HAS_POST]->(p2) + `); + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeUndefined(); + + const posts = (gqlResult.data as any).users[0].postsConnection as { edges: { node: { id: string } }[] }; + expect(posts.edges).toHaveLength(2); + const post1 = posts.edges.find((x) => x.node.id === postId1); + expect(post1).toBeTruthy(); + const post2 = posts.edges.find((x) => x.node.id === postId2); + expect(post2).toBeTruthy(); + } finally { + await session.close(); + } + }); + describe("union", () => { test("should add $jwt.id to where and return users search", async () => { const session = driver.session({ defaultAccessMode: "WRITE" }); @@ -249,7 +339,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); const posts = (gqlResult.data as any).users[0].content as any[]; @@ -263,6 +353,98 @@ describe("auth/where", () => { } }); }); + + test("should add $jwt.id to where and return users search(using connections)", async () => { + const session = driver.session({ defaultAccessMode: "WRITE" }); + + const typeDefs = ` + union Content = Post + + type User { + id: ID + content: [Content] @relationship(type: "HAS_CONTENT", direction: OUT) + } + + type Post { + id: ID + creator: User @relationship(type: "HAS_CONTENT", direction: IN) + } + + extend type Post @auth(rules: [{ operations: [READ], where: { creator: { id: "$jwt.sub" } } }]) + extend type User @auth(rules: [{ operations: [READ], where: { id: "$jwt.sub" } }]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const postId1 = generate({ + charset: "alphabetic", + }); + const postId2 = generate({ + charset: "alphabetic", + }); + + const query = ` + { + users { + contentConnection { + edges { + node { + ... on Post { + id + } + } + } + } + } + } + `; + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: userId, + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run(` + CREATE (u:User {id: "${userId}"}) + CREATE (p1:Post {id: "${postId1}"}) + CREATE (p2:Post {id: "${postId2}"}) + CREATE (:Post {id: randomUUID()}) + MERGE (u)-[:HAS_CONTENT]->(p1) + MERGE (u)-[:HAS_CONTENT]->(p2) + `); + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + expect(gqlResult.errors).toBeUndefined(); + const posts = (gqlResult.data as any).users[0].contentConnection as { + edges: { node: { id: string } }[]; + }; + expect(posts.edges).toHaveLength(2); + const post1 = posts.edges.find((x) => x.node.id === postId1); + expect(post1).toBeTruthy(); + const post2 = posts.edges.find((x) => x.node.id === postId2); + expect(post2).toBeTruthy(); + } finally { + await session.close(); + } + }); }); describe("update", () => { @@ -318,7 +500,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -379,7 +561,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -424,7 +606,7 @@ describe("auth/where", () => { const query = ` mutation { - updateUsers(update: { posts: { connect: { where: { id: "${postId}" } } } }) { + updateUsers(update: { posts: { connect: { where: { node: { id: "${postId}" } } } } }) { users { id posts { @@ -460,7 +642,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -497,7 +679,7 @@ describe("auth/where", () => { const query = ` mutation { - updateUsers(connect:{posts:{where:{id: "${postId}"}}}) { + updateUsers(connect:{posts:{where:{node:{id: "${postId}"}}}}) { users { id posts { @@ -533,7 +715,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -572,7 +754,7 @@ describe("auth/where", () => { const query = ` mutation { - updateUsers(update: { posts: { disconnect: { where: { id: "${postId}" } } } }) { + updateUsers(update: { posts: { disconnect: { where: { node: { id: "${postId}" } } } } }) { users { id posts { @@ -607,7 +789,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -644,7 +826,7 @@ describe("auth/where", () => { const query = ` mutation { - updateUsers(disconnect:{posts:{where:{id: "${postId}"}}}) { + updateUsers(disconnect: { posts: { where: {node: { id : "${postId}"}}}}) { users { id posts { @@ -679,7 +861,7 @@ describe("auth/where", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); diff --git a/packages/graphql/tests/integration/autogenerate.int.test.ts b/packages/graphql/tests/integration/autogenerate.int.test.ts index 927d7015e8..cab44da3b9 100644 --- a/packages/graphql/tests/integration/autogenerate.int.test.ts +++ b/packages/graphql/tests/integration/autogenerate.int.test.ts @@ -61,7 +61,7 @@ describe("autogenerate", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -100,7 +100,7 @@ describe("autogenerate", () => { { name: "dan", genres: { - create: [{name: "Comedy"}] + create: [{node: {name: "Comedy"}}] } } ] @@ -120,7 +120,7 @@ describe("autogenerate", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/composite-where.int.test.ts b/packages/graphql/tests/integration/composite-where.int.test.ts new file mode 100644 index 0000000000..83ae95e588 --- /dev/null +++ b/packages/graphql/tests/integration/composite-where.int.test.ts @@ -0,0 +1,200 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import neo4j from "./neo4j"; +import { Neo4jGraphQL } from "../../src/classes"; + +describe("composite-where", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + describe("Delete", () => { + test("should use composite where to delete", async () => { + const session = driver.session(); + + const typeDefs = ` + type Actor { + name: String + } + + type Movie { + id: ID! + actors: [Actor] @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + interface ActedIn { + screenTime: Int + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const actorName = generate({ + charset: "alphabetic", + }); + const movieId = generate({ + charset: "alphabetic", + }); + const screenTime = 60; + + const query = ` + mutation($movieId: ID, $actorName: String, $screenTime: Int) { + updateMovies( + where: { + id: $movieId + } + delete: { + actors: { + where: { + node: { + name: $actorName + } + edge: { + screenTime: $screenTime + } + } + } + } + ) { + movies { + id + actors { + name + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {id: $movieId})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieId, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + variableValues: { movieId, actorName, screenTime }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.updateMovies).toEqual({ movies: [{ id: movieId, actors: [] }] }); + } finally { + await session.close(); + } + }); + }); + + describe("Disconnect", () => { + test("should use composite where to delete", async () => { + const session = driver.session(); + + const typeDefs = ` + type Actor { + name: String + } + + type Movie { + id: ID! + actors: [Actor] @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + interface ActedIn { + screenTime: Int + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const actorName = generate({ + charset: "alphabetic", + }); + const movieId = generate({ + charset: "alphabetic", + }); + const screenTime = 60; + + const query = ` + mutation($movieId: ID, $actorName: String, $screenTime: Int) { + updateMovies( + where: { + id: $movieId + } + disconnect: { + actors: { + where: { + node: { + name: $actorName + } + edge: { + screenTime: $screenTime + } + } + } + } + ) { + movies { + id + actors { + name + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {id: $movieId})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieId, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + variableValues: { movieId, actorName, screenTime }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.updateMovies).toEqual({ movies: [{ id: movieId, actors: [] }] }); + } finally { + await session.close(); + } + }); + }); +}); diff --git a/packages/graphql/tests/integration/connection-resolvers-int.test.ts b/packages/graphql/tests/integration/connection-resolvers-int.test.ts new file mode 100644 index 0000000000..c9d22002b5 --- /dev/null +++ b/packages/graphql/tests/integration/connection-resolvers-int.test.ts @@ -0,0 +1,417 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { offsetToCursor } from "graphql-relay"; +import { generate } from "randomstring"; +import { Neo4jGraphQL } from "../../src/classes"; +import neo4j from "./neo4j"; + +describe("Connection Resolvers", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should define a connection field resolver and resolve it", async () => { + const session = driver.session(); + + const typeDefs = ` + type Actor { + id: ID + name: String! + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Movie { + id: ID + title: String! + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const movieId = generate({ + charset: "alphabetic", + }); + + const actorId = generate({ + charset: "alphabetic", + }); + + const create = ` + mutation { + createMovies(input:[{ + id: "${movieId}", + title: "Point Break", + actors: { + create: [{ + node: { + id: "${actorId}", + name: "Keanu Reeves" + }, + edge: { + screenTime: 100 + } + }] + } + }]) { + movies { + id + actorsConnection { + totalCount + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + edges { + screenTime + node { + id + name + } + } + } + } + } + } + `; + + try { + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: create, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect((gqlResult.data as any).createMovies.movies[0]).toEqual({ + id: movieId, + actorsConnection: { + totalCount: 1, + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + endCursor: expect.any(String), + startCursor: expect.any(String), + }, + edges: [ + { + screenTime: 100, + node: { + id: actorId, + name: "Keanu Reeves", + }, + }, + ], + }, + }); + } finally { + await session.close(); + } + }); + + test("it should provide an after offset that correctly results in the next batch of items", async () => { + const session = driver.session(); + + const typeDefs = ` + type Actor { + id: ID + name: String! + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Movie { + id: ID + title: String! + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + driver, + }); + + const create = ` + mutation CreateMovie($input: [MovieCreateInput!]!) { + createMovies(input: $input) { + movies { + id + title + actorsConnection(first: 5, sort: [{ node: { name: ASC } }]) { + totalCount + pageInfo { + hasNextPage + hasPreviousPage + endCursor + startCursor + } + edges { + cursor + screenTime + node { + id + name + } + } + } + } + } + } + `; + + const actors = [...Array(20).keys()].map((x) => ({ + node: { + id: x.toString(), + name: String.fromCharCode(x + 1 + 64) + generate({ charset: "alphabetic" }), + }, + edge: { + screenTime: Math.floor(Math.random() * 200), + }, + })); + + const movieTitle = "Bill & Ted's Excellent Pagination Adventure"; + const movieId = generate({ charset: "alphabetic" }); + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: create, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { + input: [ + { + id: movieId, + title: movieTitle, + actors: { + create: actors, + }, + }, + ], + }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.createMovies?.movies).toEqual([ + { + id: movieId, + title: movieTitle, + actorsConnection: { + totalCount: 20, + edges: actors.slice(0, 5).map(({ node, edge }) => ({ + node, + screenTime: edge.screenTime, + cursor: expect.any(String), + })), + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: offsetToCursor(0), + endCursor: offsetToCursor(4), + }, + }, + }, + ]); + + const secondQuery = ` + query Movies($movieId: ID!, $endCursor: String) { + movies(where: { id: $movieId }) { + id + title + actorsConnection(sort: [{ node: { name: ASC } }], first: 5, after: $endCursor) { + totalCount + edges { + cursor + screenTime + node { + id + name + } + } + pageInfo { + hasPreviousPage + hasNextPage + startCursor + endCursor + } + } + } + } + `; + + const result2 = await graphql({ + schema: neoSchema.schema, + source: secondQuery, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { + movieId, + endCursor: result?.data?.createMovies.movies[0].actorsConnection.pageInfo.endCursor, + }, + }); + expect(result2.errors).toBeFalsy(); + + expect(result2?.data?.movies[0]).toEqual({ + id: movieId, + title: movieTitle, + actorsConnection: { + totalCount: 20, + edges: actors.slice(5, 10).map(({ node, edge }) => ({ + node, + cursor: expect.any(String), + screenTime: edge.screenTime, + })), + pageInfo: { + hasPreviousPage: true, + hasNextPage: true, + startCursor: offsetToCursor(5), + endCursor: offsetToCursor(9), + }, + }, + }); + + const result3 = await graphql({ + schema: neoSchema.schema, + source: secondQuery, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { + movieId, + endCursor: result2?.data?.movies[0].actorsConnection.pageInfo.endCursor, + }, + }); + + expect(result3.errors).toBeFalsy(); + + expect(result3?.data?.movies[0]).toEqual({ + id: movieId, + title: movieTitle, + actorsConnection: { + totalCount: 20, + edges: actors.slice(10, 15).map(({ node, edge }) => ({ + node, + cursor: expect.any(String), + screenTime: edge.screenTime, + })), + pageInfo: { + hasPreviousPage: true, + hasNextPage: true, + startCursor: offsetToCursor(10), + endCursor: offsetToCursor(14), + }, + }, + }); + } finally { + await session.close(); + } + }); + + test("should return a total count of zero and correct pageInfo if no edges", async () => { + const session = driver.session(); + + const typeDefs = ` + type Actor { + id: ID + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) + } + + type Movie { + id: ID + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN) + } + `; + + const movieId = generate({ + charset: "alphabetic", + }); + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + driver, + }); + + const query = ` + query GetMovie($movieId: ID) { + movies(where: { id: $movieId }) { + id + actorsConnection { + totalCount + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + } + `; + + try { + await session.run("CREATE (:Movie { id: $movieId })", { movieId }); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieId }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).movies).toEqual([ + { + id: movieId, + actorsConnection: { + totalCount: 0, + pageInfo: { startCursor: null, endCursor: null, hasNextPage: false, hasPreviousPage: false }, + }, + }, + ]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/connections/alias.int.test.ts b/packages/graphql/tests/integration/connections/alias.int.test.ts new file mode 100644 index 0000000000..4cb69463b2 --- /dev/null +++ b/packages/graphql/tests/integration/connections/alias.int.test.ts @@ -0,0 +1,1027 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import { gql } from "apollo-server"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Connections Alias", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + // using totalCount as the bear minimal selection + test("should alias top level connection field and return correct totalCount", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actors: actorsConnection { + totalCount + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect(result.data as any).toEqual({ + movies: [{ actors: { totalCount: 3 } }], + }); + } finally { + await session.close(); + } + }); + + test("should alias totalCount", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection { + count: totalCount + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect(result.data as any).toEqual({ + movies: [{ actorsConnection: { count: 3 } }], + }); + } finally { + await session.close(); + } + }); + + // using hasNextPage as the bear minimal selection + test("should alias pageInfo top level key", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection { + pi:pageInfo { + hasNextPage + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect(result.data as any).toEqual({ + movies: [{ actorsConnection: { pi: { hasNextPage: false } } }], + }); + } finally { + await session.close(); + } + }); + + test("should alias startCursor", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection { + pageInfo { + sc:startCursor + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.pageInfo.sc).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias endCursor", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection { + pageInfo { + ec:endCursor + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.pageInfo.ec).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias hasPreviousPage", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection { + pageInfo { + hPP:hasPreviousPage + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + CREATE (m)<-[:ACTED_IN]-(:Actor) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.pageInfo.hPP).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias hasNextPage", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection(first: 1) { + pageInfo { + hNP:hasNextPage + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.pageInfo.hNP).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias the top level edges key", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection(first: 1) { + e:edges { + cursor + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.e[0].cursor).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias cursor", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection(first: 1) { + edges { + c:cursor + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.edges[0].c).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias the top level node key", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection(first: 1) { + edges { + n:node { + name + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.edges[0].n).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias a property on the node", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection(first: 1) { + edges { + node { + n:name + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.edges[0].node.n).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias a property on the relationship", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + interface ActedIn { + roles: [String]! + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + actorsConnection(first: 1) { + edges { + r:roles + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN {roles: [randomUUID()]}]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN {roles: [randomUUID()]}]-(:Actor {name: randomUUID()}) + CREATE (m)<-[:ACTED_IN {roles: [randomUUID()]}]-(:Actor {name: randomUUID()}) + `, + { + movieTitle, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.edges[0].r).toBeDefined(); + } finally { + await session.close(); + } + }); + + test("should alias many keys on a connection", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + type Actor { + name: String! + } + + interface ActedIn { + roles: [String]! + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + const actorName = generate({ + charset: "alphabetic", + }); + const roles = [ + generate({ + charset: "alphabetic", + }), + ]; + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + title + connection:actorsConnection { + tC:totalCount + edges { + n:node { + n:name + } + r:roles + } + page:pageInfo { + hNP:hasNextPage + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $movieTitle}) + CREATE (m)<-[:ACTED_IN {roles: $roles}]-(:Actor {name: $actorName}) + `, + { + movieTitle, + actorName, + roles, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect(result.data as any).toEqual({ + movies: [ + { + title: movieTitle, + connection: { + tC: 1, + edges: [{ n: { n: actorName }, r: roles }], + page: { + hNP: false, + }, + }, + }, + ], + }); + } finally { + await session.close(); + } + }); + + test("should allow multiple aliases on the same connection", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Post { + title: String! + comments: [Comment!]! @relationship(type: "HAS_COMMENT", direction: OUT) + } + type Comment { + flag: Boolean! + post: Post! @relationship(type: "HAS_COMMENT", direction: IN) + } + `; + + const { schema } = new Neo4jGraphQL({ typeDefs }); + + const postTitle = generate({ charset: "alphabetic" }); + + const flags = [true, true, false]; + + const flaggedCount = flags.filter((flag) => flag).length; + const unflaggedCount = flags.filter((flag) => !flag).length; + + const query = ` + { + posts(where: { title: "${postTitle}"}) { + flagged: commentsConnection(where: { node: { flag: true } }) { + edges { + node { + flag + } + } + } + unflagged: commentsConnection(where: { node: { flag: false } }) { + edges { + node { + flag + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (post:Post {title: $postTitle}) + FOREACH(flag in $flags | + CREATE (:Comment {flag: flag})<-[:HAS_COMMENT]-(post) + ) + `, + { + postTitle, + flags, + } + ); + + const result = await graphql({ + schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).posts[0].flagged.edges).toContainEqual({ node: { flag: true } }); + expect((result.data as any).posts[0].flagged.edges).toHaveLength(flaggedCount); + expect((result.data as any).posts[0].unflagged.edges).toContainEqual({ node: { flag: false } }); + expect((result.data as any).posts[0].unflagged.edges).toHaveLength(unflaggedCount); + } finally { + await session.close(); + } + }); + + test("should allow alias on nested connections", async () => { + const movieTitle = "The Matrix"; + const actorName = "Keanu Reeves"; + const screenTime = 120; + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const { schema } = new Neo4jGraphQL({ typeDefs }); + const session = driver.session(); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + title + actorsConnection(where: { node: { name: "${actorName}" } }) { + edges { + screenTime + node { + name + b: moviesConnection(where: { node: { title: "${movieTitle}"}}) { + edges { + node { + title + a: actors { + name + } + } + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (movie:Movie {title: $movieTitle}) + CREATE (actor:Actor {name: $actorName}) + CREATE (actor)-[:ACTED_IN {screenTime: $screenTime}]->(movie) + `, + { + movieTitle, + actorName, + screenTime, + } + ); + + const result = await graphql({ + schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.edges[0].node.b).toEqual({ + edges: [ + { + node: { + title: movieTitle, + a: [ + { + name: actorName, + }, + ], + }, + }, + ], + }); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/connections/enums.int.test.ts b/packages/graphql/tests/integration/connections/enums.int.test.ts new file mode 100644 index 0000000000..9f53eb56a8 --- /dev/null +++ b/packages/graphql/tests/integration/connections/enums.int.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Enum Relationship Properties", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should create a movie and an actor, with an enum as a relationship property", async () => { + const session = driver.session(); + + const roleTypeResolver = { + LEADING: "Leading", + SUPPORTING: "Supporting", + }; + + const typeDefs = ` + type Actor { + name: String! + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") + } + + type Movie { + title: String! + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") + } + + enum RoleType { + LEADING + SUPPORTING + } + + interface ActedIn { + roleType: RoleType! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: { RoleType: roleTypeResolver }, + }); + + const title = generate({ + charset: "alphabetic", + }); + + const name = generate({ + charset: "alphabetic", + }); + + const create = ` + mutation CreateMovies($title: String!, $name: String!) { + createMovies( + input: [ + { + title: $title + actors: { + create: [ + { + edge: { roleType: LEADING } + node: { name: $name } + } + ] + } + } + ] + ) { + movies { + title + actorsConnection { + edges { + roleType + node { + name + } + } + } + } + } + } + `; + + try { + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: create, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { title, name }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult.data).toEqual({ + createMovies: { + movies: [{ title, actorsConnection: { edges: [{ roleType: "LEADING", node: { name } }] } }], + }, + }); + + const result = await session.run(` + MATCH (m:Movie)<-[ai:ACTED_IN]-(a:Actor) + WHERE m.title = "${title}" AND a.name = "${name}" + RETURN ai + `); + + expect((result.records[0].toObject() as any).ai.properties).toEqual({ roleType: "Leading" }); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/connections/nested.int.test.ts b/packages/graphql/tests/integration/connections/nested.int.test.ts new file mode 100644 index 0000000000..ce308345ee --- /dev/null +++ b/packages/graphql/tests/integration/connections/nested.int.test.ts @@ -0,0 +1,198 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Connections Alias", () => { + let driver: Driver; + + const movieTitle = "Forrest Gump"; + const actorName = "Tom Hanks"; + const screenTime = 120; + + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const { schema } = new Neo4jGraphQL({ typeDefs }); + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should allow nested connections", async () => { + const session = driver.session(); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + title + actorsConnection(where: { node: { name: "${actorName}" } }) { + edges { + screenTime + node { + name + moviesConnection { + edges { + node { + title + actors { + name + } + } + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (movie:Movie {title: $movieTitle}) + CREATE (actor:Actor {name: $actorName}) + CREATE (actor)-[:ACTED_IN {screenTime: $screenTime}]->(movie) + `, + { + movieTitle, + actorName, + screenTime, + } + ); + + const result = await graphql({ + schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.edges[0].node.moviesConnection).toEqual({ + edges: [ + { + node: { + title: movieTitle, + actors: [ + { + name: actorName, + }, + ], + }, + }, + ], + }); + } finally { + await session.close(); + } + }); + + test("should allow where clause on nested connections", async () => { + const session = driver.session(); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + title + actorsConnection(where: { node: { name: "${actorName}" } }) { + edges { + screenTime + node { + name + moviesConnection(where: { node: { title: "${movieTitle}" } }) { + edges { + node { + title + actors { + name + } + } + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (movie:Movie {title: $movieTitle}) + CREATE (actor:Actor {name: $actorName}) + CREATE (actor)-[:ACTED_IN {screenTime: $screenTime}]->(movie) + `, + { + movieTitle, + actorName, + screenTime, + } + ); + + const result = await graphql({ + schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeUndefined(); + + expect((result.data as any).movies[0].actorsConnection.edges[0].node.moviesConnection).toEqual({ + edges: [ + { + node: { + title: movieTitle, + actors: [ + { + name: actorName, + }, + ], + }, + }, + ], + }); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/connections/unions.int.test.ts b/packages/graphql/tests/integration/connections/unions.int.test.ts new file mode 100644 index 0000000000..ab214540ee --- /dev/null +++ b/packages/graphql/tests/integration/connections/unions.int.test.ts @@ -0,0 +1,363 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Connections -> Unions", () => { + let driver: Driver; + const typeDefs = gql` + union Publication = Book | Journal + + type Author { + name: String! + publications: [Publication] @relationship(type: "WROTE", direction: OUT, properties: "Wrote") + } + + type Book { + title: String! + author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") + } + + type Journal { + subject: String! + author: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote") + } + + interface Wrote { + words: Int! + } + `; + + beforeAll(async () => { + driver = await neo4j(); + const session = driver.session(); + + try { + await session.run( + "CREATE (:Author { name: 'Charles Dickens' })-[:WROTE { words: 167543 }]->(:Book { title: 'Oliver Twist'})" + ); + await session.run( + `MATCH (a:Author) WHERE a.name = 'Charles Dickens' CREATE (a)-[:WROTE { words: 3413 }]->(:Journal { subject: "Master Humphrey's Clock" })` + ); + } finally { + await session.close(); + } + }); + + afterAll(async () => { + const session = driver.session(); + + try { + await session.run("MATCH (a:Author) WHERE a.name = 'Charles Dickens' DETACH DELETE a"); + await session.run("MATCH (b:Book) WHERE b.title = 'Oliver Twist' DETACH DELETE b"); + await session.run(`MATCH (j:Journal) WHERE j.subject = "Master Humphrey's Clock" DETACH DELETE j`); + } finally { + await session.close(); + } + + await driver.close(); + }); + + test("Projecting node and relationship properties with no arguments", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + authors(where: { name: "Charles Dickens" }) { + name + publicationsConnection { + edges { + words + node { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.authors).toEqual([ + { + name: "Charles Dickens", + publicationsConnection: { + edges: [ + { + words: 167543, + node: { + title: "Oliver Twist", + }, + }, + { + words: 3413, + node: { + subject: "Master Humphrey's Clock", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("Projecting node and relationship properties for one union member with no arguments", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + authors(where: { name: "Charles Dickens" }) { + name + publicationsConnection { + edges { + words + node { + ... on Book { + title + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.authors).toEqual([ + { + name: "Charles Dickens", + publicationsConnection: { + edges: [ + { + words: 167543, + node: { + title: "Oliver Twist", + }, + }, + { + words: 3413, + node: {}, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With where argument on node", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + authors(where: { name: "Charles Dickens" }) { + name + publicationsConnection(where: { Book: { node: { title: "Oliver Twist" } } }) { + edges { + words + node { + ... on Book { + title + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.authors).toEqual([ + { + name: "Charles Dickens", + publicationsConnection: { + edges: [ + { + words: 167543, + node: { + title: "Oliver Twist", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With where argument on relationship", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + authors(where: { name: "Charles Dickens" }) { + name + publicationsConnection(where: { Book: { edge: { words: 167543 } } }) { + edges { + words + node { + ... on Book { + title + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.authors).toEqual([ + { + name: "Charles Dickens", + publicationsConnection: { + edges: [ + { + words: 167543, + node: { + title: "Oliver Twist", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With where argument on relationship and node", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + authors(where: { name: "Charles Dickens" }) { + name + publicationsConnection(where: { Book: { edge: { words: 167543 }, node: { title: "Oliver Twist" } } }) { + edges { + words + node { + ... on Book { + title + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.authors).toEqual([ + { + name: "Charles Dickens", + publicationsConnection: { + edges: [ + { + words: 167543, + node: { + title: "Oliver Twist", + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/count.int.test.ts b/packages/graphql/tests/integration/count.int.test.ts new file mode 100644 index 0000000000..1ba93ff942 --- /dev/null +++ b/packages/graphql/tests/integration/count.int.test.ts @@ -0,0 +1,279 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import pluralize from "pluralize"; +import { IncomingMessage } from "http"; +import { Socket } from "net"; +import jsonwebtoken from "jsonwebtoken"; +import camelCase from "camelcase"; +import neo4j from "./neo4j"; +import { Neo4jGraphQL } from "../../src/classes"; + +describe("count", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should count nodes", async () => { + const session = driver.session(); + + const randomType = `${generate({ + charset: "alphabetic", + readable: true, + })}Movie`; + + const pluralRandomType = pluralize(camelCase(randomType)); + + const typeDefs = ` + type ${randomType} { + id: ID + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + try { + await session.run( + ` + CREATE (:${randomType} {id: randomUUID()}) + CREATE (:${randomType} {id: randomUUID()}) + ` + ); + + const query = ` + { + ${pluralRandomType}Count + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any)[`${pluralRandomType}Count`]).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should movie nodes with where predicate", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const id1 = generate({ + charset: "alphabetic", + }); + + const id2 = generate({ + charset: "alphabetic", + }); + + try { + await session.run( + ` + CREATE (:Movie {id: $id1}) + CREATE (:Movie {id: $id2}) + `, + { id1, id2 } + ); + + const query = ` + { + moviesCount(where: { OR: [{id: "${id1}"}, {id: "${id2}"}] }) + } + `; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).moviesCount).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should add auth where (read) to count query", async () => { + const session = driver.session(); + + const typeDefs = ` + type User { + id: ID + } + + type Post { + id: ID + creator: User @relationship(type: "POSTED", direction: IN) + } + + extend type Post @auth(rules: [{ where: { creator: { id: "$jwt.sub" } } }]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const post1 = generate({ + charset: "alphabetic", + }); + + const post2 = generate({ + charset: "alphabetic", + }); + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: userId, + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run( + ` + CREATE (u:User {id: $userId}) + CREATE (u)-[:POSTED]->(:Post {id: $post1}) + CREATE (u)-[:POSTED]->(:Post {id: $post2}) + CREATE (:Post {id: randomUUID()}) + `, + { userId, post1, post2 } + ); + + const query = ` + { + postsCount + } + `; + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).postsCount).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should throw forbidden with invalid allow on auth (read) while counting", async () => { + const session = driver.session(); + + const typeDefs = ` + type User { + id: ID + } + + extend type User @auth(rules: [{ allow: { id: "$jwt.sub" } }]) + `; + + const userId = generate({ + charset: "alphabetic", + }); + + const secret = "secret"; + + const token = jsonwebtoken.sign( + { + roles: [], + sub: "invalid", + }, + secret + ); + + const neoSchema = new Neo4jGraphQL({ typeDefs, config: { jwt: { secret } } }); + + try { + await session.run( + ` + CREATE (u:User {id: $userId}) + `, + { userId } + ); + + const query = ` + { + usersCount + } + `; + + const socket = new Socket({ readable: true }); + const req = new IncomingMessage(socket); + req.headers.authorization = `Bearer ${token}`; + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/create.int.test.ts b/packages/graphql/tests/integration/create.int.test.ts index 07502dc65d..58693956fd 100644 --- a/packages/graphql/tests/integration/create.int.test.ts +++ b/packages/graphql/tests/integration/create.int.test.ts @@ -68,7 +68,7 @@ describe("create", () => { schema: neoSchema.schema, source: query, variableValues: { id }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -126,7 +126,7 @@ describe("create", () => { schema: neoSchema.schema, source: query, variableValues: { id1, id2 }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -262,25 +262,29 @@ describe("create", () => { input: [ { ...product, - sizes: { create: sizes }, - colors: { create: colors }, + sizes: { create: sizes.map((x) => ({ node: x })) }, + colors: { create: colors.map((x) => ({ node: x })) }, photos: { create: [ - photos[0], + { node: photos[0] }, { - ...photos[1], - color: { connect: { where: { id: colors[0].id } } }, + node: { + ...photos[1], + color: { connect: { where: { node: { id: colors[0].id } } } }, + }, }, { - ...photos[2], - color: { connect: { where: { id: colors[1].id } } }, + node: { + ...photos[2], + color: { connect: { where: { node: { id: colors[1].id } } } }, + }, }, ], }, }, ], }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/custom-directives.int.test.ts b/packages/graphql/tests/integration/custom-directives.int.test.ts index 8eeb6b2847..43fe972939 100644 --- a/packages/graphql/tests/integration/custom-directives.int.test.ts +++ b/packages/graphql/tests/integration/custom-directives.int.test.ts @@ -84,7 +84,7 @@ describe("Custom Directives", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/custom-resolvers.int.test.ts b/packages/graphql/tests/integration/custom-resolvers.int.test.ts index e41c777270..782aaa87de 100644 --- a/packages/graphql/tests/integration/custom-resolvers.int.test.ts +++ b/packages/graphql/tests/integration/custom-resolvers.int.test.ts @@ -72,7 +72,7 @@ describe("Custom Resolvers", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -353,7 +353,7 @@ describe("Custom Resolvers", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -421,7 +421,7 @@ describe("Custom Resolvers", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -462,7 +462,7 @@ describe("Custom Resolvers", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -514,7 +514,7 @@ describe("Custom Resolvers", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -571,7 +571,7 @@ describe("Custom Resolvers", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/cypher.int.test.ts b/packages/graphql/tests/integration/cypher.int.test.ts index a40ff60a28..f05ead150a 100644 --- a/packages/graphql/tests/integration/cypher.int.test.ts +++ b/packages/graphql/tests/integration/cypher.int.test.ts @@ -95,7 +95,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { title: movieTitle }, }); @@ -165,7 +165,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { title: movieTitle, name: actorName }, }); @@ -248,7 +248,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { title: movieTitle, name: actorName }, }); @@ -326,7 +326,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { titles: [movieTitle1, movieTitle2, movieTitle3] }, }); @@ -349,6 +349,109 @@ describe("cypher", () => { await session.close(); } }); + + test("should query multiple connection fields on a type", async () => { + const session = driver.session(); + + const title = generate({ + charset: "alphabetic", + }); + + const actorName = generate({ + charset: "alphabetic", + }); + const directorName = generate({ + charset: "alphabetic", + }); + + const typeDefs = ` + type Movie { + title: String! + actors: [Actor] @relationship(type: "ACTED_IN", direction: IN) + directors: [Director] @relationship(type: "DIRECTED", direction: IN) + } + + type Actor { + name: String! + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) + } + + type Director { + name: String! + movies: [Movie] @relationship(type: "DIRECTED", direction: OUT) + } + + type Query { + movie(title: String!): Movie + @cypher( + statement: """ + MATCH (m:Movie) + WHERE m.title = $title + RETURN m + """ + ) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const source = ` + query($title: String!) { + movie(title: $title) { + title + actorsConnection { + edges { + node { + name + } + } + } + directorsConnection { + edges { + node { + name + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {title: $title})<-[:ACTED_IN]-(:Actor {name: $actorName}) + CREATE (m)<-[:DIRECTED]-(:Director {name: $directorName}) + `, + { + title, + actorName, + directorName, + } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { title }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.movie).toEqual({ + title, + actorsConnection: { + edges: [{ node: { name: actorName } }], + }, + directorsConnection: { + edges: [{ node: { name: directorName } }], + }, + }); + } finally { + await session.close(); + } + }); }); describe("Mutation", () => { @@ -408,7 +511,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { title: movieTitle }, }); @@ -478,7 +581,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { title: movieTitle }, }); @@ -548,7 +651,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { title: movieTitle }, }); @@ -623,7 +726,7 @@ describe("cypher", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id: townId }, }); diff --git a/packages/graphql/tests/integration/default-values.int.test.ts b/packages/graphql/tests/integration/default-values.int.test.ts index f0deaa9b7e..d66d84d6b3 100644 --- a/packages/graphql/tests/integration/default-values.int.test.ts +++ b/packages/graphql/tests/integration/default-values.int.test.ts @@ -76,7 +76,7 @@ describe("Default values", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -122,7 +122,7 @@ describe("Default values", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -266,7 +266,7 @@ describe("Default values", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/delete.int.test.ts b/packages/graphql/tests/integration/delete.int.test.ts index 7bdbaac17b..ab94029b9d 100644 --- a/packages/graphql/tests/integration/delete.int.test.ts +++ b/packages/graphql/tests/integration/delete.int.test.ts @@ -71,7 +71,7 @@ describe("delete", () => { schema: neoSchema.schema, source: mutation, variableValues: { id }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -128,7 +128,7 @@ describe("delete", () => { schema: neoSchema.schema, source: mutation, variableValues: { id: "NOT FOUND" }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -176,7 +176,7 @@ describe("delete", () => { const mutation = ` mutation($id: ID!, $name: String) { - deleteMovies(where: { id: $id }, delete: { actors: { where: { name: $name } } }) { + deleteMovies(where: { id: $id }, delete: { actors: { where: { node: { name: $name } } } }) { nodesDeleted relationshipsDeleted } @@ -200,7 +200,7 @@ describe("delete", () => { schema: neoSchema.schema, source: mutation, variableValues: { id, name }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -264,7 +264,7 @@ describe("delete", () => { mutation($id1: ID!, $name: String, $id2: ID!) { deleteMovies( where: { id: $id1 } - delete: { actors: { where: { name: $name }, delete: { movies: { where: { id: $id2 } } } } } + delete: { actors: { where: { node: { name: $name } }, delete: { movies: { where: { node: { id: $id2 } } } } } } ) { nodesDeleted relationshipsDeleted @@ -292,7 +292,7 @@ describe("delete", () => { schema: neoSchema.schema, source: mutation, variableValues: { id1, name, id2 }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -332,4 +332,65 @@ describe("delete", () => { await session.close(); } }); + + test("should delete a movie using a connection where filter", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Actor { + name: String + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) + } + + type Movie { + title: String + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const title = generate({ + charset: "alphabetic", + }); + + const name = generate({ + charset: "alphabetic", + }); + + const mutation = ` + mutation($name: String) { + deleteMovies(where: { actorsConnection: { node: { name: $name } } } ) { + nodesDeleted + relationshipsDeleted + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {id: $title})<-[:ACTED_IN]-(:Actor {name: $name}) + CREATE (:Movie {id: $title})<-[:ACTED_IN]-(:Actor {name: randomUUID()}) + `, + { + title, + name, + } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: mutation, + variableValues: { name }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.deleteMovies).toEqual({ nodesDeleted: 1, relationshipsDeleted: 1 }); + } finally { + await session.close(); + } + }); }); diff --git a/packages/graphql/tests/integration/enums.int.test.ts b/packages/graphql/tests/integration/enums.int.test.ts index 68674d9681..2e444d5e46 100644 --- a/packages/graphql/tests/integration/enums.int.test.ts +++ b/packages/graphql/tests/integration/enums.int.test.ts @@ -72,7 +72,7 @@ describe("enums", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/field-filtering.int.test.ts b/packages/graphql/tests/integration/field-filtering.int.test.ts new file mode 100644 index 0000000000..8333cb8f21 --- /dev/null +++ b/packages/graphql/tests/integration/field-filtering.int.test.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { generate } from "randomstring"; +import { gql } from "apollo-server"; +import neo4j from "./neo4j"; +import { Neo4jGraphQL } from "../../src/classes"; + +describe("field-filtering", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should use connection filter on field", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Movie { + title: String! + genres: [Genre!]! @relationship(type: "IN_GENRE", direction: OUT) + } + + type Genre { + name: String! + series: [Series!]! @relationship(type: "IN_SERIES", direction: OUT) + } + + type Series { + name: String! + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const genreName1 = generate({ + charset: "alphabetic", + }); + const genreName2 = generate({ + charset: "alphabetic", + }); + + const seriesName = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies(where: { title: "${movieTitle}" }) { + title + genres(where: { seriesConnection: { node: { name: "${seriesName}" } } }) { + name + series { + name + } + } + } + } + `; + + const cypher = ` + CREATE (m:Movie {title:$movieTitle})-[:IN_GENRE]->(:Genre {name:$genreName1})-[:IN_SERIES]->(:Series {name:$seriesName}) + CREATE (m)-[:IN_GENRE]->(:Genre {name:$genreName2}) + `; + + try { + await session.run(cypher, { movieTitle, genreName1, seriesName, genreName2 }); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + if (gqlResult.errors) { + console.log(JSON.stringify(gqlResult.errors, null, 2)); + } + + expect(gqlResult.errors).toBeUndefined(); + + expect((gqlResult.data as any).movies).toEqual([ + { title: movieTitle, genres: [{ name: genreName1, series: [{ name: seriesName }] }] }, + ]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/find.int.test.ts b/packages/graphql/tests/integration/find.int.test.ts index b2b52fa37f..0575de119d 100644 --- a/packages/graphql/tests/integration/find.int.test.ts +++ b/packages/graphql/tests/integration/find.int.test.ts @@ -78,7 +78,7 @@ describe("find", () => { schema: neoSchema.schema, source: query, variableValues: { id }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); @@ -131,7 +131,7 @@ describe("find", () => { schema: neoSchema.schema, source: query, variableValues: { id }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); @@ -190,7 +190,7 @@ describe("find", () => { schema: neoSchema.schema, source: query, variableValues: { ids: [id1, id2, id3] }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); @@ -255,7 +255,7 @@ describe("find", () => { schema: neoSchema.schema, source: query, variableValues: { ids: [id1, id2, id3], title }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); @@ -347,7 +347,7 @@ describe("find", () => { movieIds: [movieId1, movieId2, movieId3], actorIds: [actorId1, actorId2, actorId3], }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); @@ -485,7 +485,7 @@ describe("find", () => { schema: neoSchema.schema, source: query, variableValues: { movieIds: [movieId1, movieId2, movieId3], actorIds: [actorId1, actorId2, actorId3] }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); @@ -550,7 +550,7 @@ describe("find", () => { schema: neoSchema.schema, source: query, variableValues: { movieWhere: { OR: [{ title, id }] } }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/floats.int.test.ts b/packages/graphql/tests/integration/floats.int.test.ts index 4659e78ef9..f5ad497adf 100644 --- a/packages/graphql/tests/integration/floats.int.test.ts +++ b/packages/graphql/tests/integration/floats.int.test.ts @@ -73,7 +73,7 @@ describe("floats", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -135,7 +135,7 @@ describe("floats", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { imdbRating_float: imdbRatingFloat, imdbRating_int: imdbRatingInt, @@ -206,7 +206,7 @@ describe("floats", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { float, floats, @@ -269,7 +269,7 @@ describe("floats", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/ignore-directive.int.test.ts b/packages/graphql/tests/integration/ignore-directive.int.test.ts index 5e411b2cbd..241de201ab 100644 --- a/packages/graphql/tests/integration/ignore-directive.int.test.ts +++ b/packages/graphql/tests/integration/ignore-directive.int.test.ts @@ -77,7 +77,7 @@ describe("@ignore directive", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: usersQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { username }, }); diff --git a/packages/graphql/tests/integration/issues/190.int.test.ts b/packages/graphql/tests/integration/issues/190.int.test.ts index 82780e5b84..7c2fd7f56c 100644 --- a/packages/graphql/tests/integration/issues/190.int.test.ts +++ b/packages/graphql/tests/integration/issues/190.int.test.ts @@ -109,7 +109,7 @@ describe("https://github.com/neo4j/graphql/issues/190", () => { const result = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); @@ -165,7 +165,7 @@ describe("https://github.com/neo4j/graphql/issues/190", () => { const result = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/issues/207.int.test.ts b/packages/graphql/tests/integration/issues/207.int.test.ts index abe4135e77..53cdc1635d 100644 --- a/packages/graphql/tests/integration/issues/207.int.test.ts +++ b/packages/graphql/tests/integration/issues/207.int.test.ts @@ -90,7 +90,7 @@ describe("https://github.com/neo4j/graphql/issues/207", () => { const result = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/issues/235.int.test.ts b/packages/graphql/tests/integration/issues/235.int.test.ts index 68a248b08f..20d0489c75 100644 --- a/packages/graphql/tests/integration/issues/235.int.test.ts +++ b/packages/graphql/tests/integration/issues/235.int.test.ts @@ -35,7 +35,7 @@ describe("https://github.com/neo4j/graphql/issues/235", () => { await driver.close(); }); - test("should return the correct number of results following connect", async () => { + test("should create the correct number of nodes following multiple connect", async () => { const typeDefs = gql` type A { ID: ID! @id @@ -80,8 +80,8 @@ describe("https://github.com/neo4j/graphql/issues/235", () => { input: [ { name: $a - rel_b: { connect: { where: { name_IN: [$b1, $b2] } } } - rel_c: { create: { name: $c } } + rel_b: { connect: { where: { node: { name_IN: [$b1, $b2] } } } } + rel_c: { create: { node: { name: $c } } } } ] ) { diff --git a/packages/graphql/tests/integration/issues/247.int.test.ts b/packages/graphql/tests/integration/issues/247.int.test.ts index ddda8d99d3..9c462f5122 100644 --- a/packages/graphql/tests/integration/issues/247.int.test.ts +++ b/packages/graphql/tests/integration/issues/247.int.test.ts @@ -80,7 +80,7 @@ describe("https://github.com/neo4j/graphql/issues/247", () => { mutation Connect($name: String, $title2: String, $title3: String) { updateUsers( where: { name: $name } - connect: { movies: [{ where: { title_IN: [$title2, $title3] } }] } + connect: { movies: [{ where: { node: { title_IN: [$title2, $title3] } } }] } ) { users { name diff --git a/packages/graphql/tests/integration/issues/283.int.test.ts b/packages/graphql/tests/integration/issues/283.int.test.ts index 724908dcd9..0a84da6d03 100644 --- a/packages/graphql/tests/integration/issues/283.int.test.ts +++ b/packages/graphql/tests/integration/issues/283.int.test.ts @@ -88,7 +88,7 @@ describe("https://github.com/neo4j/graphql/issues/283", () => { const result = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/issues/288.int.test.ts b/packages/graphql/tests/integration/issues/288.int.test.ts new file mode 100644 index 0000000000..e195ed6590 --- /dev/null +++ b/packages/graphql/tests/integration/issues/288.int.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("https://github.com/neo4j/graphql/issues/288", () => { + let driver: Driver; + const typeDefs = gql` + type USER { + USERID: String + COMPANYID: String + COMPANY: [COMPANY] @relationship(type: "IS_PART_OF", direction: OUT) + } + + type COMPANY { + USERS: [USER] @relationship(type: "IS_PART_OF", direction: IN) + } + `; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("COMPANYID can be populated on create and update", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const userid = generate({ charset: "alphabetic" }); + const companyid1 = generate({ charset: "alphabetic" }); + const companyid2 = generate({ charset: "alphabetic" }); + + const createMutation = ` + mutation { + createUSERS(input: { USERID: "${userid}", COMPANYID: "${companyid1}" }) { + users { + USERID + COMPANYID + } + } + } + `; + + const updateMutation = ` + mutation { + updateUSERS(where: { USERID: "${userid}" }, update: { COMPANYID: "${companyid2}" }) { + users { + USERID + COMPANYID + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const createResult = await graphql({ + schema: neoSchema.schema, + source: createMutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(createResult.errors).toBeFalsy(); + + expect(createResult?.data?.createUSERS?.users).toEqual([{ USERID: userid, COMPANYID: companyid1 }]); + + const updateResult = await graphql({ + schema: neoSchema.schema, + source: updateMutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(updateResult.errors).toBeFalsy(); + + expect(updateResult?.data?.updateUSERS?.users).toEqual([{ USERID: userid, COMPANYID: companyid2 }]); + + await session.run(`MATCH (u:USER) WHERE u.USERID = "${userid}" DELETE u`); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/issues/315.int.test.ts b/packages/graphql/tests/integration/issues/315.int.test.ts index af6eebe7a2..714f2cecf0 100644 --- a/packages/graphql/tests/integration/issues/315.int.test.ts +++ b/packages/graphql/tests/integration/issues/315.int.test.ts @@ -82,51 +82,75 @@ describe("https://github.com/neo4j/graphql/issues/315", () => { friends: { create: [ { - id: generate({ charset: "alphabetic" }), - posts: { - create: [ - { - content: generate({ charset: "alphabetic" }), - }, - { - content: generate({ charset: "alphabetic" }), - }, - { - content: generate({ charset: "alphabetic" }), - }, - ], + node: { + id: generate({ charset: "alphabetic" }), + posts: { + create: [ + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + ], + }, }, }, { - id: generate({ charset: "alphabetic" }), - posts: { - create: [ - { - content: generate({ charset: "alphabetic" }), - }, - { - content: generate({ charset: "alphabetic" }), - }, - { - content: generate({ charset: "alphabetic" }), - }, - ], + node: { + id: generate({ charset: "alphabetic" }), + posts: { + create: [ + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + ], + }, }, }, { - id: generate({ charset: "alphabetic" }), - posts: { - create: [ - { - content: generate({ charset: "alphabetic" }), - }, - { - content: generate({ charset: "alphabetic" }), - }, - { - content: generate({ charset: "alphabetic" }), - }, - ], + node: { + id: generate({ charset: "alphabetic" }), + posts: { + create: [ + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + { + node: { + content: generate({ charset: "alphabetic" }), + }, + }, + ], + }, }, }, ], @@ -134,13 +158,19 @@ describe("https://github.com/neo4j/graphql/issues/315", () => { posts: { create: [ { - content: generate({ charset: "alphabetic" }), + node: { + content: generate({ charset: "alphabetic" }), + }, }, { - content: generate({ charset: "alphabetic" }), + node: { + content: generate({ charset: "alphabetic" }), + }, }, { - content: generate({ charset: "alphabetic" }), + node: { + content: generate({ charset: "alphabetic" }), + }, }, ], }, @@ -183,7 +213,7 @@ describe("https://github.com/neo4j/graphql/issues/315", () => { const mutationResult = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { input }, }); @@ -200,7 +230,7 @@ describe("https://github.com/neo4j/graphql/issues/315", () => { const queryResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { userID, }, diff --git a/packages/graphql/tests/integration/issues/326.int.test.ts b/packages/graphql/tests/integration/issues/326.int.test.ts index 434431c6da..b806dfb1d9 100644 --- a/packages/graphql/tests/integration/issues/326.int.test.ts +++ b/packages/graphql/tests/integration/issues/326.int.test.ts @@ -20,11 +20,11 @@ import { Driver } from "neo4j-driver"; import { graphql } from "graphql"; import { generate } from "randomstring"; -import neo4j from "../neo4j"; -import { Neo4jGraphQL } from "../../../src/classes"; import { IncomingMessage } from "http"; import jsonwebtoken from "jsonwebtoken"; import { Socket } from "net"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; describe("326", () => { let driver: Driver; @@ -97,7 +97,7 @@ describe("326", () => { schema: neoSchema.schema, source: query, variableValues: { id }, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); @@ -166,7 +166,7 @@ describe("326", () => { schema: neoSchema.schema, source: query, variableValues: { id }, - contextValue: { driver, req }, + contextValue: { driver, req, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect((gqlResult.errors as any[])[0].message).toEqual("Forbidden"); diff --git a/packages/graphql/tests/integration/issues/unauthenticated-requests.int.test.ts b/packages/graphql/tests/integration/issues/330.int.test.ts similarity index 100% rename from packages/graphql/tests/integration/issues/unauthenticated-requests.int.test.ts rename to packages/graphql/tests/integration/issues/330.int.test.ts diff --git a/packages/graphql/tests/integration/issues/349.int.test.ts b/packages/graphql/tests/integration/issues/349.int.test.ts new file mode 100644 index 0000000000..5d2bbd1541 --- /dev/null +++ b/packages/graphql/tests/integration/issues/349.int.test.ts @@ -0,0 +1,215 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import neo4j from "neo4j-driver"; +import { SchemaDirectiveVisitor } from "@graphql-tools/utils"; +import { graphql } from "graphql"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("https://github.com/neo4j/graphql/issues/349", () => { + type Field = Parameters[0]; + + class DisallowDirective extends SchemaDirectiveVisitor { + // eslint-disable-next-line class-methods-use-this + public visitFieldDefinition(field: Field) { + field.resolve = function () { + // Disallow any and all access, all the time + throw new Error("go away"); + }; + } + } + + const schemaDirectives = { + disallow: DisallowDirective, + }; + + describe("https://github.com/neo4j/graphql/issues/349#issuecomment-885295157", () => { + const neo4jGraphQL = new Neo4jGraphQL({ + typeDefs: /* GraphQL */ ` + directive @disallow on FIELD_DEFINITION + + type Mutation { + doStuff: String! @disallow + } + + type Query { + noop: Boolean + } + `, + + driver: neo4j.driver("bolt://localhost:7687"), + resolvers: { Mutation: { doStuff: () => "OK" } }, + schemaDirectives, + }); + + test("DisallowDirective", async () => { + const gqlResult = await graphql({ + schema: neo4jGraphQL.schema, + source: /* GraphQL */ ` + mutation { + doStuff + } + `, + contextValue: { driver: neo4j.driver("bolt://localhost:7687") }, + }); + + expect(gqlResult.data).toBeNull(); + expect(gqlResult.errors).toBeTruthy(); + }); + }); + + describe("https://github.com/neo4j/graphql/issues/349#issuecomment-885311918", () => { + const neo4jGraphQL = new Neo4jGraphQL({ + typeDefs: /* GraphQL */ ` + directive @disallow on FIELD_DEFINITION + + type NestedResult { + stuff: String! @disallow + } + + type Mutation { + doStuff: String! @disallow + doNestedStuff: NestedResult! + } + + type Query { + getStuff: String! @disallow + getNestedStuff: NestedResult! + } + `, + + driver: neo4j.driver("bolt://localhost:7687"), + resolvers: { + NestedResult: { + stuff: (parent: string) => parent, + }, + + Mutation: { + doStuff: () => "OK", + doNestedStuff: () => "OK", + }, + + Query: { + getStuff: () => "OK", + getNestedStuff: () => "OK", + }, + }, + schemaDirectives, + }); + + test("mutation top - DisallowDirective", async () => { + const gqlResult = await graphql({ + schema: neo4jGraphQL.schema, + source: /* GraphQL */ ` + mutation { + doStuff + } + `, + contextValue: { driver: neo4j.driver("bolt://localhost:7687") }, + }); + + expect(gqlResult.data).toBeNull(); + expect(gqlResult.errors && gqlResult.errors[0].message).toBe("go away"); + }); + + test("query top - DisallowDirective", async () => { + const gqlResult = await graphql({ + schema: neo4jGraphQL.schema, + source: /* GraphQL */ ` + query { + getStuff + } + `, + contextValue: { driver: neo4j.driver("bolt://localhost:7687") }, + }); + + expect(gqlResult.data).toBeNull(); + expect(gqlResult.errors && gqlResult.errors[0].message).toBe("go away"); + }); + + test("mutation nested - DisallowDirective", async () => { + const gqlResult = await graphql({ + schema: neo4jGraphQL.schema, + source: /* GraphQL */ ` + mutation { + doNestedStuff { + stuff + } + } + `, + contextValue: { driver: neo4j.driver("bolt://localhost:7687") }, + }); + + expect(gqlResult.data).toBeNull(); + expect(gqlResult.errors && gqlResult.errors[0].message).toBe("go away"); + }); + + test("query nested - DisallowDirective", async () => { + const gqlResult = await graphql({ + schema: neo4jGraphQL.schema, + source: /* GraphQL */ ` + query { + getNestedStuff { + stuff + } + } + `, + contextValue: { driver: neo4j.driver("bolt://localhost:7687") }, + }); + + expect(gqlResult.data).toBeNull(); + expect(gqlResult.errors && gqlResult.errors[0].message).toBe("go away"); + }); + }); + + describe("schemaDirectives can be an empty object", () => { + const neo4jGraphQL = new Neo4jGraphQL({ + typeDefs: /* GraphQL */ ` + directive @disallow on FIELD_DEFINITION + + type Mutation { + doStuff: String! @disallow + } + + type Query { + noop: Boolean + } + `, + + driver: neo4j.driver("bolt://localhost:7687"), + resolvers: { Mutation: { doStuff: () => "OK" } }, + schemaDirectives: {}, + }); + + test("DisallowDirective", async () => { + const gqlResult = await graphql({ + schema: neo4jGraphQL.schema, + source: /* GraphQL */ ` + mutation { + doStuff + } + `, + contextValue: { driver: neo4j.driver("bolt://localhost:7687") }, + }); + + expect(gqlResult.data?.doStuff).toEqual("OK"); + expect(gqlResult.errors).toBeFalsy(); + }); + }); +}); diff --git a/packages/graphql/tests/integration/issues/350.int.test.ts b/packages/graphql/tests/integration/issues/350.int.test.ts index fbf3cae0ce..10f66accd6 100644 --- a/packages/graphql/tests/integration/issues/350.int.test.ts +++ b/packages/graphql/tests/integration/issues/350.int.test.ts @@ -96,7 +96,7 @@ describe("https://github.com/neo4j/graphql/issues/350", () => { try { await session.run( - ` + ` CREATE (post:Post {id: $postId, title: $postTitle, content: $postContent}) CREATE (comment1:Comment {id: $comment1Id, content: $comment1Content, flagged: true}) CREATE (comment2:Comment {id: $comment2Id, content: $comment2Content, flagged: false}) @@ -118,7 +118,7 @@ describe("https://github.com/neo4j/graphql/issues/350", () => { const result = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); expect(result?.data?.posts[0].flaggedComments).toContainEqual({ diff --git a/packages/graphql/tests/integration/issues/360.int.test.ts b/packages/graphql/tests/integration/issues/360.int.test.ts index ccab1445f0..a1ff84e980 100644 --- a/packages/graphql/tests/integration/issues/360.int.test.ts +++ b/packages/graphql/tests/integration/issues/360.int.test.ts @@ -80,10 +80,10 @@ describe("360", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); - expect(gqlResult.errors).toBe(undefined); + expect(gqlResult.errors).toBeUndefined(); expect((gqlResult.data as any)[pluralType]).toHaveLength(3); } finally { await session.close(); @@ -134,10 +134,10 @@ describe("360", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); - expect(gqlResult.errors).toBe(undefined); + expect(gqlResult.errors).toBeUndefined(); expect((gqlResult.data as any)[pluralType]).toHaveLength(3); } finally { await session.close(); @@ -192,11 +192,11 @@ describe("360", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { rangeStart, rangeEnd }, }); - expect(gqlResult.errors).toBe(undefined); + expect(gqlResult.errors).toBeUndefined(); expect((gqlResult.data as any)[pluralType]).toHaveLength(3); } finally { await session.close(); diff --git a/packages/graphql/tests/integration/issues/369.int.test.ts b/packages/graphql/tests/integration/issues/369.int.test.ts new file mode 100644 index 0000000000..65ed600de6 --- /dev/null +++ b/packages/graphql/tests/integration/issues/369.int.test.ts @@ -0,0 +1,207 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("369", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should recreate issue and return correct data", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Dato { + uuid: ID + dependeTo: [Dato] @relationship(type: "DEPENDE", direction: OUT, properties: "Depende") + dependeFrom: [Dato] @relationship(type: "DEPENDE", direction: IN, properties: "Depende") + } + + interface Depende { + uuid: ID + } + + type Query { + getDato(uuid: String): Dato + @cypher( + statement: """ + MATCH (d:Dato {uuid: $uuid}) RETURN d + """ + ) + } + `; + + const datoUUID = generate({ + charset: "alphabetic", + }); + + const datoToUUID = generate({ + charset: "alphabetic", + }); + + const relUUID = generate({ + charset: "alphabetic", + }); + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const query = ` + { + getDato(uuid: "${datoUUID}" ){ + uuid + dependeToConnection { + edges { + uuid + node { + uuid + } + } + } + } + } + `; + try { + await session.run( + ` + CREATE (:Dato {uuid: $datoUUID})-[:DEPENDE {uuid: $relUUID}]->(:Dato {uuid: $datoToUUID}) + `, + { + datoUUID, + datoToUUID, + relUUID, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result.data as any).toEqual({ + getDato: { + uuid: datoUUID, + dependeToConnection: { edges: [{ uuid: relUUID, node: { uuid: datoToUUID } }] }, + }, + }); + } finally { + await session.close(); + } + }); + + test("should recreate issue and return correct data using a where argument on the connection", async () => { + const session = driver.session(); + + const typeDefs = gql` + type Dato { + uuid: ID + dependeTo: [Dato] @relationship(type: "DEPENDE", direction: OUT, properties: "Depende") + dependeFrom: [Dato] @relationship(type: "DEPENDE", direction: IN, properties: "Depende") + } + + interface Depende { + uuid: ID + } + + type Query { + getDato(uuid: String): Dato + @cypher( + statement: """ + MATCH (d:Dato {uuid: $uuid}) RETURN d + """ + ) + } + `; + + const datoUUID = generate({ + charset: "alphabetic", + }); + + const datoToUUID = generate({ + charset: "alphabetic", + }); + + const relUUID = generate({ + charset: "alphabetic", + }); + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const query = ` + { + getDato(uuid: "${datoUUID}" ){ + uuid + dependeToConnection(where: { node: { uuid: "${datoToUUID}" } }) { + edges { + uuid + node { + uuid + } + } + } + } + } + `; + try { + await session.run( + ` + CREATE (d:Dato {uuid: $datoUUID})-[:DEPENDE {uuid: $relUUID}]->(:Dato {uuid: $datoToUUID}) + CREATE (d)-[:DEPENDE {uuid: randomUUID()}]->(:Dato {uuid: randomUUID()}) + CREATE (d)-[:DEPENDE {uuid: randomUUID()}]->(:Dato {uuid: randomUUID()}) + `, + { + datoUUID, + datoToUUID, + relUUID, + } + ); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result.data as any).toEqual({ + getDato: { + uuid: datoUUID, + dependeToConnection: { edges: [{ uuid: relUUID, node: { uuid: datoToUUID } }] }, + }, + }); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/issues/387.int.test.ts b/packages/graphql/tests/integration/issues/387.int.test.ts new file mode 100644 index 0000000000..80455d0dd0 --- /dev/null +++ b/packages/graphql/tests/integration/issues/387.int.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("https://github.com/neo4j/graphql/issues/387", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should return custom scalars from custom Cypher fields", async () => { + const session = driver.session(); + + const name = generate({ + charset: "alphabetic", + }); + const url = generate({ + charset: "alphabetic", + }); + + const typeDefs = gql` + scalar URL + + type Place { + name: String + url: URL + @cypher( + statement: """ + return '${url}' + """ + ) + url_array: [URL] + @cypher( + statement: """ + return ['${url}', '${url}'] + """ + ) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const query = ` + { + places(where: { name: "${name}" }) { + name + url + url_array + } + } + `; + + try { + await session.run(`CREATE (:Place { name: "${name}" })`); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result.data as any).toEqual({ + places: [ + { + name, + url, + url_array: [url, url], + }, + ], + }); + } finally { + await session.close(); + } + }); + + test("should return custom scalars from root custom Cypher fields", async () => { + const url = generate({ + charset: "alphabetic", + }); + + const typeDefs = gql` + scalar URL + + type Query { + url: URL + @cypher( + statement: """ + return '${url}' + """ + ) + url_array: [URL] + @cypher( + statement: """ + return ['${url}', '${url}'] + """ + ) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const query = ` + { + url + url_array + } + `; + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result.data as any).toEqual({ + url, + url_array: [url, url], + }); + }); +}); diff --git a/packages/graphql/tests/integration/nested-unions.int.test.ts b/packages/graphql/tests/integration/nested-unions.int.test.ts new file mode 100644 index 0000000000..024ee9cf7c --- /dev/null +++ b/packages/graphql/tests/integration/nested-unions.int.test.ts @@ -0,0 +1,518 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "./neo4j"; +import { Neo4jGraphQL } from "../../src/classes"; + +describe("Nested unions", () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Series { + name: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) + } + + union Production = Movie | Series + + type LeadActor { + name: String! + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) + } + + type Extra { + name: String + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) + } + + union Actor = LeadActor | Extra + `; + + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("chain multiple connects, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + connect: { + actors: { + LeadActor: { + where: { node: { name: "${actorName}" } } + connect: { actedIn: { Series: { where: { node: { name: "${seriesName}" } } } } } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle}) + CREATE (:LeadActor {name:$actorName}) + CREATE (:Series {name:$seriesName}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies[0].title).toEqual(movieTitle); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].name).toEqual(actorName); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].actedIn).toContainEqual({ + name: seriesName, + }); + } finally { + await session.close(); + } + }); + + test("chain multiple disconnects, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + disconnect: { + actors: { + LeadActor: { + where: { node: { name: "${actorName}" } } + disconnect: { actedIn: { Series: { where: { node: { name: "${seriesName}" } } } } } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN]-(:LeadActor {name:$actorName})-[:ACTED_IN]->(:Series {name:$seriesName}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypherMovie = ` + MATCH (:Movie {title: $movieTitle}) + <-[actedIn:ACTED_IN]- + (:LeadActor {name: $actorName}) + RETURN actedIn + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle, actorName }); + expect(neo4jResultMovie.records).toHaveLength(0); + + const cypherSeries = ` + MATCH (:Series {name: $seriesName}) + <-[actedIn:ACTED_IN]- + (:LeadActor {name: $actorName}) + RETURN actedIn + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName, actorName }); + expect(neo4jResultSeries.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("chain multiple deletes, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + delete: { + actors: { + LeadActor: { + where: { node: { name: "${actorName}" } } + delete: { actedIn: { Series: { where: { node: { name: "${seriesName}" } } } } } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN]-(:LeadActor {name:$actorName})-[:ACTED_IN]->(:Series {name:$seriesName}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypherMovie = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle }); + expect(neo4jResultMovie.records).toHaveLength(1); + + const cypherActor = ` + MATCH (a:LeadActor {name: $actorName}) + RETURN a + `; + + const neo4jResultActor = await session.run(cypherActor, { actorName }); + expect(neo4jResultActor.records).toHaveLength(0); + + const cypherSeries = ` + MATCH (s:Series {name: $seriesName}) + RETURN s + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName }); + expect(neo4jResultSeries.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("chain multiple creates under update, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + create: { + actors: { + LeadActor: { + node: { + name: "${actorName}" + actedIn: { + Series: { + create: [ + { + node: { + name: "${seriesName}" + } + } + ] + } + } + } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle}) + `, + { movieTitle, seriesName, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies[0].title).toEqual(movieTitle); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].name).toEqual(actorName); + expect(gqlResult.data?.updateMovies.movies[0].actors[0].actedIn).toContainEqual({ + name: seriesName, + }); + + const cypherMovie = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle }); + expect(neo4jResultMovie.records).toHaveLength(1); + + const cypherActor = ` + MATCH (a:LeadActor {name: $actorName}) + RETURN a + `; + + const neo4jResultActor = await session.run(cypherActor, { actorName }); + expect(neo4jResultActor.records).toHaveLength(1); + + const cypherSeries = ` + MATCH (s:Series {name: $seriesName}) + RETURN s + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName }); + expect(neo4jResultSeries.records).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("chain multiple creates under create, all for union relationships", async () => { + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const seriesName = generate({ charset: "alphabetic" }); + + const source = ` + mutation { + createMovies( + input: [ + { + title: "${movieTitle}" + actors: { + LeadActor: { + create: [ + { + node: { + name: "${actorName}" + actedIn: { + Series: { + create: [ + { + node: { + name: "${seriesName}" + } + } + ] + } + } + } + } + ] + } + } + } + ] + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } + } + `; + + try { + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + expect(gqlResult.errors).toBeFalsy(); + // expect(gqlResult.data?.createMovies.movies).toEqual([ + // { + // title: movieTitle, + // actors: [ + // { + // name: actorName, + // actedIn: [ + // {}, + // { + // name: seriesName, + // }, + // ], + // }, + // ], + // }, + // ]); + expect(gqlResult.data?.createMovies.movies[0].title).toEqual(movieTitle); + expect(gqlResult.data?.createMovies.movies[0].actors[0].name).toEqual(actorName); + expect(gqlResult.data?.createMovies.movies[0].actors[0].actedIn).toContainEqual({ + name: seriesName, + }); + + const cypherMovie = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResultMovie = await session.run(cypherMovie, { movieTitle }); + expect(neo4jResultMovie.records).toHaveLength(1); + + const cypherActor = ` + MATCH (a:LeadActor {name: $actorName}) + RETURN a + `; + + const neo4jResultActor = await session.run(cypherActor, { actorName }); + expect(neo4jResultActor.records).toHaveLength(1); + + const cypherSeries = ` + MATCH (s:Series {name: $seriesName}) + RETURN s + `; + + const neo4jResultSeries = await session.run(cypherSeries, { seriesName }); + expect(neo4jResultSeries.records).toHaveLength(1); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/query-options.int.test.ts b/packages/graphql/tests/integration/query-options.int.test.ts index 0024481037..4690dd146c 100644 --- a/packages/graphql/tests/integration/query-options.int.test.ts +++ b/packages/graphql/tests/integration/query-options.int.test.ts @@ -83,7 +83,7 @@ describe("query options", () => { schema: neoSchema.schema, source: query, variableValues: { id }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(result.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/relationship-properties/connect.int.test.ts b/packages/graphql/tests/integration/relationship-properties/connect.int.test.ts new file mode 100644 index 0000000000..98847ae775 --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/connect.int.test.ts @@ -0,0 +1,393 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - connect", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should create a movie while connecting a relationship that has properties", async () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $screenTime: Int!, $actorName: String!) { + createMovies( + input: [ + { + title: $movieTitle + actors: { + connect: [{ + where: { node: { name: $actorName } }, + edge: { screenTime: $screenTime }, + }] + } + } + ] + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Actor {name:$actorName}) + `, + { actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.createMovies.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { edges: [{ screenTime, node: { name: actorName } }] }, + }, + ]); + + const cypher = ` + MATCH (m:Movie {title: $movieTitle}) + <-[:ACTED_IN {screenTime: $screenTime}]- + (:Actor {name: $actorName}) + RETURN m + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("should create an actor while connecting a relationship that has properties(with Union)", async () => { + const typeDefs = gql` + type Movie { + title: String! + } + + type Show { + name: String! + } + + type Actor { + name: String! + actedIn: [ActedInUnion!]! + @relationship(type: "ACTED_IN", properties: "ActedInInterface", direction: OUT) + } + + union ActedInUnion = Movie | Show + + interface ActedInInterface { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $screenTime: Int!, $actorName: String!) { + createActors(input: [{ + name: $actorName, + actedIn: { + Movie: { + connect: { + where: { + node: { title: $movieTitle } + }, + edge: { + screenTime: $screenTime + } + } + } + } + }]) { + actors { + name + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle}) + `, + { movieTitle } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.createActors.actors).toEqual([ + { + name: actorName, + }, + ]); + + const cypher = ` + MATCH (a:Actor {name: $actorName}) + -[:ACTED_IN {screenTime: $screenTime}]-> + (:Movie {title: $movieTitle}) + RETURN a + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("should update a movie while connecting a relationship that has properties", async () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $screenTime: Int!, $actorName: String!) { + updateMovies( + where: { title: $movieTitle } + connect: { + actors: { + where: { node: { name: $actorName } } + edge: { screenTime: $screenTime } + } + } + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle}) + CREATE (:Actor {name:$actorName}) + `, + { movieTitle, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { edges: [{ screenTime, node: { name: actorName } }] }, + }, + ]); + + const cypher = ` + MATCH (m:Movie {title: $movieTitle}) + <-[:ACTED_IN {screenTime: $screenTime}]- + (:Actor {name: $actorName}) + RETURN m + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("should update an actor while connecting a relationship that has properties(with Union)", async () => { + const typeDefs = gql` + type Movie { + title: String! + } + type Show { + name: String! + } + type Actor { + name: String! + actedIn: [ActedInUnion!]! + @relationship(type: "ACTED_IN", properties: "ActedInInterface", direction: OUT) + } + union ActedInUnion = Movie | Show + interface ActedInInterface { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $screenTime: Int!, $actorName: String!) { + updateActors( + where: { name: $actorName } + connect: { + actedIn: { + Movie: { + where: { node: { title: $movieTitle } } + edge: { screenTime: $screenTime } + } + } + } + ) { + actors { + name + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle}) + CREATE (:Actor {name:$actorName}) + `, + { movieTitle, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateActors.actors).toEqual([ + { + name: actorName, + }, + ]); + + const cypher = ` + MATCH (a:Actor {name: $actorName}) + -[:ACTED_IN {screenTime: $screenTime}]-> + (:Movie {title: $movieTitle}) + RETURN a + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(1); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/relationship-properties/create.int.test.ts b/packages/graphql/tests/integration/relationship-properties/create.int.test.ts new file mode 100644 index 0000000000..92a031ba8a --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/create.int.test.ts @@ -0,0 +1,207 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - create", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should create a node with a relationship that has properties", async () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $screenTime: Int!, $actorName: String!) { + createMovies( + input: [ + { + title: $movieTitle + actors: { + create: [{ + edge: { screenTime: $screenTime }, + node: { name: $actorName } + }] + } + } + ] + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + const result = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName, screenTime }, + }); + expect(result.errors).toBeFalsy(); + expect(result.data?.createMovies.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { edges: [{ screenTime, node: { name: actorName } }] }, + }, + ]); + + const cypher = ` + MATCH (m:Movie {title: $movieTitle}) + <-[:ACTED_IN {screenTime: $screenTime}]- + (:Actor {name: $actorName}) + RETURN m + `; + + try { + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("should create a node with a relationship that has properties(with Union)", async () => { + const typeDefs = gql` + union Publication = Movie + + type Movie { + title: String! + } + + type Actor { + name: String! + publications: [Publication!]! @relationship(type: "WROTE", properties: "Wrote", direction: OUT) + } + + interface Wrote { + words: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const words = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($actorName: String!, $words: Int!, $movieTitle: String!) { + createActors( + input: [ + { + name: $actorName + publications: { + Movie: { + create: [{ + edge: { words: $words }, + node: { title: $movieTitle } + }] + } + } + } + ] + ) { + actors { + name + publications { + ... on Movie { + title + } + } + } + } + } + `; + + const result = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName, words }, + }); + expect(result.errors).toBeFalsy(); + expect(result.data?.createActors.actors).toEqual([ + { + name: actorName, + publications: [{ title: movieTitle }], + }, + ]); + + const cypher = ` + MATCH (a:Actor {name: $actorName}) + -[:WROTE {words: $words}]-> + (:Movie {title: $movieTitle}) + RETURN a + `; + + try { + const neo4jResult = await session.run(cypher, { movieTitle, words, actorName }); + expect(neo4jResult.records).toHaveLength(1); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/relationship-properties/delete.int.test.ts b/packages/graphql/tests/integration/relationship-properties/delete.int.test.ts new file mode 100644 index 0000000000..e628f5d7cb --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/delete.int.test.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - delete", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should delete a related node for a relationship that has properties", async () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $actorName: String!) { + updateMovies( + where: { title: $movieTitle } + delete: { actors: { where: { node: { name: $actorName } } } } + ) { + movies { + title + actors { + name + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypher = ` + MATCH (a:Actor {name: $actorName}) + RETURN a + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("should delete a related node for a relationship that has properties (with Union)", async () => { + const typeDefs = gql` + type Movie { + title: String! + } + + type Show { + name: String! + } + + type Actor { + name: String! + actedIn: [ActedInUnion!]! + @relationship(type: "ACTED_IN", properties: "ActedInInterface", direction: OUT) + } + + union ActedInUnion = Movie | Show + + interface ActedInInterface { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($screenTime: Int!, $actorName: String!) { + updateActors( + where: { name: $actorName } + delete: { + actedIn: { + Movie: { + where: { edge: { screenTime: $screenTime } } + } + } + } + ) { + actors { + name + actedIn { + __typename + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateActors.actors).toEqual([ + { + name: actorName, + actedIn: [], + }, + ]); + + const cypher = ` + MATCH (m:Movie {title: $movieTitle}) + RETURN m + `; + + const neo4jResult = await session.run(cypher, { movieTitle }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/relationship-properties/disconnect.int.test.ts b/packages/graphql/tests/integration/relationship-properties/disconnect.int.test.ts new file mode 100644 index 0000000000..d6440ccb9b --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/disconnect.int.test.ts @@ -0,0 +1,205 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - disconnect", () => { + let driver: Driver; + + beforeAll(async () => { + driver = await neo4j(); + }); + + afterAll(async () => { + await driver.close(); + }); + + test("should disconnect a relationship that has properties", async () => { + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($movieTitle: String!, $actorName: String!) { + updateMovies( + where: { title: $movieTitle } + disconnect: { actors: { where: { node: { name: $actorName } } } } + ) { + movies { + title + actors { + name + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { movieTitle, actorName }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateMovies.movies).toEqual([ + { + title: movieTitle, + actors: [], + }, + ]); + + const cypher = ` + MATCH (:Movie {title: $movieTitle}) + <-[actedIn:ACTED_IN {screenTime: $screenTime}]- + (:Actor {name: $actorName}) + RETURN actedIn + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("should disconnect a relationship that has properties (with Union)", async () => { + const typeDefs = gql` + type Movie { + title: String! + } + + type Show { + name: String! + } + + type Actor { + name: String! + actedIn: [ActedInUnion!]! + @relationship(type: "ACTED_IN", properties: "ActedInInterface", direction: OUT) + } + + union ActedInUnion = Movie | Show + + interface ActedInInterface { + screenTime: Int! + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + }); + + const session = driver.session(); + const movieTitle = generate({ charset: "alphabetic" }); + const actorName = generate({ charset: "alphabetic" }); + const screenTime = Math.floor((Math.random() * 1e3) / Math.random()); + + const source = ` + mutation($screenTime: Int!, $actorName: String!) { + updateActors( + where: { name: $actorName } + disconnect: { + actedIn: { + Movie: { + where: { edge: { screenTime: $screenTime } } + } + } + } + ) { + actors { + name + actedIn { + __typename + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {title:$movieTitle})<-[:ACTED_IN {screenTime:$screenTime}]-(:Actor {name:$actorName}) + `, + { movieTitle, screenTime, actorName } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { actorName, screenTime }, + }); + expect(gqlResult.errors).toBeFalsy(); + expect(gqlResult.data?.updateActors.actors).toEqual([ + { + name: actorName, + actedIn: [], + }, + ]); + + const cypher = ` + MATCH (:Actor {name: $actorName}) + -[actedIn:ACTED_IN {screenTime: $screenTime}]-> + (:Movie {title: $movieTitle}) + RETURN actedIn + `; + + const neo4jResult = await session.run(cypher, { movieTitle, screenTime, actorName }); + expect(neo4jResult.records).toHaveLength(0); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/relationship-properties/read.int.test.ts b/packages/graphql/tests/integration/relationship-properties/read.int.test.ts new file mode 100644 index 0000000000..67c1e693ea --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/read.int.test.ts @@ -0,0 +1,577 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { offsetToCursor } from "graphql-relay"; +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - read", () => { + let driver: Driver; + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + const movieTitle = generate({ charset: "alphabetic" }); + const actorA = `a${generate({ charset: "alphabetic" })}`; + const actorB = `b${generate({ charset: "alphabetic" })}`; + const actorC = `c${generate({ charset: "alphabetic" })}`; + + beforeAll(async () => { + driver = await neo4j(); + const session = driver.session(); + + try { + await session.run( + `CREATE (:Actor { name: '${actorA}' })-[:ACTED_IN { screenTime: 105 }]->(:Movie { title: '${movieTitle}'})` + ); + // Another couple of actors to test sorting and filtering + await session.run( + `MATCH (m:Movie) WHERE m.title = '${movieTitle}' CREATE (m)<-[:ACTED_IN { screenTime: 105 }]-(:Actor { name: '${actorB}' })` + ); + await session.run( + `MATCH (m:Movie) WHERE m.title = '${movieTitle}' CREATE (m)<-[:ACTED_IN { screenTime: 5 }]-(:Actor { name: '${actorC}' })` + ); + } finally { + await session.close(); + } + }); + + afterAll(async () => { + const session = driver.session(); + + try { + await session.run(`MATCH (a:Actor) WHERE a.name = '${actorA}' DETACH DELETE a`); + await session.run(`MATCH (a:Actor) WHERE a.name = '${actorB}' DETACH DELETE a`); + await session.run(`MATCH (a:Actor) WHERE a.name = '${actorC}' DETACH DELETE a`); + await session.run(`MATCH (m:Movie) WHERE m.title = '${movieTitle}' DETACH DELETE m`); + } finally { + await session.close(); + } + + await driver.close(); + }); + + test("Projecting node and relationship properties with no arguments", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + movies(where: { title: "${movieTitle}" }) { + title + actorsConnection { + totalCount + edges { + screenTime + node { + name + } + } + pageInfo { + hasNextPage + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + totalCount: 3, + edges: [ + { + screenTime: 5, + node: { + name: actorC, + }, + }, + { + screenTime: 105, + node: { + name: actorB, + }, + }, + { + screenTime: 105, + node: { + name: actorA, + }, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With `where` argument", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + movies(where: { title: "${movieTitle}" }) { + title + actorsConnection( + where: { AND: [{ edge: { screenTime_GT: 60 } }, { node: { name_STARTS_WITH: "a" } }] } + ) { + totalCount + edges { + cursor + screenTime + node { + name + } + } + pageInfo { + hasNextPage + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + totalCount: 1, + edges: [ + { + cursor: offsetToCursor(0), + screenTime: 105, + node: { + name: actorA, + }, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With `sort` argument", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query ConnectionWithSort($nameSort: SortDirection) { + movies(where: { title: "${movieTitle}" }) { + title + actorsConnection( + sort: [{ edge: { screenTime: DESC } }, { node: { name: $nameSort } }] + ) { + totalCount + edges { + cursor + screenTime + node { + name + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const ascResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { nameSort: "ASC" }, + }); + + expect(ascResult.errors).toBeFalsy(); + + expect(ascResult?.data?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + totalCount: 3, + edges: [ + { + cursor: offsetToCursor(0), + screenTime: 105, + node: { + name: actorA, + }, + }, + { + cursor: offsetToCursor(1), + screenTime: 105, + node: { + name: actorB, + }, + }, + { + cursor: offsetToCursor(2), + screenTime: 5, + node: { + name: actorC, + }, + }, + ], + }, + }, + ]); + + const descResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { nameSort: "DESC" }, + }); + + expect(descResult.errors).toBeFalsy(); + + expect(descResult?.data?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + totalCount: 3, + edges: [ + { + cursor: offsetToCursor(0), + screenTime: 105, + node: { + name: actorB, + }, + }, + { + cursor: offsetToCursor(1), + screenTime: 105, + node: { + name: actorA, + }, + }, + { + cursor: offsetToCursor(2), + screenTime: 5, + node: { + name: actorC, + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("With `where` and `sort` arguments", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query ConnectionWithSort($nameSort: SortDirection) { + movies(where: { title: "${movieTitle}" }) { + title + actorsConnection( + where: { edge: { screenTime_GT: 60 } } + sort: [{ node: { name: $nameSort } }] + ) { + totalCount + edges { + screenTime + node { + name + } + } + pageInfo { + hasNextPage + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const ascResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { nameSort: "ASC" }, + }); + + expect(ascResult.errors).toBeFalsy(); + + expect(ascResult?.data?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + totalCount: 2, + edges: [ + { + screenTime: 105, + node: { + name: actorA, + }, + }, + { + screenTime: 105, + node: { + name: actorB, + }, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + ]); + + const descResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + variableValues: { nameSort: "DESC" }, + }); + + expect(descResult.errors).toBeFalsy(); + + expect(descResult?.data?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + totalCount: 2, + edges: [ + { + screenTime: 105, + node: { + name: actorB, + }, + }, + { + screenTime: 105, + node: { + name: actorA, + }, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("Projecting a connection from a relationship with no argument", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + actors(where: { name: "${actorA}" }) { + name + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.actors).toEqual([ + { + name: actorA, + movies: [ + { + title: movieTitle, + actorsConnection: { + edges: [ + { + screenTime: 5, + node: { + name: actorC, + }, + }, + { + screenTime: 105, + node: { + name: actorB, + }, + }, + { + screenTime: 105, + node: { + name: actorA, + }, + }, + ], + }, + }, + ], + }, + ]); + } finally { + await session.close(); + } + }); + + test("Projecting a connection from a relationship with `where` argument", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const query = ` + query { + actors(where: { name: "${actorA}" }) { + name + movies { + title + actorsConnection(where: { node: { name_NOT: "${actorA}" } }) { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.actors).toEqual([ + { + name: actorA, + movies: [ + { + title: movieTitle, + actorsConnection: { + edges: [ + { + screenTime: 5, + node: { + name: actorC, + }, + }, + { + screenTime: 105, + node: { + name: actorB, + }, + }, + ], + }, + }, + ], + }, + ]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/relationship-properties/update.int.test.ts b/packages/graphql/tests/integration/relationship-properties/update.int.test.ts new file mode 100644 index 0000000000..f7803937f1 --- /dev/null +++ b/packages/graphql/tests/integration/relationship-properties/update.int.test.ts @@ -0,0 +1,374 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Driver } from "neo4j-driver"; +import { graphql } from "graphql"; +import { gql } from "apollo-server"; +import { generate } from "randomstring"; +import neo4j from "../neo4j"; +import { Neo4jGraphQL } from "../../../src/classes"; + +describe("Relationship properties - read", () => { + let driver: Driver; + const typeDefs = gql` + type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) + } + + type Actor { + name: String! + movies: [Movie!]! @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) + } + + interface ActedIn { + screenTime: Int! + } + `; + const movieTitle = generate({ charset: "alphabetic" }); + const actor1 = generate({ charset: "alphabetic" }); + const actor2 = generate({ charset: "alphabetic" }); + const actor3 = generate({ charset: "alphabetic" }); + + beforeAll(async () => { + driver = await neo4j(); + }); + + beforeEach(async () => { + const session = driver.session(); + + try { + await session.run( + `CREATE (:Actor { name: '${actor1}' })-[:ACTED_IN { screenTime: 105 }]->(:Movie { title: '${movieTitle}'})` + ); + await session.run( + `MATCH (m:Movie) WHERE m.title = '${movieTitle}' CREATE (m)<-[:ACTED_IN { screenTime: 105 }]-(:Actor { name: '${actor2}' })` + ); + } finally { + await session.close(); + } + }); + + afterEach(async () => { + const session = driver.session(); + + try { + await session.run(`MATCH (a:Actor) WHERE a.name = '${actor1}' DETACH DELETE a`); + await session.run(`MATCH (a:Actor) WHERE a.name = '${actor2}' DETACH DELETE a`); + await session.run(`MATCH (a:Actor) WHERE a.name = '${actor3}' DETACH DELETE a`); + await session.run(`MATCH (m:Movie) WHERE m.title = '${movieTitle}' DETACH DELETE m`); + } finally { + await session.close(); + } + }); + + afterAll(async () => { + await driver.close(); + }); + + test("Update a relationship property on a relationship between two specified nodes (update -> update)", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const mutation = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + update: { actors: [{ where: { node: { name: "${actor1}" } }, update: { edge: { screenTime: 60 } } }] } + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.updateMovies?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + edges: [ + { + screenTime: 105, + node: { + name: actor2, + }, + }, + { + screenTime: 60, + node: { + name: actor1, + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("Update properties on both the relationship and end node in a nested update (update -> update)", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const mutation = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + update: { + actors: [ + { + where: { node: { name: "${actor2}" } } + update: { + edge: { screenTime: 60 } + node: { name: "${actor3}" } + } + } + ] + } + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.updateMovies?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + edges: [ + { + screenTime: 60, + node: { + name: actor3, + }, + }, + { + screenTime: 105, + node: { + name: actor1, + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("Create relationship node through update field on end node in a nested update (update -> update)", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const mutation = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + update: { + actors: [ + { + create: { + node: { name: "${actor3}" } + edge: { screenTime: 60 } + } + } + ] + } + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.updateMovies?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + edges: [ + { + screenTime: 60, + node: { + name: actor3, + }, + }, + { + screenTime: 105, + node: { + name: actor2, + }, + }, + { + screenTime: 105, + node: { + name: actor1, + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("Create a relationship node with relationship properties on end node in a nested update (update -> create)", async () => { + const session = driver.session(); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + + const mutation = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" } + create: { + actors: [ + { + node: { name: "${actor3}" } + edge: { screenTime: 60 } + } + ] + } + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + `; + + try { + await neoSchema.checkNeo4jCompat(); + + const result = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(result.errors).toBeFalsy(); + + expect(result?.data?.updateMovies?.movies).toEqual([ + { + title: movieTitle, + actorsConnection: { + edges: [ + { + screenTime: 60, + node: { + name: actor3, + }, + }, + { + screenTime: 105, + node: { + name: actor2, + }, + }, + { + screenTime: 105, + node: { + name: actor1, + }, + }, + ], + }, + }, + ]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/graphql/tests/integration/scalars.init.test.ts b/packages/graphql/tests/integration/scalars.init.test.ts index a5c89909d3..6285413b6a 100644 --- a/packages/graphql/tests/integration/scalars.init.test.ts +++ b/packages/graphql/tests/integration/scalars.init.test.ts @@ -91,7 +91,7 @@ describe("scalars", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -139,7 +139,7 @@ describe("scalars", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/sort.int.test.ts b/packages/graphql/tests/integration/sort.int.test.ts index 1d3341e569..0da30c2aa7 100644 --- a/packages/graphql/tests/integration/sort.int.test.ts +++ b/packages/graphql/tests/integration/sort.int.test.ts @@ -81,7 +81,7 @@ describe("sort", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); @@ -152,7 +152,7 @@ describe("sort", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeUndefined(); diff --git a/packages/graphql/tests/integration/teardown.ts b/packages/graphql/tests/integration/teardown.ts new file mode 100644 index 0000000000..a078bc641b --- /dev/null +++ b/packages/graphql/tests/integration/teardown.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * File not imported in project, and to be used in test pipelines to teardown target database. + * + * `ts-node tests/integration/teardown.ts` + */ + +import neo4j from "./neo4j"; + +const teardown = async () => { + const driver = await neo4j(); + const session = driver.session(); + + try { + console.log("Clearing down database..."); + await session.run("MATCH (n) DETACH DELETE n"); + } finally { + await session.close(); + await driver.close(); + } +}; + +teardown().then( + () => console.log("Successfully cleared down database."), + (reason) => console.log(`Error encountered whilst clearing down database: ${reason}`) +); diff --git a/packages/graphql/tests/integration/timestamps.int.test.ts b/packages/graphql/tests/integration/timestamps.int.test.ts index 2225a7f4d3..c8a83a26c2 100644 --- a/packages/graphql/tests/integration/timestamps.int.test.ts +++ b/packages/graphql/tests/integration/timestamps.int.test.ts @@ -67,7 +67,7 @@ describe("TimeStamps", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -127,7 +127,7 @@ describe("TimeStamps", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -183,7 +183,7 @@ describe("TimeStamps", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -241,7 +241,7 @@ describe("TimeStamps", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -297,7 +297,7 @@ describe("TimeStamps", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -355,7 +355,7 @@ describe("TimeStamps", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/types/bigint.int.test.ts b/packages/graphql/tests/integration/types/bigint.int.test.ts index b54543a4b5..77daaff1b0 100644 --- a/packages/graphql/tests/integration/types/bigint.int.test.ts +++ b/packages/graphql/tests/integration/types/bigint.int.test.ts @@ -68,7 +68,7 @@ describe("BigInt", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -129,7 +129,7 @@ describe("BigInt", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -183,7 +183,7 @@ describe("BigInt", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/types/date.int.test.ts b/packages/graphql/tests/integration/types/date.int.test.ts index 7db4d9f1e5..5a81691312 100644 --- a/packages/graphql/tests/integration/types/date.int.test.ts +++ b/packages/graphql/tests/integration/types/date.int.test.ts @@ -71,7 +71,7 @@ describe("Date", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -130,7 +130,7 @@ describe("Date", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -200,7 +200,7 @@ describe("Date", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -253,7 +253,7 @@ describe("Date", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/types/datetime.int.test.ts b/packages/graphql/tests/integration/types/datetime.int.test.ts index 7290312e9b..3d2b1f1d97 100644 --- a/packages/graphql/tests/integration/types/datetime.int.test.ts +++ b/packages/graphql/tests/integration/types/datetime.int.test.ts @@ -71,7 +71,7 @@ describe("DateTime", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -127,7 +127,7 @@ describe("DateTime", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -197,7 +197,7 @@ describe("DateTime", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -247,7 +247,7 @@ describe("DateTime", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -298,7 +298,7 @@ describe("DateTime", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/types/point-cartesian.int.test.ts b/packages/graphql/tests/integration/types/point-cartesian.int.test.ts index 0d11b9c231..f9b7d39b6f 100644 --- a/packages/graphql/tests/integration/types/point-cartesian.int.test.ts +++ b/packages/graphql/tests/integration/types/point-cartesian.int.test.ts @@ -75,7 +75,7 @@ describe("CartesianPoint", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { serial, x, y }, }); @@ -125,7 +125,7 @@ describe("CartesianPoint", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { serial, x, y, z }, }); @@ -191,7 +191,7 @@ describe("CartesianPoint", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { serial, x, y: newY }, }); @@ -258,7 +258,7 @@ describe("CartesianPoint", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { serial, x, y: newY, z }, }); @@ -321,7 +321,7 @@ describe("CartesianPoint", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: partsQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { serial }, }); @@ -376,7 +376,7 @@ describe("CartesianPoint", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: partsQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { serial }, }); diff --git a/packages/graphql/tests/integration/types/point.int.test.ts b/packages/graphql/tests/integration/types/point.int.test.ts index 779dfcc290..e3844bfe88 100644 --- a/packages/graphql/tests/integration/types/point.int.test.ts +++ b/packages/graphql/tests/integration/types/point.int.test.ts @@ -90,7 +90,7 @@ describe("Point", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, size, longitude, latitude }, }); @@ -157,7 +157,7 @@ describe("Point", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, size, longitude, latitude, height }, }); @@ -230,7 +230,7 @@ describe("Point", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, longitude, latitude: newLatitude }, }); @@ -304,7 +304,7 @@ describe("Point", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, longitude, latitude: newLatitude, height }, }); @@ -373,7 +373,7 @@ describe("Point", () => { const equalsResult = await graphql({ schema: neoSchema.schema, source: photographsEqualsQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { longitude, latitude }, }); @@ -408,7 +408,7 @@ describe("Point", () => { const inResult = await graphql({ schema: neoSchema.schema, source: photographsInQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: [ { longitude, latitude }, { longitude: parseFloat(faker.address.longitude()), latitude: parseFloat(faker.address.latitude()) }, @@ -446,7 +446,7 @@ describe("Point", () => { const notInResult = await graphql({ schema: neoSchema.schema, source: photographsNotInQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: [ { longitude: parseFloat(faker.address.longitude()), latitude: parseFloat(faker.address.latitude()) }, { longitude: parseFloat(faker.address.longitude()), latitude: parseFloat(faker.address.latitude()) }, @@ -486,7 +486,7 @@ describe("Point", () => { const lessThanResult = await graphql({ schema: neoSchema.schema, source: photographsLessThanQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { longitude, latitude: latitude + 1 }, }); @@ -523,7 +523,7 @@ describe("Point", () => { const greaterThanResult = await graphql({ schema: neoSchema.schema, source: photographsGreaterThanQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { longitude, latitude: latitude + 1 }, }); @@ -582,7 +582,7 @@ describe("Point", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: photographsQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { longitude, latitude, height }, }); diff --git a/packages/graphql/tests/integration/types/points-cartesian.int.test.ts b/packages/graphql/tests/integration/types/points-cartesian.int.test.ts index 3a7e89c3fb..3de0bad415 100644 --- a/packages/graphql/tests/integration/types/points-cartesian.int.test.ts +++ b/packages/graphql/tests/integration/types/points-cartesian.int.test.ts @@ -77,7 +77,7 @@ describe("[CartesianPoint]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, locations }, }); @@ -132,7 +132,7 @@ describe("[CartesianPoint]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, locations }, }); @@ -218,7 +218,7 @@ describe("[CartesianPoint]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, locations: newLocations }, }); @@ -306,7 +306,7 @@ describe("[CartesianPoint]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, locations: newLocations }, }); @@ -374,7 +374,7 @@ describe("[CartesianPoint]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: partsQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id }, }); @@ -425,7 +425,7 @@ describe("[CartesianPoint]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: partsQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id }, }); diff --git a/packages/graphql/tests/integration/types/points.int.test.ts b/packages/graphql/tests/integration/types/points.int.test.ts index e6696ea06e..a797f338de 100644 --- a/packages/graphql/tests/integration/types/points.int.test.ts +++ b/packages/graphql/tests/integration/types/points.int.test.ts @@ -77,7 +77,7 @@ describe("[Point]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, waypoints }, }); @@ -132,7 +132,7 @@ describe("[Point]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: create, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, waypoints }, }); @@ -218,7 +218,7 @@ describe("[Point]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, waypoints: newWaypoints }, }); @@ -306,7 +306,7 @@ describe("[Point]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: update, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id, waypoints: newWaypoints }, }); @@ -376,7 +376,7 @@ describe("[Point]", () => { const routesResult = await graphql({ schema: neoSchema.schema, source: routesQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { waypoints }, }); @@ -404,7 +404,7 @@ describe("[Point]", () => { const routesIncludesResult = await graphql({ schema: neoSchema.schema, source: routesIncludesQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { waypoint: waypoints[0] }, }); @@ -432,7 +432,7 @@ describe("[Point]", () => { const routesNotIncludesResult = await graphql({ schema: neoSchema.schema, source: routesNotIncludesQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { waypoint: { longitude: parseFloat(faker.address.longitude()), @@ -488,7 +488,7 @@ describe("[Point]", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: routesQuery, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, variableValues: { id }, }); diff --git a/packages/graphql/tests/integration/unions.int.test.ts b/packages/graphql/tests/integration/unions.int.test.ts index c96c242bac..34bed55a0f 100644 --- a/packages/graphql/tests/integration/unions.int.test.ts +++ b/packages/graphql/tests/integration/unions.int.test.ts @@ -89,7 +89,7 @@ describe("unions", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: query, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -105,6 +105,74 @@ describe("unions", () => { } }); + test("should read and return correct union members with where argument", async () => { + const session = driver.session(); + + const typeDefs = ` + union Search = Movie | Genre + + type Genre { + name: String + } + + type Movie { + title: String + search: [Search] @relationship(type: "SEARCH", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: {}, + }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const genreName = generate({ + charset: "alphabetic", + }); + + const query = ` + { + movies (where: {title: "${movieTitle}"}) { + search(where: { Genre: { name: "${genreName}" }}) { + __typename + ... on Movie { + title + } + ... on Genre { + name + } + } + } + } + `; + + try { + await session.run(` + CREATE (m:Movie {title: "${movieTitle}"}) + CREATE (g:Genre {name: "${genreName}"}) + MERGE (m)-[:SEARCH]->(m) + MERGE (m)-[:SEARCH]->(g) + `); + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect((gqlResult.data as any).movies[0]).toEqual({ + search: [{ __typename: "Genre", name: genreName }], + }); + } finally { + await session.close(); + } + }); + test("should create a nested union", async () => { const session = driver.session(); @@ -138,10 +206,14 @@ describe("unions", () => { mutation { createMovies(input: [{ title: "${movieTitle}", - search_Genre: { - create: [{ - name: "${genreName}" - }] + search: { + Genre: { + create: [{ + node: { + name: "${genreName}" + } + }] + } } }]) { movies { @@ -161,7 +233,7 @@ describe("unions", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -174,6 +246,107 @@ describe("unions", () => { } }); + test("should create multiple nested unions", async () => { + const session = driver.session(); + + const typeDefs = ` + union Search = Movie | Genre + + type Genre { + name: String + } + + type Movie { + title: String + search: [Search] @relationship(type: "SEARCH", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: {}, + }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const genreName = generate({ + charset: "alphabetic", + }); + + const nestedMovieTitle = generate({ + charset: "alphabetic", + }); + + const mutation = ` + mutation { + createMovies(input: [{ + title: "${movieTitle}", + search: { + Genre: { + create: [{ + node: { + name: "${genreName}" + } + }] + } + Movie: { + create: [{ + node: { + title: "${nestedMovieTitle}" + } + }] + } + } + }]) { + movies { + title + search { + __typename + ...on Genre { + name + } + ... on Movie { + title + } + } + } + } + } + `; + + try { + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + // expect((gqlResult.data as any).createMovies.movies[0]).toEqual({ + // title: movieTitle, + // search: [ + // { __typename: "Genre", name: genreName }, + // { __typename: "Movie", title: nestedMovieTitle }, + // ], + // }); + + expect((gqlResult.data as any).createMovies.movies[0].title).toEqual(movieTitle); + expect((gqlResult.data as any).createMovies.movies[0].search).toHaveLength(2); + expect((gqlResult.data as any).createMovies.movies[0].search).toContainEqual({ + __typename: "Genre", + name: genreName, + }); + expect((gqlResult.data as any).createMovies.movies[0].search).toContainEqual({ + __typename: "Movie", + title: nestedMovieTitle, + }); + } finally { + await session.close(); + } + }); + test("should connect to a union", async () => { const session = driver.session(); @@ -207,10 +380,12 @@ describe("unions", () => { mutation { createMovies(input: [{ title: "${movieTitle}", - search_Genre: { - connect: [{ - where: { name: "${genreName}" } - }] + search: { + Genre: { + connect: [{ + where: { node: { name: "${genreName}" } } + }] + } } }]) { movies { @@ -234,7 +409,7 @@ describe("unions", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -285,10 +460,12 @@ describe("unions", () => { updateMovies( where: { title: "${movieTitle}" }, update: { - search_Genre: { - where: { name: "${genreName}" }, - update: { - name: "${newGenreName}" + search: { + Genre: { + where: { node: { name: "${genreName}" } }, + update: { + node: { name: "${newGenreName}" } + } } } } @@ -314,7 +491,7 @@ describe("unions", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -327,6 +504,112 @@ describe("unions", () => { } }); + test("should update multiple unions", async () => { + const session = driver.session(); + + const typeDefs = ` + union Search = Movie | Genre + + type Genre { + name: String + } + + type Movie { + title: String + search: [Search] @relationship(type: "SEARCH", direction: OUT) + } + `; + + const neoSchema = new Neo4jGraphQL({ + typeDefs, + resolvers: {}, + }); + + const movieTitle = generate({ + charset: "alphabetic", + }); + + const genreName = generate({ + charset: "alphabetic", + }); + + const newGenreName = generate({ + charset: "alphabetic", + }); + + const nestedMovieTitle = generate({ + charset: "alphabetic", + }); + + const newNestedMovieTitle = generate({ + charset: "alphabetic", + }); + + const mutation = ` + mutation { + updateMovies( + where: { title: "${movieTitle}" }, + update: { + search: { + Genre: { + where: { node: { name: "${genreName}" } }, + update: { + node: { name: "${newGenreName}" } + } + } + Movie: { + where: { node: { title: "${nestedMovieTitle}" } }, + update: { + node: { title: "${newNestedMovieTitle}" } + } + } + } + } + ) { + movies { + title + search { + __typename + ...on Genre { + name + } + ...on Movie { + title + } + } + } + } + } + `; + + try { + await session.run(` + CREATE (m:Movie {title: "${movieTitle}"})-[:SEARCH]->(:Genre {name: "${genreName}"}) + CREATE (m)-[:SEARCH]->(:Movie {title: "${nestedMovieTitle}"}) + `); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: mutation, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + expect((gqlResult.data as any).updateMovies.movies[0].title).toEqual(movieTitle); + expect((gqlResult.data as any).updateMovies.movies[0].search).toHaveLength(2); + expect((gqlResult.data as any).updateMovies.movies[0].search).toContainEqual({ + __typename: "Genre", + name: newGenreName, + }); + expect((gqlResult.data as any).updateMovies.movies[0].search).toContainEqual({ + __typename: "Movie", + title: newNestedMovieTitle, + }); + } finally { + await session.close(); + } + }); + test("should disconnect from a union", async () => { const session = driver.session(); @@ -361,10 +644,12 @@ describe("unions", () => { updateMovies( where: { title: "${movieTitle}" }, update: { - search_Genre: { - disconnect: [{ - where: { name: "${genreName}" } - }] + search: { + Genre: { + disconnect: [{ + where: { node: { name: "${genreName}" } } + }] + } } } ) { @@ -389,7 +674,7 @@ describe("unions", () => { const gqlResult = await graphql({ schema: neoSchema.schema, source: mutation, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/integration/update.int.test.ts b/packages/graphql/tests/integration/update.int.test.ts index c3d9d64b42..b5526ed98d 100644 --- a/packages/graphql/tests/integration/update.int.test.ts +++ b/packages/graphql/tests/integration/update.int.test.ts @@ -71,7 +71,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: { id, name: updatedName }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -132,7 +132,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: { id, name: updatedName }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -143,6 +143,81 @@ describe("update", () => { } }); + test("should update a movie when matching on relationship property", async () => { + const session = driver.session(); + + const typeDefs = ` + type Actor { + name: String + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) + } + + type Movie { + id: ID + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const initialMovieId = generate({ + charset: "alphabetic", + }); + + const updatedMovieId = generate({ + charset: "alphabetic", + }); + + const actorName = generate({ + charset: "alphabetic", + }); + + const query = ` + mutation($updatedMovieId: ID, $actorName: String) { + updateMovies( + where: { actorsConnection: { node: { name: $actorName } } }, + update: { + id: $updatedMovieId + } + ) { + movies { + id + actors { + name + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (m:Movie {id: $initialMovieId})<-[:ACTED_IN]-(a:Actor {name: $actorName}) + `, + { + initialMovieId, + actorName, + } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + variableValues: { updatedMovieId, actorName }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.updateMovies).toEqual({ + movies: [{ id: updatedMovieId, actors: [{ name: actorName }] }], + }); + } finally { + await session.close(); + } + }); + test("should update 2 movies", async () => { const session = driver.session(); @@ -192,7 +267,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: { id1, id2, name: updatedName }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -243,8 +318,8 @@ describe("update", () => { where: { id: $movieId }, update: { actors: [{ - where: { name: $initialName }, - update: { name: $updatedName } + where: { node: { name: $initialName } }, + update: { node: { name: $updatedName } } }] } ) { @@ -275,7 +350,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: { movieId, updatedName, initialName }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -288,7 +363,7 @@ describe("update", () => { } }); - test("should delete a nested actor from a movie", async () => { + test("should delete a nested actor from a movie abc", async () => { const session = driver.session(); const typeDefs = gql` @@ -315,7 +390,7 @@ describe("update", () => { const mutation = ` mutation($id: ID, $name: String) { - updateMovies(where: { id: $id }, delete: { actors: { where: { name: $name } } }) { + updateMovies(where: { id: $id }, delete: { actors: { where: { node: { name: $name } } } }) { movies { id actors { @@ -343,7 +418,7 @@ describe("update", () => { schema: neoSchema.schema, source: mutation, variableValues: { id, name }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -383,7 +458,7 @@ describe("update", () => { const mutation = ` mutation($id: ID, $name: String) { - updateMovies(where: { id: $id }, update: { actors: { delete: { where: { name: $name } } } }) { + updateMovies(where: { id: $id }, update: { actors: { delete: { where: { node: { name: $name } } } } }) { movies { id actors { @@ -411,7 +486,7 @@ describe("update", () => { schema: neoSchema.schema, source: mutation, variableValues: { id, name }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -424,7 +499,7 @@ describe("update", () => { } }); - test("should delete a nested actor and one of their nested movies, within an update block", async () => { + test("should delete a nested actor and one of their nested movies, within an update block abc", async () => { const session = driver.session(); const typeDefs = gql` @@ -458,7 +533,7 @@ describe("update", () => { updateMovies( where: { id: $id1 } update: { - actors: { delete: { where: { name: $name }, delete: { movies: { where: { id: $id2 } } } } } + actors: { delete: { where: { node: { name: $name } }, delete: { movies: { where: { node: { id: $id2 } } } } } } } ) { movies { @@ -491,7 +566,7 @@ describe("update", () => { schema: neoSchema.schema, source: mutation, variableValues: { id1, name, id2 }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -551,7 +626,7 @@ describe("update", () => { mutation($id: ID, $name1: String, $name3: String) { updateMovies( where: { id: $id } - delete: { actors: [{ where: { name: $name1 } }, { where: { name: $name3 } }] } + delete: { actors: [{ where: { node: { name: $name1 } } }, { where: { node: { name: $name3 } } }] } ) { movies { id @@ -586,7 +661,7 @@ describe("update", () => { schema: neoSchema.schema, source: mutation, variableValues: { id, name1, name3 }, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -627,13 +702,15 @@ describe("update", () => { where: { id: "${movieId}" } update: { actors: [{ - where: { name: "old actor name" } + where: { node: { name: "old actor name" } } update: { - name: "new actor name" - movies: [{ - where: { title: "old movie title" } - update: { title: "new movie title" } - }] + node: { + name: "new actor name" + movies: [{ + where: { node: { title: "old movie title" } } + update: { node: { title: "new movie title" } } + }] + } } }] } @@ -663,7 +740,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: {}, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -703,7 +780,7 @@ describe("update", () => { const query = ` mutation { - updateMovies(where: { id: "${movieId}" }, connect: {actors: [{where: {id: "${actorId}"}}]}) { + updateMovies(where: { id: "${movieId}" }, connect: {actors: [{where: {node:{id: "${actorId}"}}}]}) { movies { id actors { @@ -730,7 +807,86 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: {}, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, + }); + + expect(gqlResult.errors).toBeFalsy(); + + expect(gqlResult?.data?.updateMovies).toEqual({ movies: [{ id: movieId, actors: [{ id: actorId }] }] }); + } finally { + await session.close(); + } + }); + + test("should connect a single movie to a actor based on a connection predicate", async () => { + const session = driver.session(); + + const typeDefs = ` + type Actor { + id: ID + movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) + series: [Series] @relationship(type: "ACTED_IN", direction: OUT) + } + + type Movie { + id: ID + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN) + } + + type Series { + id: ID + actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN) + } + `; + + const neoSchema = new Neo4jGraphQL({ typeDefs }); + + const movieId = generate({ + charset: "alphabetic", + }); + + const actorId = generate({ + charset: "alphabetic", + }); + + const seriesId = generate({ + charset: "alphabetic", + }); + + const query = ` + mutation($movieId: ID, $seriesId: ID) { + updateMovies( + where: { id: $movieId } + connect: { actors: [{ where: { node: { seriesConnection: { node: { id: $seriesId } } } } }] } + ) { + movies { + id + actors { + id + } + } + } + } + `; + + try { + await session.run( + ` + CREATE (:Movie {id: $movieId}) + CREATE (:Actor {id: $actorId})-[:ACTED_IN]->(:Series {id: $seriesId}) + `, + { + movieId, + actorId, + seriesId, + } + ); + + const gqlResult = await graphql({ + schema: neoSchema.schema, + source: query, + variableValues: { movieId, seriesId }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -768,7 +924,7 @@ describe("update", () => { const query = ` mutation { - updateMovies(where: { id: "${movieId}" }, disconnect: {actors: [{where: {id: "${actorId}"}}]}) { + updateMovies(where: { id: "${movieId}" }, disconnect: {actors: [{where: { node: { id: "${actorId}"}}}]}) { movies { id actors { @@ -796,7 +952,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: {}, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -846,9 +1002,11 @@ describe("update", () => { where: { id: "${productId}" } update: { photos: [{ - where: { id: "${photoId}" } + where: { node: { id: "${photoId}" } } update: { - color: { disconnect: { where: { id: "${colorId}" } } } + node: { + color: { disconnect: { where: { node: { id: "${colorId}" } } } } + } } }] } @@ -886,7 +1044,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: {}, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -959,23 +1117,27 @@ describe("update", () => { update: { photos: [ { - where: { name: "Green Photo", id: "${photo0Id}" } + where: { node: { name: "Green Photo", id: "${photo0Id}" } } update: { - name: "Light Green Photo" - color: { - connect: { where: { name: "Light Green", id: "${photo0Color1Id}" } } - disconnect: { where: { name: "Green", id: "${photo0Color0Id}" } } - } + node: { + name: "Light Green Photo" + color: { + connect: { where: { node: { name: "Light Green", id: "${photo0Color1Id}" } } } + disconnect: { where: { node: { name: "Green", id: "${photo0Color0Id}" } } } + } + } } } { - where: { name: "Yellow Photo", id: "${photo1Id}" } + where: { node: { name: "Yellow Photo", id: "${photo1Id}" } } update: { - name: "Light Yellow Photo" - color: { - connect: { where: { name: "Light Yellow", id: "${photo1Color1Id}" } } - disconnect: { where: { name: "Yellow", id: "${photo1Color0Id}" } } - } + node: { + name: "Light Yellow Photo" + color: { + connect: { where: { node: { name: "Light Yellow", id: "${photo1Color1Id}" } } } + disconnect: { where: { node: { name: "Yellow", id: "${photo1Color0Id}" } } } + } + } } } ] @@ -1028,7 +1190,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: {}, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -1101,12 +1263,16 @@ describe("update", () => { update: { photos: [{ create: [{ - id: "${photoId}", - name: "Green Photo", - color: { - create: { - id: "${colorId}", - name: "Green" + node: { + id: "${photoId}", + name: "Green Photo", + color: { + create: { + node: { + id: "${colorId}", + name: "Green" + } + } } } }] @@ -1142,7 +1308,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: {}, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); @@ -1199,12 +1365,16 @@ describe("update", () => { where: { id: "${productId}" } create: { photos: [{ - id: "${photoId}", - name: "Green Photo", - color: { - create: { - id: "${colorId}", - name: "Green" + node: { + id: "${photoId}", + name: "Green Photo", + color: { + create: { + node: { + id: "${colorId}", + name: "Green" + } + } } } }] @@ -1216,8 +1386,8 @@ describe("update", () => { id name color { - id - name + id + name } } } @@ -1239,7 +1409,7 @@ describe("update", () => { schema: neoSchema.schema, source: query, variableValues: {}, - contextValue: { driver }, + contextValue: { driver, driverConfig: { bookmarks: [session.lastBookmark()] } }, }); expect(gqlResult.errors).toBeFalsy(); diff --git a/packages/graphql/tests/tck/tck-test-files/.markdownlint.json b/packages/graphql/tests/tck/tck-test-files/.markdownlint.json new file mode 100644 index 0000000000..de8ca414e5 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "default": true, + "MD013": false, + "MD024": { "siblings_only": true } +} diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/advanced-filtering.md b/packages/graphql/tests/tck/tck-test-files/cypher/advanced-filtering.md index 296d52ef1e..808d6733f1 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/advanced-filtering.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/advanced-filtering.md @@ -1,10 +1,10 @@ -## Cypher Advanced Filtering +# Cypher Advanced Filtering Tests advanced filtering. Schema: -```schema +```graphql type Movie { _id: ID id: ID @@ -15,8 +15,8 @@ type Movie { } type Genre { - name: String - movies: [Movie] @relationship(type: "IN_GENRE", direction: IN) + name: String + movies: [Movie] @relationship(type: "IN_GENRE", direction: IN) } ``` @@ -26,9 +26,9 @@ NEO4J_GRAPHQL_ENABLE_REGEX=1 --- -### IN +## IN -**GraphQL input** +### GraphQL Input ```graphql { @@ -38,7 +38,7 @@ NEO4J_GRAPHQL_ENABLE_REGEX=1 } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -46,9 +46,9 @@ WHERE this._id IN $this__id_IN RETURN this { ._id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this__id_IN": ["123"] } @@ -56,9 +56,9 @@ RETURN this { ._id } as this --- -### REGEX +## REGEX -**GraphQL input** +### GraphQL Input ```graphql { @@ -68,7 +68,7 @@ RETURN this { ._id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -76,9 +76,9 @@ WHERE this.id =~ $this_id_MATCHES RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_MATCHES": "(?i)123.*" } @@ -86,9 +86,9 @@ RETURN this { .id } as this --- -### NOT +## NOT -**GraphQL input** +### GraphQL Input ```graphql { @@ -98,7 +98,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -106,9 +106,9 @@ WHERE (NOT this.id = $this_id_NOT) RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_NOT": "123" } @@ -116,9 +116,9 @@ RETURN this { .id } as this --- -### NOT_IN +## NOT_IN -**GraphQL input** +### GraphQL Input ```graphql { @@ -128,7 +128,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -136,9 +136,9 @@ WHERE (NOT this.id IN $this_id_NOT_IN) RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_NOT_IN": ["123"] } @@ -146,9 +146,9 @@ RETURN this { .id } as this --- -### CONTAINS +## CONTAINS -**GraphQL input** +### GraphQL Input ```graphql { @@ -158,7 +158,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -166,9 +166,9 @@ WHERE this.id CONTAINS $this_id_CONTAINS RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_CONTAINS": "123" } @@ -176,9 +176,9 @@ RETURN this { .id } as this --- -### NOT_CONTAINS +## NOT_CONTAINS -**GraphQL input** +### GraphQL Input ```graphql { @@ -188,7 +188,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -196,9 +196,9 @@ WHERE (NOT this.id CONTAINS $this_id_NOT_CONTAINS) RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_NOT_CONTAINS": "123" } @@ -206,9 +206,9 @@ RETURN this { .id } as this --- -### STARTS_WITH +## STARTS_WITH -**GraphQL input** +### GraphQL Input ```graphql { @@ -218,7 +218,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -226,9 +226,9 @@ WHERE this.id STARTS WITH $this_id_STARTS_WITH RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_STARTS_WITH": "123" } @@ -236,9 +236,9 @@ RETURN this { .id } as this --- -### NOT_STARTS_WITH +## NOT_STARTS_WITH -**GraphQL input** +### GraphQL Input ```graphql { @@ -248,7 +248,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -256,9 +256,9 @@ WHERE (NOT this.id STARTS WITH $this_id_NOT_STARTS_WITH) RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_NOT_STARTS_WITH": "123" } @@ -266,9 +266,9 @@ RETURN this { .id } as this --- -### ENDS_WITH +## ENDS_WITH -**GraphQL input** +### GraphQL Input ```graphql { @@ -278,7 +278,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -286,9 +286,9 @@ WHERE this.id ENDS WITH $this_id_ENDS_WITH RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_ENDS_WITH": "123" } @@ -296,9 +296,9 @@ RETURN this { .id } as this --- -### NOT_ENDS_WITH +## NOT_ENDS_WITH -**GraphQL input** +### GraphQL Input ```graphql { @@ -308,7 +308,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -316,9 +316,9 @@ WHERE (NOT this.id ENDS WITH $this_id_NOT_ENDS_WITH) RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_NOT_ENDS_WITH": "123" } @@ -326,9 +326,9 @@ RETURN this { .id } as this --- -### LT +## LT -**GraphQL input** +### GraphQL Input ```graphql { @@ -338,7 +338,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -346,9 +346,9 @@ WHERE this.actorCount < $this_actorCount_LT RETURN this { .actorCount } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_actorCount_LT": { "high": 0, @@ -359,9 +359,9 @@ RETURN this { .actorCount } as this --- -### LT BigInt +## LT BigInt -**GraphQL input** +### GraphQL Input ```graphql { @@ -371,7 +371,7 @@ RETURN this { .actorCount } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -379,9 +379,9 @@ WHERE this.budget < $this_budget_LT RETURN this { .budget } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_budget_LT": { "low": -1, @@ -392,9 +392,9 @@ RETURN this { .budget } as this --- -### LTE +## LTE -**GraphQL input** +### GraphQL Input ```graphql { @@ -404,7 +404,7 @@ RETURN this { .budget } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -412,9 +412,9 @@ WHERE this.actorCount <= $this_actorCount_LTE RETURN this { .actorCount } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_actorCount_LTE": { "high": 0, @@ -425,9 +425,9 @@ RETURN this { .actorCount } as this --- -### LTE BigInt +## LTE BigInt -**GraphQL input** +### GraphQL Input ```graphql { @@ -437,7 +437,7 @@ RETURN this { .actorCount } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -445,9 +445,9 @@ WHERE this.budget <= $this_budget_LTE RETURN this { .budget } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_budget_LTE": { "low": -1, @@ -458,9 +458,9 @@ RETURN this { .budget } as this --- -### GT +## GT -**GraphQL input** +### GraphQL Input ```graphql { @@ -470,7 +470,7 @@ RETURN this { .budget } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -478,9 +478,9 @@ WHERE this.actorCount > $this_actorCount_GT RETURN this { .actorCount } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_actorCount_GT": { "high": 0, @@ -491,9 +491,9 @@ RETURN this { .actorCount } as this --- -### GT BigInt +## GT BigInt -**GraphQL input** +### GraphQL Input ```graphql { @@ -503,7 +503,7 @@ RETURN this { .actorCount } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -511,9 +511,9 @@ WHERE this.budget > $this_budget_GT RETURN this { .budget } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_budget_GT": { "low": -808, @@ -524,9 +524,9 @@ RETURN this { .budget } as this --- -### GTE +## GTE -**GraphQL input** +### GraphQL Input ```graphql { @@ -536,7 +536,7 @@ RETURN this { .budget } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -544,9 +544,9 @@ WHERE this.actorCount >= $this_actorCount_GTE RETURN this { .actorCount } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_actorCount_GTE": { "high": 0, @@ -557,9 +557,9 @@ RETURN this { .actorCount } as this --- -### GTE BigInt +## GTE BigInt -**GraphQL input** +### GraphQL Input ```graphql { @@ -569,7 +569,7 @@ RETURN this { .actorCount } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -577,9 +577,9 @@ WHERE this.budget >= $this_budget_GTE RETURN this { .budget } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_budget_GTE": { "low": -808, @@ -590,9 +590,9 @@ RETURN this { .budget } as this --- -### Relationship equality +## Relationship equality -**GraphQL input** +### GraphQL Input ```graphql { @@ -602,7 +602,7 @@ RETURN this { .budget } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -610,9 +610,9 @@ WHERE EXISTS((this)-[:IN_GENRE]->(:Genre)) AND ANY(this_genres IN [(this)-[:IN_G RETURN this { .actorCount } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_genres_name": "some genre" } @@ -620,9 +620,9 @@ RETURN this { .actorCount } as this --- -### Relationship NOT +## Relationship NOT -**GraphQL input** +### GraphQL Input ```graphql { @@ -632,7 +632,7 @@ RETURN this { .actorCount } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -640,12 +640,92 @@ WHERE EXISTS((this)-[:IN_GENRE]->(:Genre)) AND NONE(this_genres_NOT IN [(this)-[ RETURN this { .actorCount } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_genres_NOT_name": "some genre" } ``` --- + +## Node and relationship properties equality + +### GraphQL Input + +```graphql +{ + movies(where: { genresConnection: { node: { name: "some genre" } } }) { + actorCount + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE EXISTS((this)-[:IN_GENRE]->(:Genre)) +AND ANY(this_genresConnection_map IN [(this)-[this_genresConnection_MovieGenresRelationship:IN_GENRE]->(this_genresConnection:Genre) | { node: this_genresConnection, relationship: this_genresConnection_MovieGenresRelationship } ] +WHERE this_genresConnection_map.node.name = $this_movies.where.genresConnection.node.name) +RETURN this { .actorCount } as this +``` + +### Expected Cypher Params + +```json +{ + "this_movies": { + "where": { + "genresConnection": { + "node": { + "name": "some genre" + } + } + } + } +} +``` + +--- + +## Node and relationship properties NOT + +### GraphQL Input + +```graphql +{ + movies(where: { genresConnection_NOT: { node: { name: "some genre" } } }) { + actorCount + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE EXISTS((this)-[:IN_GENRE]->(:Genre)) +AND NONE(this_genresConnection_NOT_map IN [(this)-[this_genresConnection_NOT_MovieGenresRelationship:IN_GENRE]->(this_genresConnection_NOT:Genre) | { node: this_genresConnection_NOT, relationship: this_genresConnection_NOT_MovieGenresRelationship } ] +WHERE this_genresConnection_NOT_map.node.name = $this_movies.where.genresConnection_NOT.node.name) +RETURN this { .actorCount } as this +``` + +### Expected Cypher Params + +```json +{ + "this_movies": { + "where": { + "genresConnection_NOT": { + "node": { + "name": "some genre" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/alias.md b/packages/graphql/tests/tck/tck-test-files/cypher/alias.md index d6f3b1febb..b3bb18aa4f 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/alias.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/alias.md @@ -1,10 +1,10 @@ -## Cypher Alias +# Cypher Alias Tests to ensure when using aliases that the cypher is correct. Schema: -```schema +```graphql type Actor { name: String! } @@ -14,18 +14,21 @@ type Movie { releaseDate: DateTime! location: Point! actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) - custom: [Movie!]! @cypher(statement: """ - MATCH (m:Movie) - RETURN m - """) + custom: [Movie!]! + @cypher( + statement: """ + MATCH (m:Movie) + RETURN m + """ + ) } ``` --- -### Alias +## Alias -**GraphQL input** +### GraphQL Input ```graphql { @@ -41,7 +44,7 @@ type Movie { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -165,14 +168,14 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} } } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/arrays.md b/packages/graphql/tests/tck/tck-test-files/cypher/arrays.md index eb9db95243..c7b7530e9e 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/arrays.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/arrays.md @@ -1,10 +1,10 @@ -## Cypher Arrays +# Cypher Arrays Tests for queries using options.where Schema: -```schema +```graphql type Movie { title: String! ratings: [Float!]! @@ -13,9 +13,9 @@ type Movie { --- -### WHERE INCLUDES +## WHERE INCLUDES -**GraphQL input** +### GraphQL Input ```graphql { @@ -26,7 +26,7 @@ type Movie { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -34,9 +34,9 @@ WHERE $this_ratings_INCLUDES IN this.ratings RETURN this { .title, .ratings } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_ratings_INCLUDES": 4.0 } @@ -44,9 +44,9 @@ RETURN this { .title, .ratings } as this --- -### WHERE NOT INCLUDES +## WHERE NOT INCLUDES -**GraphQL input** +### GraphQL Input ```graphql { @@ -57,7 +57,7 @@ RETURN this { .title, .ratings } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -65,9 +65,9 @@ WHERE (NOT $this_ratings_NOT_INCLUDES IN this.ratings) RETURN this { .title, .ratings } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_ratings_NOT_INCLUDES": 4.0 } diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/alias.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/alias.md new file mode 100644 index 0000000000..ac8a29d832 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/alias.md @@ -0,0 +1,148 @@ +# Connections Alias + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Alias Top Level Connection Field + +### GraphQL Input + +```graphql +{ + movies { + actors: actorsConnection { + totalCount + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ }) AS edges + RETURN { + totalCount: size(edges) + } AS actors +} +RETURN this { actors } as this +``` + +### Expected Cypher Params + +```json +{} +``` + +--- + +## Alias Top Level Connection Field Multiple Times + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + hanks: actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + jenny: actorsConnection(where: { node: { name: "Robin Wright" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_hanks.args.where.node.name + WITH collect({ + screenTime: this_acted_in.screenTime, + node: { + name: this_actor.name + } + }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS hanks +} +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_jenny.args.where.node.name + WITH collect({ + screenTime: this_acted_in.screenTime, + node: { + name: this_actor.name + } + }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS jenny +} +RETURN this { .title, hanks, jenny } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_hanks": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + }, + "this_jenny": { + "args": { + "where": { + "node": { + "name": "Robin Wright" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md new file mode 100644 index 0000000000..9f4f2a62a3 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/composite.md @@ -0,0 +1,100 @@ +# Cypher -> Connections -> Filtering -> Composite + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + firstName: String! + lastName: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Composite + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection( + where: { + node: { AND: [{ firstName: "Tom" }, { lastName: "Hanks" }] } + edge: { AND: [{ screenTime_GT: 30 }, { screenTime_LT: 90 }] } + } + ) { + edges { + screenTime + node { + firstName + lastName + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_acted_in.screenTime > $this_actorsConnection.args.where.edge.AND[0].screenTime_GT) AND (this_acted_in.screenTime < $this_actorsConnection.args.where.edge.AND[1].screenTime_LT)) AND ((this_actor.firstName = $this_actorsConnection.args.where.node.AND[0].firstName) AND (this_actor.lastName = $this_actorsConnection.args.where.node.AND[1].lastName)) + WITH collect({ screenTime: this_acted_in.screenTime, node: { firstName: this_actor.firstName, lastName: this_actor.lastName } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "AND": [{ "firstName": "Tom" }, { "lastName": "Hanks" }] + }, + "edge": { + "AND": [ + { + "screenTime_GT": { + "high": 0, + "low": 30 + } + }, + { + "screenTime_LT": { + "high": 0, + "low": 90 + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md new file mode 100644 index 0000000000..f77f75a6cf --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/and.md @@ -0,0 +1,88 @@ +# Cypher -> Connections -> Filtering -> Node -> AND + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + firstName: String! + lastName: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## AND + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + node: { AND: [{ firstName: "Tom" }, { lastName: "Hanks" }] } + } + ) { + edges { + screenTime + node { + firstName + lastName + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_actor.firstName = $this_actorsConnection.args.where.node.AND[0].firstName) AND (this_actor.lastName = $this_actorsConnection.args.where.node.AND[1].lastName)) + WITH collect({ screenTime: this_acted_in.screenTime, node: { firstName: this_actor.firstName, lastName: this_actor.lastName } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "AND": [ + { + "firstName": "Tom" + }, + { + "lastName": "Hanks" + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md new file mode 100644 index 0000000000..d8a12f3b54 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/arrays.md @@ -0,0 +1,242 @@ +# Cypher -> Connections -> Filtering -> Node -> Arrays + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + favouriteColours: [String!] + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## IN + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { name_IN: ["Tom Hanks", "Robin Wright"] } } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name IN $this_actorsConnection.args.where.node.name_IN + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_IN": ["Tom Hanks", "Robin Wright"] + } + } + } + } +} +``` + +--- + +## NOT_IN + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { name_NOT_IN: ["Tom Hanks", "Robin Wright"] } } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name IN $this_actorsConnection.args.where.node.name_NOT_IN) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_IN": ["Tom Hanks", "Robin Wright"] + } + } + } + } +} +``` + +--- + +## INCLUDES + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { favouriteColours_INCLUDES: "Blue" } } + ) { + edges { + screenTime + node { + name + favouriteColours + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE $this_actorsConnection.args.where.node.favouriteColours_INCLUDES IN this_actor.favouriteColours + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, favouriteColours: this_actor.favouriteColours } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "favouriteColours_INCLUDES": "Blue" + } + } + } + } +} +``` + +--- + +## NOT_INCLUDES + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { node: { favouriteColours_NOT_INCLUDES: "Blue" } } + ) { + edges { + screenTime + node { + name + favouriteColours + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT $this_actorsConnection.args.where.node.favouriteColours_NOT_INCLUDES IN this_actor.favouriteColours) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, favouriteColours: this_actor.favouriteColours } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "favouriteColours_NOT_INCLUDES": "Blue" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md new file mode 100644 index 0000000000..38203270a1 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/equality.md @@ -0,0 +1,127 @@ +# Cypher -> Connections -> Filtering -> Node -> Equality + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Equality + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- + +## Inequality + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name = $this_actorsConnection.args.where.node.name_NOT) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT": "Tom Hanks" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md new file mode 100644 index 0000000000..906cf456a5 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/numerical.md @@ -0,0 +1,248 @@ +# Cypher -> Connections -> Filtering -> Node -> Numerical + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + age: Int! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## LT + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_LT: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age < $this_actorsConnection.args.where.node.age_LT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_LT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +## LTE + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_LTE: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age <= $this_actorsConnection.args.where.node.age_LTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_LTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +## GT + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_GT: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age > $this_actorsConnection.args.where.node.age_GT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_GT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +## GTE + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { age_GTE: 60 } }) { + edges { + screenTime + node { + name + age + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.age >= $this_actorsConnection.args.where.node.age_GTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, age: this_actor.age } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "age_GTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md new file mode 100644 index 0000000000..0754f20657 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/or.md @@ -0,0 +1,88 @@ +# Cypher -> Connections -> Filtering -> Node -> OR + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + firstName: String! + lastName: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## OR + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + node: { OR: [{ firstName: "Tom" }, { lastName: "Hanks" }] } + } + ) { + edges { + screenTime + node { + firstName + lastName + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_actor.firstName = $this_actorsConnection.args.where.node.OR[0].firstName) OR (this_actor.lastName = $this_actorsConnection.args.where.node.OR[1].lastName)) + WITH collect({ screenTime: this_acted_in.screenTime, node: { firstName: this_actor.firstName, lastName: this_actor.lastName } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "OR": [ + { + "firstName": "Tom" + }, + { + "lastName": "Hanks" + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md new file mode 100644 index 0000000000..f42d0c7ea0 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/points.md @@ -0,0 +1,95 @@ +# Cypher -> Connections -> Filtering -> Node -> Points + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + currentLocation: Point! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## DISTANCE + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + node: { + currentLocation_DISTANCE: { + point: { longitude: 1.0, latitude: 2.0 } + distance: 3.0 + } + } + } + ) { + edges { + screenTime + node { + name + currentLocation { + latitude + longitude + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE distance(this_actor.currentLocation, point($this_actorsConnection.args.where.node.currentLocation_DISTANCE.point)) = $this_actorsConnection.args.where.node.currentLocation_DISTANCE.distance + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, currentLocation: { point: this_actor.currentLocation } } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "currentLocation_DISTANCE": { + "distance": 3, + "point": { + "latitude": 2, + "longitude": 1 + } + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md new file mode 100644 index 0000000000..4fbdfaefb0 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/node/string.md @@ -0,0 +1,391 @@ +# Cypher -> Connections -> Filtering -> Node -> String + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +```env +NEO4J_GRAPHQL_ENABLE_REGEX=1 +``` + +--- + +## CONTAINS + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_CONTAINS: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name CONTAINS $this_actorsConnection.args.where.node.name_CONTAINS + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_CONTAINS": "Tom" + } + } + } + } +} +``` + +--- + +## NOT_CONTAINS + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT_CONTAINS: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name CONTAINS $this_actorsConnection.args.where.node.name_NOT_CONTAINS) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_CONTAINS": "Tom" + } + } + } + } +} +``` + +--- + +## STARTS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_STARTS_WITH: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name STARTS WITH $this_actorsConnection.args.where.node.name_STARTS_WITH + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_STARTS_WITH": "Tom" + } + } + } + } +} +``` + +--- + +## NOT_STARTS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT_STARTS_WITH: "Tom" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name STARTS WITH $this_actorsConnection.args.where.node.name_NOT_STARTS_WITH) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_STARTS_WITH": "Tom" + } + } + } + } +} +``` + +--- + +## ENDS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_ENDS_WITH: "Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name ENDS WITH $this_actorsConnection.args.where.node.name_ENDS_WITH + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_ENDS_WITH": "Hanks" + } + } + } + } +} +``` + +--- + +## NOT_ENDS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_NOT_ENDS_WITH: "Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_actor.name ENDS WITH $this_actorsConnection.args.where.node.name_NOT_ENDS_WITH) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_NOT_ENDS_WITH": "Hanks" + } + } + } + } +} +``` + +--- + +## MATCHES + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { node: { name_MATCHES: "Tom.+" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name =~ $this_actorsConnection.args.where.node.name_MATCHES + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name_MATCHES": "Tom.+" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md new file mode 100644 index 0000000000..fdbcbcba9f --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/and.md @@ -0,0 +1,93 @@ +# Cypher -> Connections -> Filtering -> Relationship -> AND + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + role: String! + screenTime: Int! +} +``` + +--- + +## AND + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + edge: { + AND: [{ role_ENDS_WITH: "Gump" }, { screenTime_LT: 60 }] + } + } + ) { + edges { + role + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_acted_in.role ENDS WITH $this_actorsConnection.args.where.edge.AND[0].role_ENDS_WITH) AND (this_acted_in.screenTime < $this_actorsConnection.args.where.edge.AND[1].screenTime_LT)) + WITH collect({ role: this_acted_in.role, screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "AND": [ + { + "role_ENDS_WITH": "Gump" + }, + { + "screenTime_LT": { + "high": 0, + "low": 60 + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md new file mode 100644 index 0000000000..5e430e7db3 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/arrays.md @@ -0,0 +1,260 @@ +# Cypher -> Connections -> Filtering -> Relationship -> Arrays + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! + quotes: [String!] +} +``` + +--- + +## IN + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime_IN: [60, 70] } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime IN $this_actorsConnection.args.where.edge.screenTime_IN + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime_IN": [ + { + "high": 0, + "low": 60 + }, + { + "high": 0, + "low": 70 + } + ] + } + } + } + } +} +``` + +--- + +## NOT_IN + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime_NOT_IN: [60, 70] } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.screenTime IN $this_actorsConnection.args.where.edge.screenTime_NOT_IN) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime_NOT_IN": [ + { + "high": 0, + "low": 60 + }, + { + "high": 0, + "low": 70 + } + ] + } + } + } + } +} +``` + +--- + +## INCLUDES + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + edge: { quotes_INCLUDES: "Life is like a box of chocolates" } + } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE $this_actorsConnection.args.where.edge.quotes_INCLUDES IN this_acted_in.quotes + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "quotes_INCLUDES": "Life is like a box of chocolates" + } + } + } + } +} +``` + +--- + +## NOT_INCLUDES + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + edge: { + quotes_NOT_INCLUDES: "Life is like a box of chocolates" + } + } + ) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT $this_actorsConnection.args.where.edge.quotes_NOT_INCLUDES IN this_acted_in.quotes) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "quotes_NOT_INCLUDES": "Life is like a box of chocolates" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md new file mode 100644 index 0000000000..239fe3ef85 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/equality.md @@ -0,0 +1,133 @@ +# Cypher -> Connections -> Filtering -> Relationship -> Equality + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Equality + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime = $this_actorsConnection.args.where.edge.screenTime + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +## Inequality + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime_NOT: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.screenTime = $this_actorsConnection.args.where.edge.screenTime_NOT) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime_NOT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md new file mode 100644 index 0000000000..f7a0d87a2f --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/numerical.md @@ -0,0 +1,243 @@ +# Cypher -> Connections -> Filtering -> Relationship -> Numerical + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## LT + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime_LT: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime < $this_actorsConnection.args.where.edge.screenTime_LT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime_LT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +## LTE + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime_LTE: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime <= $this_actorsConnection.args.where.edge.screenTime_LTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime_LTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +## GT + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime_GT: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime > $this_actorsConnection.args.where.edge.screenTime_GT + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime_GT": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- + +## GTE + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { screenTime_GTE: 60 } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.screenTime >= $this_actorsConnection.args.where.edge.screenTime_GTE + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "screenTime_GTE": { + "high": 0, + "low": 60 + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md new file mode 100644 index 0000000000..258586ef99 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/or.md @@ -0,0 +1,93 @@ +# Cypher -> Connections -> Filtering -> Relationship -> OR + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + role: String! + screenTime: Int! +} +``` + +--- + +## OR + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + edge: { + OR: [{ role_ENDS_WITH: "Gump" }, { screenTime_LT: 60 }] + } + } + ) { + edges { + role + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE ((this_acted_in.role ENDS WITH $this_actorsConnection.args.where.edge.OR[0].role_ENDS_WITH) OR (this_acted_in.screenTime < $this_actorsConnection.args.where.edge.OR[1].screenTime_LT)) + WITH collect({ role: this_acted_in.role, screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "OR": [ + { + "role_ENDS_WITH": "Gump" + }, + { + "screenTime_LT": { + "high": 0, + "low": 60 + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md new file mode 100644 index 0000000000..2ef0e3c811 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/points.md @@ -0,0 +1,95 @@ +# Cypher -> Connections -> Filtering -> Relationship -> Points + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! + location: Point! +} +``` + +--- + +## DISTANCE + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + edge: { + location_DISTANCE: { + point: { longitude: 1.0, latitude: 2.0 } + distance: 3.0 + } + } + } + ) { + edges { + screenTime + location { + latitude + longitude + } + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE distance(this_acted_in.location, point($this_actorsConnection.args.where.edge.location_DISTANCE.point)) = $this_actorsConnection.args.where.edge.location_DISTANCE.distance + WITH collect({ screenTime: this_acted_in.screenTime, location: { point: this_acted_in.location }, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "location_DISTANCE": { + "distance": 3, + "point": { + "latitude": 2, + "longitude": 1 + } + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md new file mode 100644 index 0000000000..e8d749474c --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/string.md @@ -0,0 +1,392 @@ +# Cypher -> Connections -> Filtering -> Relationship -> String + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + role: String! + screenTime: Int! +} +``` + +```env +NEO4J_GRAPHQL_ENABLE_REGEX=1 +``` + +--- + +## CONTAINS + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { role_CONTAINS: "Forrest" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role CONTAINS $this_actorsConnection.args.where.edge.role_CONTAINS + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "role_CONTAINS": "Forrest" + } + } + } + } +} +``` + +--- + +## NOT_CONTAINS + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { role_NOT_CONTAINS: "Forrest" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.role CONTAINS $this_actorsConnection.args.where.edge.role_NOT_CONTAINS) + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "role_NOT_CONTAINS": "Forrest" + } + } + } + } +} +``` + +--- + +## STARTS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { role_STARTS_WITH: "Forrest" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role STARTS WITH $this_actorsConnection.args.where.edge.role_STARTS_WITH + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "role_STARTS_WITH": "Forrest" + } + } + } + } +} +``` + +--- + +## NOT_STARTS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { role_NOT_STARTS_WITH: "Forrest" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.role STARTS WITH $this_actorsConnection.args.where.edge.role_NOT_STARTS_WITH) + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "role_NOT_STARTS_WITH": "Forrest" + } + } + } + } +} +``` + +--- + +## ENDS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { role_ENDS_WITH: "Gump" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role ENDS WITH $this_actorsConnection.args.where.edge.role_ENDS_WITH + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "role_ENDS_WITH": "Gump" + } + } + } + } +} +``` + +--- + +## NOT_ENDS_WITH + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { role_NOT_ENDS_WITH: "Gump" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE (NOT this_acted_in.role ENDS WITH $this_actorsConnection.args.where.edge.role_NOT_ENDS_WITH) + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "role_NOT_ENDS_WITH": "Gump" + } + } + } + } +} +``` + +--- + +## MATCHES + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection(where: { edge: { role_MATCHES: "Forrest.+" } }) { + edges { + role + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.role =~ $this_actorsConnection.args.where.edge.role_MATCHES + WITH collect({ role: this_acted_in.role, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "role_MATCHES": "Forrest.+" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/temporal.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/temporal.md new file mode 100644 index 0000000000..b8b6df48b2 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/filtering/relationship/temporal.md @@ -0,0 +1,99 @@ +# Cypher -> Connections -> Filtering -> Relationship -> Temporal + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + startDate: Date + endDateTime: DateTime +} +``` + +--- + +## DISTANCE + +### GraphQL Input + +```graphql +query { + movies { + title + actorsConnection( + where: { + edge: { + startDate_GT: "2000-01-01" + endDateTime_LT: "2010-01-01T00:00:00.000Z" + } + } + ) { + edges { + startDate + endDateTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_acted_in.startDate > $this_actorsConnection.args.where.edge.startDate_GT AND this_acted_in.endDateTime < $this_actorsConnection.args.where.edge.endDateTime_LT + WITH collect({ startDate: this_acted_in.startDate, endDateTime: apoc.date.convertFormat(toString(this_acted_in.endDateTime), "iso_zoned_date_time", "iso_offset_date_time"), node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_actorsConnection": { + "args": { + "where": { + "edge": { + "endDateTime_LT": { + "day": 1, + "hour": 0, + "minute": 0, + "month": 1, + "nanosecond": 0, + "second": 0, + "timeZoneId": null, + "timeZoneOffsetSeconds": 0, + "year": 2010 + }, + "startDate_GT": { + "day": 1, + "month": 1, + "year": 2000 + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md new file mode 100644 index 0000000000..460a1e11be --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/mixed-nesting.md @@ -0,0 +1,243 @@ +# Mixed nesting + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Connection -> Relationship + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + movies(where: { title_NOT: "Forrest Gump" }) { + title + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + WITH collect({ + screenTime: this_acted_in.screenTime, + node: { + name: this_actor.name, + movies: [ (this_actor)-[:ACTED_IN]->(this_actor_movies:Movie) WHERE (NOT this_actor_movies.title = $this_actor_movies_title_NOT) | this_actor_movies { .title } ] + } + }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_actor_movies_title_NOT": "Forrest Gump", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- + +## Connection -> Connection -> Relationship + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + moviesConnection( + where: { node: { title_NOT: "Forrest Gump" } } + ) { + edges { + node { + title + actors(where: { name_NOT: "Tom Hanks" }) { + name + } + } + } + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + CALL { + WITH this_actor + MATCH (this_actor)-[this_actor_acted_in:ACTED_IN]->(this_actor_movie:Movie) + WHERE (NOT this_actor_movie.title = $this_actorsConnection.edges.node.moviesConnection.args.where.node.title_NOT) + WITH collect({ + node: { + title: this_actor_movie.title, + actors: [ (this_actor_movie)<-[:ACTED_IN]-(this_actor_movie_actors:Actor) WHERE (NOT this_actor_movie_actors.name = $this_actor_movie_actors_name_NOT) | this_actor_movie_actors { .name } ] + } + }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS moviesConnection + } + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, moviesConnection: moviesConnection } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_actor_movie_actors_name_NOT": "Tom Hanks", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + }, + "edges": { + "node": { + "moviesConnection": { + "args": { + "where": { + "node": { + "title_NOT": "Forrest Gump" + } + } + } + } + } + } + } +} +``` + +--- + +## Relationship -> Connection + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actors(where: { name: "Tom Hanks" }) { + name + moviesConnection(where: { node: { title_NOT: "Forrest Gump" } }) { + edges { + screenTime + node { + title + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +RETURN this { + .title, + actors: [ (this)<-[:ACTED_IN]-(this_actors:Actor) WHERE this_actors.name = $this_actors_name | this_actors { + .name, + moviesConnection: apoc.cypher.runFirstColumn("CALL { + WITH this_actors + MATCH (this_actors)-[this_actors_acted_in:ACTED_IN]->(this_actors_movie:Movie) + WHERE (NOT this_actors_movie.title = $this_actors_moviesConnection.args.where.node.title_NOT) + WITH collect({ screenTime: this_actors_acted_in.screenTime, node: { title: this_actors_movie.title } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS moviesConnection + } RETURN moviesConnection", { this_actors: this_actors, this_actors_moviesConnection: $this_actors_moviesConnection }, false) + } + ] +} as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_actors_name": "Tom Hanks", + "this_actors_moviesConnection": { + "args": { + "where": { + "node": { + "title_NOT": "Forrest Gump" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md new file mode 100644 index 0000000000..205756fe0c --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/create.md @@ -0,0 +1,214 @@ +# Cypher -> Connections -> Projections -> Create + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Connection can be selected following the creation of a single node + +### GraphQL Input + +```graphql +mutation { + createMovies(input: [{ title: "Forrest Gump" }]) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + RETURN this0 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this0 { .title, actorsConnection } AS this0 +``` + +### Expected Cypher Params + +```json +{ + "this0_title": "Forrest Gump" +} +``` + +--- + +## Connection can be selected following the creation of a multiple nodes + +### GraphQL Input + +```graphql +mutation { + createMovies(input: [{ title: "Forrest Gump" }, { title: "Toy Story" }]) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + RETURN this0 +} +CALL { + CREATE (this1:Movie) + SET this1.title = $this1_title + RETURN this1 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +CALL { + WITH this1 + MATCH (this1)<-[this1_acted_in:ACTED_IN]-(this1_actor:Actor) + WITH collect({ screenTime: this1_acted_in.screenTime, node: { name: this1_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this0 { .title, actorsConnection } AS this0, this1 { .title, actorsConnection } AS this1 +``` + +### Expected Cypher Params + +```json +{ + "this0_title": "Forrest Gump", + "this1_title": "Toy Story" +} +``` + +--- + +## Connection can be selected and filtered following the creation of a multiple nodes + +### GraphQL Input + +```graphql +mutation { + createMovies(input: [{ title: "Forrest Gump" }, { title: "Toy Story" }]) { + movies { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + RETURN this0 +} +CALL { + CREATE (this1:Movie) + SET this1.title = $this1_title + RETURN this1 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WHERE this0_actor.name = $this0_actorsConnection.args.where.node.name + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +CALL { + WITH this1 + MATCH (this1)<-[this1_acted_in:ACTED_IN]-(this1_actor:Actor) + WHERE this1_actor.name = $this1_actorsConnection.args.where.node.name + WITH collect({ screenTime: this1_acted_in.screenTime, node: { name: this1_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this0 { .title, actorsConnection } AS this0, this1 { .title, actorsConnection } AS this1 +``` + +### Expected Cypher Params + +```json +{ + "this0_title": "Forrest Gump", + "this1_title": "Toy Story", + "this0_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + }, + "this1_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/projections.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/projections.md new file mode 100644 index 0000000000..9b517a3e1a --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/projections.md @@ -0,0 +1,339 @@ +# Relay Cursor Connection projections + +Ensure that `totalCount` and `edges` are always returned so that `pageInfo` can be calculated. `edges` should always be minimal if not specifically requested. + +Schema: + +```graphql +union Production = Movie | Series + +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +type Series { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +type Actor { + name: String! + productions: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) +} +``` + +--- + +## edges not returned if not asked for + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + totalCount + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ }) AS edges + RETURN { totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- + +## edges and totalCount returned if pageInfo asked for + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- + +## Minimal edges returned if not asked for with pagination arguments + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(first: 5) { + totalCount + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ }) AS edges + WITH size(edges) AS totalCount, edges[..5] AS limitedSelection + RETURN { edges: limitedSelection, totalCount: totalCount } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- + +## edges not returned if not asked for on a union + +### GraphQL Input + +```graphql +query { + actors(where: { name: "Tom Hanks" }) { + name + productionsConnection { + totalCount + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Actor) +WHERE this.name = $this_name +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_acted_in:ACTED_IN]->(this_Movie:Movie) + WITH { node: { __resolveType: "Movie" } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_acted_in:ACTED_IN]->(this_Series:Series) + WITH { node: { __resolveType: "Series" } } AS edge + RETURN edge + } + WITH count(edge) as totalCount + RETURN { totalCount: totalCount } AS productionsConnection +} +RETURN this { .name, productionsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_name": "Tom Hanks" +} +``` + +--- + +## edges and totalCount returned if pageInfo asked for on a union + +### GraphQL Input + +```graphql +query { + actors(where: { name: "Tom Hanks" }) { + name + productionsConnection { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Actor) +WHERE this.name = $this_name +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_acted_in:ACTED_IN]->(this_Movie:Movie) + WITH { node: { __resolveType: "Movie" } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_acted_in:ACTED_IN]->(this_Series:Series) + WITH { node: { __resolveType: "Series" } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS productionsConnection +} +RETURN this { .name, productionsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_name": "Tom Hanks" +} +``` + +--- + +## totalCount is calculated and returned if asked for with edges + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + totalCount + edges { + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- + +## totalCount is calculated and returned if asked for with edges with pagination arguments + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(first: 5) { + totalCount + edges { + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ node: { name: this_actor.name } }) AS edges + WITH size(edges) AS totalCount, edges[..5] AS limitedSelection + RETURN { edges: limitedSelection, totalCount: totalCount } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md new file mode 100644 index 0000000000..978bb55204 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/projections/update.md @@ -0,0 +1,70 @@ +# Cypher -> Connections -> Projections -> Update + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Connection can be selected following update Mutation + +### GraphQL Input + +```graphql +mutation { + updateMovies(where: { title: "Forrest Gump" }) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship-properties.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship-properties.md new file mode 100644 index 0000000000..398e7ddfff --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship-properties.md @@ -0,0 +1,297 @@ +# Relationship Properties Cypher + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Projecting node and relationship properties with no arguments + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- + +## Projecting node and relationship properties with where argument + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(where: { node: { name: "Tom Hanks" } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WHERE this_actor.name = $this_actorsConnection.args.where.node.name + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_actorsConnection": { + "args": { + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + } +} +``` + +--- + +## Projecting node and relationship properties with sort argument + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection(sort: { edge: { screenTime: DESC } }) { + edges { + screenTime + node { + name + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH this_acted_in, this_actor + ORDER BY this_acted_in.screenTime DESC + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- + +## Projecting twice nested node and relationship properties with no arguments + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + edges { + screenTime + node { + name + moviesConnection { + edges { + screenTime + node { + title + } + } + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + CALL { + WITH this_actor + MATCH (this_actor)-[this_actor_acted_in:ACTED_IN]->(this_actor_movie:Movie) + WITH collect({ screenTime: this_actor_acted_in.screenTime, node: { title: this_actor_movie.title } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS moviesConnection + } + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, moviesConnection: moviesConnection } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- + +## Projecting thrice nested node and relationship properties with no arguments + +### GraphQL Input + +```graphql +query { + movies(where: { title: "Forrest Gump" }) { + title + actorsConnection { + edges { + screenTime + node { + name + moviesConnection { + edges { + screenTime + node { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + CALL { + WITH this_actor + MATCH (this_actor)-[this_actor_acted_in:ACTED_IN]->(this_actor_movie:Movie) + CALL { + WITH this_actor_movie + MATCH (this_actor_movie)<-[this_actor_movie_acted_in:ACTED_IN]-(this_actor_movie_actor:Actor) + WITH collect({ screenTime: this_actor_movie_acted_in.screenTime, node: { name: this_actor_movie_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection + } + WITH collect({ screenTime: this_actor_acted_in.screenTime, node: { title: this_actor_movie.title, actorsConnection: actorsConnection } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS moviesConnection + } + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name, moviesConnection: moviesConnection } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/connect.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/connect.md new file mode 100644 index 0000000000..7f1fbe58e0 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/connect.md @@ -0,0 +1,311 @@ +# Relationship Properties Connect Cypher + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Create movie while connecting a relationship that has properties + +### GraphQL Input + +```graphql +mutation { + createMovies( + input: [ + { + title: "Forrest Gump" + actors: { connect: [{ edge: { screenTime: 60 } }] } + } + ] + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + WITH this0 + CALL { + WITH this0 + OPTIONAL MATCH (this0_actors_connect0_node:Actor) + FOREACH(_ IN CASE this0_actors_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)<-[this0_actors_connect0_relationship:ACTED_IN]-(this0_actors_connect0_node) + SET this0_actors_connect0_relationship.screenTime = $this0_actors_connect0_relationship_screenTime + ) + RETURN count(*) + } + RETURN this0 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} + +RETURN +this0 { .title, actorsConnection } AS this0 +``` + +### Expected Cypher Params + +```json +{ + "this0_title": "Forrest Gump", + "this0_actors_connect0_relationship_screenTime": { + "low": 60, + "high": 0 + } +} +``` + +--- + +## Create movie while connecting a relationship that has properties(with where on node) + +### GraphQL Input + +```graphql +mutation { + createMovies( + input: [ + { + title: "Forrest Gump" + actors: { + connect: [ + { + where: { node: { name: "Tom Hanks" } } + edge: { screenTime: 60 } + } + ] + } + } + ] + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + WITH this0 + CALL { + WITH this0 + OPTIONAL MATCH (this0_actors_connect0_node:Actor) + WHERE this0_actors_connect0_node.name = $this0_actors_connect0_node_name + FOREACH(_ IN CASE this0_actors_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)<-[this0_actors_connect0_relationship:ACTED_IN]-(this0_actors_connect0_node) + SET this0_actors_connect0_relationship.screenTime = $this0_actors_connect0_relationship_screenTime + ) + RETURN count(*) + } + RETURN this0 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} + +RETURN +this0 { .title, actorsConnection } AS this0 +``` + +### Expected Cypher Params + +```json +{ + "this0_title": "Forrest Gump", + "this0_actors_connect0_node_name": "Tom Hanks", + "this0_actors_connect0_relationship_screenTime": { + "low": 60, + "high": 0 + } +} +``` + +--- + +## Update a movie while connecting a relationship that has properties(top level-connect) + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "Forrest Gump" } + connect: { actors: { edge: { screenTime: 60 } } } + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +CALL { + WITH this + OPTIONAL MATCH (this_connect_actors0_node:Actor) + FOREACH(_ IN CASE this_connect_actors0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)<-[this_connect_actors0_relationship:ACTED_IN]-(this_connect_actors0_node) + SET this_connect_actors0_relationship.screenTime = $this_connect_actors0_relationship_screenTime + ) + RETURN count(*) +} +WITH this +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_connect_actors0_relationship_screenTime": { + "low": 60, + "high": 0 + } +} +``` + +--- + +## Update a movie while connecting a relationship that has properties(top level-connect)(with where on node) + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "Forrest Gump" } + connect: { + actors: { + where: { node: { name: "Tom Hanks" } } + edge: { screenTime: 60 } + } + } + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +CALL { + WITH this + OPTIONAL MATCH (this_connect_actors0_node:Actor) + WHERE this_connect_actors0_node.name = $this_connect_actors0_node_name + FOREACH(_ IN CASE this_connect_actors0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)<-[this_connect_actors0_relationship:ACTED_IN]-(this_connect_actors0_node) + SET this_connect_actors0_relationship.screenTime = $this_connect_actors0_relationship_screenTime + ) + RETURN count(*) +} +WITH this +CALL { + WITH this + MATCH (this)<-[this_acted_in:ACTED_IN]-(this_actor:Actor) + WITH collect({ screenTime: this_acted_in.screenTime, node: { name: this_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} +RETURN this { .title, actorsConnection } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "this_connect_actors0_node_name": "Tom Hanks", + "this_connect_actors0_relationship_screenTime": { + "low": 60, + "high": 0 + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/create.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/create.md new file mode 100644 index 0000000000..3009f67c4b --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/create.md @@ -0,0 +1,100 @@ +# Relationship Properties Create Cypher + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Create movie with a relationship that has properties + +### GraphQL Input + +```graphql +mutation { + createMovies( + input: [ + { + title: "Forrest Gump" + actors: { + create: [ + { + node: { name: "Tom Hanks" } + edge: { screenTime: 60 } + } + ] + } + } + ] + ) { + movies { + title + actorsConnection { + edges { + screenTime + node { + name + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Movie) + SET this0.title = $this0_title + + WITH this0 + CREATE (this0_actors0_node:Actor) + SET this0_actors0_node.name = $this0_actors0_node_name + MERGE (this0)<-[this0_actors0_relationship:ACTED_IN]-(this0_actors0_node) + SET this0_actors0_relationship.screenTime = $this0_actors0_relationship_screenTime + + RETURN this0 +} +CALL { + WITH this0 + MATCH (this0)<-[this0_acted_in:ACTED_IN]-(this0_actor:Actor) + WITH collect({ screenTime: this0_acted_in.screenTime, node: { name: this0_actor.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS actorsConnection +} + +RETURN +this0 { .title, actorsConnection } AS this0 +``` + +### Expected Cypher Params + +```json +{ + "this0_title": "Forrest Gump", + "this0_actors0_node_name": "Tom Hanks", + "this0_actors0_relationship_screenTime": { + "high": 0, + "low": 60 + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/update.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/update.md new file mode 100644 index 0000000000..9005f7de83 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/relationship_properties/update.md @@ -0,0 +1,193 @@ +# Cypher -> Connections -> Relationship Properties -> Update + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +type Actor { + name: String! + movies: [Movie!]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) +} + +interface ActedIn { + screenTime: Int! +} +``` + +--- + +## Update a relationship property on a relationship between two specified nodes (update -> update) + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "Forrest Gump" } + update: { + actors: [ + { + where: { node: { name: "Tom Hanks" } } + update: { edge: { screenTime: 60 } } + } + ] + } + ) { + movies { + title + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title + +WITH this +OPTIONAL MATCH (this)<-[this_acted_in0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $updateMovies.args.update.actors[0].where.node.name + +CALL apoc.do.when(this_acted_in0_relationship IS NOT NULL, " +SET this_acted_in0_relationship.screenTime = $updateMovies.args.update.actors[0].update.edge.screenTime +RETURN count(*) +", "", {this_acted_in0_relationship:this_acted_in0_relationship, updateMovies: $updateMovies}) +YIELD value as this_acted_in0_relationship_actors0_edge + +RETURN this { .title } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Forrest Gump", + "updateMovies": { + "args": { + "update": { + "actors": [ + { + "update": { + "edge": { + "screenTime": { + "high": 0, + "low": 60 + } + } + }, + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + ] + } + } + } +} +``` + +--- + +## Update properties on both the relationship and end node in a nested update (update -> update) + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "Forrest Gump" } + update: { + actors: [ + { + where: { node: { name: "Tom Hanks" } } + update: { + edge: { screenTime: 60 } + node: { name: "Tom Hanks" } + } + } + ] + } + ) { + movies { + title + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title + +WITH this +OPTIONAL MATCH (this)<-[this_acted_in0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $updateMovies.args.update.actors[0].where.node.name + +CALL apoc.do.when(this_actors0 IS NOT NULL, " +SET this_actors0.name = $this_update_actors0_name +RETURN count(*) +", "", {this:this, updateMovies: $updateMovies, this_actors0:this_actors0, auth:$auth,this_update_actors0_name:$this_update_actors0_name}) +YIELD value as _ + +CALL apoc.do.when(this_acted_in0_relationship IS NOT NULL, " +SET this_acted_in0_relationship.screenTime = $updateMovies.args.update.actors[0].update.edge.screenTime +RETURN count(*) +", "", {this_acted_in0_relationship:this_acted_in0_relationship, updateMovies: $updateMovies}) +YIELD value as this_acted_in0_relationship_actors0_edge + +RETURN this { .title } AS this +``` + +### Expected Cypher Params + +```json +{ + "auth": { + "isAuthenticated": true, + "jwt": {}, + "roles": [] + }, + "this_title": "Forrest Gump", + "this_update_actors0_name": "Tom Hanks", + "updateMovies": { + "args": { + "update": { + "actors": [ + { + "update": { + "edge": { + "screenTime": { + "high": 0, + "low": 60 + } + }, + "node": { + "name": "Tom Hanks" + } + }, + "where": { + "node": { + "name": "Tom Hanks" + } + } + } + ] + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md b/packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md new file mode 100644 index 0000000000..27646b0052 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/connections/unions.md @@ -0,0 +1,348 @@ +# Cypher -> Connections -> Unions + +Schema: + +```graphql +union Publication = Book | Journal + +type Author { + name: String! + publications: [Publication] + @relationship(type: "WROTE", direction: OUT, properties: "Wrote") +} + +type Book { + title: String! + author: [Author!]! + @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +type Journal { + subject: String! + author: [Author!]! + @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +interface Wrote { + words: Int! +} +``` + +--- + +## Projecting union node and relationship properties with no arguments + +### GraphQL Input + +```graphql +query { + authors { + name + publicationsConnection { + edges { + words + node { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Author) +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Book:Book) + WITH { words: this_wrote.words, node: { __resolveType: "Book", title: this_Book.title } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Journal:Journal) + WITH { words: this_wrote.words, node: { __resolveType: "Journal", subject: this_Journal.subject } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS publicationsConnection +} +RETURN this { .name, publicationsConnection } as this +``` + +### Expected Cypher Params + +```json +{} +``` + +--- + +## Projecting union node and relationship properties with where argument + +### GraphQL Input + +```graphql +query { + authors { + name + publicationsConnection( + where: { + Book: { node: { title: "Book Title" } } + Journal: { node: { subject: "Journal Subject" } } + } + ) { + edges { + words + node { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Author) +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Book:Book) + WHERE this_Book.title = $this_publicationsConnection.args.where.Book.node.title + WITH { words: this_wrote.words, node: { __resolveType: "Book", title: this_Book.title } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Journal:Journal) + WHERE this_Journal.subject = $this_publicationsConnection.args.where.Journal.node.subject + WITH { words: this_wrote.words, node: { __resolveType: "Journal", subject: this_Journal.subject } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS publicationsConnection +} +RETURN this { .name, publicationsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_publicationsConnection": { + "args": { + "where": { + "Book": { + "node": { + "title": "Book Title" + } + }, + "Journal": { + "node": { + "subject": "Journal Subject" + } + } + } + } + } +} +``` + +--- + +## Projecting union node and relationship properties with where relationship argument + +### GraphQL Input + +```graphql +query { + authors { + name + publicationsConnection( + where: { + Book: { edge: { words: 1000 } } + Journal: { edge: { words: 2000 } } + } + ) { + edges { + words + node { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Author) +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Book:Book) + WHERE this_wrote.words = $this_publicationsConnection.args.where.Book.edge.words + WITH { words: this_wrote.words, node: { __resolveType: "Book", title: this_Book.title } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Journal:Journal) + WHERE this_wrote.words = $this_publicationsConnection.args.where.Journal.edge.words + WITH { words: this_wrote.words, node: { __resolveType: "Journal", subject: this_Journal.subject } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS publicationsConnection +} +RETURN this { .name, publicationsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_publicationsConnection": { + "args": { + "where": { + "Book": { + "edge": { + "words": { + "low": 1000, + "high": 0 + } + } + }, + "Journal": { + "edge": { + "words": { + "low": 2000, + "high": 0 + } + } + } + } + } + } +} +``` + +--- + +## Projecting union node and relationship properties with where node and relationship argument + +### GraphQL Input + +```graphql +query { + authors { + name + publicationsConnection( + where: { + Book: { edge: { words: 1000 }, node: { title: "Book Title" } } + Journal: { + edge: { words: 2000 } + node: { subject: "Journal Subject" } + } + } + ) { + edges { + words + node { + ... on Book { + title + } + ... on Journal { + subject + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Author) +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Book:Book) + WHERE this_Book.title = $this_publicationsConnection.args.where.Book.node.title AND this_wrote.words = $this_publicationsConnection.args.where.Book.edge.words + WITH { words: this_wrote.words, node: { __resolveType: "Book", title: this_Book.title } } AS edge + RETURN edge + UNION + WITH this + OPTIONAL MATCH (this)-[this_wrote:WROTE]->(this_Journal:Journal) + WHERE this_Journal.subject = $this_publicationsConnection.args.where.Journal.node.subject AND this_wrote.words = $this_publicationsConnection.args.where.Journal.edge.words + WITH { words: this_wrote.words, node: { __resolveType: "Journal", subject: this_Journal.subject } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS publicationsConnection +} +RETURN this { .name, publicationsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_publicationsConnection": { + "args": { + "where": { + "Book": { + "node": { + "title": "Book Title" + }, + "edge": { + "words": { + "low": 1000, + "high": 0 + } + } + }, + "Journal": { + "node": { + "subject": "Journal Subject" + }, + "edge": { + "words": { + "low": 2000, + "high": 0 + } + } + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/count.md b/packages/graphql/tests/tck/tck-test-files/cypher/count.md new file mode 100644 index 0000000000..8d36fff1ca --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/count.md @@ -0,0 +1,66 @@ +# Cypher Count + +Tests for queries using count + +Schema: + +```graphql +type Movie { + title: String! +} +``` + +--- + +## Simple Count + +### GraphQL Input + +```graphql +{ + moviesCount +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +RETURN count(this) +``` + +### Expected Cypher Params + +```json +{} +``` + +--- + +## Count with WHERE + +### GraphQL Input + +```graphql +{ + moviesCount(where: { title: "some-title" }) +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +RETURN count(this) +``` + +### Expected Cypher Params + +```json +{ + "this_title": "some-title" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md index 463399d5c6..d9490afc82 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/allow.md @@ -1,10 +1,10 @@ -## Cypher Auth Allow +# Cypher Auth Allow Tests auth allow operations Schema: -```schema +```graphql type Comment { id: ID content: String @@ -70,9 +70,9 @@ extend type Comment --- -### Read Node +## Read Node -**GraphQL input** +### GraphQL Input ```graphql { @@ -82,7 +82,7 @@ extend type Comment } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -90,17 +90,17 @@ CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_allow0_ RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_allow0_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -109,9 +109,9 @@ RETURN this { .id } as this --- -### Read Node & Protected Field +## Read Node & Protected Field -**GraphQL input** +### GraphQL Input ```graphql { @@ -121,7 +121,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -131,18 +131,18 @@ CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_password_aut RETURN this { .password } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_allow0_id": "id-01", "this_password_auth_allow0_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -151,9 +151,9 @@ RETURN this { .password } as this --- -### Read Relationship +## Read Relationship -**GraphQL input** +### GraphQL Input ```graphql { @@ -166,7 +166,7 @@ RETURN this { .password } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -177,18 +177,18 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_allow0_id": "id-01", "this_posts_auth_allow0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -197,9 +197,9 @@ RETURN this { --- -### Read Relationship & Protected Field +## Read Relationship & Protected Field -**GraphQL input** +### GraphQL Input ```graphql { @@ -211,7 +211,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Post) @@ -226,9 +226,9 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_allow0_creator_id": "id-01", "this_creator_auth_allow0_id": "id-01", @@ -236,9 +236,9 @@ RETURN this { } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -247,9 +247,9 @@ RETURN this { --- -### Read Two Relationships +## Read Two Relationships -**GraphQL input** +### GraphQL Input ```graphql { @@ -264,7 +264,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -278,9 +278,9 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", "this_posts_comments_auth_allow0_creator_id": "id-01", @@ -291,9 +291,9 @@ RETURN this { } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -302,9 +302,9 @@ RETURN this { --- -### Update Node +## Update Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -316,7 +316,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -330,9 +330,9 @@ SET this.id = $this_update_id RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "old-id", "this_auth_allow0_id": "old-id", @@ -340,9 +340,9 @@ RETURN this { .id } AS this } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "old-id", "roles": ["admin"] @@ -351,9 +351,9 @@ RETURN this { .id } AS this --- -### Update Node Property +## Update Node Property -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -365,7 +365,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -379,9 +379,9 @@ SET this.password = $this_update_password RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "id-01", "this_auth_allow0_id": "id-01", @@ -390,9 +390,9 @@ RETURN this { .id } AS this } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -401,15 +401,15 @@ RETURN this { .id } AS this --- -### Nested Update Node +## Nested Update Node -**GraphQL input** +### GraphQL Input ```graphql mutation { updatePosts( where: { id: "post-id" } - update: { creator: { update: { id: "new-id" } } } + update: { creator: { update: { node: { id: "new-id" } } } } ) { posts { id @@ -418,7 +418,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Post) @@ -427,45 +427,54 @@ WHERE this.id = $this_id WITH this CALL apoc.util.validate(NOT(EXISTS((this)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) WITH this -OPTIONAL MATCH (this)<-[:HAS_POST]-(this_creator0:User) +OPTIONAL MATCH (this)<-[this_has_post0_relationship:HAS_POST]-(this_creator0:User) CALL apoc.do.when(this_creator0 IS NOT NULL, " WITH this, this_creator0 CALL apoc.util.validate(NOT(this_creator0.id IS NOT NULL AND this_creator0.id = $this_creator0_auth_allow0_id), \"@neo4j/graphql/FORBIDDEN\", [0]) SET this_creator0.id = $this_update_creator0_id RETURN count(*) ", "", - {this:this, this_creator0:this_creator0, auth:$auth,this_update_creator0_id:$this_update_creator0_id,this_creator0_auth_allow0_id:$this_creator0_auth_allow0_id}) + {this:this, updatePosts: $updatePosts, this_creator0:this_creator0, auth:$auth,this_update_creator0_id:$this_update_creator0_id,this_creator0_auth_allow0_id:$this_creator0_auth_allow0_id}) YIELD value as _ RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "post-id", "this_auth_allow0_creator_id": "user-id", "this_creator0_auth_allow0_id": "user-id", "this_update_creator0_id": "new-id", "auth": { - "isAuthenticated": true, - "jwt": { - "roles": [ - "admin" - ], - "sub": "user-id" - }, - "roles": [ - "admin" - ] + "isAuthenticated": true, + "jwt": { + "roles": ["admin"], + "sub": "user-id" + }, + "roles": ["admin"] + }, + "updatePosts": { + "args": { + "update": { + "creator": { + "update": { + "node": { + "id": "new-id" + } + } + } + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "user-id", "roles": ["admin"] @@ -474,15 +483,15 @@ RETURN this { .id } AS this --- -### Nested Update Property +## Nested Update Property -**GraphQL input** +### GraphQL Input ```graphql mutation { updatePosts( where: { id: "post-id" } - update: { creator: { update: { password: "new-password" } } } + update: { creator: { update: { node: { password: "new-password" } } } } ) { posts { id @@ -491,14 +500,15 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Post) WHERE this.id = $this_id WITH this -CALL apoc.util.validate(NOT(EXISTS((this)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) WITH this OPTIONAL MATCH (this)<-[:HAS_POST]-(this_creator0:User) +CALL apoc.util.validate(NOT(EXISTS((this)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) +WITH this OPTIONAL MATCH (this)<-[this_has_post0_relationship:HAS_POST]-(this_creator0:User) CALL apoc.do.when(this_creator0 IS NOT NULL, " WITH this, this_creator0 @@ -507,13 +517,13 @@ CALL apoc.do.when(this_creator0 IS NOT NULL, " RETURN count(*) ", "", - {this:this, this_creator0:this_creator0, auth:$auth,this_update_creator0_password:$this_update_creator0_password,this_update_creator0_password_auth_allow0_id:$this_update_creator0_password_auth_allow0_id,this_creator0_auth_allow0_id:$this_creator0_auth_allow0_id}) YIELD value as _ + {this:this, updatePosts: $updatePosts, this_creator0:this_creator0, auth:$auth,this_update_creator0_password:$this_update_creator0_password,this_update_creator0_password_auth_allow0_id:$this_update_creator0_password_auth_allow0_id,this_creator0_auth_allow0_id:$this_creator0_auth_allow0_id}) YIELD value as _ RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "post-id", "this_auth_allow0_creator_id": "user-id", @@ -521,23 +531,32 @@ CALL apoc.do.when(this_creator0 IS NOT NULL, " "this_update_creator0_password": "new-password", "this_update_creator0_password_auth_allow0_id": "user-id", "auth": { - "isAuthenticated": true, - "jwt": { - "roles": [ - "admin" - ], - "sub": "user-id" - }, - "roles": [ - "admin" - ] + "isAuthenticated": true, + "jwt": { + "roles": ["admin"], + "sub": "user-id" + }, + "roles": ["admin"] + }, + "updatePosts": { + "args": { + "update": { + "creator": { + "update": { + "node": { + "password": "new-password" + } + } + } + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "user-id", "roles": ["admin"] @@ -546,9 +565,9 @@ CALL apoc.do.when(this_creator0 IS NOT NULL, " --- -### Delete Node +## Delete Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -558,7 +577,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -567,18 +586,18 @@ CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_allow0_ DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "user-id", "this_auth_allow0_id": "user-id" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "user-id", "roles": ["admin"] @@ -587,29 +606,29 @@ DETACH DELETE this --- -### Nested Delete Node +## Nested Delete Node -**GraphQL input** +### GraphQL Input ```graphql mutation { deleteUsers( where: { id: "user-id" } - delete: { posts: { where: { id: "post-id" } } } + delete: { posts: { where: { node: { id: "post-id" } } } } ) { nodesDeleted } } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)-[:HAS_POST]->(this_posts0:Post) -WHERE this_posts0.id = $this_posts0_id +OPTIONAL MATCH (this)-[this_posts0_relationship:HAS_POST]->(this_posts0:Post) +WHERE this_posts0.id = $this_deleteUsers.args.delete.posts[0].where.node.id WITH this, this_posts0 CALL apoc.util.validate(NOT(EXISTS((this_posts0)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) @@ -621,20 +640,34 @@ CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_allow0_ DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "user-id", "this_auth_allow0_id": "user-id", "this_posts0_auth_allow0_creator_id": "user-id", - "this_posts0_id": "post-id" + "this_deleteUsers": { + "args": { + "delete": { + "posts": [ + { + "where": { + "node": { + "id": "post-id" + } + } + } + ] + } + } + } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "user-id", "roles": ["admin"] @@ -643,15 +676,15 @@ DETACH DELETE this --- -### Disconnect Node +## Disconnect Node -**GraphQL input** +### GraphQL Input ```graphql mutation { updateUsers( where: { id: "user-id" } - disconnect: { posts: { where: { id: "post-id" } } } + disconnect: { posts: { where: { node: { id: "post-id" } } } } ) { users { id @@ -660,15 +693,14 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WHERE this.id = $this_id WITH this OPTIONAL MATCH (this)-[this_disconnect_posts0_rel:HAS_POST]->(this_disconnect_posts0:Post) -WHERE this_disconnect_posts0.id = $this_disconnect_posts0_id - +WHERE this_disconnect_posts0.id = $updateUsers.args.disconnect.posts[0].where.node.id WITH this, this_disconnect_posts0, this_disconnect_posts0_rel CALL apoc.util.validate(NOT(this_disconnect_posts0.id IS NOT NULL AND this_disconnect_posts0.id = $this_disconnect_posts0User0_allow_auth_allow0_id AND EXISTS((this_disconnect_posts0)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_disconnect_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_disconnect_posts0Post1_allow_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) @@ -680,20 +712,34 @@ FOREACH(_ IN CASE this_disconnect_posts0 WHEN NULL THEN [] ELSE [1] END | RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "user-id", - "this_disconnect_posts0_id": "post-id", "this_disconnect_posts0User0_allow_auth_allow0_id": "user-id", - "this_disconnect_posts0Post1_allow_auth_allow0_creator_id": "user-id" + "this_disconnect_posts0Post1_allow_auth_allow0_creator_id": "user-id", + "updateUsers": { + "args": { + "disconnect": { + "posts": [ + { + "where": { + "node": { + "id": "post-id" + } + } + } + ] + } + } + } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "user-id", "roles": ["admin"] @@ -702,9 +748,9 @@ RETURN this { .id } AS this --- -### Nested Disconnect Node +## Nested Disconnect Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -713,7 +759,9 @@ mutation { update: { post: { disconnect: { - disconnect: { creator: { where: { id: "user-id" } } } + disconnect: { + creator: { where: { node: { id: "user-id" } } } + } } } } @@ -725,7 +773,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Comment) @@ -746,7 +794,7 @@ FOREACH(_ IN CASE this_post0_disconnect0 WHEN NULL THEN [] ELSE [1] END | WITH this, this_post0_disconnect0 OPTIONAL MATCH (this_post0_disconnect0)<-[this_post0_disconnect0_creator0_rel:HAS_POST]-(this_post0_disconnect0_creator0:User) -WHERE this_post0_disconnect0_creator0.id = $this_post0_disconnect0_creator0_id +WHERE this_post0_disconnect0_creator0.id = $updateComments.args.update.post.disconnect.disconnect.creator.where.node.id WITH this, this_post0_disconnect0, this_post0_disconnect0_creator0, this_post0_disconnect0_creator0_rel CALL apoc.util.validate(NOT(EXISTS((this_post0_disconnect0_creator0)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_post0_disconnect0_creator0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_post0_disconnect0_creator0Post0_allow_auth_allow0_creator_id) AND this_post0_disconnect0_creator0.id IS NOT NULL AND this_post0_disconnect0_creator0.id = $this_post0_disconnect0_creator0User1_allow_auth_allow0_id), "@neo4j/graphql/FORBIDDEN", [0]) @@ -758,24 +806,41 @@ FOREACH(_ IN CASE this_post0_disconnect0_creator0 WHEN NULL THEN [] ELSE [1] END RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_id": "user-id", - "this_auth_allow0_creator_id": "user-id", "this_id": "comment-id", + "this_auth_allow0_creator_id": "user-id", "this_post0_disconnect0Comment0_allow_auth_allow0_creator_id": "user-id", "this_post0_disconnect0Post1_allow_auth_allow0_creator_id": "user-id", "this_post0_disconnect0_creator0Post0_allow_auth_allow0_creator_id": "user-id", "this_post0_disconnect0_creator0User1_allow_auth_allow0_id": "user-id", - "this_post0_disconnect0_creator0_id": "user-id" + "updateComments": { + "args": { + "update": { + "post": { + "disconnect": { + "disconnect": { + "creator": { + "where": { + "node": { + "id": "user-id" + } + } + } + } + } + } + } + } + } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "user-id", "roles": ["admin"] @@ -784,15 +849,15 @@ RETURN this { .id } AS this --- -### Connect Node +## Connect Node -**GraphQL input** +### GraphQL Input ```graphql mutation { updateUsers( where: { id: "user-id" } - connect: { posts: { where: { id: "post-id" } } } + connect: { posts: { where: { node: { id: "post-id" } } } } ) { users { id @@ -801,7 +866,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -809,14 +874,14 @@ WHERE this.id = $this_id WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_posts0:Post) - WHERE this_connect_posts0.id = $this_connect_posts0_id + OPTIONAL MATCH (this_connect_posts0_node:Post) + WHERE this_connect_posts0_node.id = $this_connect_posts0_node_id - WITH this, this_connect_posts0 - CALL apoc.util.validate(NOT(this_connect_posts0.id IS NOT NULL AND this_connect_posts0.id = $this_connect_posts0User0_allow_auth_allow0_id AND EXISTS((this_connect_posts0)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_connect_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_posts0Post1_allow_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) + WITH this, this_connect_posts0_node + CALL apoc.util.validate(NOT(this_connect_posts0_node.id IS NOT NULL AND this_connect_posts0_node.id = $this_connect_posts0_nodeUser0_allow_auth_allow0_id AND EXISTS((this_connect_posts0_node)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_connect_posts0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_posts0_nodePost1_allow_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) - FOREACH(_ IN CASE this_connect_posts0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this)-[:HAS_POST]->(this_connect_posts0) + FOREACH(_ IN CASE this_connect_posts0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)-[:HAS_POST]->(this_connect_posts0_node) ) RETURN count(*) } @@ -824,20 +889,20 @@ CALL { RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "user-id", - "this_connect_posts0_id": "post-id", - "this_connect_posts0Post1_allow_auth_allow0_creator_id": "user-id", - "this_connect_posts0User0_allow_auth_allow0_id": "user-id" + "this_connect_posts0_node_id": "post-id", + "this_connect_posts0_nodePost1_allow_auth_allow0_creator_id": "user-id", + "this_connect_posts0_nodeUser0_allow_auth_allow0_id": "user-id" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "user-id", "roles": ["admin"] diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/bind.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/bind.md index 455b4537ff..f9b754e05f 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/bind.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/bind.md @@ -1,10 +1,10 @@ -## Cypher Auth Allow +# Cypher Auth Allow Tests auth allow operations Schema: -```schema +```graphql type Post { id: ID creator: User @relationship(type: "HAS_POST", direction: IN) @@ -39,9 +39,9 @@ extend type Post --- -### Create Node +## Create Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -53,7 +53,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -67,9 +67,9 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "user-id", "this0_name": "bob", @@ -77,9 +77,9 @@ RETURN this0 { .id } AS this0 } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -88,9 +88,9 @@ RETURN this0 { .id } AS this0 --- -### Create Nested Node +## Create Nested Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -102,8 +102,12 @@ mutation { posts: { create: [ { - id: "post-id-1" - creator: { create: { id: "some-user-id" } } + node: { + id: "post-id-1" + creator: { + create: { node: { id: "some-user-id" } } + } + } } ] } @@ -117,7 +121,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -126,22 +130,22 @@ CALL { this0.name = $this0_name WITH this0 - CREATE (this0_posts0:Post) - SET this0_posts0.id = $this0_posts0_id + CREATE (this0_posts0_node:Post) + SET this0_posts0_node.id = $this0_posts0_node_id - WITH this0, this0_posts0 - CREATE (this0_posts0_creator0:User) - SET this0_posts0_creator0.id = $this0_posts0_creator0_id + WITH this0, this0_posts0_node + CREATE (this0_posts0_node_creator0_node:User) + SET this0_posts0_node_creator0_node.id = $this0_posts0_node_creator0_node_id - WITH this0, this0_posts0, this0_posts0_creator0 - CALL apoc.util.validate(NOT(this0_posts0_creator0.id IS NOT NULL AND this0_posts0_creator0.id = $this0_posts0_creator0_auth_bind0_id), "@neo4j/graphql/FORBIDDEN", [0]) + WITH this0, this0_posts0_node, this0_posts0_node_creator0_node + CALL apoc.util.validate(NOT(this0_posts0_node_creator0_node.id IS NOT NULL AND this0_posts0_node_creator0_node.id = $this0_posts0_node_creator0_node_auth_bind0_id), "@neo4j/graphql/FORBIDDEN", [0]) - MERGE (this0_posts0)<-[:HAS_POST]-(this0_posts0_creator0) + MERGE (this0_posts0_node)<-[:HAS_POST]-(this0_posts0_node_creator0_node) - WITH this0, this0_posts0 - CALL apoc.util.validate(NOT(EXISTS((this0_posts0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this0_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this0_posts0_auth_bind0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) + WITH this0, this0_posts0_node + CALL apoc.util.validate(NOT(EXISTS((this0_posts0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this0_posts0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this0_posts0_node_auth_bind0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) - MERGE (this0)-[:HAS_POST]->(this0_posts0) + MERGE (this0)-[:HAS_POST]->(this0_posts0_node) WITH this0 CALL apoc.util.validate(NOT(this0.id IS NOT NULL AND this0.id = $this0_auth_bind0_id), "@neo4j/graphql/FORBIDDEN", [0]) @@ -152,23 +156,23 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "user-id", "this0_name": "bob", - "this0_posts0_id": "post-id-1", + "this0_posts0_node_id": "post-id-1", "this0_auth_bind0_id": "id-01", - "this0_posts0_auth_bind0_creator_id": "id-01", - "this0_posts0_creator0_auth_bind0_id": "id-01", - "this0_posts0_creator0_id": "some-user-id" + "this0_posts0_node_auth_bind0_creator_id": "id-01", + "this0_posts0_node_creator0_node_auth_bind0_id": "id-01", + "this0_posts0_node_creator0_node_id": "some-user-id" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -177,9 +181,9 @@ RETURN this0 { .id } AS this0 --- -### Update Node +## Update Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -191,7 +195,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -204,9 +208,9 @@ CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_bind0_i RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "id-01", "this_update_id": "not bound", @@ -214,9 +218,9 @@ RETURN this { .id } AS this } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -225,9 +229,9 @@ RETURN this { .id } AS this --- -### Update Nested Node +## Update Nested Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -235,8 +239,10 @@ mutation { where: { id: "id-01" } update: { posts: { - where: { id: "post-id" } - update: { creator: { update: { id: "not bound" } } } + where: { node: { id: "post-id" } } + update: { + node: { creator: { update: { node: { id: "not bound" } } } } + } } } ) { @@ -247,18 +253,18 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WHERE this.id = $this_id -WITH this OPTIONAL MATCH (this)-[:HAS_POST]->(this_posts0:Post) -WHERE this_posts0.id = $this_posts0_id +WITH this OPTIONAL MATCH (this)-[this_has_post0_relationship:HAS_POST]->(this_posts0:Post) +WHERE this_posts0.id = $updateUsers.args.update.posts[0].where.node.id CALL apoc.do.when(this_posts0 IS NOT NULL, " WITH this, this_posts0 - OPTIONAL MATCH (this_posts0)<-[:HAS_POST]-(this_posts0_creator0:User) + OPTIONAL MATCH (this_posts0)<-[this_posts0_has_post0_relationship:HAS_POST]-(this_posts0_creator0:User) CALL apoc.do.when(this_posts0_creator0 IS NOT NULL, \" SET this_posts0_creator0.id = $this_update_posts0_creator0_id @@ -269,11 +275,11 @@ CALL apoc.do.when(this_posts0 IS NOT NULL, RETURN count(*) \", \"\", - {this:this, this_posts0:this_posts0, this_posts0_creator0:this_posts0_creator0, auth:$auth,this_update_posts0_creator0_id:$this_update_posts0_creator0_id,this_posts0_creator0_auth_bind0_id:$this_posts0_creator0_auth_bind0_id}) YIELD value as _ + {this:this, this_posts0:this_posts0, updateUsers: $updateUsers, this_posts0_creator0:this_posts0_creator0, auth:$auth,this_update_posts0_creator0_id:$this_update_posts0_creator0_id,this_posts0_creator0_auth_bind0_id:$this_posts0_creator0_auth_bind0_id}) YIELD value as _ RETURN count(*) ", "", -{this:this, this_posts0:this_posts0, auth:$auth,this_update_posts0_creator0_id:$this_update_posts0_creator0_id,this_posts0_creator0_auth_bind0_id:$this_posts0_creator0_auth_bind0_id}) YIELD value as _ +{this:this, updateUsers: $updateUsers, this_posts0:this_posts0, auth:$auth,this_update_posts0_creator0_id:$this_update_posts0_creator0_id,this_posts0_creator0_auth_bind0_id:$this_posts0_creator0_auth_bind0_id}) YIELD value as _ WITH this CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_bind0_id), "@neo4j/graphql/FORBIDDEN", [0]) @@ -281,13 +287,12 @@ CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_bind0_i RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "id-01", "this_posts0_creator0_auth_bind0_id": "id-01", - "this_posts0_id": "post-id", "this_update_posts0_creator0_id": "not bound", "this_auth_bind0_id": "id-01", "auth": { @@ -297,13 +302,39 @@ RETURN this { .id } AS this "sub": "id-01" }, "roles": ["admin"] + }, + "updateUsers": { + "args": { + "update": { + "posts": [ + { + "update": { + "node": { + "creator": { + "update": { + "node": { + "id": "not bound" + } + } + } + } + }, + "where": { + "node": { + "id": "post-id" + } + } + } + ] + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -312,15 +343,15 @@ RETURN this { .id } AS this --- -### Connect Node +## Connect Node -**GraphQL input** +### GraphQL Input ```graphql mutation { updatePosts( where: { id: "post-id" } - connect: { creator: { where: { id: "user-id" } } } + connect: { creator: { where: { node: { id: "user-id" } } } } ) { posts { id @@ -329,7 +360,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Post) @@ -338,35 +369,35 @@ WHERE this.id = $this_id WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_creator0:User) - WHERE this_connect_creator0.id = $this_connect_creator0_id - FOREACH(_ IN CASE this_connect_creator0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this)<-[:HAS_POST]-(this_connect_creator0) + OPTIONAL MATCH (this_connect_creator0_node:User) + WHERE this_connect_creator0_node.id = $this_connect_creator0_node_id + FOREACH(_ IN CASE this_connect_creator0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)<-[:HAS_POST]-(this_connect_creator0_node) ) - WITH this, this_connect_creator0 - CALL apoc.util.validate(NOT(EXISTS((this_connect_creator0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_connect_creator0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_creator0Post0_bind_auth_bind0_creator_id) AND this_connect_creator0.id IS NOT NULL AND this_connect_creator0.id = $this_connect_creator0User1_bind_auth_bind0_id), "@neo4j/graphql/FORBIDDEN", [0]) + WITH this, this_connect_creator0_node + CALL apoc.util.validate(NOT(EXISTS((this_connect_creator0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_connect_creator0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_creator0_nodePost0_bind_auth_bind0_creator_id) AND this_connect_creator0_node.id IS NOT NULL AND this_connect_creator0_node.id = $this_connect_creator0_nodeUser1_bind_auth_bind0_id), "@neo4j/graphql/FORBIDDEN", [0]) RETURN count(*) } RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "id-01", - "this_connect_creator0Post0_bind_auth_bind0_creator_id": "id-01", - "this_connect_creator0User1_bind_auth_bind0_id": "id-01", - "this_connect_creator0_id": "user-id", + "this_connect_creator0_nodePost0_bind_auth_bind0_creator_id": "id-01", + "this_connect_creator0_nodeUser1_bind_auth_bind0_id": "id-01", + "this_connect_creator0_node_id": "user-id", "this_id": "post-id" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -375,15 +406,15 @@ RETURN this { .id } AS this --- -### Disconnect Node +## Disconnect Node -**GraphQL input** +### GraphQL Input ```graphql mutation { updatePosts( where: { id: "post-id" } - disconnect: { creator: { where: { id: "user-id" } } } + disconnect: { creator: { where: { node: { id: "user-id" } } } } ) { posts { id @@ -392,7 +423,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Post) @@ -400,7 +431,7 @@ WHERE this.id = $this_id WITH this OPTIONAL MATCH (this)<-[this_disconnect_creator0_rel:HAS_POST]-(this_disconnect_creator0:User) -WHERE this_disconnect_creator0.id = $this_disconnect_creator0_id +WHERE this_disconnect_creator0.id = $updatePosts.args.disconnect.creator.where.node.id FOREACH(_ IN CASE this_disconnect_creator0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_creator0_rel ) @@ -411,21 +442,32 @@ CALL apoc.util.validate(NOT(EXISTS((this_disconnect_creator0)<-[:HAS_POST]-(:Use RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_id": "id-01", "this_disconnect_creator0Post0_bind_auth_bind0_creator_id": "id-01", "this_disconnect_creator0User1_bind_auth_bind0_id": "id-01", - "this_disconnect_creator0_id": "user-id", - "this_id": "post-id" + "this_id": "post-id", + "updatePosts": { + "args": { + "disconnect": { + "creator": { + "where": { + "node": { + "id": "user-id" + } + } + } + } + } + } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/is-authenticated.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/is-authenticated.md index 9465366503..b663913090 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/is-authenticated.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/is-authenticated.md @@ -1,10 +1,10 @@ -## Cypher Auth isAuthenticated +# Cypher Auth isAuthenticated Tests auth isAuthenticated operation Schema: -```schema +```graphql type History { url: String @auth(rules: [{ operations: [READ], isAuthenticated: true }]) } @@ -31,16 +31,18 @@ extend type User ] ) -extend type Post @auth(rules: [{ operations: [CONNECT, DISCONNECT, DELETE], isAuthenticated: true }]) +extend type Post + @auth( + rules: [ + { operations: [CONNECT, DISCONNECT, DELETE], isAuthenticated: true } + ] + ) extend type User { password: String @auth( rules: [ - { - operations: [READ, CREATE, UPDATE] - isAuthenticated: true - } + { operations: [READ, CREATE, UPDATE], isAuthenticated: true } ] ) } @@ -54,9 +56,9 @@ extend type User { --- -### Read Node +## Read Node -**GraphQL input** +### GraphQL Input ```graphql { @@ -67,7 +69,7 @@ extend type User { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -75,28 +77,24 @@ CALL apoc.util.validate(NOT(apoc.util.validatePredicate(NOT($auth.isAuthenticate RETURN this { .id, .name } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { - "isAuthenticated": true, - "roles": [ - "admin" - ], - "jwt": { - "roles": [ - "admin" - ], - "sub": "super_admin" - } + "isAuthenticated": true, + "roles": ["admin"], + "jwt": { + "roles": ["admin"], + "sub": "super_admin" + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -105,9 +103,9 @@ RETURN this { .id, .name } as this --- -### Read Node & Field +## Read Node & Field -**GraphQL input** +### GraphQL Input ```graphql { @@ -119,7 +117,7 @@ RETURN this { .id, .name } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -129,26 +127,24 @@ CALL apoc.util.validate(NOT(apoc.util.validatePredicate(NOT($auth.isAuthenticate RETURN this { .id, .name, .password } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -157,9 +153,9 @@ RETURN this { .id, .name, .password } as this --- -### Read Node & Cypher Field +## Read Node & Cypher Field -**GraphQL input** +### GraphQL Input ```graphql { @@ -171,7 +167,7 @@ RETURN this { .id, .name, .password } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -183,26 +179,24 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { - "isAuthenticated": true, - "roles": ["admin"], - "jwt": { - "roles": [ - "admin" - ], + "isAuthenticated": true, + "roles": ["admin"], + "jwt": { + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -211,9 +205,9 @@ RETURN this { --- -### Create Node +## Create Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -225,7 +219,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -239,27 +233,25 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -268,9 +260,9 @@ RETURN this0 { .id } AS this0 --- -### Create Node & Field +## Create Node & Field -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -282,7 +274,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -299,9 +291,9 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", "this0_password": "super-password", @@ -309,18 +301,16 @@ RETURN this0 { .id } AS this0 "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -329,9 +319,9 @@ RETURN this0 { .id } AS this0 --- -### Update Node +## Update Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -343,7 +333,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -357,9 +347,9 @@ SET this.id = $this_update_id RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", "this_update_id": "id-1", @@ -367,18 +357,16 @@ RETURN this { .id } AS this "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -387,9 +375,9 @@ RETURN this { .id } AS this --- -### Update Node & Field +## Update Node & Field -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -401,7 +389,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -415,9 +403,9 @@ SET this.password = $this_update_password RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", "this_update_password": "password", @@ -425,18 +413,16 @@ RETURN this { .id } AS this "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -445,9 +431,9 @@ RETURN this { .id } AS this --- -### Connect +## Connect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -459,7 +445,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -467,13 +453,13 @@ MATCH (this:User) WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_posts0:Post) + OPTIONAL MATCH (this_connect_posts0_node:Post) - WITH this, this_connect_posts0 + WITH this, this_connect_posts0_node CALL apoc.util.validate(NOT(apoc.util.validatePredicate(NOT($auth.isAuthenticated = true), "@neo4j/graphql/UNAUTHENTICATED", [0]) AND apoc.util.validatePredicate(NOT($auth.isAuthenticated = true), "@neo4j/graphql/UNAUTHENTICATED", [0])), "@neo4j/graphql/FORBIDDEN", [0]) - FOREACH(_ IN CASE this_connect_posts0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this)-[:HAS_POST]->(this_connect_posts0) + FOREACH(_ IN CASE this_connect_posts0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)-[:HAS_POST]->(this_connect_posts0_node) ) RETURN count(*) } @@ -481,26 +467,24 @@ CALL { RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -509,9 +493,9 @@ RETURN this { .id } AS this --- -### Disconnect +## Disconnect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -523,7 +507,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -539,26 +523,31 @@ FOREACH(_ IN CASE this_disconnect_posts0 WHEN NULL THEN [] ELSE [1] END | DELETE RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } + }, + "updateUsers": { + "args": { + "disconnect": { + "posts": [{}] + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -567,9 +556,9 @@ RETURN this { .id } AS this --- -### Delete +## Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -579,7 +568,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -590,26 +579,24 @@ CALL apoc.util.validate(NOT(apoc.util.validatePredicate(NOT($auth.isAuthenticate DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -618,9 +605,9 @@ DETACH DELETE this --- -### Nested Delete +## Nested Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -630,13 +617,13 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WITH this -OPTIONAL MATCH (this)-[:HAS_POST]->(this_posts0:Post) +OPTIONAL MATCH (this)-[this_posts0_relationship:HAS_POST]->(this_posts0:Post) WITH this, this_posts0 CALL apoc.util.validate(NOT(apoc.util.validatePredicate(NOT($auth.isAuthenticated = true), "@neo4j/graphql/UNAUTHENTICATED", [0])), "@neo4j/graphql/FORBIDDEN", [0]) @@ -650,26 +637,24 @@ CALL apoc.util.validate(NOT(apoc.util.validatePredicate(NOT($auth.isAuthenticate DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/roles.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/roles.md index fd0f8cde08..bd7f6d6947 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/roles.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/roles.md @@ -1,10 +1,10 @@ -## Cypher Auth Roles +# Cypher Auth Roles Tests auth operations with roles Schema: -```schema +```graphql type History { url: String @auth(rules: [{ operations: [READ], roles: ["super-admin"] }]) } @@ -33,14 +33,7 @@ extend type User @auth( rules: [ { - operations: [ - READ - CREATE - UPDATE - CONNECT - DISCONNECT - DELETE - ] + operations: [READ, CREATE, UPDATE, CONNECT, DISCONNECT, DELETE] roles: ["admin"] } ] @@ -49,7 +42,10 @@ extend type User extend type Post @auth( rules: [ - { operations: [CONNECT, DISCONNECT, DELETE], roles: ["super-admin"] } + { + operations: [CONNECT, DISCONNECT, DELETE] + roles: ["super-admin"] + } ] ) @@ -57,10 +53,7 @@ extend type User { password: String @auth( rules: [ - { - operations: [READ, CREATE, UPDATE] - roles: ["super-admin"] - } + { operations: [READ, CREATE, UPDATE], roles: ["super-admin"] } ] ) } @@ -74,9 +67,9 @@ extend type User { --- -### Read Node +## Read Node -**GraphQL input** +### GraphQL Input ```graphql { @@ -87,7 +80,7 @@ extend type User { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -95,26 +88,24 @@ CALL apoc.util.validate(NOT(ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE RETURN this { .id, .name } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -123,9 +114,9 @@ RETURN this { .id, .name } as this --- -### Read Node & Field +## Read Node & Field -**GraphQL input** +### GraphQL Input ```graphql { @@ -137,7 +128,7 @@ RETURN this { .id, .name } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -147,26 +138,24 @@ CALL apoc.util.validate(NOT(ANY(r IN ["super-admin"] WHERE ANY(rr IN $auth.roles RETURN this { .id, .name, .password } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -175,9 +164,9 @@ RETURN this { .id, .name, .password } as this --- -### Read Node & Cypher Field +## Read Node & Cypher Field -**GraphQL input** +### GraphQL Input ```graphql { @@ -189,7 +178,7 @@ RETURN this { .id, .name, .password } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -201,26 +190,24 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { - "isAuthenticated": true, - "roles": ["admin"], - "jwt": { - "roles": [ - "admin" - ], + "isAuthenticated": true, + "roles": ["admin"], + "jwt": { + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -229,9 +216,9 @@ RETURN this { --- -### Create Node +## Create Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -243,7 +230,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -257,27 +244,25 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -286,9 +271,9 @@ RETURN this0 { .id } AS this0 --- -### Create Node & Field +## Create Node & Field -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -300,7 +285,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -317,9 +302,9 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", "this0_password": "super-password", @@ -327,18 +312,16 @@ RETURN this0 { .id } AS this0 "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -347,9 +330,9 @@ RETURN this0 { .id } AS this0 --- -### Update Node +## Update Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -361,7 +344,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -375,9 +358,9 @@ SET this.id = $this_update_id RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", "this_update_id": "id-1", @@ -385,18 +368,16 @@ RETURN this { .id } AS this "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -405,9 +386,9 @@ RETURN this { .id } AS this --- -### Update Node & Field +## Update Node & Field -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -419,7 +400,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -433,9 +414,9 @@ SET this.password = $this_update_password RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", "this_update_password": "password", @@ -443,18 +424,16 @@ RETURN this { .id } AS this "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -463,9 +442,9 @@ RETURN this { .id } AS this --- -### Connect +## Connect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -477,7 +456,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -485,13 +464,13 @@ MATCH (this:User) WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_posts0:Post) + OPTIONAL MATCH (this_connect_posts0_node:Post) - WITH this, this_connect_posts0 + WITH this, this_connect_posts0_node CALL apoc.util.validate(NOT(ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr)) AND ANY(r IN ["super-admin"] WHERE ANY(rr IN $auth.roles WHERE r = rr))), "@neo4j/graphql/FORBIDDEN", [0]) - FOREACH(_ IN CASE this_connect_posts0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this)-[:HAS_POST]->(this_connect_posts0) + FOREACH(_ IN CASE this_connect_posts0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)-[:HAS_POST]->(this_connect_posts0_node) ) RETURN count(*) @@ -500,26 +479,24 @@ CALL { RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -528,16 +505,22 @@ RETURN this { .id } AS this --- -### Nested Connect +## Nested Connect -**GraphQL input** +### GraphQL Input ```graphql mutation { updateComments( update: { post: { - update: { creator: { connect: { where: { id: "user-id" } } } } + update: { + node: { + creator: { + connect: { where: { node: { id: "user-id" } } } + } + } + } } } ) { @@ -548,59 +531,74 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Comment) WITH this -OPTIONAL MATCH (this)<-[:HAS_COMMENT]-(this_post0:Post) -CALL apoc.do.when(this_post0 IS NOT NULL, -" +OPTIONAL MATCH (this)<-[this_has_comment0_relationship:HAS_COMMENT]-(this_post0:Post) +CALL apoc.do.when(this_post0 IS NOT NULL, " WITH this, this_post0 CALL { WITH this, this_post0 - OPTIONAL MATCH (this_post0_creator0_connect0:User) - WHERE this_post0_creator0_connect0.id = $this_post0_creator0_connect0_id - WITH this, this_post0, this_post0_creator0_connect0 + OPTIONAL MATCH (this_post0_creator0_connect0_node:User) + WHERE this_post0_creator0_connect0_node.id = $this_post0_creator0_connect0_node_id + WITH this, this_post0, this_post0_creator0_connect0_node CALL apoc.util.validate(NOT(ANY(r IN [\"super-admin\"] WHERE ANY(rr IN $auth.roles WHERE r = rr)) AND ANY(r IN [\"admin\"] WHERE ANY(rr IN $auth.roles WHERE r = rr))), \"@neo4j/graphql/FORBIDDEN\", [0]) - FOREACH(_ IN CASE this_post0_creator0_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this_post0)-[:HAS_POST]->(this_post0_creator0_connect0) + FOREACH(_ IN CASE this_post0_creator0_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this_post0)-[:HAS_POST]->(this_post0_creator0_connect0_node) ) RETURN count(*) } RETURN count(*) -", -"", -{this:this, this_post0:this_post0, auth:$auth,this_post0_creator0_connect0_id:$this_post0_creator0_connect0_id}) YIELD value as _ - +", "", {this:this, updateComments: $updateComments, this_post0:this_post0, auth:$auth,this_post0_creator0_connect0_node_id:$this_post0_creator0_connect0_node_id}) YIELD value as _ RETURN this { .content } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_post0_creator0_connect0_id": "user-id", + "this_post0_creator0_connect0_node_id": "user-id", "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } + }, + "updateComments": { + "args": { + "update": { + "post": { + "update": { + "node": { + "creator": { + "connect": { + "where": { + "node": { + "id": "user-id" + } + } + } + } + } + } + } + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -609,9 +607,9 @@ RETURN this { .content } AS this --- -### Disconnect +## Disconnect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -623,7 +621,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -641,26 +639,31 @@ FOREACH(_ IN CASE this_disconnect_posts0 WHEN NULL THEN [] ELSE [1] END | RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } + }, + "updateUsers": { + "args": { + "disconnect": { + "posts": [{}] + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -669,9 +672,9 @@ RETURN this { .id } AS this --- -### Nested Disconnect +## Nested Disconnect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -679,7 +682,11 @@ mutation { update: { post: { update: { - creator: { disconnect: { where: { id: "user-id" } } } + node: { + creator: { + disconnect: { where: { node: { id: "user-id" } } } + } + } } } } @@ -691,17 +698,17 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Comment) WITH this -OPTIONAL MATCH (this)<-[:HAS_COMMENT]-(this_post0:Post) +OPTIONAL MATCH (this)<-[this_has_comment0_relationship:HAS_COMMENT]-(this_post0:Post) CALL apoc.do.when(this_post0 IS NOT NULL, " WITH this, this_post0 OPTIONAL MATCH (this_post0)-[this_post0_creator0_disconnect0_rel:HAS_POST]->(this_post0_creator0_disconnect0:User) - WHERE this_post0_creator0_disconnect0.id = $this_post0_creator0_disconnect0_id + WHERE this_post0_creator0_disconnect0.id = $updateComments.args.update.post.update.node.creator.disconnect.where.node.id WITH this, this_post0, this_post0_creator0_disconnect0, this_post0_creator0_disconnect0_rel CALL apoc.util.validate(NOT(ANY(r IN [\"super-admin\"] WHERE ANY(rr IN $auth.roles WHERE r = rr)) AND ANY(r IN [\"admin\"] WHERE ANY(rr IN $auth.roles WHERE r = rr))), \"@neo4j/graphql/FORBIDDEN\", [0]) @@ -712,32 +719,50 @@ CALL apoc.do.when(this_post0 IS NOT NULL, " RETURN count(*) ", "", -{this:this, this_post0:this_post0, auth:$auth,this_post0_creator0_disconnect0_id:$this_post0_creator0_disconnect0_id}) YIELD value as _ +{this:this, updateComments: $updateComments, this_post0:this_post0, auth:$auth}) YIELD value as _ RETURN this { .content } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_post0_creator0_disconnect0_id": "user-id", "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } + }, + "updateComments": { + "args": { + "update": { + "post": { + "update": { + "node": { + "creator": { + "disconnect": { + "where": { + "node": { + "id": "user-id" + } + } + } + } + } + } + } + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -746,9 +771,9 @@ RETURN this { .content } AS this --- -### Delete +## Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -758,7 +783,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -769,26 +794,24 @@ CALL apoc.util.validate(NOT(ANY(r IN ["admin"] WHERE ANY(rr IN $auth.roles WHERE DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -797,9 +820,9 @@ DETACH DELETE this --- -### Nested Delete +## Nested Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -809,14 +832,14 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WITH this -OPTIONAL MATCH (this)-[:HAS_POST]->(this_posts0:Post) +OPTIONAL MATCH (this)-[this_posts0_relationship:HAS_POST]->(this_posts0:Post) WITH this, this_posts0 @@ -831,26 +854,24 @@ WITH this CALL apoc.util.validate(NOT(ANY(r IN ["admin"] WHERE ANY(rr IN $auth.r DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "roles": ["admin"], "jwt": { - "roles": [ - "admin" - ], + "roles": ["admin"], "sub": "super_admin" } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/where.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/where.md index 1bbb30496f..6e5b97365d 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/where.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/arguments/where.md @@ -1,10 +1,10 @@ -## Cypher Auth Where +# Cypher Auth Where Tests auth `where` operations Schema: -```schema +```graphql union Search = Post type User { @@ -32,24 +32,14 @@ extend type User extend type User { password: String! - @auth( - rules: [ - { - operations: [READ] - where: { id: "$jwt.sub" } - } - ] - ) + @auth(rules: [{ operations: [READ], where: { id: "$jwt.sub" } }]) } extend type Post { secretKey: String! @auth( rules: [ - { - operations: [READ] - where: { creator: { id: "$jwt.sub" } } - } + { operations: [READ], where: { creator: { id: "$jwt.sub" } } } ] ) } @@ -67,9 +57,9 @@ extend type Post --- -### Read Node +## Read Node -**GraphQL input** +### GraphQL Input ```graphql { @@ -79,7 +69,7 @@ extend type Post } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -87,17 +77,17 @@ WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -106,9 +96,9 @@ RETURN this { .id } as this --- -### Read Node + User Defined Where +## Read Node + User Defined Where -**GraphQL input** +### GraphQL Input ```graphql { @@ -118,7 +108,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -126,18 +116,18 @@ WHERE this.name = $this_name AND this.id IS NOT NULL AND this.id = $this_auth_wh RETURN this { .id } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_name": "bob" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -146,9 +136,9 @@ RETURN this { .id } as this --- -### Read Relationship +## Read Relationship -**GraphQL input** +### GraphQL Input ```graphql { @@ -161,7 +151,7 @@ RETURN this { .id } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -172,18 +162,71 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_posts_auth_where0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object + +```json +{ + "sub": "id-01", + "roles": ["admin"] +} +``` + +--- + +## Read Connection + +### GraphQL Input + +```graphql +{ + users { + id + postsConnection { + edges { + node { + content + } + } + } + } +} +``` + +### Expected Cypher Output -```jwt +```cypher +MATCH (this:User) +WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id +CALL { + WITH this MATCH (this)-[this_has_post:HAS_POST]->(this_post:Post) + WHERE EXISTS((this_post)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_post)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_post_auth_where0_creator_id) + WITH collect({ node: { content: this_post.content } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS postsConnection +} +RETURN this { .id, postsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_auth_where0_id": "id-01", + "this_post_auth_where0_creator_id": "id-01" +} +``` + +### JWT Object + +```json { "sub": "id-01", "roles": ["admin"] @@ -192,9 +235,71 @@ RETURN this { --- -### Read Union Relationship + User Defined Where +## Read Connection + User Defined Where -**GraphQL input** +### GraphQL Input + +```graphql +{ + users { + id + postsConnection(where: { node: { id: "some-id" } }) { + edges { + node { + content + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:User) +WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id +CALL { + WITH this MATCH (this)-[this_has_post:HAS_POST]->(this_post:Post) + WHERE this_post.id = $this_postsConnection.args.where.node.id AND EXISTS((this_post)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_post)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_post_auth_where0_creator_id) + WITH collect({ node: { content: this_post.content } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS postsConnection +} +RETURN this { .id, postsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_auth_where0_id": "id-01", + "this_post_auth_where0_creator_id": "id-01", + "this_postsConnection": { + "args": { + "where": { + "node": { + "id": "some-id" + } + } + } + } +} +``` + +### JWT Object + +```json +{ + "sub": "id-01", + "roles": ["admin"] +} +``` + +--- + +## Read Union Relationship + User Defined Where + +### GraphQL Input ```graphql { @@ -207,7 +312,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -219,9 +324,9 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_posts_content": "cool", "this_auth_where0_id": "id-01", @@ -229,9 +334,9 @@ RETURN this { } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -240,9 +345,9 @@ RETURN this { --- -### Read Union +## Read Union -**GraphQL input** +### GraphQL Input ```graphql { @@ -257,7 +362,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -268,18 +373,18 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_content_Post_auth_where0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -288,9 +393,142 @@ RETURN this { --- -### Update Node +## Read Union Using Connection -**GraphQL input** +### GraphQL Input + +```graphql +{ + users { + id + contentConnection { + edges { + node { + ... on Post { + id + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:User) +WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_has_post:HAS_POST]->(this_Post:Post) + WHERE EXISTS((this_Post)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_Post)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_Post_auth_where0_creator_id) + WITH { node: { __resolveType: "Post", id: this_Post.id } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS contentConnection +} +RETURN this { .id, contentConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_auth_where0_id": "id-01", + "this_Post_auth_where0_creator_id": "id-01" +} +``` + +### JWT Object + +```json +{ + "sub": "id-01", + "roles": ["admin"] +} +``` + +--- + +## Read Union Using Connection + User Defined Where + +### GraphQL Input + +```graphql +{ + users { + id + contentConnection(where: { Post: { node: { id: "some-id" } } }) { + edges { + node { + ... on Post { + id + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:User) +WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_has_post:HAS_POST]->(this_Post:Post) + WHERE this_Post.id = $this_contentConnection.args.where.Post.node.id AND EXISTS((this_Post)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_Post)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_Post_auth_where0_creator_id) + WITH { node: { __resolveType: "Post", id: this_Post.id } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS contentConnection +} +RETURN this { .id, contentConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_auth_where0_id": "id-01", + "this_Post_auth_where0_creator_id": "id-01", + "this_contentConnection": { + "args": { + "where": { + "Post": { + "node": { + "id": "some-id" + } + } + } + } + } +} +``` + +### JWT Object + +```json +{ + "sub": "id-01", + "roles": ["admin"] +} +``` + +--- + +## Update Node + +### GraphQL Input ```graphql mutation { @@ -302,7 +540,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -311,18 +549,18 @@ SET this.name = $this_update_name RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_update_name": "Bob", "this_auth_where0_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -331,9 +569,9 @@ RETURN this { .id } AS this --- -### Update Node + User Defined Where +## Update Node + User Defined Where -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -345,7 +583,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -354,9 +592,9 @@ SET this.name = $this_update_name RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_update_name": "Bob", "this_auth_where0_id": "id-01", @@ -364,9 +602,9 @@ RETURN this { .id } AS this } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -375,13 +613,13 @@ RETURN this { .id } AS this --- -### Update Nested Node +## Update Nested Node -**GraphQL input** +### GraphQL Input ```graphql mutation { - updateUsers(update: { posts: { update: { id: "new-id" } } }) { + updateUsers(update: { posts: { update: { node: { id: "new-id" } } } }) { users { id posts { @@ -392,16 +630,16 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id -WITH this OPTIONAL MATCH (this)-[:HAS_POST]->(this_posts0:Post) +WITH this OPTIONAL MATCH (this)-[this_has_post0_relationship:HAS_POST]->(this_posts0:Post) WHERE EXISTS((this_posts0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_auth_where0_creator_id) -CALL apoc.do.when(this_posts0 IS NOT NULL, " SET this_posts0.id = $this_update_posts0_id RETURN count(*) ", "", {this:this, this_posts0:this_posts0, auth:$auth,this_update_posts0_id:$this_update_posts0_id}) YIELD value as _ +CALL apoc.do.when(this_posts0 IS NOT NULL, " SET this_posts0.id = $this_update_posts0_id RETURN count(*) ", "", {this:this, updateUsers: $updateUsers, this_posts0:this_posts0, auth:$auth,this_update_posts0_id:$this_update_posts0_id}) YIELD value as _ RETURN this { .id, @@ -409,32 +647,43 @@ RETURN this { } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_posts_auth_where0_creator_id": "id-01", "this_posts0_auth_where0_creator_id": "id-01", "this_update_posts0_id": "new-id", "this_auth_where0_id": "id-01", "auth": { - "isAuthenticated": true, - "jwt": { - "roles": [ - "admin" - ], - "sub": "id-01" - }, - "roles": [ - "admin" - ] + "isAuthenticated": true, + "jwt": { + "roles": ["admin"], + "sub": "id-01" + }, + "roles": ["admin"] + }, + "updateUsers": { + "args": { + "update": { + "posts": [ + { + "update": { + "node": { + "id": "new-id" + } + } + } + ] + } + } } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -443,9 +692,9 @@ RETURN this { --- -### Delete Node +## Delete Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -455,7 +704,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -463,17 +712,17 @@ WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -482,9 +731,9 @@ DETACH DELETE this --- -### Delete Node + User Defined Where +## Delete Node + User Defined Where -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -494,7 +743,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -502,18 +751,18 @@ WHERE this.name = $this_name AND this.id IS NOT NULL AND this.id = $this_auth_wh DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_name": "Bob" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -522,9 +771,9 @@ DETACH DELETE this --- -### Delete Nested Node +## Delete Nested Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -534,14 +783,14 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this -OPTIONAL MATCH (this)-[:HAS_POST]->(this_posts0:Post) +OPTIONAL MATCH (this)-[this_posts0_relationship:HAS_POST]->(this_posts0:Post) WHERE EXISTS((this_posts0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_auth_where0_creator_id) FOREACH(_ IN CASE this_posts0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_posts0 ) @@ -549,18 +798,18 @@ FOREACH(_ IN CASE this_posts0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE thi DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_posts0_auth_where0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -569,9 +818,9 @@ DETACH DELETE this --- -### Connect Node (from create) +## Connect Node (from create) -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -581,7 +830,7 @@ mutation { id: "123" name: "Bob" password: "password" - posts: { connect: { where: {} } } + posts: { connect: { where: { node: {} } } } } ] ) { @@ -592,7 +841,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -604,10 +853,10 @@ CALL { WITH this0 CALL { WITH this0 - OPTIONAL MATCH (this0_posts_connect0:Post) - WHERE EXISTS((this0_posts_connect0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this0_posts_connect0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this0_posts_connect0_auth_where0_creator_id) + OPTIONAL MATCH (this0_posts_connect0_node:Post) + WHERE EXISTS((this0_posts_connect0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this0_posts_connect0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this0_posts_connect0_node_auth_where0_creator_id) - FOREACH(_ IN CASE this0_posts_connect0 WHEN NULL THEN [] ELSE [1] END | MERGE (this0)-[:HAS_POST]->(this0_posts_connect0) ) + FOREACH(_ IN CASE this0_posts_connect0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this0)-[:HAS_POST]->(this0_posts_connect0_node) ) RETURN count(*) } @@ -618,20 +867,20 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "123", "this0_name": "Bob", "this0_password": "password", - "this0_posts_connect0_auth_where0_creator_id": "id-01" + "this0_posts_connect0_node_auth_where0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -640,9 +889,9 @@ RETURN this0 { .id } AS this0 --- -### Connect Node + User Defined Where (from create) +## Connect Node + User Defined Where (from create) -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -652,7 +901,7 @@ mutation { id: "123" name: "Bob" password: "password" - posts: { connect: { where: { id: "post-id" } } } + posts: { connect: { where: { node: { id: "post-id" } } } } } ] ) { @@ -663,7 +912,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -674,10 +923,10 @@ CALL { WITH this0 CALL { WITH this0 - OPTIONAL MATCH (this0_posts_connect0:Post) - WHERE this0_posts_connect0.id = $this0_posts_connect0_id AND EXISTS((this0_posts_connect0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this0_posts_connect0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this0_posts_connect0_auth_where0_creator_id) + OPTIONAL MATCH (this0_posts_connect0_node:Post) + WHERE this0_posts_connect0_node.id = $this0_posts_connect0_node_id AND EXISTS((this0_posts_connect0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this0_posts_connect0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this0_posts_connect0_node_auth_where0_creator_id) - FOREACH(_ IN CASE this0_posts_connect0 WHEN NULL THEN [] ELSE [1] END | MERGE (this0)-[:HAS_POST]->(this0_posts_connect0) ) + FOREACH(_ IN CASE this0_posts_connect0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this0)-[:HAS_POST]->(this0_posts_connect0_node) ) RETURN count(*) } @@ -687,21 +936,21 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "123", "this0_name": "Bob", "this0_password": "password", - "this0_posts_connect0_auth_where0_creator_id": "id-01", - "this0_posts_connect0_id": "post-id" + "this0_posts_connect0_node_auth_where0_creator_id": "id-01", + "this0_posts_connect0_node_id": "post-id" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -710,13 +959,13 @@ RETURN this0 { .id } AS this0 --- -### Connect Node (from update update) +## Connect Node (from update update) -**GraphQL input** +### GraphQL Input ```graphql mutation { - updateUsers(update: { posts: { connect: { where: {} } } }) { + updateUsers(update: { posts: { connect: { where: { node: {} } } } }) { users { id } @@ -724,7 +973,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -737,10 +986,10 @@ WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this CALL { WITH this - OPTIONAL MATCH (this_posts0_connect0:Post) - WHERE EXISTS((this_posts0_connect0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0_connect0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_connect0_auth_where0_creator_id) + OPTIONAL MATCH (this_posts0_connect0_node:Post) + WHERE EXISTS((this_posts0_connect0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0_connect0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_connect0_node_auth_where0_creator_id) - FOREACH(_ IN CASE this_posts0_connect0 WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_posts0_connect0) ) + FOREACH(_ IN CASE this_posts0_connect0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_posts0_connect0_node) ) RETURN count(*) } @@ -748,18 +997,18 @@ CALL { RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", - "this_posts0_connect0_auth_where0_creator_id": "id-01" + "this_posts0_connect0_node_auth_where0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -768,13 +1017,15 @@ RETURN this { .id } AS this --- -### Connect Node + User Defined Where (from update update) +## Connect Node + User Defined Where (from update update) -**GraphQL input** +### GraphQL Input ```graphql mutation { - updateUsers(update: { posts: { connect: { where: { id: "new-id" } } } }) { + updateUsers( + update: { posts: { connect: { where: { node: { id: "new-id" } } } } } + ) { users { id } @@ -782,7 +1033,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -794,10 +1045,10 @@ WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this CALL { WITH this - OPTIONAL MATCH (this_posts0_connect0:Post) - WHERE this_posts0_connect0.id = $this_posts0_connect0_id AND EXISTS((this_posts0_connect0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0_connect0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_connect0_auth_where0_creator_id) + OPTIONAL MATCH (this_posts0_connect0_node:Post) + WHERE this_posts0_connect0_node.id = $this_posts0_connect0_node_id AND EXISTS((this_posts0_connect0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0_connect0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_connect0_node_auth_where0_creator_id) - FOREACH(_ IN CASE this_posts0_connect0 WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_posts0_connect0) ) + FOREACH(_ IN CASE this_posts0_connect0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_posts0_connect0_node) ) RETURN count(*) } @@ -805,19 +1056,19 @@ CALL { RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", - "this_posts0_connect0_auth_where0_creator_id": "id-01", - "this_posts0_connect0_id": "new-id" + "this_posts0_connect0_node_auth_where0_creator_id": "id-01", + "this_posts0_connect0_node_id": "new-id" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -826,13 +1077,13 @@ RETURN this { .id } AS this --- -### Connect Node (from update connect) +## Connect Node (from update connect) -**GraphQL input** +### GraphQL Input ```graphql mutation { - updateUsers(connect: { posts: { where: {} } }) { + updateUsers(connect: { posts: { where: { node: {} } } }) { users { id } @@ -840,7 +1091,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -852,10 +1103,10 @@ WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_posts0:Post) - WHERE EXISTS((this_connect_posts0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_connect_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_posts0_auth_where0_creator_id) + OPTIONAL MATCH (this_connect_posts0_node:Post) + WHERE EXISTS((this_connect_posts0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_connect_posts0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_posts0_node_auth_where0_creator_id) - FOREACH(_ IN CASE this_connect_posts0 WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_connect_posts0) ) + FOREACH(_ IN CASE this_connect_posts0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_connect_posts0_node) ) RETURN count(*) } @@ -863,18 +1114,18 @@ CALL { RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", - "this_connect_posts0_auth_where0_creator_id": "id-01" + "this_connect_posts0_node_auth_where0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -883,13 +1134,13 @@ RETURN this { .id } AS this --- -### Connect Node + User Defined Where (from update connect) +## Connect Node + User Defined Where (from update connect) -**GraphQL input** +### GraphQL Input ```graphql mutation { - updateUsers(connect: { posts: { where: { id: "some-id" } } }) { + updateUsers(connect: { posts: { where: { node: { id: "some-id" } } } }) { users { id } @@ -897,7 +1148,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -909,10 +1160,10 @@ WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_posts0:Post) - WHERE this_connect_posts0.id = $this_connect_posts0_id AND EXISTS((this_connect_posts0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_connect_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_posts0_auth_where0_creator_id) + OPTIONAL MATCH (this_connect_posts0_node:Post) + WHERE this_connect_posts0_node.id = $this_connect_posts0_node_id AND EXISTS((this_connect_posts0_node)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_connect_posts0_node)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_connect_posts0_node_auth_where0_creator_id) - FOREACH(_ IN CASE this_connect_posts0 WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_connect_posts0) ) + FOREACH(_ IN CASE this_connect_posts0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:HAS_POST]->(this_connect_posts0_node) ) RETURN count(*) } @@ -920,19 +1171,19 @@ CALL { RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", - "this_connect_posts0_auth_where0_creator_id": "id-01", - "this_connect_posts0_id": "some-id" + "this_connect_posts0_node_auth_where0_creator_id": "id-01", + "this_connect_posts0_node_id": "some-id" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -941,9 +1192,9 @@ RETURN this { .id } AS this --- -### Disconnect Node (from update update) +## Disconnect Node (from update update) -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -955,7 +1206,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -973,18 +1224,18 @@ FOREACH(_ IN CASE this_posts0_disconnect0 WHEN NULL THEN [] ELSE [1] END | DELET RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_posts0_disconnect0_auth_where0_creator_id": "id-01" } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -993,14 +1244,16 @@ RETURN this { .id } AS this --- -### Disconnect Node + User Defined Where (from update update) +## Disconnect Node + User Defined Where (from update update) -**GraphQL input** +### GraphQL Input ```graphql mutation { updateUsers( - update: { posts: { disconnect: { where: { id: "new-id" } } } } + update: { + posts: [{ disconnect: { where: { node: { id: "new-id" } } } }] + } ) { users { id @@ -1009,7 +1262,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -1019,26 +1272,44 @@ WITH this WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this -OPTIONAL MATCH (this)-[this_posts0_disconnect0_rel:HAS_POST]->(this_posts0_disconnect0:Post) WHERE this_posts0_disconnect0.id = $this_posts0_disconnect0_id AND EXISTS((this_posts0_disconnect0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0_disconnect0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_disconnect0_auth_where0_creator_id) +OPTIONAL MATCH (this)-[this_posts0_disconnect0_rel:HAS_POST]->(this_posts0_disconnect0:Post) WHERE this_posts0_disconnect0.id = $updateUsers.args.update.posts[0].disconnect[0].where.node.id AND EXISTS((this_posts0_disconnect0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_posts0_disconnect0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_posts0_disconnect0_auth_where0_creator_id) FOREACH(_ IN CASE this_posts0_disconnect0 WHEN NULL THEN [] ELSE [1] END | DELETE this_posts0_disconnect0_rel ) RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_posts0_disconnect0_auth_where0_creator_id": "id-01", - "this_posts0_disconnect0_id": "new-id" + "updateUsers": { + "args": { + "update": { + "posts": [ + { + "disconnect": [ + { + "where": { + "node": { + "id": "new-id" + } + } + } + ] + } + ] + } + } + } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -1047,9 +1318,9 @@ RETURN this { .id } AS this --- -### Disconnect Node (from update disconnect) +## Disconnect Node (from update disconnect) -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -1061,7 +1332,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -1077,18 +1348,29 @@ FOREACH(_ IN CASE this_disconnect_posts0 WHEN NULL THEN [] ELSE [1] END | RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", - "this_disconnect_posts0_auth_where0_creator_id": "id-01" + "this_disconnect_posts0_auth_where0_creator_id": "id-01", + "updateUsers": { + "args": { + "disconnect": { + "posts": [ + { + "where": {} + } + ] + } + } + } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] @@ -1097,13 +1379,13 @@ RETURN this { .id } AS this --- -### Disconnect Node + User Defined Where (from update disconnect) +## Disconnect Node + User Defined Where (from update disconnect) -**GraphQL input** +### GraphQL Input ```graphql mutation { - updateUsers(disconnect: { posts: { where: { id: "some-id" } } }) { + updateUsers(disconnect: { posts: { where: { node: { id: "some-id" } } } }) { users { id } @@ -1111,13 +1393,14 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this -WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id WITH this OPTIONAL MATCH (this)-[this_disconnect_posts0_rel:HAS_POST]->(this_disconnect_posts0:Post) WHERE this_disconnect_posts0.id = $this_disconnect_posts0_id AND EXISTS((this_disconnect_posts0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_disconnect_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_disconnect_posts0_auth_where0_creator_id) +WHERE this.id IS NOT NULL AND this.id = $this_auth_where0_id +WITH this OPTIONAL MATCH (this)-[this_disconnect_posts0_rel:HAS_POST]->(this_disconnect_posts0:Post) WHERE this_disconnect_posts0.id = $updateUsers.args.disconnect.posts[0].where.node.id AND EXISTS((this_disconnect_posts0)<-[:HAS_POST]-(:User)) AND ALL(creator IN [(this_disconnect_posts0)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_disconnect_posts0_auth_where0_creator_id) FOREACH(_ IN CASE this_disconnect_posts0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_posts0_rel @@ -1126,19 +1409,33 @@ FOREACH(_ IN CASE this_disconnect_posts0 WHEN NULL THEN [] ELSE [1] END | RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_auth_where0_id": "id-01", "this_disconnect_posts0_auth_where0_creator_id": "id-01", - "this_disconnect_posts0_id": "some-id" + "updateUsers": { + "args": { + "disconnect": { + "posts": [ + { + "where": { + "node": { + "id": "some-id" + } + } + } + ] + } + } + } } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "id-01", "roles": ["admin"] diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection-union.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection-union.md new file mode 100644 index 0000000000..6737943f76 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection-union.md @@ -0,0 +1,99 @@ +# Cypher Auth Projection On Connections On Unions + +Tests auth is added to projection connections + +Schema: + +```graphql +type Post { + content: String + creator: User @relationship(type: "HAS_POST", direction: IN) +} + +type User { + id: ID + name: String + content: [Content] @relationship(type: "PUBLISHED", direction: OUT) +} + +union Content = Post + +extend type User @auth(rules: [{ allow: { id: "$jwt.sub" } }]) +extend type Post @auth(rules: [{ allow: { creator: { id: "$jwt.sub" } } }]) +``` + +--- + +## Two connection + +### GraphQL Input + +```graphql +{ + users { + contentConnection { + edges { + node { + ... on Post { + content + creatorConnection { + edges { + node { + name + } + } + } + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:User) +CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_allow0_id), "@neo4j/graphql/FORBIDDEN", [0]) +CALL { + WITH this + CALL { + WITH this + OPTIONAL MATCH (this)-[this_published:PUBLISHED]->(this_Post:Post) + CALL apoc.util.validate(NOT(EXISTS((this_Post)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_Post)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_Post_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) + CALL { + WITH this_Post + MATCH (this_Post)<-[this_Post_has_post:HAS_POST]-(this_Post_user:User) + CALL apoc.util.validate(NOT(this_Post_user.id IS NOT NULL AND this_Post_user.id = $this_Post_user_auth_allow0_id), "@neo4j/graphql/FORBIDDEN", [0]) + WITH collect({ node: { name: this_Post_user.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS creatorConnection + } + WITH { node: { __resolveType: "Post", content: this_Post.content, creatorConnection: creatorConnection } } AS edge + RETURN edge + } + WITH collect(edge) as edges, count(edge) as totalCount + RETURN { edges: edges, totalCount: totalCount } AS contentConnection +} +RETURN this { contentConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_Post_auth_allow0_creator_id": "super_admin", + "this_Post_user_auth_allow0_id": "super_admin", + "this_auth_allow0_id": "super_admin" +} +``` + +### JWT Object + +```json +{ + "sub": "super_admin" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection.md new file mode 100644 index 0000000000..d05074f6a9 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection-connection.md @@ -0,0 +1,144 @@ +# Cypher Auth Projection On Connections + +Tests auth is added to projection connections + +Schema: + +```graphql +type Post { + content: String + creator: User @relationship(type: "HAS_POST", direction: IN) +} + +type User { + id: ID + name: String + posts: [Post] @relationship(type: "HAS_POST", direction: OUT) +} + +extend type User @auth(rules: [{ allow: { id: "$jwt.sub" } }]) +extend type Post @auth(rules: [{ allow: { creator: { id: "$jwt.sub" } } }]) +``` + +--- + +## One connection + +### GraphQL Input + +```graphql +{ + users { + name + postsConnection { + edges { + node { + content + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:User) +CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_allow0_id), "@neo4j/graphql/FORBIDDEN", [0]) +CALL { + WITH this + MATCH (this)-[this_has_post:HAS_POST]->(this_post:Post) + CALL apoc.util.validate(NOT(EXISTS((this_post)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_post)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_post_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) + WITH collect({ node: { content: this_post.content } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS postsConnection +} +RETURN this { .name, postsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_post_auth_allow0_creator_id": "super_admin", + "this_auth_allow0_id": "super_admin" +} +``` + +### JWT Object + +```json +{ + "sub": "super_admin" +} +``` + +--- + +## Two connection + +### GraphQL Input + +```graphql +{ + users { + name + postsConnection { + edges { + node { + content + creatorConnection { + edges { + node { + name + } + } + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:User) +CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_auth_allow0_id), "@neo4j/graphql/FORBIDDEN", [0]) +CALL { + WITH this + MATCH (this)-[this_has_post:HAS_POST]->(this_post:Post) + CALL apoc.util.validate(NOT(EXISTS((this_post)<-[:HAS_POST]-(:User)) AND ANY(creator IN [(this_post)<-[:HAS_POST]-(creator:User) | creator] WHERE creator.id IS NOT NULL AND creator.id = $this_post_auth_allow0_creator_id)), "@neo4j/graphql/FORBIDDEN", [0]) + CALL { + WITH this_post + MATCH (this_post)<-[this_post_has_post:HAS_POST]-(this_post_user:User) + CALL apoc.util.validate(NOT(this_post_user.id IS NOT NULL AND this_post_user.id = $this_post_user_auth_allow0_id), "@neo4j/graphql/FORBIDDEN", [0]) + WITH collect({ node: { name: this_post_user.name } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS creatorConnection + } + WITH collect({ node: { content: this_post.content, creatorConnection: creatorConnection } }) AS edges + RETURN { edges: edges, totalCount: size(edges) } AS postsConnection +} +RETURN this { .name, postsConnection } as this +``` + +### Expected Cypher Params + +```json +{ + "this_post_auth_allow0_creator_id": "super_admin", + "this_post_user_auth_allow0_id": "super_admin", + "this_auth_allow0_id": "super_admin" +} +``` + +### JWT Object + +```json +{ + "sub": "super_admin" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection.md index 4617bfbcf6..1ce7441711 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/auth/projection.md @@ -1,10 +1,10 @@ -## Cypher Auth Projection +# Cypher Auth Projection Tests auth is added to projections in all operations Schema: -```schema +```graphql type User { id: ID name: String @@ -17,9 +17,9 @@ extend type User { --- -### Update Node +## Update Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -31,7 +31,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -43,9 +43,9 @@ CALL apoc.util.validate(NOT(this.id IS NOT NULL AND this.id = $this_id_auth_allo RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id_auth_allow0_id": "super_admin", "this_update_id": "new-id", @@ -53,9 +53,9 @@ RETURN this { .id } AS this } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] @@ -64,9 +64,9 @@ RETURN this { .id } AS this --- -### Create Node +## Create Node -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -78,7 +78,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -99,9 +99,9 @@ CALL apoc.util.validate(NOT(this1.id IS NOT NULL AND this1.id = $projection_id_a RETURN this0 { .id } AS this0, this1 { .id } AS this1 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "id-1", "this1_id": "id-2", @@ -109,9 +109,9 @@ RETURN this0 { .id } AS this0, this1 { .id } AS this1 } ``` -**JWT Object** +### JWT Object -```jwt +```json { "sub": "super_admin", "roles": ["admin"] diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/autogenerate.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/autogenerate.md index fe9010df3e..25754f3ce5 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/autogenerate.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/autogenerate.md @@ -1,10 +1,10 @@ -## Cypher autogenerate directive +# Cypher autogenerate directive Tests autogenerate operations. Schema: -```schema +```graphql type Movie { id: ID! @id name: String! @@ -13,9 +13,9 @@ type Movie { --- -### Simple Create +## Simple Create -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -28,7 +28,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -41,9 +41,9 @@ CALL { RETURN this0 { .id, .name } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_name": "dan" } @@ -51,9 +51,9 @@ RETURN this0 { .id, .name } AS this0 --- -### Simple Update +## Simple Update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -66,7 +66,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -74,9 +74,9 @@ SET this.name = $this_update_name RETURN this { .id, .name } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_update_name": "dan" } diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/coalesce.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/coalesce.md index a2c3712781..57b43c9235 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/coalesce.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/coalesce.md @@ -1,10 +1,10 @@ -## Cypher coalesce() +# Cypher coalesce() Tests for queries where queried fields are decorated with @coalesce Schema: -```schema +```graphql type User { id: ID! @coalesce(value: "00000000-00000000-00000000-00000000") name: String! @coalesce(value: "Jane Smith") @@ -20,9 +20,9 @@ NEO4J_GRAPHQL_ENABLE_REGEX=1 --- -### Simple +## Simple -**GraphQL input** +### GraphQL Input ```graphql query( @@ -46,11 +46,19 @@ query( } ``` -```graphql-params -{ "id": "Some ID", "name": "Some name", "verified": true, "numberOfFriends": 10, "rating": 3.5 } +### GraphQL Params Input + +```json +{ + "id": "Some ID", + "name": "Some name", + "verified": true, + "numberOfFriends": 10, + "rating": 3.5 +} ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -62,9 +70,9 @@ AND coalesce(this.rating, 2.5) < $this_rating_LT RETURN this { .name } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "Some ID", "this_name_MATCHES": "Some name", diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/cypher.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/cypher.md index 4329d1d654..9c8f8b6e9a 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/cypher.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/cypher.md @@ -1,40 +1,52 @@ -## Cypher directive +# Cypher directive Tests for queries on cypher directives. Schema: -```schema +```graphql type Actor { name: String - movies(title: String): [Movie] @cypher(statement: """ - MATCH (m:Movie {title: $title}) - RETURN m - """) - randomNumber: Int @cypher(statement: """ - RETURN rand() - """) + movies(title: String): [Movie] + @cypher( + statement: """ + MATCH (m:Movie {title: $title}) + RETURN m + """ + ) + randomNumber: Int + @cypher( + statement: """ + RETURN rand() + """ + ) } type Movie { id: ID title: String - actors: [Actor] @cypher(statement: """ - MATCH (a:Actor) - RETURN a - """) - topActor: Actor @cypher(statement: """ - MATCH (a:Actor) - RETURN a - """) + actors: [Actor] + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + ) + topActor: Actor + @cypher( + statement: """ + MATCH (a:Actor) + RETURN a + """ + ) } ``` --- -### Simple directive +## Simple directive -**GraphQL input** +### GraphQL Input ```graphql { @@ -47,7 +59,7 @@ type Movie { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -59,23 +71,23 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} } } ``` --- -### Simple directive (primitive) +## Simple directive (primitive) -**GraphQL input** +### GraphQL Input ```graphql { @@ -85,7 +97,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Actor) @@ -94,23 +106,23 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} } } ``` --- -### Nested directive +## Nested directive -**GraphQL input** +### GraphQL Input ```graphql { @@ -126,7 +138,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -141,24 +153,24 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_topActor_movies_title": "some title", "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} } } ``` --- -### Super Nested directive +## Super Nested directive -**GraphQL input** +### GraphQL Input ```graphql { @@ -180,7 +192,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -201,25 +213,25 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_topActor_movies_title": "some title", "this_topActor_movies_topActor_movies_title": "another title", "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} } } ``` --- -### Nested directive with params +## Nested directive with params -**GraphQL input** +### GraphQL Input ```graphql { @@ -235,7 +247,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -250,15 +262,15 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_topActor_movies_title": "some title", "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} } } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/relationship.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/relationship.md index 2734dae1e4..95649842ea 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/relationship.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/relationship.md @@ -1,10 +1,10 @@ -## Cypher relationship +# Cypher relationship Tests for queries on relationships. Schema: -```schema +```graphql type Actor { name: String movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) @@ -20,9 +20,9 @@ type Movie { --- -### Simple relation +## Simple relation -**GraphQL input** +### GraphQL Input ```graphql { @@ -35,24 +35,24 @@ type Movie { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) RETURN this { .title, topActor: head([ (this)-[:TOP_ACTOR]->(this_topActor:Actor) | this_topActor { .name } ]) } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Many relation +## Many relation -**GraphQL input** +### GraphQL Input ```graphql { @@ -65,24 +65,24 @@ RETURN this { .title, topActor: head([ (this)-[:TOP_ACTOR]->(this_topActor:Actor } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) RETURN this { .title, actors: [ (this)<-[:ACTED_IN]-(this_actors:Actor) | this_actors { .name } ] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Nested relation +## Nested relation -**GraphQL input** +### GraphQL Input ```graphql { @@ -98,7 +98,7 @@ RETURN this { .title, actors: [ (this)<-[:ACTED_IN]-(this_actors:Actor) | this_a } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -113,17 +113,17 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Nested relation with params +## Nested relation with params -**GraphQL input** +### GraphQL Input ```graphql { @@ -139,7 +139,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -155,9 +155,9 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_title": "some title", "this_topActor_name": "top actor", diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/directives/timestamps.md b/packages/graphql/tests/tck/tck-test-files/cypher/directives/timestamps.md index d1311653f7..f75967a305 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/directives/timestamps.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/directives/timestamps.md @@ -1,10 +1,10 @@ -## Cypher TimeStamps +# Cypher TimeStamps Tests TimeStamps operations. ⚠ The string in params is actually an object but the test suite turns it into a string when calling `JSON.stringify`. Schema: -```schema +```graphql type Movie { id: ID name: String @@ -15,9 +15,9 @@ type Movie { --- -### Simple Create +## Simple Create -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -29,7 +29,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -41,9 +41,9 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "123" } @@ -51,9 +51,9 @@ RETURN this0 { .id } AS this0 --- -### Simple Update +## Simple Update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -65,7 +65,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -75,9 +75,9 @@ SET this.name = $this_update_name RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_update_id": "123", "this_update_name": "dan" diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/issues/190.md b/packages/graphql/tests/tck/tck-test-files/cypher/issues/190.md index f14bde49db..19b991e81b 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/issues/190.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/issues/190.md @@ -1,14 +1,15 @@ -## #190 +# #190 -Type definitions: +Schema: -```schema +```graphql type User { client_id: String uid: String - demographics: [UserDemographics] @relationship(type: "HAS_DEMOGRAPHIC", direction: OUT) + demographics: [UserDemographics] + @relationship(type: "HAS_DEMOGRAPHIC", direction: OUT) } type UserDemographics { @@ -21,9 +22,9 @@ type UserDemographics { --- -### Example 1 +## Example 1 -**GraphQL input** +### GraphQL Input ```graphql query { @@ -37,7 +38,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -45,20 +46,20 @@ WHERE EXISTS((this)-[:HAS_DEMOGRAPHIC]->(:UserDemographics)) AND ANY(this_demogr RETURN this { .uid, demographics: [ (this)-[:HAS_DEMOGRAPHIC]->(this_demographics:UserDemographics) | this_demographics { .type, .value } ] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_demographics_type": "Gender", - "this_demographics_value": "Female" + "this_demographics_type": "Gender", + "this_demographics_value": "Female" } ``` --- -### Example 2 +## Example 2 -**GraphQL input** +### GraphQL Input ```graphql query { @@ -82,7 +83,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:User) @@ -90,14 +91,14 @@ WHERE EXISTS((this)-[:HAS_DEMOGRAPHIC]->(:UserDemographics)) AND ANY(this_demogr RETURN this { .uid, demographics: [ (this)-[:HAS_DEMOGRAPHIC]->(this_demographics:UserDemographics) | this_demographics { .type, .value } ] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_demographics_OR_type": "Gender", - "this_demographics_OR_value": "Female", - "this_demographics_OR1_type": "State", - "this_demographics_OR2_type": "Age" + "this_demographics_OR_type": "Gender", + "this_demographics_OR_value": "Female", + "this_demographics_OR1_type": "State", + "this_demographics_OR2_type": "Age" } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/issues/288.md b/packages/graphql/tests/tck/tck-test-files/cypher/issues/288.md new file mode 100644 index 0000000000..be2bca67a5 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/issues/288.md @@ -0,0 +1,99 @@ +# #288 + + + +Schema: + +```graphql +type USER { + USERID: String + COMPANYID: String + COMPANY: [COMPANY] @relationship(type: "IS_PART_OF", direction: OUT) +} + +type COMPANY { + USERS: [USER] @relationship(type: "IS_PART_OF", direction: IN) +} +``` + +--- + +## Can create a USER and COMPANYID is populated + +### GraphQL Input + +```graphql +mutation { + createUSERS(input: { USERID: "userid", COMPANYID: "companyid" }) { + users { + USERID + COMPANYID + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:USER) + SET this0.USERID = $this0_USERID + SET this0.COMPANYID = $this0_COMPANYID + RETURN this0 +} + +RETURN +this0 { .USERID, .COMPANYID } AS this0 +``` + +### Expected Cypher Params + +```json +{ + "this0_USERID": "userid", + "this0_COMPANYID": "companyid" +} +``` + +--- + +## Can update a USER and COMPANYID is populated + +### GraphQL Input + +```graphql +mutation { + updateUSERS( + where: { USERID: "userid" } + update: { COMPANYID: "companyid2" } + ) { + users { + USERID + COMPANYID + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:USER) +WHERE this.USERID = $this_USERID + +SET this.COMPANYID = $this_update_COMPANYID + +RETURN this { .USERID, .COMPANYID } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_USERID": "userid", + "this_update_COMPANYID": "companyid2" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/issues/324.md b/packages/graphql/tests/tck/tck-test-files/cypher/issues/324.md index 2f7c8966a9..21ab6bf9bb 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/issues/324.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/issues/324.md @@ -1,37 +1,38 @@ -## #324 +# #324 -Type definitions: +Schema: -```schema +```graphql type Person { - identifier: ID! - car: Car @relationship(type: "CAR", direction: OUT) + identifier: ID! + car: Car @relationship(type: "CAR", direction: OUT) } type Car { - identifier: ID! - manufacturer: Manufacturer @relationship(type: "MANUFACTURER", direction: OUT) + identifier: ID! + manufacturer: Manufacturer + @relationship(type: "MANUFACTURER", direction: OUT) } type Manufacturer { - identifier: ID! - logo: Logo @relationship(type: "LOGO", direction: OUT) - name: String + identifier: ID! + logo: Logo @relationship(type: "LOGO", direction: OUT) + name: String } type Logo { - identifier: ID! - name: String + identifier: ID! + name: String } ``` --- -### Should have correct variables in apoc.do.when +## Should have correct variables in apoc.do.when -**GraphQL input** +### GraphQL Input ```graphql mutation updatePeople($where: PersonWhere, $update: PersonUpdateInput) { @@ -43,18 +44,28 @@ mutation updatePeople($where: PersonWhere, $update: PersonUpdateInput) { } ``` -```graphql-params +### GraphQL Params Input + +```json { "where": { "identifier": "Someone" }, "update": { "car": { "update": { - "manufacturer": { - "update": { - "name": "Manufacturer", - "logo": { - "connect": { - "where": { "identifier": "Opel Logo" } + "node": { + "manufacturer": { + "update": { + "node": { + "name": "Manufacturer", + "logo": { + "connect": { + "where": { + "node": { + "identifier": "Opel Logo" + } + } + } + } } } } @@ -65,52 +76,80 @@ mutation updatePeople($where: PersonWhere, $update: PersonUpdateInput) { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Person) WHERE this.identifier = $this_identifier WITH this -OPTIONAL MATCH (this)-[:CAR]->(this_car0:Car) +OPTIONAL MATCH (this)-[this_car0_relationship:CAR]->(this_car0:Car) CALL apoc.do.when(this_car0 IS NOT NULL, " WITH this, this_car0 - OPTIONAL MATCH (this_car0)-[:MANUFACTURER]->(this_car0_manufacturer0:Manufacturer) + OPTIONAL MATCH (this_car0)-[this_car0_manufacturer0_relationship:MANUFACTURER]->(this_car0_manufacturer0:Manufacturer) CALL apoc.do.when(this_car0_manufacturer0 IS NOT NULL, \" SET this_car0_manufacturer0.name = $this_update_car0_manufacturer0_name WITH this, this_car0, this_car0_manufacturer0 CALL { WITH this, this_car0, this_car0_manufacturer0 - OPTIONAL MATCH (this_car0_manufacturer0_logo0_connect0:Logo) - WHERE this_car0_manufacturer0_logo0_connect0.identifier = $this_car0_manufacturer0_logo0_connect0_identifier - FOREACH(_ IN CASE this_car0_manufacturer0_logo0_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this_car0_manufacturer0)-[:LOGO]->(this_car0_manufacturer0_logo0_connect0) + OPTIONAL MATCH (this_car0_manufacturer0_logo0_connect0_node:Logo) + WHERE this_car0_manufacturer0_logo0_connect0_node.identifier = $this_car0_manufacturer0_logo0_connect0_node_identifier + FOREACH(_ IN CASE this_car0_manufacturer0_logo0_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this_car0_manufacturer0)-[:LOGO]->(this_car0_manufacturer0_logo0_connect0_node) ) RETURN count(*) } RETURN count(*) \", \"\", - {this:this, this_car0:this_car0, this_car0_manufacturer0:this_car0_manufacturer0, auth:$auth,this_update_car0_manufacturer0_name:$this_update_car0_manufacturer0_name,this_car0_manufacturer0_logo0_connect0_identifier:$this_car0_manufacturer0_logo0_connect0_identifier}) YIELD value as _ + {this:this, this_car0:this_car0, updatePeople: $updatePeople, this_car0_manufacturer0:this_car0_manufacturer0, auth:$auth,this_update_car0_manufacturer0_name:$this_update_car0_manufacturer0_name,this_car0_manufacturer0_logo0_connect0_node_identifier:$this_car0_manufacturer0_logo0_connect0_node_identifier}) YIELD value as _ RETURN count(*) ", "", - {this:this, this_car0:this_car0, auth:$auth,this_update_car0_manufacturer0_name:$this_update_car0_manufacturer0_name,this_car0_manufacturer0_logo0_connect0_identifier:$this_car0_manufacturer0_logo0_connect0_identifier}) YIELD value as _ + {this:this, updatePeople: $updatePeople, this_car0:this_car0, auth:$auth,this_update_car0_manufacturer0_name:$this_update_car0_manufacturer0_name,this_car0_manufacturer0_logo0_connect0_node_identifier:$this_car0_manufacturer0_logo0_connect0_node_identifier}) YIELD value as _ RETURN this { .identifier } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "auth": { "isAuthenticated": true, "jwt": {}, "roles": [] }, - "this_car0_manufacturer0_logo0_connect0_identifier": "Opel Logo", + "this_car0_manufacturer0_logo0_connect0_node_identifier": "Opel Logo", "this_identifier": "Someone", - "this_update_car0_manufacturer0_name": "Manufacturer" + "this_update_car0_manufacturer0_name": "Manufacturer", + "updatePeople": { + "args": { + "update": { + "car": { + "update": { + "node": { + "manufacturer": { + "update": { + "node": { + "logo": { + "connect": { + "where": { + "node": { + "identifier": "Opel Logo" + } + } + } + }, + "name": "Manufacturer" + } + } + } + } + } + } + } + } + } } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/issues/360.md b/packages/graphql/tests/tck/tck-test-files/cypher/issues/360.md index 77c2101eef..2ad7c9f2e5 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/issues/360.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/issues/360.md @@ -1,10 +1,10 @@ -## #360 +# #360 -Type definitions: +Schema: -```schema +```graphql type Event { id: ID! name: String @@ -16,9 +16,9 @@ type Event { --- -### Should exclude undefined members in AND +## Should exclude undefined members in AND -**GraphQL input** +### GraphQL Input ```graphql query($rangeStart: DateTime, $rangeEnd: DateTime, $activity: String) { @@ -37,14 +37,16 @@ query($rangeStart: DateTime, $rangeEnd: DateTime, $activity: String) { } ``` -```graphql-params +### GraphQL Params Input + +```json { - "rangeStart": "2021-07-18T00:00:00+0100", - "rangeEnd": "2021-07-18T23:59:59+0100" + "rangeStart": "2021-07-18T00:00:00+0100", + "rangeEnd": "2021-07-18T23:59:59+0100" } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Event) @@ -55,9 +57,9 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_AND1_start_LTE": { "day": 18, @@ -86,9 +88,9 @@ RETURN this { --- -### Should exclude undefined members in OR +## Should exclude undefined members in OR -**GraphQL input** +### GraphQL Input ```graphql query($rangeStart: DateTime, $rangeEnd: DateTime, $activity: String) { @@ -107,14 +109,16 @@ query($rangeStart: DateTime, $rangeEnd: DateTime, $activity: String) { } ``` -```graphql-params +### GraphQL Params Input + +```json { - "rangeStart": "2021-07-18T00:00:00+0100", - "rangeEnd": "2021-07-18T23:59:59+0100" + "rangeStart": "2021-07-18T00:00:00+0100", + "rangeEnd": "2021-07-18T23:59:59+0100" } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Event) @@ -125,9 +129,9 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_OR1_start_LTE": { "day": 18, diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/issues/387.md b/packages/graphql/tests/tck/tck-test-files/cypher/issues/387.md new file mode 100644 index 0000000000..1b77cd4aa9 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/issues/387.md @@ -0,0 +1,78 @@ +# #387 + + + +Schema: + +```graphql +scalar URL + +type Place { + name: String + url_works: String + @cypher( + statement: """ + return '' + '' + """ + ) + url_fails: URL + @cypher( + statement: """ + return '' + '' + """ + ) + url_array_works: [String] + @cypher( + statement: """ + return ['' + ''] + """ + ) + url_array_fails: [URL] + @cypher( + statement: """ + return ['' + ''] + """ + ) +} +``` + +--- + +## Should project custom scalars from custom Cypher correctly + +### GraphQL Input + +```graphql +{ + places { + url_works + url_fails + url_array_works + url_array_fails + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Place) +RETURN this { + url_works: apoc.cypher.runFirstColumn("return '' + ''", {this: this, auth: $auth}, false), + url_fails: apoc.cypher.runFirstColumn("return '' + ''", {this: this, auth: $auth}, false), + url_array_works: apoc.cypher.runFirstColumn("return ['' + '']", {this: this, auth: $auth}, false), + url_array_fails: apoc.cypher.runFirstColumn("return ['' + '']", {this: this, auth: $auth}, false) +} as this +``` + +### Expected Cypher Params + +```json +{ + "auth": { + "isAuthenticated": true, + "jwt": {}, + "roles": [] + } +} +``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/nested-unions.md b/packages/graphql/tests/tck/tck-test-files/cypher/nested-unions.md new file mode 100644 index 0000000000..b60372f061 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/nested-unions.md @@ -0,0 +1,200 @@ +# Nested Unions + +Tests for edge cases where either end of a relationship might be a union. + +Schema: + +```graphql +type Movie { + title: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +type Series { + name: String! + actors: [Actor!]! @relationship(type: "ACTED_IN", direction: IN) +} + +union Production = Movie | Series + +type LeadActor { + name: String! + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) +} + +type Extra { + name: String + actedIn: [Production!]! @relationship(type: "ACTED_IN", direction: OUT) +} + +union Actor = LeadActor | Extra +``` + +--- + +## Nested Unions - Connect -> Connect + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "Movie" } + connect: { + actors: { + LeadActor: { + where: { node: { name: "Actor" } } + connect: { + actedIn: { + Series: { where: { node: { name: "Series" } } } + } + } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +CALL { + WITH this + OPTIONAL MATCH (this_connect_actors_LeadActor0_node:LeadActor) + WHERE this_connect_actors_LeadActor0_node.name = $this_connect_actors_LeadActor0_node_name + FOREACH(_ IN CASE this_connect_actors_LeadActor0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)<-[:ACTED_IN]-(this_connect_actors_LeadActor0_node) ) + WITH this, this_connect_actors_LeadActor0_node + CALL { + WITH this, this_connect_actors_LeadActor0_node + OPTIONAL MATCH (this_connect_actors_LeadActor0_node_actedIn_Series0_node:Series) + WHERE this_connect_actors_LeadActor0_node_actedIn_Series0_node.name = $this_connect_actors_LeadActor0_node_actedIn_Series0_node_name + FOREACH(_ IN CASE this_connect_actors_LeadActor0_node_actedIn_Series0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this_connect_actors_LeadActor0_node)-[:ACTED_IN]->(this_connect_actors_LeadActor0_node_actedIn_Series0_node) ) + RETURN count(*) + } + RETURN count(*) +} +RETURN this { .title, actors: [(this)<-[:ACTED_IN]-(this_actors) WHERE "LeadActor" IN labels(this_actors) OR "Extra" IN labels(this_actors) | head( [ this_actors IN [this_actors] WHERE "LeadActor" IN labels (this_actors) | this_actors { __resolveType: "LeadActor", .name, actedIn: [(this_actors)-[:ACTED_IN]->(this_actors_actedIn) WHERE "Movie" IN labels(this_actors_actedIn) OR "Series" IN labels(this_actors_actedIn) | head( [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Movie" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Movie" } ] + [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Series" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Series", .name } ] ) ] } ] + [ this_actors IN [this_actors] WHERE "Extra" IN labels (this_actors) | this_actors { __resolveType: "Extra" } ] ) ] } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_connect_actors_LeadActor0_node_actedIn_Series0_node_name": "Series", + "this_connect_actors_LeadActor0_node_name": "Actor", + "this_title": "Movie" +} +``` + +--- + +## Nested Unions - Disconnect -> Disconnect + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "Movie" } + disconnect: { + actors: { + LeadActor: { + where: { node: { name: "Actor" } } + disconnect: { + actedIn: { + Series: { where: { node: { name: "Series" } } } + } + } + } + } + } + ) { + movies { + title + actors { + ... on LeadActor { + name + actedIn { + ... on Series { + name + } + } + } + } + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title WITH this +OPTIONAL MATCH (this)<-[this_disconnect_actors_LeadActor0_rel:ACTED_IN]-(this_disconnect_actors_LeadActor0:LeadActor) +WHERE this_disconnect_actors_LeadActor0.name = $updateMovies.args.disconnect.actors.LeadActor[0].where.node.name +FOREACH(_ IN CASE this_disconnect_actors_LeadActor0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_actors_LeadActor0_rel ) +WITH this, this_disconnect_actors_LeadActor0 +OPTIONAL MATCH (this_disconnect_actors_LeadActor0)-[this_disconnect_actors_LeadActor0_actedIn_Series0_rel:ACTED_IN]->(this_disconnect_actors_LeadActor0_actedIn_Series0:Series) +WHERE this_disconnect_actors_LeadActor0_actedIn_Series0.name = $updateMovies.args.disconnect.actors.LeadActor[0].disconnect.actedIn.Series[0].where.node.name +FOREACH(_ IN CASE this_disconnect_actors_LeadActor0_actedIn_Series0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_actors_LeadActor0_actedIn_Series0_rel ) +RETURN this { .title, actors: [(this)<-[:ACTED_IN]-(this_actors) WHERE "LeadActor" IN labels(this_actors) OR "Extra" IN labels(this_actors) | head( [ this_actors IN [this_actors] WHERE "LeadActor" IN labels (this_actors) | this_actors { __resolveType: "LeadActor", .name, actedIn: [(this_actors)-[:ACTED_IN]->(this_actors_actedIn) WHERE "Movie" IN labels(this_actors_actedIn) OR "Series" IN labels(this_actors_actedIn) | head( [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Movie" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Movie" } ] + [ this_actors_actedIn IN [this_actors_actedIn] WHERE "Series" IN labels (this_actors_actedIn) | this_actors_actedIn { __resolveType: "Series", .name } ] ) ] } ] + [ this_actors IN [this_actors] WHERE "Extra" IN labels (this_actors) | this_actors { __resolveType: "Extra" } ] ) ] } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "Movie", + "updateMovies": { + "args": { + "disconnect": { + "actors": { + "LeadActor": [ + { + "disconnect": { + "actedIn": { + "Series": [ + { + "where": { + "node": { + "name": "Series" + } + } + } + ] + } + }, + "where": { + "node": { + "name": "Actor" + } + } + } + ] + } + } + } + } +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/null.md b/packages/graphql/tests/tck/tck-test-files/cypher/null.md index 5e4e3d39f9..1e1fed5757 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/null.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/null.md @@ -1,10 +1,10 @@ -## Cypher NULL +# Cypher NULL Tests for queries using null (in)equality in options.where Schema: -```schema +```graphql type Actor { name: String movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) @@ -20,9 +20,9 @@ type Movie { --- -### Simple IS NULL +## Simple IS NULL -**GraphQL input** +### GraphQL Input ```graphql query { @@ -32,7 +32,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -40,17 +40,17 @@ WHERE this.title IS NULL RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Simple IS NOT NULL +## Simple IS NOT NULL -**GraphQL input** +### GraphQL Input ```graphql query { @@ -60,7 +60,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -68,17 +68,17 @@ WHERE this.title IS NOT NULL RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Simple relationship IS NULL +## Simple relationship IS NULL -**GraphQL input** +### GraphQL Input ```graphql query { @@ -88,7 +88,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -96,17 +96,17 @@ WHERE NOT EXISTS((this)<-[:ACTED_IN]-(:Actor)) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Simple relationship IS NOT NULL +## Simple relationship IS NOT NULL -**GraphQL input** +### GraphQL Input ```graphql query { @@ -116,7 +116,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -124,9 +124,9 @@ WHERE EXISTS((this)<-[:ACTED_IN]-(:Actor)) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/operations/connect.md b/packages/graphql/tests/tck/tck-test-files/cypher/operations/connect.md index 7cd827769e..e6b2e94461 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/operations/connect.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/operations/connect.md @@ -1,10 +1,10 @@ -## Cypher Connect +# Cypher Connect Tests connect operations. Schema: -```schema +```graphql type Product { id: ID! name: String @@ -34,9 +34,9 @@ type Photo { --- -### Recursive Connect +## Recursive Connect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -48,13 +48,15 @@ mutation { colors: { connect: [ { - where: { name: "Red" } + where: { node: { name: "Red" } } connect: { photos: [ { - where: { id: "123" } + where: { node: { id: "123" } } connect: { - color: { where: { id: "134" } } + color: { + where: { node: { id: "134" } } + } } } ] @@ -65,12 +67,16 @@ mutation { photos: { connect: [ { - where: { id: "321" } - connect: { color: { where: { name: "Green" } } } + where: { node: { id: "321" } } + connect: { + color: { where: { node: { name: "Green" } } } + } } { - where: { id: "33211" } - connect: { color: { where: { name: "Red" } } } + where: { node: { id: "33211" } } + connect: { + color: { where: { node: { name: "Red" } } } + } } ] } @@ -84,7 +90,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -95,28 +101,28 @@ CALL { WITH this0 CALL { WITH this0 - OPTIONAL MATCH (this0_colors_connect0:Color) - WHERE this0_colors_connect0.name = $this0_colors_connect0_name - FOREACH(_ IN CASE this0_colors_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0)-[:HAS_COLOR]->(this0_colors_connect0) + OPTIONAL MATCH (this0_colors_connect0_node:Color) + WHERE this0_colors_connect0_node.name = $this0_colors_connect0_node_name + FOREACH(_ IN CASE this0_colors_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_COLOR]->(this0_colors_connect0_node) ) - WITH this0, this0_colors_connect0 + WITH this0, this0_colors_connect0_node CALL { - WITH this0, this0_colors_connect0 - OPTIONAL MATCH (this0_colors_connect0_photos0:Photo) - WHERE this0_colors_connect0_photos0.id = $this0_colors_connect0_photos0_id - FOREACH(_ IN CASE this0_colors_connect0_photos0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0_colors_connect0)<-[:OF_COLOR]-(this0_colors_connect0_photos0) + WITH this0, this0_colors_connect0_node + OPTIONAL MATCH (this0_colors_connect0_node_photos0_node:Photo) + WHERE this0_colors_connect0_node_photos0_node.id = $this0_colors_connect0_node_photos0_node_id + FOREACH(_ IN CASE this0_colors_connect0_node_photos0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_colors_connect0_node)<-[:OF_COLOR]-(this0_colors_connect0_node_photos0_node) ) - WITH this0, this0_colors_connect0, this0_colors_connect0_photos0 + WITH this0, this0_colors_connect0_node, this0_colors_connect0_node_photos0_node CALL { - WITH this0, this0_colors_connect0, this0_colors_connect0_photos0 - OPTIONAL MATCH (this0_colors_connect0_photos0_color0:Color) - WHERE this0_colors_connect0_photos0_color0.id = $this0_colors_connect0_photos0_color0_id - FOREACH(_ IN CASE this0_colors_connect0_photos0_color0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0_colors_connect0_photos0)-[:OF_COLOR]->(this0_colors_connect0_photos0_color0) + WITH this0, this0_colors_connect0_node, this0_colors_connect0_node_photos0_node + OPTIONAL MATCH (this0_colors_connect0_node_photos0_node_color0_node:Color) + WHERE this0_colors_connect0_node_photos0_node_color0_node.id = $this0_colors_connect0_node_photos0_node_color0_node_id + FOREACH(_ IN CASE this0_colors_connect0_node_photos0_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_colors_connect0_node_photos0_node)-[:OF_COLOR]->(this0_colors_connect0_node_photos0_node_color0_node) ) RETURN count(*) } @@ -129,19 +135,19 @@ CALL { WITH this0 CALL { WITH this0 - OPTIONAL MATCH (this0_photos_connect0:Photo) - WHERE this0_photos_connect0.id = $this0_photos_connect0_id - FOREACH(_ IN CASE this0_photos_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect0) + OPTIONAL MATCH (this0_photos_connect0_node:Photo) + WHERE this0_photos_connect0_node.id = $this0_photos_connect0_node_id + FOREACH(_ IN CASE this0_photos_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect0_node) ) - WITH this0, this0_photos_connect0 + WITH this0, this0_photos_connect0_node CALL { - WITH this0, this0_photos_connect0 - OPTIONAL MATCH (this0_photos_connect0_color0:Color) - WHERE this0_photos_connect0_color0.name = $this0_photos_connect0_color0_name - FOREACH(_ IN CASE this0_photos_connect0_color0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0_photos_connect0)-[:OF_COLOR]->(this0_photos_connect0_color0) + WITH this0, this0_photos_connect0_node + OPTIONAL MATCH (this0_photos_connect0_node_color0_node:Color) + WHERE this0_photos_connect0_node_color0_node.name = $this0_photos_connect0_node_color0_node_name + FOREACH(_ IN CASE this0_photos_connect0_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos_connect0_node)-[:OF_COLOR]->(this0_photos_connect0_node_color0_node) ) RETURN count(*) } @@ -150,19 +156,19 @@ CALL { WITH this0 CALL { WITH this0 - OPTIONAL MATCH (this0_photos_connect1:Photo) - WHERE this0_photos_connect1.id = $this0_photos_connect1_id - FOREACH(_ IN CASE this0_photos_connect1 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect1) + OPTIONAL MATCH (this0_photos_connect1_node:Photo) + WHERE this0_photos_connect1_node.id = $this0_photos_connect1_node_id + FOREACH(_ IN CASE this0_photos_connect1_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect1_node) ) - WITH this0, this0_photos_connect1 + WITH this0, this0_photos_connect1_node CALL { - WITH this0, this0_photos_connect1 - OPTIONAL MATCH (this0_photos_connect1_color0:Color) - WHERE this0_photos_connect1_color0.name = $this0_photos_connect1_color0_name - FOREACH(_ IN CASE this0_photos_connect1_color0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0_photos_connect1)-[:OF_COLOR]->(this0_photos_connect1_color0) + WITH this0, this0_photos_connect1_node + OPTIONAL MATCH (this0_photos_connect1_node_color0_node:Color) + WHERE this0_photos_connect1_node_color0_node.name = $this0_photos_connect1_node_color0_node_name + FOREACH(_ IN CASE this0_photos_connect1_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos_connect1_node)-[:OF_COLOR]->(this0_photos_connect1_node_color0_node) ) RETURN count(*) } @@ -173,19 +179,19 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this0_id": "123", - "this0_name": "Nested Connect", - "this0_colors_connect0_name": "Red", - "this0_colors_connect0_photos0_id": "123", - "this0_colors_connect0_photos0_color0_id": "134", - "this0_photos_connect0_id": "321", - "this0_photos_connect0_color0_name": "Green", - "this0_photos_connect1_id": "33211", - "this0_photos_connect1_color0_name": "Red" + "this0_id": "123", + "this0_name": "Nested Connect", + "this0_colors_connect0_node_name": "Red", + "this0_colors_connect0_node_photos0_node_id": "123", + "this0_colors_connect0_node_photos0_node_color0_node_id": "134", + "this0_photos_connect0_node_id": "321", + "this0_photos_connect0_node_color0_node_name": "Green", + "this0_photos_connect1_node_id": "33211", + "this0_photos_connect1_node_color0_node_name": "Red" } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/operations/create.md b/packages/graphql/tests/tck/tck-test-files/cypher/operations/create.md index 176c191f13..71ae19bfbb 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/operations/create.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/operations/create.md @@ -1,10 +1,10 @@ -## Cypher Create +# Cypher Create Tests create operations. Schema: -```schema +```graphql type Actor { name: String movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) @@ -18,9 +18,9 @@ type Movie { --- -### Simple Create +## Simple Create -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -32,7 +32,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -44,9 +44,9 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1" } @@ -54,9 +54,9 @@ RETURN this0 { .id } AS this0 --- -### Simple Multi Create +## Simple Multi Create -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -68,7 +68,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -87,9 +87,9 @@ RETURN this0 { .id } AS this0, this1 { .id } AS this1 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", "this1_id": "2" @@ -98,16 +98,16 @@ RETURN this0 { .id } AS this0, --- -### Two Level Nested create +## Two Level Nested create -**GraphQL input** +### GraphQL Input ```graphql mutation { createMovies( input: [ - { id: 1, actors: { create: [{ name: "actor 1" }] } } - { id: 2, actors: { create: [{ name: "actor 2" }] } } + { id: 1, actors: { create: [{ node: { name: "actor 1" } }] } } + { id: 2, actors: { create: [{ node: { name: "actor 2" } }] } } ] ) { movies { @@ -117,7 +117,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -125,9 +125,9 @@ CALL { SET this0.id = $this0_id WITH this0 - CREATE (this0_actors0:Actor) - SET this0_actors0.name = $this0_actors0_name - MERGE (this0)<-[:ACTED_IN]-(this0_actors0) + CREATE (this0_actors0_node:Actor) + SET this0_actors0_node.name = $this0_actors0_node_name + MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) RETURN this0 } @@ -137,9 +137,9 @@ CALL { SET this1.id = $this1_id WITH this1 - CREATE (this1_actors0:Actor) - SET this1_actors0.name = $this1_actors0_name - MERGE (this1)<-[:ACTED_IN]-(this1_actors0) + CREATE (this1_actors0_node:Actor) + SET this1_actors0_node.name = $this1_actors0_node_name + MERGE (this1)<-[:ACTED_IN]-(this1_actors0_node) RETURN this1 } @@ -147,22 +147,22 @@ CALL { RETURN this0 { .id } AS this0, this1 { .id } AS this1 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", - "this0_actors0_name": "actor 1", + "this0_actors0_node_name": "actor 1", "this1_id": "2", - "this1_actors0_name": "actor 2" + "this1_actors0_node_name": "actor 2" } ``` --- -### Three Level Nested create +## Three Level Nested create -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -172,7 +172,12 @@ mutation { id: "1" actors: { create: [ - { name: "actor 1", movies: { create: [{ id: "10" }] } } + { + node: { + name: "actor 1" + movies: { create: [{ node: { id: "10" } }] } + } + } ] } } @@ -180,7 +185,12 @@ mutation { id: "2" actors: { create: [ - { name: "actor 2", movies: { create: [{ id: "20" }] } } + { + node: { + name: "actor 2" + movies: { create: [{ node: { id: "20" } }] } + } + } ] } } @@ -193,7 +203,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -201,13 +211,13 @@ CALL { SET this0.id = $this0_id WITH this0 - CREATE (this0_actors0:Actor) - SET this0_actors0.name = $this0_actors0_name - WITH this0, this0_actors0 - CREATE (this0_actors0_movies0:Movie) - SET this0_actors0_movies0.id = $this0_actors0_movies0_id - MERGE (this0_actors0)-[:ACTED_IN]->(this0_actors0_movies0) - MERGE (this0)<-[:ACTED_IN]-(this0_actors0) + CREATE (this0_actors0_node:Actor) + SET this0_actors0_node.name = $this0_actors0_node_name + WITH this0, this0_actors0_node + CREATE (this0_actors0_node_movies0_node:Movie) + SET this0_actors0_node_movies0_node.id = $this0_actors0_node_movies0_node_id + MERGE (this0_actors0_node)-[:ACTED_IN]->(this0_actors0_node_movies0_node) + MERGE (this0)<-[:ACTED_IN]-(this0_actors0_node) RETURN this0 } @@ -217,13 +227,13 @@ CALL { SET this1.id = $this1_id WITH this1 - CREATE (this1_actors0:Actor) - SET this1_actors0.name = $this1_actors0_name - WITH this1, this1_actors0 - CREATE (this1_actors0_movies0:Movie) - SET this1_actors0_movies0.id = $this1_actors0_movies0_id - MERGE (this1_actors0)-[:ACTED_IN]->(this1_actors0_movies0) - MERGE (this1)<-[:ACTED_IN]-(this1_actors0) + CREATE (this1_actors0_node:Actor) + SET this1_actors0_node.name = $this1_actors0_node_name + WITH this1, this1_actors0_node + CREATE (this1_actors0_node_movies0_node:Movie) + SET this1_actors0_node_movies0_node.id = $this1_actors0_node_movies0_node_id + MERGE (this1_actors0_node)-[:ACTED_IN]->(this1_actors0_node_movies0_node) + MERGE (this1)<-[:ACTED_IN]-(this1_actors0_node) RETURN this1 } @@ -231,29 +241,34 @@ CALL { RETURN this0 { .id } AS this0, this1 { .id } AS this1 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", - "this0_actors0_name": "actor 1", - "this0_actors0_movies0_id": "10", + "this0_actors0_node_name": "actor 1", + "this0_actors0_node_movies0_node_id": "10", "this1_id": "2", - "this1_actors0_name": "actor 2", - "this1_actors0_movies0_id": "20" + "this1_actors0_node_name": "actor 2", + "this1_actors0_node_movies0_node_id": "20" } ``` --- -### Simple create and connect +## Simple create and connect -**GraphQL input** +### GraphQL Input ```graphql mutation { createMovies( - input: [{ id: 1, actors: { connect: [{ where: { name: "Dan" } }] } }] + input: [ + { + id: 1 + actors: { connect: [{ where: { node: { name: "Dan" } } }] } + } + ] ) { movies { id @@ -262,7 +277,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -272,10 +287,10 @@ CALL { WITH this0 CALL { WITH this0 - OPTIONAL MATCH (this0_actors_connect0:Actor) - WHERE this0_actors_connect0.name = $this0_actors_connect0_name - FOREACH(_ IN CASE this0_actors_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0)<-[:ACTED_IN]-(this0_actors_connect0) + OPTIONAL MATCH (this0_actors_connect0_node:Actor) + WHERE this0_actors_connect0_node.name = $this0_actors_connect0_node_name + FOREACH(_ IN CASE this0_actors_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)<-[:ACTED_IN]-(this0_actors_connect0_node) ) RETURN count(*) } @@ -286,12 +301,12 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", - "this0_actors_connect0_name": "Dan" + "this0_actors_connect0_node_name": "Dan" } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/operations/delete.md b/packages/graphql/tests/tck/tck-test-files/cypher/operations/delete.md index 4342690aee..d6df212c6f 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/operations/delete.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/operations/delete.md @@ -1,10 +1,10 @@ -## Cypher Delete +# Cypher Delete Tests delete operations. Schema: -```schema +```graphql type Actor { name: String movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) @@ -19,9 +19,9 @@ type Movie { --- -### Simple Delete +## Simple Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -31,7 +31,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -39,9 +39,9 @@ WHERE this.id = $this_id DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "123" } @@ -49,49 +49,63 @@ DETACH DELETE this --- -### Single Nested Delete +## Single Nested Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { deleteMovies( where: { id: 123 } - delete: { actors: { where: { name: "Actor to delete" } } } + delete: { actors: { where: { node: { name: "Actor to delete" } } } } ) { nodesDeleted } } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0:Actor) -WHERE this_actors0.name = $this_actors0_name +OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name FOREACH(_ IN CASE this_actors0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors0 ) DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "123", - "this_actors0_name": "Actor to delete" + "this_deleteMovies": { + "args": { + "delete": { + "actors": [ + { + "where": { + "node": { + "name": "Actor to delete" + } + } + } + ] + } + } + } } ``` --- -### Single Nested Delete deleting multiple +## Single Nested Delete deleting multiple -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -99,8 +113,8 @@ mutation { where: { id: 123 } delete: { actors: [ - { where: { name: "Actor to delete" } } - { where: { name: "Another actor to delete" } } + { where: { node: { name: "Actor to delete" } } } + { where: { node: { name: "Another actor to delete" } } } ] } ) { @@ -109,41 +123,61 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0:Actor) -WHERE this_actors0.name = $this_actors0_name +OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name FOREACH(_ IN CASE this_actors0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors0 ) WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors1:Actor) -WHERE this_actors1.name = $this_actors1_name +OPTIONAL MATCH (this)<-[this_actors1_relationship:ACTED_IN]-(this_actors1:Actor) +WHERE this_actors1.name = $this_deleteMovies.args.delete.actors[1].where.node.name FOREACH(_ IN CASE this_actors1 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors1 ) DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "123", - "this_actors0_name": "Actor to delete", - "this_actors1_name": "Another actor to delete" + "this_deleteMovies": { + "args": { + "delete": { + "actors": [ + { + "where": { + "node": { + "name": "Actor to delete" + } + } + }, + { + "where": { + "node": { + "name": "Another actor to delete" + } + } + } + ] + } + } + } } ``` --- -### Double Nested Delete +## Double Nested Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -151,8 +185,8 @@ mutation { where: { id: 123 } delete: { actors: { - where: { name: "Actor to delete" } - delete: { movies: { where: { id: 321 } } } + where: { node: { name: "Actor to delete" } } + delete: { movies: { where: { node: { id: 321 } } } } } } ) { @@ -161,17 +195,17 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0:Actor) -WHERE this_actors0.name = $this_actors0_name +OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name WITH this, this_actors0 -OPTIONAL MATCH (this_actors0)-[:ACTED_IN]->(this_actors0_movies0:Movie) -WHERE this_actors0_movies0.id = $this_actors0_movies0_id +OPTIONAL MATCH (this_actors0)-[this_actors0_movies0_relationship:ACTED_IN]->(this_actors0_movies0:Movie) +WHERE this_actors0_movies0.id = $this_deleteMovies.args.delete.actors[0].delete.movies[0].where.node.id FOREACH(_ IN CASE this_actors0_movies0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors0_movies0 ) @@ -181,21 +215,45 @@ FOREACH(_ IN CASE this_actors0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "123", - "this_actors0_name": "Actor to delete", - "this_actors0_movies0_id": "321" + "this_deleteMovies": { + "args": { + "delete": { + "actors": [ + { + "delete": { + "movies": [ + { + "where": { + "node": { + "id": "321" + } + } + } + ] + }, + "where": { + "node": { + "name": "Actor to delete" + } + } + } + ] + } + } + } } ``` --- -### Triple Nested Delete +## Triple Nested Delete -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -203,13 +261,15 @@ mutation { where: { id: 123 } delete: { actors: { - where: { name: "Actor to delete" } + where: { node: { name: "Actor to delete" } } delete: { movies: { - where: { id: 321 } + where: { node: { id: 321 } } delete: { actors: { - where: { name: "Another actor to delete" } + where: { + node: { name: "Another actor to delete" } + } } } } @@ -222,20 +282,20 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0:Actor) -WHERE this_actors0.name = $this_actors0_name +OPTIONAL MATCH (this)<-[this_actors0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $this_deleteMovies.args.delete.actors[0].where.node.name WITH this, this_actors0 -OPTIONAL MATCH (this_actors0)-[:ACTED_IN]->(this_actors0_movies0:Movie) -WHERE this_actors0_movies0.id = $this_actors0_movies0_id +OPTIONAL MATCH (this_actors0)-[this_actors0_movies0_relationship:ACTED_IN]->(this_actors0_movies0:Movie) +WHERE this_actors0_movies0.id = $this_deleteMovies.args.delete.actors[0].delete.movies[0].where.node.id WITH this, this_actors0, this_actors0_movies0 -OPTIONAL MATCH (this_actors0_movies0)<-[:ACTED_IN]-(this_actors0_movies0_actors0:Actor) -WHERE this_actors0_movies0_actors0.name = $this_actors0_movies0_actors0_name +OPTIONAL MATCH (this_actors0_movies0)<-[this_actors0_movies0_actors0_relationship:ACTED_IN]-(this_actors0_movies0_actors0:Actor) +WHERE this_actors0_movies0_actors0.name = $this_deleteMovies.args.delete.actors[0].delete.movies[0].delete.actors[0].where.node.name FOREACH(_ IN CASE this_actors0_movies0_actors0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors0_movies0_actors0 ) @@ -248,14 +308,48 @@ FOREACH(_ IN CASE this_actors0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "123", - "this_actors0_name": "Actor to delete", - "this_actors0_movies0_id": "321", - "this_actors0_movies0_actors0_name": "Another actor to delete" + "this_deleteMovies": { + "args": { + "delete": { + "actors": [ + { + "delete": { + "movies": [ + { + "delete": { + "actors": [ + { + "where": { + "node": { + "name": "Another actor to delete" + } + } + } + ] + }, + "where": { + "node": { + "id": "321" + } + } + } + ] + }, + "where": { + "node": { + "name": "Actor to delete" + } + } + } + ] + } + } + } } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/operations/disconnect.md b/packages/graphql/tests/tck/tck-test-files/cypher/operations/disconnect.md new file mode 100644 index 0000000000..bacb4b7cad --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/cypher/operations/disconnect.md @@ -0,0 +1,201 @@ +# Cypher Disconnect + +Tests connect operations. + +Schema: + +```graphql +type Product { + id: ID! + name: String + sizes: [Size] @relationship(type: "HAS_SIZE", direction: OUT) + colors: [Color] @relationship(type: "HAS_COLOR", direction: OUT) + photos: [Photo] @relationship(type: "HAS_PHOTO", direction: OUT) +} + +type Size { + id: ID! + name: String! +} + +type Color { + id: ID! + name: String! + photos: [Photo] @relationship(type: "OF_COLOR", direction: IN) +} + +type Photo { + id: ID! + description: String! + url: String! + color: Color @relationship(type: "OF_COLOR", direction: OUT) +} +``` + +--- + +## Recursive Connect + +### GraphQL Input + +```graphql +mutation { + createProducts( + input: [ + { + id: "123" + name: "Nested Connect" + colors: { + connect: [ + { + where: { node: { name: "Red" } } + connect: { + photos: [ + { + where: { node: { id: "123" } } + connect: { + color: { + where: { node: { id: "134" } } + } + } + } + ] + } + } + ] + } + photos: { + connect: [ + { + where: { node: { id: "321" } } + connect: { + color: { where: { node: { name: "Green" } } } + } + } + { + where: { node: { id: "33211" } } + connect: { + color: { where: { node: { name: "Red" } } } + } + } + ] + } + } + ] + ) { + products { + id + } + } +} +``` + +### Expected Cypher Output + +```cypher +CALL { + CREATE (this0:Product) + SET this0.id = $this0_id + SET this0.name = $this0_name + + WITH this0 + CALL { + WITH this0 + OPTIONAL MATCH (this0_colors_connect0_node:Color) + WHERE this0_colors_connect0_node.name = $this0_colors_connect0_node_name + FOREACH(_ IN CASE this0_colors_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_COLOR]->(this0_colors_connect0_node) + ) + + WITH this0, this0_colors_connect0_node + CALL { + WITH this0, this0_colors_connect0_node + OPTIONAL MATCH (this0_colors_connect0_node_photos0_node:Photo) + WHERE this0_colors_connect0_node_photos0_node.id = $this0_colors_connect0_node_photos0_node_id + FOREACH(_ IN CASE this0_colors_connect0_node_photos0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_colors_connect0_node)<-[:OF_COLOR]-(this0_colors_connect0_node_photos0_node) + ) + + WITH this0, this0_colors_connect0_node, this0_colors_connect0_node_photos0_node + CALL { + WITH this0, this0_colors_connect0_node, this0_colors_connect0_node_photos0_node + OPTIONAL MATCH (this0_colors_connect0_node_photos0_node_color0_node:Color) + WHERE this0_colors_connect0_node_photos0_node_color0_node.id = $this0_colors_connect0_node_photos0_node_color0_node_id + FOREACH(_ IN CASE this0_colors_connect0_node_photos0_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_colors_connect0_node_photos0_node)-[:OF_COLOR]->(this0_colors_connect0_node_photos0_node_color0_node) + ) + RETURN count(*) + } + RETURN count(*) + } + RETURN count(*) + } + + WITH this0 + CALL { + WITH this0 + OPTIONAL MATCH (this0_photos_connect0_node:Photo) + WHERE this0_photos_connect0_node.id = $this0_photos_connect0_node_id + FOREACH(_ IN CASE this0_photos_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect0_node) + ) + + WITH this0, this0_photos_connect0_node + CALL { + WITH this0, this0_photos_connect0_node + OPTIONAL MATCH (this0_photos_connect0_node_color0_node:Color) + WHERE this0_photos_connect0_node_color0_node.name = $this0_photos_connect0_node_color0_node_name + FOREACH(_ IN CASE this0_photos_connect0_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos_connect0_node)-[:OF_COLOR]->(this0_photos_connect0_node_color0_node) + ) + RETURN count(*) + } + RETURN count(*) + } + + WITH this0 + CALL { + WITH this0 + OPTIONAL MATCH (this0_photos_connect1_node:Photo) + WHERE this0_photos_connect1_node.id = $this0_photos_connect1_node_id + FOREACH(_ IN CASE this0_photos_connect1_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:HAS_PHOTO]->(this0_photos_connect1_node) + ) + + WITH this0, this0_photos_connect1_node + CALL { + WITH this0, this0_photos_connect1_node + OPTIONAL MATCH (this0_photos_connect1_node_color0_node:Color) + WHERE this0_photos_connect1_node_color0_node.name = $this0_photos_connect1_node_color0_node_name + FOREACH(_ IN CASE this0_photos_connect1_node_color0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos_connect1_node)-[:OF_COLOR]->(this0_photos_connect1_node_color0_node) + ) + RETURN count(*) + } + RETURN count(*) + } + + RETURN this0 +} + +RETURN +this0 { .id } AS this0 +``` + +### Expected Cypher Params + +```json +{ + "this0_id": "123", + "this0_name": "Nested Connect", + "this0_colors_connect0_node_name": "Red", + "this0_colors_connect0_node_photos0_node_id": "123", + "this0_colors_connect0_node_photos0_node_color0_node_id": "134", + "this0_photos_connect0_node_id": "321", + "this0_photos_connect0_node_color0_node_name": "Green", + "this0_photos_connect1_node_id": "33211", + "this0_photos_connect1_node_color0_node_name": "Red" +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/operations/update.md b/packages/graphql/tests/tck/tck-test-files/cypher/operations/update.md index c792d4a1a6..f6cd7af7b3 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/operations/update.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/operations/update.md @@ -1,27 +1,33 @@ -## Cypher Update +# Cypher Update Tests Update operations. Schema: -```schema +```graphql type Actor { name: String - movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) + movies: [Movie] + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: OUT) } type Movie { id: ID title: String - actors: [Actor]! @relationship(type: "ACTED_IN", direction: IN) + actors: [Actor]! + @relationship(type: "ACTED_IN", properties: "ActedIn", direction: IN) +} + +interface ActedIn { + screenTime: Int } ``` --- -### Simple Update +## Simple Update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -33,7 +39,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -43,9 +49,9 @@ SET this.id = $this_update_id RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", "this_update_id": "2" @@ -54,9 +60,9 @@ RETURN this { .id } AS this --- -### Single Nested Update +## Single Nested Update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -64,7 +70,10 @@ mutation { where: { id: "1" } update: { actors: [ - { where: { name: "old name" }, update: { name: "new name" } } + { + where: { node: { name: "old name" } } + update: { node: { name: "new name" } } + } ] } ) { @@ -75,45 +84,64 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0:Actor) -WHERE this_actors0.name = $this_actors0_name +OPTIONAL MATCH (this)<-[this_acted_in0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $updateMovies.args.update.actors[0].where.node.name CALL apoc.do.when(this_actors0 IS NOT NULL, " SET this_actors0.name = $this_update_actors0_name RETURN count(*) ", "", - {this:this, this_actors0:this_actors0, auth:$auth,this_update_actors0_name:$this_update_actors0_name}) YIELD value as _ + {this:this, updateMovies: $updateMovies, this_actors0:this_actors0, auth:$auth,this_update_actors0_name:$this_update_actors0_name}) YIELD value as _ RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_actors0_name": "old name", "this_update_actors0_name": "new name", "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} + }, + "updateMovies": { + "args": { + "update": { + "actors": [ + { + "update": { + "node": { + "name": "new name" + } + }, + "where": { + "node": { + "name": "old name" + } + } + } + ] + } + } } } ``` --- -### Double Nested Update +## Double Nested Update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -122,15 +150,19 @@ mutation { update: { actors: [ { - where: { name: "old actor name" } + where: { node: { name: "old actor name" } } update: { - name: "new actor name" - movies: [ - { - where: { id: "old movie title" } - update: { title: "new movie title" } - } - ] + node: { + name: "new actor name" + movies: [ + { + where: { node: { id: "old movie title" } } + update: { + node: { title: "new movie title" } + } + } + ] + } } } ] @@ -143,64 +175,95 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0:Actor) -WHERE this_actors0.name = $this_actors0_name +OPTIONAL MATCH (this)<-[this_acted_in0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $updateMovies.args.update.actors[0].where.node.name CALL apoc.do.when(this_actors0 IS NOT NULL, " SET this_actors0.name = $this_update_actors0_name WITH this, this_actors0 - OPTIONAL MATCH (this_actors0)-[:ACTED_IN]->(this_actors0_movies0:Movie) - WHERE this_actors0_movies0.id = $this_actors0_movies0_id + OPTIONAL MATCH (this_actors0)-[this_actors0_acted_in0_relationship:ACTED_IN]->(this_actors0_movies0:Movie) + WHERE this_actors0_movies0.id = $updateMovies.args.update.actors[0].update.node.movies[0].where.node.id CALL apoc.do.when(this_actors0_movies0 IS NOT NULL, \" SET this_actors0_movies0.title = $this_update_actors0_movies0_title RETURN count(*) \", \"\", - {this:this, this_actors0:this_actors0, this_actors0_movies0:this_actors0_movies0, auth:$auth,this_update_actors0_movies0_title:$this_update_actors0_movies0_title}) YIELD value as _ + {this:this, this_actors0:this_actors0, updateMovies: $updateMovies, this_actors0_movies0:this_actors0_movies0, auth:$auth,this_update_actors0_movies0_title:$this_update_actors0_movies0_title}) YIELD value as _ RETURN count(*) ", "", - {this:this, this_actors0:this_actors0, auth:$auth,this_update_actors0_name:$this_update_actors0_name,this_actors0_movies0_id:$this_actors0_movies0_id,this_update_actors0_movies0_title:$this_update_actors0_movies0_title}) YIELD value as _ + {this:this, updateMovies: $updateMovies, this_actors0:this_actors0, auth:$auth,this_update_actors0_name:$this_update_actors0_name,this_update_actors0_movies0_title:$this_update_actors0_movies0_title}) YIELD value as _ RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_actors0_name": "old name", - "this_actors0_movies0_id": "old movie title", - "this_actors0_name": "old actor name", "this_update_actors0_movies0_title": "new movie title", "this_update_actors0_name": "new actor name", "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} + "isAuthenticated": true, + "roles": [], + "jwt": {} + }, + "updateMovies": { + "args": { + "update": { + "actors": [ + { + "update": { + "node": { + "movies": [ + { + "update": { + "node": { + "title": "new movie title" + } + }, + "where": { + "node": { + "id": "old movie title" + } + } + } + ], + "name": "new actor name" + } + }, + "where": { + "node": { + "name": "old actor name" + } + } + } + ] + } + } } } ``` --- -### Simple Update as Connect +## Simple Update as Connect -**GraphQL input** +### GraphQL Input ```graphql mutation { updateMovies( where: { id: "1" } - connect: { actors: [{ where: { name: "Daniel" } }] } + connect: { actors: [{ where: { node: { name: "Daniel" } } }] } ) { movies { id @@ -209,7 +272,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -217,30 +280,30 @@ WHERE this.id = $this_id WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_actors0:Actor) - WHERE this_connect_actors0.name = $this_connect_actors0_name - FOREACH(_ IN CASE this_connect_actors0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this)<-[:ACTED_IN]-(this_connect_actors0) + OPTIONAL MATCH (this_connect_actors0_node:Actor) + WHERE this_connect_actors0_node.name = $this_connect_actors0_node_name + FOREACH(_ IN CASE this_connect_actors0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)<-[:ACTED_IN]-(this_connect_actors0_node) ) RETURN count(*) } RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_connect_actors0_name": "Daniel" + "this_connect_actors0_node_name": "Daniel" } ``` --- -### Update as multiple Connect +## Update as multiple Connect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -248,8 +311,8 @@ mutation { where: { id: "1" } connect: { actors: [ - { where: { name: "Daniel" } } - { where: { name: "Darrell" } } + { where: { node: { name: "Daniel" } } } + { where: { node: { name: "Darrell" } } } ] } ) { @@ -260,7 +323,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -268,47 +331,47 @@ WHERE this.id = $this_id WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_actors0:Actor) - WHERE this_connect_actors0.name = $this_connect_actors0_name - FOREACH(_ IN CASE this_connect_actors0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this)<-[:ACTED_IN]-(this_connect_actors0) + OPTIONAL MATCH (this_connect_actors0_node:Actor) + WHERE this_connect_actors0_node.name = $this_connect_actors0_node_name + FOREACH(_ IN CASE this_connect_actors0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)<-[:ACTED_IN]-(this_connect_actors0_node) ) RETURN count(*) } WITH this CALL { WITH this - OPTIONAL MATCH (this_connect_actors1:Actor) - WHERE this_connect_actors1.name = $this_connect_actors1_name - FOREACH(_ IN CASE this_connect_actors1 WHEN NULL THEN [] ELSE [1] END | - MERGE (this)<-[:ACTED_IN]-(this_connect_actors1) + OPTIONAL MATCH (this_connect_actors1_node:Actor) + WHERE this_connect_actors1_node.name = $this_connect_actors1_node_name + FOREACH(_ IN CASE this_connect_actors1_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this)<-[:ACTED_IN]-(this_connect_actors1_node) ) RETURN count(*) } RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_connect_actors0_name": "Daniel", - "this_connect_actors1_name": "Darrell" + "this_connect_actors0_node_name": "Daniel", + "this_connect_actors1_node_name": "Darrell" } ``` --- -### Simple Update as Disconnect +## Simple Update as Disconnect -**GraphQL input** +### GraphQL Input ```graphql mutation { updateMovies( where: { id: "1" } - disconnect: { actors: [{ where: { name: "Daniel" } }] } + disconnect: { actors: [{ where: { node: { name: "Daniel" } } }] } ) { movies { id @@ -317,34 +380,48 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this OPTIONAL MATCH (this)<-[this_disconnect_actors0_rel:ACTED_IN]-(this_disconnect_actors0:Actor) -WHERE this_disconnect_actors0.name = $this_disconnect_actors0_name +WHERE this_disconnect_actors0.name = $updateMovies.args.disconnect.actors[0].where.node.name FOREACH(_ IN CASE this_disconnect_actors0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_actors0_rel ) RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_disconnect_actors0_name": "Daniel" + "updateMovies": { + "args": { + "disconnect": { + "actors": [ + { + "where": { + "node": { + "name": "Daniel" + } + } + } + ] + } + } + } } ``` --- -### Update as multiple Disconnect +## Update as multiple Disconnect -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -352,8 +429,8 @@ mutation { where: { id: "1" } disconnect: { actors: [ - { where: { name: "Daniel" } } - { where: { name: "Darrell" } } + { where: { node: { name: "Daniel" } } } + { where: { node: { name: "Darrell" } } } ] } ) { @@ -364,41 +441,61 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this OPTIONAL MATCH (this)<-[this_disconnect_actors0_rel:ACTED_IN]-(this_disconnect_actors0:Actor) -WHERE this_disconnect_actors0.name = $this_disconnect_actors0_name +WHERE this_disconnect_actors0.name = $updateMovies.args.disconnect.actors[0].where.node.name FOREACH(_ IN CASE this_disconnect_actors0 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_actors0_rel ) WITH this OPTIONAL MATCH (this)<-[this_disconnect_actors1_rel:ACTED_IN]-(this_disconnect_actors1:Actor) -WHERE this_disconnect_actors1.name = $this_disconnect_actors1_name +WHERE this_disconnect_actors1.name = $updateMovies.args.disconnect.actors[1].where.node.name FOREACH(_ IN CASE this_disconnect_actors1 WHEN NULL THEN [] ELSE [1] END | DELETE this_disconnect_actors1_rel ) RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_disconnect_actors0_name": "Daniel", - "this_disconnect_actors1_name": "Darrell" + "updateMovies": { + "args": { + "disconnect": { + "actors": [ + { + "where": { + "node": { + "name": "Daniel" + } + } + }, + { + "where": { + "node": { + "name": "Darrell" + } + } + } + ] + } + } + } } ``` --- -### Update an Actor while creating and connecting to a new Movie (via field level) +## Update an Actor while creating and connecting to a new Movie (via field level) -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -406,7 +503,9 @@ mutation { where: { name: "Dan" } update: { movies: { - create: [{ id: "dan_movie_id", title: "The Story of Beer" }] + create: [ + { node: { id: "dan_movie_id", title: "The Story of Beer" } } + ] } } ) { @@ -421,42 +520,46 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Actor) WHERE this.name = $this_name WITH this -CREATE (this_movies0_create0:Movie) -SET this_movies0_create0.id = $this_movies0_create0_id -SET this_movies0_create0.title = $this_movies0_create0_title -MERGE (this)-[:ACTED_IN]->(this_movies0_create0) +CREATE (this_movies0_create0_node:Movie) +SET this_movies0_create0_node.id = $this_movies0_create0_node_id +SET this_movies0_create0_node.title = $this_movies0_create0_node_title +MERGE (this)-[:ACTED_IN]->(this_movies0_create0_node) RETURN this { .name, movies: [ (this)-[:ACTED_IN]->(this_movies:Movie) | this_movies { .id, .title } ] } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_name": "Dan", - "this_movies0_create0_id": "dan_movie_id", - "this_movies0_create0_title": "The Story of Beer" + "this_name": "Dan", + "this_movies0_create0_node_id": "dan_movie_id", + "this_movies0_create0_node_title": "The Story of Beer" } ``` --- -### Update an Actor while creating and connecting to a new Movie (via top level) +## Update an Actor while creating and connecting to a new Movie (via top level) -**GraphQL input** +### GraphQL Input ```graphql mutation { updateActors( where: { name: "Dan" } - create: { movies: [{ id: "dan_movie_id", title: "The Story of Beer" }] } + create: { + movies: [ + { node: { id: "dan_movie_id", title: "The Story of Beer" } } + ] + } ) { actors { name @@ -469,35 +572,35 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Actor) WHERE this.name = $this_name -CREATE (this_create_movies0:Movie) -SET this_create_movies0.id = $this_create_movies0_id -SET this_create_movies0.title = $this_create_movies0_title -MERGE (this)-[:ACTED_IN]->(this_create_movies0) +CREATE (this_create_movies0_node:Movie) +SET this_create_movies0_node.id = $this_create_movies0_node_id +SET this_create_movies0_node.title = $this_create_movies0_node_title +MERGE (this)-[:ACTED_IN]->(this_create_movies0_node) RETURN this { .name, movies: [ (this)-[:ACTED_IN]->(this_movies:Movie) | this_movies { .id, .title } ] } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_name": "Dan", - "this_create_movies0_id": "dan_movie_id", - "this_create_movies0_title": "The Story of Beer" + "this_name": "Dan", + "this_create_movies0_node_id": "dan_movie_id", + "this_create_movies0_node_title": "The Story of Beer" } ``` --- -### Update an Actor while creating and connecting to multiple new Movies (via top level) +## Update an Actor while creating and connecting to multiple new Movies (via top level) -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -505,8 +608,8 @@ mutation { where: { name: "Dan" } create: { movies: [ - { id: "dan_movie_id", title: "The Story of Beer" } - { id: "dan_movie2_id", title: "Forrest Gump" } + { node: { id: "dan_movie_id", title: "The Story of Beer" } } + { node: { id: "dan_movie2_id", title: "Forrest Gump" } } ] } ) { @@ -521,48 +624,55 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Actor) WHERE this.name = $this_name -CREATE (this_create_movies0:Movie) -SET this_create_movies0.id = $this_create_movies0_id -SET this_create_movies0.title = $this_create_movies0_title -MERGE (this)-[:ACTED_IN]->(this_create_movies0) +CREATE (this_create_movies0_node:Movie) +SET this_create_movies0_node.id = $this_create_movies0_node_id +SET this_create_movies0_node.title = $this_create_movies0_node_title +MERGE (this)-[:ACTED_IN]->(this_create_movies0_node) -CREATE (this_create_movies1:Movie) -SET this_create_movies1.id = $this_create_movies1_id -SET this_create_movies1.title = $this_create_movies1_title -MERGE (this)-[:ACTED_IN]->(this_create_movies1) +CREATE (this_create_movies1_node:Movie) +SET this_create_movies1_node.id = $this_create_movies1_node_id +SET this_create_movies1_node.title = $this_create_movies1_node_title +MERGE (this)-[:ACTED_IN]->(this_create_movies1_node) RETURN this { .name, movies: [ (this)-[:ACTED_IN]->(this_movies:Movie) | this_movies { .id, .title } ] } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_name": "Dan", - "this_create_movies0_id": "dan_movie_id", - "this_create_movies0_title": "The Story of Beer", - "this_create_movies1_id": "dan_movie2_id", - "this_create_movies1_title": "Forrest Gump" + "this_name": "Dan", + "this_create_movies0_node_id": "dan_movie_id", + "this_create_movies0_node_title": "The Story of Beer", + "this_create_movies1_node_id": "dan_movie2_id", + "this_create_movies1_node_title": "Forrest Gump" } ``` --- -### Delete related node as update +## Delete related node as update -**GraphQL input** +### GraphQL Input ```graphql mutation { updateMovies( where: { id: "1" } - delete: { actors: { where: { name: "Actor to delete" } } } + delete: { + actors: { + where: { + node: { name: "Actor to delete" } + edge: { screenTime: 60 } + } + } + } ) { movies { id @@ -571,34 +681,54 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_delete_actors0:Actor) -WHERE this_delete_actors0.name = $this_delete_actors0_name +OPTIONAL MATCH (this)<-[this_delete_actors0_relationship:ACTED_IN]-(this_delete_actors0:Actor) +WHERE this_delete_actors0_relationship.screenTime = $updateMovies.args.delete.actors[0].where.edge.screenTime AND this_delete_actors0.name = $updateMovies.args.delete.actors[0].where.node.name FOREACH(_ IN CASE this_delete_actors0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_delete_actors0 ) RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_delete_actors0_name": "Actor to delete" + "updateMovies": { + "args": { + "delete": { + "actors": [ + { + "where": { + "node": { + "name": "Actor to delete" + }, + "edge": { + "screenTime": { + "high": 0, + "low": 60 + } + } + } + } + ] + } + } + } } ``` --- -### Delete and update nested operations under same mutation +## Delete and update nested operations under same mutation -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -606,11 +736,11 @@ mutation { where: { id: "1" } update: { actors: { - where: { name: "Actor to update" } - update: { name: "Updated name" } + where: { node: { name: "Actor to update" } } + update: { node: { name: "Updated name" } } } } - delete: { actors: { where: { name: "Actor to delete" } } } + delete: { actors: { where: { node: { name: "Actor to delete" } } } } ) { movies { id @@ -619,56 +749,87 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0:Actor) -WHERE this_actors0.name = $this_actors0_name +OPTIONAL MATCH (this)<-[this_acted_in0_relationship:ACTED_IN]-(this_actors0:Actor) +WHERE this_actors0.name = $updateMovies.args.update.actors[0].where.node.name CALL apoc.do.when(this_actors0 IS NOT NULL, " SET this_actors0.name = $this_update_actors0_name RETURN count(*) ", "", -{this:this, this_actors0:this_actors0, auth:$auth,this_update_actors0_name:$this_update_actors0_name}) YIELD value as _ +{this:this, updateMovies: $updateMovies, this_actors0:this_actors0, auth:$auth,this_update_actors0_name:$this_update_actors0_name}) YIELD value as _ WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_delete_actors0:Actor) -WHERE this_delete_actors0.name = $this_delete_actors0_name +OPTIONAL MATCH (this)<-[this_delete_actors0_relationship:ACTED_IN]-(this_delete_actors0:Actor) +WHERE this_delete_actors0.name = $updateMovies.args.delete.actors[0].where.node.name FOREACH(_ IN CASE this_delete_actors0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_delete_actors0 ) RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_actors0_name": "Actor to update", "this_update_actors0_name": "Updated name", - "this_delete_actors0_name": "Actor to delete", "auth": { "isAuthenticated": true, "jwt": {}, "roles": [] + }, + "updateMovies": { + "args": { + "delete": { + "actors": [ + { + "where": { + "node": { + "name": "Actor to delete" + } + } + } + ] + }, + "update": { + "actors": [ + { + "update": { + "node": { + "name": "Updated name" + } + }, + "where": { + "node": { + "name": "Actor to update" + } + } + } + ] + } + } } } ``` --- -### Nested delete under a nested update +## Nested delete under a nested update -**GraphQL input** +### GraphQL Input ```graphql mutation { updateMovies( where: { id: "1" } - update: { actors: { delete: { where: { name: "Actor to delete" } } } } + update: { + actors: { delete: { where: { node: { name: "Actor to delete" } } } } + } ) { movies { id @@ -677,34 +838,52 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0_delete0:Actor) -WHERE this_actors0_delete0.name = $this_actors0_delete0_name +OPTIONAL MATCH (this)<-[this_actors0_delete0_relationship:ACTED_IN]-(this_actors0_delete0:Actor) +WHERE this_actors0_delete0.name = $updateMovies.args.update.actors[0].delete[0].where.node.name FOREACH(_ IN CASE this_actors0_delete0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors0_delete0 ) RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_actors0_delete0_name": "Actor to delete" + "updateMovies": { + "args": { + "update": { + "actors": [ + { + "delete": [ + { + "where": { + "node": { + "name": "Actor to delete" + } + } + } + ] + } + ] + } + } + } } ``` --- -### Double nested delete under a nested update +## Double nested delete under a nested update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -713,8 +892,8 @@ mutation { update: { actors: { delete: { - where: { name: "Actor to delete" } - delete: { movies: { where: { id: "2" } } } + where: { node: { name: "Actor to delete" } } + delete: { movies: { where: { node: { id: "2" } } } } } } } @@ -726,33 +905,56 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.id = $this_id -WITH this -OPTIONAL MATCH (this)<-[:ACTED_IN]-(this_actors0_delete0:Actor) -WHERE this_actors0_delete0.name = $this_actors0_delete0_name +WITH this OPTIONAL MATCH (this)<-[this_actors0_delete0_relationship:ACTED_IN]-(this_actors0_delete0:Actor) +WHERE this_actors0_delete0.name = $updateMovies.args.update.actors[0].delete[0].where.node.name WITH this, this_actors0_delete0 -OPTIONAL MATCH (this_actors0_delete0)-[:ACTED_IN]->(this_actors0_delete0_movies0:Movie) -WHERE this_actors0_delete0_movies0.id = $this_actors0_delete0_movies0_id -FOREACH(_ IN CASE this_actors0_delete0_movies0 WHEN NULL THEN [] ELSE [1] END | - DETACH DELETE this_actors0_delete0_movies0 -) -FOREACH(_ IN CASE this_actors0_delete0 WHEN NULL THEN [] ELSE [1] END | - DETACH DELETE this_actors0_delete0 -) +OPTIONAL MATCH (this_actors0_delete0)-[this_actors0_delete0_movies0_relationship:ACTED_IN]->(this_actors0_delete0_movies0:Movie) +WHERE this_actors0_delete0_movies0.id = $updateMovies.args.update.actors[0].delete[0].delete.movies[0].where.node.id +FOREACH(_ IN CASE this_actors0_delete0_movies0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors0_delete0_movies0 ) +FOREACH(_ IN CASE this_actors0_delete0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_actors0_delete0 ) RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_id": "1", - "this_actors0_delete0_name": "Actor to delete", - "this_actors0_delete0_movies0_id": "2" + "updateMovies": { + "args": { + "update": { + "actors": [ + { + "delete": [ + { + "delete": { + "movies": [ + { + "where": { + "node": { + "id": "2" + } + } + } + ] + }, + "where": { + "node": { + "name": "Actor to delete" + } + } + } + ] + } + ] + } + } + } } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/pagination.md b/packages/graphql/tests/tck/tck-test-files/cypher/pagination.md index dcbdcac736..64ac7a3154 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/pagination.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/pagination.md @@ -1,10 +1,10 @@ -## Cypher pagination tests +# Cypher pagination tests -Tests for queries including reserved arguments `skip` and `limit`. +Tests for queries including reserved arguments `offset` and `limit`. Schema: -```schema +```graphql type Movie { id: ID title: String @@ -13,31 +13,31 @@ type Movie { --- -### Skipping +## Skipping -**GraphQL input** +### GraphQL Input ```graphql { - movies(options: { skip: 1 }) { + movies(options: { offset: 1 }) { title } } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) RETURN this { .title } as this -SKIP $this_skip +SKIP $this_offset ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_skip": { + "this_offset": { "high": 0, "low": 1 } @@ -46,9 +46,9 @@ SKIP $this_skip --- -### Limit +## Limit -**GraphQL input** +### GraphQL Input ```graphql { @@ -58,7 +58,7 @@ SKIP $this_skip } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -66,9 +66,9 @@ RETURN this { .title } as this LIMIT $this_limit ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_limit": { "high": 0, @@ -79,36 +79,36 @@ LIMIT $this_limit --- -### Skip + Limit +## Skip + Limit -**GraphQL input** +### GraphQL Input ```graphql { - movies(options: { limit: 1, skip: 2 }) { + movies(options: { limit: 1, offset: 2 }) { title } } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) RETURN this { .title } as this -SKIP $this_skip +SKIP $this_offset LIMIT $this_limit ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_limit": { "high": 0, "low": 1 }, - "this_skip": { + "this_offset": { "high": 0, "low": 2 } @@ -117,41 +117,41 @@ LIMIT $this_limit --- -### Skip + Limit as variables +## Skip + Limit as variables -**GraphQL input** +### GraphQL Input ```graphql -query($skip: Int, $limit: Int) { - movies(options: { limit: $limit, skip: $skip }) { +query($offset: Int, $limit: Int) { + movies(options: { limit: $limit, offset: $offset }) { title } } ``` -**GraphQL params input** +### GraphQL Params Input -```graphql-params +```json { - "skip": 0, + "offset": 0, "limit": 0 } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) RETURN this { .title } as this -SKIP $this_skip +SKIP $this_offset LIMIT $this_limit ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_skip": { + "this_offset": { "high": 0, "low": 0 }, @@ -164,47 +164,50 @@ LIMIT $this_limit --- -### Skip + Limit with other variables +## Skip + Limit with other variables -**GraphQL input** +### GraphQL Input ```graphql -query($skip: Int, $limit: Int, $title: String) { - movies(options: { limit: $limit, skip: $skip }, where: { title: $title }) { +query($offset: Int, $limit: Int, $title: String) { + movies( + options: { limit: $limit, offset: $offset } + where: { title: $title } + ) { title } } ``` -**GraphQL params input** +### GraphQL Params Input -```graphql-params +```json { "limit": 1, - "skip": 2, + "offset": 2, "title": "some title" } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.title = $this_title RETURN this { .title } as this -SKIP $this_skip +SKIP $this_offset LIMIT $this_limit ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_limit": { "high": 0, "low": 1 }, - "this_skip": { + "this_offset": { "high": 0, "low": 2 }, diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/pringles.md b/packages/graphql/tests/tck/tck-test-files/cypher/pringles.md index 81abed22ac..834950e991 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/pringles.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/pringles.md @@ -1,10 +1,10 @@ -## Cypher Create Pringles +# Cypher Create Pringles Tests operations for Pringles base case. see @ https://paper.dropbox.com/doc/Nested-mutations--A9l6qeiLzvYSxcyrii1ru0MNAg-LbUKLCTNN1nMO3Ka4VBoV Schema: -```schema +```graphql type Product { id: ID! name: String @@ -34,11 +34,11 @@ type Photo { --- -### Create Pringles +## Create Pringles Test the creation of the Pringles. -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -49,34 +49,44 @@ mutation { name: "Pringles" sizes: { create: [ - { id: 103, name: "Small" } - { id: 104, name: "Large" } + { node: { id: 103, name: "Small" } } + { node: { id: 104, name: "Large" } } ] } colors: { create: [ - { id: 100, name: "Red" } - { id: 102, name: "Green" } + { node: { id: 100, name: "Red" } } + { node: { id: 102, name: "Green" } } ] } photos: { create: [ { - id: 105 - description: "Outdoor photo" - url: "outdoor.png" + node: { + id: 105 + description: "Outdoor photo" + url: "outdoor.png" + } } { - id: 106 - description: "Green photo" - url: "g.png" - color: { connect: { where: { id: "102" } } } + node: { + id: 106 + description: "Green photo" + url: "g.png" + color: { + connect: { where: { node: { id: "102" } } } + } + } } { - id: 107 - description: "Red photo" - url: "r.png" - color: { connect: { where: { id: "100" } } } + node: { + id: 107 + description: "Red photo" + url: "r.png" + color: { + connect: { where: { node: { id: "100" } } } + } + } } ] } @@ -90,7 +100,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -99,71 +109,71 @@ CALL { SET this0.name = $this0_name WITH this0 - CREATE (this0_sizes0:Size) - SET this0_sizes0.id = $this0_sizes0_id - SET this0_sizes0.name = $this0_sizes0_name - MERGE (this0)-[:HAS_SIZE]->(this0_sizes0) + CREATE (this0_sizes0_node:Size) + SET this0_sizes0_node.id = $this0_sizes0_node_id + SET this0_sizes0_node.name = $this0_sizes0_node_name + MERGE (this0)-[:HAS_SIZE]->(this0_sizes0_node) WITH this0 - CREATE (this0_sizes1:Size) - SET this0_sizes1.id = $this0_sizes1_id - SET this0_sizes1.name = $this0_sizes1_name - MERGE (this0)-[:HAS_SIZE]->(this0_sizes1) + CREATE (this0_sizes1_node:Size) + SET this0_sizes1_node.id = $this0_sizes1_node_id + SET this0_sizes1_node.name = $this0_sizes1_node_name + MERGE (this0)-[:HAS_SIZE]->(this0_sizes1_node) WITH this0 - CREATE (this0_colors0:Color) - SET this0_colors0.id = $this0_colors0_id - SET this0_colors0.name = $this0_colors0_name - MERGE (this0)-[:HAS_COLOR]->(this0_colors0) + CREATE (this0_colors0_node:Color) + SET this0_colors0_node.id = $this0_colors0_node_id + SET this0_colors0_node.name = $this0_colors0_node_name + MERGE (this0)-[:HAS_COLOR]->(this0_colors0_node) WITH this0 - CREATE (this0_colors1:Color) - SET this0_colors1.id = $this0_colors1_id - SET this0_colors1.name = $this0_colors1_name - MERGE (this0)-[:HAS_COLOR]->(this0_colors1) + CREATE (this0_colors1_node:Color) + SET this0_colors1_node.id = $this0_colors1_node_id + SET this0_colors1_node.name = $this0_colors1_node_name + MERGE (this0)-[:HAS_COLOR]->(this0_colors1_node) WITH this0 - CREATE (this0_photos0:Photo) - SET this0_photos0.id = $this0_photos0_id - SET this0_photos0.description = $this0_photos0_description - SET this0_photos0.url = $this0_photos0_url - MERGE (this0)-[:HAS_PHOTO]->(this0_photos0) + CREATE (this0_photos0_node:Photo) + SET this0_photos0_node.id = $this0_photos0_node_id + SET this0_photos0_node.description = $this0_photos0_node_description + SET this0_photos0_node.url = $this0_photos0_node_url + MERGE (this0)-[:HAS_PHOTO]->(this0_photos0_node) WITH this0 - CREATE (this0_photos1:Photo) - SET this0_photos1.id = $this0_photos1_id - SET this0_photos1.description = $this0_photos1_description - SET this0_photos1.url = $this0_photos1_url + CREATE (this0_photos1_node:Photo) + SET this0_photos1_node.id = $this0_photos1_node_id + SET this0_photos1_node.description = $this0_photos1_node_description + SET this0_photos1_node.url = $this0_photos1_node_url - WITH this0, this0_photos1 + WITH this0, this0_photos1_node CALL { - WITH this0, this0_photos1 - OPTIONAL MATCH (this0_photos1_color_connect0:Color) - WHERE this0_photos1_color_connect0.id = $this0_photos1_color_connect0_id - FOREACH(_ IN CASE this0_photos1_color_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0_photos1)-[:OF_COLOR]->(this0_photos1_color_connect0) + WITH this0, this0_photos1_node + OPTIONAL MATCH (this0_photos1_node_color_connect0_node:Color) + WHERE this0_photos1_node_color_connect0_node.id = $this0_photos1_node_color_connect0_node_id + FOREACH(_ IN CASE this0_photos1_node_color_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos1_node)-[:OF_COLOR]->(this0_photos1_node_color_connect0_node) ) RETURN count(*) } - MERGE (this0)-[:HAS_PHOTO]->(this0_photos1) + MERGE (this0)-[:HAS_PHOTO]->(this0_photos1_node) WITH this0 - CREATE (this0_photos2:Photo) - SET this0_photos2.id = $this0_photos2_id - SET this0_photos2.description = $this0_photos2_description - SET this0_photos2.url = $this0_photos2_url + CREATE (this0_photos2_node:Photo) + SET this0_photos2_node.id = $this0_photos2_node_id + SET this0_photos2_node.description = $this0_photos2_node_description + SET this0_photos2_node.url = $this0_photos2_node_url - WITH this0, this0_photos2 + WITH this0, this0_photos2_node CALL { - WITH this0, this0_photos2 - OPTIONAL MATCH (this0_photos2_color_connect0:Color) - WHERE this0_photos2_color_connect0.id = $this0_photos2_color_connect0_id - FOREACH(_ IN CASE this0_photos2_color_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0_photos2)-[:OF_COLOR]->(this0_photos2_color_connect0) + WITH this0, this0_photos2_node + OPTIONAL MATCH (this0_photos2_node_color_connect0_node:Color) + WHERE this0_photos2_node_color_connect0_node.id = $this0_photos2_node_color_connect0_node_id + FOREACH(_ IN CASE this0_photos2_node_color_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0_photos2_node)-[:OF_COLOR]->(this0_photos2_node_color_connect0_node) ) RETURN count(*) } - MERGE (this0)-[:HAS_PHOTO]->(this0_photos2) + MERGE (this0)-[:HAS_PHOTO]->(this0_photos2_node) RETURN this0 } @@ -171,41 +181,41 @@ CALL { RETURN this0 { .id } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this0_id": "1", - "this0_name": "Pringles", - "this0_sizes0_id": "103", - "this0_sizes0_name": "Small", - "this0_sizes1_id": "104", - "this0_sizes1_name": "Large", - "this0_colors0_id": "100", - "this0_colors0_name": "Red", - "this0_colors1_id": "102", - "this0_colors1_name": "Green", - "this0_photos0_id": "105", - "this0_photos0_description": "Outdoor photo", - "this0_photos0_url": "outdoor.png", - "this0_photos1_id": "106", - "this0_photos1_description": "Green photo", - "this0_photos1_url": "g.png", - "this0_photos1_color_connect0_id": "102", - "this0_photos2_id": "107", - "this0_photos2_description": "Red photo", - "this0_photos2_url": "r.png", - "this0_photos2_color_connect0_id": "100" + "this0_id": "1", + "this0_name": "Pringles", + "this0_sizes0_node_id": "103", + "this0_sizes0_node_name": "Small", + "this0_sizes1_node_id": "104", + "this0_sizes1_node_name": "Large", + "this0_colors0_node_id": "100", + "this0_colors0_node_name": "Red", + "this0_colors1_node_id": "102", + "this0_colors1_node_name": "Green", + "this0_photos0_node_id": "105", + "this0_photos0_node_description": "Outdoor photo", + "this0_photos0_node_url": "outdoor.png", + "this0_photos1_node_id": "106", + "this0_photos1_node_description": "Green photo", + "this0_photos1_node_url": "g.png", + "this0_photos1_node_color_connect0_node_id": "102", + "this0_photos2_node_id": "107", + "this0_photos2_node_description": "Red photo", + "this0_photos2_node_url": "r.png", + "this0_photos2_node_color_connect0_node_id": "100" } ``` --- -### Update Pringles Color +## Update Pringles Color Changes the color of Pringles from Green to Light Green. -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -214,12 +224,18 @@ mutation { update: { photos: [ { - where: { description: "Green Photo" } + where: { node: { description: "Green Photo" } } update: { - description: "Light Green Photo" - color: { - connect: { where: { name: "Light Green" } } - disconnect: { where: { name: "Green" } } + node: { + description: "Light Green Photo" + color: { + connect: { + where: { node: { name: "Light Green" } } + } + disconnect: { + where: { node: { name: "Green" } } + } + } } } } @@ -233,21 +249,21 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Product) WHERE this.name = $this_name WITH this -OPTIONAL MATCH (this)-[:HAS_PHOTO]->(this_photos0:Photo) -WHERE this_photos0.description = $this_photos0_description +OPTIONAL MATCH (this)-[this_has_photo0_relationship:HAS_PHOTO]->(this_photos0:Photo) +WHERE this_photos0.description = $updateProducts.args.update.photos[0].where.node.description CALL apoc.do.when(this_photos0 IS NOT NULL, " SET this_photos0.description = $this_update_photos0_description WITH this, this_photos0 OPTIONAL MATCH (this_photos0)-[this_photos0_color0_disconnect0_rel:OF_COLOR]->(this_photos0_color0_disconnect0:Color) - WHERE this_photos0_color0_disconnect0.name = $this_photos0_color0_disconnect0_name + WHERE this_photos0_color0_disconnect0.name = $updateProducts.args.update.photos[0].update.node.color.disconnect.where.node.name FOREACH(_ IN CASE this_photos0_color0_disconnect0 WHEN NULL THEN [] ELSE [1] END | DELETE this_photos0_color0_disconnect0_rel ) @@ -255,10 +271,10 @@ CALL apoc.do.when(this_photos0 IS NOT NULL, WITH this, this_photos0 CALL { WITH this, this_photos0 - OPTIONAL MATCH (this_photos0_color0_connect0:Color) - WHERE this_photos0_color0_connect0.name = $this_photos0_color0_connect0_name - FOREACH(_ IN CASE this_photos0_color0_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this_photos0)-[:OF_COLOR]->(this_photos0_color0_connect0) + OPTIONAL MATCH (this_photos0_color0_connect0_node:Color) + WHERE this_photos0_color0_connect0_node.name = $this_photos0_color0_connect0_node_name + FOREACH(_ IN CASE this_photos0_color0_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this_photos0)-[:OF_COLOR]->(this_photos0_color0_connect0_node) ) RETURN count(*) } @@ -266,25 +282,59 @@ CALL apoc.do.when(this_photos0 IS NOT NULL, RETURN count(*) ", "", - {this:this, this_photos0:this_photos0, auth:$auth,this_update_photos0_description:$this_update_photos0_description,this_photos0_color0_disconnect0_name:$this_photos0_color0_disconnect0_name,this_photos0_color0_connect0_name:$this_photos0_color0_connect0_name}) YIELD value as _ + {this:this, updateProducts: $updateProducts, this_photos0:this_photos0, auth:$auth,this_update_photos0_description:$this_update_photos0_description,this_photos0_color0_connect0_node_name:$this_photos0_color0_connect0_node_name}) YIELD value as _ RETURN this { .id } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_name": "Pringles", - "this_photos0_description": "Green Photo", - "this_update_photos0_description": "Light Green Photo", - "this_photos0_color0_connect0_name": "Light Green", - "this_photos0_color0_disconnect0_name": "Green", - "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} - } + "this_name": "Pringles", + "this_update_photos0_description": "Light Green Photo", + "this_photos0_color0_connect0_node_name": "Light Green", + "auth": { + "isAuthenticated": true, + "roles": [], + "jwt": {} + }, + "updateProducts": { + "args": { + "update": { + "photos": [ + { + "update": { + "node": { + "color": { + "connect": { + "where": { + "node": { + "name": "Light Green" + } + } + }, + "disconnect": { + "where": { + "node": { + "name": "Green" + } + } + } + }, + "description": "Light Green Photo" + } + }, + "where": { + "node": { + "description": "Green Photo" + } + } + } + ] + } + } + } } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/projection.md b/packages/graphql/tests/tck/tck-test-files/cypher/projection.md index 8f066eb6b7..832724d09f 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/projection.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/projection.md @@ -1,8 +1,8 @@ -## Cypher Projection +# Cypher Projection Schema: -```schema +```graphql type Product { id: ID! name: String @@ -33,11 +33,11 @@ type Photo { --- -### Multi Create With Projection +## Multi Create With Projection Makes that the projection is generated correctly. Usage of `projection` var name. -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -63,7 +63,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -95,9 +95,9 @@ this1 { } AS this1 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_id": "1", "this1_id": "2", diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/simple.md b/packages/graphql/tests/tck/tck-test-files/cypher/simple.md index f277c0be2b..f44757bd21 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/simple.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/simple.md @@ -1,10 +1,10 @@ -## Simple Cypher tests +# Simple Cypher tests Simple queries with arguments and variables. Schema: -```schema +```graphql type Movie { id: ID title: String @@ -13,9 +13,9 @@ type Movie { --- -### Single selection, Movie by title +## Single selection, Movie by title -**GraphQL input** +### GraphQL Input ```graphql { @@ -25,7 +25,7 @@ type Movie { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -33,17 +33,17 @@ WHERE this.title = $this_title RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_title": "River Runs Through It, A" } ``` --- -### Multi selection, Movie by title +## Multi selection, Movie by title -**GraphQL input** +### GraphQL Input ```graphql { @@ -54,7 +54,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -62,17 +62,17 @@ WHERE this.title = $this_title RETURN this { .id, .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_title": "River Runs Through It, A" } ``` --- -### Multi selection, Movie by title via variable +## Multi selection, Movie by title via variable -**GraphQL input** +### GraphQL Input ```graphql query($title: String) { @@ -83,13 +83,13 @@ query($title: String) { } ``` -**GraphQL params input** +### GraphQL Params Input -```graphql-params +```json { "title": "some title" } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -97,8 +97,8 @@ WHERE this.title = $this_title RETURN this { .id, .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_title": "some title" } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/sort.md b/packages/graphql/tests/tck/tck-test-files/cypher/sort.md index 95c2f2206a..9b0a2ab1f5 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/sort.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/sort.md @@ -1,10 +1,10 @@ -## Cypher sort tests +# Cypher sort tests Tests for queries including reserved arguments `sort`. Schema: -```schema +```graphql type Movie { id: ID title: String @@ -19,9 +19,9 @@ type Genre { --- -### Simple Sort +## Simple Sort -**GraphQL input** +### GraphQL Input ```graphql { @@ -31,7 +31,7 @@ type Genre { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -40,17 +40,17 @@ ORDER BY this.id DESC RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Multi Sort +## Multi Sort -**GraphQL input** +### GraphQL Input ```graphql { @@ -60,7 +60,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -69,24 +69,24 @@ ORDER BY this.id DESC, this.title ASC RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Sort with skip limit & with other variables +## Sort with offset limit & with other variables -**GraphQL input** +### GraphQL Input ```graphql -query($title: String, $skip: Int, $limit: Int) { +query($title: String, $offset: Int, $limit: Int) { movies( options: { sort: [{ id: DESC }, { title: ASC }] - skip: $skip + offset: $offset limit: $limit } where: { title: $title } @@ -96,17 +96,17 @@ query($title: String, $skip: Int, $limit: Int) { } ``` -**GraphQL params input** +### GraphQL Params Input -```graphql-params +```json { "limit": 2, - "skip": 1, + "offset": 1, "title": "some title" } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -114,19 +114,19 @@ WHERE this.title = $this_title WITH this ORDER BY this.id DESC, this.title ASC RETURN this { .title } as this -SKIP $this_skip +SKIP $this_offset LIMIT $this_limit ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_limit": { "high": 0, "low": 2 }, - "this_skip": { + "this_offset": { "high": 0, "low": 1 }, @@ -136,9 +136,9 @@ LIMIT $this_limit --- -### Nested Sort DESC +## Nested Sort DESC -**GraphQL input** +### GraphQL Input ```graphql { @@ -150,7 +150,7 @@ LIMIT $this_limit } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -159,17 +159,17 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` --- -### Nested Sort ASC +## Nested Sort ASC -**GraphQL input** +### GraphQL Input ```graphql { @@ -181,7 +181,7 @@ RETURN this { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -190,9 +190,9 @@ RETURN this { } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json {} ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/types/bigint.md b/packages/graphql/tests/tck/tck-test-files/cypher/types/bigint.md index 55a87d26f9..290748cba1 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/types/bigint.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/types/bigint.md @@ -1,10 +1,10 @@ -## Cypher BigInt +# Cypher BigInt Tests BigInt scalar type. Schema: -```schema +```graphql type File { name: String! size: BigInt! @@ -13,9 +13,9 @@ type File { --- -### Querying with native BigInt in AST +## Querying with native BigInt in AST -**GraphQL input** +### GraphQL Input ```graphql query { @@ -25,7 +25,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:File) @@ -33,9 +33,9 @@ WHERE this.size = $this_size RETURN this { .name } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_size": { "low": -1, @@ -46,9 +46,9 @@ RETURN this { .name } as this --- -### Querying with BigInt as string in AST +## Querying with BigInt as string in AST -**GraphQL input** +### GraphQL Input ```graphql query { @@ -58,7 +58,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:File) @@ -66,9 +66,9 @@ WHERE this.size = $this_size RETURN this { .name } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_size": { "low": -1, @@ -79,9 +79,9 @@ RETURN this { .name } as this --- -### Querying with BigInt as string in variables +## Querying with BigInt as string in variables -**GraphQL input** +### GraphQL Input ```graphql query Files($size: BigInt) { @@ -91,15 +91,15 @@ query Files($size: BigInt) { } ``` -**GraphQL params** +### GraphQL Params Input -```graphql-params +```json { "size": "9223372036854775807" } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:File) @@ -107,9 +107,9 @@ WHERE this.size = $this_size RETURN this { .name } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_size": { "low": -1, diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/types/date.md b/packages/graphql/tests/tck/tck-test-files/cypher/types/date.md index b97bd41271..7a099fbdf0 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/types/date.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/types/date.md @@ -1,10 +1,10 @@ -## Cypher Date +# Cypher Date Tests Date operations. ⚠ The string in params is actually an object but the test suite turns it into a string when calling `JSON.stringify`. Schema: -```schema +```graphql type Movie { id: ID date: Date @@ -13,9 +13,9 @@ type Movie { --- -### Simple Read +## Simple Read -**GraphQL input** +### GraphQL Input ```graphql query { @@ -25,7 +25,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -33,9 +33,9 @@ WHERE this.date = $this_date RETURN this { .date } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_date": { "day": 1, @@ -47,9 +47,9 @@ RETURN this { .date } as this --- -### GTE Read +## GTE Read -**GraphQL input** +### GraphQL Input ```graphql query { @@ -59,7 +59,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -67,9 +67,9 @@ WHERE this.date >= $this_date_GTE RETURN this { .date } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_date_GTE": { "day": 8, @@ -81,9 +81,9 @@ RETURN this { .date } as this --- -### Simple Create +## Simple Create -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -95,7 +95,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -106,9 +106,9 @@ CALL { RETURN this0 { .date } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_date": { "day": 1, @@ -120,9 +120,9 @@ RETURN this0 { .date } AS this0 --- -### Simple Update +## Simple Update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -135,7 +135,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -143,9 +143,9 @@ SET this.date = $this_update_date RETURN this { .id, .date } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_update_date": { "day": 1, diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/types/datetime.md b/packages/graphql/tests/tck/tck-test-files/cypher/types/datetime.md index 4f73ee9d23..6b4064a7bc 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/types/datetime.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/types/datetime.md @@ -1,10 +1,10 @@ -## Cypher DateTime +# Cypher DateTime Tests DateTime operations. ⚠ The string in params is actually an object but the test suite turns it into a string when calling `JSON.stringify`. Schema: -```schema +```graphql type Movie { id: ID datetime: DateTime @@ -13,9 +13,9 @@ type Movie { --- -### Simple Read +## Simple Read -**GraphQL input** +### GraphQL Input ```graphql query { @@ -25,7 +25,7 @@ query { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -33,9 +33,9 @@ WHERE this.datetime = $this_datetime RETURN this { datetime: apoc.date.convertFormat(toString(this.datetime), "iso_zoned_date_time", "iso_offset_date_time") } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_datetime": { "day": 1, @@ -53,9 +53,9 @@ RETURN this { datetime: apoc.date.convertFormat(toString(this.datetime), "iso_zo --- -### Simple Create +## Simple Create -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -67,7 +67,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -78,9 +78,9 @@ CALL { RETURN this0 { datetime: apoc.date.convertFormat(toString(this0.datetime), "iso_zoned_date_time", "iso_offset_date_time") } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this0_datetime": { "day": 1, @@ -98,9 +98,9 @@ RETURN this0 { datetime: apoc.date.convertFormat(toString(this0.datetime), "iso_ --- -### Simple Update +## Simple Update -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -113,7 +113,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -121,9 +121,9 @@ SET this.datetime = $this_update_datetime RETURN this { .id, datetime: apoc.date.convertFormat(toString(this.datetime), "iso_zoned_date_time", "iso_offset_date_time") } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_update_datetime": { "day": 1, diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/types/point.md b/packages/graphql/tests/tck/tck-test-files/cypher/types/point.md index 581c166fa2..91fd4b8f1e 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/types/point.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/types/point.md @@ -1,10 +1,10 @@ -## Cypher Points +# Cypher Points Tests Cypher generation for spatial types. Point and CartesianPoint are processed equivalently when it comes to Cypher translation, so only one needs to be extensively tested. Schema: -```schema +```graphql type PointContainer { id: String point: Point @@ -13,9 +13,9 @@ type PointContainer { --- -### Simple Point query +## Simple Point query -**GraphQL input** +### GraphQL Input ```graphql { @@ -29,7 +29,7 @@ type PointContainer { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -37,22 +37,22 @@ WHERE this.point = point($this_point) RETURN this { point: { point: this.point, crs: this.point.crs } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point": { - "longitude": 1, - "latitude": 2 - } + "this_point": { + "longitude": 1, + "latitude": 2 + } } ``` --- -### Simple Point NOT query +## Simple Point NOT query -**GraphQL input** +### GraphQL Input ```graphql { @@ -65,7 +65,7 @@ RETURN this { point: { point: this.point, crs: this.point.crs } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -73,22 +73,22 @@ WHERE (NOT this.point = point($this_point_NOT)) RETURN this { point: { point: this.point } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_NOT": { - "longitude": 1, - "latitude": 2 - } + "this_point_NOT": { + "longitude": 1, + "latitude": 2 + } } ``` --- -### Simple Point IN query +## Simple Point IN query -**GraphQL input** +### GraphQL Input ```graphql { @@ -102,7 +102,7 @@ RETURN this { point: { point: this.point } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -110,22 +110,24 @@ WHERE this.point IN [p in $this_point_IN | point(p)] RETURN this { point: { point: this.point, crs: this.point.crs } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_IN": [{ - "longitude": 1, - "latitude": 2 - }] + "this_point_IN": [ + { + "longitude": 1, + "latitude": 2 + } + ] } ``` --- -### Simple Point NOT IN query +## Simple Point NOT IN query -**GraphQL input** +### GraphQL Input ```graphql { @@ -141,7 +143,7 @@ RETURN this { point: { point: this.point, crs: this.point.crs } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -149,22 +151,24 @@ WHERE (NOT this.point IN [p in $this_point_NOT_IN | point(p)]) RETURN this { point: { point: this.point, crs: this.point.crs } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_NOT_IN": [{ - "longitude": 1, - "latitude": 2 - }] + "this_point_NOT_IN": [ + { + "longitude": 1, + "latitude": 2 + } + ] } ``` --- -### Simple Point LT query +## Simple Point LT query -**GraphQL input** +### GraphQL Input ```graphql { @@ -184,7 +188,7 @@ RETURN this { point: { point: this.point, crs: this.point.crs } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -192,25 +196,25 @@ WHERE distance(this.point, point($this_point_LT.point)) < $this_point_LT.distanc RETURN this { point: { point: this.point } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_LT": { - "point": { - "longitude": 1.1, - "latitude": 2.2 - }, - "distance": 3.3 - } + "this_point_LT": { + "point": { + "longitude": 1.1, + "latitude": 2.2 + }, + "distance": 3.3 + } } ``` --- -### Simple Point LTE query +## Simple Point LTE query -**GraphQL input** +### GraphQL Input ```graphql { @@ -230,7 +234,7 @@ RETURN this { point: { point: this.point } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -238,25 +242,25 @@ WHERE distance(this.point, point($this_point_LTE.point)) <= $this_point_LTE.dist RETURN this { point: { point: this.point } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_LTE": { - "point": { - "longitude": 1.1, - "latitude": 2.2 - }, - "distance": 3.3 - } + "this_point_LTE": { + "point": { + "longitude": 1.1, + "latitude": 2.2 + }, + "distance": 3.3 + } } ``` --- -### Simple Point GT query +## Simple Point GT query -**GraphQL input** +### GraphQL Input ```graphql { @@ -276,7 +280,7 @@ RETURN this { point: { point: this.point } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -284,25 +288,25 @@ WHERE distance(this.point, point($this_point_GT.point)) > $this_point_GT.distanc RETURN this { point: { point: this.point } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_GT": { - "point": { - "longitude": 1.1, - "latitude": 2.2 - }, - "distance": 3.3 - } + "this_point_GT": { + "point": { + "longitude": 1.1, + "latitude": 2.2 + }, + "distance": 3.3 + } } ``` --- -### Simple Point GTE query +## Simple Point GTE query -**GraphQL input** +### GraphQL Input ```graphql { @@ -322,7 +326,7 @@ RETURN this { point: { point: this.point } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -330,25 +334,25 @@ WHERE distance(this.point, point($this_point_GTE.point)) >= $this_point_GTE.dist RETURN this { point: { point: this.point } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_GTE": { - "point": { - "longitude": 1.1, - "latitude": 2.2 - }, - "distance": 3.3 - } + "this_point_GTE": { + "point": { + "longitude": 1.1, + "latitude": 2.2 + }, + "distance": 3.3 + } } ``` --- -### Simple Point DISTANCE query +## Simple Point DISTANCE query -**GraphQL input** +### GraphQL Input ```graphql { @@ -368,7 +372,7 @@ RETURN this { point: { point: this.point } } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -376,25 +380,25 @@ WHERE distance(this.point, point($this_point_DISTANCE.point)) = $this_point_DIST RETURN this { point: { point: this.point } } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_point_DISTANCE": { - "point": { - "longitude": 1.1, - "latitude": 2.2 - }, - "distance": 3.3 - } + "this_point_DISTANCE": { + "point": { + "longitude": 1.1, + "latitude": 2.2 + }, + "distance": 3.3 + } } ``` --- -### Simple Point create mutation +## Simple Point create mutation -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -410,7 +414,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -423,22 +427,22 @@ RETURN this0 { point: { point: this0.point, crs: this0.point.crs } } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this0_point": { - "longitude": 1, - "latitude": 2 - } + "this0_point": { + "longitude": 1, + "latitude": 2 + } } ``` --- -### Simple Point update mutation +## Simple Point update mutation -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -457,7 +461,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -466,14 +470,14 @@ SET this.point = point($this_update_point) RETURN this { point: { point: this.point, crs: this.point.crs } } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_id": "id", - "this_update_point": { - "longitude": 1, - "latitude": 2 - } + "this_id": "id", + "this_update_point": { + "longitude": 1, + "latitude": 2 + } } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/types/points.md b/packages/graphql/tests/tck/tck-test-files/cypher/types/points.md index a1ebe64605..2fd726727d 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/types/points.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/types/points.md @@ -1,10 +1,10 @@ -## Cypher Points +# Cypher Points Tests Cypher generation for arrays of spatial types. [Point] and [CartesianPoint] are processed equivalently when it comes to Cypher translation, so only one needs to be extensively tested. Schema: -```schema +```graphql type PointContainer { id: String points: [Point] @@ -13,9 +13,9 @@ type PointContainer { --- -### Simple Points query +## Simple Points query -**GraphQL input** +### GraphQL Input ```graphql { @@ -29,7 +29,7 @@ type PointContainer { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -37,22 +37,24 @@ WHERE this.points = [p in $this_points | point(p)] RETURN this { points: [p in this.points | { point:p, crs: p.crs }] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_points": [{ - "longitude": 1, - "latitude": 2 - }] + "this_points": [ + { + "longitude": 1, + "latitude": 2 + } + ] } ``` --- -### Simple Points NOT query +## Simple Points NOT query -**GraphQL input** +### GraphQL Input ```graphql { @@ -67,7 +69,7 @@ RETURN this { points: [p in this.points | { point:p, crs: p.crs }] } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -75,22 +77,24 @@ WHERE (NOT this.points = [p in $this_points_NOT | point(p)]) RETURN this { points: [p in this.points | { point:p }] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_points_NOT": [{ - "longitude": 1, - "latitude": 2 - }] + "this_points_NOT": [ + { + "longitude": 1, + "latitude": 2 + } + ] } ``` --- -### Simple Points INCLUDES query +## Simple Points INCLUDES query -**GraphQL input** +### GraphQL Input ```graphql { @@ -106,7 +110,7 @@ RETURN this { points: [p in this.points | { point:p }] } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -114,22 +118,22 @@ WHERE point($this_points_INCLUDES) IN this.points RETURN this { points: [p in this.points | { point:p, crs: p.crs }] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_points_INCLUDES": { - "longitude": 1, - "latitude": 2 - } + "this_points_INCLUDES": { + "longitude": 1, + "latitude": 2 + } } ``` --- -### Simple Points NOT INCLUDES query +## Simple Points NOT INCLUDES query -**GraphQL input** +### GraphQL Input ```graphql { @@ -145,7 +149,7 @@ RETURN this { points: [p in this.points | { point:p, crs: p.crs }] } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -153,22 +157,22 @@ WHERE (NOT point($this_points_NOT_INCLUDES) IN this.points) RETURN this { points: [p in this.points | { point:p, crs: p.crs }] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_points_NOT_INCLUDES": { - "longitude": 1, - "latitude": 2 - } + "this_points_NOT_INCLUDES": { + "longitude": 1, + "latitude": 2 + } } ``` --- -### Simple Points create mutation +## Simple Points create mutation -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -186,7 +190,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -198,22 +202,24 @@ CALL { RETURN this0 { points: [p in this0.points | { point:p, crs: p.crs }] } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this0_points": [{ - "longitude": 1, - "latitude": 2 - }] + "this0_points": [ + { + "longitude": 1, + "latitude": 2 + } + ] } ``` --- -### Simple Points update mutation +## Simple Points update mutation -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -232,7 +238,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:PointContainer) @@ -241,14 +247,16 @@ SET this.points = [p in $this_update_points | point(p)] RETURN this { points: [p in this.points | { point:p, crs: p.crs }] } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_id": "id", - "this_update_points": [{ - "longitude": 1, - "latitude": 2 - }] + "this_id": "id", + "this_update_points": [ + { + "longitude": 1, + "latitude": 2 + } + ] } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/union.md b/packages/graphql/tests/tck/tck-test-files/cypher/union.md index b711efc1f3..f9760ca77d 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/union.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/union.md @@ -1,20 +1,21 @@ -## Cypher Union +# Cypher Union Tests for queries on Unions. Schema: -```schema +```graphql union Search = Movie | Genre -type Genre @auth(rules: [ - { - operations: [READ], - allow: { - name: "$jwt.jwtAllowedNamesExample" - } - } -]) { +type Genre + @auth( + rules: [ + { + operations: [READ] + allow: { name: "$jwt.jwtAllowedNamesExample" } + } + ] + ) { name: String } @@ -26,17 +27,16 @@ type Movie { --- -### Read Unions +## Read Unions -**GraphQL input** +### GraphQL Input ```graphql { movies(where: { title: "some title" }) { search( - Movie: { title: "The Matrix" } - Genre: { name: "Horror" } - options: { skip: 1, limit: 10 } + where: { Movie: { title: "The Matrix" }, Genre: { name: "Horror" } } + options: { offset: 1, limit: 10 } ) { ... on Movie { title @@ -49,7 +49,7 @@ type Movie { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -75,13 +75,13 @@ RETURN this { .title } ] ) - ] [1..10] + ] [1..11] } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_title": "some title", "this_search_Genre_auth_allow0_name": ["Horror"], @@ -90,9 +90,9 @@ RETURN this { } ``` -**JWT Object** +### JWT Object -```jwt +```json { "jwtAllowedNamesExample": ["Horror"] } @@ -100,9 +100,9 @@ RETURN this { --- -### Create Unions +## Create Unions from create mutation -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -110,7 +110,9 @@ mutation { input: [ { title: "some movie" - search_Genre: { create: [{ name: "some genre" }] } + search: { + Genre: { create: [{ node: { name: "some genre" } }] } + } } ] ) { @@ -121,7 +123,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -129,9 +131,9 @@ CALL { SET this0.title = $this0_title WITH this0 - CREATE (this0_search_Genre0:Genre) - SET this0_search_Genre0.name = $this0_search_Genre0_name - MERGE (this0)-[:SEARCH]->(this0_search_Genre0) + CREATE (this0_search_Genre0_node:Genre) + SET this0_search_Genre0_node.name = $this0_search_Genre0_node_name + MERGE (this0)-[:SEARCH]->(this0_search_Genre0_node) RETURN this0 } @@ -141,20 +143,56 @@ RETURN this0 { } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params + +```json +{ + "this0_title": "some movie", + "this0_search_Genre0_node_name": "some genre" +} +``` + +--- + +## Create Unions from update create(top-level) + +### GraphQL Input + +```graphql +mutation { + updateMovies( + create: { search: { Genre: [{ node: { name: "some genre" } }] } } + ) { + movies { + title + } + } +} +``` + +### Expected Cypher Output -```cypher-params +```cypher +MATCH (this:Movie) +CREATE (this_create_search_Genre0_node:Genre) +SET this_create_search_Genre0_node.name = $this_create_search_Genre0_node_name +MERGE (this)-[:SEARCH]->(this_create_search_Genre0_node) +RETURN this { .title } AS this +``` + +### Expected Cypher Params + +```json { - "this0_title": "some movie", - "this0_search_Genre0_name": "some genre" + "this_create_search_Genre0_node_name": "some genre" } ``` --- -### Connect Unions +## Connect Unions (in create) -**GraphQL input** +### GraphQL Input ```graphql mutation { @@ -162,7 +200,11 @@ mutation { input: [ { title: "some movie" - search_Genre: { connect: [{ where: { name: "some genre" } }] } + search: { + Genre: { + connect: [{ where: { node: { name: "some genre" } } }] + } + } } ] ) { @@ -173,7 +215,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher CALL { @@ -183,10 +225,10 @@ CALL { WITH this0 CALL { WITH this0 - OPTIONAL MATCH (this0_search_Genre_connect0:Genre) - WHERE this0_search_Genre_connect0.name = $this0_search_Genre_connect0_name - FOREACH(_ IN CASE this0_search_Genre_connect0 WHEN NULL THEN [] ELSE [1] END | - MERGE (this0)-[:SEARCH]->(this0_search_Genre_connect0) + OPTIONAL MATCH (this0_search_Genre_connect0_node:Genre) + WHERE this0_search_Genre_connect0_node.name = $this0_search_Genre_connect0_node_name + FOREACH(_ IN CASE this0_search_Genre_connect0_node WHEN NULL THEN [] ELSE [1] END | + MERGE (this0)-[:SEARCH]->(this0_search_Genre_connect0_node) ) RETURN count(*) } @@ -197,30 +239,31 @@ CALL { RETURN this0 { .title } AS this0 ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this0_title": "some movie", - "this0_search_Genre_connect0_name": "some genre" + "this0_title": "some movie", + "this0_search_Genre_connect0_node_name": "some genre" } - ``` --- -### Update Unions +## Update Unions -**GraphQL input** +### GraphQL Input ```graphql mutation { updateMovies( where: { title: "some movie" } update: { - search_Genre: { - where: { name: "some genre" } - update: { name: "some new genre" } + search: { + Genre: { + where: { node: { name: "some genre" } } + update: { node: { name: "some new genre" } } + } } } ) { @@ -231,47 +274,72 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) WHERE this.title = $this_title WITH this -OPTIONAL MATCH (this)-[:SEARCH]->(this_search_Genre0:Genre) -WHERE this_search_Genre0.name = $this_search_Genre0_name -CALL apoc.do.when(this_search_Genre0 IS NOT NULL, " SET this_search_Genre0.name = $this_update_search_Genre0_name RETURN count(*) ", "", {this:this, this_search_Genre0:this_search_Genre0, auth:$auth,this_update_search_Genre0_name:$this_update_search_Genre0_name}) YIELD value as _ +OPTIONAL MATCH (this)-[this_search0_relationship:SEARCH]->(this_search_Genre0:Genre) +WHERE this_search_Genre0.name = $updateMovies.args.update.search.Genre[0].where.node.name +CALL apoc.do.when(this_search_Genre0 IS NOT NULL, " SET this_search_Genre0.name = $this_update_search_Genre0_name RETURN count(*) ", "", {this:this, updateMovies: $updateMovies, this_search_Genre0:this_search_Genre0, auth:$auth,this_update_search_Genre0_name:$this_update_search_Genre0_name}) YIELD value as _ RETURN this { .title } AS this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { - "this_title": "some movie", - "this_search_Genre0_name": "some genre", - "this_update_search_Genre0_name": "some new genre", - "auth": { - "isAuthenticated": true, - "roles": [], - "jwt": {} - } + "this_title": "some movie", + "this_update_search_Genre0_name": "some new genre", + "auth": { + "isAuthenticated": true, + "roles": [], + "jwt": {} + }, + "updateMovies": { + "args": { + "update": { + "search": { + "Genre": [ + { + "update": { + "node": { + "name": "some new genre" + } + }, + "where": { + "node": { + "name": "some genre" + } + } + } + ] + } + } + } + } } ``` --- -### Disconnect Unions +## Disconnect Unions (in update) -**GraphQL input** +### GraphQL Input ```graphql mutation { updateMovies( where: { title: "some movie" } update: { - search_Genre: { disconnect: [{ where: { name: "some genre" } }] } + search: { + Genre: { + disconnect: [{ where: { node: { name: "some genre" } } }] + } + } } ) { movies { @@ -281,7 +349,7 @@ mutation { } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -289,7 +357,7 @@ WHERE this.title = $this_title WITH this OPTIONAL MATCH (this)-[this_search_Genre0_disconnect0_rel:SEARCH]->(this_search_Genre0_disconnect0:Genre) -WHERE this_search_Genre0_disconnect0.name = $this_search_Genre0_disconnect0_name +WHERE this_search_Genre0_disconnect0.name = $updateMovies.args.update.search.Genre[0].disconnect[0].where.node.name FOREACH(_ IN CASE this_search_Genre0_disconnect0 WHEN NULL THEN [] ELSE [1] END | DELETE this_search_Genre0_disconnect0_rel ) @@ -297,11 +365,199 @@ FOREACH(_ IN CASE this_search_Genre0_disconnect0 WHEN NULL THEN [] ELSE [1] END RETURN this { .title } AS this ``` -**Expected Cypher params** +### Expected Cypher Params + +```json +{ + "this_title": "some movie", + "updateMovies": { + "args": { + "update": { + "search": { + "Genre": [ + { + "disconnect": [ + { + "where": { + "node": { + "name": "some genre" + } + } + } + ] + } + ] + } + } + } + } +} +``` + +--- + +## Disconnect Unions + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "some movie" } + disconnect: { + search: { Genre: { where: { node: { name: "some genre" } } } } + } + ) { + movies { + title + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title + +WITH this +OPTIONAL MATCH (this)-[this_disconnect_search_Genre0_rel:SEARCH]->(this_disconnect_search_Genre0:Genre) +WHERE this_disconnect_search_Genre0.name = $updateMovies.args.disconnect.search.Genre[0].where.node.name +FOREACH(_ IN CASE this_disconnect_search_Genre0 WHEN NULL THEN [] ELSE [1] END | + DELETE this_disconnect_search_Genre0_rel +) + +RETURN this { .title } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "some movie", + "updateMovies": { + "args": { + "disconnect": { + "search": { + "Genre": [ + { + "where": { + "node": { + "name": "some genre" + } + } + } + ] + } + } + } + } +} +``` + +--- + +## Connect Unions (in update) + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "some movie" } + connect: { + search: { Genre: { where: { node: { name: "some genre" } } } } + } + ) { + movies { + title + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +CALL { + WITH this + OPTIONAL MATCH (this_connect_search_Genre0_node:Genre) + WHERE this_connect_search_Genre0_node.name = $this_connect_search_Genre0_node_name + FOREACH(_ IN CASE this_connect_search_Genre0_node WHEN NULL THEN [] ELSE [1] END | MERGE (this)-[:SEARCH]->(this_connect_search_Genre0_node) ) + RETURN count(*) +} +RETURN this { .title } AS this +``` + +### Expected Cypher Params + +```json +{ + "this_title": "some movie", + "this_connect_search_Genre0_node_name": "some genre" +} +``` + +--- + +## Delete Unions (from update) + +### GraphQL Input + +```graphql +mutation { + updateMovies( + where: { title: "some movie" } + delete: { + search: { Genre: { where: { node: { name: "some genre" } } } } + } + ) { + movies { + title + } + } +} +``` + +### Expected Cypher Output + +```cypher +MATCH (this:Movie) +WHERE this.title = $this_title +WITH this +OPTIONAL MATCH (this)-[this_delete_search_Genre0_relationship:SEARCH]->(this_delete_search_Genre0:Genre) +WHERE this_delete_search_Genre0.name = $updateMovies.args.delete.search.Genre[0].where.node.name +FOREACH(_ IN CASE this_delete_search_Genre0 WHEN NULL THEN [] ELSE [1] END | DETACH DELETE this_delete_search_Genre0 ) +RETURN this { .title } AS this +``` + +### Expected Cypher Params -```cypher-params +```json { - "this_title": "some movie", - "this_search_Genre0_disconnect0_name": "some genre" + "this_title": "some movie", + "updateMovies": { + "args": { + "delete": { + "search": { + "Genre": [ + { + "where": { + "node": { + "name": "some genre" + } + } + } + ] + } + } + } + } } ``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/cypher/where.md b/packages/graphql/tests/tck/tck-test-files/cypher/where.md index 48ec2d752a..46e8697bf0 100644 --- a/packages/graphql/tests/tck/tck-test-files/cypher/where.md +++ b/packages/graphql/tests/tck/tck-test-files/cypher/where.md @@ -1,10 +1,10 @@ -## Cypher WHERE +# Cypher WHERE Tests for queries using options.where Schema: -```schema +```graphql type Actor { name: String movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) @@ -20,9 +20,9 @@ type Movie { --- -### Simple +## Simple -**GraphQL input** +### GraphQL Input ```graphql query($title: String, $isFavorite: Boolean) { @@ -32,11 +32,13 @@ query($title: String, $isFavorite: Boolean) { } ``` -```graphql-params +### GraphQL Params Input + +```json { "title": "some title", "isFavorite": true } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -45,9 +47,9 @@ AND this.isFavorite = $this_isFavorite RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_title": "some title", "this_isFavorite": true @@ -56,9 +58,9 @@ RETURN this { .title } as this --- -### Simple AND +## Simple AND -**GraphQL input** +### GraphQL Input ```graphql { @@ -68,7 +70,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -76,9 +78,9 @@ WHERE (this.title = $this_AND_title) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_AND_title": "some title" } @@ -86,9 +88,9 @@ RETURN this { .title } as this --- -### Nested AND +## Nested AND -**GraphQL input** +### GraphQL Input ```graphql { @@ -98,7 +100,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -106,9 +108,9 @@ WHERE ((this.title = $this_AND_AND_title)) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_AND_AND_title": "some title" } @@ -116,9 +118,9 @@ RETURN this { .title } as this --- -### Super Nested AND +## Super Nested AND -**GraphQL input** +### GraphQL Input ```graphql { @@ -128,7 +130,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -136,9 +138,9 @@ WHERE (((this.title = $this_AND_AND_AND_title))) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_AND_AND_AND_title": "some title" } @@ -146,9 +148,9 @@ RETURN this { .title } as this --- -### Simple OR +## Simple OR -**GraphQL input** +### GraphQL Input ```graphql { @@ -158,7 +160,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -166,9 +168,9 @@ WHERE (this.title = $this_OR_title) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_OR_title": "some title" } @@ -176,9 +178,9 @@ RETURN this { .title } as this --- -### Nested OR +## Nested OR -**GraphQL input** +### GraphQL Input ```graphql { @@ -188,7 +190,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -196,9 +198,9 @@ WHERE ((this.title = $this_OR_OR_title)) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_OR_OR_title": "some title" } @@ -206,9 +208,9 @@ RETURN this { .title } as this --- -### Super Nested OR +## Super Nested OR -**GraphQL input** +### GraphQL Input ```graphql { @@ -218,7 +220,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -226,9 +228,9 @@ WHERE (((this.title = $this_OR_OR_OR_title))) RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_OR_OR_OR_title": "some title" } @@ -236,9 +238,9 @@ RETURN this { .title } as this --- -### Simple IN +## Simple IN -**GraphQL input** +### GraphQL Input ```graphql { @@ -248,7 +250,7 @@ RETURN this { .title } as this } ``` -**Expected Cypher output** +### Expected Cypher Output ```cypher MATCH (this:Movie) @@ -256,9 +258,9 @@ WHERE this.title IN $this_title_IN RETURN this { .title } as this ``` -**Expected Cypher params** +### Expected Cypher Params -```cypher-params +```json { "this_title_IN": ["some title"] } diff --git a/packages/graphql/tests/tck/tck-test-files/schema/arrays.md b/packages/graphql/tests/tck/tck-test-files/schema/arrays.md index 78c456352f..a04c12f609 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/arrays.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/arrays.md @@ -1,14 +1,14 @@ -## Schema Arrays +# Schema Arrays Tests that the provided typeDefs return the correct schema. --- -### Arrays +## Arrays -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { id: ID! ratings: [Float!]! @@ -16,96 +16,107 @@ type Movie { } ``` -**Output** - -```schema-output +### Output +```graphql type Movie { - id: ID! - ratings: [Float!]! - averageRating: Float! + id: ID! + ratings: [Float!]! + averageRating: Float! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID! - ratings: [Float!]! - averageRating: Float! + id: ID! + ratings: [Float!]! + averageRating: Float! } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - averageRating: SortDirection + id: SortDirection + averageRating: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - ratings: [Float!] - ratings_INCLUDES: Float - ratings_NOT: [Float!] - ratings_NOT_INCLUDES: Float - averageRating: Float - averageRating_IN: [Float] - averageRating_NOT: Float - averageRating_NOT_IN: [Float] - averageRating_LT: Float - averageRating_LTE: Float - averageRating_GT: Float - averageRating_GTE: Float - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + ratings: [Float!] + ratings_INCLUDES: Float + ratings_NOT: [Float!] + ratings_NOT_INCLUDES: Float + averageRating: Float + averageRating_IN: [Float] + averageRating_NOT: Float + averageRating_NOT_IN: [Float] + averageRating_LT: Float + averageRating_LTE: Float + averageRating_GT: Float + averageRating_GTE: Float + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID - ratings: [Float!] - averageRating: Float + id: ID + ratings: [Float!] + averageRating: Float } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/comments.md b/packages/graphql/tests/tck/tck-test-files/schema/comments.md index 52994dc8d3..8388b6ec65 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/comments.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/comments.md @@ -1,178 +1,199 @@ -## Schema Simple +# Schema Simple Tests that the provided typeDefs return the correct schema. --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql "A custom scalar." scalar CustomScalar "An enumeration of movie genres." enum Genre { - ACTION - DRAMA - ROMANCE + ACTION + DRAMA + ROMANCE } """ A type describing a movie. """ type Movie { - id: ID - "The number of actors who acted in the movie." - actorCount: Int - """ - The average rating for the movie. - """ - averageRating: Float - """ - Is the movie active? - - This is measured based on annual profit. - """ - isActive: Boolean - genre: Genre - customScalar: CustomScalar + id: ID + "The number of actors who acted in the movie." + actorCount: Int + """ + The average rating for the movie. + """ + averageRating: Float + """ + Is the movie active? + + This is measured based on annual profit. + """ + isActive: Boolean + genre: Genre + customScalar: CustomScalar } ``` -**Output** +### Output -```schema-output - -"""A custom scalar.""" +```graphql +""" +A custom scalar. +""" scalar CustomScalar -"""An enumeration of movie genres.""" +""" +An enumeration of movie genres. +""" enum Genre { - ACTION - DRAMA - ROMANCE + ACTION + DRAMA + ROMANCE } -"""A type describing a movie.""" +""" +A type describing a movie. +""" type Movie { - id: ID - """The number of actors who acted in the movie.""" - actorCount: Int - """The average rating for the movie.""" - averageRating: Float - """ - Is the movie active? - - This is measured based on annual profit. - """ - isActive: Boolean - customScalar: CustomScalar - genre: Genre + id: ID + """ + The number of actors who acted in the movie. + """ + actorCount: Int + """ + The average rating for the movie. + """ + averageRating: Float + """ + Is the movie active? + + This is measured based on annual profit. + """ + isActive: Boolean + customScalar: CustomScalar + genre: Genre } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID - actorCount: Int - averageRating: Float - isActive: Boolean - customScalar: CustomScalar - genre: Genre + id: ID + actorCount: Int + averageRating: Float + isActive: Boolean + customScalar: CustomScalar + genre: Genre } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - actorCount: SortDirection - averageRating: SortDirection - customScalar: SortDirection - genre: SortDirection - isActive: SortDirection + id: SortDirection + actorCount: SortDirection + averageRating: SortDirection + customScalar: SortDirection + genre: SortDirection + isActive: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - actorCount: Int - actorCount_IN: [Int] - actorCount_NOT: Int - actorCount_NOT_IN: [Int] - actorCount_LT: Int - actorCount_LTE: Int - actorCount_GT: Int - actorCount_GTE: Int - averageRating: Float - averageRating_IN: [Float] - averageRating_NOT: Float - averageRating_NOT_IN: [Float] - customScalar: CustomScalar - genre: Genre - genre_IN: [Genre] - genre_NOT: Genre - genre_NOT_IN: [Genre] - averageRating_LT: Float - averageRating_LTE: Float - averageRating_GT: Float - averageRating_GTE: Float - isActive: Boolean - isActive_NOT: Boolean - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + actorCount: Int + actorCount_IN: [Int] + actorCount_NOT: Int + actorCount_NOT_IN: [Int] + actorCount_LT: Int + actorCount_LTE: Int + actorCount_GT: Int + actorCount_GTE: Int + averageRating: Float + averageRating_IN: [Float] + averageRating_NOT: Float + averageRating_NOT_IN: [Float] + customScalar: CustomScalar + genre: Genre + genre_IN: [Genre] + genre_NOT: Genre + genre_NOT_IN: [Genre] + averageRating_LT: Float + averageRating_LTE: Float + averageRating_GT: Float + averageRating_GTE: Float + isActive: Boolean + isActive_NOT: Boolean + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID - actorCount: Int - averageRating: Float - isActive: Boolean - customScalar: CustomScalar - genre: Genre + id: ID + actorCount: Int + averageRating: Float + isActive: Boolean + customScalar: CustomScalar + genre: Genre } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md b/packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md new file mode 100644 index 0000000000..0bcfddfdb9 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/enums.md @@ -0,0 +1,425 @@ +# Schema -> Connections -> Enums + +Tests that enums work correctly as relationship properties. + +--- + +## Enum Relationship Properties + +### TypeDefs + +```graphql +type Actor { + name: String! + movies: [Movie] + @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") +} + +type Movie { + title: String! + actors: [Actor]! + @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") +} + +enum RoleType { + LEADING + SUPPORTING +} + +interface ActedIn { + roleType: RoleType! +} +``` + +### Output + +```graphql +enum RoleType { + LEADING + SUPPORTING +} + +interface ActedIn { + roleType: RoleType! +} + +input ActedInCreateInput { + roleType: RoleType! +} + +input ActedInSort { + roleType: SortDirection +} + +input ActedInUpdateInput { + roleType: RoleType +} + +input ActedInWhere { + OR: [ActedInWhere!] + AND: [ActedInWhere!] + roleType: RoleType + roleType_IN: [RoleType] + roleType_NOT: RoleType + roleType_NOT_IN: [RoleType] +} + +type Actor { + name: String! + movies(where: MovieWhere, options: MovieOptions): [Movie] + moviesConnection( + after: String + first: Int + where: ActorMoviesConnectionWhere + sort: [ActorMoviesConnectionSort!] + ): ActorMoviesConnection! +} + +input ActorConnectInput { + movies: [ActorMoviesConnectFieldInput!] +} + +input ActorCreateInput { + name: String! + movies: ActorMoviesFieldInput +} + +input ActorDeleteInput { + movies: [ActorMoviesDeleteFieldInput!] +} + +input ActorMoviesDeleteFieldInput { + delete: MovieDeleteInput + where: ActorMoviesConnectionWhere +} + +input ActorMoviesDisconnectFieldInput { + disconnect: MovieDisconnectInput + where: ActorMoviesConnectionWhere +} + +input ActorDisconnectInput { + movies: [ActorMoviesDisconnectFieldInput!] +} + +input MovieConnectWhere { + node: MovieWhere! +} + +input ActorMoviesConnectFieldInput { + where: MovieConnectWhere + connect: [MovieConnectInput!] + edge: ActedInCreateInput! +} + +type ActorMoviesConnection { + edges: [ActorMoviesRelationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input ActorMoviesConnectionSort { + node: MovieSort + edge: ActedInSort +} + +input ActorMoviesConnectionWhere { + AND: [ActorMoviesConnectionWhere!] + OR: [ActorMoviesConnectionWhere!] + edge: ActedInWhere + edge_NOT: ActedInWhere + node: MovieWhere + node_NOT: MovieWhere +} + +input ActorMoviesCreateFieldInput { + node: MovieCreateInput! + edge: ActedInCreateInput! +} + +input ActorMoviesFieldInput { + create: [ActorMoviesCreateFieldInput!] + connect: [ActorMoviesConnectFieldInput!] +} + +type ActorMoviesRelationship implements ActedIn { + cursor: String! + node: Movie! + roleType: RoleType! +} + +input ActorMoviesUpdateConnectionInput { + node: MovieUpdateInput + edge: ActedInUpdateInput +} + +input ActorMoviesUpdateFieldInput { + where: ActorMoviesConnectionWhere + update: ActorMoviesUpdateConnectionInput + connect: [ActorMoviesConnectFieldInput!] + disconnect: [ActorMoviesDisconnectFieldInput!] + create: [ActorMoviesCreateFieldInput!] + delete: [ActorMoviesDeleteFieldInput!] +} + +input ActorOptions { + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] + limit: Int + offset: Int +} + +input ActorRelationInput { + movies: [ActorMoviesCreateFieldInput!] +} + +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" +input ActorSort { + name: SortDirection +} + +input ActorUpdateInput { + name: String + movies: [ActorMoviesUpdateFieldInput!] +} + +input ActorWhere { + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + movies: MovieWhere + movies_NOT: MovieWhere + moviesConnection: ActorMoviesConnectionWhere + moviesConnection_NOT: ActorMoviesConnectionWhere +} + +type CreateActorsMutationResponse { + actors: [Actor!]! +} + +type CreateMoviesMutationResponse { + movies: [Movie!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Movie { + title: String! + actors(where: ActorWhere, options: ActorOptions): [Actor]! + actorsConnection( + after: String + first: Int + where: MovieActorsConnectionWhere + sort: [MovieActorsConnectionSort!] + ): MovieActorsConnection! +} + +input ActorConnectWhere { + node: ActorWhere! +} + +input MovieActorsConnectFieldInput { + where: ActorConnectWhere + connect: [ActorConnectInput!] + edge: ActedInCreateInput! +} + +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! + totalCount: Int! + pageInfo: PageInfo! +} + +input MovieActorsConnectionSort { + node: ActorSort + edge: ActedInSort +} + +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + edge: ActedInWhere + edge_NOT: ActedInWhere + node: ActorWhere + node_NOT: ActorWhere +} + +input MovieActorsCreateFieldInput { + node: ActorCreateInput! + edge: ActedInCreateInput! +} + +input MovieActorsFieldInput { + create: [MovieActorsCreateFieldInput!] + connect: [MovieActorsConnectFieldInput!] +} + +type MovieActorsRelationship implements ActedIn { + cursor: String! + node: Actor! + roleType: RoleType! +} + +input MovieActorsUpdateConnectionInput { + node: ActorUpdateInput + edge: ActedInUpdateInput +} + +input MovieActorsUpdateFieldInput { + where: MovieActorsConnectionWhere + update: MovieActorsUpdateConnectionInput + connect: [MovieActorsConnectFieldInput!] + disconnect: [MovieActorsDisconnectFieldInput!] + create: [MovieActorsCreateFieldInput!] + delete: [MovieActorsDeleteFieldInput!] +} + +input MovieConnectInput { + actors: [MovieActorsConnectFieldInput!] +} + +input MovieCreateInput { + title: String! + actors: MovieActorsFieldInput +} + +input MovieDeleteInput { + actors: [MovieActorsDeleteFieldInput!] +} + +input MovieActorsDeleteFieldInput { + delete: ActorDeleteInput + where: MovieActorsConnectionWhere +} + +input MovieActorsDisconnectFieldInput { + disconnect: ActorDisconnectInput + where: MovieActorsConnectionWhere +} + +input MovieDisconnectInput { + actors: [MovieActorsDisconnectFieldInput!] +} + +input MovieOptions { + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int +} + +input MovieRelationInput { + actors: [MovieActorsCreateFieldInput!] +} + +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" +input MovieSort { + title: SortDirection +} + +input MovieUpdateInput { + title: String + actors: [MovieActorsUpdateFieldInput!] +} + +input MovieWhere { + OR: [MovieWhere!] + AND: [MovieWhere!] + title: String + title_NOT: String + title_IN: [String] + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String + actors: ActorWhere + actors_NOT: ActorWhere + actorsConnection: MovieActorsConnectionWhere + actorsConnection_NOT: MovieActorsConnectionWhere +} + +type Mutation { + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors(where: ActorWhere, delete: ActorDeleteInput): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + connect: ActorConnectInput + disconnect: ActorDisconnectInput + create: ActorRelationInput + delete: ActorDeleteInput + ): UpdateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} + +type Query { + actors(where: ActorWhere, options: ActorOptions): [Actor!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! +} + +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC +} + +type UpdateActorsMutationResponse { + actors: [Actor!]! +} + +type UpdateMoviesMutationResponse { + movies: [Movie!]! +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md b/packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md new file mode 100644 index 0000000000..bbc013b8e7 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/sort.md @@ -0,0 +1,330 @@ +# Sort + +Tests sort argument on connection fields + +--- + +## sort argument is not present when nothing to sort + +### TypeDefs + +```graphql +type Node1 { + property: String! + relatedTo: [Node2!]! @relationship(type: "RELATED_TO", direction: OUT) +} + +type Node2 { + relatedTo: [Node1!]! @relationship(type: "RELATED_TO", direction: OUT) +} +``` + +### Output + +```graphql +type CreateNode1sMutationResponse { + node1s: [Node1!]! +} + +type CreateNode2sMutationResponse { + node2s: [Node2!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Mutation { + createNode1s(input: [Node1CreateInput!]!): CreateNode1sMutationResponse! + deleteNode1s(where: Node1Where, delete: Node1DeleteInput): DeleteInfo! + updateNode1s( + where: Node1Where + update: Node1UpdateInput + connect: Node1ConnectInput + disconnect: Node1DisconnectInput + create: Node1RelationInput + delete: Node1DeleteInput + ): UpdateNode1sMutationResponse! + createNode2s(input: [Node2CreateInput!]!): CreateNode2sMutationResponse! + deleteNode2s(where: Node2Where, delete: Node2DeleteInput): DeleteInfo! + updateNode2s( + where: Node2Where + update: Node2UpdateInput + connect: Node2ConnectInput + disconnect: Node2DisconnectInput + create: Node2RelationInput + delete: Node2DeleteInput + ): UpdateNode2sMutationResponse! +} + +type Node1 { + property: String! + relatedTo(where: Node2Where, options: Node2Options): [Node2!]! + relatedToConnection( + where: Node1RelatedToConnectionWhere + first: Int + after: String + ): Node1RelatedToConnection! +} + +input Node1ConnectInput { + relatedTo: [Node1RelatedToConnectFieldInput!] +} + +input Node1ConnectWhere { + node: Node1Where! +} + +input Node1CreateInput { + property: String! + relatedTo: Node1RelatedToFieldInput +} + +input Node1DeleteInput { + relatedTo: [Node1RelatedToDeleteFieldInput!] +} + +input Node1DisconnectInput { + relatedTo: [Node1RelatedToDisconnectFieldInput!] +} + +input Node1Options { + # Specify one or more Node1Sort objects to sort Node1s by. The sorts will be applied in the order in which they are arranged in the array. + sort: [Node1Sort] + limit: Int + offset: Int +} + +input Node1RelatedToConnectFieldInput { + where: Node2ConnectWhere + connect: [Node2ConnectInput!] +} + +type Node1RelatedToConnection { + edges: [Node1RelatedToRelationship!]! + totalCount: Int! + pageInfo: PageInfo! +} + +input Node1RelatedToConnectionWhere { + AND: [Node1RelatedToConnectionWhere!] + OR: [Node1RelatedToConnectionWhere!] + node: Node2Where + node_NOT: Node2Where +} + +input Node1RelatedToCreateFieldInput { + node: Node2CreateInput! +} + +input Node1RelatedToDeleteFieldInput { + where: Node1RelatedToConnectionWhere + delete: Node2DeleteInput +} + +input Node1RelatedToDisconnectFieldInput { + where: Node1RelatedToConnectionWhere + disconnect: Node2DisconnectInput +} + +input Node1RelatedToFieldInput { + create: [Node1RelatedToCreateFieldInput!] + connect: [Node1RelatedToConnectFieldInput!] +} + +type Node1RelatedToRelationship { + cursor: String! + node: Node2! +} + +input Node1RelatedToUpdateConnectionInput { + node: Node2UpdateInput +} + +input Node1RelatedToUpdateFieldInput { + where: Node1RelatedToConnectionWhere + update: Node1RelatedToUpdateConnectionInput + connect: [Node1RelatedToConnectFieldInput!] + disconnect: [Node1RelatedToDisconnectFieldInput!] + create: [Node1RelatedToCreateFieldInput!] + delete: [Node1RelatedToDeleteFieldInput!] +} + +input Node1RelationInput { + relatedTo: [Node1RelatedToCreateFieldInput!] +} + +# Fields to sort Node1s by. The order in which sorts are applied is not guaranteed when specifying many fields in one Node1Sort object. +input Node1Sort { + property: SortDirection +} + +input Node1UpdateInput { + property: String + relatedTo: [Node1RelatedToUpdateFieldInput!] +} + +input Node1Where { + OR: [Node1Where!] + AND: [Node1Where!] + property: String + property_NOT: String + property_IN: [String] + property_NOT_IN: [String] + property_CONTAINS: String + property_NOT_CONTAINS: String + property_STARTS_WITH: String + property_NOT_STARTS_WITH: String + property_ENDS_WITH: String + property_NOT_ENDS_WITH: String + relatedTo: Node2Where + relatedTo_NOT: Node2Where + relatedToConnection: Node1RelatedToConnectionWhere + relatedToConnection_NOT: Node1RelatedToConnectionWhere +} + +type Node2 { + relatedTo(where: Node1Where, options: Node1Options): [Node1!]! + relatedToConnection( + where: Node2RelatedToConnectionWhere + first: Int + after: String + sort: [Node2RelatedToConnectionSort!] + ): Node2RelatedToConnection! +} + +input Node2ConnectInput { + relatedTo: [Node2RelatedToConnectFieldInput!] +} + +input Node2ConnectWhere { + node: Node2Where! +} + +input Node2CreateInput { + relatedTo: Node2RelatedToFieldInput +} + +input Node2DeleteInput { + relatedTo: [Node2RelatedToDeleteFieldInput!] +} + +input Node2DisconnectInput { + relatedTo: [Node2RelatedToDisconnectFieldInput!] +} + +input Node2Options { + limit: Int + offset: Int +} + +input Node2RelatedToConnectFieldInput { + where: Node1ConnectWhere + connect: [Node1ConnectInput!] +} + +type Node2RelatedToConnection { + edges: [Node2RelatedToRelationship!]! + totalCount: Int! + pageInfo: PageInfo! +} + +input Node2RelatedToConnectionSort { + node: Node1Sort +} + +input Node2RelatedToConnectionWhere { + AND: [Node2RelatedToConnectionWhere!] + OR: [Node2RelatedToConnectionWhere!] + node: Node1Where + node_NOT: Node1Where +} + +input Node2RelatedToCreateFieldInput { + node: Node1CreateInput! +} + +input Node2RelatedToDeleteFieldInput { + where: Node2RelatedToConnectionWhere + delete: Node1DeleteInput +} + +input Node2RelatedToDisconnectFieldInput { + where: Node2RelatedToConnectionWhere + disconnect: Node1DisconnectInput +} + +input Node2RelatedToFieldInput { + create: [Node2RelatedToCreateFieldInput!] + connect: [Node2RelatedToConnectFieldInput!] +} + +type Node2RelatedToRelationship { + cursor: String! + node: Node1! +} + +input Node2RelatedToUpdateConnectionInput { + node: Node1UpdateInput +} + +input Node2RelatedToUpdateFieldInput { + where: Node2RelatedToConnectionWhere + update: Node2RelatedToUpdateConnectionInput + connect: [Node2RelatedToConnectFieldInput!] + disconnect: [Node2RelatedToDisconnectFieldInput!] + create: [Node2RelatedToCreateFieldInput!] + delete: [Node2RelatedToDeleteFieldInput!] +} + +input Node2RelationInput { + relatedTo: [Node2RelatedToCreateFieldInput!] +} + +input Node2UpdateInput { + relatedTo: [Node2RelatedToUpdateFieldInput!] +} + +input Node2Where { + OR: [Node2Where!] + AND: [Node2Where!] + relatedTo: Node1Where + relatedTo_NOT: Node1Where + relatedToConnection: Node2RelatedToConnectionWhere + relatedToConnection_NOT: Node2RelatedToConnectionWhere +} + +# Pagination information (Relay) +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} + +type Query { + node1s(options: Node1Options, where: Node1Where): [Node1!]! + node1sCount(where: Node1Where): Int! + node2s(options: Node2Options, where: Node2Where): [Node2!]! + node2sCount(where: Node2Where): Int! +} + +enum SortDirection { + # Sort by field values in ascending order. + ASC + + # Sort by field values in descending order. + DESC +} + +type UpdateNode1sMutationResponse { + node1s: [Node1!]! +} + +type UpdateNode2sMutationResponse { + node2s: [Node2!]! +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md b/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md new file mode 100644 index 0000000000..f09795e8a6 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/schema/connections/unions.md @@ -0,0 +1,695 @@ +# Schema -> Connections -> Unions + +Tests that the provided typeDefs return the correct schema (with relationships). + +--- + +## Relationship Properties + +### TypeDefs + +```graphql +union Publication = Book | Journal + +type Author { + name: String! + publications: [Publication] + @relationship(type: "WROTE", direction: OUT, properties: "Wrote") +} + +type Book { + title: String! + author: [Author!]! + @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +type Journal { + subject: String! + author: [Author!]! + @relationship(type: "WROTE", direction: IN, properties: "Wrote") +} + +interface Wrote { + words: Int! +} +``` + +### Output + +```graphql +type Author { + name: String! + publications(options: QueryOptions, where: PublicationWhere): [Publication] + publicationsConnection( + where: AuthorPublicationsConnectionWhere + ): AuthorPublicationsConnection! +} + +input AuthorConnectInput { + publications: AuthorPublicationsConnectInput +} + +input AuthorConnectWhere { + node: AuthorWhere! +} + +input AuthorCreateInput { + name: String! + publications: AuthorPublicationsCreateInput +} + +input AuthorDeleteInput { + publications: AuthorPublicationsDeleteInput +} + +input AuthorDisconnectInput { + publications: AuthorPublicationsDisconnectInput +} + +input AuthorOptions { + """ + Specify one or more AuthorSort objects to sort Authors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [AuthorSort] + limit: Int + offset: Int +} + +input AuthorPublicationsBookConnectFieldInput { + where: BookConnectWhere + connect: [BookConnectInput!] + edge: WroteCreateInput! +} + +input AuthorPublicationsBookConnectionWhere { + node: BookWhere + node_NOT: BookWhere + AND: [AuthorPublicationsBookConnectionWhere!] + OR: [AuthorPublicationsBookConnectionWhere!] + edge: WroteWhere + edge_NOT: WroteWhere +} + +input AuthorPublicationsBookCreateFieldInput { + node: BookCreateInput! + edge: WroteCreateInput! +} + +input AuthorPublicationsBookDeleteFieldInput { + where: AuthorPublicationsBookConnectionWhere + delete: BookDeleteInput +} + +input AuthorPublicationsBookDisconnectFieldInput { + where: AuthorPublicationsBookConnectionWhere + disconnect: BookDisconnectInput +} + +input AuthorPublicationsBookFieldInput { + create: [AuthorPublicationsBookCreateFieldInput!] + connect: [AuthorPublicationsBookConnectFieldInput!] +} + +input AuthorPublicationsBookUpdateConnectionInput { + edge: WroteUpdateInput + node: BookUpdateInput +} + +input AuthorPublicationsBookUpdateFieldInput { + where: AuthorPublicationsBookConnectionWhere + update: AuthorPublicationsBookUpdateConnectionInput + connect: [AuthorPublicationsBookConnectFieldInput!] + disconnect: [AuthorPublicationsBookDisconnectFieldInput!] + create: [AuthorPublicationsBookCreateFieldInput!] + delete: [AuthorPublicationsBookDeleteFieldInput!] +} + +input AuthorPublicationsConnectInput { + Book: [AuthorPublicationsBookConnectFieldInput!] + Journal: [AuthorPublicationsJournalConnectFieldInput!] +} + +type AuthorPublicationsConnection { + edges: [AuthorPublicationsRelationship!]! + totalCount: Int! + pageInfo: PageInfo! +} + +input AuthorPublicationsConnectionBookWhere { + OR: [AuthorPublicationsConnectionBookWhere] + AND: [AuthorPublicationsConnectionBookWhere] + node: BookWhere + node_NOT: BookWhere + edge: WroteWhere + edge_NOT: WroteWhere +} + +input AuthorPublicationsConnectionJournalWhere { + OR: [AuthorPublicationsConnectionJournalWhere] + AND: [AuthorPublicationsConnectionJournalWhere] + node: JournalWhere + node_NOT: JournalWhere + edge: WroteWhere + edge_NOT: WroteWhere +} + +input AuthorPublicationsConnectionWhere { + Book: AuthorPublicationsConnectionBookWhere + Journal: AuthorPublicationsConnectionJournalWhere +} + +input AuthorPublicationsCreateFieldInput { + Book: [AuthorPublicationsBookCreateFieldInput!] + Journal: [AuthorPublicationsJournalCreateFieldInput!] +} + +input AuthorPublicationsCreateInput { + Book: AuthorPublicationsBookFieldInput + Journal: AuthorPublicationsJournalFieldInput +} + +input AuthorPublicationsDeleteInput { + Book: [AuthorPublicationsBookDeleteFieldInput!] + Journal: [AuthorPublicationsJournalDeleteFieldInput!] +} + +input AuthorPublicationsDisconnectInput { + Book: [AuthorPublicationsBookDisconnectFieldInput!] + Journal: [AuthorPublicationsJournalDisconnectFieldInput!] +} + +input AuthorPublicationsJournalConnectFieldInput { + where: JournalConnectWhere + connect: [JournalConnectInput!] + edge: WroteCreateInput! +} + +input AuthorPublicationsJournalConnectionWhere { + node: JournalWhere + node_NOT: JournalWhere + AND: [AuthorPublicationsJournalConnectionWhere!] + OR: [AuthorPublicationsJournalConnectionWhere!] + edge: WroteWhere + edge_NOT: WroteWhere +} + +input AuthorPublicationsJournalCreateFieldInput { + node: JournalCreateInput! + edge: WroteCreateInput! +} + +input AuthorPublicationsJournalDeleteFieldInput { + where: AuthorPublicationsJournalConnectionWhere + delete: JournalDeleteInput +} + +input AuthorPublicationsJournalDisconnectFieldInput { + where: AuthorPublicationsJournalConnectionWhere + disconnect: JournalDisconnectInput +} + +input AuthorPublicationsJournalFieldInput { + create: [AuthorPublicationsJournalCreateFieldInput!] + connect: [AuthorPublicationsJournalConnectFieldInput!] +} + +input AuthorPublicationsJournalUpdateConnectionInput { + edge: WroteUpdateInput + node: JournalUpdateInput +} + +input AuthorPublicationsJournalUpdateFieldInput { + where: AuthorPublicationsJournalConnectionWhere + update: AuthorPublicationsJournalUpdateConnectionInput + connect: [AuthorPublicationsJournalConnectFieldInput!] + disconnect: [AuthorPublicationsJournalDisconnectFieldInput!] + create: [AuthorPublicationsJournalCreateFieldInput!] + delete: [AuthorPublicationsJournalDeleteFieldInput!] +} + +type AuthorPublicationsRelationship implements Wrote { + cursor: String! + node: Publication! + words: Int! +} + +input AuthorPublicationsUpdateInput { + Book: [AuthorPublicationsBookUpdateFieldInput!] + Journal: [AuthorPublicationsJournalUpdateFieldInput!] +} + +input AuthorRelationInput { + publications: AuthorPublicationsCreateFieldInput +} + +""" +Fields to sort Authors by. The order in which sorts are applied is not guaranteed when specifying many fields in one AuthorSort object. +""" +input AuthorSort { + name: SortDirection +} + +input AuthorUpdateInput { + name: String + publications: AuthorPublicationsUpdateInput +} + +input AuthorWhere { + OR: [AuthorWhere!] + AND: [AuthorWhere!] + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + publicationsConnection: AuthorPublicationsConnectionWhere + publicationsConnection_NOT: AuthorPublicationsConnectionWhere +} + +type Book { + title: String! + author(where: AuthorWhere, options: AuthorOptions): [Author!]! + authorConnection( + where: BookAuthorConnectionWhere + sort: [BookAuthorConnectionSort!] + first: Int + after: String + ): BookAuthorConnection! +} + +input BookAuthorConnectFieldInput { + where: AuthorConnectWhere + connect: [AuthorConnectInput!] + edge: WroteCreateInput! +} + +type BookAuthorConnection { + edges: [BookAuthorRelationship!]! + totalCount: Int! + pageInfo: PageInfo! +} + +input BookAuthorConnectionSort { + node: AuthorSort + edge: WroteSort +} + +input BookAuthorConnectionWhere { + AND: [BookAuthorConnectionWhere!] + OR: [BookAuthorConnectionWhere!] + edge: WroteWhere + edge_NOT: WroteWhere + node: AuthorWhere + node_NOT: AuthorWhere +} + +input BookAuthorCreateFieldInput { + node: AuthorCreateInput! + edge: WroteCreateInput! +} + +input BookAuthorDeleteFieldInput { + where: BookAuthorConnectionWhere + delete: AuthorDeleteInput +} + +input BookAuthorDisconnectFieldInput { + where: BookAuthorConnectionWhere + disconnect: AuthorDisconnectInput +} + +input BookAuthorFieldInput { + create: [BookAuthorCreateFieldInput!] + connect: [BookAuthorConnectFieldInput!] +} + +type BookAuthorRelationship implements Wrote { + cursor: String! + node: Author! + words: Int! +} + +input BookAuthorUpdateConnectionInput { + node: AuthorUpdateInput + edge: WroteUpdateInput +} + +input BookAuthorUpdateFieldInput { + where: BookAuthorConnectionWhere + update: BookAuthorUpdateConnectionInput + connect: [BookAuthorConnectFieldInput!] + disconnect: [BookAuthorDisconnectFieldInput!] + create: [BookAuthorCreateFieldInput!] + delete: [BookAuthorDeleteFieldInput!] +} + +input BookConnectInput { + author: [BookAuthorConnectFieldInput!] +} + +input BookConnectWhere { + node: BookWhere! +} + +input BookCreateInput { + title: String! + author: BookAuthorFieldInput +} + +input BookDeleteInput { + author: [BookAuthorDeleteFieldInput!] +} + +input BookDisconnectInput { + author: [BookAuthorDisconnectFieldInput!] +} + +input BookOptions { + """ + Specify one or more BookSort objects to sort Books by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [BookSort] + limit: Int + offset: Int +} + +input BookRelationInput { + author: [BookAuthorCreateFieldInput!] +} + +""" +Fields to sort Books by. The order in which sorts are applied is not guaranteed when specifying many fields in one BookSort object. +""" +input BookSort { + title: SortDirection +} + +input BookUpdateInput { + title: String + author: [BookAuthorUpdateFieldInput!] +} + +input BookWhere { + OR: [BookWhere!] + AND: [BookWhere!] + title: String + title_NOT: String + title_IN: [String] + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String + author: AuthorWhere + author_NOT: AuthorWhere + authorConnection: BookAuthorConnectionWhere + authorConnection_NOT: BookAuthorConnectionWhere +} + +type CreateAuthorsMutationResponse { + authors: [Author!]! +} + +type CreateBooksMutationResponse { + books: [Book!]! +} + +type CreateJournalsMutationResponse { + journals: [Journal!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Journal { + subject: String! + author(where: AuthorWhere, options: AuthorOptions): [Author!]! + authorConnection( + where: JournalAuthorConnectionWhere + sort: [JournalAuthorConnectionSort!] + first: Int + after: String + ): JournalAuthorConnection! +} + +input JournalAuthorConnectFieldInput { + where: AuthorConnectWhere + connect: [AuthorConnectInput!] + edge: WroteCreateInput! +} + +type JournalAuthorConnection { + edges: [JournalAuthorRelationship!]! + totalCount: Int! + pageInfo: PageInfo! +} + +input JournalAuthorConnectionSort { + node: AuthorSort + edge: WroteSort +} + +input JournalAuthorConnectionWhere { + AND: [JournalAuthorConnectionWhere!] + OR: [JournalAuthorConnectionWhere!] + edge: WroteWhere + edge_NOT: WroteWhere + node: AuthorWhere + node_NOT: AuthorWhere +} + +input JournalAuthorCreateFieldInput { + node: AuthorCreateInput! + edge: WroteCreateInput! +} + +input JournalAuthorDeleteFieldInput { + where: JournalAuthorConnectionWhere + delete: AuthorDeleteInput +} + +input JournalAuthorDisconnectFieldInput { + where: JournalAuthorConnectionWhere + disconnect: AuthorDisconnectInput +} + +input JournalAuthorFieldInput { + create: [JournalAuthorCreateFieldInput!] + connect: [JournalAuthorConnectFieldInput!] +} + +type JournalAuthorRelationship implements Wrote { + cursor: String! + node: Author! + words: Int! +} + +input JournalAuthorUpdateConnectionInput { + node: AuthorUpdateInput + edge: WroteUpdateInput +} + +input JournalAuthorUpdateFieldInput { + where: JournalAuthorConnectionWhere + update: JournalAuthorUpdateConnectionInput + connect: [JournalAuthorConnectFieldInput!] + disconnect: [JournalAuthorDisconnectFieldInput!] + create: [JournalAuthorCreateFieldInput!] + delete: [JournalAuthorDeleteFieldInput!] +} + +input JournalConnectInput { + author: [JournalAuthorConnectFieldInput!] +} + +input JournalConnectWhere { + node: JournalWhere! +} + +input JournalCreateInput { + subject: String! + author: JournalAuthorFieldInput +} + +input JournalDeleteInput { + author: [JournalAuthorDeleteFieldInput!] +} + +input JournalDisconnectInput { + author: [JournalAuthorDisconnectFieldInput!] +} + +input JournalOptions { + """ + Specify one or more JournalSort objects to sort Journals by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [JournalSort] + limit: Int + offset: Int +} + +input JournalRelationInput { + author: [JournalAuthorCreateFieldInput!] +} + +""" +Fields to sort Journals by. The order in which sorts are applied is not guaranteed when specifying many fields in one JournalSort object. +""" +input JournalSort { + subject: SortDirection +} + +input JournalUpdateInput { + subject: String + author: [JournalAuthorUpdateFieldInput!] +} + +input JournalWhere { + OR: [JournalWhere!] + AND: [JournalWhere!] + subject: String + subject_NOT: String + subject_IN: [String] + subject_NOT_IN: [String] + subject_CONTAINS: String + subject_NOT_CONTAINS: String + subject_STARTS_WITH: String + subject_NOT_STARTS_WITH: String + subject_ENDS_WITH: String + subject_NOT_ENDS_WITH: String + author: AuthorWhere + author_NOT: AuthorWhere + authorConnection: JournalAuthorConnectionWhere + authorConnection_NOT: JournalAuthorConnectionWhere +} + +type Mutation { + createAuthors(input: [AuthorCreateInput!]!): CreateAuthorsMutationResponse! + deleteAuthors(where: AuthorWhere, delete: AuthorDeleteInput): DeleteInfo! + updateAuthors( + where: AuthorWhere + update: AuthorUpdateInput + connect: AuthorConnectInput + disconnect: AuthorDisconnectInput + create: AuthorRelationInput + delete: AuthorDeleteInput + ): UpdateAuthorsMutationResponse! + createBooks(input: [BookCreateInput!]!): CreateBooksMutationResponse! + deleteBooks(where: BookWhere, delete: BookDeleteInput): DeleteInfo! + updateBooks( + where: BookWhere + update: BookUpdateInput + connect: BookConnectInput + disconnect: BookDisconnectInput + create: BookRelationInput + delete: BookDeleteInput + ): UpdateBooksMutationResponse! + createJournals( + input: [JournalCreateInput!]! + ): CreateJournalsMutationResponse! + deleteJournals(where: JournalWhere, delete: JournalDeleteInput): DeleteInfo! + updateJournals( + where: JournalWhere + update: JournalUpdateInput + connect: JournalConnectInput + disconnect: JournalDisconnectInput + create: JournalRelationInput + delete: JournalDeleteInput + ): UpdateJournalsMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} + +union Publication = Book | Journal + +input PublicationWhere { + Book: BookWhere + Journal: JournalWhere +} + +type Query { + authors(where: AuthorWhere, options: AuthorOptions): [Author!]! + books(where: BookWhere, options: BookOptions): [Book!]! + journals(where: JournalWhere, options: JournalOptions): [Journal!]! + authorsCount(where: AuthorWhere): Int! + booksCount(where: BookWhere): Int! + journalsCount(where: JournalWhere): Int! +} + +input QueryOptions { + offset: Int + limit: Int +} + +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC +} + +type UpdateAuthorsMutationResponse { + authors: [Author!]! +} + +type UpdateBooksMutationResponse { + books: [Book!]! +} + +type UpdateJournalsMutationResponse { + journals: [Journal!]! +} + +interface Wrote { + words: Int! +} + +input WroteCreateInput { + words: Int! +} + +input WroteSort { + words: SortDirection +} + +input WroteUpdateInput { + words: Int +} + +input WroteWhere { + OR: [WroteWhere!] + AND: [WroteWhere!] + words: Int + words_NOT: Int + words_IN: [Int] + words_NOT_IN: [Int] + words_LT: Int + words_LTE: Int + words_GT: Int + words_GTE: Int +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md b/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md index 4ed88d19e4..093ba42989 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/custom-mutations.md @@ -1,14 +1,14 @@ -## Schema Custom Mutations +# Schema Custom Mutations Tests that the provided typeDefs return the correct schema. --- -### Custom Mutations +## Custom Mutations -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql input ExampleInput { id: ID } @@ -18,103 +18,114 @@ type Movie { } type Query { - testQuery(input: ExampleInput): String - testCypherQuery(input: ExampleInput): String @cypher(statement: "") + testQuery(input: ExampleInput): String + testCypherQuery(input: ExampleInput): String @cypher(statement: "") } type Mutation { - testMutation(input: ExampleInput): String - testCypherMutation(input: ExampleInput): String @cypher(statement: "") + testMutation(input: ExampleInput): String + testCypherMutation(input: ExampleInput): String @cypher(statement: "") } type Subscription { - testSubscription(input: ExampleInput): String + testSubscription(input: ExampleInput): String } ``` -**Output** - -```schema-output +### Output +```graphql input ExampleInput { id: ID } type Movie { - id: ID + id: ID } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID + id: ID } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection + id: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID + id: ID } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! - testMutation(input: ExampleInput): String - testCypherMutation(input: ExampleInput): String + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! + testMutation(input: ExampleInput): String + testCypherMutation(input: ExampleInput): String } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! - testQuery(input: ExampleInput): String - testCypherQuery(input: ExampleInput): String + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + testQuery(input: ExampleInput): String + testCypherQuery(input: ExampleInput): String } type Subscription { - testSubscription(input: ExampleInput): String + testSubscription(input: ExampleInput): String } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md b/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md index ab1d1d9b0e..43c4617ad1 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directive-preserve.md @@ -1,96 +1,129 @@ -## Schema Preserve Directives +# Schema Preserve Directives Tests that the provided typeDefs return the correct schema(with preserving directives). --- -### Preserve Directives +## Preserve Directives -**TypeDefs** +### TypeDefs -```typedefs-input -directive @preservedTopLevel(string: String, int: Int, float: Float, boolean: Boolean) on OBJECT -directive @preservedFieldLevel(string: String, int: Int, float: Float, boolean: Boolean) on FIELD_DEFINITION +```graphql +directive @preservedTopLevel( + string: String + int: Int + float: Float + boolean: Boolean +) on OBJECT +directive @preservedFieldLevel( + string: String + int: Int + float: Float + boolean: Boolean +) on FIELD_DEFINITION type Movie @preservedTopLevel { - id: ID @preservedFieldLevel(string: "str", int: 12, float: 1.2, boolean: true) + id: ID + @preservedFieldLevel(string: "str", int: 12, float: 1.2, boolean: true) } ``` -**Output** - -```schema-output - -directive @preservedTopLevel(string: String, int: Int, float: Float, boolean: Boolean) on OBJECT -directive @preservedFieldLevel(string: String, int: Int, float: Float, boolean: Boolean) on FIELD_DEFINITION +### Output + +```graphql +directive @preservedTopLevel( + string: String + int: Int + float: Float + boolean: Boolean +) on OBJECT +directive @preservedFieldLevel( + string: String + int: Int + float: Float + boolean: Boolean +) on FIELD_DEFINITION type Movie @preservedTopLevel { - id: ID @preservedFieldLevel(string: "str", int: 12, float: 1.2, boolean: true) + id: ID + @preservedFieldLevel(string: "str", int: 12, float: 1.2, boolean: true) } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID + id: ID } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection + id: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID + id: ID } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md index 2a861beabc..93ce6d8363 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/access-directives.md @@ -1,14 +1,14 @@ -## Schema Access Directives +# Schema Access Directives Tests that the access directives @readonly and @writeonly work as expected. --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type User { id: ID! @readonly username: String! @@ -16,103 +16,114 @@ type User { } ``` -**Output** - -```schema-output +### Output +```graphql type User { - id: ID! - username: String! + id: ID! + username: String! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input UserCreateInput { - id: ID! - username: String! - password: String! + id: ID! + username: String! + password: String! } input UserOptions { - """Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array.""" - sort: [UserSort] - limit: Int - skip: Int + """ + Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [UserSort] + limit: Int + offset: Int } -"""Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object.""" +""" +Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object. +""" input UserSort { - id: SortDirection - username: SortDirection - password: SortDirection + id: SortDirection + username: SortDirection + password: SortDirection } input UserWhere { - AND: [UserWhere!] - id: ID - id_CONTAINS: ID - id_ENDS_WITH: ID - id_IN: [ID] - id_NOT: ID - id_NOT_CONTAINS: ID - id_NOT_ENDS_WITH: ID - id_NOT_IN: [ID] - id_NOT_STARTS_WITH: ID - id_STARTS_WITH: ID - OR: [UserWhere!] - password: String - password_CONTAINS: String - password_ENDS_WITH: String - password_IN: [String] - password_NOT: String - password_NOT_CONTAINS: String - password_NOT_ENDS_WITH: String - password_NOT_IN: [String] - password_NOT_STARTS_WITH: String - password_STARTS_WITH: String - username: String - username_CONTAINS: String - username_ENDS_WITH: String - username_IN: [String] - username_NOT: String - username_NOT_CONTAINS: String - username_NOT_ENDS_WITH: String - username_NOT_IN: [String] - username_NOT_STARTS_WITH: String - username_STARTS_WITH: String + AND: [UserWhere!] + id: ID + id_CONTAINS: ID + id_ENDS_WITH: ID + id_IN: [ID] + id_NOT: ID + id_NOT_CONTAINS: ID + id_NOT_ENDS_WITH: ID + id_NOT_IN: [ID] + id_NOT_STARTS_WITH: ID + id_STARTS_WITH: ID + OR: [UserWhere!] + password: String + password_CONTAINS: String + password_ENDS_WITH: String + password_IN: [String] + password_NOT: String + password_NOT_CONTAINS: String + password_NOT_ENDS_WITH: String + password_NOT_IN: [String] + password_NOT_STARTS_WITH: String + password_STARTS_WITH: String + username: String + username_CONTAINS: String + username_ENDS_WITH: String + username_IN: [String] + username_NOT: String + username_NOT_CONTAINS: String + username_NOT_ENDS_WITH: String + username_NOT_IN: [String] + username_NOT_STARTS_WITH: String + username_STARTS_WITH: String } input UserUpdateInput { - username: String - password: String + username: String + password: String } type CreateUsersMutationResponse { - users: [User!]! + users: [User!]! } type UpdateUsersMutationResponse { - users: [User!]! + users: [User!]! } type Mutation { - createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! - deleteUsers(where: UserWhere): DeleteInfo! - updateUsers(where: UserWhere, update: UserUpdateInput): UpdateUsersMutationResponse! + createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! + deleteUsers(where: UserWhere): DeleteInfo! + updateUsers( + where: UserWhere + update: UserUpdateInput + ): UpdateUsersMutationResponse! } type Query { - users(where: UserWhere, options: UserOptions): [User!]! + users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md index e453da5764..6f062de964 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/autogenerate.md @@ -1,103 +1,114 @@ -## Schema autogenerate directive +# Schema autogenerate directive Tests that the autogenerate directive produces the correct schema. --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { id: ID! @id name: String! } ``` -**Output** - -```schema-output +### Output +```graphql type Movie { - id: ID! - name: String! + id: ID! + name: String! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - name: String! + name: String! } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" - sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - name: SortDirection + id: SortDirection + name: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + name: String + name_IN: [String] + name_NOT: String + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - name: String + name: String } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md index e8e821b185..b3a5f257d3 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/cypher.md @@ -1,151 +1,173 @@ -## Schema Cypher Directive +# Schema Cypher Directive Tests that the provided typeDefs return the correct schema (with cypher directives). --- -### Custom Directive Simple +## Custom Directive Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor { - name: String + name: String } type Movie { id: ID - actors(title: String): [Actor] @cypher(statement: """ - MATCH (a:Actor {title: $title}) - RETURN a - LIMIT 1 - """) + actors(title: String): [Actor] + @cypher( + statement: """ + MATCH (a:Actor {title: $title}) + RETURN a + LIMIT 1 + """ + ) } ``` -**Output** - -```schema-output +### Output +```graphql type Actor { - name: String + name: String } input ActorCreateInput { - name: String + name: String } input ActorOptions { - """Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [ActorSort] - limit: Int - skip: Int + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] + limit: Int + offset: Int } -"""Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object.""" +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" input ActorSort { - name: SortDirection + name: SortDirection } input ActorUpdateInput { - name: String + name: String } input ActorWhere { - OR: [ActorWhere!] - AND: [ActorWhere!] - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_IN: [String] + name_NOT: String + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } type Movie { - id: ID - actors(title: String): [Actor] + id: ID + actors(title: String): [Actor] } input MovieCreateInput { - id: ID + id: ID } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection + id: SortDirection } input MovieUpdateInput { - id: ID + id: ID } input MovieWhere { - OR: [MovieWhere!] - AND: [MovieWhere!] - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID + OR: [MovieWhere!] + AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type CreateActorsMutationResponse { - actors: [Actor!]! + actors: [Actor!]! } type UpdateActorsMutationResponse { - actors: [Actor!]! + actors: [Actor!]! } type Mutation { - createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! - deleteActors(where: ActorWhere): DeleteInfo! - updateActors(where: ActorWhere, update: ActorUpdateInput): UpdateActorsMutationResponse! - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors(where: ActorWhere): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + ): UpdateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - actors(where: ActorWhere, options: ActorOptions): [Actor!]! - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + actors(where: ActorWhere, options: ActorOptions): [Actor!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md index 6b666a3cd1..c9a994f29f 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/default.md @@ -1,14 +1,14 @@ -## Schema Default +# Schema Default Tests that the @default directive works as expected. --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type User { id: ID! @default(value: "00000000-00000000-00000000-00000000") name: String! @default(value: "Jane Smith") @@ -19,135 +19,149 @@ type User { } ``` -**Output** +### Output -```schema-output -"""A date and time, represented as an ISO-8601 string""" +```graphql +""" +A date and time, represented as an ISO-8601 string +""" scalar DateTime type User { - id: ID! - name: String! - verified: Boolean! - numberOfFriends: Int! - rating: Float! - verifiedDate: DateTime! + id: ID! + name: String! + verified: Boolean! + numberOfFriends: Int! + rating: Float! + verifiedDate: DateTime! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input UserCreateInput { - id: ID! = "00000000-00000000-00000000-00000000" - name: String! = "Jane Smith" - verified: Boolean! = false - numberOfFriends: Int! = 0 - rating: Float! = 0.0 - verifiedDate: DateTime! = "1970-01-01T00:00:00.000Z" + id: ID! = "00000000-00000000-00000000-00000000" + name: String! = "Jane Smith" + verified: Boolean! = false + numberOfFriends: Int! = 0 + rating: Float! = 0.0 + verifiedDate: DateTime! = "1970-01-01T00:00:00.000Z" } input UserOptions { - """Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array.""" - sort: [UserSort] - limit: Int - skip: Int + """ + Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [UserSort] + limit: Int + offset: Int } -"""Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object.""" +""" +Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object. +""" input UserSort { - id: SortDirection - name: SortDirection - verified: SortDirection - numberOfFriends: SortDirection - rating: SortDirection - verifiedDate: SortDirection + id: SortDirection + name: SortDirection + verified: SortDirection + numberOfFriends: SortDirection + rating: SortDirection + verifiedDate: SortDirection } input UserWhere { - id: ID - id_CONTAINS: ID - id_ENDS_WITH: ID - id_IN: [ID] - id_NOT: ID - id_NOT_CONTAINS: ID - id_NOT_ENDS_WITH: ID - id_NOT_IN: [ID] - id_NOT_STARTS_WITH: ID - id_STARTS_WITH: ID - name: String - name_CONTAINS: String - name_ENDS_WITH: String - name_IN: [String] - name_NOT: String - name_NOT_CONTAINS: String - name_NOT_ENDS_WITH: String - name_NOT_IN: [String] - name_NOT_STARTS_WITH: String - name_STARTS_WITH: String - numberOfFriends: Int - numberOfFriends_GT: Int - numberOfFriends_GTE: Int - numberOfFriends_IN: [Int] - numberOfFriends_LT: Int - numberOfFriends_LTE: Int - numberOfFriends_NOT: Int - numberOfFriends_NOT_IN: [Int] - rating: Float - rating_GT: Float - rating_GTE: Float - rating_IN: [Float] - rating_LT: Float - rating_LTE: Float - rating_NOT: Float - rating_NOT_IN: [Float] - verified: Boolean - verified_NOT: Boolean - verifiedDate: DateTime - verifiedDate_GT: DateTime - verifiedDate_GTE: DateTime - verifiedDate_IN: [DateTime] - verifiedDate_LT: DateTime - verifiedDate_LTE: DateTime - verifiedDate_NOT: DateTime - verifiedDate_NOT_IN: [DateTime] - OR: [UserWhere!] - AND: [UserWhere!] + id: ID + id_CONTAINS: ID + id_ENDS_WITH: ID + id_IN: [ID] + id_NOT: ID + id_NOT_CONTAINS: ID + id_NOT_ENDS_WITH: ID + id_NOT_IN: [ID] + id_NOT_STARTS_WITH: ID + id_STARTS_WITH: ID + name: String + name_CONTAINS: String + name_ENDS_WITH: String + name_IN: [String] + name_NOT: String + name_NOT_CONTAINS: String + name_NOT_ENDS_WITH: String + name_NOT_IN: [String] + name_NOT_STARTS_WITH: String + name_STARTS_WITH: String + numberOfFriends: Int + numberOfFriends_GT: Int + numberOfFriends_GTE: Int + numberOfFriends_IN: [Int] + numberOfFriends_LT: Int + numberOfFriends_LTE: Int + numberOfFriends_NOT: Int + numberOfFriends_NOT_IN: [Int] + rating: Float + rating_GT: Float + rating_GTE: Float + rating_IN: [Float] + rating_LT: Float + rating_LTE: Float + rating_NOT: Float + rating_NOT_IN: [Float] + verified: Boolean + verified_NOT: Boolean + verifiedDate: DateTime + verifiedDate_GT: DateTime + verifiedDate_GTE: DateTime + verifiedDate_IN: [DateTime] + verifiedDate_LT: DateTime + verifiedDate_LTE: DateTime + verifiedDate_NOT: DateTime + verifiedDate_NOT_IN: [DateTime] + OR: [UserWhere!] + AND: [UserWhere!] } input UserUpdateInput { - id: ID - name: String - verified: Boolean - numberOfFriends: Int - rating: Float - verifiedDate: DateTime + id: ID + name: String + verified: Boolean + numberOfFriends: Int + rating: Float + verifiedDate: DateTime } type CreateUsersMutationResponse { - users: [User!]! + users: [User!]! } type UpdateUsersMutationResponse { - users: [User!]! + users: [User!]! } type Mutation { - createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! - deleteUsers(where: UserWhere): DeleteInfo! - updateUsers(where: UserWhere, update: UserUpdateInput): UpdateUsersMutationResponse! + createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! + deleteUsers(where: UserWhere): DeleteInfo! + updateUsers( + where: UserWhere + update: UserUpdateInput + ): UpdateUsersMutationResponse! } type Query { - users(where: UserWhere, options: UserOptions): [User!]! + users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md index d6cf52474d..4f89d40606 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/exclude.md @@ -1,670 +1,791 @@ -## Schema Cypher Directive +# Schema Cypher Directive Tests that the provided typeDefs return the correct schema (with `@exclude` directives). --- -### Using `@exclude` directive to skip generation of Query +## Using `@exclude` directive to skip generation of Query -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor @exclude(operations: [READ]) { - name: String + name: String } type Movie { - title: String + title: String } ``` -**Output** - -```schema-output +### Output +```graphql type Actor { - name: String + name: String } input ActorCreateInput { - name: String + name: String } input ActorUpdateInput { - name: String + name: String } input ActorWhere { - OR: [ActorWhere!] - AND: [ActorWhere!] - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_IN: [String] + name_NOT: String + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } type Movie { - title: String + title: String } input MovieCreateInput { - title: String + title: String } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - title: SortDirection + title: SortDirection } input MovieUpdateInput { - title: String + title: String } input MovieWhere { - OR: [MovieWhere!] - AND: [MovieWhere!] - title: String - title_IN: [String] - title_NOT: String - title_NOT_IN: [String] - title_CONTAINS: String - title_NOT_CONTAINS: String - title_STARTS_WITH: String - title_NOT_STARTS_WITH: String - title_ENDS_WITH: String - title_NOT_ENDS_WITH: String + OR: [MovieWhere!] + AND: [MovieWhere!] + title: String + title_IN: [String] + title_NOT: String + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type CreateActorsMutationResponse { - actors: [Actor!]! + actors: [Actor!]! } type UpdateActorsMutationResponse { - actors: [Actor!]! + actors: [Actor!]! } type Mutation { - createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! - deleteActors(where: ActorWhere): DeleteInfo! - updateActors(where: ActorWhere, update: ActorUpdateInput): UpdateActorsMutationResponse! - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors(where: ActorWhere): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + ): UpdateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` --- -### Using `@exclude` directive to skip generator of Mutation +## Using `@exclude` directive to skip generator of Mutation -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor @exclude(operations: [CREATE]) { - name: String + name: String } ``` -**Output** +### Output -```schema-output +```graphql type Actor { - name: String + name: String } input ActorOptions { - """Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [ActorSort] - limit: Int - skip: Int + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] + limit: Int + offset: Int } -"""Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object.""" +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" input ActorSort { - name: SortDirection + name: SortDirection } input ActorUpdateInput { - name: String + name: String } input ActorWhere { - OR: [ActorWhere!] - AND: [ActorWhere!] - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_IN: [String] + name_NOT: String + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } type UpdateActorsMutationResponse { - actors: [Actor!]! + actors: [Actor!]! } type Mutation { - deleteActors(where: ActorWhere): DeleteInfo! - updateActors(where: ActorWhere, update: ActorUpdateInput): UpdateActorsMutationResponse! + deleteActors(where: ActorWhere): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + ): UpdateActorsMutationResponse! } type Query { - actors(where: ActorWhere, options: ActorOptions): [Actor!]! + actors(where: ActorWhere, options: ActorOptions): [Actor!]! + actorsCount(where: ActorWhere): Int! } ``` --- -### Using `@exclude` directive with `"*"` skips generation of all Queries and Mutations and removes the type itself if not referenced elsewhere +## Using `@exclude` directive with `"*"` skips generation of all Queries and Mutations and removes the type itself if not referenced elsewhere -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor @exclude { - name: String + name: String } type Movie { - title: String + title: String } ``` -**Output** +### Output -```schema-output +```graphql type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } type Movie { - title: String + title: String } input MovieCreateInput { - title: String + title: String } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - title: SortDirection + title: SortDirection } input MovieUpdateInput { - title: String + title: String } input MovieWhere { - OR: [MovieWhere!] - AND: [MovieWhere!] - title: String - title_IN: [String] - title_NOT: String - title_NOT_IN: [String] - title_CONTAINS: String - title_NOT_CONTAINS: String - title_STARTS_WITH: String - title_NOT_STARTS_WITH: String - title_ENDS_WITH: String - title_NOT_ENDS_WITH: String + OR: [MovieWhere!] + AND: [MovieWhere!] + title: String + title_IN: [String] + title_NOT: String + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` --- -### Using `@exclude` directive with `"*"` skips generation of all Queries and Mutations but retains the type itself if referenced elsewhere +## Using `@exclude` directive with `"*"` skips generation of all Queries and Mutations but retains the type itself if referenced elsewhere -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor @exclude { - name: String + name: String } type Movie { - title: String + title: String } type Query { - customActorQuery: Actor + customActorQuery: Actor } ``` -**Output** +### Output -```schema-output +```graphql type Actor { - name: String + name: String } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } type Movie { - title: String + title: String } input MovieCreateInput { - title: String + title: String } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - title: SortDirection + title: SortDirection } input MovieUpdateInput { - title: String + title: String } input MovieWhere { - OR: [MovieWhere!] - AND: [MovieWhere!] - title: String - title_IN: [String] - title_NOT: String - title_NOT_IN: [String] - title_CONTAINS: String - title_NOT_CONTAINS: String - title_STARTS_WITH: String - title_NOT_STARTS_WITH: String - title_ENDS_WITH: String - title_NOT_ENDS_WITH: String + OR: [MovieWhere!] + AND: [MovieWhere!] + title: String + title_IN: [String] + title_NOT: String + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - customActorQuery: Actor - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + customActorQuery: Actor + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` --- -### Using `@exclude` directive with `"*"` skips generation of all Queries and Mutations but retains the type itself if referenced in a `@relationship` directive +## Using `@exclude` directive with `"*"` skips generation of all Queries and Mutations but retains the type itself if referenced in a `@relationship` directive -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor @exclude { - name: String + name: String } type Movie { - title: String - actors: [Actor] @relationship(type: "ACTED_IN", direction: IN) + title: String + actors: [Actor] @relationship(type: "ACTED_IN", direction: IN) } ``` -**Output** - -```schema-output -enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC -} +### Output +```graphql type Actor { - name: String + name: String } -input ActorConnectFieldInput { - where: ActorWhere +input ActorCreateInput { + name: String } -input ActorCreateInput { - name: String +input MovieActorsDeleteFieldInput { + where: MovieActorsConnectionWhere } -input ActorDisconnectFieldInput { - where: ActorWhere +input MovieActorsDisconnectFieldInput { + where: MovieActorsConnectionWhere } input ActorOptions { - """Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [ActorSort] - limit: Int - skip: Int + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] + limit: Int + offset: Int } -"""Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object.""" +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" input ActorSort { - name: SortDirection + name: SortDirection } input ActorUpdateInput { - name: String + name: String } input ActorWhere { - OR: [ActorWhere!] - AND: [ActorWhere!] - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String +} + +type CreateMoviesMutationResponse { + movies: [Movie!]! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } type Movie { - title: String - actors(options: ActorOptions, where: ActorWhere): [Actor] + title: String + actors(where: ActorWhere, options: ActorOptions): [Actor] + actorsConnection( + after: String + first: Int + where: MovieActorsConnectionWhere + sort: [MovieActorsConnectionSort!] + ): MovieActorsConnection! +} + +input ActorConnectWhere { + node: ActorWhere! +} + +input MovieActorsConnectFieldInput { + where: ActorConnectWhere +} + +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input MovieActorsConnectionSort { + node: ActorSort +} + +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + node: ActorWhere + node_NOT: ActorWhere +} + +input MovieActorsCreateFieldInput { + node: ActorCreateInput! } input MovieActorsFieldInput { - connect: [ActorConnectFieldInput!] - create: [ActorCreateInput!] + create: [MovieActorsCreateFieldInput!] + connect: [MovieActorsConnectFieldInput!] +} + +type MovieActorsRelationship { + cursor: String! + node: Actor! } -input ActorDeleteFieldInput { - where: ActorWhere +input MovieActorsUpdateConnectionInput { + node: ActorUpdateInput } input MovieActorsUpdateFieldInput { - connect: [ActorConnectFieldInput!] - create: [ActorCreateInput!] - disconnect: [ActorDisconnectFieldInput!] - update: ActorUpdateInput - where: ActorWhere - delete: [ActorDeleteFieldInput!] + where: MovieActorsConnectionWhere + update: MovieActorsUpdateConnectionInput + connect: [MovieActorsConnectFieldInput!] + disconnect: [MovieActorsDisconnectFieldInput!] + create: [MovieActorsCreateFieldInput!] + delete: [MovieActorsDeleteFieldInput!] } input MovieConnectInput { - actors: [ActorConnectFieldInput!] + actors: [MovieActorsConnectFieldInput!] } input MovieCreateInput { - actors: MovieActorsFieldInput - title: String + title: String + actors: MovieActorsFieldInput +} + +input MovieDeleteInput { + actors: [MovieActorsDeleteFieldInput!] } input MovieDisconnectInput { - actors: [ActorDisconnectFieldInput!] + actors: [MovieActorsDisconnectFieldInput!] } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } input MovieRelationInput { - actors: [ActorCreateInput!] + actors: [MovieActorsCreateFieldInput!] } -input MovieActorsDeleteFieldInput { - where: ActorWhere -} - -input MovieDeleteInput { - actors: [MovieActorsDeleteFieldInput!] -} - -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - title: SortDirection + title: SortDirection } input MovieUpdateInput { - actors: [MovieActorsUpdateFieldInput!] - title: String + title: String + actors: [MovieActorsUpdateFieldInput!] } input MovieWhere { - actors: ActorWhere - actors_NOT: ActorWhere - OR: [MovieWhere!] - AND: [MovieWhere!] - title: String - title_IN: [String] - title_NOT: String - title_NOT_IN: [String] - title_CONTAINS: String - title_NOT_CONTAINS: String - title_STARTS_WITH: String - title_NOT_STARTS_WITH: String - title_ENDS_WITH: String - title_NOT_ENDS_WITH: String + OR: [MovieWhere!] + AND: [MovieWhere!] + title: String + title_NOT: String + title_IN: [String] + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String + actors: ActorWhere + actors_NOT: ActorWhere + actorsConnection: MovieActorsConnectionWhere + actorsConnection_NOT: MovieActorsConnectionWhere } -type CreateMoviesMutationResponse { - movies: [Movie!]! +type Mutation { + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String } -type UpdateMoviesMutationResponse { - movies: [Movie!]! +type Query { + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } -type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies( - where: MovieWhere - delete: MovieDeleteInput - ): DeleteInfo! - updateMovies( - connect: MovieConnectInput - create: MovieRelationInput - disconnect: MovieDisconnectInput - update: MovieUpdateInput - where: MovieWhere - delete: MovieDeleteInput - ): UpdateMoviesMutationResponse! +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC } -type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! +type UpdateMoviesMutationResponse { + movies: [Movie!]! } ``` --- -### Ensure generation doesn't break if `@exclude` is provided with an empty array +## Ensure generation doesn't break if `@exclude` is provided with an empty array -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor @exclude(operations: []) { - name: String + name: String } ``` -**Output** +### Output -```schema-output +```graphql enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } type Actor { - name: String + name: String } input ActorCreateInput { - name: String + name: String } input ActorOptions { - """Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [ActorSort] - limit: Int - skip: Int + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] + limit: Int + offset: Int } -"""Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object.""" +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" input ActorSort { - name: SortDirection + name: SortDirection } input ActorUpdateInput { - name: String + name: String } input ActorWhere { - OR: [ActorWhere!] - AND: [ActorWhere!] - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_IN: [String] + name_NOT: String + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } type CreateActorsMutationResponse { - actors: [Actor!]! + actors: [Actor!]! } type UpdateActorsMutationResponse { - actors: [Actor!]! + actors: [Actor!]! } type Mutation { - createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! - deleteActors(where: ActorWhere): DeleteInfo! - updateActors(where: ActorWhere, update: ActorUpdateInput): UpdateActorsMutationResponse! + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors(where: ActorWhere): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + ): UpdateActorsMutationResponse! } type Query { - actors(where: ActorWhere, options: ActorOptions): [Actor!]! + actors(where: ActorWhere, options: ActorOptions): [Actor!]! + actorsCount(where: ActorWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md index a559936e4f..40301ff544 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/ignore.md @@ -1,14 +1,14 @@ -## Schema @ignore directive +# Schema @ignore directive Tests that the @ignore directive works as expected. --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type User { id: ID! username: String! @@ -17,105 +17,117 @@ type User { } ``` -**Output** +### Output -```schema-output +```graphql type User { - id: ID! - username: String! - password: String! - nickname: String! + id: ID! + username: String! + password: String! + nickname: String! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input UserCreateInput { - id: ID! - username: String! - password: String! + id: ID! + username: String! + password: String! } input UserOptions { - """Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array.""" - sort: [UserSort] - limit: Int - skip: Int + """ + Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [UserSort] + limit: Int + offset: Int } -"""Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object.""" +""" +Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object. +""" input UserSort { - id: SortDirection - username: SortDirection - password: SortDirection + id: SortDirection + username: SortDirection + password: SortDirection } input UserWhere { - AND: [UserWhere!] - id: ID - id_CONTAINS: ID - id_ENDS_WITH: ID - id_IN: [ID] - id_NOT: ID - id_NOT_CONTAINS: ID - id_NOT_ENDS_WITH: ID - id_NOT_IN: [ID] - id_NOT_STARTS_WITH: ID - id_STARTS_WITH: ID - OR: [UserWhere!] - password: String - password_CONTAINS: String - password_ENDS_WITH: String - password_IN: [String] - password_NOT: String - password_NOT_CONTAINS: String - password_NOT_ENDS_WITH: String - password_NOT_IN: [String] - password_NOT_STARTS_WITH: String - password_STARTS_WITH: String - username: String - username_CONTAINS: String - username_ENDS_WITH: String - username_IN: [String] - username_NOT: String - username_NOT_CONTAINS: String - username_NOT_ENDS_WITH: String - username_NOT_IN: [String] - username_NOT_STARTS_WITH: String - username_STARTS_WITH: String + AND: [UserWhere!] + id: ID + id_CONTAINS: ID + id_ENDS_WITH: ID + id_IN: [ID] + id_NOT: ID + id_NOT_CONTAINS: ID + id_NOT_ENDS_WITH: ID + id_NOT_IN: [ID] + id_NOT_STARTS_WITH: ID + id_STARTS_WITH: ID + OR: [UserWhere!] + password: String + password_CONTAINS: String + password_ENDS_WITH: String + password_IN: [String] + password_NOT: String + password_NOT_CONTAINS: String + password_NOT_ENDS_WITH: String + password_NOT_IN: [String] + password_NOT_STARTS_WITH: String + password_STARTS_WITH: String + username: String + username_CONTAINS: String + username_ENDS_WITH: String + username_IN: [String] + username_NOT: String + username_NOT_CONTAINS: String + username_NOT_ENDS_WITH: String + username_NOT_IN: [String] + username_NOT_STARTS_WITH: String + username_STARTS_WITH: String } input UserUpdateInput { - id: ID - username: String - password: String + id: ID + username: String + password: String } type CreateUsersMutationResponse { - users: [User!]! + users: [User!]! } type UpdateUsersMutationResponse { - users: [User!]! + users: [User!]! } type Mutation { - createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! - deleteUsers(where: UserWhere): DeleteInfo! - updateUsers(where: UserWhere, update: UserUpdateInput): UpdateUsersMutationResponse! + createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! + deleteUsers(where: UserWhere): DeleteInfo! + updateUsers( + where: UserWhere + update: UserUpdateInput + ): UpdateUsersMutationResponse! } type Query { - users(where: UserWhere, options: UserOptions): [User!]! + users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md index 6bcf9cd86a..a289d1b315 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/private.md @@ -1,90 +1,102 @@ -## Schema Private +# Schema Private Tests private fields are not included in the schema. --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type User { id: ID password: String @private } ``` -**Output** +### Output -```schema-output +```graphql type User { - id: ID + id: ID } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input UserCreateInput { - id: ID + id: ID } input UserOptions { - """Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array.""" - sort: [UserSort] - limit: Int - skip: Int + """ + Specify one or more UserSort objects to sort Users by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [UserSort] + limit: Int + offset: Int } -"""Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object.""" +""" +Fields to sort Users by. The order in which sorts are applied is not guaranteed when specifying many fields in one UserSort object. +""" input UserSort { - id: SortDirection + id: SortDirection } input UserWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - OR: [UserWhere!] - AND: [UserWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + OR: [UserWhere!] + AND: [UserWhere!] } input UserUpdateInput { - id: ID + id: ID } type CreateUsersMutationResponse { - users: [User!]! + users: [User!]! } type UpdateUsersMutationResponse { - users: [User!]! + users: [User!]! } type Mutation { - createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! - deleteUsers(where: UserWhere): DeleteInfo! - updateUsers(where: UserWhere, update: UserUpdateInput): UpdateUsersMutationResponse! + createUsers(input: [UserCreateInput!]!): CreateUsersMutationResponse! + deleteUsers(where: UserWhere): DeleteInfo! + updateUsers( + where: UserWhere + update: UserUpdateInput + ): UpdateUsersMutationResponse! } type Query { - users(where: UserWhere, options: UserOptions): [User!]! + users(where: UserWhere, options: UserOptions): [User!]! + usersCount(where: UserWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md b/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md index f3f878f0e5..06a9ea7f53 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/directives/timestamps.md @@ -1,14 +1,14 @@ -## Schema TimeStamps +# Schema TimeStamps Tests that the provided typeDefs return the correct schema. --- -### TimeStamp +## TimeStamp -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { id: ID createdAt: DateTime! @timestamp(operations: [CREATE]) @@ -16,100 +16,113 @@ type Movie { } ``` -**Output** +### Output -```schema-output - -"""A date and time, represented as an ISO-8601 string""" +```graphql +""" +A date and time, represented as an ISO-8601 string +""" scalar DateTime type Movie { - id: ID - createdAt: DateTime! - updatedAt: DateTime! + id: ID + createdAt: DateTime! + updatedAt: DateTime! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID + id: ID } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - createdAt: SortDirection - updatedAt: SortDirection + id: SortDirection + createdAt: SortDirection + updatedAt: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - createdAt: DateTime - createdAt_NOT: DateTime - createdAt_IN: [DateTime] - createdAt_NOT_IN: [DateTime] - createdAt_LT: DateTime - createdAt_LTE: DateTime - createdAt_GT: DateTime - createdAt_GTE: DateTime - updatedAt: DateTime - updatedAt_NOT: DateTime - updatedAt_IN: [DateTime] - updatedAt_NOT_IN: [DateTime] - updatedAt_LT: DateTime - updatedAt_LTE: DateTime - updatedAt_GT: DateTime - updatedAt_GTE: DateTime - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + createdAt: DateTime + createdAt_NOT: DateTime + createdAt_IN: [DateTime] + createdAt_NOT_IN: [DateTime] + createdAt_LT: DateTime + createdAt_LTE: DateTime + createdAt_GT: DateTime + createdAt_GTE: DateTime + updatedAt: DateTime + updatedAt_NOT: DateTime + updatedAt_IN: [DateTime] + updatedAt_NOT_IN: [DateTime] + updatedAt_LT: DateTime + updatedAt_LTE: DateTime + updatedAt_GT: DateTime + updatedAt_GTE: DateTime + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID + id: ID } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/enum.md b/packages/graphql/tests/tck/tck-test-files/schema/enum.md index 9202dbffdb..a1ee731ce4 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/enum.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/enum.md @@ -1,96 +1,107 @@ -## Schema Enums +# Schema Enums Tests that the provided typeDefs return the correct schema(with enums). --- -### Enums +## Enums -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql enum Status { - ACTIVE - INACTIVE - PENDING + ACTIVE + INACTIVE + PENDING } type Movie { - status: Status + status: Status } ``` -**Output** - -```schema-output +### Output +```graphql enum Status { - ACTIVE - INACTIVE - PENDING + ACTIVE + INACTIVE + PENDING } type Movie { - status: Status + status: Status } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - status: Status + status: Status } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - status: SortDirection + status: SortDirection } input MovieWhere { - status: Status - status_IN: [Status] - status_NOT: Status - status_NOT_IN: [Status] - OR: [MovieWhere!] - AND: [MovieWhere!] + status: Status + status_IN: [Status] + status_NOT: Status + status_NOT_IN: [Status] + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - status: Status + status: Status } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/extend.md b/packages/graphql/tests/tck/tck-test-files/schema/extend.md index c8729ee3a4..e6ea22c3bd 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/extend.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/extend.md @@ -1,107 +1,119 @@ -## Schema Extends +# Schema Extends Tests that the provided typeDefs return the correct schema(with extends). --- -### Extend +## Extend -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { - id: ID + id: ID } extend type Movie { - name: String + name: String } ``` -**Output** +### Output -```schema-output +```graphql type Movie { - id: ID - name: String + id: ID + name: String } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID - name: String + id: ID + name: String } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - name: SortDirection + id: SortDirection + name: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + name: String + name_IN: [String] + name_NOT: String + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID - name: String + id: ID + name: String } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/inputs.md b/packages/graphql/tests/tck/tck-test-files/schema/inputs.md index 8f911e3ca6..04cb54ce90 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/inputs.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/inputs.md @@ -1,14 +1,14 @@ -## Schema Inputs +# Schema Inputs Tests that the provided typeDefs return the correct schema. --- -### Inputs +## Inputs -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql input NodeInput { id: ID } @@ -18,85 +18,97 @@ type Movie { } type Query { - name(input: NodeInput): String + name(input: NodeInput): String } ``` -**Output** +### Output -```schema-output +```graphql input NodeInput { id: ID } type Movie { - id: ID + id: ID } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID + id: ID } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection + id: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID + id: ID } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! - name(input: NodeInput): String + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + name(input: NodeInput): String } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md b/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md index 36b1f55854..6e6e560a84 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/interfaces.md @@ -1,174 +1,238 @@ -## Schema Interfaces +# Schema Interfaces Tests that the provided typeDefs return the correct schema. --- -### Interfaces +## Interfaces -**TypeDefs** +### TypeDefs -```typedefs-input -interface Node @auth(rules: [{allow: "*", operations: [READ]}]) { +```graphql +interface MovieNode @auth(rules: [{ allow: "*", operations: [READ] }]) { id: ID movies: [Movie] @relationship(type: "HAS_MOVIE", direction: OUT) - customQuery: [Movie] @cypher(statement: """ - MATCH (m:Movie) - RETURN m - """) + customQuery: [Movie] + @cypher( + statement: """ + MATCH (m:Movie) + RETURN m + """ + ) } -type Movie implements Node @auth(rules: [{allow: "*", operations: [READ]}]) { +type Movie implements MovieNode + @auth(rules: [{ allow: "*", operations: [READ] }]) { id: ID - nodes: [Node] + nodes: [MovieNode] movies: [Movie] @relationship(type: "HAS_MOVIE", direction: OUT) - customQuery: [Movie] @cypher(statement: """ - MATCH (m:Movie) - RETURN m - """) + customQuery: [Movie] + @cypher( + statement: """ + MATCH (m:Movie) + RETURN m + """ + ) } ``` -**Output** +### Output -```schema-output -enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC +```graphql +type CreateMoviesMutationResponse { + movies: [Movie!]! } -interface Node { +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Movie implements MovieNode { id: ID - movies: [Movie] customQuery: [Movie] + nodes: [MovieNode] + movies(where: MovieWhere, options: MovieOptions): [Movie] + moviesConnection( + after: String + first: Int + where: MovieMoviesConnectionWhere + sort: [MovieMoviesConnectionSort!] + ): MovieMoviesConnection! } -type Movie implements Node { - id: ID - nodes: [Node] - movies(options: MovieOptions, where: MovieWhere): [Movie] - customQuery: [Movie] +input MovieConnectInput { + movies: [MovieMoviesConnectFieldInput!] } -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! +input MovieCreateInput { + id: ID + movies: MovieMoviesFieldInput } -input MovieCreateInput { - id: ID - movies: MovieMoviesFieldInput +input MovieDeleteInput { + movies: [MovieMoviesDeleteFieldInput!] } -input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int +input MovieDisconnectInput { + movies: [MovieMoviesDisconnectFieldInput!] } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" -input MovieSort { - id: SortDirection +input MovieMoviesDeleteFieldInput { + delete: MovieDeleteInput + where: MovieMoviesConnectionWhere } -input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - movies: MovieWhere - movies_NOT: MovieWhere - OR: [MovieWhere!] - AND: [MovieWhere!] -} - -input MovieDisconnectFieldInput { - disconnect: MovieDisconnectInput - where: MovieWhere +input MovieMoviesDisconnectFieldInput { + disconnect: MovieDisconnectInput + where: MovieMoviesConnectionWhere } -input MovieDisconnectInput { - movies: [MovieDisconnectFieldInput!] +input MovieConnectWhere { + node: MovieWhere! +} + +input MovieMoviesConnectFieldInput { + where: MovieConnectWhere + connect: [MovieConnectInput!] +} + +type MovieMoviesConnection { + edges: [MovieMoviesRelationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input MovieMoviesConnectionSort { + node: MovieSort +} + +input MovieMoviesConnectionWhere { + AND: [MovieMoviesConnectionWhere!] + OR: [MovieMoviesConnectionWhere!] + node: MovieWhere + node_NOT: MovieWhere +} + +input MovieMoviesCreateFieldInput { + node: MovieCreateInput! } input MovieMoviesFieldInput { - connect: [MovieConnectFieldInput!] - create: [MovieCreateInput!] + create: [MovieMoviesCreateFieldInput!] + connect: [MovieMoviesConnectFieldInput!] +} + +type MovieMoviesRelationship { + cursor: String! + node: Movie! } -input MovieDeleteFieldInput { - delete: MovieDeleteInput - where: MovieWhere +input MovieMoviesUpdateConnectionInput { + node: MovieUpdateInput } input MovieMoviesUpdateFieldInput { - connect: [MovieConnectFieldInput!] - create: [MovieCreateInput!] - disconnect: [MovieDisconnectFieldInput!] - update: MovieUpdateInput - where: MovieWhere - delete: [MovieDeleteFieldInput!] + where: MovieMoviesConnectionWhere + update: MovieMoviesUpdateConnectionInput + connect: [MovieMoviesConnectFieldInput!] + disconnect: [MovieMoviesDisconnectFieldInput!] + create: [MovieMoviesCreateFieldInput!] + delete: [MovieMoviesDeleteFieldInput!] } -input MovieRelationInput { - movies: [MovieCreateInput!] +input MovieOptions { + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -input MovieConnectFieldInput { - connect: MovieConnectInput - where: MovieWhere +input MovieRelationInput { + movies: [MovieMoviesCreateFieldInput!] } -input MovieConnectInput { - movies: [MovieConnectFieldInput!] +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" +input MovieSort { + id: SortDirection } input MovieUpdateInput { - id: ID - movies: [MovieMoviesUpdateFieldInput!] + id: ID + movies: [MovieMoviesUpdateFieldInput!] } -input MovieMoviesDeleteFieldInput { - where: MovieWhere - delete: MovieDeleteInput +input MovieWhere { + OR: [MovieWhere!] + AND: [MovieWhere!] + id: ID + id_NOT: ID + id_IN: [ID] + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + movies: MovieWhere + movies_NOT: MovieWhere + moviesConnection: MovieMoviesConnectionWhere + moviesConnection_NOT: MovieMoviesConnectionWhere } -input MovieDeleteInput { - movies: [MovieMoviesDeleteFieldInput!] +type Mutation { + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +interface MovieNode { + movies: [Movie] + id: ID + customQuery: [Movie] } -type CreateMoviesMutationResponse { - movies: [Movie!]! +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String } -type UpdateMoviesMutationResponse { - movies: [Movie!]! +type Query { + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } -type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! - updateMovies( - where: MovieWhere - update: MovieUpdateInput - connect: MovieConnectInput - create: MovieRelationInput - disconnect: MovieDisconnectInput - delete: MovieDeleteInput - ): UpdateMoviesMutationResponse! +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC } -type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! +type UpdateMoviesMutationResponse { + movies: [Movie!]! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md b/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md index 25d2f015f4..ccf77a9acc 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/issues/162.md @@ -1,32 +1,32 @@ -## #162 +# #162 --- -### 2 instances of DeleteInput type created +## 2 instances of DeleteInput type created -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Tiger { - x: Int + x: Int } type TigerJawLevel2 { - id: ID - part1: TigerJawLevel2Part1 @relationship(type: "REL1", direction: OUT) + id: ID + part1: TigerJawLevel2Part1 @relationship(type: "REL1", direction: OUT) } type TigerJawLevel2Part1 { - id: ID - tiger: Tiger @relationship(type: "REL2", direction: OUT) + id: ID + tiger: Tiger @relationship(type: "REL2", direction: OUT) } ``` -**Output** +### Output -```schema-output +```graphql type CreateTigerJawLevel2Part1sMutationResponse { tigerJawLevel2Part1s: [TigerJawLevel2Part1!]! } @@ -44,39 +44,79 @@ type DeleteInfo { relationshipsDeleted: Int! } -""" -The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. -""" -scalar ID - -""" -The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. -""" -scalar Int - type Mutation { createTigers(input: [TigerCreateInput!]!): CreateTigersMutationResponse! deleteTigers(where: TigerWhere): DeleteInfo! - updateTigers(where: TigerWhere, update: TigerUpdateInput): UpdateTigersMutationResponse! - createTigerJawLevel2s(input: [TigerJawLevel2CreateInput!]!): CreateTigerJawLevel2sMutationResponse! - deleteTigerJawLevel2s(where: TigerJawLevel2Where, delete: TigerJawLevel2DeleteInput): DeleteInfo! - updateTigerJawLevel2s(where: TigerJawLevel2Where, update: TigerJawLevel2UpdateInput, connect: TigerJawLevel2ConnectInput, disconnect: TigerJawLevel2DisconnectInput, create: TigerJawLevel2RelationInput, delete: TigerJawLevel2DeleteInput): UpdateTigerJawLevel2sMutationResponse! - createTigerJawLevel2Part1s(input: [TigerJawLevel2Part1CreateInput!]!): CreateTigerJawLevel2Part1sMutationResponse! - deleteTigerJawLevel2Part1s(where: TigerJawLevel2Part1Where, delete: TigerJawLevel2Part1DeleteInput): DeleteInfo! - updateTigerJawLevel2Part1s(where: TigerJawLevel2Part1Where, update: TigerJawLevel2Part1UpdateInput, connect: TigerJawLevel2Part1ConnectInput, disconnect: TigerJawLevel2Part1DisconnectInput, create: TigerJawLevel2Part1RelationInput, delete: TigerJawLevel2Part1DeleteInput): UpdateTigerJawLevel2Part1sMutationResponse! + updateTigers( + where: TigerWhere + update: TigerUpdateInput + ): UpdateTigersMutationResponse! + createTigerJawLevel2s( + input: [TigerJawLevel2CreateInput!]! + ): CreateTigerJawLevel2sMutationResponse! + deleteTigerJawLevel2s( + where: TigerJawLevel2Where + delete: TigerJawLevel2DeleteInput + ): DeleteInfo! + updateTigerJawLevel2s( + where: TigerJawLevel2Where + update: TigerJawLevel2UpdateInput + connect: TigerJawLevel2ConnectInput + disconnect: TigerJawLevel2DisconnectInput + create: TigerJawLevel2RelationInput + delete: TigerJawLevel2DeleteInput + ): UpdateTigerJawLevel2sMutationResponse! + createTigerJawLevel2Part1s( + input: [TigerJawLevel2Part1CreateInput!]! + ): CreateTigerJawLevel2Part1sMutationResponse! + deleteTigerJawLevel2Part1s( + where: TigerJawLevel2Part1Where + delete: TigerJawLevel2Part1DeleteInput + ): DeleteInfo! + updateTigerJawLevel2Part1s( + where: TigerJawLevel2Part1Where + update: TigerJawLevel2Part1UpdateInput + connect: TigerJawLevel2Part1ConnectInput + disconnect: TigerJawLevel2Part1DisconnectInput + create: TigerJawLevel2Part1RelationInput + delete: TigerJawLevel2Part1DeleteInput + ): UpdateTigerJawLevel2Part1sMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String } type Query { - tigers(where: TigerWhere, options: TigerOptions): [Tiger!]! - tigerJawLevel2s(where: TigerJawLevel2Where, options: TigerJawLevel2Options): [TigerJawLevel2!]! - tigerJawLevel2Part1s(where: TigerJawLevel2Part1Where, options: TigerJawLevel2Part1Options): [TigerJawLevel2Part1!]! + tigerJawLevel2Part1s( + options: TigerJawLevel2Part1Options + where: TigerJawLevel2Part1Where + ): [TigerJawLevel2Part1!]! + tigerJawLevel2Part1sCount(where: TigerJawLevel2Part1Where): Int! + tigerJawLevel2s( + options: TigerJawLevel2Options + where: TigerJawLevel2Where + ): [TigerJawLevel2!]! + tigerJawLevel2sCount(where: TigerJawLevel2Where): Int! + tigers(options: TigerOptions, where: TigerWhere): [Tiger!]! + tigersCount(where: TigerWhere): Int! } enum SortDirection { - """Sort by field values in ascending order.""" + """ + Sort by field values in ascending order. + """ ASC - """Sort by field values in descending order.""" + """ + Sort by field values in descending order. + """ DESC } @@ -84,25 +124,22 @@ type Tiger { x: Int } -input TigerConnectFieldInput { - where: TigerWhere -} - input TigerCreateInput { x: Int } -input TigerDeleteFieldInput { - where: TigerWhere -} - -input TigerDisconnectFieldInput { - where: TigerWhere -} - type TigerJawLevel2 { id: ID - part1(where: TigerJawLevel2Part1Where, options: TigerJawLevel2Part1Options): TigerJawLevel2Part1 + part1( + where: TigerJawLevel2Part1Where + options: TigerJawLevel2Part1Options + ): TigerJawLevel2Part1 + part1Connection( + after: String + first: Int + where: TigerJawLevel2Part1ConnectionWhere + sort: [TigerJawLevel2Part1ConnectionSort!] + ): TigerJawLevel2Part1Connection! } input TigerJawLevel2ConnectInput { @@ -128,21 +165,52 @@ input TigerJawLevel2Options { """ sort: [TigerJawLevel2Sort] limit: Int - skip: Int + offset: Int } type TigerJawLevel2Part1 { id: ID tiger(where: TigerWhere, options: TigerOptions): Tiger + tigerConnection( + after: String + first: Int + where: TigerJawLevel2Part1TigerConnectionWhere + sort: [TigerJawLevel2Part1TigerConnectionSort!] + ): TigerJawLevel2Part1TigerConnection! +} + +input TigerJawLevel2Part1ConnectWhere { + node: TigerJawLevel2Part1Where! } input TigerJawLevel2Part1ConnectFieldInput { - where: TigerJawLevel2Part1Where + where: TigerJawLevel2Part1ConnectWhere connect: TigerJawLevel2Part1ConnectInput } input TigerJawLevel2Part1ConnectInput { - tiger: TigerConnectFieldInput + tiger: TigerJawLevel2Part1TigerConnectFieldInput +} + +type TigerJawLevel2Part1Connection { + edges: [TigerJawLevel2Part1Relationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input TigerJawLevel2Part1ConnectionSort { + node: TigerJawLevel2Part1Sort +} + +input TigerJawLevel2Part1ConnectionWhere { + AND: [TigerJawLevel2Part1ConnectionWhere!] + OR: [TigerJawLevel2Part1ConnectionWhere!] + node: TigerJawLevel2Part1Where + node_NOT: TigerJawLevel2Part1Where +} + +input TigerJawLevel2Part1CreateFieldInput { + node: TigerJawLevel2Part1CreateInput! } input TigerJawLevel2Part1CreateInput { @@ -151,7 +219,7 @@ input TigerJawLevel2Part1CreateInput { } input TigerJawLevel2Part1DeleteFieldInput { - where: TigerJawLevel2Part1Where + where: TigerJawLevel2Part1ConnectionWhere delete: TigerJawLevel2Part1DeleteInput } @@ -160,16 +228,16 @@ input TigerJawLevel2Part1DeleteInput { } input TigerJawLevel2Part1DisconnectFieldInput { - where: TigerJawLevel2Part1Where + where: TigerJawLevel2Part1ConnectionWhere disconnect: TigerJawLevel2Part1DisconnectInput } input TigerJawLevel2Part1DisconnectInput { - tiger: TigerDisconnectFieldInput + tiger: TigerJawLevel2Part1TigerDisconnectFieldInput } input TigerJawLevel2Part1FieldInput { - create: TigerJawLevel2Part1CreateInput + create: TigerJawLevel2Part1CreateFieldInput connect: TigerJawLevel2Part1ConnectFieldInput } @@ -179,11 +247,16 @@ input TigerJawLevel2Part1Options { """ sort: [TigerJawLevel2Part1Sort] limit: Int - skip: Int + offset: Int } input TigerJawLevel2Part1RelationInput { - tiger: TigerCreateInput + tiger: TigerJawLevel2Part1TigerCreateFieldInput +} + +type TigerJawLevel2Part1Relationship { + cursor: String! + node: TigerJawLevel2Part1! } """ @@ -193,30 +266,76 @@ input TigerJawLevel2Part1Sort { id: SortDirection } +input TigerConnectWhere { + node: TigerWhere! +} + +input TigerJawLevel2Part1TigerConnectFieldInput { + where: TigerConnectWhere +} + +type TigerJawLevel2Part1TigerConnection { + edges: [TigerJawLevel2Part1TigerRelationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input TigerJawLevel2Part1TigerConnectionSort { + node: TigerSort +} + +input TigerJawLevel2Part1TigerConnectionWhere { + AND: [TigerJawLevel2Part1TigerConnectionWhere!] + OR: [TigerJawLevel2Part1TigerConnectionWhere!] + node: TigerWhere + node_NOT: TigerWhere +} + +input TigerJawLevel2Part1TigerCreateFieldInput { + node: TigerCreateInput! +} + input TigerJawLevel2Part1TigerDeleteFieldInput { - where: TigerWhere + where: TigerJawLevel2Part1TigerConnectionWhere +} + +input TigerJawLevel2Part1TigerDisconnectFieldInput { + where: TigerJawLevel2Part1TigerConnectionWhere } input TigerJawLevel2Part1TigerFieldInput { - create: TigerCreateInput - connect: TigerConnectFieldInput + create: TigerJawLevel2Part1TigerCreateFieldInput + connect: TigerJawLevel2Part1TigerConnectFieldInput +} + +type TigerJawLevel2Part1TigerRelationship { + cursor: String! + node: Tiger! +} + +input TigerJawLevel2Part1TigerUpdateConnectionInput { + node: TigerUpdateInput } input TigerJawLevel2Part1TigerUpdateFieldInput { - where: TigerWhere - update: TigerUpdateInput - connect: TigerConnectFieldInput - disconnect: TigerDisconnectFieldInput - create: TigerCreateInput - delete: TigerDeleteFieldInput + where: TigerJawLevel2Part1TigerConnectionWhere + update: TigerJawLevel2Part1TigerUpdateConnectionInput + connect: TigerJawLevel2Part1TigerConnectFieldInput + disconnect: TigerJawLevel2Part1TigerDisconnectFieldInput + create: TigerJawLevel2Part1TigerCreateFieldInput + delete: TigerJawLevel2Part1TigerDeleteFieldInput +} + +input TigerJawLevel2Part1UpdateConnectionInput { + node: TigerJawLevel2Part1UpdateInput } input TigerJawLevel2Part1UpdateFieldInput { - where: TigerJawLevel2Part1Where - update: TigerJawLevel2Part1UpdateInput + where: TigerJawLevel2Part1ConnectionWhere + update: TigerJawLevel2Part1UpdateConnectionInput connect: TigerJawLevel2Part1ConnectFieldInput disconnect: TigerJawLevel2Part1DisconnectFieldInput - create: TigerJawLevel2Part1CreateInput + create: TigerJawLevel2Part1CreateFieldInput delete: TigerJawLevel2Part1DeleteFieldInput } @@ -240,12 +359,12 @@ input TigerJawLevel2Part1Where { id_NOT_ENDS_WITH: ID tiger: TigerWhere tiger_NOT: TigerWhere - tiger_IN: [TigerWhere!] - tiger_NOT_IN: [TigerWhere!] + tigerConnection: TigerJawLevel2Part1TigerConnectionWhere + tigerConnection_NOT: TigerJawLevel2Part1TigerConnectionWhere } input TigerJawLevel2RelationInput { - part1: TigerJawLevel2Part1CreateInput + part1: TigerJawLevel2Part1CreateFieldInput } """ @@ -275,8 +394,8 @@ input TigerJawLevel2Where { id_NOT_ENDS_WITH: ID part1: TigerJawLevel2Part1Where part1_NOT: TigerJawLevel2Part1Where - part1_IN: [TigerJawLevel2Part1Where!] - part1_NOT_IN: [TigerJawLevel2Part1Where!] + part1Connection: TigerJawLevel2Part1ConnectionWhere + part1Connection_NOT: TigerJawLevel2Part1ConnectionWhere } input TigerOptions { @@ -285,7 +404,7 @@ input TigerOptions { """ sort: [TigerSort] limit: Int - skip: Int + offset: Int } """ diff --git a/packages/graphql/tests/tck/tck-test-files/schema/issues/200.md b/packages/graphql/tests/tck/tck-test-files/schema/issues/200.md index 85d1867473..4ecdd14955 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/issues/200.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/issues/200.md @@ -1,130 +1,140 @@ -## #162 +# #162 --- -### 2 instances of DeleteInput type created +## 2 instances of DeleteInput type created -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Category { - categoryId: ID! @id - name: String! - description: String! @default(value: "") - exampleImageLocations: [String!] + categoryId: ID! @id + name: String! + description: String! @default(value: "") + exampleImageLocations: [String!] } ``` -**Output** +### Output -```schema-output +```graphql type Category { - categoryId: ID! - name: String! - description: String! - exampleImageLocations: [String!] + categoryId: ID! + name: String! + description: String! + exampleImageLocations: [String!] } input CategoryCreateInput { - name: String! - description: String! = "" - exampleImageLocations: [String!] + name: String! + description: String! = "" + exampleImageLocations: [String!] } input CategoryOptions { - """ - Specify one or more CategorySort objects to sort Categories by. The sorts will be applied in the order in which they are arranged in the array. - """ - sort: [CategorySort] - limit: Int - skip: Int + """ + Specify one or more CategorySort objects to sort Categories by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [CategorySort] + limit: Int + offset: Int } """ Fields to sort Categories by. The order in which sorts are applied is not guaranteed when specifying many fields in one CategorySort object. """ input CategorySort { - categoryId: SortDirection - name: SortDirection - description: SortDirection + categoryId: SortDirection + name: SortDirection + description: SortDirection } input CategoryUpdateInput { - name: String - description: String - exampleImageLocations: [String!] + name: String + description: String + exampleImageLocations: [String!] } input CategoryWhere { - OR: [CategoryWhere!] - AND: [CategoryWhere!] - categoryId: ID - categoryId_NOT: ID - categoryId_IN: [ID] - categoryId_NOT_IN: [ID] - categoryId_CONTAINS: ID - categoryId_NOT_CONTAINS: ID - categoryId_STARTS_WITH: ID - categoryId_NOT_STARTS_WITH: ID - categoryId_ENDS_WITH: ID - categoryId_NOT_ENDS_WITH: ID - name: String - name_NOT: String - name_IN: [String] - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String - description: String - description_NOT: String - description_IN: [String] - description_NOT_IN: [String] - description_CONTAINS: String - description_NOT_CONTAINS: String - description_STARTS_WITH: String - description_NOT_STARTS_WITH: String - description_ENDS_WITH: String - description_NOT_ENDS_WITH: String - exampleImageLocations: [String!] - exampleImageLocations_NOT: [String!] - exampleImageLocations_INCLUDES: String - exampleImageLocations_NOT_INCLUDES: String + OR: [CategoryWhere!] + AND: [CategoryWhere!] + categoryId: ID + categoryId_NOT: ID + categoryId_IN: [ID] + categoryId_NOT_IN: [ID] + categoryId_CONTAINS: ID + categoryId_NOT_CONTAINS: ID + categoryId_STARTS_WITH: ID + categoryId_NOT_STARTS_WITH: ID + categoryId_ENDS_WITH: ID + categoryId_NOT_ENDS_WITH: ID + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + description: String + description_NOT: String + description_IN: [String] + description_NOT_IN: [String] + description_CONTAINS: String + description_NOT_CONTAINS: String + description_STARTS_WITH: String + description_NOT_STARTS_WITH: String + description_ENDS_WITH: String + description_NOT_ENDS_WITH: String + exampleImageLocations: [String!] + exampleImageLocations_NOT: [String!] + exampleImageLocations_INCLUDES: String + exampleImageLocations_NOT_INCLUDES: String } type CreateCategoriesMutationResponse { - categories: [Category!]! + categories: [Category!]! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } type Mutation { - createCategories(input: [CategoryCreateInput!]!): CreateCategoriesMutationResponse! - deleteCategories(where: CategoryWhere): DeleteInfo! - updateCategories(where: CategoryWhere, update: CategoryUpdateInput): UpdateCategoriesMutationResponse! + createCategories( + input: [CategoryCreateInput!]! + ): CreateCategoriesMutationResponse! + deleteCategories(where: CategoryWhere): DeleteInfo! + updateCategories( + where: CategoryWhere + update: CategoryUpdateInput + ): UpdateCategoriesMutationResponse! } type Query { - categories(where: CategoryWhere, options: CategoryOptions): [Category!]! + categories(where: CategoryWhere, options: CategoryOptions): [Category!]! + categoriesCount(where: CategoryWhere): Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC } type UpdateCategoriesMutationResponse { - categories: [Category!]! + categories: [Category!]! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/null.md b/packages/graphql/tests/tck/tck-test-files/schema/null.md index 9bfefa5f38..df12f45c3e 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/null.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/null.md @@ -1,14 +1,14 @@ -## Schema Null +# Schema Null Tests that the not null of types are preserved --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { id: ID! ids: [ID!]! @@ -26,214 +26,226 @@ type Movie { } ``` -**Output** +### Output -```schema-output +```graphql type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } -"""A date and time, represented as an ISO-8601 string""" +""" +A date and time, represented as an ISO-8601 string +""" scalar DateTime type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } type Movie { - id: ID! - ids: [ID!]! - name: String! - names: [String!]! - actorCount: Int! - actorCounts: [Int!]! - averageRating: Float! - averageRatings: [Float!]! - isActives: [Boolean!]! - createdAt: DateTime! - createdAts: [DateTime!]! - filmedAt: Point! - filmedAts: [Point!]! + id: ID! + ids: [ID!]! + name: String! + names: [String!]! + actorCount: Int! + actorCounts: [Int!]! + averageRating: Float! + averageRatings: [Float!]! + isActives: [Boolean!]! + createdAt: DateTime! + createdAts: [DateTime!]! + filmedAt: Point! + filmedAts: [Point!]! } input MovieCreateInput { - id: ID! - ids: [ID!]! - name: String! - names: [String!]! - actorCount: Int! - actorCounts: [Int!]! - averageRating: Float! - averageRatings: [Float!]! - isActives: [Boolean!]! - createdAt: DateTime! - createdAts: [DateTime!]! - filmedAt: PointInput! - filmedAts: [PointInput!]! + id: ID! + ids: [ID!]! + name: String! + names: [String!]! + actorCount: Int! + actorCounts: [Int!]! + averageRating: Float! + averageRatings: [Float!]! + isActives: [Boolean!]! + createdAt: DateTime! + createdAts: [DateTime!]! + filmedAt: PointInput! + filmedAts: [PointInput!]! } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" - sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC -} - -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC +} + +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - actorCount: SortDirection - averageRating: SortDirection - createdAt: SortDirection - filmedAt: SortDirection - id: SortDirection - name: SortDirection + actorCount: SortDirection + averageRating: SortDirection + createdAt: SortDirection + filmedAt: SortDirection + id: SortDirection + name: SortDirection } input MovieUpdateInput { - id: ID - ids: [ID!] - name: String - names: [String!] - actorCount: Int - actorCounts: [Int!] - averageRating: Float - averageRatings: [Float!] - isActives: [Boolean!] - createdAt: DateTime - createdAts: [DateTime!] - filmedAt: PointInput - filmedAts: [PointInput!] + id: ID + ids: [ID!] + name: String + names: [String!] + actorCount: Int + actorCounts: [Int!] + averageRating: Float + averageRatings: [Float!] + isActives: [Boolean!] + createdAt: DateTime + createdAts: [DateTime!] + filmedAt: PointInput + filmedAts: [PointInput!] } input MovieWhere { - OR: [MovieWhere!] - AND: [MovieWhere!] - id: ID - id_NOT: ID - id_IN: [ID] - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - ids: [ID!] - ids_INCLUDES: ID - ids_NOT: [ID!] - ids_NOT_INCLUDES: ID - name: String - name_CONTAINS: String - name_ENDS_WITH: String - name_IN: [String] - name_NOT: String - name_NOT_CONTAINS: String - name_NOT_ENDS_WITH: String - name_NOT_IN: [String] - name_NOT_STARTS_WITH: String - name_STARTS_WITH: String - names: [String!] - names_INCLUDES: String - names_NOT: [String!] - names_NOT_INCLUDES: String - actorCount: Int - actorCount_NOT: Int - actorCount_IN: [Int] - actorCount_NOT_IN: [Int] - actorCount_LT: Int - actorCount_LTE: Int - actorCount_GT: Int - actorCount_GTE: Int - actorCounts: [Int!] - actorCounts_INCLUDES: Int - actorCounts_NOT: [Int!] - actorCounts_NOT_INCLUDES: Int - averageRating: Float - averageRating_NOT: Float - averageRating_IN: [Float] - averageRating_NOT_IN: [Float] - averageRating_LT: Float - averageRating_LTE: Float - averageRating_GT: Float - averageRating_GTE: Float - averageRatings: [Float!] - averageRatings_INCLUDES: Float - averageRatings_NOT: [Float!] - averageRatings_NOT_INCLUDES: Float - isActives: [Boolean!] - isActives_NOT: [Boolean!] - createdAt: DateTime - createdAt_NOT: DateTime - createdAt_IN: [DateTime] - createdAt_NOT_IN: [DateTime] - createdAt_LT: DateTime - createdAt_LTE: DateTime - createdAt_GT: DateTime - createdAt_GTE: DateTime - createdAts: [DateTime!] - createdAts_INCLUDES: DateTime - createdAts_NOT: [DateTime!] - createdAts_NOT_INCLUDES: DateTime - filmedAt: PointInput - filmedAt_NOT: PointInput - filmedAt_IN: [PointInput] - filmedAt_NOT_IN: [PointInput] - filmedAt_DISTANCE: PointDistance - filmedAt_LT: PointDistance - filmedAt_LTE: PointDistance - filmedAt_GT: PointDistance - filmedAt_GTE: PointDistance - filmedAts: [PointInput!] - filmedAts_INCLUDES: PointInput - filmedAts_NOT: [PointInput!] - filmedAts_NOT_INCLUDES: PointInput + OR: [MovieWhere!] + AND: [MovieWhere!] + id: ID + id_NOT: ID + id_IN: [ID] + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + ids: [ID!] + ids_INCLUDES: ID + ids_NOT: [ID!] + ids_NOT_INCLUDES: ID + name: String + name_CONTAINS: String + name_ENDS_WITH: String + name_IN: [String] + name_NOT: String + name_NOT_CONTAINS: String + name_NOT_ENDS_WITH: String + name_NOT_IN: [String] + name_NOT_STARTS_WITH: String + name_STARTS_WITH: String + names: [String!] + names_INCLUDES: String + names_NOT: [String!] + names_NOT_INCLUDES: String + actorCount: Int + actorCount_NOT: Int + actorCount_IN: [Int] + actorCount_NOT_IN: [Int] + actorCount_LT: Int + actorCount_LTE: Int + actorCount_GT: Int + actorCount_GTE: Int + actorCounts: [Int!] + actorCounts_INCLUDES: Int + actorCounts_NOT: [Int!] + actorCounts_NOT_INCLUDES: Int + averageRating: Float + averageRating_NOT: Float + averageRating_IN: [Float] + averageRating_NOT_IN: [Float] + averageRating_LT: Float + averageRating_LTE: Float + averageRating_GT: Float + averageRating_GTE: Float + averageRatings: [Float!] + averageRatings_INCLUDES: Float + averageRatings_NOT: [Float!] + averageRatings_NOT_INCLUDES: Float + isActives: [Boolean!] + isActives_NOT: [Boolean!] + createdAt: DateTime + createdAt_NOT: DateTime + createdAt_IN: [DateTime] + createdAt_NOT_IN: [DateTime] + createdAt_LT: DateTime + createdAt_LTE: DateTime + createdAt_GT: DateTime + createdAt_GTE: DateTime + createdAts: [DateTime!] + createdAts_INCLUDES: DateTime + createdAts_NOT: [DateTime!] + createdAts_NOT_INCLUDES: DateTime + filmedAt: PointInput + filmedAt_NOT: PointInput + filmedAt_IN: [PointInput] + filmedAt_NOT_IN: [PointInput] + filmedAt_DISTANCE: PointDistance + filmedAt_LT: PointDistance + filmedAt_LTE: PointDistance + filmedAt_GT: PointDistance + filmedAt_GTE: PointDistance + filmedAts: [PointInput!] + filmedAts_INCLUDES: PointInput + filmedAts_NOT: [PointInput!] + filmedAts_NOT_INCLUDES: PointInput } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies( - where: MovieWhere - update: MovieUpdateInput - ): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Point { - longitude: Float! - latitude: Float! - height: Float - crs: String! - srid: Int! + longitude: Float! + latitude: Float! + height: Float + crs: String! + srid: Int! } input PointDistance { - point: PointInput! - """The distance in metres to be used when comparing two points""" - distance: Float! + point: PointInput! + """ + The distance in metres to be used when comparing two points + """ + distance: Float! } input PointInput { - longitude: Float! - latitude: Float! - height: Float + longitude: Float! + latitude: Float! + height: Float } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } - ``` --- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md b/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md new file mode 100644 index 0000000000..bcb0d8776b --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/schema/relationship-properties.md @@ -0,0 +1,448 @@ +# Schema Relationship Properties + +Tests that the provided typeDefs return the correct schema (with relationships). + +--- + +## Relationship Properties + +### TypeDefs + +```graphql +type Actor { + name: String! + movies: [Movie] + @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn") +} + +type Movie { + title: String! + actors: [Actor]! + @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn") +} + +interface ActedIn { + screenTime: Int! + startDate: Date! + leadRole: Boolean! +} +``` + +### Output + +```graphql +""" +A date, represented as a 'yyyy-mm-dd' string +""" +scalar Date + +interface ActedIn { + screenTime: Int! + startDate: Date! + leadRole: Boolean! +} + +input ActedInCreateInput { + screenTime: Int! + startDate: Date! + leadRole: Boolean! +} + +input ActedInSort { + screenTime: SortDirection + startDate: SortDirection + leadRole: SortDirection +} + +input ActedInUpdateInput { + screenTime: Int + startDate: Date + leadRole: Boolean +} + +input ActedInWhere { + OR: [ActedInWhere!] + AND: [ActedInWhere!] + screenTime: Int + screenTime_NOT: Int + screenTime_IN: [Int] + screenTime_NOT_IN: [Int] + screenTime_LT: Int + screenTime_LTE: Int + screenTime_GT: Int + screenTime_GTE: Int + startDate: Date + startDate_NOT: Date + startDate_IN: [Date] + startDate_NOT_IN: [Date] + startDate_LT: Date + startDate_LTE: Date + startDate_GT: Date + startDate_GTE: Date + leadRole: Boolean + leadRole_NOT: Boolean +} + +type Actor { + name: String! + movies(where: MovieWhere, options: MovieOptions): [Movie] + moviesConnection( + after: String + first: Int + where: ActorMoviesConnectionWhere + sort: [ActorMoviesConnectionSort!] + ): ActorMoviesConnection! +} + +input ActorConnectInput { + movies: [ActorMoviesConnectFieldInput!] +} + +input ActorCreateInput { + name: String! + movies: ActorMoviesFieldInput +} + +input ActorDeleteInput { + movies: [ActorMoviesDeleteFieldInput!] +} + +input ActorMoviesDeleteFieldInput { + delete: MovieDeleteInput + where: ActorMoviesConnectionWhere +} + +input ActorMoviesDisconnectFieldInput { + disconnect: MovieDisconnectInput + where: ActorMoviesConnectionWhere +} + +input ActorDisconnectInput { + movies: [ActorMoviesDisconnectFieldInput!] +} + +input MovieConnectWhere { + node: MovieWhere! +} + +input ActorMoviesConnectFieldInput { + where: MovieConnectWhere + connect: [MovieConnectInput!] + edge: ActedInCreateInput! +} + +type ActorMoviesConnection { + edges: [ActorMoviesRelationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input ActorMoviesConnectionSort { + node: MovieSort + edge: ActedInSort +} + +input ActorMoviesConnectionWhere { + AND: [ActorMoviesConnectionWhere!] + OR: [ActorMoviesConnectionWhere!] + edge: ActedInWhere + edge_NOT: ActedInWhere + node: MovieWhere + node_NOT: MovieWhere +} + +input ActorMoviesCreateFieldInput { + node: MovieCreateInput! + edge: ActedInCreateInput! +} + +input ActorMoviesFieldInput { + create: [ActorMoviesCreateFieldInput!] + connect: [ActorMoviesConnectFieldInput!] +} + +type ActorMoviesRelationship implements ActedIn { + cursor: String! + node: Movie! + screenTime: Int! + startDate: Date! + leadRole: Boolean! +} + +input ActorMoviesUpdateConnectionInput { + node: MovieUpdateInput + edge: ActedInUpdateInput +} + +input ActorMoviesUpdateFieldInput { + where: ActorMoviesConnectionWhere + update: ActorMoviesUpdateConnectionInput + connect: [ActorMoviesConnectFieldInput!] + disconnect: [ActorMoviesDisconnectFieldInput!] + create: [ActorMoviesCreateFieldInput!] + delete: [ActorMoviesDeleteFieldInput!] +} + +input ActorOptions { + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] + limit: Int + offset: Int +} + +input ActorRelationInput { + movies: [ActorMoviesCreateFieldInput!] +} + +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" +input ActorSort { + name: SortDirection +} + +input ActorUpdateInput { + name: String + movies: [ActorMoviesUpdateFieldInput!] +} + +input ActorWhere { + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + movies: MovieWhere + movies_NOT: MovieWhere + moviesConnection: ActorMoviesConnectionWhere + moviesConnection_NOT: ActorMoviesConnectionWhere +} + +input ActorConnectWhere { + node: ActorWhere! +} + +type CreateActorsMutationResponse { + actors: [Actor!]! +} + +type CreateMoviesMutationResponse { + movies: [Movie!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +type Movie { + title: String! + actors(where: ActorWhere, options: ActorOptions): [Actor]! + actorsConnection( + after: String + first: Int + where: MovieActorsConnectionWhere + sort: [MovieActorsConnectionSort!] + ): MovieActorsConnection! +} + +input MovieActorsConnectFieldInput { + where: ActorConnectWhere + connect: [ActorConnectInput!] + edge: ActedInCreateInput! +} + +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input MovieActorsConnectionSort { + node: ActorSort + edge: ActedInSort +} + +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + edge: ActedInWhere + edge_NOT: ActedInWhere + node: ActorWhere + node_NOT: ActorWhere +} + +input MovieActorsCreateFieldInput { + node: ActorCreateInput! + edge: ActedInCreateInput! +} + +input MovieActorsFieldInput { + create: [MovieActorsCreateFieldInput!] + connect: [MovieActorsConnectFieldInput!] +} + +type MovieActorsRelationship implements ActedIn { + cursor: String! + node: Actor! + screenTime: Int! + startDate: Date! + leadRole: Boolean! +} + +input MovieActorsUpdateConnectionInput { + node: ActorUpdateInput + edge: ActedInUpdateInput +} + +input MovieActorsUpdateFieldInput { + where: MovieActorsConnectionWhere + update: MovieActorsUpdateConnectionInput + connect: [MovieActorsConnectFieldInput!] + disconnect: [MovieActorsDisconnectFieldInput!] + create: [MovieActorsCreateFieldInput!] + delete: [MovieActorsDeleteFieldInput!] +} + +input MovieConnectInput { + actors: [MovieActorsConnectFieldInput!] +} + +input MovieCreateInput { + title: String! + actors: MovieActorsFieldInput +} + +input MovieDeleteInput { + actors: [MovieActorsDeleteFieldInput!] +} + +input MovieActorsDeleteFieldInput { + delete: ActorDeleteInput + where: MovieActorsConnectionWhere +} + +input MovieActorsDisconnectFieldInput { + disconnect: ActorDisconnectInput + where: MovieActorsConnectionWhere +} + +input MovieDisconnectInput { + actors: [MovieActorsDisconnectFieldInput!] +} + +input MovieOptions { + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int +} + +input MovieRelationInput { + actors: [MovieActorsCreateFieldInput!] +} + +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" +input MovieSort { + title: SortDirection +} + +input MovieUpdateInput { + title: String + actors: [MovieActorsUpdateFieldInput!] +} + +input MovieWhere { + OR: [MovieWhere!] + AND: [MovieWhere!] + title: String + title_NOT: String + title_IN: [String] + title_NOT_IN: [String] + title_CONTAINS: String + title_NOT_CONTAINS: String + title_STARTS_WITH: String + title_NOT_STARTS_WITH: String + title_ENDS_WITH: String + title_NOT_ENDS_WITH: String + actors: ActorWhere + actors_NOT: ActorWhere + actorsConnection: MovieActorsConnectionWhere + actorsConnection_NOT: MovieActorsConnectionWhere +} + +type Mutation { + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors(where: ActorWhere, delete: ActorDeleteInput): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + connect: ActorConnectInput + disconnect: ActorDisconnectInput + create: ActorRelationInput + delete: ActorDeleteInput + ): UpdateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String +} + +type Query { + actors(where: ActorWhere, options: ActorOptions): [Actor!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! +} + +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC +} + +type UpdateActorsMutationResponse { + actors: [Actor!]! +} + +type UpdateMoviesMutationResponse { + movies: [Movie!]! +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/relationship.md b/packages/graphql/tests/tck/tck-test-files/schema/relationship.md index 3bf93224c8..a6b2c9b49b 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/relationship.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/relationship.md @@ -1,14 +1,14 @@ -## Schema Relationship +# Schema Relationship Tests that the provided typeDefs return the correct schema (with relationships). --- -### Single Relationship +## Single Relationship -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor { name: String } @@ -19,191 +19,261 @@ type Movie { } ``` -**Output** +### Output -```schema-output +```graphql type Actor { - name: String -} - -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! -} - -enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + name: String } input ActorCreateInput { - name: String + name: String } input ActorOptions { - """Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [ActorSort] - limit: Int - skip: Int + limit: Int + offset: Int + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] } -"""Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object.""" +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" input ActorSort { - name: SortDirection + name: SortDirection +} + +input ActorUpdateInput { + name: String } input ActorWhere { - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String - OR: [ActorWhere!] - AND: [ActorWhere!] + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String } -input ActorConnectFieldInput { - where: ActorWhere +type CreateActorsMutationResponse { + actors: [Actor!]! } -input ActorDisconnectFieldInput { - where: ActorWhere +type CreateMoviesMutationResponse { + movies: [Movie!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! } type Movie { - id: ID - actors(where: ActorWhere, options: ActorOptions): [Actor]! + id: ID + actors(where: ActorWhere, options: ActorOptions): [Actor]! + actorsConnection( + after: String + first: Int + sort: [MovieActorsConnectionSort!] + where: MovieActorsConnectionWhere + ): MovieActorsConnection! } -input MovieActorsFieldInput { - connect: [ActorConnectFieldInput!] - create: [ActorCreateInput!] +input ActorConnectWhere { + node: ActorWhere! } -input MovieRelationInput { - actors: [ActorCreateInput!] +input MovieActorsConnectFieldInput { + where: ActorConnectWhere } -input MovieCreateInput { - id: ID - actors: MovieActorsFieldInput +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! + pageInfo: PageInfo! + totalCount: Int! } -input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int +input MovieActorsConnectionSort { + node: ActorSort } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" -input MovieSort { - id: SortDirection +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + node: ActorWhere + node_NOT: ActorWhere } -input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - OR: [MovieWhere!] - AND: [MovieWhere!] - actors: ActorWhere - actors_NOT: ActorWhere +input MovieActorsCreateFieldInput { + node: ActorCreateInput! } -input MovieUpdateInput { - id: ID - actors: [MovieActorsUpdateFieldInput!] +input MovieActorsDeleteFieldInput { + where: MovieActorsConnectionWhere } -input ActorDeleteFieldInput { - where: ActorWhere +input MovieActorsDisconnectFieldInput { + where: MovieActorsConnectionWhere +} + +input MovieActorsFieldInput { + create: [MovieActorsCreateFieldInput!] + connect: [MovieActorsConnectFieldInput!] +} + +type MovieActorsRelationship { + cursor: String! + node: Actor! +} + +input MovieActorsUpdateConnectionInput { + node: ActorUpdateInput } input MovieActorsUpdateFieldInput { - connect: [ActorConnectFieldInput!] - create: [ActorCreateInput!] - disconnect: [ActorDisconnectFieldInput!] - update: ActorUpdateInput - where: ActorWhere - delete: [ActorDeleteFieldInput!] + where: MovieActorsConnectionWhere + update: MovieActorsUpdateConnectionInput + connect: [MovieActorsConnectFieldInput!] + disconnect: [MovieActorsDisconnectFieldInput!] + create: [MovieActorsCreateFieldInput!] + delete: [MovieActorsDeleteFieldInput!] } input MovieConnectInput { - actors: [ActorConnectFieldInput!] + actors: [MovieActorsConnectFieldInput!] } -input MovieDisconnectInput { - actors: [ActorDisconnectFieldInput!] +input MovieCreateInput { + id: ID + actors: MovieActorsFieldInput } -input ActorUpdateInput { - name: String +input MovieDeleteInput { + actors: [MovieActorsDeleteFieldInput!] } -type CreateMoviesMutationResponse { - movies: [Movie!]! +input MovieDisconnectInput { + actors: [MovieActorsDisconnectFieldInput!] } -input MovieActorsDeleteFieldInput { - where: ActorWhere +input MovieOptions { + limit: Int + offset: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] } -input MovieDeleteInput { - actors: [MovieActorsDeleteFieldInput!] +input MovieRelationInput { + actors: [MovieActorsCreateFieldInput!] } -type UpdateMoviesMutationResponse { - movies: [Movie!]! +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" +input MovieSort { + id: SortDirection } -type CreateActorsMutationResponse { - actors: [Actor!]! +input MovieUpdateInput { + id: ID + actors: [MovieActorsUpdateFieldInput!] } -type UpdateActorsMutationResponse { - actors: [Actor!]! +input MovieWhere { + OR: [MovieWhere!] + AND: [MovieWhere!] + id: ID + id_NOT: ID + id_IN: [ID] + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + actors: ActorWhere + actors_NOT: ActorWhere + actorsConnection: MovieActorsConnectionWhere + actorsConnection_NOT: MovieActorsConnectionWhere } type Mutation { - createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies( - where: MovieWhere - delete: MovieDeleteInput - ): DeleteInfo! - deleteActors(where: ActorWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput, connect: MovieConnectInput, disconnect: MovieDisconnectInput, create: MovieRelationInput, delete: MovieDeleteInput): UpdateMoviesMutationResponse! - updateActors(where: ActorWhere, update: ActorUpdateInput): UpdateActorsMutationResponse! + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors(where: ActorWhere): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + ): UpdateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String } type Query { - actors(where: ActorWhere, options: ActorOptions): [Actor!]! - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + actors(where: ActorWhere, options: ActorOptions): [Actor!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + actorsCount(where: ActorWhere): Int! +} + +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC +} + +type UpdateActorsMutationResponse { + actors: [Actor!]! +} + +type UpdateMoviesMutationResponse { + movies: [Movie!]! } ``` --- -### Multi Relationship +## Multi Relationship -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Actor { name: String movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT) @@ -215,257 +285,350 @@ type Movie { } ``` -**Output** - -```schema-output -enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC -} +### Output +```graphql type Actor { - name: String - movies(where: MovieWhere, options: MovieOptions): [Movie] -} - -input ActorConnectFieldInput { - where: ActorWhere - connect: ActorConnectInput + name: String + movies(where: MovieWhere, options: MovieOptions): [Movie] + moviesConnection( + after: String + first: Int + where: ActorMoviesConnectionWhere + sort: [ActorMoviesConnectionSort!] + ): ActorMoviesConnection! } input ActorConnectInput { - movies: [MovieConnectFieldInput!] + movies: [ActorMoviesConnectFieldInput!] } input ActorCreateInput { - name: String - movies: ActorMoviesFieldInput + name: String + movies: ActorMoviesFieldInput } -input ActorRelationInput { - movies: [MovieCreateInput!] +input ActorDeleteInput { + movies: [ActorMoviesDeleteFieldInput!] } -input ActorDisconnectFieldInput { - where: ActorWhere - disconnect: ActorDisconnectInput +input ActorMoviesDeleteFieldInput { + delete: MovieDeleteInput + where: ActorMoviesConnectionWhere +} + +input ActorMoviesDisconnectFieldInput { + disconnect: MovieDisconnectInput + where: ActorMoviesConnectionWhere } input ActorDisconnectInput { - movies: [MovieDisconnectFieldInput!] + movies: [ActorMoviesDisconnectFieldInput!] +} + +input MovieConnectWhere { + node: MovieWhere! +} + +input ActorMoviesConnectFieldInput { + where: MovieConnectWhere + connect: [MovieConnectInput!] +} + +type ActorMoviesConnection { + edges: [ActorMoviesRelationship!]! + pageInfo: PageInfo! + totalCount: Int! +} + +input ActorMoviesConnectionSort { + node: MovieSort +} + +input ActorMoviesConnectionWhere { + AND: [ActorMoviesConnectionWhere!] + OR: [ActorMoviesConnectionWhere!] + node: MovieWhere + node_NOT: MovieWhere +} + +input ActorMoviesCreateFieldInput { + node: MovieCreateInput! } input ActorMoviesFieldInput { - create: [MovieCreateInput!] - connect: [MovieConnectFieldInput!] + create: [ActorMoviesCreateFieldInput!] + connect: [ActorMoviesConnectFieldInput!] +} + +type ActorMoviesRelationship { + cursor: String! + node: Movie! +} + +input ActorMoviesUpdateConnectionInput { + node: MovieUpdateInput } input ActorMoviesUpdateFieldInput { - where: MovieWhere - update: MovieUpdateInput - connect: [MovieConnectFieldInput!] - create: [MovieCreateInput!] - disconnect: [MovieDisconnectFieldInput!] - delete: [MovieDeleteFieldInput!] + where: ActorMoviesConnectionWhere + update: ActorMoviesUpdateConnectionInput + connect: [ActorMoviesConnectFieldInput!] + disconnect: [ActorMoviesDisconnectFieldInput!] + create: [ActorMoviesCreateFieldInput!] + delete: [ActorMoviesDeleteFieldInput!] } input ActorOptions { - """Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [ActorSort] - limit: Int - skip: Int + limit: Int + offset: Int + """ + Specify one or more ActorSort objects to sort Actors by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [ActorSort] +} + +input ActorRelationInput { + movies: [ActorMoviesCreateFieldInput!] } -"""Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object.""" +""" +Fields to sort Actors by. The order in which sorts are applied is not guaranteed when specifying many fields in one ActorSort object. +""" input ActorSort { - name: SortDirection + name: SortDirection } input ActorUpdateInput { - name: String - movies: [ActorMoviesUpdateFieldInput!] + name: String + movies: [ActorMoviesUpdateFieldInput!] } input ActorWhere { - OR: [ActorWhere!] - AND: [ActorWhere!] - name: String - name_IN: [String] - name_NOT: String - name_NOT_IN: [String] - name_CONTAINS: String - name_NOT_CONTAINS: String - name_STARTS_WITH: String - name_NOT_STARTS_WITH: String - name_ENDS_WITH: String - name_NOT_ENDS_WITH: String - movies: MovieWhere - movies_NOT: MovieWhere + OR: [ActorWhere!] + AND: [ActorWhere!] + name: String + name_NOT: String + name_IN: [String] + name_NOT_IN: [String] + name_CONTAINS: String + name_NOT_CONTAINS: String + name_STARTS_WITH: String + name_NOT_STARTS_WITH: String + name_ENDS_WITH: String + name_NOT_ENDS_WITH: String + movies: MovieWhere + movies_NOT: MovieWhere + moviesConnection: ActorMoviesConnectionWhere + moviesConnection_NOT: ActorMoviesConnectionWhere +} + +type CreateActorsMutationResponse { + actors: [Actor!]! +} + +type CreateMoviesMutationResponse { + movies: [Movie!]! } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } type Movie { - id: ID - actors(where: ActorWhere, options: ActorOptions): [Actor]! + id: ID + actors(where: ActorWhere, options: ActorOptions): [Actor]! + actorsConnection( + after: String + first: Int + where: MovieActorsConnectionWhere + sort: [MovieActorsConnectionSort!] + ): MovieActorsConnection! } -input MovieActorsFieldInput { - create: [ActorCreateInput!] - connect: [ActorConnectFieldInput!] +input ActorConnectWhere { + node: ActorWhere! } -input MovieActorsUpdateFieldInput { - where: ActorWhere - update: ActorUpdateInput - create: [ActorCreateInput!] - connect: [ActorConnectFieldInput!] - disconnect: [ActorDisconnectFieldInput!] - delete: [ActorDeleteFieldInput!] +input MovieActorsConnectFieldInput { + where: ActorConnectWhere + connect: [ActorConnectInput!] } -input MovieConnectFieldInput { - where: MovieWhere - connect: MovieConnectInput +type MovieActorsConnection { + edges: [MovieActorsRelationship!]! + pageInfo: PageInfo! + totalCount: Int! } -input MovieConnectInput { - actors: [ActorConnectFieldInput!] +input MovieActorsConnectionSort { + node: ActorSort } -input MovieCreateInput { - id: ID - actors: MovieActorsFieldInput +input MovieActorsConnectionWhere { + AND: [MovieActorsConnectionWhere!] + OR: [MovieActorsConnectionWhere!] + node: ActorWhere + node_NOT: ActorWhere } -input MovieDisconnectFieldInput { - where: MovieWhere - disconnect: MovieDisconnectInput +input MovieActorsCreateFieldInput { + node: ActorCreateInput! } -input MovieDisconnectInput { - actors: [ActorDisconnectFieldInput!] +input MovieActorsDeleteFieldInput { + delete: ActorDeleteInput + where: MovieActorsConnectionWhere } -input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int +input MovieActorsDisconnectFieldInput { + disconnect: ActorDisconnectInput + where: MovieActorsConnectionWhere } -input MovieRelationInput { - actors: [ActorCreateInput!] +input MovieActorsFieldInput { + create: [MovieActorsCreateFieldInput!] + connect: [MovieActorsConnectFieldInput!] } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" -input MovieSort { - id: SortDirection +type MovieActorsRelationship { + cursor: String! + node: Actor! } -input MovieActorsDeleteFieldInput { - where: ActorWhere - delete: ActorDeleteInput +input MovieActorsUpdateConnectionInput { + node: ActorUpdateInput } -input ActorMoviesDeleteFieldInput { - where: MovieWhere - delete: MovieDeleteInput +input MovieActorsUpdateFieldInput { + where: MovieActorsConnectionWhere + update: MovieActorsUpdateConnectionInput + connect: [MovieActorsConnectFieldInput!] + disconnect: [MovieActorsDisconnectFieldInput!] + create: [MovieActorsCreateFieldInput!] + delete: [MovieActorsDeleteFieldInput!] +} + +input MovieConnectInput { + actors: [MovieActorsConnectFieldInput!] +} + +input MovieCreateInput { + id: ID + actors: MovieActorsFieldInput } input MovieDeleteInput { - actors: [MovieActorsDeleteFieldInput!] + actors: [MovieActorsDeleteFieldInput!] } -input MovieDeleteFieldInput { - where: MovieWhere - delete: MovieDeleteInput +input MovieDisconnectInput { + actors: [MovieActorsDisconnectFieldInput!] } -input ActorDeleteInput { - movies: [ActorMoviesDeleteFieldInput!] +input MovieOptions { + limit: Int + offset: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] } -input ActorDeleteFieldInput { - where: ActorWhere - delete: ActorDeleteInput +input MovieRelationInput { + actors: [MovieActorsCreateFieldInput!] +} + +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" +input MovieSort { + id: SortDirection } input MovieUpdateInput { - id: ID - actors: [MovieActorsUpdateFieldInput!] + id: ID + actors: [MovieActorsUpdateFieldInput!] } input MovieWhere { - OR: [MovieWhere!] - AND: [MovieWhere!] - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - actors: ActorWhere - actors_NOT: ActorWhere + OR: [MovieWhere!] + AND: [MovieWhere!] + id: ID + id_NOT: ID + id_IN: [ID] + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + actors: ActorWhere + actors_NOT: ActorWhere + actorsConnection: MovieActorsConnectionWhere + actorsConnection_NOT: MovieActorsConnectionWhere } -type CreateMoviesMutationResponse { - movies: [Movie!]! -} +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC -type UpdateMoviesMutationResponse { - movies: [Movie!]! + """ + Sort by field values in descending order. + """ + DESC } -type CreateActorsMutationResponse { - actors: [Actor!]! +type UpdateActorsMutationResponse { + actors: [Actor!]! } -type UpdateActorsMutationResponse { - actors: [Actor!]! +type UpdateMoviesMutationResponse { + movies: [Movie!]! } type Mutation { - createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! - deleteActors( - where: ActorWhere - delete: ActorDeleteInput - ): DeleteInfo! - updateActors( - where: ActorWhere - update: ActorUpdateInput - connect: ActorConnectInput - disconnect: ActorDisconnectInput - create: ActorRelationInput - delete: ActorDeleteInput - ): UpdateActorsMutationResponse! - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies( - where: MovieWhere - delete: MovieDeleteInput - ): DeleteInfo! - updateMovies( - where: MovieWhere - update: MovieUpdateInput - connect: MovieConnectInput - disconnect: MovieDisconnectInput - create: MovieRelationInput - delete: MovieDeleteInput - ): UpdateMoviesMutationResponse! + createActors(input: [ActorCreateInput!]!): CreateActorsMutationResponse! + deleteActors(where: ActorWhere, delete: ActorDeleteInput): DeleteInfo! + updateActors( + where: ActorWhere + update: ActorUpdateInput + connect: ActorConnectInput + disconnect: ActorDisconnectInput + create: ActorRelationInput + delete: ActorDeleteInput + ): UpdateActorsMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String } type Query { - actors(where: ActorWhere, options: ActorOptions): [Actor!]! - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + actors(where: ActorWhere, options: ActorOptions): [Actor!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + actorsCount(where: ActorWhere): Int! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/scalar.md b/packages/graphql/tests/tck/tck-test-files/schema/scalar.md index a174471190..348a7b7a52 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/scalar.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/scalar.md @@ -1,99 +1,111 @@ -## Schema Scalars +# Schema Scalars Tests that the provided typeDefs return the correct schema(with scalars). --- -### Scalars +## Scalars -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql scalar CustomScalar type Movie { - id: ID - myCustomScalar: CustomScalar + id: ID + myCustomScalar: CustomScalar } ``` -**Output** +### Output -```schema-output +```graphql scalar CustomScalar type Movie { - id: ID - myCustomScalar: CustomScalar + id: ID + myCustomScalar: CustomScalar } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID - myCustomScalar: CustomScalar + id: ID + myCustomScalar: CustomScalar } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - myCustomScalar: SortDirection + id: SortDirection + myCustomScalar: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - myCustomScalar: CustomScalar - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + myCustomScalar: CustomScalar + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID - myCustomScalar: CustomScalar + id: ID + myCustomScalar: CustomScalar } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/simple.md b/packages/graphql/tests/tck/tck-test-files/schema/simple.md index 740523f94c..d84a397159 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/simple.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/simple.md @@ -1,14 +1,14 @@ -## Schema Simple +# Schema Simple Tests that the provided typeDefs return the correct schema. --- -### Simple +## Simple -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { id: ID actorCount: Int @@ -17,106 +17,118 @@ type Movie { } ``` -**Output** +### Output -```schema-output +```graphql type Movie { - id: ID - actorCount: Int - averageRating: Float - isActive: Boolean + id: ID + actorCount: Int + averageRating: Float + isActive: Boolean } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID - actorCount: Int - averageRating: Float - isActive: Boolean + id: ID + actorCount: Int + averageRating: Float + isActive: Boolean } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - actorCount: SortDirection - averageRating: SortDirection - isActive: SortDirection + id: SortDirection + actorCount: SortDirection + averageRating: SortDirection + isActive: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - actorCount: Int - actorCount_IN: [Int] - actorCount_NOT: Int - actorCount_NOT_IN: [Int] - actorCount_LT: Int - actorCount_LTE: Int - actorCount_GT: Int - actorCount_GTE: Int - averageRating: Float - averageRating_IN: [Float] - averageRating_NOT: Float - averageRating_NOT_IN: [Float] - averageRating_LT: Float - averageRating_LTE: Float - averageRating_GT: Float - averageRating_GTE: Float - isActive: Boolean - isActive_NOT: Boolean - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + actorCount: Int + actorCount_IN: [Int] + actorCount_NOT: Int + actorCount_NOT_IN: [Int] + actorCount_LT: Int + actorCount_LTE: Int + actorCount_GT: Int + actorCount_GTE: Int + averageRating: Float + averageRating_IN: [Float] + averageRating_NOT: Float + averageRating_NOT_IN: [Float] + averageRating_LT: Float + averageRating_LTE: Float + averageRating_GT: Float + averageRating_GTE: Float + isActive: Boolean + isActive_NOT: Boolean + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID - actorCount: Int - averageRating: Float - isActive: Boolean + id: ID + actorCount: Int + averageRating: Float + isActive: Boolean } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md b/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md index 55258a130e..5bd4ef4f54 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/bigint.md @@ -1,24 +1,26 @@ -## Schema BigInt +# Schema BigInt Tests that the provided typeDefs return the correct schema. --- -### BigInt +## BigInt -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type File { name: String! size: BigInt! } ``` -**Output** +### Output -```schema-output -"""A BigInt value up to 64 bits in size, which can be a number or a string if used inline, or a string only if used as a variable. Always returned as a string.""" +```graphql +""" +A BigInt value up to 64 bits in size, which can be a number or a string if used inline, or a string only if used as a variable. Always returned as a string. +""" scalar BigInt type File { @@ -27,79 +29,91 @@ type File { } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input FileCreateInput { - name: String! - size: BigInt! + name: String! + size: BigInt! } input FileOptions { - """Specify one or more FileSort objects to sort Files by. The sorts will be applied in the order in which they are arranged in the array.""" - sort: [FileSort] - limit: Int - skip: Int + """ + Specify one or more FileSort objects to sort Files by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [FileSort] + limit: Int + offset: Int } -"""Fields to sort Files by. The order in which sorts are applied is not guaranteed when specifying many fields in one FileSort object.""" +""" +Fields to sort Files by. The order in which sorts are applied is not guaranteed when specifying many fields in one FileSort object. +""" input FileSort { - name: SortDirection - size: SortDirection + name: SortDirection + size: SortDirection } input FileWhere { - name: String - name_CONTAINS: String - name_ENDS_WITH: String - name_IN: [String] - name_NOT: String - name_NOT_CONTAINS: String - name_NOT_ENDS_WITH: String - name_NOT_IN: [String] - name_NOT_STARTS_WITH: String - name_STARTS_WITH: String - size: BigInt - size_IN: [BigInt] - size_NOT: BigInt - size_NOT_IN: [BigInt] - size_LT: BigInt - size_LTE: BigInt - size_GT: BigInt - size_GTE: BigInt - OR: [FileWhere!] - AND: [FileWhere!] + name: String + name_CONTAINS: String + name_ENDS_WITH: String + name_IN: [String] + name_NOT: String + name_NOT_CONTAINS: String + name_NOT_ENDS_WITH: String + name_NOT_IN: [String] + name_NOT_STARTS_WITH: String + name_STARTS_WITH: String + size: BigInt + size_IN: [BigInt] + size_NOT: BigInt + size_NOT_IN: [BigInt] + size_LT: BigInt + size_LTE: BigInt + size_GT: BigInt + size_GTE: BigInt + OR: [FileWhere!] + AND: [FileWhere!] } input FileUpdateInput { - name: String - size: BigInt + name: String + size: BigInt } type CreateFilesMutationResponse { - files: [File!]! + files: [File!]! } type UpdateFilesMutationResponse { - files: [File!]! + files: [File!]! } type Mutation { - createFiles(input: [FileCreateInput!]!): CreateFilesMutationResponse! - deleteFiles(where: FileWhere): DeleteInfo! - updateFiles(where: FileWhere, update: FileUpdateInput): UpdateFilesMutationResponse! + createFiles(input: [FileCreateInput!]!): CreateFilesMutationResponse! + deleteFiles(where: FileWhere): DeleteInfo! + updateFiles( + where: FileWhere + update: FileUpdateInput + ): UpdateFilesMutationResponse! } type Query { - files(where: FileWhere, options: FileOptions): [File!]! + files(where: FileWhere, options: FileOptions): [File!]! + filesCount(where: FileWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/date.md b/packages/graphql/tests/tck/tck-test-files/schema/types/date.md index b524d89e21..981ead6696 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/date.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/date.md @@ -1,106 +1,119 @@ -## Schema Date +# Schema Date Tests that the provided typeDefs return the correct schema. --- -### Date +## Date -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { id: ID date: Date } ``` -**Output** +### Output -```schema-output - -"""A date, represented as a 'yyyy-mm-dd' string""" +```graphql +""" +A date, represented as a 'yyyy-mm-dd' string +""" scalar Date type Movie { - id: ID - date: Date + id: ID + date: Date } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID - date: Date + id: ID + date: Date } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - date: SortDirection + id: SortDirection + date: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - date: Date - date_GT: Date - date_GTE: Date - date_IN: [Date] - date_NOT: Date - date_NOT_IN: [Date] - date_LT: Date - date_LTE: Date - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + date: Date + date_GT: Date + date_GTE: Date + date_IN: [Date] + date_NOT: Date + date_NOT_IN: [Date] + date_LT: Date + date_LTE: Date + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID - date: Date + id: ID + date: Date } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md b/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md index d79d67cfa0..466705ba80 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/datetime.md @@ -1,106 +1,119 @@ -## Schema DateTime +# Schema DateTime Tests that the provided typeDefs return the correct schema. --- -### DateTime +## DateTime -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql type Movie { id: ID datetime: DateTime } ``` -**Output** +### Output -```schema-output - -"""A date and time, represented as an ISO-8601 string""" +```graphql +""" +A date and time, represented as an ISO-8601 string +""" scalar DateTime type Movie { - id: ID - datetime: DateTime + id: ID + datetime: DateTime } type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! + nodesDeleted: Int! + relationshipsDeleted: Int! } enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC } input MovieCreateInput { - id: ID - datetime: DateTime + id: ID + datetime: DateTime } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" input MovieSort { - id: SortDirection - datetime: SortDirection + id: SortDirection + datetime: SortDirection } input MovieWhere { - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID - datetime: DateTime - datetime_NOT: DateTime - datetime_IN: [DateTime] - datetime_NOT_IN: [DateTime] - datetime_LT: DateTime - datetime_LTE: DateTime - datetime_GT: DateTime - datetime_GTE: DateTime - OR: [MovieWhere!] - AND: [MovieWhere!] + id: ID + id_IN: [ID] + id_NOT: ID + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + datetime: DateTime + datetime_NOT: DateTime + datetime_IN: [DateTime] + datetime_NOT_IN: [DateTime] + datetime_LT: DateTime + datetime_LTE: DateTime + datetime_GT: DateTime + datetime_GTE: DateTime + OR: [MovieWhere!] + AND: [MovieWhere!] } input MovieUpdateInput { - id: ID - datetime: DateTime + id: ID + datetime: DateTime } type CreateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type UpdateMoviesMutationResponse { - movies: [Movie!]! + movies: [Movie!]! } type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! } type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! } ``` diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/point.md b/packages/graphql/tests/tck/tck-test-files/schema/types/point.md new file mode 100644 index 0000000000..796daf3a35 --- /dev/null +++ b/packages/graphql/tests/tck/tck-test-files/schema/types/point.md @@ -0,0 +1,408 @@ +# Schema Points + +Tests that the provided typeDefs return the correct schema. + +--- + +## Point + +### TypeDefs + +```graphql +type Movie { + filmedAt: Point! +} +``` + +### Output + +```graphql +type Point { + latitude: Float! + longitude: Float! + height: Float + crs: String! + srid: Int! +} + +input PointInput { + latitude: Float! + longitude: Float! + height: Float +} + +input PointDistance { + point: PointInput! + """ + The distance in metres to be used when comparing two points + """ + distance: Float! +} + +type Movie { + filmedAt: Point! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC +} + +input MovieCreateInput { + filmedAt: PointInput! +} + +input MovieOptions { + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int +} + +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" +input MovieSort { + filmedAt: SortDirection +} + +input MovieWhere { + filmedAt: PointInput + filmedAt_NOT: PointInput + filmedAt_IN: [PointInput] + filmedAt_NOT_IN: [PointInput] + filmedAt_LT: PointDistance + filmedAt_LTE: PointDistance + filmedAt_GT: PointDistance + filmedAt_GTE: PointDistance + filmedAt_DISTANCE: PointDistance + OR: [MovieWhere!] + AND: [MovieWhere!] +} + +input MovieUpdateInput { + filmedAt: PointInput +} + +type CreateMoviesMutationResponse { + movies: [Movie!]! +} + +type UpdateMoviesMutationResponse { + movies: [Movie!]! +} + +type Mutation { + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! +} + +type Query { + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! +} +``` + +--- + +## CartesianPoint + +### TypeDefs + +```graphql +type Machine { + partLocation: CartesianPoint! +} +``` + +### Output + +```graphql +type CartesianPoint { + x: Float! + y: Float! + z: Float + crs: String! + srid: Int! +} + +input CartesianPointInput { + x: Float! + y: Float! + z: Float +} + +input CartesianPointDistance { + point: CartesianPointInput! + distance: Float! +} + +type Machine { + partLocation: CartesianPoint! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + """ + Sort by field values in descending order. + """ + DESC +} + +input MachineCreateInput { + partLocation: CartesianPointInput! +} + +input MachineOptions { + """ + Specify one or more MachineSort objects to sort Machines by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MachineSort] + limit: Int + offset: Int +} + +""" +Fields to sort Machines by. The order in which sorts are applied is not guaranteed when specifying many fields in one MachineSort object. +""" +input MachineSort { + partLocation: SortDirection +} + +input MachineWhere { + partLocation: CartesianPointInput + partLocation_NOT: CartesianPointInput + partLocation_IN: [CartesianPointInput] + partLocation_NOT_IN: [CartesianPointInput] + partLocation_LT: CartesianPointDistance + partLocation_LTE: CartesianPointDistance + partLocation_GT: CartesianPointDistance + partLocation_GTE: CartesianPointDistance + partLocation_DISTANCE: CartesianPointDistance + OR: [MachineWhere!] + AND: [MachineWhere!] +} + +input MachineUpdateInput { + partLocation: CartesianPointInput +} + +type CreateMachinesMutationResponse { + machines: [Machine!]! +} + +type UpdateMachinesMutationResponse { + machines: [Machine!]! +} + +type Mutation { + createMachines( + input: [MachineCreateInput!]! + ): CreateMachinesMutationResponse! + deleteMachines(where: MachineWhere): DeleteInfo! + updateMachines( + where: MachineWhere + update: MachineUpdateInput + ): UpdateMachinesMutationResponse! +} + +type Query { + machines(where: MachineWhere, options: MachineOptions): [Machine!]! + machinesCount(where: MachineWhere): Int! +} +``` + +--- + +## Points + +### TypeDefs + +```graphql +type Movie { + filmedAt: [Point!]! +} +``` + +### Output + +```graphql +type Point { + latitude: Float! + longitude: Float! + height: Float + crs: String! + srid: Int! +} + +input PointInput { + latitude: Float! + longitude: Float! + height: Float +} + +type Movie { + filmedAt: [Point!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +input MovieCreateInput { + filmedAt: [PointInput!]! +} + +input MovieOptions { + limit: Int + offset: Int +} + +input MovieWhere { + filmedAt: [PointInput!] + filmedAt_INCLUDES: PointInput + filmedAt_NOT: [PointInput!] + filmedAt_NOT_INCLUDES: PointInput + OR: [MovieWhere!] + AND: [MovieWhere!] +} + +input MovieUpdateInput { + filmedAt: [PointInput!] +} + +type CreateMoviesMutationResponse { + movies: [Movie!]! +} + +type UpdateMoviesMutationResponse { + movies: [Movie!]! +} + +type Mutation { + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + ): UpdateMoviesMutationResponse! +} + +type Query { + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! +} +``` + +--- + +## CartesianPoints + +### TypeDefs + +```graphql +type Machine { + partLocations: [CartesianPoint!]! +} +``` + +### Output + +```graphql +type CartesianPoint { + x: Float! + y: Float! + z: Float + crs: String! + srid: Int! +} + +input CartesianPointInput { + x: Float! + y: Float! + z: Float +} + +type Machine { + partLocations: [CartesianPoint!]! +} + +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! +} + +input MachineCreateInput { + partLocations: [CartesianPointInput!]! +} + +input MachineOptions { + limit: Int + offset: Int +} + +input MachineWhere { + partLocations: [CartesianPointInput!] + partLocations_INCLUDES: CartesianPointInput + partLocations_NOT: [CartesianPointInput!] + partLocations_NOT_INCLUDES: CartesianPointInput + OR: [MachineWhere!] + AND: [MachineWhere!] +} + +input MachineUpdateInput { + partLocations: [CartesianPointInput!] +} + +type CreateMachinesMutationResponse { + machines: [Machine!]! +} + +type UpdateMachinesMutationResponse { + machines: [Machine!]! +} + +type Mutation { + createMachines( + input: [MachineCreateInput!]! + ): CreateMachinesMutationResponse! + deleteMachines(where: MachineWhere): DeleteInfo! + updateMachines( + where: MachineWhere + update: MachineUpdateInput + ): UpdateMachinesMutationResponse! +} + +type Query { + machines(where: MachineWhere, options: MachineOptions): [Machine!]! + machinesCount(where: MachineWhere): Int! +} +``` + +--- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/types/points.md b/packages/graphql/tests/tck/tck-test-files/schema/types/points.md deleted file mode 100644 index 06e353fe51..0000000000 --- a/packages/graphql/tests/tck/tck-test-files/schema/types/points.md +++ /dev/null @@ -1,370 +0,0 @@ -## Schema Points - -Tests that the provided typeDefs return the correct schema. - ---- - -### Point - -**TypeDefs** - -```typedefs-input -type Movie { - filmedAt: Point! -} -``` - -**Output** - -```schema-output -type Point { - latitude: Float! - longitude: Float! - height: Float - crs: String! - srid: Int! -} - -input PointInput { - latitude: Float! - longitude: Float! - height: Float -} - -input PointDistance { - point: PointInput! - """The distance in metres to be used when comparing two points""" - distance: Float! -} - -type Movie { - filmedAt: Point! -} - -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! -} - -enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC -} - -input MovieCreateInput { - filmedAt: PointInput! -} - -input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int -} - -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" -input MovieSort { - filmedAt: SortDirection -} - -input MovieWhere { - filmedAt: PointInput - filmedAt_NOT: PointInput - filmedAt_IN: [PointInput] - filmedAt_NOT_IN: [PointInput] - filmedAt_LT: PointDistance - filmedAt_LTE: PointDistance - filmedAt_GT: PointDistance - filmedAt_GTE: PointDistance - filmedAt_DISTANCE: PointDistance - OR: [MovieWhere!] - AND: [MovieWhere!] -} - -input MovieUpdateInput { - filmedAt: PointInput -} - -type CreateMoviesMutationResponse { - movies: [Movie!]! -} - -type UpdateMoviesMutationResponse { - movies: [Movie!]! -} - -type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! -} - -type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! -} -``` - ---- - -### CartesianPoint - -**TypeDefs** - -```typedefs-input -type Machine { - partLocation: CartesianPoint! -} -``` - -**Output** - -```schema-output -type CartesianPoint { - x: Float! - y: Float! - z: Float - crs: String! - srid: Int! -} - -input CartesianPointInput { - x: Float! - y: Float! - z: Float -} - -input CartesianPointDistance { - point: CartesianPointInput! - distance: Float! -} - -type Machine { - partLocation: CartesianPoint! -} - -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! -} - -enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC -} - -input MachineCreateInput { - partLocation: CartesianPointInput! -} - -input MachineOptions { - """Specify one or more MachineSort objects to sort Machines by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MachineSort] - limit: Int - skip: Int -} - -"""Fields to sort Machines by. The order in which sorts are applied is not guaranteed when specifying many fields in one MachineSort object.""" -input MachineSort { - partLocation: SortDirection -} - -input MachineWhere { - partLocation: CartesianPointInput - partLocation_NOT: CartesianPointInput - partLocation_IN: [CartesianPointInput] - partLocation_NOT_IN: [CartesianPointInput] - partLocation_LT: CartesianPointDistance - partLocation_LTE: CartesianPointDistance - partLocation_GT: CartesianPointDistance - partLocation_GTE: CartesianPointDistance - partLocation_DISTANCE: CartesianPointDistance - OR: [MachineWhere!] - AND: [MachineWhere!] -} - -input MachineUpdateInput { - partLocation: CartesianPointInput -} - -type CreateMachinesMutationResponse { - machines: [Machine!]! -} - -type UpdateMachinesMutationResponse { - machines: [Machine!]! -} - -type Mutation { - createMachines(input: [MachineCreateInput!]!): CreateMachinesMutationResponse! - deleteMachines(where: MachineWhere): DeleteInfo! - updateMachines(where: MachineWhere, update: MachineUpdateInput): UpdateMachinesMutationResponse! -} - -type Query { - machines(where: MachineWhere, options: MachineOptions): [Machine!]! -} -``` - ---- - -### Points - -**TypeDefs** - -```typedefs-input -type Movie { - filmedAt: [Point!]! -} -``` - -**Output** - -```schema-output -type Point { - latitude: Float! - longitude: Float! - height: Float - crs: String! - srid: Int! -} - -input PointInput { - latitude: Float! - longitude: Float! - height: Float -} - -type Movie { - filmedAt: [Point!]! -} - -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! -} - -input MovieCreateInput { - filmedAt: [PointInput!]! -} - -input MovieOptions { - limit: Int - skip: Int -} - -input MovieWhere { - filmedAt: [PointInput!] - filmedAt_INCLUDES: PointInput - filmedAt_NOT: [PointInput!] - filmedAt_NOT_INCLUDES: PointInput - OR: [MovieWhere!] - AND: [MovieWhere!] -} - -input MovieUpdateInput { - filmedAt: [PointInput!] -} - -type CreateMoviesMutationResponse { - movies: [Movie!]! -} - -type UpdateMoviesMutationResponse { - movies: [Movie!]! -} - -type Mutation { - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies(where: MovieWhere): DeleteInfo! - updateMovies(where: MovieWhere, update: MovieUpdateInput): UpdateMoviesMutationResponse! -} - -type Query { - movies(where: MovieWhere, options: MovieOptions): [Movie!]! -} -``` - ---- - -### CartesianPoints - -**TypeDefs** - -```typedefs-input -type Machine { - partLocations: [CartesianPoint!]! -} -``` - -**Output** - -```schema-output -type CartesianPoint { - x: Float! - y: Float! - z: Float - crs: String! - srid: Int! -} - -input CartesianPointInput { - x: Float! - y: Float! - z: Float -} - -type Machine { - partLocations: [CartesianPoint!]! -} - -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! -} - -input MachineCreateInput { - partLocations: [CartesianPointInput!]! -} - -input MachineOptions { - limit: Int - skip: Int -} - -input MachineWhere { - partLocations: [CartesianPointInput!] - partLocations_INCLUDES: CartesianPointInput - partLocations_NOT: [CartesianPointInput!] - partLocations_NOT_INCLUDES: CartesianPointInput - OR: [MachineWhere!] - AND: [MachineWhere!] -} - -input MachineUpdateInput { - partLocations: [CartesianPointInput!] -} - -type CreateMachinesMutationResponse { - machines: [Machine!]! -} - -type UpdateMachinesMutationResponse { - machines: [Machine!]! -} - -type Mutation { - createMachines(input: [MachineCreateInput!]!): CreateMachinesMutationResponse! - deleteMachines(where: MachineWhere): DeleteInfo! - updateMachines(where: MachineWhere, update: MachineUpdateInput): UpdateMachinesMutationResponse! -} - -type Query { - machines(where: MachineWhere, options: MachineOptions): [Machine!]! -} -``` - ---- diff --git a/packages/graphql/tests/tck/tck-test-files/schema/unions.md b/packages/graphql/tests/tck/tck-test-files/schema/unions.md index 747d87d64f..7e1ddf36a8 100644 --- a/packages/graphql/tests/tck/tck-test-files/schema/unions.md +++ b/packages/graphql/tests/tck/tck-test-files/schema/unions.md @@ -1,14 +1,14 @@ -## Schema Unions +# Schema Unions Tests that the provided typeDefs return the correct schema. --- -### Unions +## Unions -**TypeDefs** +### TypeDefs -```typedefs-input +```graphql union Search = Movie | Genre type Genre { @@ -22,234 +22,350 @@ type Movie { } ``` -**Output** +### Output -```schema-output -union Search = Movie | Genre +```graphql +type CreateGenresMutationResponse { + genres: [Genre!]! +} -type DeleteInfo { - nodesDeleted: Int! - relationshipsDeleted: Int! +type CreateMoviesMutationResponse { + movies: [Movie!]! } -enum SortDirection { - """Sort by field values in ascending order.""" - ASC - """Sort by field values in descending order.""" - DESC +type DeleteInfo { + nodesDeleted: Int! + relationshipsDeleted: Int! } type Genre { - id: ID + id: ID } -input GenreConnectFieldInput { - where: GenreWhere +input GenreConnectWhere { + node: GenreWhere! } input GenreCreateInput { - id: ID -} - -input GenreDisconnectFieldInput { - where: GenreWhere + id: ID } input GenreOptions { - """Specify one or more GenreSort objects to sort Genres by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [GenreSort] - limit: Int - skip: Int + """ + Specify one or more GenreSort objects to sort Genres by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [GenreSort] + limit: Int + offset: Int } -"""Fields to sort Genres by. The order in which sorts are applied is not guaranteed when specifying many fields in one GenreSort object.""" +""" +Fields to sort Genres by. The order in which sorts are applied is not guaranteed when specifying many fields in one GenreSort object. +""" input GenreSort { - id: SortDirection + id: SortDirection } input GenreUpdateInput { - id: ID + id: ID } input GenreWhere { - OR: [GenreWhere!] - AND: [GenreWhere!] - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID + OR: [GenreWhere!] + AND: [GenreWhere!] + id: ID + id_NOT: ID + id_IN: [ID] + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID } type Movie { - id: ID - search(options: QueryOptions, Genre: GenreWhere, Movie: MovieWhere): [Search] - searchNoDirective: Search + id: ID + searchNoDirective: Search + search(options: QueryOptions, where: SearchWhere): [Search] + searchConnection(where: MovieSearchConnectionWhere): MovieSearchConnection! } -input MovieConnectFieldInput { - where: MovieWhere - connect: MovieConnectInput +input MovieConnectInput { + search: MovieSearchConnectInput } -input MovieConnectInput { - search_Genre: [GenreConnectFieldInput!] - search_Movie: [MovieConnectFieldInput!] +input MovieConnectWhere { + node: MovieWhere! } input MovieCreateInput { - id: ID - search_Genre: MovieSearchGenreFieldInput - search_Movie: MovieSearchMovieFieldInput + id: ID + search: MovieSearchCreateInput } -input MovieDisconnectFieldInput { - where: MovieWhere - disconnect: MovieDisconnectInput +input MovieDeleteInput { + search: MovieSearchDeleteInput } input MovieDisconnectInput { - search_Genre: [GenreDisconnectFieldInput!] - search_Movie: [MovieDisconnectFieldInput!] + search: MovieSearchDisconnectInput } input MovieOptions { - """Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array.""" -sort: [MovieSort] - limit: Int - skip: Int + """ + Specify one or more MovieSort objects to sort Movies by. The sorts will be applied in the order in which they are arranged in the array. + """ + sort: [MovieSort] + limit: Int + offset: Int } input MovieRelationInput { - search_Genre: [GenreCreateInput!] - search_Movie: [MovieCreateInput!] + search: MovieSearchCreateFieldInput } -input MovieSearchGenreFieldInput { - create: [GenreCreateInput!] - connect: [GenreConnectFieldInput!] +input MovieSearchConnectInput { + Genre: [MovieSearchGenreConnectFieldInput!] + Movie: [MovieSearchMovieConnectFieldInput!] } -input MovieSearchGenreUpdateFieldInput { - where: GenreWhere - update: GenreUpdateInput - connect: [GenreConnectFieldInput!] - disconnect: [GenreDisconnectFieldInput!] - create: [GenreCreateInput!] - delete: [GenreDeleteFieldInput!] +type MovieSearchConnection { + edges: [MovieSearchRelationship!]! + totalCount: Int! + pageInfo: PageInfo! } -input MovieSearchMovieFieldInput { - create: [MovieCreateInput!] - connect: [MovieConnectFieldInput!] +input MovieSearchConnectionGenreWhere { + OR: [MovieSearchConnectionGenreWhere] + AND: [MovieSearchConnectionGenreWhere] + node: GenreWhere + node_NOT: GenreWhere } -input GenreDeleteFieldInput { - where: GenreWhere +input MovieSearchConnectionMovieWhere { + OR: [MovieSearchConnectionMovieWhere] + AND: [MovieSearchConnectionMovieWhere] + node: MovieWhere + node_NOT: MovieWhere } -input MovieDeleteFieldInput { - delete: MovieDeleteInput - where: MovieWhere +input MovieSearchConnectionWhere { + Genre: MovieSearchConnectionGenreWhere + Movie: MovieSearchConnectionMovieWhere } -input MovieSearchMovieUpdateFieldInput { - where: MovieWhere - update: MovieUpdateInput - connect: [MovieConnectFieldInput!] - disconnect: [MovieDisconnectFieldInput!] - create: [MovieCreateInput!] - delete: [MovieDeleteFieldInput!] +input MovieSearchCreateFieldInput { + Genre: [MovieSearchGenreCreateFieldInput!] + Movie: [MovieSearchMovieCreateFieldInput!] } -"""Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object.""" -input MovieSort { - id: SortDirection +input MovieSearchCreateInput { + Genre: MovieSearchGenreFieldInput + Movie: MovieSearchMovieFieldInput } -input MovieUpdateInput { - id: ID - search_Genre: [MovieSearchGenreUpdateFieldInput!] - search_Movie: [MovieSearchMovieUpdateFieldInput!] +input MovieSearchDeleteInput { + Genre: [MovieSearchGenreDeleteFieldInput!] + Movie: [MovieSearchMovieDeleteFieldInput!] +} + +input MovieSearchDisconnectInput { + Genre: [MovieSearchGenreDisconnectFieldInput!] + Movie: [MovieSearchMovieDisconnectFieldInput!] +} + +input MovieSearchGenreConnectFieldInput { + where: GenreConnectWhere +} + +input MovieSearchGenreConnectionWhere { + node: GenreWhere + node_NOT: GenreWhere + AND: [MovieSearchGenreConnectionWhere!] + OR: [MovieSearchGenreConnectionWhere!] +} + +input MovieSearchGenreCreateFieldInput { + node: GenreCreateInput! } input MovieSearchGenreDeleteFieldInput { - where: GenreWhere + where: MovieSearchGenreConnectionWhere } -input MovieSearchMovieDeleteFieldInput { - where: MovieWhere - delete: MovieDeleteInput +input MovieSearchGenreDisconnectFieldInput { + where: MovieSearchGenreConnectionWhere } -input MovieDeleteInput { - search_Genre: [MovieSearchGenreDeleteFieldInput!] - search_Movie: [MovieSearchMovieDeleteFieldInput!] +input MovieSearchGenreFieldInput { + create: [MovieSearchGenreCreateFieldInput!] + connect: [MovieSearchGenreConnectFieldInput!] } -input MovieWhere { - OR: [MovieWhere!] - AND: [MovieWhere!] - id: ID - id_IN: [ID] - id_NOT: ID - id_NOT_IN: [ID] - id_CONTAINS: ID - id_NOT_CONTAINS: ID - id_STARTS_WITH: ID - id_NOT_STARTS_WITH: ID - id_ENDS_WITH: ID - id_NOT_ENDS_WITH: ID +input MovieSearchGenreUpdateConnectionInput { + node: GenreUpdateInput } -type CreateMoviesMutationResponse { - movies: [Movie!]! +input MovieSearchGenreUpdateFieldInput { + where: MovieSearchGenreConnectionWhere + update: MovieSearchGenreUpdateConnectionInput + connect: [MovieSearchGenreConnectFieldInput!] + disconnect: [MovieSearchGenreDisconnectFieldInput!] + create: [MovieSearchGenreCreateFieldInput!] + delete: [MovieSearchGenreDeleteFieldInput!] } -type UpdateMoviesMutationResponse { - movies: [Movie!]! +input MovieSearchMovieConnectFieldInput { + where: MovieConnectWhere + connect: [MovieConnectInput!] } -type CreateGenresMutationResponse { - genres: [Genre!]! +input MovieSearchMovieConnectionWhere { + node: MovieWhere + node_NOT: MovieWhere + AND: [MovieSearchMovieConnectionWhere!] + OR: [MovieSearchMovieConnectionWhere!] } -type UpdateGenresMutationResponse { - genres: [Genre!]! +input MovieSearchMovieCreateFieldInput { + node: MovieCreateInput! } -type Mutation { - createGenres(input: [GenreCreateInput!]!): CreateGenresMutationResponse! - deleteGenres(where: GenreWhere): DeleteInfo! - updateGenres(where: GenreWhere, update: GenreUpdateInput): UpdateGenresMutationResponse! - createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! - deleteMovies( - where: MovieWhere +input MovieSearchMovieDeleteFieldInput { + where: MovieSearchMovieConnectionWhere delete: MovieDeleteInput - ): DeleteInfo! - updateMovies( - where: MovieWhere - update: MovieUpdateInput - connect: MovieConnectInput +} + +input MovieSearchMovieDisconnectFieldInput { + where: MovieSearchMovieConnectionWhere disconnect: MovieDisconnectInput - create: MovieRelationInput - delete: MovieDeleteInput - ): UpdateMoviesMutationResponse! +} + +input MovieSearchMovieFieldInput { + create: [MovieSearchMovieCreateFieldInput!] + connect: [MovieSearchMovieConnectFieldInput!] +} + +input MovieSearchMovieUpdateConnectionInput { + node: MovieUpdateInput +} + +input MovieSearchMovieUpdateFieldInput { + where: MovieSearchMovieConnectionWhere + update: MovieSearchMovieUpdateConnectionInput + connect: [MovieSearchMovieConnectFieldInput!] + disconnect: [MovieSearchMovieDisconnectFieldInput!] + create: [MovieSearchMovieCreateFieldInput!] + delete: [MovieSearchMovieDeleteFieldInput!] +} + +type MovieSearchRelationship { + cursor: String! + node: Search! +} + +input MovieSearchUpdateInput { + Genre: [MovieSearchGenreUpdateFieldInput!] + Movie: [MovieSearchMovieUpdateFieldInput!] +} + +""" +Fields to sort Movies by. The order in which sorts are applied is not guaranteed when specifying many fields in one MovieSort object. +""" +input MovieSort { + id: SortDirection +} + +input MovieUpdateInput { + id: ID + search: MovieSearchUpdateInput +} + +input MovieWhere { + OR: [MovieWhere!] + AND: [MovieWhere!] + id: ID + id_NOT: ID + id_IN: [ID] + id_NOT_IN: [ID] + id_CONTAINS: ID + id_NOT_CONTAINS: ID + id_STARTS_WITH: ID + id_NOT_STARTS_WITH: ID + id_ENDS_WITH: ID + id_NOT_ENDS_WITH: ID + searchConnection: MovieSearchConnectionWhere + searchConnection_NOT: MovieSearchConnectionWhere +} + +type Mutation { + createGenres(input: [GenreCreateInput!]!): CreateGenresMutationResponse! + deleteGenres(where: GenreWhere): DeleteInfo! + updateGenres( + where: GenreWhere + update: GenreUpdateInput + ): UpdateGenresMutationResponse! + createMovies(input: [MovieCreateInput!]!): CreateMoviesMutationResponse! + deleteMovies(where: MovieWhere, delete: MovieDeleteInput): DeleteInfo! + updateMovies( + where: MovieWhere + update: MovieUpdateInput + connect: MovieConnectInput + disconnect: MovieDisconnectInput + create: MovieRelationInput + delete: MovieDeleteInput + ): UpdateMoviesMutationResponse! +} + +""" +Pagination information (Relay) +""" +type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String } type Query { - genres(where: GenreWhere, options: GenreOptions): [Genre!]! - movies(where: MovieWhere, options: MovieOptions): [Movie!]! + genres(where: GenreWhere, options: GenreOptions): [Genre!]! + movies(where: MovieWhere, options: MovieOptions): [Movie!]! + moviesCount(where: MovieWhere): Int! + genresCount(where: GenreWhere): Int! } input QueryOptions { - skip: Int - limit: Int + offset: Int + limit: Int +} + +union Search = Movie | Genre + +input SearchWhere { + Movie: MovieWhere + Genre: GenreWhere +} + +enum SortDirection { + """ + Sort by field values in ascending order. + """ + ASC + + """ + Sort by field values in descending order. + """ + DESC +} + +type UpdateGenresMutationResponse { + genres: [Genre!]! +} + +type UpdateMoviesMutationResponse { + movies: [Movie!]! } ``` diff --git a/packages/graphql/tests/tck/tck.test.ts b/packages/graphql/tests/tck/tck.test.ts index 6e4ca4980f..6b0626681b 100644 --- a/packages/graphql/tests/tck/tck.test.ts +++ b/packages/graphql/tests/tck/tck.test.ts @@ -36,7 +36,7 @@ import { IncomingMessage } from "http"; import { Socket } from "net"; // import { parseResolveInfo, ResolveTree } from "graphql-parse-resolve-info"; import { SchemaDirectiveVisitor, printSchemaWithDirectives } from "@graphql-tools/utils"; -import { translateCreate, translateDelete, translateRead, translateUpdate } from "../../src/translate"; +import { translateCount, translateCreate, translateDelete, translateRead, translateUpdate } from "../../src/translate"; import { Context } from "../../src/types"; import { Neo4jGraphQL } from "../../src"; import { @@ -112,20 +112,20 @@ describe("TCK Generated tests", () => { } function compare( - cypher: { expected: string; recived: string }, - params: { expected: any; recived: any }, + cypher: { expected: string; received: string }, + params: { expected: any; received: any }, context: any ) { if ( - cypher.recived.includes("$auth.") || - cypher.recived.includes("auth: $auth") || - cypher.recived.includes("auth:$auth") + cypher.received.includes("$auth.") || + cypher.received.includes("auth: $auth") || + cypher.received.includes("auth:$auth") ) { params.expected.auth = createAuthParam({ context }); } - expect(trimmer(cypher.expected)).toEqual(trimmer(cypher.recived)); - expect(params.expected).toEqual(params.recived); + expect(trimmer(cypher.expected)).toEqual(trimmer(cypher.received)); + expect(params.expected).toEqual(params.received); } const queries = document.definitions.reduce((res, def) => { @@ -154,13 +154,39 @@ describe("TCK Generated tests", () => { }); compare( - { expected: cQuery, recived: cypherQuery }, - { expected: cQueryParams, recived: cypherParams }, + { expected: cQuery, received: cypherQuery }, + { expected: cQueryParams, received: cypherParams }, mergedContext ); return []; }, + [`${pluralize(camelCase(def.name.value))}Count`]: ( + _root: any, + _params: any, + context: Context, + info: GraphQLResolveInfo + ) => { + const resolveTree = getNeo4jResolveTree(info); + + context.neoSchema = neoSchema; + context.resolveTree = resolveTree; + + const mergedContext = { ...context, ...defaultContext }; + + const [cQuery, cQueryParams] = translateCount({ + context: mergedContext, + node: neoSchema.nodes.find((x) => x.name === def.name.value) as Node, + }); + + compare( + { expected: cQuery, received: cypherQuery }, + { expected: cQueryParams, received: cypherParams }, + mergedContext + ); + + return 1; + }, }; }, {}); @@ -190,8 +216,8 @@ describe("TCK Generated tests", () => { }); compare( - { expected: cQuery, recived: cypherQuery }, - { expected: cQueryParams, recived: cypherParams }, + { expected: cQuery, received: cypherQuery }, + { expected: cQueryParams, received: cypherParams }, mergedContext ); @@ -218,8 +244,8 @@ describe("TCK Generated tests", () => { }); compare( - { expected: cQuery, recived: cypherQuery }, - { expected: cQueryParams, recived: cypherParams }, + { expected: cQuery, received: cypherQuery }, + { expected: cQueryParams, received: cypherParams }, mergedContext ); @@ -241,8 +267,8 @@ describe("TCK Generated tests", () => { }); compare( - { expected: cQuery, recived: cypherQuery }, - { expected: cQueryParams, recived: cypherParams }, + { expected: cQuery, received: cypherQuery }, + { expected: cQueryParams, received: cypherParams }, mergedContext ); diff --git a/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts b/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts index e5857d06fe..89b54b129e 100644 --- a/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts +++ b/packages/graphql/tests/tck/utils/generate-test-cases-from-md.utils.ts @@ -51,14 +51,14 @@ function captureOrEmptyString(contents: string, re: RegExp): string { return ""; } -const nameRe = /###(?([^\n]+))/; -const graphqlQueryRe = /```graphql(?(.|\s)*?)```/; -const graphqlParamsRe = /```graphql-params(?(.|\s)*?)```/; -const cypherQueryRe = /```cypher(?(.|\s)*?)```/; -const cypherParamsRe = /```cypher-params(?(.|\s)*?)```/; -const typeDefsInputRe = /```typedefs-input(?(.|\s)*?)```/; -const schemaOutputRe = /```schema-output(?(.|\s)*?)```/; -const jwtRe = /```jwt(?(.|\s)*?)```/; +const nameRe = /##(?([^\n]+))/; +const graphqlQueryRe = /### GraphQL Input\s+```graphql(?(.|\s)*?)```/; +const graphqlParamsRe = /### GraphQL Params Input\s+```json(?(.|\s)*?)```/; +const cypherQueryRe = /### Expected Cypher Output\s+```cypher(?(.|\s)*?)```/; +const cypherParamsRe = /### Expected Cypher Params\s+```json(?(.|\s)*?)```/; +const typeDefsInputRe = /### TypeDefs\s+```graphql(?(.|\s)*?)```/; +const schemaOutputRe = /### Output\s+```graphql(?(.|\s)*?)```/; +const jwtRe = /### JWT Object\s+```json(?(.|\s)*?)```/; const envVarsRe = /```env(?(.|\s)*?)```/; function extractTests(contents: string, kind: string): Test[] { @@ -112,7 +112,7 @@ function extractTests(contents: string, kind: string): Test[] { } function extractSchema(contents: string): string { - const re = /```schema(?(.|\s)*?)```/; + const re = /Schema:\s+```graphql(?(.|\s)*?)```/; return captureOrEmptyString(contents, re); } @@ -138,7 +138,11 @@ function generateTests(filePath, kind: string): TestCase { export function generateTestCasesFromMd(dir: string, kind = ""): TestCase[] { const files = fs.readdirSync(dir, { withFileTypes: true }).reduce((res: TestCase[], item) => { if (item.isFile()) { - return [...res, generateTests(path.join(dir, item.name), kind)]; + try { + return [...res, generateTests(path.join(dir, item.name), kind)]; + } catch { + throw new Error(`Error generating test ${path.join(dir, item.name)}`); + } } if (item.isDirectory()) { diff --git a/packages/ogm/README.md b/packages/ogm/README.md index 2206abf1db..05de511b66 100644 --- a/packages/ogm/README.md +++ b/packages/ogm/README.md @@ -14,7 +14,7 @@ GraphQL powered OGM for Neo4j and Javascript applications. -1. [Documentation](https://neo4j.com/docs/graphql-manual/current/) +1. [Documentation](https://neo4j.com/docs/graphql-manual/2.0/ogm/) ## Installation diff --git a/packages/ogm/package.json b/packages/ogm/package.json index f2a8ab6e4c..924a78ff0c 100644 --- a/packages/ogm/package.json +++ b/packages/ogm/package.json @@ -1,6 +1,6 @@ { "name": "@neo4j/graphql-ogm", - "version": "1.2.4", + "version": "2.0.0-rc.2", "description": "GraphQL powered OGM for Neo4j and Javascript applications", "keywords": [ "neo4j", @@ -26,8 +26,8 @@ }, "author": "Neo4j Inc.", "dependencies": { - "@graphql-tools/merge": "^6.2.13", - "@neo4j/graphql": "^1.2.4", + "@graphql-tools/merge": "6.2.14", + "@neo4j/graphql": "^2.0.0-rc.2", "camelcase": "^6.2.0", "pluralize": "^8.0.0" }, diff --git a/packages/ogm/src/classes/Model.ts b/packages/ogm/src/classes/Model.ts index 7a3ef7849c..a5c1b3986b 100644 --- a/packages/ogm/src/classes/Model.ts +++ b/packages/ogm/src/classes/Model.ts @@ -17,7 +17,7 @@ * limitations under the License. */ -import { DocumentNode, graphql, parse, print } from "graphql"; +import { DocumentNode, graphql, parse, print, SelectionSetNode } from "graphql"; import pluralize from "pluralize"; import camelCase from "camelcase"; import { Neo4jGraphQL, upperFirst } from "@neo4j/graphql"; @@ -29,7 +29,7 @@ export interface ModelConstructor { neoSchema: Neo4jGraphQL; } -function printSelectionSet(selectionSet: string | DocumentNode): string { +function printSelectionSet(selectionSet: string | DocumentNode | SelectionSetNode): string { if (typeof selectionSet === "string") { return print(parse(selectionSet)); } @@ -70,7 +70,7 @@ class Model { }: { where?: GraphQLWhereArg; options?: GraphQLOptionsArg; - selectionSet?: string | DocumentNode; + selectionSet?: string | DocumentNode | SelectionSetNode; args?: any; context?: any; rootValue?: any; @@ -108,6 +108,32 @@ class Model { return (result.data as any)[this.camelCaseName] as T; } + async count({ + where, + }: { + where?: GraphQLWhereArg; + } = {}): Promise { + const argDefinitions = [`${where ? `($where: ${this.name}Where)` : ""}`]; + + const argsApply = [`${where ? `(where: $where)` : ""}`]; + + const query = ` + query ${argDefinitions.join(" ")}{ + ${this.camelCaseName}Count${argsApply.join(" ")} + } + `; + + const variableValues = { where }; + + const result = await graphql(this.neoSchema.schema, query, null, {}, variableValues); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + return (result.data as any)[`${this.camelCaseName}Count`] as number; + } + async create({ input, selectionSet, @@ -116,7 +142,7 @@ class Model { rootValue = null, }: { input?: any; - selectionSet?: string | DocumentNode; + selectionSet?: string | DocumentNode | SelectionSetNode; args?: any; context?: any; rootValue?: any; @@ -168,7 +194,7 @@ class Model { connect?: any; disconnect?: any; create?: any; - selectionSet?: string | DocumentNode; + selectionSet?: string | DocumentNode | SelectionSetNode; args?: any; context?: any; rootValue?: any; diff --git a/packages/ogm/tests/integration/ogm.int.test.ts b/packages/ogm/tests/integration/ogm.int.test.ts index fcbc7bacb1..829d93e1c1 100644 --- a/packages/ogm/tests/integration/ogm.int.test.ts +++ b/packages/ogm/tests/integration/ogm.int.test.ts @@ -197,6 +197,76 @@ describe("OGM", () => { }); }); + describe("count", () => { + test("should count nodes", async () => { + const session = driver.session(); + + const randomType = `${generate({ + charset: "alphabetic", + readable: true, + })}Movie`; + + const typeDefs = ` + type ${randomType} { + id: ID + } + `; + + const ogm = new OGM({ typeDefs, driver }); + + try { + await session.run( + ` + CREATE (:${randomType} {id: randomUUID()}) + CREATE (:${randomType} {id: randomUUID()}) + ` + ); + + const model = ogm.model(randomType); + + const count = await model?.count(); + + expect(count).toEqual(2); + } finally { + await session.close(); + } + }); + + test("should count movies with a where predicate", async () => { + const session = driver.session(); + + const typeDefs = ` + type Movie { + id: ID + } + `; + + const ogm = new OGM({ typeDefs, driver }); + + const id = generate({ + charset: "alphabetic", + }); + + try { + await ogm.checkNeo4jCompat(); + + await session.run(` + CREATE (:Movie {id: "${id}"}) + CREATE (:Movie {id: randomUUID()}) + CREATE (:Movie {id: randomUUID()}) + `); + + const Movie = ogm.model("Movie"); + + const count = await Movie?.count({ where: { id } }); + + expect(count).toEqual(1); + } finally { + await session.close(); + } + }); + }); + describe("create", () => { test("should create a single node", async () => { const session = driver.session(); @@ -363,18 +433,22 @@ describe("OGM", () => { input: [ { ...product, - sizes: { create: sizes }, - colors: { create: colors }, + sizes: { create: sizes.map((x) => ({ node: x })) }, + colors: { create: colors.map((x) => ({ node: x })) }, photos: { create: [ - photos[0], + { node: photos[0] }, { - ...photos[1], - color: { connect: { where: { id: colors[0].id } } }, + node: { + ...photos[1], + color: { connect: { where: { node: { id: colors[0].id } } } }, + }, }, { - ...photos[2], - color: { connect: { where: { id: colors[1].id } } }, + node: { + ...photos[2], + color: { connect: { where: { node: { id: colors[1].id } } } }, + }, }, ], }, @@ -567,7 +641,7 @@ describe("OGM", () => { const { movies } = await Movie?.update({ where: { id: movieId }, - connect: { actors: [{ where: { id: actorId } }] }, + connect: { actors: [{ where: { node: { id: actorId } } }] }, selectionSet: ` { movies { @@ -625,7 +699,7 @@ describe("OGM", () => { const { movies } = await Movie?.update({ where: { id: movieId }, - create: { actors: [{ id: actorId }] }, + create: { actors: [{ node: { id: actorId } }] }, selectionSet: ` { movies { @@ -686,7 +760,7 @@ describe("OGM", () => { const { movies } = await Movie?.update({ where: { id: movieId }, - disconnect: { actors: [{ where: { id: actorId } }] }, + disconnect: { actors: [{ where: { node: { id: actorId } } }] }, selectionSet: ` { movies { @@ -770,7 +844,7 @@ describe("OGM", () => { const result = await Movie?.delete({ where: { id: movieId }, - delete: { genres: { where: { id: genreId } } }, + delete: { genres: { where: { node: { id: genreId } } } }, }); expect(result).toEqual({ nodesDeleted: 2, relationshipsDeleted: 1 }); diff --git a/yarn.lock b/yarn.lock index 130e029477..1380f84d63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35,6 +35,30 @@ __metadata: languageName: node linkType: hard +"@apollo/protobufjs@npm:1.2.2": + version: 1.2.2 + resolution: "@apollo/protobufjs@npm:1.2.2" + dependencies: + "@protobufjs/aspromise": ^1.1.2 + "@protobufjs/base64": ^1.1.2 + "@protobufjs/codegen": ^2.0.4 + "@protobufjs/eventemitter": ^1.1.0 + "@protobufjs/fetch": ^1.1.0 + "@protobufjs/float": ^1.0.2 + "@protobufjs/inquire": ^1.1.0 + "@protobufjs/path": ^1.1.2 + "@protobufjs/pool": ^1.1.0 + "@protobufjs/utf8": ^1.1.0 + "@types/long": ^4.0.0 + "@types/node": ^10.1.0 + long: ^4.0.0 + bin: + apollo-pbjs: bin/pbjs + apollo-pbts: bin/pbts + checksum: 75940f213d39728f2fdd662732eb6c085fb393bea01ccb58c5084a18559d1d7342bac3ebb44f93c9e86b8237c675ae81a04427866d156f93b16b691d020bfd44 + languageName: node + linkType: hard + "@apollo/protobufjs@npm:^1.0.3": version: 1.2.0 resolution: "@apollo/protobufjs@npm:1.2.0" @@ -77,6 +101,13 @@ __metadata: languageName: node linkType: hard +"@apollographql/apollo-tools@npm:^0.5.1": + version: 0.5.1 + resolution: "@apollographql/apollo-tools@npm:0.5.1" + checksum: f06b922b829167298ccfedd60bf9a918c537ceb83299a27d9e3369fe6009638cf58f3ed16ca3d88c94ec5b6e20867820c07f464d5666516d8d32a96fb82685c4 + languageName: node + linkType: hard + "@apollographql/graphql-playground-html@npm:1.6.26": version: 1.6.26 resolution: "@apollographql/graphql-playground-html@npm:1.6.26" @@ -95,6 +126,15 @@ __metadata: languageName: node linkType: hard +"@apollographql/graphql-playground-html@npm:1.6.29": + version: 1.6.29 + resolution: "@apollographql/graphql-playground-html@npm:1.6.29" + dependencies: + xss: ^1.0.8 + checksum: ea45ba54c1110f21f5bfc5751d29802346165568f47431ebcf9411164bb98ea551dab0fd5cc95bf688be98f0df5414811b04a0b794621782db9be20d0cf60939 + languageName: node + linkType: hard + "@apollographql/graphql-upload-8-fork@npm:^8.1.3": version: 8.1.3 resolution: "@apollographql/graphql-upload-8-fork@npm:8.1.3" @@ -599,20 +639,48 @@ __metadata: languageName: node linkType: hard -"@graphql-tools/merge@npm:^6.2.13": - version: 6.2.13 - resolution: "@graphql-tools/merge@npm:6.2.13" +"@graphql-tools/merge@npm:6.2.14": + version: 6.2.14 + resolution: "@graphql-tools/merge@npm:6.2.14" dependencies: "@graphql-tools/schema": ^7.0.0 "@graphql-tools/utils": ^7.7.0 tslib: ~2.2.0 peerDependencies: graphql: ^14.0.0 || ^15.0.0 - checksum: 19c977ebd6f918ec9ccdeb9cfd7cd4e818767c9f7fd19bd3d7d17888d9f98a977e63a9c111b254eb14ac420453fec84d2b336cc4d7a6a3c8a2e61f25af2f6b85 + checksum: 98ee1c70394a881ca784577fbfbdc5253b291e6df4af77da70f9f6d9e01ad5831c57344a454136abd9eaeff4b513d0d9ab5d9e92e6fe80f9c07873a5ce0dc1d6 languageName: node linkType: hard -"@graphql-tools/schema@npm:^7.0.0, @graphql-tools/schema@npm:^7.1.3": +"@graphql-tools/merge@npm:6.2.16": + version: 6.2.16 + resolution: "@graphql-tools/merge@npm:6.2.16" + dependencies: + "@graphql-tools/schema": ^8.0.1 + "@graphql-tools/utils": 8.0.1 + tslib: ~2.3.0 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 + checksum: 38185615f046875e0110cfe23f551a8c05da8216b40693f5fbc442f047f29ff8d4f2d5f8f6b9846990ed32108a4765b3103791eab162a2c71872103a5481f964 + languageName: node + linkType: hard + +"@graphql-tools/mock@npm:^8.1.2": + version: 8.1.6 + resolution: "@graphql-tools/mock@npm:8.1.6" + dependencies: + "@graphql-tools/merge": 6.2.16 + "@graphql-tools/schema": ^8.0.1 + "@graphql-tools/utils": 8.0.1 + fast-json-stable-stringify: ^2.1.0 + tslib: ~2.3.0 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 + checksum: 5e64dc869f1488af4e739fe59b5489275f518a400e9b1eda461b7a79f212bd9ccb0dfb41b8e5ecb1d52a238d67f4d59a317f34730d77951a364e6b67155bb82f + languageName: node + linkType: hard + +"@graphql-tools/schema@npm:^7.0.0": version: 7.1.3 resolution: "@graphql-tools/schema@npm:7.1.3" dependencies: @@ -624,7 +692,45 @@ __metadata: languageName: node linkType: hard -"@graphql-tools/utils@npm:^7.1.2, @graphql-tools/utils@npm:^7.7.0, @graphql-tools/utils@npm:^7.7.3": +"@graphql-tools/schema@npm:^7.1.5": + version: 7.1.5 + resolution: "@graphql-tools/schema@npm:7.1.5" + dependencies: + "@graphql-tools/utils": ^7.1.2 + tslib: ~2.2.0 + value-or-promise: 1.0.6 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 + checksum: 23b1e5443919a2d9abff535bfa9cd935e3ee3f0b2e3938d396af40ffcbf8d90bacf74fd3faf895845b1b0bf7cada5f7e0d1fd67f667ec02752884f25b1e19ac4 + languageName: node + linkType: hard + +"@graphql-tools/schema@npm:^8.0.1": + version: 8.0.1 + resolution: "@graphql-tools/schema@npm:8.0.1" + dependencies: + "@graphql-tools/merge": 6.2.16 + "@graphql-tools/utils": 8.0.1 + tslib: ~2.3.0 + value-or-promise: 1.0.10 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 + checksum: 852e1f1dc78854bc2648b5078d955c54590260ddea57e927daeb6740bf02e21fd0aac8147d854c6892304f06fead8b594fd19fbeb0ca28df9c1370960a98843d + languageName: node + linkType: hard + +"@graphql-tools/utils@npm:8.0.1": + version: 8.0.1 + resolution: "@graphql-tools/utils@npm:8.0.1" + dependencies: + tslib: ~2.3.0 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 + checksum: d66fca40457f91aeb5daeed9824bab688fe34c05cb6b1f3e5a34394464822a33dbc468a2514caf3128063aaafeb0c869adb9e39a4ec82659675b71c487e8fe60 + languageName: node + linkType: hard + +"@graphql-tools/utils@npm:^7.1.2, @graphql-tools/utils@npm:^7.7.0": version: 7.8.0 resolution: "@graphql-tools/utils@npm:7.8.0" dependencies: @@ -637,6 +743,19 @@ __metadata: languageName: node linkType: hard +"@graphql-tools/utils@npm:^7.10.0, @graphql-tools/utils@npm:^7.9.0": + version: 7.10.0 + resolution: "@graphql-tools/utils@npm:7.10.0" + dependencies: + "@ardatan/aggregate-error": 0.0.6 + camel-case: 4.1.2 + tslib: ~2.2.0 + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 + checksum: 8b8e674f344e825c27816ead8a66edca5aed2eac26b601bb028350837ae39fee3ca9241255b918b8199d60bff41884a4814f98df465a0a8a5db5b91428ce2aa9 + languageName: node + linkType: hard + "@graphql-typed-document-node/core@npm:^3.0.0": version: 3.1.0 resolution: "@graphql-typed-document-node/core@npm:3.1.0" @@ -899,12 +1018,12 @@ __metadata: languageName: node linkType: hard -"@neo4j/graphql-ogm@^1.2.4, @neo4j/graphql-ogm@workspace:packages/ogm": +"@neo4j/graphql-ogm@^2.0.0-rc.2, @neo4j/graphql-ogm@workspace:packages/ogm": version: 0.0.0-use.local resolution: "@neo4j/graphql-ogm@workspace:packages/ogm" dependencies: - "@graphql-tools/merge": ^6.2.13 - "@neo4j/graphql": ^1.2.4 + "@graphql-tools/merge": 6.2.14 + "@neo4j/graphql": ^2.0.0-rc.2 "@types/jest": 26.0.8 "@types/node": 14.0.27 camelcase: ^6.2.0 @@ -923,13 +1042,13 @@ __metadata: languageName: unknown linkType: soft -"@neo4j/graphql@^1.2.4, @neo4j/graphql@workspace:packages/graphql": +"@neo4j/graphql@^2.0.0-rc.2, @neo4j/graphql@workspace:packages/graphql": version: 0.0.0-use.local resolution: "@neo4j/graphql@workspace:packages/graphql" dependencies: - "@graphql-tools/merge": ^6.2.13 - "@graphql-tools/schema": ^7.1.3 - "@graphql-tools/utils": ^7.7.3 + "@graphql-tools/merge": 6.2.14 + "@graphql-tools/schema": ^7.1.5 + "@graphql-tools/utils": ^7.10.0 "@types/deep-equal": 1.0.1 "@types/faker": 5.1.7 "@types/is-uuid": 1.0.0 @@ -938,14 +1057,16 @@ __metadata: "@types/node": 14.0.27 "@types/pluralize": 0.0.29 "@types/randomstring": 1.1.6 - apollo-server: 2.21.0 + apollo-server: ^3.0.2 camelcase: ^6.2.0 - debug: ^4.3.1 + debug: ^4.3.2 + dedent: ^0.7.0 deep-equal: ^2.0.5 dot-prop: ^6.0.1 faker: 5.2.0 - graphql-compose: ^7.25.1 - graphql-parse-resolve-info: ^4.11.0 + graphql-compose: ^9.0.2 + graphql-parse-resolve-info: ^4.12.0 + graphql-relay: ^0.8.0 graphql-tag: 2.11.0 is-uuid: 1.0.2 jest: 26.2.2 @@ -955,7 +1076,7 @@ __metadata: pluralize: ^8.0.0 randomstring: 1.1.5 rimraf: 3.0.2 - semver: 7.3.5 + semver: ^7.3.5 ts-jest: 26.1.4 ts-node: ^10.0.0 typescript: 3.9.7 @@ -1263,6 +1384,16 @@ __metadata: languageName: node linkType: hard +"@types/body-parser@npm:1.19.1": + version: 1.19.1 + resolution: "@types/body-parser@npm:1.19.1" + dependencies: + "@types/connect": "*" + "@types/node": "*" + checksum: abbebf715ffda345ee9ffc53f4fdad033b63cffcae1e22ffe58c6c8dc251f4879efd6d06714171c106ea89ad3175d9d70021fdc470bf1f0234eb334f3922770e + languageName: node + linkType: hard + "@types/classnames@npm:^2.2.10": version: 2.3.0 resolution: "@types/classnames@npm:2.3.0" @@ -1307,6 +1438,13 @@ __metadata: languageName: node linkType: hard +"@types/cors@npm:2.8.12": + version: 2.8.12 + resolution: "@types/cors@npm:2.8.12" + checksum: f3b62196df42c286282b9dbda26e24d2264211d932e7d9c2240f361e27ef693a5cb03790daeb3610fd958a0690f4d31d45ef7848b0df7a0dd2afc698a4f9df76 + languageName: node + linkType: hard + "@types/cors@npm:2.8.8": version: 2.8.8 resolution: "@types/cors@npm:2.8.8" @@ -1395,6 +1533,17 @@ __metadata: languageName: node linkType: hard +"@types/express-serve-static-core@npm:4.17.24": + version: 4.17.24 + resolution: "@types/express-serve-static-core@npm:4.17.24" + dependencies: + "@types/node": "*" + "@types/qs": "*" + "@types/range-parser": "*" + checksum: 30fc9b62a7cc52fa320fbdf11510b20d91c1aa55d99d90ecb51708b9178aa670f2aaa94eed01f68a96a1a80b3575f1143c0cb23b0e69d60070d6d6d42e40b62a + languageName: node + linkType: hard + "@types/express@npm:*, @types/express@npm:4.17.11": version: 4.17.11 resolution: "@types/express@npm:4.17.11" @@ -1407,6 +1556,18 @@ __metadata: languageName: node linkType: hard +"@types/express@npm:4.17.13": + version: 4.17.13 + resolution: "@types/express@npm:4.17.13" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.18 + "@types/qs": "*" + "@types/serve-static": "*" + checksum: 9f17da703df21e3f1cee2fe1864b9fcac2ab07c37382b972a194a3a484b41c1fbe4022b6cfe546f0171fd2d93b324dd3839512494f4cba639c2afa021e6dbb12 + languageName: node + linkType: hard + "@types/express@npm:4.17.7": version: 4.17.7 resolution: "@types/express@npm:4.17.7" @@ -1738,6 +1899,13 @@ __metadata: languageName: node linkType: hard +"@types/object-path@npm:^0.11.0": + version: 0.11.1 + resolution: "@types/object-path@npm:0.11.1" + checksum: d0bdb453cb99e741fc99918889b0262e6ba0848b04c5c686f895de2cc22fde99414971adec6f33467a1c6150fcd624f6cc99eccdd47bffee3909f3883ad557d7 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -2821,6 +2989,16 @@ __metadata: languageName: node linkType: hard +"apollo-datasource@npm:^3.0.3": + version: 3.0.3 + resolution: "apollo-datasource@npm:3.0.3" + dependencies: + apollo-server-caching: ^3.0.1 + apollo-server-env: ^4.0.3 + checksum: fb3729cdc85169899c99444e109ae4b671be68752d4d1d02de8d8fe66577c958d6accf8502e129098745e157d2168a63d69513be2fa6bb1e4a13c199c11a2008 + languageName: node + linkType: hard + "apollo-env@npm:^0.6.6": version: 0.6.6 resolution: "apollo-env@npm:0.6.6" @@ -2857,6 +3035,19 @@ __metadata: languageName: node linkType: hard +"apollo-graphql@npm:^0.9.0": + version: 0.9.3 + resolution: "apollo-graphql@npm:0.9.3" + dependencies: + core-js-pure: ^3.10.2 + lodash.sortby: ^4.7.0 + sha.js: ^2.4.11 + peerDependencies: + graphql: ^14.2.1 || ^15.0.0 + checksum: 0da8498289a5a6f7b586d7a4336ec2219069c0136b70b3480079ac6cb357a94a53a1e6a411f0a18e1752f6644d5935e3627e67be9b88da5eb7d1731d8513e783 + languageName: node + linkType: hard + "apollo-link-context@npm:1.0.20": version: 1.0.20 resolution: "apollo-link-context@npm:1.0.20" @@ -2927,6 +3118,15 @@ __metadata: languageName: node linkType: hard +"apollo-reporting-protobuf@npm:^3.0.0": + version: 3.0.0 + resolution: "apollo-reporting-protobuf@npm:3.0.0" + dependencies: + "@apollo/protobufjs": 1.2.2 + checksum: fec8965cac06753090a7625e7c0f06db136e1680dd6c28a92d961222f3c3c65764d559eda44a1088c8bf50f168b757131e36960dfda265b82e282d6ff6e26dfd + languageName: node + linkType: hard + "apollo-server-caching@npm:^0.5.3": version: 0.5.3 resolution: "apollo-server-caching@npm:0.5.3" @@ -2945,7 +3145,16 @@ __metadata: languageName: node linkType: hard -"apollo-server-core@npm:^2.19.0, apollo-server-core@npm:^2.21.0, apollo-server-core@npm:^2.23.0": +"apollo-server-caching@npm:^3.0.1": + version: 3.0.1 + resolution: "apollo-server-caching@npm:3.0.1" + dependencies: + lru-cache: ^6.0.0 + checksum: 0fd19a71bf017ba31de9b9fa751f141407986cdaaad423cc265186bfb51e0f6cfef53e391172dd470b03fc68f5e302b059942bcf012b497aaf18337814c2c4b7 + languageName: node + linkType: hard + +"apollo-server-core@npm:^2.19.0, apollo-server-core@npm:^2.23.0": version: 2.23.0 resolution: "apollo-server-core@npm:2.23.0" dependencies: @@ -2981,6 +3190,37 @@ __metadata: languageName: node linkType: hard +"apollo-server-core@npm:^3.1.1": + version: 3.1.1 + resolution: "apollo-server-core@npm:3.1.1" + dependencies: + "@apollographql/apollo-tools": ^0.5.1 + "@apollographql/graphql-playground-html": 1.6.29 + "@graphql-tools/mock": ^8.1.2 + "@graphql-tools/schema": ^7.1.5 + "@graphql-tools/utils": ^7.9.0 + "@josephg/resolvable": ^1.0.0 + apollo-datasource: ^3.0.3 + apollo-graphql: ^0.9.0 + apollo-reporting-protobuf: ^3.0.0 + apollo-server-caching: ^3.0.1 + apollo-server-env: ^4.0.3 + apollo-server-errors: ^3.0.1 + apollo-server-plugin-base: ^3.1.1 + apollo-server-types: ^3.1.1 + async-retry: ^1.2.1 + fast-json-stable-stringify: ^2.1.0 + graphql-tag: ^2.11.0 + loglevel: ^1.6.8 + lru-cache: ^6.0.0 + sha.js: ^2.4.11 + uuid: ^8.0.0 + peerDependencies: + graphql: ^15.3.0 + checksum: 157faaa2a55a8cecf54830e447e7f10956dae99205c054a10a52ad56fca27012a63d97888480fe242368b52bad7475ce990edc73146ded01f28d273c950a88b6 + languageName: node + linkType: hard + "apollo-server-env@npm:^3.0.0": version: 3.0.0 resolution: "apollo-server-env@npm:3.0.0" @@ -2991,6 +3231,15 @@ __metadata: languageName: node linkType: hard +"apollo-server-env@npm:^4.0.3": + version: 4.0.3 + resolution: "apollo-server-env@npm:4.0.3" + dependencies: + node-fetch: ^2.6.1 + checksum: d4e0f626c54fe2850c9c3c5ce2723ff27dcd634aec51bf38c466271f42222dda02af5d26d8f39f2e6c55e4b71c46f50ae851bc5f8dee8e042e7671fa341fc1c2 + languageName: node + linkType: hard + "apollo-server-errors@npm:^2.5.0": version: 2.5.0 resolution: "apollo-server-errors@npm:2.5.0" @@ -3000,6 +3249,15 @@ __metadata: languageName: node linkType: hard +"apollo-server-errors@npm:^3.0.1": + version: 3.0.1 + resolution: "apollo-server-errors@npm:3.0.1" + peerDependencies: + graphql: ^15.3.0 + checksum: 7ffce7299ecaf3ad1f23ea5fbb580ec21fd3412730f6387270fd14a86755d58e909a107c07d334f219d894bce9ebd5d3354e99d827f4ab2d71d2db1a1d3b1e99 + languageName: node + linkType: hard + "apollo-server-express@npm:2.19.0": version: 2.19.0 resolution: "apollo-server-express@npm:2.19.0" @@ -3027,7 +3285,7 @@ __metadata: languageName: node linkType: hard -"apollo-server-express@npm:^2.21.0, apollo-server-express@npm:^2.23.0": +"apollo-server-express@npm:^2.23.0": version: 2.23.0 resolution: "apollo-server-express@npm:2.23.0" dependencies: @@ -3054,6 +3312,28 @@ __metadata: languageName: node linkType: hard +"apollo-server-express@npm:^3.1.1": + version: 3.1.1 + resolution: "apollo-server-express@npm:3.1.1" + dependencies: + "@types/accepts": ^1.3.5 + "@types/body-parser": 1.19.1 + "@types/cors": 2.8.12 + "@types/express": 4.17.13 + "@types/express-serve-static-core": 4.17.24 + accepts: ^1.3.5 + apollo-server-core: ^3.1.1 + apollo-server-types: ^3.1.1 + body-parser: ^1.19.0 + cors: ^2.8.5 + parseurl: ^1.3.3 + peerDependencies: + express: ^4.17.1 + graphql: ^15.3.0 + checksum: 25c627cfa88208b57a90153037e5489ac38b29df9f08fd0eec6b48943b7a121c22aa1b43c97bad7f3f2d3ad39adde63915834f040c6f637585226a153b30e5c3 + languageName: node + linkType: hard + "apollo-server-plugin-base@npm:^0.11.0": version: 0.11.0 resolution: "apollo-server-plugin-base@npm:0.11.0" @@ -3065,6 +3345,17 @@ __metadata: languageName: node linkType: hard +"apollo-server-plugin-base@npm:^3.1.1": + version: 3.1.1 + resolution: "apollo-server-plugin-base@npm:3.1.1" + dependencies: + apollo-server-types: ^3.1.1 + peerDependencies: + graphql: ^15.3.0 + checksum: 095cf37cc403aa8c413de79988f259d0b24eea0ef35a3b313873c8c632460e4cc3bd3aeed8d3058ff8be280887eb195c4af8915927b49c701c0b5e42ca3a7a15 + languageName: node + linkType: hard + "apollo-server-testing@npm:2.19.0": version: 2.19.0 resolution: "apollo-server-testing@npm:2.19.0" @@ -3102,19 +3393,16 @@ __metadata: languageName: node linkType: hard -"apollo-server@npm:2.21.0": - version: 2.21.0 - resolution: "apollo-server@npm:2.21.0" +"apollo-server-types@npm:^3.1.1": + version: 3.1.1 + resolution: "apollo-server-types@npm:3.1.1" dependencies: - apollo-server-core: ^2.21.0 - apollo-server-express: ^2.21.0 - express: ^4.0.0 - graphql-subscriptions: ^1.0.0 - graphql-tools: ^4.0.8 - stoppable: ^1.1.0 + apollo-reporting-protobuf: ^3.0.0 + apollo-server-caching: ^3.0.1 + apollo-server-env: ^4.0.3 peerDependencies: - graphql: ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 - checksum: ab5e80064c95a33ca9875256639197123542d1569a645537324403508a1e42d79f9bc464d1117d9f8bfc7e25e9045f417b198a0877172e71d56e62964535f417 + graphql: ^15.3.0 + checksum: 4257799fc3ef5b147bafe2e977ae0a352c9ddc37edbb2b1c669ac739ecf61ae82207753d5a73cc6b81ba04c364adfc2412a2ad5b5e27ad9ce64dc9111c206ef3 languageName: node linkType: hard @@ -3134,6 +3422,19 @@ __metadata: languageName: node linkType: hard +"apollo-server@npm:^3.0.2": + version: 3.1.1 + resolution: "apollo-server@npm:3.1.1" + dependencies: + apollo-server-core: ^3.1.1 + apollo-server-express: ^3.1.1 + express: ^4.17.1 + peerDependencies: + graphql: ^15.3.0 + checksum: 1d5a546a535087fc3cb5d3df8233212adf7077306fb90c14215b70422ba02512edf7358a73b3a2098f5786c80cbe445c9f767553c54eba81aeb6141a2db17e5f + languageName: node + linkType: hard + "apollo-tracing@npm:^0.13.0": version: 0.13.0 resolution: "apollo-tracing@npm:0.13.0" @@ -3616,7 +3917,7 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:1.19.0, body-parser@npm:^1.18.3": +"body-parser@npm:1.19.0, body-parser@npm:^1.18.3, body-parser@npm:^1.19.0": version: 1.19.0 resolution: "body-parser@npm:1.19.0" dependencies: @@ -4429,6 +4730,13 @@ __metadata: languageName: node linkType: hard +"core-js-pure@npm:^3.10.2": + version: 3.15.2 + resolution: "core-js-pure@npm:3.15.2" + checksum: 098de70a85d245422046c8859d699f8d1a5e971d12074f26e108a3db539430e641af4bb311888efbb944cfdc747d4b56ec8a1970458a2ce444cc834cabdf1772 + languageName: node + linkType: hard + "core-js@npm:^3.0.1": version: 3.11.0 resolution: "core-js@npm:3.11.0" @@ -4646,7 +4954,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1": +"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2": version: 4.3.2 resolution: "debug@npm:4.3.2" dependencies: @@ -6032,7 +6340,7 @@ __metadata: languageName: node linkType: hard -"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0": +"fast-json-stable-stringify@npm:2.x, fast-json-stable-stringify@npm:^2.0.0, fast-json-stable-stringify@npm:^2.1.0": version: 2.1.0 resolution: "fast-json-stable-stringify@npm:2.1.0" checksum: 7df3fabfe445d65953b2d9d9d3958bd895438b215a40fb87dae8b2165c5169a897785eb5d51e6cf0eb03523af756e3d82ea01083f6ac6341fe16db532fee3016 @@ -6608,15 +6916,16 @@ fsevents@^1.2.7: languageName: node linkType: hard -"graphql-compose@npm:^7.25.1": - version: 7.25.1 - resolution: "graphql-compose@npm:7.25.1" +"graphql-compose@npm:^9.0.2": + version: 9.0.2 + resolution: "graphql-compose@npm:9.0.2" dependencies: + "@types/object-path": ^0.11.0 graphql-type-json: 0.3.2 object-path: 0.11.5 peerDependencies: - graphql: ^14.2.0 || ^15.0.0 - checksum: 96aa8a6ab9f2c3b2c97747bb2706aeaa4a88d90c10246d0d40caf8936aeea04e61267fb60db2651e59a6665e58a4a52c911259297c005a63900095dd3881e44f + graphql: ^14.2.0 || ^15.0.0 || ^16.0.0 + checksum: 0c5c7967c1e8e6ea4fd7f65fb08212d65ddda980a9321a089599dd7e000b9d53a71be317abe6d16fb4079e6687b95470b383f6670a288322e7d1d16fdfcd2132 languageName: node linkType: hard @@ -6633,15 +6942,24 @@ fsevents@^1.2.7: languageName: node linkType: hard -"graphql-parse-resolve-info@npm:^4.11.0": - version: 4.11.0 - resolution: "graphql-parse-resolve-info@npm:4.11.0" +"graphql-parse-resolve-info@npm:^4.12.0": + version: 4.12.0 + resolution: "graphql-parse-resolve-info@npm:4.12.0" dependencies: debug: ^4.1.1 tslib: ^2.0.1 peerDependencies: graphql: ">=0.9 <0.14 || ^14.0.2 || ^15.4.0" - checksum: ef41c34e647abbfeb720398012c559c4d3e4d3f78f6e5e994e55940bcfa5c48ad65274609bc22f1e8837c7b1027a897ef9c2334ec2152d3b52f1ba9f22dbc817 + checksum: 995f76c99ef02116a802ccc02c64da848ef3facd55d894782a5a93097cf3074dc60bdeca932fa0de2b35b6788b93de504a18def5f5a236a6769ad3435eb6fe4e + languageName: node + linkType: hard + +"graphql-relay@npm:^0.8.0": + version: 0.8.0 + resolution: "graphql-relay@npm:0.8.0" + peerDependencies: + graphql: 15.5.1 + checksum: 3986b64ca5126e2ad8cbfaf2c7c64161ce00a8f9832c70e226d096dcb47121a7adc652022a746706527661663537d6f721b313d0ad3ff651c8babe339b5b2469 languageName: node linkType: hard @@ -9335,7 +9653,7 @@ fsevents@^1.2.7: version: 0.0.0-use.local resolution: "migration@workspace:examples/migration" dependencies: - "@neo4j/graphql": ^1.2.4 + "@neo4j/graphql": ^2.0.0-rc.2 apollo-server: ^2.23.0 graphql: ^15.0.0 neo4j-driver: ^4.2.0 @@ -9686,8 +10004,8 @@ fsevents@^1.2.7: version: 0.0.0-use.local resolution: "neo-push-server@workspace:examples/neo-push/server" dependencies: - "@neo4j/graphql": ^1.2.4 - "@neo4j/graphql-ogm": ^1.2.4 + "@neo4j/graphql": ^2.0.0-rc.2 + "@neo4j/graphql-ogm": ^2.0.0-rc.2 "@types/bcrypt": 3.0.0 "@types/debug": 4.1.5 "@types/dotenv": 8.2.0 @@ -10486,7 +10804,7 @@ fsevents@^1.2.7: languageName: node linkType: hard -"parseurl@npm:^1.3.2, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": +"parseurl@npm:^1.3.2, parseurl@npm:^1.3.3, parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" checksum: 52c9e86cb58e38b28f1a50a6354d16648974ab7a2b91b209f97102840471de8adf524427774af6d5bc482fb7c0a6af6ba08ab37de9a1a7ae389ebe074015914b @@ -13261,6 +13579,13 @@ resolve@^2.0.0-next.3: languageName: node linkType: hard +"tslib@npm:~2.3.0": + version: 2.3.0 + resolution: "tslib@npm:2.3.0" + checksum: 7b4fc9feff0f704743c3760f5d8d708f6417fac6458159e63df3a6b1100f0736e3b99edb9fe370f274ad15160a1f49ff05cb49402534c818ff552c48e38c3e6e + languageName: node + linkType: hard + "tsutils@npm:^3.17.1": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -13744,6 +14069,20 @@ typescript@4.1.3: languageName: node linkType: hard +"value-or-promise@npm:1.0.10": + version: 1.0.10 + resolution: "value-or-promise@npm:1.0.10" + checksum: 381d254e1bf6e4b56a1512591664c88989e2fc584ac939e3164ba92055f078862e1b7dd8d0bed33d7772e16ac7ff14d521bd06fa7fd6468305132cb8958daf4c + languageName: node + linkType: hard + +"value-or-promise@npm:1.0.6": + version: 1.0.6 + resolution: "value-or-promise@npm:1.0.6" + checksum: ea5fa311aad0c6f63feccb6891e162f847e9bb2b257813eef8fc945f9e48b3df5fe7402227309171f0b096b6c2d17e163d6384ec3de819f49743537bde078699 + languageName: node + linkType: hard + "vary@npm:^1, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2"