spanner-graph-notebook 1.1.2__tar.gz → 1.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {spanner_graph_notebook-1.1.2/spanner_graph_notebook.egg-info → spanner_graph_notebook-1.1.3}/PKG-INFO +1 -1
  2. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/app.js +5 -0
  3. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/graph-server.js +4 -10
  4. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/graph-server.test.ts +17 -13
  5. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/setup.py +1 -1
  6. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3/spanner_graph_notebook.egg-info}/PKG-INFO +1 -1
  7. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/graph_server.py +115 -106
  8. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/magics.py +28 -17
  9. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/graph_server_test.py +53 -36
  10. spanner_graph_notebook-1.1.3/tests/node_expansion_test.py +165 -0
  11. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/server_test.py +35 -3
  12. spanner_graph_notebook-1.1.2/tests/node_expansion_test.py +0 -106
  13. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/CONTRIBUTING.md +0 -0
  14. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/LICENSE +0 -0
  15. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/MANIFEST.in +0 -0
  16. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/README.md +0 -0
  17. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/authentication.png +0 -0
  18. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/colab_usage.png +0 -0
  19. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/full_viz.png +0 -0
  20. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/hero.png +0 -0
  21. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/hero_with_properties.png +0 -0
  22. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/jupyter-spin-up.png +0 -0
  23. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/jupyter.gif +0 -0
  24. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/load_ext.png +0 -0
  25. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/mock_data_result.png +0 -0
  26. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/new_notebook.png +0 -0
  27. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/notebook_package_load.png +0 -0
  28. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/query_graph.png +0 -0
  29. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/assets/sample_jupyter.png +0 -0
  30. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/babel.config.js +0 -0
  31. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/jest-puppeteer.config.js +0 -0
  32. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/jest.config.ts +0 -0
  33. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/package.json +0 -0
  34. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/models/edge.js +0 -0
  35. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/models/graph-object.js +0 -0
  36. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/models/node.js +0 -0
  37. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/models/schema.js +0 -0
  38. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/spanner-config.js +0 -0
  39. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/spanner-store.js +0 -0
  40. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/visualization/spanner-forcegraph.js +0 -0
  41. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/visualization/spanner-menu.js +0 -0
  42. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/visualization/spanner-sidebar.js +0 -0
  43. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/src/visualization/spanner-table.js +0 -0
  44. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/static/dev.html +0 -0
  45. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/static/graph-bg.svg +0 -0
  46. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/static/index.html +0 -0
  47. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/static/test.html +0 -0
  48. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/jest.setup.ts +0 -0
  49. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/mock-data.json +0 -0
  50. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/serve-content.js +0 -0
  51. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/models/edge.test.ts +0 -0
  52. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/models/graph-object.test.ts +0 -0
  53. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/models/node.test.ts +0 -0
  54. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/models/schema.test.ts +0 -0
  55. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/spanner-config.test.ts +0 -0
  56. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/spanner-store.test.ts +0 -0
  57. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/visualization/spanner-menu.test.ts +0 -0
  58. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/visualization/spanner-sidebar.test.ts +0 -0
  59. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/unit/visualization/spanner-table.test.ts +0 -0
  60. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/visual/app.test.ts +0 -0
  61. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/visual/jest-visual.setup.ts +0 -0
  62. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/visual/visualization/spanner-forcegraph.test.ts +0 -0
  63. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/visual/visualization/spanner-menu.test.ts +0 -0
  64. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/visual/visualization/spanner-sidebar.test.ts +0 -0
  65. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tests/visual/visualization/spanner-table.test.ts +0 -0
  66. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/frontend/tsconfig.json +0 -0
  67. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/sample.ipynb +0 -0
  68. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/setup.cfg +0 -0
  69. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graph_notebook.egg-info/SOURCES.txt +0 -0
  70. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graph_notebook.egg-info/dependency_links.txt +0 -0
  71. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graph_notebook.egg-info/requires.txt +0 -0
  72. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graph_notebook.egg-info/top_level.txt +0 -0
  73. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/__init__.py +0 -0
  74. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/conversion.py +0 -0
  75. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/database.py +0 -0
  76. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/dev_util/__init__.py +0 -0
  77. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/dev_util/serve_dev.py +0 -0
  78. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/graph_entities.py +0 -0
  79. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/graph_mock_data.csv +0 -0
  80. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/graph_mock_schema.json +0 -0
  81. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/graph_visualization.py +0 -0
  82. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/spanner_graphs/schema_manager.py +0 -0
  83. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/__init__.py +0 -0
  84. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/conversion_test.py +0 -0
  85. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/database_test.py +0 -0
  86. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/graph_entities_test.py +0 -0
  87. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/magics_test.py +0 -0
  88. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/sample_notebook_test.py +0 -0
  89. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/schema_manager_test.py +0 -0
  90. {spanner_graph_notebook-1.1.2 → spanner_graph_notebook-1.1.3}/tests/test_notebook.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: spanner-graph-notebook
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Visually query Spanner Graph data in notebooks.
5
5
  Description-Content-Type: text/markdown
6
6
  License-File: LICENSE
@@ -164,6 +164,11 @@ class SpannerApp {
164
164
  return;
165
165
  }
166
166
 
167
+ if (data.error) {
168
+ this.graph.showErrorStateForNode(node, error);
169
+ return;
170
+ }
171
+
167
172
  const newData = this.store.appendGraphData(data.response.nodes, data.response.edges)
168
173
  if (newData) {
169
174
  // Show success message before appending data
@@ -109,10 +109,7 @@ class GraphServer {
109
109
  }
110
110
  }
111
111
 
112
- const {project, instance, database, graph} = JSON.parse(this.params);
113
-
114
112
  const request = {
115
- project, instance, database, graph,
116
113
  uid: node.uid,
117
114
  node_labels: node.labels,
118
115
  node_properties: validProperties,
@@ -131,15 +128,12 @@ class GraphServer {
131
128
  .finally(() => this.isFetching = false);
132
129
  }
133
130
 
134
- // For non-Colab environment, combine params and request
135
- const fullRequest = {
136
- ...JSON.parse(this.params),
137
- ...request
138
- };
139
-
140
131
  return fetch(this.buildRoute(this.endpoints.postNodeExpansion), {
141
132
  method: 'POST',
142
- body: JSON.stringify(fullRequest)
133
+ body: JSON.stringify({
134
+ params: this.params,
135
+ request
136
+ })
143
137
  })
144
138
  .then(response => {
145
139
  if (!response.ok) {
@@ -148,19 +148,23 @@ describe('GraphServer', () => {
148
148
  {
149
149
  method: 'POST',
150
150
  body: JSON.stringify({
151
- project: 'test-project',
152
- instance: 'test-instance',
153
- database: 'test-database',
154
- graph: 'test-graph',
155
- mock: false,
156
- uid: 'node1',
157
- node_labels: ['TestLabel1'],
158
- node_properties: [{
159
- key: 'name',
160
- value: 'my-node-name',
161
- type: 'STRING'
162
- }],
163
- direction: 'OUTGOING'
151
+ params: JSON.stringify({
152
+ project: 'test-project',
153
+ instance: 'test-instance',
154
+ database: 'test-database',
155
+ graph: 'test-graph',
156
+ mock: false
157
+ }),
158
+ request: {
159
+ uid: 'node1',
160
+ node_labels: ['TestLabel1'],
161
+ node_properties: [{
162
+ key: 'name',
163
+ value: 'my-node-name',
164
+ type: 'STRING'
165
+ }],
166
+ direction: 'OUTGOING'
167
+ }
164
168
  })
165
169
  }
166
170
  );
@@ -32,7 +32,7 @@ with open("README.md", "r") as fh:
32
32
 
33
33
  setup(
34
34
  name="spanner-graph-notebook",
35
- version="v1.1.2",
35
+ version="v1.1.3",
36
36
  packages=find_packages(),
37
37
  install_requires=[
38
38
  "networkx", "numpy", "google-cloud-spanner", "ipython",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: spanner-graph-notebook
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Visually query Spanner Graph data in notebooks.
5
5
  Description-Content-Type: text/markdown
6
6
  License-File: LICENSE
@@ -42,6 +42,17 @@ PROPERTY_TYPE_MAP = {
42
42
  'TIMESTAMP': TypeCode.TIMESTAMP
43
43
  }
44
44
 
45
+ class NodePropertyForDataExploration:
46
+ def __init__(self, key: str, value: Union[str, int, float, bool], type: TypeCode):
47
+ self.key = key
48
+ self.value = value
49
+ self.type = type
50
+
51
+
52
+ class EdgeDirection(Enum):
53
+ INCOMING = "INCOMING"
54
+ OUTGOING = "OUTGOING"
55
+
45
56
  def validate_property_type(property_type: str) -> TypeCode:
46
57
  """
47
58
  Validates and converts a property type string to a Spanner TypeCode.
@@ -68,26 +79,95 @@ def validate_property_type(property_type: str) -> TypeCode:
68
79
 
69
80
  return PROPERTY_TYPE_MAP[property_type]
70
81
 
71
- class EdgeDirection(Enum):
72
- INCOMING = "INCOMING"
73
- OUTGOING = "OUTGOING"
82
+ def validate_node_expansion_request(data) -> (list[NodePropertyForDataExploration], EdgeDirection):
83
+ required_fields = ["project", "instance", "database", "graph", "uid", "node_labels", "direction"]
84
+ missing_fields = [field for field in required_fields if data.get(field) is None]
74
85
 
75
- class NodeProperty:
76
- def __init__(self, key: str, value: Union[str, int, float, bool], type: TypeCode):
77
- self.key = key
78
- self.value = value
79
- self.type = type
86
+ if missing_fields:
87
+ raise ValueError(f"Missing required fields: {', '.join(missing_fields)}")
88
+
89
+ node_labels = data.get("node_labels")
90
+ node_properties = data.get("node_properties", [])
91
+
92
+ # Validate node_labels
93
+ if not isinstance(node_labels, list):
94
+ raise ValueError("node_labels must be an array")
95
+
96
+ for label in node_labels:
97
+ if not isinstance(label, str):
98
+ raise ValueError("Each node label must be a string")
99
+
100
+ # Validate node_properties
101
+ if not isinstance(node_properties, list):
102
+ raise ValueError("node_properties must be an array")
103
+
104
+ validated_properties: list[NodePropertyForDataExploration] = []
105
+ for idx, prop in enumerate(node_properties):
106
+ if not isinstance(prop, dict):
107
+ raise ValueError(f"Property at index {idx} must be an object")
108
+
109
+ if not all(field in prop for field in ["key", "value", "type"]):
110
+ raise ValueError(f"Property at index {idx} is missing required fields (key, value, type)")
111
+
112
+ try:
113
+ prop_type_str = prop["type"]
114
+ if isinstance(prop_type_str, str):
115
+ prop_type = validate_property_type(prop_type_str)
116
+
117
+ value = prop["value"]
118
+ if prop_type in (TypeCode.INT64, TypeCode.NUMERIC):
119
+ if not (isinstance(value, int) or (isinstance(value, str) and value.isdigit())):
120
+ raise ValueError(f"Property '{prop['key']}' value must be a number for type {prop_type_str}")
121
+ elif prop_type in (TypeCode.FLOAT32, TypeCode.FLOAT64):
122
+ try:
123
+ float(value)
124
+ except (ValueError, TypeError):
125
+ raise ValueError(
126
+ f"Property '{prop['key']}' value must be a valid float for type {prop_type_str}")
127
+ elif prop_type == TypeCode.BOOL:
128
+ if not isinstance(value, bool) and not (isinstance(value, str) and value.lower() in ["true", "false"]):
129
+ raise ValueError(f"Property '{prop['key']}' value must be a boolean for type {prop_type_str}")
130
+
131
+ validated_properties.append(NodePropertyForDataExploration(
132
+ key=prop["key"],
133
+ value=prop["value"],
134
+ type=prop_type
135
+ ))
136
+ else:
137
+ raise ValueError(f"Property type at index {idx} must be a string")
138
+ except ValueError as e:
139
+ raise ValueError(f"Invalid property type in property at index {idx}: {str(e)}")
140
+
141
+ try:
142
+ direction = EdgeDirection(data.get("direction"))
143
+ except ValueError:
144
+ raise ValueError(f"Invalid direction: must be INCOMING or OUTGOING, got \"{data.get('direction')}\"")
145
+
146
+ return validated_properties, direction
80
147
 
81
148
  def execute_node_expansion(
82
- project: str,
83
- instance: str,
84
- database: str,
85
- node_labels: list[str],
86
- node_properties: list[NodeProperty],
87
- graph: str,
88
- uid: str,
89
- direction: EdgeDirection,
90
- edge_label: str = None):
149
+ params_str: str,
150
+ request: dict) -> dict:
151
+ """Execute a node expansion query to find connected nodes and edges.
152
+
153
+ Args:
154
+ params_str: A JSON string containing connection parameters (project, instance, database, graph, mock).
155
+ request: A dictionary containing node expansion request details (uid, node_labels, node_properties, direction, edge_label).
156
+
157
+ Returns:
158
+ dict: A dictionary containing the query response with nodes and edges.
159
+ """
160
+
161
+ params = json.loads(params_str)
162
+ node_properties, direction = validate_node_expansion_request(params | request)
163
+
164
+ project = params.get("project")
165
+ instance = params.get("instance")
166
+ database = params.get("database")
167
+ graph = params.get("graph")
168
+ uid = request.get("uid")
169
+ node_labels = request.get("node_labels")
170
+ edge_label = request.get("edge_label")
91
171
 
92
172
  edge = "e" if not edge_label else f"e:{edge_label}"
93
173
 
@@ -232,6 +312,8 @@ class GraphServerHandler(http.server.SimpleHTTPRequestHandler):
232
312
  self.do_json_response(data)
233
313
 
234
314
  def do_error_response(self, message):
315
+ if isinstance(message, Exception):
316
+ message = str(message)
235
317
  self.do_json_response({'error': message})
236
318
 
237
319
  def parse_post_data(self):
@@ -259,99 +341,26 @@ class GraphServerHandler(http.server.SimpleHTTPRequestHandler):
259
341
  self.do_data_response(response)
260
342
 
261
343
  def handle_post_node_expansion(self):
262
- data = self.parse_post_data()
263
- required_fields = ["project", "instance", "database", "graph", "uid", "node_labels", "direction"]
264
- missing_fields = [field for field in required_fields if data.get(field) is None]
265
-
266
- if missing_fields:
267
- self.do_error_response(f"Missing required fields: {', '.join(missing_fields)}")
268
- return
269
-
270
- project = data.get("project")
271
- instance = data.get("instance")
272
- database = data.get("database")
273
- graph = data.get("graph")
274
- uid = data.get("uid")
275
- node_labels = data.get("node_labels")
276
- node_properties = data.get("node_properties", [])
277
- edge_label = data.get("edge_label")
278
-
279
- # Validate node_labels
280
- if not isinstance(node_labels, list):
281
- self.do_error_response("node_labels must be an array")
282
- return
283
-
284
- for label in node_labels:
285
- if not isinstance(label, str):
286
- self.do_error_response("Each node label must be a string")
287
- return
288
-
289
- # Validate node_properties
290
- if not isinstance(node_properties, list):
291
- self.do_error_response("node_properties must be an array")
292
- return
293
-
294
- validated_properties: list[NodeProperty] = []
295
- for idx, prop in enumerate(node_properties):
296
- if not isinstance(prop, dict):
297
- self.do_error_response(f"Property at index {idx} must be an object")
298
- return
299
-
300
- if not all(field in prop for field in ["key", "value", "type"]):
301
- self.do_error_response(f"Property at index {idx} is missing required fields (key, value, type)")
302
- return
303
-
304
- try:
305
- prop_type_str = prop["type"]
306
- if isinstance(prop_type_str, str):
307
- prop_type = validate_property_type(prop_type_str)
308
-
309
- value = prop["value"]
310
- if prop_type in (TypeCode.INT64, TypeCode.NUMERIC):
311
- if not (isinstance(value, int) or (isinstance(value, str) and value.isdigit())):
312
- self.do_error_response(f"Property '{prop['key']}' value must be a number for type {prop_type_str}")
313
- return
314
- elif prop_type in (TypeCode.FLOAT32, TypeCode.FLOAT64):
315
- try:
316
- float(value)
317
- except (ValueError, TypeError):
318
- self.do_error_response(f"Property '{prop['key']}' value must be a valid float for type {prop_type_str}")
319
- return
320
- elif prop_type == TypeCode.BOOL:
321
- if not isinstance(value, bool) and not (isinstance(value, str) and value.lower() in ["true", "false"]):
322
- self.do_error_response(f"Property '{prop['key']}' value must be a boolean for type {prop_type_str}")
323
- return
324
-
325
- validated_properties.append(NodeProperty(
326
- key=prop["key"],
327
- value=prop["value"],
328
- type=prop_type
329
- ))
330
- else:
331
- self.do_error_response(f"Property type at index {idx} must be a string")
332
- return
333
- except ValueError as e:
334
- self.do_error_response(f"Invalid property type in property at index {idx}: {str(e)}")
335
- return
344
+ """Handle POST requests for node expansion.
336
345
 
346
+ Expects a JSON payload with:
347
+ - params: A JSON string containing connection parameters (project, instance, database, graph)
348
+ - request: A dictionary with node details (uid, node_labels, node_properties, direction, edge_label)
349
+ """
337
350
  try:
338
- direction = EdgeDirection(data.get("direction"))
339
- except ValueError:
340
- self.do_error_response(f"Invalid direction: must be INCOMING or OUTGOING, got \"{data.get('direction')}\"")
351
+ data = self.parse_post_data()
352
+
353
+ # Execute node expansion with:
354
+ # - params_str: JSON string with connection parameters (project, instance, database, graph)
355
+ # - request: Dict with node details (uid, node_labels, node_properties, direction, edge_label)
356
+ self.do_data_response(execute_node_expansion(
357
+ params_str=data.get("params"),
358
+ request=data.get("request")
359
+ ))
360
+ except BaseException as e:
361
+ self.do_error_response(e)
341
362
  return
342
363
 
343
- self.do_data_response(execute_node_expansion(
344
- project=project,
345
- instance=instance,
346
- database=database,
347
- node_labels=node_labels,
348
- node_properties=validated_properties,
349
- graph=graph,
350
- uid=uid,
351
- direction=direction,
352
- edge_label=edge_label,
353
- ))
354
-
355
364
  def do_GET(self):
356
365
  if self.path == GraphServer.endpoints["get_ping"]:
357
366
  self.handle_get_ping()
@@ -35,8 +35,8 @@ from jinja2 import Template
35
35
 
36
36
  from spanner_graphs.database import get_database_instance
37
37
  from spanner_graphs.graph_server import (
38
- GraphServer, execute_query, execute_node_expansion,
39
- EdgeDirection, validate_property_type
38
+ GraphServer, execute_query, execute_node_expansion,
39
+ validate_node_expansion_request
40
40
  )
41
41
  from spanner_graphs.graph_visualization import generate_visualization_html
42
42
 
@@ -92,21 +92,32 @@ def receive_query_request(query: str, params: str):
92
92
  query=query,
93
93
  mock=params_dict["mock"]))
94
94
 
95
- def receive_node_expansion_request(request: dict, params: str):
96
- """Handle node expansion requests in Google Colab environment"""
97
- params_dict = json.loads(params)
98
- return JSON(execute_node_expansion(
99
- project=params_dict["project"],
100
- instance=params_dict["instance"],
101
- database=params_dict["database"],
102
- graph=params_dict["graph"],
103
- uid=request["uid"],
104
- node_key_property_name=request["node_key_property_name"],
105
- node_key_property_value=request["node_key_property_value"],
106
- direction=EdgeDirection(request["direction"]),
107
- edge_label=request.get("edge_label"),
108
- property_type=validate_property_type(request["node_key_property_type"])
109
- ))
95
+ def receive_node_expansion_request(request: dict, params_str: str):
96
+ """Handle node expansion requests in Google Colab environment
97
+
98
+ Args:
99
+ request: A dictionary containing node expansion details including:
100
+ - uid: str - Unique identifier of the node to expand
101
+ - node_labels: List[str] - Labels of the node
102
+ - node_properties: List[Dict] - Properties of the node with key, value, and type
103
+ - direction: str - Direction of expansion ("INCOMING" or "OUTGOING")
104
+ - edge_label: Optional[str] - Label of edges to filter by
105
+ params_str: A JSON string containing connection parameters:
106
+ - project: str - GCP project ID
107
+ - instance: str - Spanner instance ID
108
+ - database: str - Spanner database ID
109
+ - graph: str - Graph name
110
+ - mock: bool - Whether to use mock data
111
+
112
+ Returns:
113
+ JSON: A JSON-serialized response containing either:
114
+ - The query results with nodes and edges
115
+ - An error message if the request failed
116
+ """
117
+ try:
118
+ return JSON(execute_node_expansion(params_str, request))
119
+ except BaseException as e:
120
+ return JSON({"error": e})
110
121
 
111
122
  @magics_class
112
123
  class NetworkVisualizationMagics(Magics):
@@ -1,12 +1,11 @@
1
1
  import unittest
2
2
  from unittest.mock import patch, MagicMock
3
3
  from google.cloud.spanner_v1 import TypeCode
4
+ import json
4
5
 
5
6
  from spanner_graphs.graph_server import (
6
7
  validate_property_type,
7
8
  execute_node_expansion,
8
- EdgeDirection,
9
- PROPERTY_TYPE_MAP
10
9
  )
11
10
 
12
11
  class TestPropertyTypeHandling(unittest.TestCase):
@@ -64,36 +63,42 @@ class TestPropertyTypeHandling(unittest.TestCase):
64
63
 
65
64
  test_cases = [
66
65
  # Numeric types (unquoted)
67
- (TypeCode.INT64, "123", "123"),
68
- (TypeCode.NUMERIC, "123.45", "123.45"),
69
- (TypeCode.FLOAT32, "123.45", "123.45"),
70
- (TypeCode.FLOAT64, "123.45", "123.45"),
66
+ ("INT64", "123", "123"),
67
+ ("NUMERIC", "123", "123"),
68
+ ("FLOAT32", "123.45", "123.45"),
69
+ ("FLOAT64", "123.45", "123.45"),
71
70
  # Boolean (unquoted)
72
- (TypeCode.BOOL, "true", "true"),
71
+ ("BOOL", "true", "true"),
73
72
  # String types (quoted)
74
- (TypeCode.STRING, "hello", '"hello"'),
75
- (TypeCode.DATE, "2024-03-14", '"2024-03-14"'),
76
- (TypeCode.TIMESTAMP, "2024-03-14T12:00:00Z", '"2024-03-14T12:00:00Z"'),
77
- (TypeCode.BYTES, "base64data", '"base64data"'),
78
- (TypeCode.ENUM, "ENUM_VALUE", '"ENUM_VALUE"'),
73
+ ("STRING", "hello", '"hello"'),
74
+ ("DATE", "2024-03-14", '"2024-03-14"'),
75
+ ("TIMESTAMP", "2024-03-14T12:00:00Z", '"2024-03-14T12:00:00Z"'),
76
+ ("BYTES", "base64data", '"base64data"'),
77
+ ("ENUM", "ENUM_VALUE", '"ENUM_VALUE"'),
79
78
  ]
80
79
 
81
- base_args = {
80
+ params = json.dumps({
82
81
  "project": "test-project",
83
82
  "instance": "test-instance",
84
83
  "database": "test-database",
85
84
  "graph": "test-graph",
86
- "uid": "test-uid",
87
- "node_key_property_name": "test_property",
88
- "direction": EdgeDirection.OUTGOING,
89
- }
85
+ })
90
86
 
91
- for type_code, value, expected_format in test_cases:
92
- with self.subTest(type=type_code, value=value):
87
+ for type_str, value, expected_format in test_cases:
88
+ with self.subTest(type=type_str, value=value):
89
+ # Create a property dictionary
90
+ prop_dict = {"key": "test_property", "value": value, "type": type_str}
91
+
92
+ request = {
93
+ "uid": "test-uid",
94
+ "node_labels": ["Person"],
95
+ "node_properties": [prop_dict],
96
+ "direction": "OUTGOING"
97
+ }
98
+
93
99
  execute_node_expansion(
94
- **base_args,
95
- node_key_property_value=value,
96
- property_type=type_code
100
+ params_str=params,
101
+ request=request
97
102
  )
98
103
 
99
104
  # Extract the actual formatted value from the query
@@ -102,32 +107,44 @@ class TestPropertyTypeHandling(unittest.TestCase):
102
107
 
103
108
  # Find the WHERE clause in the query and extract the value
104
109
  where_line = [line for line in query.split('\n') if 'WHERE' in line][0]
105
- self.assertIn(f"= {expected_format}", where_line,
106
- f"Expected value {value} to be formatted as {expected_format} for type {type_code}")
107
-
110
+ expected_pattern = f"n.test_property={expected_format}"
111
+ self.assertIn(expected_pattern, where_line,
112
+ f"Expected property value pattern {expected_pattern} not found in WHERE clause for type {type_str}")
113
+
108
114
  @patch('spanner_graphs.graph_server.execute_query')
109
115
  def test_property_value_formatting_no_type(self, mock_execute_query):
110
116
  """Test that property values are quoted when no type is provided."""
111
117
  mock_execute_query.return_value = {"response": {"nodes": [], "edges": []}}
112
118
 
119
+ # Create a property dictionary with string type (since null type is not allowed)
120
+ prop_dict = {"key": "test_property", "value": "test_value", "type": "STRING"}
121
+
122
+ params = json.dumps({
123
+ "project": "test-project",
124
+ "instance": "test-instance",
125
+ "database": "test-database",
126
+ "graph": "test-graph",
127
+ })
128
+
129
+ request = {
130
+ "uid": "test-uid",
131
+ "node_labels": ["Person"],
132
+ "node_properties": [prop_dict],
133
+ "direction": "OUTGOING"
134
+ }
135
+
113
136
  execute_node_expansion(
114
- project="test-project",
115
- instance="test-instance",
116
- database="test-database",
117
- graph="test-graph",
118
- uid="test-uid",
119
- node_key_property_name="test_property",
120
- node_key_property_value="test_value",
121
- direction=EdgeDirection.OUTGOING,
122
- property_type=None
137
+ params_str=params,
138
+ request=request
123
139
  )
124
140
 
125
141
  # Extract the actual formatted value from the query
126
142
  last_call = mock_execute_query.call_args[0]
127
143
  query = last_call[3]
128
144
  where_line = [line for line in query.split('\n') if 'WHERE' in line][0]
129
- self.assertIn('= "test_value"', where_line,
130
- "Value should be quoted when no type is provided")
145
+ expected_pattern = 'n.test_property="test_value"'
146
+ self.assertIn(expected_pattern, where_line,
147
+ "Property value should be quoted when string type is provided")
131
148
 
132
149
  if __name__ == '__main__':
133
150
  unittest.main()
@@ -0,0 +1,165 @@
1
+ import unittest
2
+ from unittest.mock import patch, MagicMock
3
+ import json
4
+
5
+ from spanner_graphs.magics import receive_node_expansion_request
6
+ from spanner_graphs.graph_server import EdgeDirection, TypeCode
7
+
8
+ class TestNodeExpansion(unittest.TestCase):
9
+ def setUp(self):
10
+ self.sample_request = {
11
+ "uid": "node-123",
12
+ "node_key_property_name": "id",
13
+ "node_key_property_value": "123",
14
+ "node_key_property_type": "INT64",
15
+ "direction": "OUTGOING",
16
+ "edge_label": "CONNECTS_TO"
17
+ }
18
+ self.sample_params = json.dumps({
19
+ "project": "test-project",
20
+ "instance": "test-instance",
21
+ "database": "test-database",
22
+ "graph": "test_graph",
23
+ "mock": False
24
+ })
25
+
26
+ @patch('spanner_graphs.magics.validate_node_expansion_request')
27
+ @patch('spanner_graphs.magics.execute_node_expansion')
28
+ def test_receive_node_expansion_request(self, mock_execute, mock_validate):
29
+ """Test that the receive_node_expansion_request function correctly processes requests."""
30
+ # Setup mock return values
31
+ mock_validate.return_value = ([], EdgeDirection.OUTGOING)
32
+ mock_execute.return_value = {
33
+ "response": {
34
+ "nodes": [],
35
+ "edges": []
36
+ }
37
+ }
38
+
39
+ # Create request and params objects
40
+ request = {
41
+ "uid": "node-123",
42
+ "node_labels": ["Person"],
43
+ "node_properties": [
44
+ {"key": "id", "value": "123", "type": "INT64"}
45
+ ],
46
+ "direction": "OUTGOING",
47
+ "edge_label": "CONNECTS_TO"
48
+ }
49
+
50
+ params = json.dumps({
51
+ "project": "test-project",
52
+ "instance": "test-instance",
53
+ "database": "test-database",
54
+ "graph": "test_graph",
55
+ "mock": False
56
+ })
57
+
58
+ # Call the function
59
+ result = receive_node_expansion_request(request, params)
60
+
61
+ # Verify execute_node_expansion was called with correct parameters
62
+ mock_execute.assert_called_once_with(params, request)
63
+
64
+ # Verify the result is wrapped in JSON
65
+ self.assertEqual(result.data, mock_execute.return_value)
66
+
67
+ @patch('spanner_graphs.magics.validate_node_expansion_request')
68
+ @patch('spanner_graphs.magics.execute_node_expansion')
69
+ def test_receive_node_expansion_request_without_edge_label(self, mock_execute, mock_validate):
70
+ """Test that the receive_node_expansion_request function correctly handles missing edge_label."""
71
+ # Setup mock return values
72
+ mock_validate.return_value = ([], EdgeDirection.OUTGOING)
73
+ mock_execute.return_value = {
74
+ "response": {
75
+ "nodes": [],
76
+ "edges": []
77
+ }
78
+ }
79
+
80
+ # Create request without edge_label and params objects
81
+ request = {
82
+ "uid": "node-123",
83
+ "node_labels": ["Person"],
84
+ "node_properties": [
85
+ {"key": "id", "value": "123", "type": "INT64"}
86
+ ],
87
+ "direction": "OUTGOING"
88
+ # No edge_label
89
+ }
90
+
91
+ params = json.dumps({
92
+ "project": "test-project",
93
+ "instance": "test-instance",
94
+ "database": "test-database",
95
+ "graph": "test_graph",
96
+ "mock": False
97
+ })
98
+
99
+ # Call the function
100
+ result = receive_node_expansion_request(request, params)
101
+
102
+ # Verify execute_node_expansion was called with correct parameters
103
+ mock_execute.assert_called_once_with(params, request)
104
+
105
+ # Verify the result is wrapped in JSON
106
+ self.assertEqual(result.data, mock_execute.return_value)
107
+
108
+ @patch('spanner_graphs.magics.validate_node_expansion_request')
109
+ def test_invalid_property_type(self, mock_validate):
110
+ """Test that invalid property types are correctly caught."""
111
+ # Set up the mock to raise a ValueError when called with invalid data
112
+ mock_validate.side_effect = ValueError("Invalid property type")
113
+
114
+ # Create a request with invalid property type
115
+ request = {
116
+ "uid": "node-123",
117
+ "node_labels": ["Person"],
118
+ "node_properties": [
119
+ {"key": "id", "value": "123", "type": "INVALID_TYPE"}
120
+ ],
121
+ "direction": "OUTGOING"
122
+ }
123
+
124
+ params = json.dumps({
125
+ "project": "test-project",
126
+ "instance": "test-instance",
127
+ "database": "test-database",
128
+ "graph": "test_graph",
129
+ "mock": False
130
+ })
131
+
132
+ # Call the function and verify it returns an error response
133
+ result = receive_node_expansion_request(request, params)
134
+ self.assertIn("error", result.data)
135
+
136
+ @patch('spanner_graphs.magics.validate_node_expansion_request')
137
+ def test_invalid_direction(self, mock_validate):
138
+ """Test that invalid directions are correctly caught."""
139
+ # Set up the mock to raise a ValueError when called with invalid data
140
+ mock_validate.side_effect = ValueError("Invalid direction")
141
+
142
+ # Create a request with invalid direction
143
+ request = {
144
+ "uid": "node-123",
145
+ "node_labels": ["Person"],
146
+ "node_properties": [
147
+ {"key": "id", "value": "123", "type": "INT64"}
148
+ ],
149
+ "direction": "INVALID_DIRECTION"
150
+ }
151
+
152
+ params = json.dumps({
153
+ "project": "test-project",
154
+ "instance": "test-instance",
155
+ "database": "test-database",
156
+ "graph": "test_graph",
157
+ "mock": False
158
+ })
159
+
160
+ # Call the function and verify it returns an error response
161
+ result = receive_node_expansion_request(request, params)
162
+ self.assertIn("error", result.data)
163
+
164
+ if __name__ == '__main__':
165
+ unittest.main()
@@ -14,6 +14,7 @@
14
14
 
15
15
  import unittest
16
16
  import requests
17
+ import json
17
18
  from spanner_graphs.graph_server import GraphServer
18
19
 
19
20
  class TestSpannerServer(unittest.TestCase):
@@ -39,13 +40,17 @@ class TestSpannerServer(unittest.TestCase):
39
40
  # Build the request URL
40
41
  route = GraphServer.build_route(GraphServer.endpoints["post_query"])
41
42
 
42
- # Create request data
43
- request_data = {
43
+ # Create request data with the new structure
44
+ params = json.dumps({
44
45
  "project": "test-project",
45
46
  "instance": "test-instance",
46
47
  "database": "test-database",
47
- "query": "GRAPH TestGraph MATCH (n) RETURN n",
48
48
  "mock": True
49
+ })
50
+
51
+ request_data = {
52
+ "params": params,
53
+ "query": "GRAPH TestGraph MATCH (n) RETURN n"
49
54
  }
50
55
 
51
56
  # Send POST request
@@ -82,5 +87,32 @@ class TestSpannerServer(unittest.TestCase):
82
87
  self.assertIn("source_node_identifier", edge)
83
88
  self.assertIn("destination_node_identifier", edge)
84
89
 
90
+ def test_node_expansion_error_handling(self):
91
+ """Test that errors in node expansion are properly handled and returned."""
92
+ # Build the request URL
93
+ route = GraphServer.build_route(GraphServer.endpoints["post_node_expansion"])
94
+
95
+ # Create request data with invalid fields to trigger validation error
96
+ request_data = {
97
+ "project": "test-project",
98
+ "instance": "test-instance",
99
+ "database": "test-database",
100
+ "graph": "test-graph",
101
+ "uid": "test-uid",
102
+ # Missing required node_labels field
103
+ "direction": "INVALID_DIRECTION" # Invalid direction
104
+ }
105
+
106
+ # Send POST request
107
+ response = requests.post(route, json=request_data)
108
+
109
+ # Verify response
110
+ self.assertEqual(response.status_code, 200) # Server still returns 200 but with error data
111
+ response_data = response.json()
112
+
113
+ # Check error presence
114
+ self.assertIn("error", response_data)
115
+ self.assertIsNotNone(response_data["error"])
116
+
85
117
  if __name__ == '__main__':
86
118
  unittest.main()
@@ -1,106 +0,0 @@
1
- import unittest
2
- from unittest.mock import patch, MagicMock
3
- import json
4
-
5
- from spanner_graphs.magics import receive_node_expansion_request
6
- from spanner_graphs.graph_server import EdgeDirection, TypeCode
7
-
8
- class TestNodeExpansion(unittest.TestCase):
9
- def setUp(self):
10
- self.sample_request = {
11
- "uid": "node-123",
12
- "node_key_property_name": "id",
13
- "node_key_property_value": "123",
14
- "node_key_property_type": "INT64",
15
- "direction": "OUTGOING",
16
- "edge_label": "CONNECTS_TO"
17
- }
18
- self.sample_params = json.dumps({
19
- "project": "test-project",
20
- "instance": "test-instance",
21
- "database": "test-database",
22
- "graph": "test_graph",
23
- "mock": False
24
- })
25
-
26
- @patch('spanner_graphs.magics.execute_node_expansion')
27
- def test_receive_node_expansion_request(self, mock_execute):
28
- # Setup mock return value
29
- mock_execute.return_value = {
30
- "response": {
31
- "nodes": [],
32
- "edges": []
33
- }
34
- }
35
-
36
- # Call the function
37
- result = receive_node_expansion_request(self.sample_request, self.sample_params)
38
-
39
- # Verify execute_node_expansion was called with correct parameters
40
- mock_execute.assert_called_once_with(
41
- project="test-project",
42
- instance="test-instance",
43
- database="test-database",
44
- graph="test_graph",
45
- uid="node-123",
46
- node_key_property_name="id",
47
- node_key_property_value="123",
48
- direction=EdgeDirection.OUTGOING,
49
- edge_label="CONNECTS_TO",
50
- property_type=TypeCode.INT64
51
- )
52
-
53
- # Verify the result is wrapped in JSON
54
- self.assertEqual(result.data, mock_execute.return_value)
55
-
56
- @patch('spanner_graphs.magics.execute_node_expansion')
57
- def test_receive_node_expansion_request_without_edge_label(self, mock_execute):
58
- # Remove edge_label from request
59
- request = dict(self.sample_request)
60
- del request["edge_label"]
61
-
62
- # Setup mock return value
63
- mock_execute.return_value = {
64
- "response": {
65
- "nodes": [],
66
- "edges": []
67
- }
68
- }
69
-
70
- # Call the function
71
- result = receive_node_expansion_request(request, self.sample_params)
72
-
73
- # Verify execute_node_expansion was called with correct parameters
74
- mock_execute.assert_called_once_with(
75
- project="test-project",
76
- instance="test-instance",
77
- database="test-database",
78
- graph="test_graph",
79
- uid="node-123",
80
- node_key_property_name="id",
81
- node_key_property_value="123",
82
- direction=EdgeDirection.OUTGOING,
83
- edge_label=None,
84
- property_type=TypeCode.INT64
85
- )
86
-
87
- def test_invalid_property_type(self):
88
- # Modify request to have invalid property type
89
- request = dict(self.sample_request)
90
- request["node_key_property_type"] = "INVALID_TYPE"
91
-
92
- # Verify it raises ValueError
93
- with self.assertRaises(ValueError):
94
- receive_node_expansion_request(request, self.sample_params)
95
-
96
- def test_invalid_direction(self):
97
- # Modify request to have invalid direction
98
- request = dict(self.sample_request)
99
- request["direction"] = "INVALID_DIRECTION"
100
-
101
- # Verify it raises ValueError
102
- with self.assertRaises(ValueError):
103
- receive_node_expansion_request(request, self.sample_params)
104
-
105
- if __name__ == '__main__':
106
- unittest.main()