Repository: neo4j/cypher-builder
Pull request #505: 504 flag to disable escaping labels and types
Author: login: angrykoala
Created: 2025-03-03T16:40:18Z | Merged: 2025-03-04T15:17:30Z

New options to disable automatic escaping of labels and relationship types have been added to the `.build` method on clauses, inside the new object `unsafeEscapeOptions`:

- `disableLabelEscaping` (defaults to `false`): If set to true, node labels will not be escaped if unsafe.
- `disableRelationshipTypeEscaping` (defaults to `false`): If set to true, relationship types will not be escaped if unsafe

For example:

```js
const personNode = new Cypher.Node();
const movieNode = new Cypher.Node();

const matchQuery = new Cypher.Match(
    new Cypher.Pattern(personNode, {
        labels: ["Person"],
        properties: {
            ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
        },
    })
        .related({ type: "ACTED IN" })
        .to(movieNode, { labels: ["A Movie"] })
).return(personNode);

const queryResult = matchQuery.build({
    unsafeEscapeOptions: {
        disableLabelEscaping: true,
        disableRelationshipTypeEscaping: true,
    },
});
```

This query will generate the following (invalid) Cypher:

```
MATCH (this0:Person { `person name`: "Uneak \"Seveer" })-[:ACTED IN]->(this1:A Movie)
RETURN this0
```

Instead of the default (safe) Cypher:

```cypher
MATCH (this0:Person { `person name`: "Uneak \"Seveer" })-[:`ACTED IN`]->(this1:`A Movie`)
RETURN this0
```

**WARNING:**: Changing these options may lead to code injection and unsafe Cypher.


Diff:
diff --git a/.changeset/shiny-badgers-reply.md b/.changeset/shiny-badgers-reply.md
new file mode 100644
index 00000000..d1e79861
--- /dev/null
+++ b/.changeset/shiny-badgers-reply.md
@@ -0,0 +1,49 @@
+---
+"@neo4j/cypher-builder": patch
+---
+
+New options to disable automatic escaping of labels and relationship types have been added to the `.build` method on clauses, inside the new object `unsafeEscapeOptions`:
+
+- `disableLabelEscaping` (defaults to `false`): If set to true, node labels will not be escaped if unsafe.
+- `disableRelationshipTypeEscaping` (defaults to `false`): If set to true, relationship types will not be escaped if unsafe
+
+For example:
+
+```js
+const personNode = new Cypher.Node();
+const movieNode = new Cypher.Node();
+
+const matchQuery = new Cypher.Match(
+    new Cypher.Pattern(personNode, {
+        labels: ["Person"],
+        properties: {
+            ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+        },
+    })
+        .related({ type: "ACTED IN" })
+        .to(movieNode, { labels: ["A Movie"] })
+).return(personNode);
+
+const queryResult = matchQuery.build({
+    unsafeEscapeOptions: {
+        disableLabelEscaping: true,
+        disableRelationshipTypeEscaping: true,
+    },
+});
+```
+
+This query will generate the following (invalid) Cypher:
+
+```
+MATCH (this0:Person { `person name`: "Uneak \"Seveer" })-[:ACTED IN]->(this1:A Movie)
+RETURN this0
+```
+
+Instead of the default (safe) Cypher:
+
+```cypher
+MATCH (this0:Person { `person name`: "Uneak \"Seveer" })-[:`ACTED IN`]->(this1:`A Movie`)
+RETURN this0
+```
+
+**WARNING:**: Changing these options may lead to code injection and unsafe Cypher.
diff --git a/docs/modules/ROOT/pages/how-to/customize-cypher.adoc b/docs/modules/ROOT/pages/how-to/customize-cypher.adoc
index ce676729..e3ac50c1 100644
--- a/docs/modules/ROOT/pages/how-to/customize-cypher.adoc
+++ b/docs/modules/ROOT/pages/how-to/customize-cypher.adoc
@@ -346,3 +346,92 @@ And the following parameters:
 The callback passed into `Raw` is producing the string `this0.prop = $myParam`. 
 To achieve this, it uses the utility method `utils.compileCypher` and passes the variable `movie` and the `context` parameter, which then returns the string `this0`. 
 Finally, the custom parameter `$myParam` is returned in the tuple `[cypher, params]`, ensuring that it is available when executing `match.build()`.
+
+
+== Disable automatic escaping
+
+[WARNING]
+====
+Changing these options may lead to code injection and unsafe Cypher.
+====
+
+Cypher Builder automatically escapes unsafe strings that could lead to code injection. This behavior can be configured using the `unsafeEscapeOptions` parameter in the `.build` method of clauses:
+
+- `disableLabelEscaping` (defaults to `false`): If set to `true`, node labels will not be escaped, even if unsafe.
+- `disableRelationshipTypeEscaping` (defaults to `false`): If set to `true`, relationship types will not be escaped, even if unsafe.
+
+For example:
+
+[source, javascript]
+----
+const personNode = new Cypher.Node();
+const movieNode = new Cypher.Node();
+
+const matchQuery = new Cypher.Match(
+    new Cypher.Pattern(personNode, {
+        labels: ["Person"],
+        properties: {
+            ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+        },
+    })
+        .related({ type: "ACTED IN" })
+        .to(movieNode, { labels: ["A Movie"] })
+).return(personNode);
+
+const queryResult = matchQuery.build({
+    unsafeEscapeOptions: {
+        disableLabelEscaping: true,
+        disableRelationshipTypeEscaping: true,
+    },
+});
+----
+
+This query will generate the following (invalid) Cypher:
+
+
+[source]
+----
+MATCH (this0:Person { `person name`: "Uneak \"Seveer" })-[:ACTED IN]->(this1:A Movie)
+RETURN this0
+----
+
+Instead of the default (safe) Cypher:
+
+[source, cypher]
+----
+MATCH (this0:Person { `person name`: "Uneak \"Seveer" })-[:`ACTED IN`]->(this1:`A Movie`)
+RETURN this0
+----
+
+=== Manually escaping labels and types
+
+If automatic escaping is disabled, strings used for labels and relationship types must be escaped manually. This can be done using the following utility functions:
+
+* `Cypher.utils.escapeLabel(str)`
+* `Cypher.utils.escapeType(str)`
+
+In the previous example, labels and types can be escaped manually to produce valid Cypher:
+
+[source, javascript]
+----
+const personNode = new Cypher.Node();
+const movieNode = new Cypher.Node();
+
+const matchQuery = new Cypher.Match(
+    new Cypher.Pattern(personNode, {
+        labels: [Cypher.utils.escapeLabel("Person")],
+        properties: {
+            ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+        },
+    })
+        .related({ type: Cypher.utils.escapeType("ACTED IN") })
+        .to(movieNode, { labels: [Cypher.utils.escapeLabel("A Movie")] })
+).return(personNode);
+
+const queryResult = matchQuery.build({
+    unsafeEscapeOptions: {
+        disableLabelEscaping: true,
+        disableRelationshipTypeEscaping: true,
+    },
+});
+----
diff --git a/src/Environment.ts b/src/Environment.ts
index 18bcb3a5..bd855b5e 100644
--- a/src/Environment.ts
+++ b/src/Environment.ts
@@ -17,6 +17,7 @@
  * limitations under the License.
  */
 
+import type { BuildConfig } from "./Cypher";
 import { Param } from "./references/Param";
 import type { NamedReference, Variable } from "./references/Variable";
 
@@ -26,12 +27,15 @@ export type EnvPrefix = {
 };
 
 export type EnvConfig = {
-    labelOperator: ":" | "&";
-    cypherVersion?: "5";
+    labelOperator: NonNullable<BuildConfig["labelOperator"]>;
+    unsafeEscapeOptions: NonNullable<BuildConfig["unsafeEscapeOptions"]>;
+    cypherVersion: BuildConfig["cypherVersion"];
 };
 
 const defaultConfig: EnvConfig = {
     labelOperator: ":",
+    unsafeEscapeOptions: {},
+    cypherVersion: undefined,
 };
 
 /** Hold the internal references of Cypher parameters and variables
diff --git a/src/clauses/Clause.ts b/src/clauses/Clause.ts
index 31fa02b0..3a5e4d2f 100644
--- a/src/clauses/Clause.ts
+++ b/src/clauses/Clause.ts
@@ -28,10 +28,48 @@ const customInspectSymbol = Symbol.for("nodejs.util.inspect.custom");
 
 /** Config fields for the .build method */
 export type BuildConfig = Partial<{
+    /** Defines the default operator for adding multiple labels in a Pattern. Defaults to `":"`
+     *
+     * If the target Cypher is version 5 or above, `"&"` is recommended
+     *
+     * @example
+     * `MATCH (this:Movie:Film)`
+     * `MATCH (this:Movie&Film)`
+     *
+     */
     labelOperator: ":" | "&";
+    /** Will prefix generated queries with the Cypher version
+     * @example `CYPHER 5` */
     cypherVersion: "5";
+    /** Prefix variables with given string.
+     *
+     * This is useful to avoid name collisions if separate Cypher queries are generated and merged after generating the strings.
+     * @example `myPrefix_this0`
+     *
+     */
     prefix: string;
+    /** Extra parameters to be added to the result of `.build`. */
     extraParams: Record<string, unknown>;
+    /** Options for disabling automatic escaping of potentially unsafe strings.
+     *
+     * **WARNING**: Changing these options may lead to code injection and unsafe Cypher.
+     */
+    unsafeEscapeOptions: Partial<{
+        /** Disables automatic escaping of node labels.
+         *
+         * If disabled, any labels passed should be properly escaped with `utils.escapeLabel`.
+         *
+         * **WARNING**: Disabling label escaping may lead to code injection and unsafe Cypher.
+         */
+        disableLabelEscaping: boolean;
+        /** Disables automatic escaping of relationship types.
+         *
+         * If disabled, any types passed should be properly escaped with `utils.escapeType`.
+         *
+         * **WARNING**: Disabling type escaping may lead to code injection and unsafe Cypher.
+         */
+        disableRelationshipTypeEscaping: boolean;
+    }>;
 }>;
 
 /** Represents a clause AST node
@@ -41,11 +79,18 @@ export abstract class Clause extends CypherASTNode {
     protected nextClause: Clause | undefined;
 
     /** Compiles a clause into Cypher and params */
-    public build({ prefix, extraParams = {}, labelOperator = ":", cypherVersion }: BuildConfig = {}): CypherResult {
+    public build({
+        prefix,
+        extraParams = {},
+        labelOperator = ":",
+        cypherVersion,
+        unsafeEscapeOptions = {},
+    }: BuildConfig = {}): CypherResult {
         if (this.isRoot) {
             const env = this.getEnv(prefix, {
                 labelOperator,
                 cypherVersion,
+                unsafeEscapeOptions,
             });
             const cypher = this.getCypher(env);
 
@@ -58,7 +103,7 @@ export abstract class Clause extends CypherASTNode {
         }
         const root = this.getRoot();
         if (root instanceof Clause) {
-            return root.build({ prefix, extraParams, labelOperator, cypherVersion });
+            return root.build({ prefix, extraParams, labelOperator, cypherVersion, unsafeEscapeOptions });
         }
         throw new Error(`Cannot build root: ${root.constructor.name}`);
     }
diff --git a/src/pattern/PartialPattern.ts b/src/pattern/PartialPattern.ts
index 3efcd275..63f324dd 100644
--- a/src/pattern/PartialPattern.ts
+++ b/src/pattern/PartialPattern.ts
@@ -26,7 +26,7 @@ import type { Expr } from "../types";
 import { compileCypherIfExists } from "../utils/compile-cypher-if-exists";
 import { NestedPattern, type NodePattern, type Pattern, type RelationshipPattern } from "./Pattern";
 import { PatternElement } from "./PatternElement";
-import { labelsToString } from "./labels-to-string";
+import { typeToString } from "./labels-to-string";
 
 // eslint-disable-next-line @typescript-eslint/no-empty-object-type
 export interface PartialPattern extends WithWhere {}
@@ -99,7 +99,7 @@ export class PartialPattern extends PatternElement {
 
     private getTypeStr(env: CypherEnvironment): string {
         if (this.type) {
-            return labelsToString(this.type, env);
+            return typeToString(this.type, env);
         }
         return "";
     }
diff --git a/src/pattern/Pattern.test.ts b/src/pattern/Pattern.test.ts
index f169d07e..42049b0e 100644
--- a/src/pattern/Pattern.test.ts
+++ b/src/pattern/Pattern.test.ts
@@ -517,4 +517,16 @@ describe("Patterns", () => {
 
         expect(queryResult.params).toMatchInlineSnapshot(`{}`);
     });
+
+    test("Pattern with empty strings as labels and types", () => {
+        const a = new Cypher.Node();
+        const b = new Cypher.Node();
+        const rel = new Cypher.Relationship();
+
+        const query = new TestClause(new Cypher.Pattern(a, { labels: [""] }).related(rel, { type: "" }).to(b));
+        const queryResult = query.build();
+        expect(queryResult.cypher).toMatchInlineSnapshot(`"(this0)-[this1]->(this2)"`);
+
+        expect(queryResult.params).toMatchInlineSnapshot(`{}`);
+    });
 });
diff --git a/src/pattern/labels-to-string.ts b/src/pattern/labels-to-string.ts
index a399d384..e3fe438c 100644
--- a/src/pattern/labels-to-string.ts
+++ b/src/pattern/labels-to-string.ts
@@ -21,20 +21,26 @@ import type { CypherEnvironment } from "../Environment";
 import { LabelExpr } from "../expressions/labels/label-expressions";
 import { addLabelToken } from "../utils/add-label-token";
 import { asArray } from "../utils/as-array";
-import { escapeLabel } from "../utils/escape";
+import { escapeLabel, escapeType } from "../utils/escape";
 
 export function labelsToString(labels: string | string[] | LabelExpr, env: CypherEnvironment): string {
     if (labels instanceof LabelExpr) {
-        const labelsStr = labels.getCypher(env);
-        if (!labelsStr) {
-            return "";
-        }
         return addLabelToken(env.config.labelOperator, labels.getCypher(env));
     } else {
-        const escapedLabels = asArray(labels).map(escapeLabel);
-        if (escapedLabels.length === 0) {
-            return "";
-        }
+        const shouldEscape = !env.config.unsafeEscapeOptions.disableLabelEscaping;
+        const escapedLabels = shouldEscape ? asArray(labels).map(escapeLabel) : asArray(labels);
+
         return addLabelToken(env.config.labelOperator, ...escapedLabels);
     }
 }
+
+export function typeToString(type: string | LabelExpr, env: CypherEnvironment): string {
+    if (type instanceof LabelExpr) {
+        return addLabelToken(env.config.labelOperator, type.getCypher(env));
+    } else {
+        const shouldEscape = !env.config.unsafeEscapeOptions.disableRelationshipTypeEscaping;
+        const escapedType = shouldEscape ? escapeType(type) : type;
+
+        return addLabelToken(env.config.labelOperator, escapedType);
+    }
+}
diff --git a/src/references/Variable.ts b/src/references/Variable.ts
index d4124ad0..7399d9f1 100644
--- a/src/references/Variable.ts
+++ b/src/references/Variable.ts
@@ -17,11 +17,11 @@
  * limitations under the License.
  */
 
-import { PropertyRef } from "./PropertyRef";
+import type { CypherEnvironment } from "../Environment";
 import { ListIndex } from "../expressions/list/ListIndex";
 import type { Expr } from "../types";
-import type { CypherEnvironment } from "../Environment";
 import { escapeVariable } from "../utils/escape";
+import { PropertyRef } from "./PropertyRef";
 
 /** Represents a variable
  * @group Variables
diff --git a/src/utils/stringify-object.test.ts b/src/utils/stringify-object.test.ts
index d0b35f31..ea360b6c 100644
--- a/src/utils/stringify-object.test.ts
+++ b/src/utils/stringify-object.test.ts
@@ -39,4 +39,13 @@ describe("stringifyObject", () => {
 
         expect(result).toBe(`{ nobody: expects }`);
     });
+
+    test("escape keys", () => {
+        const result = stringifyObject({
+            ["this name"]: "this",
+            that: `"that"`,
+        });
+
+        expect(result).toBe(`{ \`this name\`: this, that: "that" }`);
+    });
 });
diff --git a/tests/build-config/unsafe-escape.test.ts b/tests/build-config/unsafe-escape.test.ts
new file mode 100644
index 00000000..3012b781
--- /dev/null
+++ b/tests/build-config/unsafe-escape.test.ts
@@ -0,0 +1,225 @@
+/*
+ * 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 Cypher from "../../src";
+
+describe("escapeUnsafeLabels", () => {
+    test("Match and update with escaping", () => {
+        const personNode = new Cypher.Node();
+        const movieNode = new Cypher.Node();
+
+        const matchQuery = new Cypher.Match(
+            new Cypher.Pattern(personNode, {
+                labels: ["Person"],
+                properties: {
+                    ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+                },
+            })
+                .related({ type: "ACTED IN" })
+                .to(movieNode, { labels: ["A Movie"] })
+        )
+            .set([personNode.property("person name"), new Cypher.Param("Keanu Reeves")])
+            .return([personNode, new Cypher.NamedVariable("My Result")]);
+
+        const queryResult = matchQuery.build({
+            unsafeEscapeOptions: {
+                disableLabelEscaping: false,
+                disableRelationshipTypeEscaping: false,
+            },
+        });
+        expect(queryResult.cypher).toMatchInlineSnapshot(`
+"MATCH (this0:Person { \`person name\`: \\"Uneak \\\\\\"Seveer\\" })-[:\`ACTED IN\`]->(this1:\`A Movie\`)
+SET
+    this0.\`person name\` = $param0
+RETURN this0 AS \`My Result\`"
+`);
+
+        expect(queryResult.params).toMatchInlineSnapshot(`
+{
+  "param0": "Keanu Reeves",
+}
+`);
+    });
+
+    test("Match and update without escaping", () => {
+        const personNode = new Cypher.Node();
+        const movieNode = new Cypher.Node();
+
+        const matchQuery = new Cypher.Match(
+            new Cypher.Pattern(personNode, {
+                labels: ["Person"],
+                properties: {
+                    ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+                },
+            })
+                .related({ type: "ACTED IN" })
+                .to(movieNode, { labels: ["A Movie"] })
+        )
+            .set([personNode.property("person name"), new Cypher.Param("Keanu Reeves")])
+            .return([personNode, new Cypher.NamedVariable("My Result")]);
+
+        const queryResult = matchQuery.build({
+            unsafeEscapeOptions: {
+                disableLabelEscaping: true,
+                disableRelationshipTypeEscaping: true,
+            },
+        });
+        expect(queryResult.cypher).toMatchInlineSnapshot(`
+"MATCH (this0:Person { \`person name\`: \\"Uneak \\\\\\"Seveer\\" })-[:ACTED IN]->(this1:A Movie)
+SET
+    this0.\`person name\` = $param0
+RETURN this0 AS \`My Result\`"
+`);
+
+        expect(queryResult.params).toMatchInlineSnapshot(`
+{
+  "param0": "Keanu Reeves",
+}
+`);
+    });
+
+    test("Match and update without escaping labels", () => {
+        const personNode = new Cypher.Node();
+        const movieNode = new Cypher.Node();
+
+        const matchQuery = new Cypher.Match(
+            new Cypher.Pattern(personNode, {
+                labels: ["Person"],
+                properties: {
+                    ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+                },
+            })
+                .related({ type: "ACTED IN" })
+                .to(movieNode, { labels: ["A Movie"] })
+        )
+            .set([personNode.property("person name"), new Cypher.Param("Keanu Reeves")])
+            .return([personNode, new Cypher.NamedVariable("My Result")]);
+
+        const queryResult = matchQuery.build({
+            unsafeEscapeOptions: {
+                disableLabelEscaping: true,
+            },
+        });
+        expect(queryResult.cypher).toMatchInlineSnapshot(`
+"MATCH (this0:Person { \`person name\`: \\"Uneak \\\\\\"Seveer\\" })-[:\`ACTED IN\`]->(this1:A Movie)
+SET
+    this0.\`person name\` = $param0
+RETURN this0 AS \`My Result\`"
+`);
+
+        expect(queryResult.params).toMatchInlineSnapshot(`
+{
+  "param0": "Keanu Reeves",
+}
+`);
+    });
+    test("Match and update without escaping types", () => {
+        const personNode = new Cypher.Node();
+        const movieNode = new Cypher.Node();
+
+        const matchQuery = new Cypher.Match(
+            new Cypher.Pattern(personNode, {
+                labels: ["Person"],
+                properties: {
+                    ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+                },
+            })
+                .related({ type: "ACTED IN" })
+                .to(movieNode, { labels: ["A Movie"] })
+        )
+            .set([personNode.property("person name"), new Cypher.Param("Keanu Reeves")])
+            .return([personNode, new Cypher.NamedVariable("My Result")]);
+
+        const queryResult = matchQuery.build({
+            unsafeEscapeOptions: {
+                disableRelationshipTypeEscaping: true,
+            },
+        });
+        expect(queryResult.cypher).toMatchInlineSnapshot(`
+"MATCH (this0:Person { \`person name\`: \\"Uneak \\\\\\"Seveer\\" })-[:ACTED IN]->(this1:\`A Movie\`)
+SET
+    this0.\`person name\` = $param0
+RETURN this0 AS \`My Result\`"
+`);
+
+        expect(queryResult.params).toMatchInlineSnapshot(`
+{
+  "param0": "Keanu Reeves",
+}
+`);
+    });
+
+    test("Simple example", () => {
+        const personNode = new Cypher.Node();
+        const movieNode = new Cypher.Node();
+
+        const matchQuery = new Cypher.Match(
+            new Cypher.Pattern(personNode, {
+                labels: ["Person"],
+                properties: {
+                    ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+                },
+            })
+                .related({ type: "ACTED IN" })
+                .to(movieNode, { labels: ["A Movie"] })
+        ).return(personNode);
+
+        const queryResult = matchQuery.build({
+            unsafeEscapeOptions: {
+                disableLabelEscaping: true,
+                disableRelationshipTypeEscaping: true,
+            },
+        });
+
+        expect(queryResult.cypher).toMatchInlineSnapshot(`
+"MATCH (this0:Person { \`person name\`: \\"Uneak \\\\\\"Seveer\\" })-[:ACTED IN]->(this1:A Movie)
+RETURN this0"
+`);
+        expect(queryResult.params).toMatchInlineSnapshot(`{}`);
+    });
+
+    test("Manually escaping with utils", () => {
+        const personNode = new Cypher.Node();
+        const movieNode = new Cypher.Node();
+
+        const matchQuery = new Cypher.Match(
+            new Cypher.Pattern(personNode, {
+                labels: [Cypher.utils.escapeLabel("Person")],
+                properties: {
+                    ["person name"]: new Cypher.Literal(`Uneak "Seveer`),
+                },
+            })
+                .related({ type: Cypher.utils.escapeType("ACTED IN") })
+                .to(movieNode, { labels: [Cypher.utils.escapeLabel("A Movie")] })
+        ).return(personNode);
+
+        const queryResult = matchQuery.build({
+            unsafeEscapeOptions: {
+                disableLabelEscaping: true,
+                disableRelationshipTypeEscaping: true,
+            },
+        });
+
+        expect(queryResult.cypher).toMatchInlineSnapshot(`
+"MATCH (this0:Person { \`person name\`: \\"Uneak \\\\\\"Seveer\\" })-[:\`ACTED IN\`]->(this1:\`A Movie\`)
+RETURN this0"
+`);
+        expect(queryResult.params).toMatchInlineSnapshot(`{}`);
+    });
+});


Reviews:
user: login: MacondoExpress
body: LGTM

state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T14:40:04Z
user: login: angrykoala
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T15:10:34Z

Review comments:
diff_hunk: @@ -21,20 +21,31 @@ import type { CypherEnvironment } from "../Environment";
 import { LabelExpr } from "../expressions/labels/label-expressions";
 import { addLabelToken } from "../utils/add-label-token";
 import { asArray } from "../utils/as-array";
-import { escapeLabel } from "../utils/escape";
+import { escapeLabel, escapeType } from "../utils/escape";
 
 export function labelsToString(labels: string | string[] | LabelExpr, env: CypherEnvironment): string {
     if (labels instanceof LabelExpr) {
-        const labelsStr = labels.getCypher(env);
-        if (!labelsStr) {
-            return "";
-        }
         return addLabelToken(env.config.labelOperator, labels.getCypher(env));
     } else {
-        const escapedLabels = asArray(labels).map(escapeLabel);
-        if (escapedLabels.length === 0) {
-            return "";
+        let escapedLabels = asArray(labels);
+
+        if (!env.config.unsafeEscapeOptions.disableLabelEscaping) {
path: src/pattern/labels-to-string.ts
user: login: MacondoExpress
body: I would avoid the `not` and `disable` in the same condition, I would do an early return if it's disabled instead
created_at: 2025-03-04T14:36:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,20 +21,31 @@ import type { CypherEnvironment } from "../Environment";
 import { LabelExpr } from "../expressions/labels/label-expressions";
 import { addLabelToken } from "../utils/add-label-token";
 import { asArray } from "../utils/as-array";
-import { escapeLabel } from "../utils/escape";
+import { escapeLabel, escapeType } from "../utils/escape";
 
 export function labelsToString(labels: string | string[] | LabelExpr, env: CypherEnvironment): string {
     if (labels instanceof LabelExpr) {
-        const labelsStr = labels.getCypher(env);
-        if (!labelsStr) {
-            return "";
-        }
         return addLabelToken(env.config.labelOperator, labels.getCypher(env));
     } else {
-        const escapedLabels = asArray(labels).map(escapeLabel);
-        if (escapedLabels.length === 0) {
-            return "";
+        let escapedLabels = asArray(labels);
+
+        if (!env.config.unsafeEscapeOptions.disableLabelEscaping) {
path: src/pattern/labels-to-string.ts
user: login: angrykoala
body: early return here means duplicating the call to `addLabelToken(env.config.labelOperator, ...escapedLabels)`, which I find even more confusing

Maybe a variable `shouldEscape`?
created_at: 2025-03-04T15:10:34Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-03T16:40:21Z
body: ###  🦋  Changeset detected

Latest commit: 820d46bc1160568e61f3021e08db42527555ff91

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                  | Type  |
| --------------------- | ----- |
| @neo4j/cypher-builder | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/neo4j/cypher-builder/new/504-flag-to-disable-esaping-labels-and-types?filename=.changeset/fair-pandas-battle.md&value=---%0A%22%40neo4j%2Fcypher-builder%22%3A%20patch%0A---%0A%0A504%20flag%20to%20disable%20escaping%20labels%20and%20types%0A)


author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-03-04T15:16:05Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=neo4j_cypher-builder&pullRequest=505) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=neo4j_cypher-builder&pullRequest=505&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=neo4j_cypher-builder&pullRequest=505&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=neo4j_cypher-builder&pullRequest=505&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=neo4j_cypher-builder&pullRequest=505&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=neo4j_cypher-builder&pullRequest=505&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=neo4j_cypher-builder&pullRequest=505)


author_association: NONE