Browse Source

feat: GraphQL related improvements

Matthais Ladkau 3 months ago
parent
commit
d7771c24e2

+ 99 - 5
graphql.md

@@ -16,7 +16,29 @@ It reads: "Get all graph nodes of a certain node kind and fetch attr1, attr2 and
 Filtering results
 -----------------
 
-It is possible to reduce the number of resulting nodes by defining a condition using the `matches` argument. For example to get all `Person` nodes which start with the letters `Jo` you could write:
+It is possible to reduce the number of resulting nodes by defining a condition using the `matches` argument.
+
+The simplest case is to retrieve a node with a specific value:
+```
+query {
+  Person(matches: {
+    name : "John"
+  }) {
+    name
+  }
+}
+```
+Multiple values can be matched by specifying a list:
+```
+query {
+  Person(matches: {
+    name : ["John", "Frank"]
+  }) {
+    name
+  }
+}
+```
+For more complex cases it is also possible to use a Regex. For example to get all `Person` nodes where the `name` starts with the letters `Jo` you could write:
 ```
 query {
   Person(matches: {
@@ -26,7 +48,7 @@ query {
   }
 }
 ```
-The condition can be inverted by prefixing with `not_` so to get all `Person` nodes which do *NOT* start with `Jo` you could write:
+The condition can be inverted by prefixing with `not_`. To get all `Person` nodes where the `name` does *NOT* start with `Jo`:
 ```
 query {
   Person(matches: {
@@ -36,6 +58,7 @@ query {
   }
 }
 ```
+
 To retrieve a specific node with a known key it is possible to do a direct lookup by key:
 ```
 query {
@@ -49,7 +72,7 @@ Sorting and limiting
 --------------------
 To manage potentially large results and avoid overwhelming a client with data it is possible to sort and limit the result.
 
-To sort a result in ascending or descending order use the arguments `ascending` or `descending` with the ordering attribute. To order all Person nodes by ascending name write:
+To sort a result in ascending or descending order use the arguments `ascending` or `descending` with the ordering attribute. Ordering is only possible for attributes which are part of the query. To order all Person nodes in ascending name write:
 ```
 query {
   Person(ascending: "name") {
@@ -68,7 +91,7 @@ query {
 
 Traversal
 ---------
-To traverse the graph you need to add the `traverse` argument on a field of the selection set. For example to get the friends of a Person write:
+To traverse the graph you can add the `traverse` argument on a field of the selection set. For example to get the friends of a Person write:
 ```
 query {
   Person(ascending: "name") {
@@ -79,6 +102,59 @@ query {
   }
 }
 ```
+If the traversal route does not matter (e.g. a traversal wildcard would be used above :::Person) then a shortcut is available:
+```
+query {
+  Person(ascending: "name") {
+    name
+    friends: Person {
+      name
+    }
+  }
+}
+```
+
+Fragments
+---------
+Fragments allow repeated selections to be defined once and be reused via a label:
+```
+{
+  Station(ascending:key) {
+    ...stationFields
+    Station(ascending:key) {
+      ...stationFields
+    }
+  }
+}
+fragment stationFields on Station {
+  key
+  name
+  zone
+}
+```
+
+Fragments can also be used as type conditions to query different attributes dependent on the encountered node kind:
+```
+{
+  Station(ascending:key) {
+    ...stationFields
+    StationAndLines(traverse: ":::", ascending:key) {
+      ...stationFields
+      ... on Line {
+        key
+        name
+      }
+    }
+  }
+}
+fragment stationFields on Station {
+  key
+  name
+  zone
+}
+```
+The example above shows a combination of a separate fragment definition and an inline fragment.
+
 
 Data modification
 -----------------
@@ -101,7 +177,7 @@ Possible arguments are `storeNode, storeEdge, removeNode and removeEdge`. The op
 
 Variables
 ---------
-To avoid parsing issues and possible security risks it is advisable to always use variables to pass data to EliasDB especially if it is a user-provided value. EliasDB supports all default GraphQL default types: string, integer, float
+To avoid parsing issues and possible security risks it is advisable to always use variables to pass data to EliasDB especially if it is a user-provided value. EliasDB supports all GraphQL default types: string, integer, float
 ```
 mutation($name: string) {
     Person(storeNode: {
@@ -119,6 +195,24 @@ The type name (in the example `string`) is not evaluated in EliasDB's GraphQL in
   name: "Hans"
 }
 ```
+Variables can be used in combination with fragments and the directives `@skip` and `@include` to modify queries:
+```
+query Stations($expandedInfo: boolean=true){
+  Station(ascending:key) {
+    ...stationFields
+    Station(ascending:key) {
+      ...stationFields
+      ... on Station @include(if: $expandedInfo) {
+        zone
+      }
+    }
+  }
+}
+fragment stationFields on Station {
+  key
+  name
+}
+```
 
 Subscription to updates
 -----------------------

+ 16 - 2
graphql/interpreter/helpers.go

@@ -160,11 +160,25 @@ func (d DataSlice) Less(i, j int) bool {
 	ja, ok2 := d.data[j][d.attr]
 
 	if ok1 && ok2 {
+
+		is := fmt.Sprint(ia)
+		js := fmt.Sprint(ja)
+
+		in, err1 := strconv.Atoi(is)
+		jn, err2 := strconv.Atoi(js)
+
+		if err1 == nil && err2 == nil {
+			if d.ascending {
+				return in < jn
+			}
+			return in > jn
+		}
+
 		if d.ascending {
-			return fmt.Sprint(ia) < fmt.Sprint(ja)
+			return is < js
 		}
 
-		return fmt.Sprint(ia) > fmt.Sprint(ja)
+		return is > js
 	}
 
 	return false

+ 21 - 2
graphql/interpreter/selectionset.go

@@ -221,8 +221,21 @@ func (rt *selectionSetRuntime) ProcessNodes(path []string, kind string,
 				if matchesOk {
 					if matchesMapOk {
 						for k, v := range matchesMap {
+
 							matchAttrs = append(matchAttrs, k)
 
+							if valueList, ok := v.([]interface{}); ok {
+								stringList := make([]string, 0, len(valueList))
+
+								// Shortcut for matching against multiple string values
+
+								for _, val := range valueList {
+									stringList = append(stringList, regexp.QuoteMeta(fmt.Sprint(val)))
+								}
+
+								v = fmt.Sprintf("^(%v)$", strings.Join(stringList, "|"))
+							}
+
 							if re, rerr := regexp.Compile(fmt.Sprint(v)); rerr == nil {
 								matchesRegexMap[k] = re
 							} else {
@@ -575,8 +588,14 @@ func (rt *selectionSetRuntime) GetPlainFieldsAndAliases(path []string, kind stri
 					}
 
 				} else {
-					rt.rtp.handleRuntimeError(fmt.Errorf(
-						"Traversal argument is missing"), path, c)
+
+					// Shortcut to take the name as the traversal kind
+
+					traversalMap[field.Alias()] = &traversal{
+						spec:                fmt.Sprintf(":::%v", field.Name()),
+						args:                args,
+						selectionSetRuntime: field.SelectionSetRuntime(),
+					}
 				}
 
 			} else if stringutil.IndexOf(field.Name(), resList) == -1 {

+ 138 - 49
graphql/interpreter/selectionset_test.go

@@ -154,6 +154,86 @@ func TestSortingAndLimiting(t *testing.T) {
 		"operationName": nil,
 		"query": `
 {
+  Song(ascending:"ranking", last: 3) {
+    key
+	name
+	ranking
+  }
+}
+`,
+		"variables": nil,
+	}
+
+	if rerr := checkResult(`
+{
+  "data": {
+    "Song": [
+      {
+        "key": "Aria1",
+        "name": "Aria1",
+        "ranking": 8
+      },
+      {
+        "key": "Aria4",
+        "name": "Aria4",
+        "ranking": 18
+      },
+      {
+        "key": "MyOnlySong3",
+        "name": "MyOnlySong3",
+        "ranking": 19
+      }
+    ]
+  }
+}`[1:], query, gm); rerr != nil {
+		t.Error(rerr)
+		return
+	}
+
+	query = map[string]interface{}{
+		"operationName": nil,
+		"query": `
+{
+  Song(descending:"ranking", last: 3) {
+    key
+	name
+	ranking
+  }
+}
+`,
+		"variables": nil,
+	}
+
+	if rerr := checkResult(`
+{
+  "data": {
+    "Song": [
+      {
+        "key": "FightSong4",
+        "name": "FightSong4",
+        "ranking": 3
+      },
+      {
+        "key": "Aria2",
+        "name": "Aria2",
+        "ranking": 2
+      },
+      {
+        "key": "LoveSong3",
+        "name": "LoveSong3",
+        "ranking": 1
+      }
+    ]
+  }
+}`[1:], query, gm); rerr != nil {
+		t.Error(rerr)
+		return
+	}
+
+	query = map[string]interface{}{
+		"operationName": nil,
+		"query": `
+{
   Song(ascending:"name", items: 2, last: 3) {
     key
 	name
@@ -848,55 +928,6 @@ func TestTraversals(t *testing.T) {
       }
     ]
   }
-}`[1:], query, gm); rerr != nil {
-		t.Error(rerr)
-		return
-	}
-
-	query = map[string]interface{}{
-		"operationName": nil,
-		"query": `
-{
-  Song(key : "StrangeSong1") {
-    song_key : key
-    foo : bar(traverse : ":::Author") {
-       kind() {}
-    }
-  }
-}
-`,
-		"variables": nil,
-	}
-
-	if rerr := checkResult(`
-{
-  "data": {
-    "Song": [
-      {
-        "foo": [
-          {
-            "kind": "Author"
-          }
-        ],
-        "song_key": "StrangeSong1"
-      }
-    ]
-  },
-  "errors": [
-    {
-      "locations": [
-        {
-          "column": 9,
-          "line": 6
-        }
-      ],
-      "message": "Traversal argument is missing",
-      "path": [
-        "Song",
-        ":::Author"
-      ]
-    }
-  ]
 }`[1:], query, gm); rerr != nil {
 		t.Error(rerr)
 		return
@@ -1292,3 +1323,61 @@ fragment SongKind on Song {
 		return
 	}
 }
+
+func TestShortcutListQueries(t *testing.T) {
+	gm, _ := songGraphGroups()
+
+	query := map[string]interface{}{
+		"operationName": nil,
+		"query": `
+{
+  Song(matches : {name : ["Aria1", Aria2, "Aria3" ]}) {
+	foo : key
+	name
+	bar: Author {
+	  name
+	}
+  }
+}
+`,
+		"variables": nil,
+	}
+
+	if rerr := checkResult(`
+{
+  "data": {
+    "Song": [
+      {
+        "bar": [
+          {
+            "name": "John"
+          }
+        ],
+        "foo": "Aria1",
+        "name": "Aria1"
+      },
+      {
+        "bar": [
+          {
+            "name": "John"
+          }
+        ],
+        "foo": "Aria2",
+        "name": "Aria2"
+      },
+      {
+        "bar": [
+          {
+            "name": "John"
+          }
+        ],
+        "foo": "Aria3",
+        "name": "Aria3"
+      }
+    ]
+  }
+}`[1:], query, gm); rerr != nil {
+		t.Error(rerr)
+		return
+	}
+}