diff --git a/.changeset/neat-insects-pretend.md b/.changeset/neat-insects-pretend.md new file mode 100644 index 0000000000..1a4446d27d --- /dev/null +++ b/.changeset/neat-insects-pretend.md @@ -0,0 +1,5 @@ +--- +"@neo4j/introspector": minor +--- + +Added options to customise schema naming conventions to introspector diff --git a/packages/graphql/tests/performance/server/package.json b/packages/graphql/tests/performance/server/package.json index be5c7ea0ec..9bf854861e 100644 --- a/packages/graphql/tests/performance/server/package.json +++ b/packages/graphql/tests/performance/server/package.json @@ -27,6 +27,6 @@ "neo4j-driver": "^5.8.0" }, "dependencies": { - "@neo4j/graphql": "link:../../.." + "@neo4j/graphql": "file:../../.." } } diff --git a/packages/introspector/README.md b/packages/introspector/README.md index 2ac1ac101e..37435cb7fd 100644 --- a/packages/introspector/README.md +++ b/packages/introspector/README.md @@ -97,7 +97,7 @@ You can introspect the schema and then transform it to any desired format. Example: ```js -import { toGenericStruct } from "@neo4j/introspector"; +import { toGenericStruct, graphqlFormatter } from "@neo4j/introspector"; import neo4j from "neo4j-driver"; const driver = neo4j.driver("neo4j://localhost:7687", neo4j.auth.basic("neo4j", "password")); @@ -105,8 +105,64 @@ const driver = neo4j.driver("neo4j://localhost:7687", neo4j.auth.basic("neo4j", const sessionFactory = () => driver.session({ defaultAccessMode: neo4j.session.READ }); async function main() { + const readonly = true; // We don't want to expose mutations in this case + const genericStruct = await toGenericStruct(sessionFactory); + + // Programmatically transform to what you need. + + // create type definitions for gql from the generic string. + const typeDefs = graphqlFormatter(genericStruct, readonly); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + const server = new ApolloServer({ + schema: await neoSchema.getSchema(), + }); + + await startStandaloneServer(server, { + context: async ({ req }) => ({ req }), + }); +} + +main(); +``` + +### Sanitize GraphQL type names + +You can sanitize the GraphQL type names by passing functions using the `sanitizeNodeLabels` and `sanitizeRelationshipTypes` options. + +```js +import { toGenericStruct, graphqlFormatter } from "@neo4j/introspector"; +import neo4j from "neo4j-driver"; + +const driver = neo4j.driver("neo4j://localhost:7687", neo4j.auth.basic("neo4j", "password")); + +const sessionFactory = () => driver.session({ defaultAccessMode: neo4j.session.READ }); + +async function main() { + const readonly = true; // We don't want to expose mutations in this case const genericStruct = await toGenericStruct(sessionFactory); + // Programmatically transform to what you need. + + // create type definitions for gql from the generic string. + const typeDefs = graphqlFormatter(genericStruct, readonly, { + sanitizeNodeLabels: (node) => + node.labels + .sort((a, b) => a.length - b.length)[0] // Take the shortest label + .replace(/[^a-zA-Z0-9]/g, "") // Remove all non-alphanumeric characters + .replace(/^graph_node_prefix__/g, ""), // Remove a prefix + // Remove all non-alphanumeric characters from the relationship types + sanitizeRelationshipTypes: (type) => type.replace(/[^a-zA-Z0-9]/g, ""), + }); + + const neoSchema = new Neo4jGraphQL({ typeDefs, driver }); + const server = new ApolloServer({ + schema: await neoSchema.getSchema(), + }); + + await startStandaloneServer(server, { + context: async ({ req }) => ({ req }), + }); } main(); diff --git a/packages/introspector/src/index.ts b/packages/introspector/src/index.ts index 861efd3a35..ae3b206493 100644 --- a/packages/introspector/src/index.ts +++ b/packages/introspector/src/index.ts @@ -21,6 +21,7 @@ import type { Session } from "neo4j-driver"; import graphqlFormatter from "./transforms/neo4j-graphql"; import toInternalStruct from "./to-internal-struct"; import type { Neo4jStruct } from "./types"; +export { graphqlFormatter }; export async function toGenericStruct(sessionFactory: () => Session): Promise { return toInternalStruct(sessionFactory); diff --git a/packages/introspector/src/transforms/neo4j-graphql/graphql.ts b/packages/introspector/src/transforms/neo4j-graphql/graphql.ts index 7b720700ce..41324838d6 100644 --- a/packages/introspector/src/transforms/neo4j-graphql/graphql.ts +++ b/packages/introspector/src/transforms/neo4j-graphql/graphql.ts @@ -27,15 +27,25 @@ import { RelationshipPropertiesDirective } from "./directives/RelationshipProper import createRelationshipFields from "./utils/create-relationship-fields"; import generateGraphQLSafeName from "./utils/generate-graphql-safe-name"; import nodeKey from "../../utils/node-key"; +import type Node from "../../classes/Node"; type GraphQLNodeMap = { [key: string]: GraphQLNode; }; -export default function graphqlFormatter(neo4jStruct: Neo4jStruct, readonly = false): string { +type FormatterOptions = { + getNodeLabel?: (node: Node) => string; + sanitizeRelType?: (relType: string) => string; +}; + +export default function graphqlFormatter( + neo4jStruct: Neo4jStruct, + readonly = false, + options: FormatterOptions = {} +): string { const { nodes, relationships } = neo4jStruct; - const bareNodes = transformNodes(nodes); - const withRelationships = hydrateWithRelationships(bareNodes, relationships); + const bareNodes = transformNodes(nodes, options); + const withRelationships = hydrateWithRelationships(bareNodes, relationships, options); const sorted = Object.keys(withRelationships).sort((a, b) => { return withRelationships[a].typeName > withRelationships[b].typeName ? 1 : -1; }); @@ -46,7 +56,7 @@ export default function graphqlFormatter(neo4jStruct: Neo4jStruct, readonly = fa return sortedWithRelationships.join("\n\n"); } -function transformNodes(nodes: NodeMap): GraphQLNodeMap { +function transformNodes(nodes: NodeMap, options: FormatterOptions = {}): GraphQLNodeMap { const out = {}; const takenTypeNames: string[] = []; Object.keys(nodes).forEach((nodeType) => { @@ -54,9 +64,12 @@ function transformNodes(nodes: NodeMap): GraphQLNodeMap { if (!nodeType) { return; } + const neo4jNode = nodes[nodeType]; + const neo4jNodeKey = nodeKey(neo4jNode.labels); - const mainLabel = neo4jNode.labels[0]; + + const mainLabel = options.getNodeLabel ? options.getNodeLabel(neo4jNode) : neo4jNode.labels[0]; const typeName = generateGraphQLSafeName(mainLabel); const uniqueTypeName = uniqueString(typeName, takenTypeNames); @@ -79,7 +92,11 @@ function transformNodes(nodes: NodeMap): GraphQLNodeMap { return out; } -function hydrateWithRelationships(nodes: GraphQLNodeMap, rels: RelationshipMap): GraphQLNodeMap { +function hydrateWithRelationships( + nodes: GraphQLNodeMap, + rels: RelationshipMap, + options: FormatterOptions = {} +): GraphQLNodeMap { Object.entries(rels).forEach(([relType, rel]) => { let relInterfaceName: string; @@ -94,12 +111,14 @@ function hydrateWithRelationships(nodes: GraphQLNodeMap, rels: RelationshipMap): relTypePropertiesFields.forEach((f) => relInterfaceNode.addField(f)); nodes[relInterfaceName] = relInterfaceNode; } + // console.dir(rel, { depth: 7 }); rel.paths.forEach((path) => { const { fromField, toField } = createRelationshipFields( nodes[path.fromTypeId].typeName, nodes[path.toTypeId].typeName, relType, - relInterfaceName + relInterfaceName, + options.sanitizeRelType ); nodes[path.fromTypeId].addField(fromField); nodes[path.toTypeId].addField(toField); diff --git a/packages/introspector/src/transforms/neo4j-graphql/utils/create-relationship-fields.ts b/packages/introspector/src/transforms/neo4j-graphql/utils/create-relationship-fields.ts index 0c8e3bdcaa..530c0b57c4 100644 --- a/packages/introspector/src/transforms/neo4j-graphql/utils/create-relationship-fields.ts +++ b/packages/introspector/src/transforms/neo4j-graphql/utils/create-relationship-fields.ts @@ -25,17 +25,18 @@ export default function createRelationshipFields( fromTypeName: string, toTypeName: string, relType: string, - propertiesTypeName?: string + propertiesTypeName?: string, + sanitizeRelType?: (relType: string) => string ): { fromField: NodeField; toField: NodeField } { const fromField = new NodeField( - generateRelationshipFieldName(relType, fromTypeName, toTypeName, "OUT"), + generateRelationshipFieldName(relType, fromTypeName, toTypeName, "OUT", sanitizeRelType), `[${toTypeName}!]!` ); const fromDirective = new RelationshipDirective(relType, "OUT", propertiesTypeName); fromField.addDirective(fromDirective); const toField = new NodeField( - generateRelationshipFieldName(relType, fromTypeName, toTypeName, "IN"), + generateRelationshipFieldName(relType, fromTypeName, toTypeName, "IN", sanitizeRelType), `[${fromTypeName}!]!` ); const toDirective = new RelationshipDirective(relType, "IN", propertiesTypeName); diff --git a/packages/introspector/src/transforms/neo4j-graphql/utils/generate-relationship-field-name.ts b/packages/introspector/src/transforms/neo4j-graphql/utils/generate-relationship-field-name.ts index f17d872aca..306505ef73 100644 --- a/packages/introspector/src/transforms/neo4j-graphql/utils/generate-relationship-field-name.ts +++ b/packages/introspector/src/transforms/neo4j-graphql/utils/generate-relationship-field-name.ts @@ -25,9 +25,10 @@ export default function inferRelationshipFieldName( relType: string, fromType: string, toType: string, - direction: Direction + direction: Direction, + sanitizeRelType: (relType: string) => string = (relType) => relType.replace(/[\s/()\\`]/g, "") ): string { - const sanitizedRelType = relType.replaceAll(/[\s/()\\`]/g, ""); + const sanitizedRelType = sanitizeRelType(relType); if (direction === "OUT") { return camelcase(sanitizedRelType + pluralize(toType)); } diff --git a/packages/introspector/tests/integration/graphql/graphs.test.ts b/packages/introspector/tests/integration/graphql/graphs.test.ts index c593dc7a75..50e397ffd5 100644 --- a/packages/introspector/tests/integration/graphql/graphs.test.ts +++ b/packages/introspector/tests/integration/graphql/graphs.test.ts @@ -34,7 +34,7 @@ describe("GraphQL - Infer Schema on graphs", () => { driver = await createDriver(); const cSession = driver.session({ defaultAccessMode: neo4j.session.WRITE }); try { - await cSession.writeTransaction((tx) => tx.run(`CREATE DATABASE ${dbName} WAIT`)); + await cSession.executeWrite((tx) => tx.run(`CREATE DATABASE ${dbName} WAIT`)); } catch (e) { if (e instanceof Error) { if ( @@ -69,7 +69,7 @@ describe("GraphQL - Infer Schema on graphs", () => { if (MULTIDB_SUPPORT) { const cSession = driver.session({ defaultAccessMode: neo4j.session.WRITE }); try { - await cSession.writeTransaction((tx) => tx.run(`DROP DATABASE ${dbName}`)); + await cSession.executeWrite((tx) => tx.run(`DROP DATABASE ${dbName}`)); } catch (e) { // ignore } @@ -86,7 +86,7 @@ describe("GraphQL - Infer Schema on graphs", () => { const nodeProperties = { title: "Forrest Gump", name: "Glenn Hysén" }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (m:Movie {title: $props.title}) CREATE (a:Actor {name: $props.name}) @@ -125,7 +125,7 @@ describe("GraphQL - Infer Schema on graphs", () => { const nodeProperties = { title: "Forrest Gump", name: "Glenn Hysén" }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (m:Movie {title: $props.title}) CREATE (p:Play:Theater {title: $props.title}) @@ -192,7 +192,7 @@ describe("GraphQL - Infer Schema on graphs", () => { int: neo4j.int(1), }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (m:Movie {title: $props.title}) CREATE (a:Actor {name: $props.name}) @@ -251,7 +251,7 @@ describe("GraphQL - Infer Schema on graphs", () => { roles: ["Footballer", "Drunken man on the street"], }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (m:\`Movie-Label\` {title: $props.title}) CREATE (a:\`Actor-Label\` {name: $props.name}) diff --git a/packages/introspector/tests/integration/graphql/label-injection.test.ts b/packages/introspector/tests/integration/graphql/label-injection.test.ts index 7ede59ddc7..5976791711 100644 --- a/packages/introspector/tests/integration/graphql/label-injection.test.ts +++ b/packages/introspector/tests/integration/graphql/label-injection.test.ts @@ -34,7 +34,7 @@ describe("GraphQL - Infer Schema on graphs", () => { driver = await createDriver(); const cSession = driver.session({ defaultAccessMode: neo4j.session.WRITE }); try { - await cSession.writeTransaction((tx) => tx.run(`CREATE DATABASE ${dbName} WAIT`)); + await cSession.executeWrite((tx) => tx.run(`CREATE DATABASE ${dbName} WAIT`)); } catch (e) { if (e instanceof Error) { if ( @@ -69,7 +69,7 @@ describe("GraphQL - Infer Schema on graphs", () => { if (MULTIDB_SUPPORT) { const cSession = driver.session({ defaultAccessMode: neo4j.session.WRITE }); try { - await cSession.writeTransaction((tx) => tx.run(`DROP DATABASE ${dbName}`)); + await cSession.executeWrite((tx) => tx.run(`DROP DATABASE ${dbName}`)); } catch (e) { // ignore } @@ -85,7 +85,7 @@ describe("GraphQL - Infer Schema on graphs", () => { } const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run("CREATE (a:Wurst) -[:```MATCH (n) DETACH DELETE n //`] -> (:Salat)") ); const bm = wSession.lastBookmark(); diff --git a/packages/introspector/tests/integration/graphql/nodes.test.ts b/packages/introspector/tests/integration/graphql/nodes.test.ts index db748bf817..c3999734bc 100644 --- a/packages/introspector/tests/integration/graphql/nodes.test.ts +++ b/packages/introspector/tests/integration/graphql/nodes.test.ts @@ -19,7 +19,7 @@ import * as neo4j from "neo4j-driver"; import { Neo4jGraphQL } from "@neo4j/graphql"; -import { toGraphQLTypeDefs } from "../../../src/index"; +import { graphqlFormatter, toGenericStruct, toGraphQLTypeDefs } from "../../../src/index"; import createDriver from "../neo4j"; describe("GraphQL - Infer Schema nodes basic tests", () => { @@ -34,7 +34,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { driver = await createDriver(); const cSession = driver.session({ defaultAccessMode: neo4j.session.WRITE }); try { - await cSession.writeTransaction((tx) => tx.run(`CREATE DATABASE ${dbName} WAIT`)); + await cSession.executeWrite((tx) => tx.run(`CREATE DATABASE ${dbName} WAIT`)); } catch (e) { if (e instanceof Error) { if ( @@ -69,7 +69,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { if (MULTIDB_SUPPORT) { const cSession = driver.session({ defaultAccessMode: neo4j.session.WRITE }); try { - await cSession.writeTransaction((tx) => tx.run(`DROP DATABASE ${dbName}`)); + await cSession.executeWrite((tx) => tx.run(`DROP DATABASE ${dbName}`)); } catch (e) { // ignore } @@ -85,7 +85,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { const nodeProperty = "testString"; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run("CREATE (:TestLabel {nodeProperty: $prop})", { prop: nodeProperty }) ); const bm = wSession.lastBookmark(); @@ -112,7 +112,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { const nodeProperties = { str: "testString", int: neo4j.int(42), number: 80, strArr: ["Stella", "Molly"] }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( "CREATE (:TestLabel {strProp: $props.str, intProp: $props.int, numberProp: $props.number, strArrProp: $props.strArr})", { props: nodeProperties } @@ -145,7 +145,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { const nodeProperties = { first: "testString", second: neo4j.int(42) }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (:TestLabel {strProp: $props.first}) CREATE (:TestLabel2 {singleProp: $props.second})`, @@ -180,7 +180,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { const nodeProperties = { first: "testString", second: neo4j.int(42) }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (:TestLabel {strProp: $props.first}) CREATE (:TestLabel2:TestLabel3 {singleProp: $props.second})`, @@ -215,7 +215,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { const nodeProperties = { first: "testString", second: neo4j.int(42) }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( "CREATE (:`Test``Label` {strProp: $props.first}) CREATE (:`Test-Label` {singleProp: $props.second})", { props: nodeProperties } @@ -249,7 +249,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { } const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => tx.run("CREATE (:`2number` {prop: 1})")); + await wSession.executeWrite((tx) => tx.run("CREATE (:`2number` {prop: 1})")); const bm = wSession.lastBookmark(); await wSession.close(); @@ -274,7 +274,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { const nodeProperties = { str: "testString", int: neo4j.int(42) }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (:FullNode {amb: $props.str, str: $props.str}) CREATE (:FullNode {amb: $props.int, str: $props.str}) @@ -307,7 +307,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { } const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run("CREATE ({prop: 1}) CREATE ({prop: 2}) CREATE (:EmptyNode) CREATE (:FullNode {prop: 1})") ); const bm = wSession.lastBookmark(); @@ -333,7 +333,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { } const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => tx.run("CREATE (:EmptyNode)-[:RELATIONSHIP]->(:FullNode {prop: 1})")); + await wSession.executeWrite((tx) => tx.run("CREATE (:EmptyNode)-[:RELATIONSHIP]->(:FullNode {prop: 1})")); const bm = wSession.lastBookmark(); await wSession.close(); @@ -363,7 +363,7 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { const nodeProperties = { first: "testString", second: neo4j.int(42) }; const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); - await wSession.writeTransaction((tx) => + await wSession.executeWrite((tx) => tx.run( `CREATE (:TestLabel {strProp: $props.first}) CREATE (:TestLabel2:TestLabel3 {singleProp: $props.second})`, @@ -391,4 +391,75 @@ describe("GraphQL - Infer Schema nodes basic tests", () => { await expect(neoSchema.getSchema()).resolves.not.toThrow(); }); + + test("Should support custom label mapping", async () => { + if (!MULTIDB_SUPPORT) { + console.log("MULTIDB_SUPPORT NOT AVAILABLE - SKIPPING"); + return; + } + + const nodeProperties = { first: "testString", second: neo4j.int(42) }; + const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); + await wSession.executeWrite((tx) => + tx.run( + `CREATE (a:TestLabel {strProp: $props.first}) + CREATE (b:TestLabel2:TestLabel3 {singleProp: $props.second}) + CREATE (a)-[:RelationshipName]->(b)`, + { props: nodeProperties } + ) + ); + const bm = wSession.lastBookmark(); + await wSession.close(); + + const genericStruct = await toGenericStruct(sessionFactory(bm) as any); + const typeDefs = graphqlFormatter(genericStruct, false, { + getNodeLabel: (node: any) => `${node.labels.join("-")}`, + sanitizeRelType: (relType: string) => relType.replace(/Name$/, ""), + }); + expect(typeDefs).toMatch( + `type TestLabel { + relationshipTestLabel2TestLabel3S: [TestLabel2_TestLabel3!]! @relationship(type: "RelationshipName", direction: OUT) + strProp: String! +} + +type TestLabel2_TestLabel3 @node(labels: ["TestLabel2", "TestLabel3"]) { + singleProp: BigInt! + testLabelsRelationship: [TestLabel!]! @relationship(type: "RelationshipName", direction: IN) +}`.replace(/ {2}/g, "\t") + ); + }); + + test("Should not require custom label mapping", async () => { + if (!MULTIDB_SUPPORT) { + console.log("MULTIDB_SUPPORT NOT AVAILABLE - SKIPPING"); + return; + } + + const nodeProperties = { first: "testString", second: neo4j.int(42) }; + const wSession = driver.session({ defaultAccessMode: neo4j.session.WRITE, database: dbName }); + await wSession.executeWrite((tx) => + tx.run( + `CREATE (a:TestLabel {strProp: $props.first}) + CREATE (b:TestLabel2:TestLabel3 {singleProp: $props.second}) + CREATE (a)-[:RelationshipName]->(b)`, + { props: nodeProperties } + ) + ); + const bm = wSession.lastBookmark(); + await wSession.close(); + + const genericStruct = await toGenericStruct(sessionFactory(bm) as any); + const typeDefs = graphqlFormatter({ ...genericStruct }, false); + expect(typeDefs).toMatch( + `type TestLabel { + relationshipNameTestLabel2S: [TestLabel2!]! @relationship(type: "RelationshipName", direction: OUT) + strProp: String! +} + +type TestLabel2 @node(labels: ["TestLabel2", "TestLabel3"]) { + singleProp: BigInt! + testLabelsRelationshipName: [TestLabel!]! @relationship(type: "RelationshipName", direction: IN) +}`.replace(/ {2}/g, "\t") + ); + }); });