diff --git a/gremlin-dart/.gitignore b/gremlin-dart/.gitignore new file mode 100644 index 00000000000..d41edb61dc4 --- /dev/null +++ b/gremlin-dart/.gitignore @@ -0,0 +1,4 @@ +.dart_tool/ +build/ +.packages +doc/api/ diff --git a/gremlin-dart/docker-compose.yml b/gremlin-dart/docker-compose.yml new file mode 100644 index 00000000000..bbbbe4f077f --- /dev/null +++ b/gremlin-dart/docker-compose.yml @@ -0,0 +1,32 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +services: + gremlin-server: + build: + context: ./docker + dockerfile: Dockerfile + container_name: gremlin-server-dart + ports: + - "45940:45940" + command: ["conf/gremlin-server-dart.yaml"] + healthcheck: + test: ["CMD-SHELL", "curl -sf -X POST -H 'Content-Type: application/json' -d '{\"gremlin\":\"g.inject(1)\",\"g\":\"gmodern\"}' http://localhost:45940/gremlin"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 20s diff --git a/gremlin-dart/docker/Dockerfile b/gremlin-dart/docker/Dockerfile new file mode 100644 index 00000000000..90450e0048c --- /dev/null +++ b/gremlin-dart/docker/Dockerfile @@ -0,0 +1,21 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +FROM tinkerpop/gremlin-server:4.0.0-beta.2 +COPY gremlin-server-dart.yaml /opt/gremlin-server/conf/gremlin-server-dart.yaml +COPY tinkergraph-service.properties /opt/gremlin-server/conf/tinkergraph-service.properties +COPY load-test-graphs.groovy /opt/gremlin-server/scripts/load-test-graphs.groovy diff --git a/gremlin-dart/docker/gremlin-server-dart.yaml b/gremlin-dart/docker/gremlin-server-dart.yaml new file mode 100644 index 00000000000..a2667ce85b2 --- /dev/null +++ b/gremlin-dart/docker/gremlin-server-dart.yaml @@ -0,0 +1,45 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +host: 0.0.0.0 +port: 45940 +evaluationTimeout: 30000 +channelizer: org.apache.tinkerpop.gremlin.server.channel.HttpChannelizer +graphs: { + graph: conf/tinkergraph-service.properties, + modern: conf/tinkergraph-service.properties, + classic: conf/tinkergraph-service.properties, + crew: conf/tinkergraph-service.properties, + grateful: conf/tinkergraph-service.properties, + sink: conf/tinkergraph-service.properties} +scriptEngines: { + gremlin-lang: {}, + gremlin-groovy: { + plugins: { + org.apache.tinkerpop.gremlin.server.jsr223.GremlinServerGremlinPlugin: {}, + org.apache.tinkerpop.gremlin.tinkergraph.jsr223.TinkerGraphGremlinPlugin: {}, + org.apache.tinkerpop.gremlin.jsr223.ScriptFileGremlinPlugin: { + files: [scripts/load-test-graphs.groovy]}}}} +serializers: + - { className: org.apache.tinkerpop.gremlin.util.ser.GraphSONMessageSerializerV4 } + - { className: org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV4 } + - { className: org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV4, config: { serializeResultToString: true }} +metrics: { + slf4jReporter: {enabled: true, interval: 180000}} +gremlinPool: 8 +strictTransactionManagement: false +resultIterationBatchSize: 64 diff --git a/gremlin-dart/docker/load-test-graphs.groovy b/gremlin-dart/docker/load-test-graphs.groovy new file mode 100644 index 00000000000..508f856fde8 --- /dev/null +++ b/gremlin-dart/docker/load-test-graphs.groovy @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerFactory + +TinkerFactory.generateModern(modern) +TinkerFactory.generateClassic(classic) +TinkerFactory.generateTheCrew(crew) +TinkerFactory.generateGratefulDead(grateful) +TinkerFactory.generateKitchenSink(sink) + +def globals = [:] +globals << [g : traversal().withEmbedded(graph)] +globals << [ggraph : traversal().withEmbedded(graph)] +globals << [gmodern : traversal().withEmbedded(modern)] +globals << [gclassic: traversal().withEmbedded(classic)] +globals << [gcrew : traversal().withEmbedded(crew)] +globals << [ggrateful: traversal().withEmbedded(grateful)] +globals << [gsink : traversal().withEmbedded(sink)] diff --git a/gremlin-dart/docker/tinkergraph-service.properties b/gremlin-dart/docker/tinkergraph-service.properties new file mode 100644 index 00000000000..a58ef846521 --- /dev/null +++ b/gremlin-dart/docker/tinkergraph-service.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +gremlin.graph=org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph +gremlin.tinkergraph.vertexIdManager=INTEGER +gremlin.tinkergraph.edgeIdManager=INTEGER +gremlin.tinkergraph.vertexPropertyIdManager=LONG +gremlin.tinkergraph.service=org.apache.tinkerpop.gremlin.tinkergraph.services.TinkerTextSearchFactory +gremlin.tinkergraph.service=org.apache.tinkerpop.gremlin.tinkergraph.services.TinkerDegreeCentralityFactory diff --git a/gremlin-dart/example/basic.dart b/gremlin-dart/example/basic.dart new file mode 100644 index 00000000000..ed42165cd3c --- /dev/null +++ b/gremlin-dart/example/basic.dart @@ -0,0 +1,52 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 + +import 'package:gremlin_dart/gremlin_dart.dart'; + +Future main() async { + final conn = DriverRemoteConnection('http://localhost:8182/gremlin'); + + // g is the graph traversal source — all queries start here + final g = traversal().withRemote(conn); + + // Buffered query: returns Future> + final vertices = await g.V().hasLabel('person').toList(); + print('People: $vertices'); + + // Chained property access + final names = await g.V().hasLabel('person').values(['name']).toList(); + print('Names: $names'); + + // Anonymous traversal inside repeat + final reachable = await g + .V() + .has('name', P.eq('marko')) + .repeat(Anon.out(['knows'])) + .times(2) + .values(['name']) + .toList(); + print('Reachable from marko in 2 hops: $reachable'); + + // Streaming query + await for (final item in g.V().hasLabel('software').stream()) { + print(' software: $item'); + } + + await conn.close(); +} + +extension StreamingTraversal on GraphTraversal { + // Convenience extension so callers can do g.V()...stream() + Stream stream() { + return Stream.fromFuture(applyStrategies()).asyncExpand((_) { + return resultsStream ?? const Stream.empty(); + }); + } +} diff --git a/gremlin-dart/lib/driver/auth.dart b/gremlin-dart/lib/driver/auth.dart new file mode 100644 index 00000000000..f56a4943bb7 --- /dev/null +++ b/gremlin-dart/lib/driver/auth.dart @@ -0,0 +1,25 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 + +import 'dart:convert'; + +abstract class AuthOptions {} + +class BasicAuth extends AuthOptions { + final String username; + final String password; + + BasicAuth({required this.username, required this.password}); + + String get headerValue { + final encoded = base64Encode(utf8.encode('$username:$password')); + return 'Basic $encoded'; + } +} diff --git a/gremlin-dart/lib/driver/client.dart b/gremlin-dart/lib/driver/client.dart new file mode 100644 index 00000000000..6410d897779 --- /dev/null +++ b/gremlin-dart/lib/driver/client.dart @@ -0,0 +1,99 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'connection.dart'; +import 'request_message.dart'; +import 'result_set.dart'; + +class RequestOptions { + final Map? bindings; + final String? language; + final int? evaluationTimeout; + final bool? bulkResults; + final String? materializeProperties; + + const RequestOptions({ + this.bindings, + this.language, + this.evaluationTimeout, + this.bulkResults, + this.materializeProperties, + }); +} + +class Client { + final Connection _connection; + final ConnectionOptions options; + + Client(String url, [ConnectionOptions? options]) + : options = options ?? const ConnectionOptions(), + _connection = Connection(url, options); + + bool get isOpen => _connection.isOpen; + + Future open() => _connection.open(); + + Future> submit( + String message, { + Map? bindings, + RequestOptions? requestOptions, + }) { + return _connection.submit( + _buildRequest(message, bindings: bindings, requestOptions: requestOptions)); + } + + Stream stream( + String message, { + Map? bindings, + RequestOptions? requestOptions, + }) { + return _connection.stream( + _buildRequest(message, bindings: bindings, requestOptions: requestOptions)); + } + + RequestMessage _buildRequest( + String message, { + Map? bindings, + RequestOptions? requestOptions, + }) { + final builder = RequestMessage.build(message) + .addG(options.traversalSource); + + if (requestOptions?.language != null) { + builder.addLanguage(requestOptions!.language!); + } + if (requestOptions?.bindings != null) { + builder.addBindings(requestOptions!.bindings!); + } + if (bindings != null) { + builder.addBindings(bindings); + } + if (requestOptions?.materializeProperties != null) { + builder.addMaterializeProperties(requestOptions!.materializeProperties!); + } + if (requestOptions?.evaluationTimeout != null) { + builder.addTimeoutMillis(requestOptions!.evaluationTimeout!); + } + if (requestOptions?.bulkResults != null) { + builder.addBulkResults(requestOptions!.bulkResults!); + } + + return builder.create(); + } + + Future close() => _connection.close(); +} diff --git a/gremlin-dart/lib/driver/connection.dart b/gremlin-dart/lib/driver/connection.dart new file mode 100644 index 00000000000..fcd1184998e --- /dev/null +++ b/gremlin-dart/lib/driver/connection.dart @@ -0,0 +1,274 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:convert'; +import 'dart:io' as io; +import 'dart:typed_data'; + +import '../structure/io/graph_binary/graph_binary_reader.dart'; +import '../structure/io/graph_binary/graph_binary_writer.dart'; +import 'auth.dart'; +import 'request_message.dart'; +import 'response_error.dart'; +import 'result_set.dart'; + +typedef RequestInterceptor = Future> Function( + Map request); + +class ConnectionOptions { + final bool enableUserAgentOnConnect; + final Map headers; + final String traversalSource; + final AuthOptions? auth; + final List interceptors; + + const ConnectionOptions({ + this.enableUserAgentOnConnect = true, + this.headers = const {}, + this.traversalSource = 'g', + this.auth, + this.interceptors = const [], + }); +} + +class _RawResponse { + final int statusCode; + final String? contentType; + final Uint8List bodyBytes; + const _RawResponse(this.statusCode, this.contentType, this.bodyBytes); +} + +class Connection { + final String url; + final ConnectionOptions options; + final GraphBinaryReader _reader; + final GraphBinaryWriter _writer; + + bool isOpen = true; + + Connection(this.url, [ConnectionOptions? options]) + : options = options ?? const ConnectionOptions(), + _reader = GraphBinaryReader(), + _writer = GraphBinaryWriter(); + + Future open() async {} + + Future> submit(RequestMessage request) async { + final body = _writer.writeRequest(request); + final response = await _makeHttpRequest(body); + return _handleResponse(response); + } + + Stream stream(RequestMessage request) async* { + final body = _writer.writeRequest(request); + final response = await _makeHttpRequest(body); + yield* _streamResponse(response); + } + + // --------------------------------------------------------------------------- + // HTTP transport — uses dart:io directly so we can handle HTTP trailers that + // TinkerPop's Netty server appends after the final 0\r\n chunk. Dart's built-in + // HTTP parser throws HttpException when it sees trailer bytes; we swallow that + // error after the body has been fully buffered. + // --------------------------------------------------------------------------- + + Future<_RawResponse> _makeHttpRequest(Uint8List body) async { + final reqHeaders = { + 'Content-Type': _writer.mimeType, + 'Accept': _reader.mimeType, + }; + + if (options.enableUserAgentOnConnect) { + reqHeaders['x-gremlin-useragent'] = _userAgent(); + } + + reqHeaders.addAll(options.headers); + + if (options.auth is BasicAuth) { + reqHeaders['Authorization'] = (options.auth as BasicAuth).headerValue; + } + + Map req = { + 'url': url, + 'method': 'POST', + 'headers': reqHeaders, + 'body': body, + }; + + for (final interceptor in options.interceptors) { + req = await interceptor(req); + } + + final uri = Uri.parse(req['url'] as String); + final finalHeaders = Map.from(req['headers'] as Map); + final finalBody = req['body'] as Uint8List; + + final client = io.HttpClient(); + try { + final ioReq = await client.postUrl(uri); + finalHeaders.forEach((k, v) => ioReq.headers.set(k, v)); + ioReq.add(finalBody); + + final ioResp = await ioReq.close(); + final statusCode = ioResp.statusCode; + final contentType = ioResp.headers.contentType?.toString(); + + final bodyBytes = BytesBuilder(copy: false); + bool trailerException = false; + try { + await for (final chunk in ioResp) { + bodyBytes.add(chunk); + } + } on io.HttpException catch (_) { + // TinkerPop Netty sends HTTP trailers (e.g. "code: 200") after the + // final 0\r\n chunk. Dart's parser throws here. The body is already + // complete, so we can safely swallow this error. + if (bodyBytes.isEmpty) rethrow; + trailerException = true; + } on StateError catch (_) { + if (bodyBytes.isEmpty) rethrow; + trailerException = true; + } + + final raw = bodyBytes.takeBytes(); + // When the HttpException fires, dart:io may have handed us raw chunked- + // encoding bytes instead of the decoded payload (the stream yields wire + // bytes before the codec finishes). Detect and decode manually. + final decoded = trailerException ? _maybeDecodeChunked(raw) : raw; + return _RawResponse(statusCode, contentType, decoded); + } finally { + client.close(); + } + } + + Future> _handleResponse(_RawResponse response) async { + if (response.statusCode < 200 || response.statusCode >= 300) { + _throwResponseError( + response.statusCode, response.bodyBytes, 'HTTP ${response.statusCode}'); + } + + if (response.bodyBytes.isEmpty) return ResultSet([]); + + final deserialized = await _reader.readResponse(response.bodyBytes); + + if (deserialized['status'] != null) { + final code = deserialized['status']['code'] as int?; + if (code != null && code != 200 && code != 204 && code != 206) { + throw ResponseError( + 'Server error (code $code)', + statusCode: code, + serverMessage: deserialized['status']['message'] as String?, + exception: deserialized['status']['exception'] as String?, + ); + } + } + + final result = deserialized['result']; + final bulked = result['bulked'] as bool? ?? false; + final data = result['data'] as List? ?? []; + + final items = bulked + ? data + .expand((item) { + final bulk = (item['bulk'] as int?) ?? 1; + return List.filled(bulk, item['v']); + }) + .toList() + : data; + + return ResultSet(items); + } + + Stream _streamResponse(_RawResponse response) async* { + if (response.statusCode < 200 || response.statusCode >= 300) { + _throwResponseError( + response.statusCode, response.bodyBytes, 'HTTP ${response.statusCode}'); + } + if (response.bodyBytes.isEmpty) return; + yield* _reader + .readResponseStream(Stream.value(response.bodyBytes)); + } + + void _throwResponseError(int statusCode, Uint8List body, String reasonPhrase) { + final message = 'Server returned HTTP $statusCode: $reasonPhrase'; + try { + final decoded = jsonDecode(utf8.decode(body)) as Map; + final status = decoded['status'] as Map?; + throw ResponseError( + message, + statusCode: statusCode, + serverMessage: status?['message'] as String? ?? + decoded['message'] as String? ?? + decoded['error'] as String? ?? + reasonPhrase, + ); + } catch (e) { + if (e is ResponseError) rethrow; + throw ResponseError(message, statusCode: statusCode); + } + } + + Future close() async { + isOpen = false; + } + + // Decodes HTTP chunked transfer encoding manually. When dart:io throws an + // HttpException due to trailing headers, the stream may yield raw wire bytes + // (chunk-size CRLF chunk-data CRLF ... 0 CRLF) instead of decoded payload. + // If the buffer doesn't look like chunked encoding, return it unchanged. + static Uint8List _maybeDecodeChunked(Uint8List raw) { + // Chunked encoding starts with a hex size followed by \r\n. + // If the first byte is not a hex digit, it's already decoded. + if (raw.isEmpty) return raw; + final first = raw[0]; + final isHex = (first >= 0x30 && first <= 0x39) || // 0-9 + (first >= 0x41 && first <= 0x46) || // A-F + (first >= 0x61 && first <= 0x66); // a-f + if (!isHex) return raw; + + final out = BytesBuilder(); + int pos = 0; + while (pos < raw.length) { + // Find the \r\n after the chunk size + int crPos = pos; + while (crPos < raw.length - 1 && + !(raw[crPos] == 0x0D && raw[crPos + 1] == 0x0A)) { + crPos++; + } + if (crPos >= raw.length - 1) break; + + final sizeHex = String.fromCharCodes(raw.sublist(pos, crPos)); + final chunkSize = int.tryParse(sizeHex.trim(), radix: 16); + if (chunkSize == null) return raw; // not chunked after all + if (chunkSize == 0) break; // final chunk + + pos = crPos + 2; // skip \r\n + if (pos + chunkSize > raw.length) { + // Partial last chunk — take what we have + out.add(raw.sublist(pos)); + break; + } + out.add(raw.sublist(pos, pos + chunkSize)); + pos += chunkSize + 2; // skip chunk data + trailing \r\n + } + + final result = out.takeBytes(); + return result.isEmpty ? raw : result; + } + + static String _userAgent() => 'gremlin-dart/0.1.0 Dart/unknown'; +} diff --git a/gremlin-dart/lib/driver/driver_remote_connection.dart b/gremlin-dart/lib/driver/driver_remote_connection.dart new file mode 100644 index 00000000000..83ae72422cf --- /dev/null +++ b/gremlin-dart/lib/driver/driver_remote_connection.dart @@ -0,0 +1,93 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 '../process/gremlin_lang.dart'; +import '../process/traversal.dart'; +import 'client.dart'; +import 'connection.dart'; +import 'remote_connection.dart'; + +class DriverRemoteConnection extends RemoteConnection { + final Client _client; + + DriverRemoteConnection(String url, [ConnectionOptions? options]) + : _client = Client(url, options), + super(url); + + @override + Future open() => _client.open(); + + @override + bool get isOpen => _client.isOpen; + + @override + Future submit(GremlinLang gremlinLang) { + final (gremlin, requestOptions) = _buildRequestArgs(gremlinLang); + final stream = _client.stream(gremlin, requestOptions: requestOptions); + return Future.value(RemoteTraversal(stream)); + } + + (String, RequestOptions) _buildRequestArgs(GremlinLang gremlinLang) { + final strategies = gremlinLang.getOptionsStrategies(); + final allowed = { + 'evaluationTimeout', + 'materializeProperties', + 'bulkResults', + }; + + int? evalTimeout; + bool? bulkResults; + String? materializeProperties; + + for (final s in strategies) { + for (final entry in s.configuration.entries) { + if (!allowed.contains(entry.key)) continue; + switch (entry.key) { + case 'evaluationTimeout': + evalTimeout = entry.value as int?; + case 'bulkResults': + bulkResults = entry.value as bool?; + case 'materializeProperties': + materializeProperties = entry.value as String?; + } + } + } + + bulkResults ??= true; + + final requestOptions = RequestOptions( + evaluationTimeout: evalTimeout, + bulkResults: bulkResults, + materializeProperties: materializeProperties, + ); + + return (gremlinLang.getGremlin(), requestOptions); + } + + @override + Future commit() async { + await _client.submit('g.tx().commit()'); + } + + @override + Future rollback() async { + await _client.submit('g.tx().rollback()'); + } + + @override + Future close() => _client.close(); +} diff --git a/gremlin-dart/lib/driver/remote_connection.dart b/gremlin-dart/lib/driver/remote_connection.dart new file mode 100644 index 00000000000..130e844058f --- /dev/null +++ b/gremlin-dart/lib/driver/remote_connection.dart @@ -0,0 +1,39 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 '../process/gremlin_lang.dart'; +import '../process/traversal.dart'; +import '../process/traversal_strategy.dart'; + +abstract class RemoteConnection implements RemoteConnectionBase { + final String url; + + RemoteConnection(this.url); + + Future open(); + + bool get isOpen; + + @override + Future submit(GremlinLang gremlinLang); + + Future commit(); + + Future rollback(); + + Future close(); +} diff --git a/gremlin-dart/lib/driver/request_message.dart b/gremlin-dart/lib/driver/request_message.dart new file mode 100644 index 00000000000..50f57a97b93 --- /dev/null +++ b/gremlin-dart/lib/driver/request_message.dart @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +class RequestMessage { + final String gremlin; + final String language; + final int? timeoutMs; + final Map? bindings; + final String? g; + final String? materializeProperties; + final bool? bulkResults; + final Map fields; + + static const _matTokens = 'tokens'; + static const _matAll = 'all'; + + const RequestMessage._({ + required this.gremlin, + required this.language, + this.timeoutMs, + this.bindings, + this.g, + this.materializeProperties, + this.bulkResults, + this.fields = const {}, + }); + + static RequestMessageBuilder build(String gremlin) => + RequestMessageBuilder._(gremlin); + + Map toJson() { + final m = {'gremlin': gremlin, 'language': language}; + if (bindings != null) m['bindings'] = bindings; + if (g != null) m['g'] = g; + if (materializeProperties != null) { + m['materializeProperties'] = materializeProperties; + } + if (timeoutMs != null) m['timeoutMs'] = timeoutMs; + if (bulkResults != null) m['bulkResults'] = bulkResults; + m.addAll(fields); + return m; + } +} + +class RequestMessageBuilder { + final String _gremlin; + String _language = 'gremlin-lang'; + int? _timeoutMs; + final Map _bindings = {}; + String? _g; + String? _materializeProperties; + bool? _bulkResults; + final Map _fields = {}; + + RequestMessageBuilder._(this._gremlin); + + RequestMessageBuilder addLanguage(String language) { + _language = language; + return this; + } + + RequestMessageBuilder addBinding(String key, dynamic value) { + _bindings[key] = value; + return this; + } + + RequestMessageBuilder addBindings(Map bindings) { + _bindings.addAll(bindings); + return this; + } + + RequestMessageBuilder addG(String g) { + _g = g; + return this; + } + + RequestMessageBuilder addMaterializeProperties(String value) { + if (value != RequestMessage._matTokens && + value != RequestMessage._matAll) { + throw ArgumentError( + 'materializeProperties must be "tokens" or "all"'); + } + _materializeProperties = value; + return this; + } + + RequestMessageBuilder addTimeoutMillis(int timeout) { + if (timeout < 0) throw ArgumentError('timeout cannot be negative'); + _timeoutMs = timeout; + return this; + } + + RequestMessageBuilder addBulkResults(bool bulking) { + _bulkResults = bulking; + return this; + } + + RequestMessageBuilder addField(String key, dynamic value) { + _fields[key] = value; + return this; + } + + RequestMessage create() { + final Map? bindings = + _bindings.isNotEmpty ? Map.unmodifiable(_bindings) : null; + return RequestMessage._( + gremlin: _gremlin, + language: _language, + timeoutMs: _timeoutMs, + bindings: bindings, + g: _g, + materializeProperties: _materializeProperties, + bulkResults: _bulkResults, + fields: Map.unmodifiable(_fields), + ); + } +} diff --git a/gremlin-dart/lib/driver/response_error.dart b/gremlin-dart/lib/driver/response_error.dart new file mode 100644 index 00000000000..80036f4b8ad --- /dev/null +++ b/gremlin-dart/lib/driver/response_error.dart @@ -0,0 +1,32 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 + +class ResponseError implements Exception { + final String message; + final int? statusCode; + final String? serverMessage; + final String? exception; + + const ResponseError( + this.message, { + this.statusCode, + this.serverMessage, + this.exception, + }); + + @override + String toString() { + final parts = [message]; + if (statusCode != null) parts.add('status=$statusCode'); + if (serverMessage != null) parts.add('serverMessage=$serverMessage'); + if (exception != null) parts.add('exception=$exception'); + return 'ResponseError(${parts.join(', ')})'; + } +} diff --git a/gremlin-dart/lib/driver/result_set.dart b/gremlin-dart/lib/driver/result_set.dart new file mode 100644 index 00000000000..ebd0fba81bb --- /dev/null +++ b/gremlin-dart/lib/driver/result_set.dart @@ -0,0 +1,25 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 + +class ResultSet { + final List items; + final Map attributes; + + const ResultSet(this.items, [this.attributes = const {}]); + + T operator [](int index) => items[index]; + + int get length => items.length; + + bool get isEmpty => items.isEmpty; + + @override + String toString() => 'ResultSet($items)'; +} diff --git a/gremlin-dart/lib/gremlin_dart.dart b/gremlin-dart/lib/gremlin_dart.dart new file mode 100644 index 00000000000..8c88f8b0ae0 --- /dev/null +++ b/gremlin-dart/lib/gremlin_dart.dart @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +library gremlin_dart; + +// Structure +export 'structure/graph.dart'; + +// Process +export 'process/traversal.dart'; +export 'process/traversal_strategy.dart'; +export 'process/gremlin_lang.dart'; +export 'process/graph_traversal.dart'; +export 'process/anonymous_traversal.dart' show traversal, Anon, AnonymousTraversalSource; + +// Driver +export 'driver/auth.dart'; +export 'driver/client.dart'; +export 'driver/connection.dart' show Connection, ConnectionOptions; +export 'driver/driver_remote_connection.dart'; +export 'driver/remote_connection.dart'; +export 'driver/request_message.dart'; +export 'driver/response_error.dart'; +export 'driver/result_set.dart'; diff --git a/gremlin-dart/lib/process/anonymous_traversal.dart b/gremlin-dart/lib/process/anonymous_traversal.dart new file mode 100644 index 00000000000..eca3248b145 --- /dev/null +++ b/gremlin-dart/lib/process/anonymous_traversal.dart @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 '../structure/graph.dart'; +import 'gremlin_lang.dart'; +import 'graph_traversal.dart'; +import 'traversal_strategy.dart'; + +class AnonymousTraversalSource { + const AnonymousTraversalSource._(); + + static AnonymousTraversalSource traversal() => + const AnonymousTraversalSource._(); + + GraphTraversalSource withRemote(dynamic connection) { + final strategies = TraversalStrategies(); + strategies.addStrategy(RemoteStrategy(connection)); + return GraphTraversalSource( + Graph(), + strategies, + GremlinLang(), + ); + } + + // Alias kept for API symmetry with other language drivers. + GraphTraversalSource with_(dynamic connection) => withRemote(connection); +} + +// Top-level factory function matching other drivers' `traversal()` call. +AnonymousTraversalSource traversal() => AnonymousTraversalSource.traversal(); + +// --------------------------------------------------------------------------- +// Anonymous traversal spawner — mirrors JS `__` object. +// Used inside child traversals (e.g. repeat(__.out('knows'))). +// --------------------------------------------------------------------------- + +class Anon { + Anon._(/* prevent instantiation */); + + static GraphTraversal _startAnon(String step, [List? args]) { + final gl = GremlinLang()..addStep(step, args); + return GraphTraversal(null, null, gl); + } + + static GraphTraversal V([List? args]) => _startAnon('V', args); + static GraphTraversal E([List? args]) => _startAnon('E', args); + static GraphTraversal out([List? labels]) => _startAnon('out', labels); + static GraphTraversal in_([List? labels]) => _startAnon('in', labels); + static GraphTraversal both([List? labels]) => _startAnon('both', labels); + static GraphTraversal outE([List? labels]) => _startAnon('outE', labels); + static GraphTraversal inE([List? labels]) => _startAnon('inE', labels); + static GraphTraversal bothE([List? labels]) => _startAnon('bothE', labels); + static GraphTraversal outV() => _startAnon('outV'); + static GraphTraversal inV() => _startAnon('inV'); + static GraphTraversal bothV() => _startAnon('bothV'); + static GraphTraversal values([List? keys]) => _startAnon('values', keys); + static GraphTraversal properties([List? keys]) => _startAnon('properties', keys); + static GraphTraversal id() => _startAnon('id'); + static GraphTraversal label() => _startAnon('label'); + static GraphTraversal identity() => _startAnon('identity'); + static GraphTraversal count([dynamic scope]) => + _startAnon('count', scope != null ? [scope] : null); + static GraphTraversal sum([dynamic scope]) => + _startAnon('sum', scope != null ? [scope] : null); + static GraphTraversal max([dynamic scope]) => + _startAnon('max', scope != null ? [scope] : null); + static GraphTraversal min([dynamic scope]) => + _startAnon('min', scope != null ? [scope] : null); + static GraphTraversal mean([dynamic scope]) => + _startAnon('mean', scope != null ? [scope] : null); + static GraphTraversal has(dynamic first, [dynamic second, dynamic third]) { + final args = [first, if (second != null) second, if (third != null) third]; + return _startAnon('has', args); + } + static GraphTraversal hasLabel(dynamic label, [List? rest]) => + _startAnon('hasLabel', [label, ...?rest]); + static GraphTraversal hasId(dynamic id, [List? rest]) => + _startAnon('hasId', [id, ...?rest]); + static GraphTraversal not_(dynamic traversal) => _startAnon('not', [traversal]); + static GraphTraversal where(dynamic predicateOrTraversal) => + _startAnon('where', [predicateOrTraversal]); + static GraphTraversal is_(dynamic value) => _startAnon('is', [value]); + static GraphTraversal loops([String? loopName]) => + _startAnon('loops', loopName != null ? [loopName] : null); + static GraphTraversal path() => _startAnon('path'); + static GraphTraversal select(dynamic key, [dynamic second]) => + _startAnon('select', [key, if (second != null) second]); + static GraphTraversal fold() => _startAnon('fold'); + static GraphTraversal unfold() => _startAnon('unfold'); + static GraphTraversal limit(dynamic count) => _startAnon('limit', [count]); + static GraphTraversal tail([dynamic count]) => + _startAnon('tail', count != null ? [count] : null); + static GraphTraversal constant(dynamic value) => _startAnon('constant', [value]); + static GraphTraversal union(List traversals) => + _startAnon('union', traversals); + static GraphTraversal coalesce(List traversals) => + _startAnon('coalesce', traversals); + static GraphTraversal repeat(dynamic traversal) => + _startAnon('repeat', [traversal]); + static GraphTraversal emit([dynamic traversalOrPredicate]) => + _startAnon('emit', traversalOrPredicate != null ? [traversalOrPredicate] : null); + static GraphTraversal until(dynamic traversalOrPredicate) => + _startAnon('until', [traversalOrPredicate]); + static GraphTraversal times(int count) => _startAnon('times', [count]); + static GraphTraversal local(dynamic traversal) => + _startAnon('local', [traversal]); + static GraphTraversal math_(String expression) => + _startAnon('math', [expression]); + static GraphTraversal addV_([dynamic label]) => + _startAnon('addV', label != null ? [label] : null); + static GraphTraversal addE_(dynamic label) => _startAnon('addE', [label]); + static GraphTraversal drop() => _startAnon('drop'); + static GraphTraversal optional(dynamic traversal) => + _startAnon('optional', [traversal]); + static GraphTraversal dedup([List? args]) => _startAnon('dedup', args); + static GraphTraversal inject(List args) => _startAnon('inject', args); +} diff --git a/gremlin-dart/lib/process/graph_traversal.dart b/gremlin-dart/lib/process/graph_traversal.dart new file mode 100644 index 00000000000..e0ccddb2b65 --- /dev/null +++ b/gremlin-dart/lib/process/graph_traversal.dart @@ -0,0 +1,462 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 '../structure/graph.dart'; +import 'gremlin_lang.dart'; +import 'traversal.dart'; +import 'traversal_strategy.dart'; + +// --------------------------------------------------------------------------- +// GraphTraversalSource +// --------------------------------------------------------------------------- + +class GraphTraversalSource { + final Graph graph; + final TraversalStrategies traversalStrategies; + final GremlinLang gremlinLang; + + GraphTraversalSource( + this.graph, + this.traversalStrategies, [ + GremlinLang? gremlinLang, + ]) : gremlinLang = gremlinLang ?? GremlinLang(); + + GraphTraversalSource _spawn([GremlinLang? gl]) => GraphTraversalSource( + graph, + TraversalStrategies(traversalStrategies), + gl ?? GremlinLang(gremlinLang), + ); + + GraphTraversal _spawnTraversal(GremlinLang gl) => + GraphTraversal(graph, traversalStrategies, gl); + + // ---- Source modifiers ---------------------------------------------------- + + GraphTraversalSource withStrategies(List strategies) { + final gl = GremlinLang(gremlinLang)..addSource('withStrategies', strategies); + return _spawn(gl); + } + + GraphTraversalSource with_(String key, [dynamic value]) { + final val = value ?? true; + final gl = GremlinLang(gremlinLang); + final opts = gl.getOptionsStrategies(); + if (opts.isEmpty) { + opts.add(OptionsStrategy({key: val})); + } else { + // Replace the last entry in the clone's list with a new merged copy, + // so the original source's OptionsStrategy object is never mutated. + final merged = Map.from(opts.last.configuration)..[key] = val; + opts[opts.length - 1] = OptionsStrategy(merged); + } + return _spawn(gl); + } + + GraphTraversalSource withBulk([List? args]) => + _spawn(GremlinLang(gremlinLang)..addSource('withBulk', args)); + + GraphTraversalSource withPath([List? args]) => + _spawn(GremlinLang(gremlinLang)..addSource('withPath', args)); + + GraphTraversalSource withSack([List? args]) => + _spawn(GremlinLang(gremlinLang)..addSource('withSack', args)); + + GraphTraversalSource withSideEffect([List? args]) => + _spawn(GremlinLang(gremlinLang)..addSource('withSideEffect', args)); + + // ---- Spawn traversals ---------------------------------------------------- + + GraphTraversal V([List? args]) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('V', args)); + + GraphTraversal E([List? args]) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('E', args)); + + GraphTraversal addV([dynamic label]) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('addV', label != null ? [label] : null)); + + GraphTraversal addE(dynamic label) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('addE', [label])); + + GraphTraversal mergeV([dynamic args]) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('mergeV', args != null ? [args] : null)); + + GraphTraversal mergeE([dynamic args]) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('mergeE', args != null ? [args] : null)); + + GraphTraversal inject(List args) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('inject', args)); + + GraphTraversal io(String file) => + _spawnTraversal(GremlinLang(gremlinLang)..addStep('io', [file])); + + GraphTraversal call_(String procedure, [List? args]) => + _spawnTraversal(GremlinLang(gremlinLang) + ..addStep('call', [procedure, ...?args])); + + @override + String toString() => 'graphtraversalsource[$graph]'; +} + +// --------------------------------------------------------------------------- +// GraphTraversal — the core DSL +// All step methods follow the same pattern: +// - Clone current GremlinLang +// - Add the step +// - Return a new GraphTraversal with same graph/strategies +// --------------------------------------------------------------------------- + +class GraphTraversal extends Traversal { + GraphTraversal( + Graph? graph, + TraversalStrategies? strategies, + GremlinLang gl, + ) : super(graph, strategies, gl); + + GraphTraversal _step(String name, [List? args]) => + GraphTraversal(graph, traversalStrategies, GremlinLang(gremlinLang)..addStep(name, args)); + + // ---- Map steps ----------------------------------------------------------- + + GraphTraversal map_(dynamic traversalOrLambda) => + _step('map', [traversalOrLambda]); + + GraphTraversal flatMap(dynamic traversalOrLambda) => + _step('flatMap', [traversalOrLambda]); + + GraphTraversal id() => _step('id'); + + GraphTraversal label() => _step('label'); + + GraphTraversal identity() => _step('identity'); + + GraphTraversal constant(dynamic value) => _step('constant', [value]); + + GraphTraversal V([List? args]) => _step('V', args); + + GraphTraversal E([List? args]) => _step('E', args); + + GraphTraversal to(dynamic toVertex) => _step('to', [toVertex]); + + GraphTraversal from_(dynamic fromVertex) => _step('from', [fromVertex]); + + GraphTraversal out([List? labels]) => + _step('out', labels?.isNotEmpty == true ? labels : null); + + GraphTraversal in_([List? labels]) => + _step('in', labels?.isNotEmpty == true ? labels : null); + + GraphTraversal both([List? labels]) => + _step('both', labels?.isNotEmpty == true ? labels : null); + + GraphTraversal outE([List? labels]) => + _step('outE', labels?.isNotEmpty == true ? labels : null); + + GraphTraversal inE([List? labels]) => + _step('inE', labels?.isNotEmpty == true ? labels : null); + + GraphTraversal bothE([List? labels]) => + _step('bothE', labels?.isNotEmpty == true ? labels : null); + + GraphTraversal outV() => _step('outV'); + + GraphTraversal inV() => _step('inV'); + + GraphTraversal bothV() => _step('bothV'); + + GraphTraversal otherV() => _step('otherV'); + + GraphTraversal order([dynamic scope]) => + _step('order', scope != null ? [scope] : null); + + GraphTraversal properties([List? keys]) => + _step('properties', keys?.isNotEmpty == true ? keys : null); + + GraphTraversal values([List? keys]) => + _step('values', keys?.isNotEmpty == true ? keys : null); + + GraphTraversal propertyMap([List? keys]) => + _step('propertyMap', keys?.isNotEmpty == true ? keys : null); + + GraphTraversal elementMap([List? keys]) => + _step('elementMap', keys?.isNotEmpty == true ? keys : null); + + GraphTraversal valueMap([List? args]) => + _step('valueMap', args?.isNotEmpty == true ? args : null); + + GraphTraversal select(dynamic first, [dynamic second, dynamic third]) { + final args = [first, if (second != null) second, if (third != null) third]; + return _step('select', args); + } + + GraphTraversal by(dynamic arg, [dynamic order]) => + _step('by', [arg, if (order != null) order]); + + GraphTraversal fold([dynamic seed, dynamic foldFunction]) { + if (seed != null && foldFunction != null) { + return _step('fold', [seed, foldFunction]); + } + return _step('fold'); + } + + GraphTraversal unfold() => _step('unfold'); + + GraphTraversal path() => _step('path'); + + GraphTraversal limit(dynamic scopeOrCount, [int? count]) { + if (count != null) return _step('limit', [scopeOrCount, count]); + return _step('limit', [scopeOrCount]); + } + + GraphTraversal tail([dynamic scopeOrCount, int? count]) { + if (count != null) return _step('tail', [scopeOrCount, count]); + if (scopeOrCount != null) return _step('tail', [scopeOrCount]); + return _step('tail'); + } + + GraphTraversal range(dynamic start, dynamic end) => + _step('range', [start, end]); + + GraphTraversal skip(dynamic scopeOrCount, [int? count]) { + if (count != null) return _step('skip', [scopeOrCount, count]); + return _step('skip', [scopeOrCount]); + } + + GraphTraversal sample(dynamic scopeOrAmount, [int? amount]) { + if (amount != null) return _step('sample', [scopeOrAmount, amount]); + return _step('sample', [scopeOrAmount]); + } + + GraphTraversal count([dynamic scope]) => + _step('count', scope != null ? [scope] : null); + + GraphTraversal sum([dynamic scope]) => + _step('sum', scope != null ? [scope] : null); + + GraphTraversal max([dynamic scope]) => + _step('max', scope != null ? [scope] : null); + + GraphTraversal min([dynamic scope]) => + _step('min', scope != null ? [scope] : null); + + GraphTraversal mean([dynamic scope]) => + _step('mean', scope != null ? [scope] : null); + + GraphTraversal group([dynamic sideEffectKey]) => + _step('group', sideEffectKey != null ? [sideEffectKey] : null); + + GraphTraversal groupCount([String? sideEffectKey]) => + _step('groupCount', sideEffectKey != null ? [sideEffectKey] : null); + + GraphTraversal tree([String? sideEffectKey]) => + _step('tree', sideEffectKey != null ? [sideEffectKey] : null); + + // ---- Filter steps -------------------------------------------------------- + + GraphTraversal filter(dynamic traversalOrPredicate) => + _step('filter', [traversalOrPredicate]); + + GraphTraversal has(dynamic first, [dynamic second, dynamic third]) { + final args = [first, if (second != null) second, if (third != null) third]; + return _step('has', args); + } + + GraphTraversal hasLabel(dynamic first, [List? rest]) => + _step('hasLabel', [first, ...?rest]); + + GraphTraversal hasId(dynamic first, [List? rest]) => + _step('hasId', [first, ...?rest]); + + GraphTraversal hasKey(dynamic first, [List? rest]) => + _step('hasKey', [first, ...?rest]); + + GraphTraversal hasValue(dynamic first, [List? rest]) => + _step('hasValue', [first, ...?rest]); + + GraphTraversal hasNot(String key) => _step('hasNot', [key]); + + GraphTraversal and_(List traversals) => _step('and', traversals); + + GraphTraversal or_(List traversals) => _step('or', traversals); + + GraphTraversal not_(dynamic traversal) => _step('not', [traversal]); + + GraphTraversal where(dynamic predicateOrTraversal) => + _step('where', [predicateOrTraversal]); + + GraphTraversal is_(dynamic predicateOrValue) => + _step('is', [predicateOrValue]); + + GraphTraversal dedup([List? args]) => _step('dedup', args); + + GraphTraversal simplePath() => _step('simplePath'); + + GraphTraversal cyclicPath() => _step('cyclicPath'); + + // ---- Side-effect steps --------------------------------------------------- + + GraphTraversal sideEffect(dynamic traversal) => + _step('sideEffect', [traversal]); + + GraphTraversal store(String key) => _step('store', [key]); + + GraphTraversal aggregate(dynamic first, [String? key]) => + _step('aggregate', [first, if (key != null) key]); + + GraphTraversal subgraph(String key) => _step('subgraph', [key]); + + GraphTraversal cap(String first, [List? rest]) => + _step('cap', [first, ...?rest]); + + GraphTraversal timeLimit(int millis) => _step('timeLimit', [millis]); + + GraphTraversal profile([String? key]) => + _step('profile', key != null ? [key] : null); + + GraphTraversal property(dynamic first, dynamic second, + [List? rest]) => + _step('property', [first, second, ...?rest]); + + // ---- Branch steps -------------------------------------------------------- + + GraphTraversal branch(dynamic traversal) => _step('branch', [traversal]); + + GraphTraversal choose(dynamic first, [dynamic second, dynamic third]) { + final args = [first, if (second != null) second, if (third != null) third]; + return _step('choose', args); + } + + GraphTraversal optional(dynamic traversal) => + _step('optional', [traversal]); + + GraphTraversal union(List traversals) => + _step('union', traversals); + + GraphTraversal coalesce(List traversals) => + _step('coalesce', traversals); + + GraphTraversal repeat(dynamic traversal) => _step('repeat', [traversal]); + + GraphTraversal emit([dynamic traversalOrPredicate]) => + _step('emit', traversalOrPredicate != null ? [traversalOrPredicate] : null); + + GraphTraversal until(dynamic traversalOrPredicate) => + _step('until', [traversalOrPredicate]); + + GraphTraversal times(int count) => _step('times', [count]); + + GraphTraversal local(dynamic traversal) => _step('local', [traversal]); + + // ---- Mutation steps ------------------------------------------------------ + + GraphTraversal addV_([dynamic label]) => + _step('addV', label != null ? [label] : null); + + GraphTraversal addE_(dynamic label) => _step('addE', [label]); + + GraphTraversal drop() => _step('drop'); + + GraphTraversal mergeV_([dynamic args]) => + _step('mergeV', args != null ? [args] : null); + + GraphTraversal mergeE_([dynamic args]) => + _step('mergeE', args != null ? [args] : null); + + GraphTraversal option(dynamic first, [dynamic second]) => + _step('option', [first, if (second != null) second]); + + // ---- Math / string steps ------------------------------------------------- + + GraphTraversal math_(String expression) => _step('math', [expression]); + + GraphTraversal concat_(List args) => _step('concat', args); + + GraphTraversal toLower() => _step('toLower'); + + GraphTraversal toUpper() => _step('toUpper'); + + GraphTraversal trim() => _step('trim'); + + GraphTraversal lTrim() => _step('lTrim'); + + GraphTraversal rTrim() => _step('rTrim'); + + GraphTraversal length_() => _step('length'); + + GraphTraversal reverse() => _step('reverse'); + + GraphTraversal replace(String from, String to) => + _step('replace', [from, to]); + + GraphTraversal split(String separator) => _step('split', [separator]); + + GraphTraversal substring(int start, [int? end]) => + _step('substring', [start, if (end != null) end]); + + GraphTraversal asString() => _step('asString'); + + // ---- Misc steps ---------------------------------------------------------- + + GraphTraversal as_(List labels) => _step('as', labels); + + GraphTraversal barrier([dynamic maxBarrierSize]) => + _step('barrier', maxBarrierSize != null ? [maxBarrierSize] : null); + + GraphTraversal index() => _step('index'); + + GraphTraversal none_() => _step('none'); + + GraphTraversal read() => _step('read'); + + GraphTraversal write() => _step('write'); + + GraphTraversal with_(String key, [dynamic value]) => + _step('with', [key, if (value != null) value]); + + GraphTraversal coin(double probability) => _step('coin', [probability]); + + GraphTraversal element() => _step('element'); + + GraphTraversal discard() => _step('discard'); + + GraphTraversal fail([String? message]) => + _step('fail', message != null ? [message] : null); + + GraphTraversal intersect_(dynamic first, [dynamic second]) => + _step('intersect', [first, if (second != null) second]); + + GraphTraversal any_(dynamic traversalOrPredicate) => + _step('any', [traversalOrPredicate]); + + GraphTraversal all_(dynamic traversalOrPredicate) => + _step('all', [traversalOrPredicate]); + + GraphTraversal none__(dynamic traversalOrPredicate) => + _step('none', [traversalOrPredicate]); + + GraphTraversal difference(dynamic first, [dynamic second]) => + _step('difference', [first, if (second != null) second]); + + GraphTraversal product(dynamic first, [dynamic second]) => + _step('product', [first, if (second != null) second]); + + GraphTraversal combine(dynamic first, [dynamic second]) => + _step('combine', [first, if (second != null) second]); + + GraphTraversal merge_(dynamic first, [dynamic second]) => + _step('merge', [first, if (second != null) second]); +} diff --git a/gremlin-dart/lib/process/gremlin_lang.dart b/gremlin-dart/lib/process/gremlin_lang.dart new file mode 100644 index 00000000000..84a3a321db5 --- /dev/null +++ b/gremlin-dart/lib/process/gremlin_lang.dart @@ -0,0 +1,223 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:convert'; +import 'dart:typed_data'; + +import '../structure/graph.dart'; +import 'traversal.dart'; +import 'traversal_strategy.dart'; + +const int _int32Min = -2147483648; +const int _int32Max = 2147483647; + +class GremlinLang { + String _gremlin = ''; + final List _optionsStrategies; + + GremlinLang([GremlinLang? toClone]) + : _gremlin = toClone?._gremlin ?? '', + _optionsStrategies = List.of(toClone?._optionsStrategies ?? []); + + List getOptionsStrategies() => _optionsStrategies; + + // ------------------------------------------------------------------------- + // Serialisation helpers + // ------------------------------------------------------------------------- + + String _predicateAsString(dynamic p) { + if (p is P) { + if (p.operator == 'and' || p.operator == 'or') { + return '${_predicateAsString(p.value)}.${p.operator}(${_predicateAsString(p.other)})'; + } + final buf = StringBuffer('${p.operator}('); + if (p.value is List) { + buf.write('['); + buf.write((p.value as List).map(_argAsString).join(',')); + buf.write(']'); + } else { + buf.write(_argAsString(p.value)); + if (p.other != null) { + buf.write(',${_argAsString(p.other)}'); + } + } + buf.write(')'); + return buf.toString(); + } + if (p is TextP) { + final buf = StringBuffer('${p.operator}('); + buf.write(_argAsString(p.value)); + if (p.other != null) { + buf.write(',${_argAsString(p.other)}'); + } + buf.write(')'); + return buf.toString(); + } + throw ArgumentError('Expected P or TextP, got ${p.runtimeType}'); + } + + String _argAsString(dynamic arg) { + if (arg == null) return 'null'; + if (arg is bool) return arg ? 'true' : 'false'; + + if (arg is GLong) return '${arg.value}L'; + if (arg is GFloat) return _fpAsString(arg.value, 'F'); + if (arg is GDouble) return _fpAsString(arg.value, 'D'); + if (arg is GShort) return '${arg.value}S'; + if (arg is GByte) return '${arg.value}B'; + if (arg is GInt) return '${arg.value}'; + + if (arg is int) { + if (arg >= _int32Min && arg <= _int32Max) return '$arg'; + return '${arg}L'; + } + if (arg is BigInt) return '${arg}N'; + if (arg is double) { + if (arg.isNaN) return 'NaN'; + if (arg.isInfinite) return arg > 0 ? '+Infinity' : '-Infinity'; + return _fpAsString(arg, 'D'); + } + + if (arg is DateTime) { + return 'datetime("${arg.toUtc().toIso8601String()}")'; + } + + if (arg is String) { + final escaped = arg + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll('\n', r'\n') + .replaceAll('\r', r'\r') + .replaceAll('\t', r'\t'); + return "'$escaped'"; + } + + if (arg is P || arg is TextP) return _predicateAsString(arg); + + if (arg is EnumValue) return arg.toString(); + + if (arg is TraversalStrategy && arg is! OptionsStrategy) { + final name = arg.strategyName; + final entries = arg.configuration.entries.toList(); + if (entries.isEmpty) return name; + final cfg = + entries.map((e) => '${e.key}:${_argAsString(e.value)}').join(','); + return 'new $name($cfg)'; + } + + if (arg is Vertex) return _argAsString(arg.id); + + if (arg is GremlinLang) return arg.getGremlin('__'); + + if (arg is Traversal) { + if (arg.graph != null) { + throw StateError( + 'Child traversal must be anonymous - use __ not g'); + } + return arg.getGremlinLang().getGremlin('__'); + } + + if (arg is Uint8List) { + return 'Binary("${base64.encode(arg)}")'; + } + + if (arg is Set) { + if (arg.isEmpty) return '{}'; + return '{${arg.map(_argAsString).join(',')}}'; + } + + if (arg is Map) { + if (arg.isEmpty) return '[:]'; + final parts = []; + arg.forEach((k, v) { + final ks = _argAsString(k); + final vs = _argAsString(v); + parts.add(k is String ? '$ks:$vs' : '($ks):$vs'); + }); + return '[${parts.join(',')}]'; + } + + if (arg is List) { + return '[${arg.map(_argAsString).join(',')}]'; + } + + throw ArgumentError( + 'GremlinLang cannot represent type ${arg.runtimeType}'); + } + + static String _fpAsString(double v, String suffix) { + if (v.isNaN) return 'NaN'; + if (v.isInfinite) return v > 0 ? '+Infinity' : '-Infinity'; + if (v == v.truncateToDouble()) return '${v.toStringAsFixed(1)}$suffix'; + return '$v$suffix'; + } + + // ------------------------------------------------------------------------- + // Step / source builders + // ------------------------------------------------------------------------- + + GremlinLang addStep(String name, [List? args]) { + final argsStr = args != null && args.isNotEmpty + ? args.map(_argAsString).join(',') + : ''; + _gremlin += '.$name($argsStr)'; + return this; + } + + GremlinLang addSource(String name, [List? args]) { + if (name == 'CardinalityValueTraversal' && args != null) { + final card = args[0] as EnumValue; + _gremlin += 'Cardinality.${card.elementName}(${_argAsString(args[1])})'; + return this; + } + if (name == 'withStrategies' && args != null) { + final nonOptions = []; + for (final s in args) { + if (s is OptionsStrategy) { + _optionsStrategies.add(s); + } else { + nonOptions.add(s); + } + } + if (nonOptions.isNotEmpty) { + final argsStr = nonOptions.map(_argAsString).join(','); + _gremlin += '.withStrategies($argsStr)'; + } + return this; + } + final argsStr = args != null && args.isNotEmpty + ? args.map(_argAsString).join(',') + : ''; + _gremlin += '.$name($argsStr)'; + return this; + } + + // ------------------------------------------------------------------------- + // Accessors + // ------------------------------------------------------------------------- + + String getGremlin([String prefix = 'g']) { + if (_gremlin.isNotEmpty && !_gremlin.startsWith('.')) { + return _gremlin; + } + return '$prefix$_gremlin'; + } + + /// Converts a single value to its gremlin-lang literal representation. + static String valueToGremlinLiteral(dynamic value) => + GremlinLang()._argAsString(value); +} diff --git a/gremlin-dart/lib/process/traversal.dart b/gremlin-dart/lib/process/traversal.dart new file mode 100644 index 00000000000..a1d85313187 --- /dev/null +++ b/gremlin-dart/lib/process/traversal.dart @@ -0,0 +1,299 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 '../structure/graph.dart'; +import 'gremlin_lang.dart'; +import 'traversal_strategy.dart'; + +class Traversal { + final Graph? graph; + final TraversalStrategies? traversalStrategies; + final GremlinLang gremlinLang; + + // Non-private so subclasses and strategies in other files can set them. + Stream? resultsStream; + Future? _strategiesApplied; + + Traversal(this.graph, this.traversalStrategies, + [GremlinLang? gremlinLang]) + : gremlinLang = gremlinLang ?? GremlinLang(); + + GremlinLang getGremlinLang() => gremlinLang; + + Future applyStrategies() { + _strategiesApplied ??= traversalStrategies?.applyStrategies(this) ?? + Future.value(); + return _strategiesApplied!; + } + + Future> toList() async { + await applyStrategies(); + final result = []; + await for (final item in resultsStream ?? const Stream.empty()) { + if (item is Traverser) { + for (int i = 0; i < item.bulk; i++) { + result.add(item.object as T); + } + } else { + result.add(item as T); + } + } + return result; + } + + Future next() async { + await applyStrategies(); + if (resultsStream == null) return null; + // For one-off next(), collect into list then take first; streaming + // callers should use toList() or iterate() for efficiency. + final items = await toList(); + return items.isEmpty ? null : items.first; + } + + Future iterate() async { + gremlinLang.addStep('discard'); + await applyStrategies(); + await resultsStream?.drain(); + } + + @override + String toString() => gremlinLang.getGremlin(); +} + +class RemoteTraversal extends Traversal { + RemoteTraversal(Stream stream) : super(null, null) { + resultsStream = stream; + } +} + +class Traverser { + final T object; + int bulk; + + Traverser(this.object, [int? bulk]) : bulk = bulk ?? 1; +} + +// --------------------------------------------------------------------------- +// Predicate +// --------------------------------------------------------------------------- + +class P { + final String operator; + final dynamic value; + final dynamic other; + + const P(this.operator, this.value, this.other); + + P and_(P arg) => P('and', this, arg); + P or_(P arg) => P('or', this, arg); + + static P within(List args) => P('within', args, null); + static P without(List args) => P('without', args, null); + static P between(dynamic a, dynamic b) => P('between', a, b); + static P eq(dynamic a) => P('eq', a, null); + static P neq(dynamic a) => P('neq', a, null); + static P gt(dynamic a) => P('gt', a, null); + static P gte(dynamic a) => P('gte', a, null); + static P lt(dynamic a) => P('lt', a, null); + static P lte(dynamic a) => P('lte', a, null); + static P inside(dynamic a, dynamic b) => P('inside', a, b); + static P outside(dynamic a, dynamic b) => P('outside', a, b); + static P not_(dynamic a) => P('not', a, null); + static P test(dynamic a) => P('test', a, null); + + @override + String toString() { + if (other == null) return '$operator($value)'; + return '$operator($value, $other)'; + } +} + +class TextP { + final String operator; + final dynamic value; + final dynamic other; + + const TextP(this.operator, this.value, [this.other]); + + P and_(P arg) => P('and', this, arg); + P or_(P arg) => P('or', this, arg); + + static TextP containing(String a) => TextP('containing', a); + static TextP notContaining(String a) => TextP('notContaining', a); + static TextP startingWith(String a) => TextP('startingWith', a); + static TextP notStartingWith(String a) => TextP('notStartingWith', a); + static TextP endingWith(String a) => TextP('endingWith', a); + static TextP notEndingWith(String a) => TextP('notEndingWith', a); + static TextP regex(String a) => TextP('regex', a); + static TextP notRegex(String a) => TextP('notRegex', a); + + @override + String toString() { + if (other == null) return "$operator('$value')"; + return "$operator('$value', '$other')"; + } +} + +// --------------------------------------------------------------------------- +// EnumValue +// --------------------------------------------------------------------------- + +class EnumValue { + final String typeName; + final String elementName; + + const EnumValue(this.typeName, this.elementName); + + @override + String toString() => '$typeName.$elementName'; +} + +// --------------------------------------------------------------------------- +// Numeric wrappers (match JS driver typed values) +// --------------------------------------------------------------------------- + +class GInt { + final int value; + const GInt(this.value); +} + +class GLong { + final int value; + const GLong(this.value); +} + +class GFloat { + final double value; + const GFloat(this.value); +} + +class GDouble { + final double value; + const GDouble(this.value); +} + +class GShort { + final int value; + const GShort(this.value); +} + +class GByte { + final int value; + const GByte(this.value); +} + +class GDecimal { + final int scale; + final BigInt unscaled; + const GDecimal(this.scale, this.unscaled); + + double toDouble() => unscaled.toDouble() / _pow10(scale); + + static double _pow10(int n) { + double r = 1.0; + for (var i = 0; i < n; i++) r *= 10.0; + return r; + } + + @override + String toString() => 'GDecimal($unscaled e-$scale)'; +} + +// --------------------------------------------------------------------------- +// Enum singletons (mirrors JS driver exports) +// --------------------------------------------------------------------------- + +EnumValue _e(String type, String name) => EnumValue(type, name); + +final barrier = (normSack: _e('Barrier', 'normSack'),); +final cardinality = ( + list: _e('Cardinality', 'list'), + set_: _e('Cardinality', 'set'), + single: _e('Cardinality', 'single'), +); +final column = ( + keys: _e('Column', 'keys'), + values: _e('Column', 'values'), +); +final direction = ( + both: _e('Direction', 'BOTH'), + in_: _e('Direction', 'IN'), + out: _e('Direction', 'OUT'), +); +final dt = ( + second: _e('DT', 'second'), + minute: _e('DT', 'minute'), + hour: _e('DT', 'hour'), + day: _e('DT', 'day'), +); +final merge = ( + onCreate: _e('Merge', 'onCreate'), + onMatch: _e('Merge', 'onMatch'), + outV: _e('Merge', 'outV'), + inV: _e('Merge', 'inV'), +); +final operator_ = ( + addAll: _e('Operator', 'addAll'), + and_: _e('Operator', 'and'), + assign: _e('Operator', 'assign'), + div: _e('Operator', 'div'), + max: _e('Operator', 'max'), + min: _e('Operator', 'min'), + minus: _e('Operator', 'minus'), + mult: _e('Operator', 'mult'), + or_: _e('Operator', 'or'), + sum: _e('Operator', 'sum'), + sumLong: _e('Operator', 'sumLong'), +); +final order = ( + asc: _e('Order', 'asc'), + desc: _e('Order', 'desc'), + shuffle: _e('Order', 'shuffle'), +); +final pop = ( + all: _e('Pop', 'all'), + first: _e('Pop', 'first'), + last: _e('Pop', 'last'), + mixed: _e('Pop', 'mixed'), +); +final scope = ( + global: _e('Scope', 'global'), + local: _e('Scope', 'local'), +); +final t = ( + id: _e('T', 'id'), + key: _e('T', 'key'), + label: _e('T', 'label'), + value: _e('T', 'value'), +); +final pick = ( + any: _e('Pick', 'any'), + none: _e('Pick', 'none'), +); + +const withOptions = ( + tokens: '~tinkerpop.valueMap.tokens', + none: 0, + ids: 1, + labels: 2, + keys: 4, + values: 8, + all: 15, + indexer: '~tinkerpop.index.indexer', + list: 0, + map: 1, +); diff --git a/gremlin-dart/lib/process/traversal_strategy.dart b/gremlin-dart/lib/process/traversal_strategy.dart new file mode 100644 index 00000000000..f1c4cb590ff --- /dev/null +++ b/gremlin-dart/lib/process/traversal_strategy.dart @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'gremlin_lang.dart'; +import 'traversal.dart'; + +class TraversalStrategies { + final List strategies; + + TraversalStrategies([TraversalStrategies? parent]) + : strategies = List.of(parent?.strategies ?? []); + + void addStrategy(TraversalStrategy strategy) => strategies.add(strategy); + + void removeStrategy(TraversalStrategy strategy) => + strategies.removeWhere((s) => s.strategyName == strategy.strategyName); + + Future applyStrategies(Traversal traversal) async { + for (final s in strategies) { + await s.apply(traversal); + } + } +} + +abstract class TraversalStrategy { + final String strategyName; + final Map configuration; + + TraversalStrategy({String? strategyName, Map? configuration}) + : strategyName = strategyName ?? '', + configuration = configuration ?? {}; + + Future apply(Traversal traversal) async {} +} + +// --------------------------------------------------------------------------- +// RemoteStrategy — wires a RemoteConnection to a traversal +// --------------------------------------------------------------------------- + +class RemoteStrategy extends TraversalStrategy { + final RemoteConnectionBase connection; + + RemoteStrategy(this.connection) : super(strategyName: 'RemoteStrategy'); + + @override + Future apply(Traversal traversal) async { + if (traversal.resultsStream != null) return; + final remote = await connection.submit(traversal.getGremlinLang()); + traversal.resultsStream = remote.resultsStream; + } +} + +// Forward declaration to avoid circular import — concrete class lives in +// driver/remote_connection.dart. +abstract class RemoteConnectionBase { + Future submit(GremlinLang gremlinLang); +} + +// --------------------------------------------------------------------------- +// Common strategies +// --------------------------------------------------------------------------- + +class OptionsStrategy extends TraversalStrategy { + OptionsStrategy(Map options) + : super(strategyName: 'OptionsStrategy', configuration: options); +} + +class PartitionStrategy extends TraversalStrategy { + PartitionStrategy({ + String? partitionKey, + String? writePartition, + List? readPartitions, + bool? includeMetaProperties, + }) : super( + strategyName: 'PartitionStrategy', + configuration: { + if (partitionKey != null) 'partitionKey': partitionKey, + if (writePartition != null) 'writePartition': writePartition, + if (readPartitions != null) 'readPartitions': readPartitions, + if (includeMetaProperties != null) + 'includeMetaProperties': includeMetaProperties, + }); +} + +class SubgraphStrategy extends TraversalStrategy { + SubgraphStrategy({ + dynamic vertices, + dynamic edges, + dynamic vertexProperties, + bool? checkAdjacentVertices, + }) : super( + strategyName: 'SubgraphStrategy', + configuration: { + if (vertices != null) 'vertices': vertices, + if (edges != null) 'edges': edges, + if (vertexProperties != null) + 'vertexProperties': vertexProperties, + if (checkAdjacentVertices != null) + 'checkAdjacentVertices': checkAdjacentVertices, + }); +} + +class SeedStrategy extends TraversalStrategy { + SeedStrategy({required int seed}) + : super( + strategyName: 'SeedStrategy', + configuration: {'seed': seed}); +} + +class ReadOnlyStrategy extends TraversalStrategy { + ReadOnlyStrategy() : super(strategyName: 'ReadOnlyStrategy'); +} + +class VertexProgramStrategy extends TraversalStrategy { + VertexProgramStrategy(Map options) + : super(strategyName: 'VertexProgramStrategy', configuration: options); +} diff --git a/gremlin-dart/lib/structure/graph.dart b/gremlin-dart/lib/structure/graph.dart new file mode 100644 index 00000000000..e4226c47d86 --- /dev/null +++ b/gremlin-dart/lib/structure/graph.dart @@ -0,0 +1,117 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +class Graph { + @override + String toString() => 'graph[]'; +} + +abstract class Element { + final dynamic id; + final String label; + final List properties; + + const Element(this.id, this.label, [this.properties = const []]); + + @override + bool operator ==(Object other) => + runtimeType == other.runtimeType && other is Element && id == other.id; + + @override + int get hashCode => Object.hash(runtimeType, id); +} + +class Vertex extends Element { + const Vertex(super.id, super.label, [super.properties]); + + @override + String toString() => 'v[$id]'; +} + +class Edge extends Element { + final Vertex outV; + final Vertex inV; + + const Edge(super.id, this.outV, super.label, this.inV, + [super.properties]); + + @override + String toString() => 'e[$id][${outV.id}-$label->${inV.id}]'; +} + +class VertexProperty extends Element { + final dynamic value; + String get key => label; + + const VertexProperty(super.id, super.label, this.value, + [super.properties]); + + @override + String toString() => 'vp[$label->$value]'; +} + +class Property { + final String key; + final T value; + + const Property(this.key, this.value); + + @override + bool operator ==(Object other) => + other is Property && key == other.key && value == other.value; + + @override + int get hashCode => Object.hash(key, value); + + @override + String toString() => 'p[$key->$value]'; +} + +class Path { + final List> labels; + final List objects; + + const Path(this.labels, this.objects); + + @override + String toString() => 'path[${objects.join(', ')}]'; + + @override + bool operator ==(Object other) { + if (other is! Path) return false; + if (identical(this, other)) return true; + if (objects.length != other.objects.length) return false; + for (int i = 0; i < objects.length; i++) { + if (objects[i] != other.objects[i]) return false; + } + if (labels.length != other.labels.length) return false; + for (int i = 0; i < labels.length; i++) { + final a = labels[i], b = other.labels[i]; + if (a.length != b.length) return false; + for (int j = 0; j < a.length; j++) { + if (a[j] != b[j]) return false; + } + } + return true; + } + + @override + int get hashCode => Object.hashAll([ + ...objects, + for (final l in labels) Object.hashAll(l), + ]); +} diff --git a/gremlin-dart/lib/structure/io/graph_binary/data_type.dart b/gremlin-dart/lib/structure/io/graph_binary/data_type.dart new file mode 100644 index 00000000000..a0250089f80 --- /dev/null +++ b/gremlin-dart/lib/structure/io/graph_binary/data_type.dart @@ -0,0 +1,67 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. + +// GraphBinary v4 type codes. +enum DataType { + custom(0x00), + int_(0x01), + long(0x02), + string(0x03), + dateTime(0x04), + timestamp(0x05), + clazz(0x06), + double_(0x07), + float_(0x08), + list(0x09), + map(0x0A), + set_(0x0B), + uuid(0x0C), + edge(0x0D), + path(0x0E), + property(0x0F), + graph(0x10), + vertex(0x11), + vertexProperty(0x12), + direction(0x18), + t(0x20), + bigDecimal(0x22), + bigInt(0x23), + byte_(0x24), + binary(0x25), + short(0x26), + boolean(0x27), + bulkSet(0x2A), + tree(0x2B), + merge(0x2E), + gType(0x30), + char(0x80), + duration(0x81), + compositePdt(0xF0), + primitivePdt(0xF1), + marker(0xFD), + unspecifiedNull(0xFE); + + final int code; + const DataType(this.code); + + static DataType? fromCode(int code) { + for (final dt in values) { + if (dt.code == code) return dt; + } + return null; + } +} diff --git a/gremlin-dart/lib/structure/io/graph_binary/graph_binary_reader.dart b/gremlin-dart/lib/structure/io/graph_binary/graph_binary_reader.dart new file mode 100644 index 00000000000..d819dca9963 --- /dev/null +++ b/gremlin-dart/lib/structure/io/graph_binary/graph_binary_reader.dart @@ -0,0 +1,463 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:convert'; +import 'dart:typed_data'; + +import '../../../process/traversal.dart'; +import '../../../structure/graph.dart'; +import 'data_type.dart'; + +class GraphBinaryReader { + static const String _mimeType = 'application/vnd.graphbinary-v4.0'; + static const int _version = 0x84; + static const Object _endOfStream = Object(); + + String get mimeType => _mimeType; + + Future> readResponse(Uint8List bytes) async { + if (bytes.isEmpty) { + throw ArgumentError('GraphBinary response is empty.'); + } + + final reader = _GraphBinaryValueReader(bytes); + final version = reader.readUint8(); + if (version != _version) { + throw FormatException('Unsupported GraphBinary version: 0x${version.toRadixString(16)}'); + } + + final bulked = reader.readUint8() == 0x01; + final data = []; + while (true) { + final value = reader.readAny(); + if (identical(value, _endOfStream)) break; + + if (bulked) { + // bulk count is a fully-typed value (type + flag + int64), not raw bytes + final bulk = reader.readAny(); + data.add({'v': value, 'bulk': bulk is int ? bulk : (bulk as num).toInt()}); + } else { + data.add(value); + } + } + + final status = _readStatus(reader); + return { + 'status': status, + 'result': {'data': data, 'bulked': bulked}, + }; + } + + Stream readResponseStream(Stream source) async* { + final chunks = []; + await for (final chunk in source) { + chunks.addAll(chunk); + } + final response = await readResponse(Uint8List.fromList(chunks)); + final result = response['result'] as Map; + final data = result['data'] as List; + if (result['bulked'] == true) { + for (final item in data.cast()) { + yield Traverser(item['v'], item['bulk'] as int? ?? 1); + } + } else { + for (final item in data) { + yield item; + } + } + } + + Map _readStatus(_GraphBinaryValueReader reader) { + final code = reader.readInt32(); + final message = reader.readNullableBareString(); + final exception = reader.readNullableBareString(); + return {'code': code, 'message': message, 'exception': exception}; + } +} + +class _GraphBinaryValueReader { + static const int _bulkFlag = 0x02; + + final Uint8List _bytes; + final ByteData _data; + int _offset = 0; + + _GraphBinaryValueReader(this._bytes) + : _data = ByteData.sublistView(_bytes); + + int readUint8() { + _require(1); + return _bytes[_offset++]; + } + + int readInt8() { + _require(1); + return _data.getInt8(_offset++); + } + + int readInt16() { + _require(2); + final value = _data.getInt16(_offset, Endian.big); + _offset += 2; + return value; + } + + int readInt32() { + _require(4); + final value = _data.getInt32(_offset, Endian.big); + _offset += 4; + return value; + } + + int readInt64() { + final value = readBigInt64(); + return value.toInt(); + } + + BigInt readBigInt64() { + _require(8); + BigInt value = BigInt.zero; + for (var i = 0; i < 8; i++) { + value = (value << 8) | BigInt.from(_bytes[_offset + i]); + } + _offset += 8; + if ((_bytes[_offset - 8] & 0x80) != 0) { + value -= BigInt.one << 64; + } + return value; + } + + double readFloat32() { + _require(4); + final value = _data.getFloat32(_offset, Endian.big); + _offset += 4; + return value; + } + + double readFloat64() { + _require(8); + final value = _data.getFloat64(_offset, Endian.big); + _offset += 8; + return value; + } + + Uint8List readBytes(int length) { + if (length < 0) { + throw FormatException('Negative byte length: $length'); + } + _require(length); + final value = Uint8List.sublistView(_bytes, _offset, _offset + length); + _offset += length; + return value; + } + + dynamic readAny() { + final position = _offset; + final typeCode = readUint8(); + final type = DataType.fromCode(typeCode); + if (type == null) { + throw FormatException('Unknown GraphBinary type 0x${typeCode.toRadixString(16)} at $position'); + } + + final valueFlag = readUint8(); + if (valueFlag == 0x01) return null; + if (valueFlag != 0x00 && valueFlag != _bulkFlag) { + throw FormatException('Unexpected value flag 0x${valueFlag.toRadixString(16)} at $position'); + } + + return _readValue(type, valueFlag); + } + + String? readNullableBareString() { + final flag = readUint8(); + if (flag == 0x01) return null; + if (flag != 0x00) { + throw FormatException('Unexpected nullable string flag 0x${flag.toRadixString(16)}'); + } + return _readString(); + } + + dynamic _readValue(DataType type, int valueFlag) { + switch (type) { + case DataType.int_: + return readInt32(); + case DataType.long: + return readInt64(); + case DataType.float_: + return readFloat32(); + case DataType.double_: + return readFloat64(); + case DataType.string: + return _readString(); + case DataType.boolean: + return _readBoolean(); + case DataType.list: + return _readList(valueFlag == _bulkFlag); + case DataType.set_: + return _readSet(valueFlag == _bulkFlag); + case DataType.map: + return _readMap(); + case DataType.uuid: + return _readUuid(); + case DataType.vertex: + return _readVertex(); + case DataType.edge: + return _readEdge(); + case DataType.vertexProperty: + return _readVertexProperty(); + case DataType.property: + return _readProperty(); + case DataType.path: + return _readPath(); + case DataType.bulkSet: + return _readBulkSet(); + case DataType.dateTime: + return _readDateTime(); + case DataType.binary: + return readBytes(readInt32()); + case DataType.byte_: + return readInt8(); + case DataType.short: + return readInt16(); + case DataType.bigInt: + return _readBigInt(); + case DataType.bigDecimal: + return _readBigDecimal(); + case DataType.duration: + return _readDuration(); + case DataType.marker: + final marker = readUint8(); + if (marker != 0x00) { + throw FormatException('Unexpected GraphBinary marker value: $marker'); + } + return GraphBinaryReader._endOfStream; + case DataType.t: + case DataType.direction: + case DataType.merge: + case DataType.gType: + return _readEnum(type); + default: + throw FormatException('Unsupported GraphBinary type: $type'); + } + } + + String _readString() { + final length = readInt32(); + final bytes = readBytes(length); + return utf8.decode(bytes); + } + + bool _readBoolean() { + final value = readUint8(); + if (value != 0x00 && value != 0x01) { + throw FormatException('Unexpected boolean value: $value'); + } + return value == 0x01; + } + + List _readList(bool bulked) { + final length = readInt32(); + if (length < 0) throw FormatException('Negative list length: $length'); + final values = []; + for (var i = 0; i < length; i++) { + final value = readAny(); + if (bulked) { + final bulk = readInt64(); + for (var j = 0; j < bulk; j++) { + values.add(value); + } + } else { + values.add(value); + } + } + return values; + } + + Set _readSet(bool bulked) { + final length = readInt32(); + if (length < 0) throw FormatException('Negative set length: $length'); + final values = {}; + for (var i = 0; i < length; i++) { + final value = readAny(); + if (bulked) readInt64(); + values.add(value); + } + return values; + } + + Map _readMap() { + final length = readInt32(); + if (length < 0) throw FormatException('Negative map length: $length'); + final values = {}; + for (var i = 0; i < length; i++) { + final key = readAny(); + values[key] = readAny(); + } + return values; + } + + List> _readBulkSet() { + final length = readInt32(); + if (length < 0) throw FormatException('Negative bulk set length: $length'); + final values = >[]; + for (var i = 0; i < length; i++) { + values.add(Traverser(readAny(), readInt64())); + } + return values; + } + + String _readUuid() { + final bytes = readBytes(16); + String hex(int start, int end) => + bytes.sublist(start, end).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}'; + } + + Vertex _readVertex() { + final id = readAny(); + final label = _firstLabel(_readList(false)); + final properties = _asProperties(readAny()); + return Vertex(id, label, properties); + } + + Edge _readEdge() { + final id = readAny(); + final label = _firstLabel(_readList(false)); + final inVId = readAny(); + final inVLabel = _firstLabel(_readList(false)); + final outVId = readAny(); + final outVLabel = _firstLabel(_readList(false)); + readAny(); + final properties = _asProperties(readAny()); + return Edge(id, Vertex(outVId, outVLabel), label, Vertex(inVId, inVLabel), properties); + } + + VertexProperty _readVertexProperty() { + final id = readAny(); + final label = _firstLabel(_readList(false)); + final value = readAny(); + readAny(); + final properties = _asProperties(readAny()); + return VertexProperty(id, label, value, properties); + } + + Property _readProperty() { + final key = _readString(); + final value = readAny(); + readAny(); + return Property(key, value); + } + + Path _readPath() { + final labelsValue = readAny(); + final objectsValue = readAny(); + final labels = >[]; + if (labelsValue is List) { + for (final item in labelsValue) { + if (item is Set) { + labels.add(item.map((label) => label.toString()).toList()); + } else if (item is List) { + labels.add(item.map((label) => label.toString()).toList()); + } else { + labels.add([item.toString()]); + } + } + } + final objects = objectsValue is List ? objectsValue : []; + return Path(labels, objects); + } + + DateTime _readDateTime() { + final year = readInt32(); + final month = readUint8(); + final day = readUint8(); + final nanos = readBigInt64(); + final offsetSeconds = readInt32(); + + final hour = (nanos ~/ BigInt.from(3600000000000)).toInt(); + var remaining = nanos.remainder(BigInt.from(3600000000000)); + final minute = (remaining ~/ BigInt.from(60000000000)).toInt(); + remaining = remaining.remainder(BigInt.from(60000000000)); + final second = (remaining ~/ BigInt.from(1000000000)).toInt(); + remaining = remaining.remainder(BigInt.from(1000000000)); + final millisecond = (remaining ~/ BigInt.from(1000000)).toInt(); + remaining = remaining.remainder(BigInt.from(1000000)); + final microsecond = (remaining ~/ BigInt.from(1000)).toInt(); + + return DateTime.utc(year, month, day, hour, minute, second, millisecond, microsecond) + .subtract(Duration(seconds: offsetSeconds)); + } + + BigInt _readBigInt() { + final bytes = readBytes(readInt32()); + if (bytes.isEmpty) return BigInt.zero; + BigInt value = BigInt.zero; + for (final byte in bytes) { + value = (value << 8) | BigInt.from(byte); + } + if ((bytes.first & 0x80) != 0) { + value -= BigInt.one << (bytes.length * 8); + } + return value; + } + + GDecimal _readBigDecimal() { + final scale = readInt32(); + final unscaled = _readBigInt(); + return GDecimal(scale, unscaled); + } + + Duration _readDuration() { + final seconds = readInt64(); + final nanos = readInt32(); + return Duration(seconds: seconds, microseconds: nanos ~/ 1000); + } + + EnumValue _readEnum(DataType type) { + final elementName = readAny() as String; + final typeName = switch (type) { + DataType.direction => 'Direction', + DataType.merge => 'Merge', + DataType.t => 'T', + DataType.gType => 'GType', + _ => 'Enum', + }; + return EnumValue(typeName, elementName); + } + + String _firstLabel(List labels) { + if (labels.isEmpty) return ''; + return labels.first.toString(); + } + + List _asProperties(dynamic value) { + if (value == null) return const []; + if (value is List) { + return value.whereType().toList(); + } + if (value is Map) { + return value.entries.map((entry) => Property(entry.key.toString(), entry.value)).toList(); + } + return const []; + } + + void _require(int count) { + if (_offset + count > _bytes.length) { + throw FormatException('Unexpected end of GraphBinary buffer at $_offset'); + } + } +} diff --git a/gremlin-dart/lib/structure/io/graph_binary/graph_binary_writer.dart b/gremlin-dart/lib/structure/io/graph_binary/graph_binary_writer.dart new file mode 100644 index 00000000000..96ed709a1ef --- /dev/null +++ b/gremlin-dart/lib/structure/io/graph_binary/graph_binary_writer.dart @@ -0,0 +1,355 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:convert'; +import 'dart:typed_data'; + +import 'package:uuid/uuid_value.dart'; + +import '../../../driver/request_message.dart'; +import '../../../process/traversal.dart'; +import '../../../structure/graph.dart'; +import 'data_type.dart'; + +class GraphBinaryWriter { + static const String _mimeType = 'application/vnd.graphbinary-v4.0'; + static const int _version = 0x84; + + String get mimeType => _mimeType; + + Uint8List writeRequest(RequestMessage message) { + final fields = {}; + if (message.language.isNotEmpty) fields['language'] = message.language; + if (message.g != null) fields['g'] = message.g; + if (message.bindings != null) fields['bindings'] = message.bindings; + if (message.timeoutMs != null) fields['evaluationTimeout'] = message.timeoutMs; + if (message.materializeProperties != null) { + fields['materializeProperties'] = message.materializeProperties; + } + if (message.bulkResults != null) fields['bulkResults'] = message.bulkResults; + fields.addAll(message.fields); + + final writer = _GraphBinaryValueWriter(); + writer.addByte(_version); + writer.writeMap(fields, fullyQualified: false); + writer.writeString(message.gremlin, fullyQualified: false); + return writer.takeBytes(); + } +} + +class _GraphBinaryValueWriter { + static const int _int32Min = -2147483648; + static const int _int32Max = 2147483647; + + final BytesBuilder _builder = BytesBuilder(copy: false); + + void addByte(int value) => _builder.addByte(value & 0xff); + + Uint8List takeBytes() => _builder.takeBytes(); + + void writeAny(dynamic value) { + if (value == null) { + addByte(DataType.unspecifiedNull.code); + addByte(0x01); + } else if (value is GInt) { + writeInt32(value.value); + } else if (value is GLong) { + writeInt64(value.value); + } else if (value is GFloat) { + writeFloat(value.value); + } else if (value is GDouble) { + writeDouble(value.value); + } else if (value is GShort) { + writeShort(value.value); + } else if (value is GByte) { + writeByte(value.value); + } else if (value is int) { + if (value >= _int32Min && value <= _int32Max) { + writeInt32(value); + } else { + writeInt64(value); + } + } else if (value is double) { + writeDouble(value); + } else if (value is bool) { + writeBoolean(value); + } else if (value is GDecimal) { + writeGDecimal(value); + } else if (value is BigInt) { + writeBigInt(value); + } else if (value is String) { + writeString(value); + } else if (value is UuidValue) { + writeUuid(value); + } else if (value is DateTime) { + writeDateTime(value); + } else if (value is Uint8List) { + writeBinary(value); + } else if (value is List) { + writeList(value); + } else if (value is Set) { + writeSet(value); + } else if (value is Map) { + writeMap(value); + } else if (value is VertexProperty) { + writeVertexProperty(value); + } else if (value is Vertex) { + writeVertex(value); + } else if (value is Edge) { + writeEdge(value); + } else if (value is Property) { + writeProperty(value); + } else if (value is Path) { + writePath(value); + } else if (value is EnumValue) { + writeEnum(value); + } else { + throw ArgumentError('Unsupported GraphBinary value: ${value.runtimeType}'); + } + } + + void writeInt32(int value, {bool fullyQualified = true}) { + _writeHeader(DataType.int_, fullyQualified); + _writeInt32Bare(value); + } + + void writeInt64(int value, {bool fullyQualified = true}) { + _writeHeader(DataType.long, fullyQualified); + _writeInt64Bare(value); + } + + void writeFloat(double value, {bool fullyQualified = true}) { + _writeHeader(DataType.float_, fullyQualified); + final data = ByteData(4)..setFloat32(0, value, Endian.big); + _builder.add(data.buffer.asUint8List()); + } + + void writeDouble(double value, {bool fullyQualified = true}) { + _writeHeader(DataType.double_, fullyQualified); + final data = ByteData(8)..setFloat64(0, value, Endian.big); + _builder.add(data.buffer.asUint8List()); + } + + void writeShort(int value, {bool fullyQualified = true}) { + _writeHeader(DataType.short, fullyQualified); + final data = ByteData(2)..setInt16(0, value, Endian.big); + _builder.add(data.buffer.asUint8List()); + } + + void writeByte(int value, {bool fullyQualified = true}) { + _writeHeader(DataType.byte_, fullyQualified); + addByte(value); + } + + void writeBoolean(bool value, {bool fullyQualified = true}) { + _writeHeader(DataType.boolean, fullyQualified); + addByte(value ? 0x01 : 0x00); + } + + void writeString(String value, {bool fullyQualified = true}) { + _writeHeader(DataType.string, fullyQualified); + final bytes = utf8.encode(value); + _writeInt32Bare(bytes.length); + _builder.add(bytes); + } + + void writeUuid(UuidValue value, {bool fullyQualified = true}) { + _writeHeader(DataType.uuid, fullyQualified); + _builder.add(value.toBytes(validate: true)); + } + + void writeDateTime(DateTime value, {bool fullyQualified = true}) { + _writeHeader(DataType.dateTime, fullyQualified); + final utc = value.toUtc(); + _writeInt32Bare(utc.year); + addByte(utc.month); + addByte(utc.day); + final nanos = BigInt.from(utc.hour * 3600 + utc.minute * 60 + utc.second) * + BigInt.from(1000000000) + + BigInt.from(utc.millisecond * 1000000 + utc.microsecond * 1000); + _writeInt64BareBig(nanos); + _writeInt32Bare(0); + } + + void writeBinary(Uint8List value, {bool fullyQualified = true}) { + _writeHeader(DataType.binary, fullyQualified); + _writeInt32Bare(value.length); + _builder.add(value); + } + + void writeGDecimal(GDecimal value, {bool fullyQualified = true}) { + _writeHeader(DataType.bigDecimal, fullyQualified); + _writeInt32Bare(value.scale); + final bytes = _bigIntToBytes(value.unscaled); + _writeInt32Bare(bytes.length); + _builder.add(bytes); + } + + void writeBigInt(BigInt value, {bool fullyQualified = true}) { + _writeHeader(DataType.bigInt, fullyQualified); + final bytes = _bigIntToBytes(value); + _writeInt32Bare(bytes.length); + _builder.add(bytes); + } + + Uint8List _bigIntToBytes(BigInt value) { + if (value == BigInt.zero) return Uint8List.fromList([0x00]); + if (value > BigInt.zero) { + final bytes = []; + var v = value; + while (v > BigInt.zero) { + bytes.add((v & BigInt.from(0xFF)).toInt()); + v >>= 8; + } + final result = bytes.reversed.toList(); + // Prepend 0x00 if MSB is set, to preserve positive sign. + if (result[0] & 0x80 != 0) result.insert(0, 0x00); + return Uint8List.fromList(result); + } else { + // Negative: compute minimum-length two's-complement encoding. + int byteCount = 1; + var limit = BigInt.from(0x80); + while (-value > limit) { + byteCount++; + limit <<= 8; + } + var twos = (BigInt.one << (byteCount * 8)) + value; + final bytes = []; + for (var i = 0; i < byteCount; i++) { + bytes.add((twos & BigInt.from(0xFF)).toInt()); + twos >>= 8; + } + return Uint8List.fromList(bytes.reversed.toList()); + } + } + + void writeList(List value, {bool fullyQualified = true}) { + _writeHeader(DataType.list, fullyQualified); + _writeInt32Bare(value.length); + for (final item in value) { + writeAny(item); + } + } + + void writeSet(Set value, {bool fullyQualified = true}) { + _writeHeader(DataType.set_, fullyQualified); + _writeInt32Bare(value.length); + for (final item in value) { + writeAny(item); + } + } + + void writeMap(Map value, {bool fullyQualified = true}) { + _writeHeader(DataType.map, fullyQualified); + _writeInt32Bare(value.length); + for (final entry in value.entries) { + // Map entries are always fully-qualified, even when the map container is + // written as a bare value by a surrounding serializer. + _writeMapEntry(entry.key); + _writeMapEntry(entry.value); + } + } + + void _writeMapEntry(dynamic value) { + writeAny(value); + } + + void writeVertex(Vertex value, {bool fullyQualified = true}) { + _writeHeader(DataType.vertex, fullyQualified); + writeAny(value.id); + writeList([value.label], fullyQualified: false); + writeList(value.properties, fullyQualified: true); + } + + void writeEdge(Edge value, {bool fullyQualified = true}) { + _writeHeader(DataType.edge, fullyQualified); + writeAny(value.id); + writeList([value.label], fullyQualified: false); + writeAny(value.inV.id); + writeList([value.inV.label], fullyQualified: false); + writeAny(value.outV.id); + writeList([value.outV.label], fullyQualified: false); + writeAny(null); + writeList(value.properties, fullyQualified: true); + } + + void writeVertexProperty(VertexProperty value, {bool fullyQualified = true}) { + _writeHeader(DataType.vertexProperty, fullyQualified); + writeAny(value.id); + writeList([value.label], fullyQualified: false); + writeAny(value.value); + writeAny(null); + writeList(value.properties, fullyQualified: true); + } + + void writeProperty(Property value, {bool fullyQualified = true}) { + _writeHeader(DataType.property, fullyQualified); + writeString(value.key, fullyQualified: false); + writeAny(value.value); + writeAny(null); + } + + void writePath(Path value, {bool fullyQualified = true}) { + _writeHeader(DataType.path, fullyQualified); + writeList(value.labels.map((labels) => labels.toSet()).toList()); + writeList(value.objects); + } + + void writeEnum(EnumValue value, {bool fullyQualified = true}) { + final type = switch (value.typeName) { + 'Direction' => DataType.direction, + 'Merge' => DataType.merge, + 'T' => DataType.t, + _ => throw ArgumentError('Unsupported GraphBinary enum: ${value.typeName}'), + }; + _writeHeader(type, fullyQualified); + // Normalize gremlin-lang Direction aliases to canonical Java enum names. + final name = switch (value.elementName) { + 'from' => 'OUT', + 'to' => 'IN', + _ => value.elementName, + }; + writeString(name, fullyQualified: true); + } + + void _writeHeader(DataType type, bool fullyQualified) { + if (fullyQualified) { + addByte(type.code); + addByte(0x00); + } + } + + void _writeInt32Bare(int value) { + final data = ByteData(4)..setInt32(0, value, Endian.big); + _builder.add(data.buffer.asUint8List()); + } + + void _writeInt64Bare(int value) => _writeInt64BareBig(BigInt.from(value)); + + void _writeInt64BareBig(BigInt value) { + var unsigned = value; + if (value.isNegative) { + unsigned += BigInt.one << 64; + } + final bytes = Uint8List(8); + for (var i = 7; i >= 0; i--) { + bytes[i] = (unsigned & BigInt.from(0xff)).toInt(); + unsigned >>= 8; + } + _builder.add(bytes); + } +} diff --git a/gremlin-dart/pubspec.lock b/gremlin-dart/pubspec.lock new file mode 100644 index 00000000000..3ddce8cd1ff --- /dev/null +++ b/gremlin-dart/pubspec.lock @@ -0,0 +1,405 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/gremlin-dart/pubspec.yaml b/gremlin-dart/pubspec.yaml new file mode 100644 index 00000000000..40be94efe2c --- /dev/null +++ b/gremlin-dart/pubspec.yaml @@ -0,0 +1,31 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +name: gremlin_dart +description: Apache TinkerPop Gremlin driver for Dart +version: 0.1.0 +homepage: https://tinkerpop.apache.org + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + uuid: ^4.4.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/gremlin-dart/test/feature/cucumber_world.dart b/gremlin-dart/test/feature/cucumber_world.dart new file mode 100644 index 00000000000..ed182f4f65a --- /dev/null +++ b/gremlin-dart/test/feature/cucumber_world.dart @@ -0,0 +1,68 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'package:gremlin_dart/gremlin_dart.dart'; + +class CucumberWorld { + final String serverUrl; + GraphTraversalSource? g; + String graphName = ''; + String? pendingTraversal; + Map params = {}; + List result = []; + bool resultIsNext = false; + String? errorMessage; + bool ignore = false; + Map graphDataMap; + Set tags = {}; + + CucumberWorld(this.serverUrl, [Map? graphDataMap]) + : graphDataMap = graphDataMap ?? {}; + + Map sideEffects = {}; + + void resetScenario(Set scenarioTags) { + tags = scenarioTags; + graphName = ''; + pendingTraversal = null; + params = {}; + sideEffects = {}; + result = []; + resultIsNext = false; + errorMessage = null; + ignore = false; + g = null; + } +} + +class DataGraph { + final String name; + final DriverRemoteConnection connection; + Map vertices; + Map edges; + Map vertexProperties; + + DataGraph({ + required this.name, + required this.connection, + Map? vertices, + Map? edges, + Map? vertexProperties, + }) : vertices = vertices ?? {}, + edges = edges ?? {}, + vertexProperties = vertexProperties ?? {}; +} diff --git a/gremlin-dart/test/feature/feature_runner.dart b/gremlin-dart/test/feature/feature_runner.dart new file mode 100644 index 00000000000..c0f242a6f14 --- /dev/null +++ b/gremlin-dart/test/feature/feature_runner.dart @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:io'; + +class FeatureFile { + final String path; + final List scenarios; + + const FeatureFile(this.path, this.scenarios); +} + +class FeatureScenario { + final String name; + final Set tags; + final List steps; + + const FeatureScenario(this.name, this.tags, this.steps); +} + +class FeatureStep { + final String keyword; + final String text; + String? docString; + List> table = >[]; + + FeatureStep(this.keyword, this.text); +} + +class FeatureRunner { + FeatureFile parseFile(File file) => + FeatureFile(file.path, parse(file.readAsLinesSync())); + + List parse(List lines) { + final scenarios = []; + final featureTags = {}; + var pendingTags = {}; + FeatureScenarioBuilder? currentScenario; + FeatureStep? currentStep; + var inDocString = false; + final doc = StringBuffer(); + List>? pendingTable; + + void flushTable() { + if (currentStep == null || pendingTable == null || pendingTable!.isEmpty) { + pendingTable = null; + return; + } + final header = pendingTable!.first; + currentStep!.table = pendingTable!.skip(1).map((row) { + final values = {}; + for (var i = 0; i < header.length && i < row.length; i++) { + values[header[i]] = row[i]; + } + return values; + }).toList(); + pendingTable = null; + } + + void flushScenario() { + flushTable(); + if (currentScenario != null) scenarios.add(currentScenario!.build()); + currentScenario = null; + currentStep = null; + } + + for (final raw in lines) { + final line = raw.trim(); + + if (inDocString) { + if (line == '"""') { + currentStep?.docString = doc.toString().trim(); + doc.clear(); + inDocString = false; + } else { + doc.writeln(raw); + } + continue; + } + + if (line.isEmpty || line.startsWith('#')) { + continue; + } + if (line.startsWith('Feature:')) { + featureTags.addAll(pendingTags); + pendingTags = {}; + continue; + } + if (line.startsWith('@')) { + pendingTags.addAll(line + .split(RegExp(r'\s+')) + .where((tag) => tag.isNotEmpty) + .map((tag) => tag.substring(1))); + continue; + } + if (line.startsWith('Scenario:') || line.startsWith('Scenario Outline:')) { + flushScenario(); + final name = line.substring(line.indexOf(':') + 1).trim(); + currentScenario = FeatureScenarioBuilder(name, { + ...featureTags, + ...pendingTags, + }); + pendingTags = {}; + continue; + } + if (line == '"""') { + flushTable(); + inDocString = true; + continue; + } + if (line.startsWith('|')) { + pendingTable ??= >[]; + pendingTable!.add(_parseTableRow(line)); + continue; + } + + final step = _parseStep(line); + if (step != null && currentScenario != null) { + flushTable(); + currentScenario!.steps.add(step); + currentStep = step; + } + } + flushScenario(); + return scenarios; + } + + FeatureStep? _parseStep(String line) { + final match = RegExp(r'^(Given|When|Then|And|But)\s+(.+)$').firstMatch(line); + if (match == null) return null; + return FeatureStep(match.group(1)!, match.group(2)!); + } + + List _parseTableRow(String line) { + final trimmed = line.trim(); + // Split on unescaped | (Gherkin escapes \| as a literal pipe) + final inner = trimmed.substring(1, trimmed.length - 1); + final cells = []; + final buf = StringBuffer(); + for (var i = 0; i < inner.length; i++) { + if (inner[i] == '\\' && i + 1 < inner.length) { + final next = inner[i + 1]; + if (next == '|') { buf.write('|'); i++; } + else if (next == '\\') { buf.write('\\'); i++; } + else if (next == 'n') { buf.write('\n'); i++; } + else { buf.write(inner[i]); } + } else if (inner[i] == '|') { + cells.add(buf.toString().trim()); + buf.clear(); + } else { + buf.write(inner[i]); + } + } + cells.add(buf.toString().trim()); + return cells; + } +} + +class FeatureScenarioBuilder { + final String name; + final Set tags; + final List steps = []; + + FeatureScenarioBuilder(this.name, Set tags) : tags = Set.of(tags); + + FeatureScenario build() => FeatureScenario(name, tags, List.of(steps)); +} diff --git a/gremlin-dart/test/feature/feature_test.dart b/gremlin-dart/test/feature/feature_test.dart new file mode 100644 index 00000000000..4b5a7645b36 --- /dev/null +++ b/gremlin-dart/test/feature/feature_test.dart @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:io'; + +import 'package:test/test.dart'; + +import 'cucumber_world.dart'; +import 'feature_runner.dart'; +import 'graph_setup.dart'; +import 'steps.dart'; + +const defaultFeatureDir = + '../../gremlin-test/src/main/resources/org/apache/tinkerpop/gremlin/test/features'; +const defaultServerUrl = 'http://localhost:45940/gremlin'; + +void main() { + final featureDir = + Platform.environment['CUCUMBER_FEATURE_FOLDER'] ?? defaultFeatureDir; + final serverUrl = + Platform.environment['GREMLIN_SERVER_URL'] ?? defaultServerUrl; + final featureFiles = _discoverFeatureFiles(featureDir); + final runner = FeatureRunner(); + + late Map graphDataMap; + Object? setupError; + + setUpAll(() async { + try { + await GraphSetup(serverUrl).submit('g.inject(1)', 'ggraph'); + graphDataMap = await GraphSetup(serverUrl).loadAllDataGraphs(); + } catch (error) { + setupError = error; + print('Skipping gremlin-dart feature tests: Gremlin Server at ' + '$serverUrl is unreachable or not ready ($error)'); + } + }); + + group('Gremlin language feature tests', () { + for (final file in featureFiles) { + final feature = runner.parseFile(file); + for (final scenario in feature.scenarios) { + test('${_relativePath(file.path)}: ${scenario.name}', () async { + if (setupError != null) { + markTestSkipped('Gremlin Server at $serverUrl is unreachable.'); + return; + } + final world = CucumberWorld(serverUrl, graphDataMap); + await FeatureSteps(world).run(scenario); + }); + } + } + + if (featureFiles.isEmpty) { + test('feature discovery', () { + if (setupError != null) { + markTestSkipped('Gremlin Server at $serverUrl is unreachable.'); + return; + } + fail('No .feature files found under $featureDir'); + }); + } + }); +} + +List _discoverFeatureFiles(String featureDir) { + final dir = _featureDirectory(featureDir); + if (!dir.existsSync()) return []; + final files = dir + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.feature')) + .toList(); + files.sort((a, b) => a.path.compareTo(b.path)); + return files; +} + +Directory _featureDirectory(String featureDir) { + final dir = Directory(featureDir); + if (dir.existsSync() || featureDir.startsWith('/')) return dir; + + final monorepoRelative = Directory('../$featureDir'); + if (monorepoRelative.existsSync()) return monorepoRelative; + + final packageRelative = Directory(featureDir.replaceFirst('../../', '../')); + if (packageRelative.existsSync()) return packageRelative; + + return dir; +} + +String _relativePath(String path) { + final cwd = Directory.current.absolute.path; + final absolute = File(path).absolute.path; + if (absolute.startsWith(cwd)) { + return absolute.substring(cwd.length + 1); + } + return path; +} diff --git a/gremlin-dart/test/feature/graph_setup.dart b/gremlin-dart/test/feature/graph_setup.dart new file mode 100644 index 00000000000..f4142fdcd8c --- /dev/null +++ b/gremlin-dart/test/feature/graph_setup.dart @@ -0,0 +1,162 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'package:gremlin_dart/gremlin_dart.dart'; + +import 'cucumber_world.dart'; + +const graphTraversalSources = { + 'modern': 'gmodern', + 'classic': 'gclassic', + 'crew': 'gcrew', + 'grateful': 'ggrateful', + 'sink': 'gsink', + 'empty': 'ggraph', +}; + +class GraphSetup { + final String serverUrl; + + const GraphSetup(this.serverUrl); + + Future> loadAllDataGraphs() async { + final graphs = {}; + for (final entry in graphTraversalSources.entries) { + final graph = await loadDataGraph(entry.key); + graphs[entry.key] = graph; + } + return graphs; + } + + Future loadDataGraph(String name) async { + final traversalSource = graphTraversalSources[name]!; + final remote = DriverRemoteConnection( + serverUrl, + ConnectionOptions(traversalSource: traversalSource), + ); + final graph = DataGraph(name: name, connection: remote); + if (name == 'empty') return graph; + + graph.vertices = await _vertices(traversalSource); + graph.edges = await _edges(traversalSource); + graph.vertexProperties = await _vertexProperties(traversalSource); + return graph; + } + + Future cleanEmptyGraph() async { + await submit('g.V().drop()', 'ggraph'); + } + + Future> submit( + String traversal, + String traversalSource, [ + Map? params, + Map? sideEffects, + ]) async { + // Strip .iterate() — it is a client-side terminal; the server iterates for us. + var resolved = traversal.replaceAll(RegExp(r'\.iterate\(\)\s*$'), '').trim(); + + // Inject side effects as withSideEffect() calls after the leading "g" + if (sideEffects != null && sideEffects.isNotEmpty) { + final seStr = sideEffects.entries + .map((e) => '.withSideEffect("${e.key}", ${GremlinLang.valueToGremlinLiteral(e.value)})') + .join(); + if (resolved.startsWith('g.') || resolved == 'g') { + resolved = 'g$seStr${resolved.substring(1)}'; + } + } + final connection = Connection( + serverUrl, + ConnectionOptions(traversalSource: traversalSource), + ); + try { + final builder = RequestMessage.build(resolved) + .addG(traversalSource) + .addBulkResults(true); + if (params != null && params.isNotEmpty) { + builder.addBindings(params); + } + return (await connection.submit(builder.create())).items; + } finally { + await connection.close(); + } + } + + Future refreshEmptyGraph(DataGraph graph) async { + graph.vertices = await _vertices('ggraph'); + graph.edges = await _edges('ggraph'); + graph.vertexProperties = await _vertexProperties('ggraph'); + } + + Future> _vertices(String traversalSource) async { + final result = await submit( + 'g.V().group().by("name").by(__.tail())', + traversalSource, + ); + if (result.isEmpty || result.first is! Map) return {}; + return (result.first as Map).map((key, value) { + return MapEntry(key.toString(), value as Vertex); + }); + } + + Future> _edges(String traversalSource) async { + final result = await submit( + 'g.E().group().by(__.project("o","l","i").by(__.outV().values("name")).by(__.label()).by(__.inV().values("name"))).by(__.tail())', + traversalSource, + ); + if (result.isEmpty || result.first is! Map) return {}; + final edges = {}; + (result.first as Map).forEach((key, value) { + if (key is Map) { + edges['${key['o']}-${key['l']}->${key['i']}'] = value as Edge; + } + }); + return edges; + } + + Future> _vertexProperties( + String traversalSource, + ) async { + final result = await submit( + 'g.V().properties().group().by(__.project("n","k","v").by(__.element().values("name")).by(__.key()).by(__.value())).by(__.tail())', + traversalSource, + ); + if (result.isEmpty || result.first is! Map) { + return {}; + } + final vertexProperties = {}; + (result.first as Map).forEach((key, value) { + if (key is Map) { + vertexProperties[_vertexPropertyKey(key)] = value as VertexProperty; + } + }); + return vertexProperties; + } + + String _vertexPropertyKey(Map key) { + final propertyKey = key['k']; + var value = key['v']; + if (propertyKey == 'weight') { + value = 'd[$value].d'; + } else if (propertyKey == 'age' || + propertyKey == 'since' || + propertyKey == 'skill') { + value = 'd[$value].i'; + } + return '${key['n']}-$propertyKey->$value'; + } +} diff --git a/gremlin-dart/test/feature/steps.dart b/gremlin-dart/test/feature/steps.dart new file mode 100644 index 00000000000..243e038be9a --- /dev/null +++ b/gremlin-dart/test/feature/steps.dart @@ -0,0 +1,345 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:typed_data'; + +import 'package:gremlin_dart/gremlin_dart.dart'; +import 'package:test/test.dart'; + +import 'cucumber_world.dart'; +import 'feature_runner.dart'; +import 'graph_setup.dart'; +import 'value_parser.dart'; + +const skipTags = { + 'GraphComputerOnly', + 'AllowNullPropertyValues', + 'StepTree', + 'StepWrite', + 'StepSubgraph', + 'DataChar', + 'WithReservedKeysVerificationStrategy', + // Optional features not supported by beta.2 gremlin-lang string parser + 'SupportsDuration', + 'SupportsBinary', + 'SupportsChar', + 'SupportsBigInteger', + 'SupportsBigDecimal', +}; + +class FeatureSteps { + final CucumberWorld world; + final GraphSetup graphSetup; + + FeatureSteps(this.world) : graphSetup = GraphSetup(world.serverUrl); + + Future run(FeatureScenario scenario) async { + world.resetScenario(scenario.tags); + world.ignore = scenario.tags.any(skipTags.contains); + for (final step in scenario.steps) { + await runStep(step); + } + } + + Future runStep(FeatureStep step) async { + final text = step.text; + if (world.ignore) return; + + final graphMatch = RegExp(r'^the (\w+) graph$').firstMatch(text); + if (graphMatch != null) { + await _chooseGraph(graphMatch.group(1)!); + return; + } + + final parameterMatch = + RegExp(r'^using the parameter (\w+) defined as "(.*)"$').firstMatch(text); + if (parameterMatch != null) { + world.params[parameterMatch.group(1)!] = + ValueParser(world).parse(parameterMatch.group(2)!.replaceAll(r'\"', '"')); + return; + } + + final sideEffectMatch = + RegExp(r'^using the side effect (\w+) defined as "(.*)"$').firstMatch(text); + if (sideEffectMatch != null) { + world.sideEffects[sideEffectMatch.group(1)!] = + ValueParser(world).parse(sideEffectMatch.group(2)!.replaceAll(r'\"', '"')); + return; + } + + if (text == 'the traversal of') { + world.pendingTraversal = step.docString; + return; + } + + if (text == 'the graph initializer of') { + await _submit(step.docString ?? ''); + // Refresh vertex/edge lookups for empty graph so v[name].id params resolve correctly + if (world.graphName == 'empty') { + await graphSetup.refreshEmptyGraph(world.graphDataMap['empty']!); + } + return; + } + + if (text == 'iterated to list') { + await _iterateToList(); + return; + } + + if (text == 'iterated next') { + await _iterateNext(); + return; + } + + if (text == 'the result should be empty') { + _assertNoError(); + expect(world.result, isEmpty); + return; + } + + final countMatch = + RegExp(r'^the result should have a count of (\d+)$').firstMatch(text); + if (countMatch != null) { + _assertNoError(); + final expected = int.parse(countMatch.group(1)!); + var actual = world.result.length; + // For "iterated next", a single Map result means the count is the number + // of map entries (e.g. group() returns one Map whose size is the answer). + if (world.resultIsNext && actual == 1 && world.result.first is Map) { + actual = (world.result.first as Map).length; + } + expect(actual, expected); + return; + } + + if (text == 'the result should be ordered') { + _assertTable(step.table, ordered: true); + return; + } + + if (text == 'the result should be unordered') { + _assertTable(step.table, ordered: false); + return; + } + + if (text == 'the result should be of') { + _assertSubset(step.table); + return; + } + + if (text == 'the traversal will raise an error') { + expect(world.errorMessage, isNotNull); + return; + } + + final errorMsgMatch = RegExp( + r'^the traversal will raise an error with message containing text of "(.*)"$', + ).firstMatch(text); + if (errorMsgMatch != null) { + final expectedText = errorMsgMatch.group(1)!.replaceAll(r'\"', '"'); + if (world.errorMessage == null) { + fail('Expected traversal to raise an error containing "$expectedText" but it succeeded'); + } + expect(world.errorMessage, contains(expectedText), + reason: 'Error message should contain "$expectedText"'); + return; + } + + final graphCountMatch = + RegExp(r'^the graph should return (\d+) for count of "(.*)"$') + .firstMatch(text); + if (graphCountMatch != null) { + await _assertGraphCount( + int.parse(graphCountMatch.group(1)!), + graphCountMatch.group(2)!.replaceAll(r'\"', '"'), + ); + return; + } + + if (text == 'nothing should happen because' || + text == 'an unsupported test' || + text.startsWith('the file ') && text.endsWith(' should exist')) { + world.ignore = text == 'an unsupported test'; + return; + } + + throw UnsupportedError('Unsupported feature step: $text'); + } + + Future _chooseGraph(String graphName) async { + world.graphName = graphName; + final graph = world.graphDataMap[graphName]; + if (graph == null) throw StateError('Unknown graph: $graphName'); + world.g = traversal().withRemote(graph.connection).with_('language', 'gremlin-lang'); + if (graphName == 'empty') { + await graphSetup.cleanEmptyGraph(); + graph.vertices = {}; + graph.edges = {}; + graph.vertexProperties = {}; + } + } + + Future _iterateToList() async { + try { + world.result = await _submit(world.pendingTraversal ?? ''); + world.resultIsNext = false; + world.errorMessage = null; + } catch (error) { + world.result = []; + world.resultIsNext = false; + world.errorMessage = error.toString(); + } + } + + Future _iterateNext() async { + try { + final list = await _submit(world.pendingTraversal ?? ''); + if (list.isEmpty) { + world.result = []; + } else if (list.first is Iterable && list.first is! String && list.first is! Map) { + world.result = List.from(list.first as Iterable); + } else { + world.result = [list.first]; + } + world.resultIsNext = true; + world.errorMessage = null; + } catch (error) { + world.result = []; + world.resultIsNext = false; + world.errorMessage = error.toString(); + } + } + + Future> _submit(String traversalString) { + return graphSetup.submit( + traversalString.trim(), + graphTraversalSources[world.graphName]!, + world.params, + world.sideEffects, + ); + } + + void _assertNoError() { + if (world.errorMessage != null) { + fail(world.errorMessage!); + } + } + + void _assertTable(List> table, {required bool ordered}) { + _assertNoError(); + expect(world.result.length, table.length, reason: 'result: ${world.result}'); + final expected = + table.map((row) => ValueParser(world).parse(row['result']!)).toList(); + if (ordered) { + for (var i = 0; i < expected.length; i++) { + expect(_deepEquals(world.result[i], expected[i]), isTrue, + reason: 'expected ${expected[i]} but got ${world.result[i]}'); + } + return; + } + + final remaining = List.from(world.result); + for (final expectedValue in expected) { + final index = + remaining.indexWhere((actual) => _deepEquals(actual, expectedValue)); + expect(index, isNonNegative, + reason: 'expected $expectedValue in ${world.result}'); + remaining.removeAt(index); + } + expect(remaining, isEmpty); + } + + void _assertSubset(List> table) { + _assertNoError(); + final expected = + table.map((row) => ValueParser(world).parse(row['result']!)).toList(); + for (final actual in world.result) { + expect(expected.any((value) => _deepEquals(actual, value)), isTrue, + reason: 'unexpected result $actual'); + } + } + + Future _assertGraphCount(int count, String traversalString) async { + _assertNoError(); + final result = await _submit('$traversalString.count()'); + expect(result.single, count); + } + + bool _deepEquals(dynamic actual, dynamic expected) { + final a = _normalize(actual); + final b = _normalize(expected); + if (a is double && b is double && a.isNaN && b.isNaN) return true; + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_deepEquals(a[i], b[i])) return false; + } + return true; + } + if (a is Set && b is Set) { + if (a.length != b.length) return false; + final remaining = b.toList(); + for (final value in a) { + final index = remaining.indexWhere((item) => _deepEquals(value, item)); + if (index < 0) return false; + remaining.removeAt(index); + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final aEntry in a.entries) { + // Use deep key equality (Dart's Map.containsKey uses == which fails for List keys) + final bEntry = b.entries.where((e) => _deepEquals(e.key, aEntry.key)).firstOrNull; + if (bEntry == null || !_deepEquals(aEntry.value, bEntry.value)) { + return false; + } + } + return true; + } + if (a is Uint8List && b is Uint8List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } + return a == b; + } + + dynamic _normalize(dynamic value) { + if (value is GInt) return value.value; + if (value is GLong) return value.value; + if (value is GFloat) return value.value; + if (value is GDouble) return value.value; + if (value is GByte) return value.value; + if (value is GShort) return value.value; + if (value is GDecimal) return value.toDouble(); + if (value is EnumValue) return value.toString(); + if (value is Path) return value.objects.map(_normalize).toList(); + if (value is Property) return Property(value.key, _normalize(value.value)); + if (value is VertexProperty) { + return VertexProperty(value.id, value.label, _normalize(value.value)); + } + if (value is List) return value.map(_normalize).toList(); + if (value is Set) return value.map(_normalize).toSet(); + if (value is Map) { + return value.map((key, val) => MapEntry(_normalize(key), _normalize(val))); + } + return value; + } +} diff --git a/gremlin-dart/test/feature/value_parser.dart b/gremlin-dart/test/feature/value_parser.dart new file mode 100644 index 00000000000..166c78ed833 --- /dev/null +++ b/gremlin-dart/test/feature/value_parser.dart @@ -0,0 +1,164 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 'dart:convert'; + +import 'package:gremlin_dart/gremlin_dart.dart'; + +import 'cucumber_world.dart'; + +class ValueParser { + final CucumberWorld world; + + ValueParser(this.world); + + dynamic parse(String literal) { + final value = literal.trim(); + if (value == 'null') return null; + if (value == 'true') return true; + if (value == 'false') return false; + if (value == 'd[NaN]') return double.nan; + if (value == 'd[Infinity]') return double.infinity; + if (value == 'd[-Infinity]') return double.negativeInfinity; + + final numeric = RegExp(r'^d\[(.*)\]\.([ilfdbsnm])$').firstMatch(value); + if (numeric != null) { + final n = numeric.group(1)!; + switch (numeric.group(2)!) { + case 'i': + return GInt(int.parse(n)); + case 'l': + return GLong(int.parse(n)); + case 'f': + return GFloat(double.parse(n)); + case 'd': + return GDouble(double.parse(n)); + case 'b': + return GByte(int.parse(n)); + case 's': + return GShort(int.parse(n)); + case 'n': + return BigInt.parse(n); + case 'm': + return double.parse(n); + } + } + + if (_matches(value, 'str')) return _inner(value, 'str'); + if (_matches(value, 'dt')) return DateTime.parse(_inner(value, 'dt')); + if (_matches(value, 'uuid')) return _inner(value, 'uuid'); + if (_matches(value, 'dur')) return _duration(_inner(value, 'dur')); + if (_matches(value, 'bin')) return base64.decode(_inner(value, 'bin')); + if (_matches(value, 'l')) return _list(_inner(value, 'l')); + if (_matches(value, 's')) return _list(_inner(value, 's')).toSet(); + if (_matches(value, 'm')) return _map(jsonDecode(_inner(value, 'm'))); + if (_matches(value, 'p')) { + return Path(>[[]], _list(_inner(value, 'p'))); + } + if (_matches(value, 't')) return EnumValue('T', _inner(value, 't')); + if (_matches(value, 'D')) return EnumValue('Direction', _inner(value, 'D')); + if (_matches(value, 'M')) return EnumValue('Merge', _inner(value, 'M')); + if (_matches(value, 'prop')) { + final parts = splitTopLevel(_inner(value, 'prop')); + return Property(parts.first, parse(parts.sublist(1).join(','))); + } + if (_matches(value, 'vp')) return _vertexProperty(_inner(value, 'vp')); + + final vertexId = RegExp(r'^v\[(.+)\]\.id$').firstMatch(value); + if (vertexId != null) return _vertex(vertexId.group(1)!).id; + final vertexStringId = RegExp(r'^v\[(.+)\]\.sid$').firstMatch(value); + if (vertexStringId != null) return _vertex(vertexStringId.group(1)!).id.toString(); + if (_matches(value, 'v')) return _vertex(_inner(value, 'v')); + + final edgeId = RegExp(r'^e\[(.+)\]\.id$').firstMatch(value); + if (edgeId != null) return _edge(edgeId.group(1)!).id; + final edgeStringId = RegExp(r'^e\[(.+)\]\.sid$').firstMatch(value); + if (edgeStringId != null) return _edge(edgeStringId.group(1)!).id.toString(); + if (_matches(value, 'e')) return _edge(_inner(value, 'e')); + + return value; + } + + static List splitTopLevel(String value) { + final result = []; + final current = StringBuffer(); + var depth = 0; + for (final rune in value.runes) { + final char = String.fromCharCode(rune); + if (char == '[' || char == '{') { + depth++; + current.write(char); + } else if (char == ']' || char == '}') { + depth--; + current.write(char); + } else if (char == ',' && depth == 0) { + result.add(current.toString().trim()); + current.clear(); + } else { + current.write(char); + } + } + if (current.isNotEmpty) result.add(current.toString().trim()); + return result; + } + + bool _matches(String value, String prefix) => + value.startsWith('$prefix[') && value.endsWith(']'); + + String _inner(String value, String prefix) => + value.substring(prefix.length + 1, value.length - 1); + + Duration _duration(String value) { + final parts = value.split(',').map((part) => part.trim()).toList(); + final seconds = int.parse(parts[0]); + final nanos = int.parse(parts[1]); + final positive = parts.length < 3 || parts[2] == 'true'; + final duration = Duration(seconds: seconds, microseconds: nanos ~/ 1000); + return positive ? duration : -duration; + } + + List _list(String value) { + if (value.isEmpty) return []; + return splitTopLevel(value).map(parse).toList(); + } + + dynamic _map(dynamic value) { + if (value is List) return value.map(_map).toList(); + if (value is Map) { + return value.map((key, val) => MapEntry(_map(key), _map(val))); + } + if (value is String) return parse(value); + return value; + } + + Vertex _vertex(String name) { + final graph = world.graphDataMap[world.graphName]; + return graph?.vertices[name] ?? Vertex(name, 'vertex'); + } + + Edge _edge(String name) { + final edge = world.graphDataMap[world.graphName]?.edges[name]; + if (edge == null) throw StateError('Edge with key $name not found'); + return edge; + } + + VertexProperty _vertexProperty(String name) { + final vp = world.graphDataMap[world.graphName]?.vertexProperties[name]; + if (vp == null) throw StateError('VertexProperty with key $name not found'); + return vp; + } +} diff --git a/gremlin-dart/test/unit/graph_binary_test.dart b/gremlin-dart/test/unit/graph_binary_test.dart new file mode 100644 index 00000000000..5a79bb2324f --- /dev/null +++ b/gremlin-dart/test/unit/graph_binary_test.dart @@ -0,0 +1,288 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:gremlin_dart/driver/request_message.dart'; +import 'package:gremlin_dart/structure/graph.dart'; +import 'package:gremlin_dart/structure/io/graph_binary/data_type.dart'; +import 'package:gremlin_dart/structure/io/graph_binary/graph_binary_reader.dart'; +import 'package:gremlin_dart/structure/io/graph_binary/graph_binary_writer.dart'; +import 'package:test/test.dart'; + +void main() { + group('GraphBinary v4', () { + test('writer encodes a request as binary GraphBinary v4', () { + final message = RequestMessage.build('g.V().has("name",x)') + .addG('g') + .addTimeoutMillis(1234) + .addBulkResults(false) + .addField('materializeProperties', 'tokens') + .create(); + + final bytes = GraphBinaryWriter().writeRequest(message); + expect(bytes.first, 0x84); + expect(utf8.decode(bytes, allowMalformed: true), isNot(startsWith('{'))); + + final reader = _TestReader(bytes); + expect(reader.readUint8(), 0x84); + final fields = reader.readBareMap(); + final gremlin = reader.readBareString(); + + expect(gremlin, 'g.V().has("name",x)'); + expect(fields['language'], 'gremlin-lang'); + expect(fields['g'], 'g'); + expect(fields['evaluationTimeout'], 1234); + expect(fields['bulkResults'], false); + expect(fields['materializeProperties'], 'tokens'); + }); + + test('reader decodes primitive and collection values from a response', () async { + final response = _response([ + _string('marko'), + _int32(29), + _int64(3000000000), + _double(3.14), + _boolean(true), + _nullValue(), + _list([_string('a'), _int32(1)]), + _map({_string('name'): _string('lop'), _string('age'): _int32(5)}), + ]); + + final decoded = await GraphBinaryReader().readResponse(response); + final data = decoded['result']['data'] as List; + + expect(data[0], 'marko'); + expect(data[1], 29); + expect(data[2], 3000000000); + expect(data[3], closeTo(3.14, 0.0000001)); + expect(data[4], true); + expect(data[5], isNull); + expect(data[6], ['a', 1]); + expect(data[7], {'name': 'lop', 'age': 5}); + }); + + test('reader decodes vertex values from a response', () async { + final response = _response([ + _vertex(1, 'person'), + ]); + + final decoded = await GraphBinaryReader().readResponse(response); + final vertex = (decoded['result']['data'] as List).single as Vertex; + + expect(vertex.id, 1); + expect(vertex.label, 'person'); + }); + + test('reader decodes a full response envelope', () async { + final response = _response([ + _string('ok'), + ], statusCode: 206, statusMessage: 'partial', exception: 'x'); + + final decoded = await GraphBinaryReader().readResponse(response); + + expect(decoded['result']['bulked'], false); + expect(decoded['result']['data'], ['ok']); + expect(decoded['status'], { + 'code': 206, + 'message': 'partial', + 'exception': 'x', + }); + }); + }); +} + +Uint8List _response(List values, + {int statusCode = 200, String? statusMessage = 'OK', String? exception}) { + final b = _Bytes(); + b.u8(0x84); + b.u8(0x00); + for (final value in values) { + b.bytes(value); + } + b.u8(DataType.marker.code); + b.u8(0x00); + b.u8(0x00); + b.i32(statusCode); + b.nullableBareString(statusMessage); + b.nullableBareString(exception); + return b.done(); +} + +Uint8List _string(String value) { + final b = _Bytes()..header(DataType.string); + b.bareString(value); + return b.done(); +} + +Uint8List _int32(int value) { + final b = _Bytes()..header(DataType.int_); + b.i32(value); + return b.done(); +} + +Uint8List _int64(int value) { + final b = _Bytes()..header(DataType.long); + b.i64(value); + return b.done(); +} + +Uint8List _double(double value) { + final b = _Bytes()..header(DataType.double_); + b.f64(value); + return b.done(); +} + +Uint8List _boolean(bool value) { + final b = _Bytes()..header(DataType.boolean); + b.u8(value ? 1 : 0); + return b.done(); +} + +Uint8List _nullValue() => Uint8List.fromList([DataType.unspecifiedNull.code, 0x01]); + +Uint8List _list(List values, {bool fullyQualified = true}) { + final b = _Bytes(); + if (fullyQualified) b.header(DataType.list); + b.i32(values.length); + values.forEach(b.bytes); + return b.done(); +} + +Uint8List _map(Map values) { + final b = _Bytes()..header(DataType.map); + b.i32(values.length); + for (final entry in values.entries) { + b.bytes(entry.key); + b.bytes(entry.value); + } + return b.done(); +} + +Uint8List _vertex(int id, String label) { + final b = _Bytes()..header(DataType.vertex); + b.bytes(_int32(id)); + b.bytes(_list([_string(label)], fullyQualified: false)); + b.bytes(_list(const [])); + return b.done(); +} + +class _Bytes { + final BytesBuilder _builder = BytesBuilder(copy: false); + + void header(DataType type) { + u8(type.code); + u8(0x00); + } + + void u8(int value) => _builder.addByte(value & 0xff); + + void i32(int value) { + final data = ByteData(4)..setInt32(0, value, Endian.big); + _builder.add(data.buffer.asUint8List()); + } + + void i64(int value) { + final data = ByteData(8)..setInt64(0, value, Endian.big); + _builder.add(data.buffer.asUint8List()); + } + + void f64(double value) { + final data = ByteData(8)..setFloat64(0, value, Endian.big); + _builder.add(data.buffer.asUint8List()); + } + + void bareString(String value) { + final encoded = utf8.encode(value); + i32(encoded.length); + _builder.add(encoded); + } + + void nullableBareString(String? value) { + if (value == null) { + u8(0x01); + } else { + u8(0x00); + bareString(value); + } + } + + void bytes(Uint8List value) => _builder.add(value); + + Uint8List done() => _builder.takeBytes(); +} + +class _TestReader { + final Uint8List bytes; + late final ByteData data = ByteData.sublistView(bytes); + int offset = 0; + + _TestReader(this.bytes); + + int readUint8() => bytes[offset++]; + + int readInt32() { + final value = data.getInt32(offset, Endian.big); + offset += 4; + return value; + } + + int readInt64() { + final value = data.getInt64(offset, Endian.big); + offset += 8; + return value; + } + + double readFloat64() { + final value = data.getFloat64(offset, Endian.big); + offset += 8; + return value; + } + + String readBareString() { + final length = readInt32(); + final value = utf8.decode(bytes.sublist(offset, offset + length)); + offset += length; + return value; + } + + Map readBareMap() { + final length = readInt32(); + final map = {}; + for (var i = 0; i < length; i++) { + final key = readAny(); + map[key] = readAny(); + } + return map; + } + + dynamic readAny() { + final type = DataType.fromCode(readUint8()); + final flag = readUint8(); + if (flag == 0x01) return null; + switch (type) { + case DataType.int_: + return readInt32(); + case DataType.long: + return readInt64(); + case DataType.double_: + return readFloat64(); + case DataType.string: + return readBareString(); + case DataType.boolean: + return readUint8() == 1; + case DataType.map: + return readBareMap(); + default: + throw StateError('Unsupported test type: $type'); + } + } +} diff --git a/gremlin-dart/test/unit/gremlin_lang_test.dart b/gremlin-dart/test/unit/gremlin_lang_test.dart new file mode 100644 index 00000000000..7916e3e99ec --- /dev/null +++ b/gremlin-dart/test/unit/gremlin_lang_test.dart @@ -0,0 +1,136 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 + +import 'package:test/test.dart'; +import 'package:gremlin_dart/gremlin_dart.dart'; + +void main() { + group('GremlinLang', () { + test('builds basic traversal string', () { + final gl = GremlinLang(); + gl.addStep('V'); + gl.addStep('hasLabel', ['person']); + expect(gl.getGremlin(), 'g.V().hasLabel(\'person\')'); + }); + + test('serialises int32 without suffix', () { + final gl = GremlinLang(); + gl.addStep('has', ['age', P.gt(30)]); + expect(gl.getGremlin(), contains("gt(30)")); + }); + + test('serialises long with L suffix', () { + final gl = GremlinLang(); + gl.addStep('has', ['count', P.gt(GLong(9999999999))]); + expect(gl.getGremlin(), contains('9999999999L')); + }); + + test('serialises string with single quotes', () { + final gl = GremlinLang(); + gl.addStep('hasLabel', ['person']); + expect(gl.getGremlin(), contains("'person'")); + }); + + test('serialises list', () { + final gl = GremlinLang(); + gl.addStep('hasLabel', [['person', 'software']]); + expect(gl.getGremlin(), contains("['person','software']")); + }); + + test('serialises null', () { + final gl = GremlinLang(); + gl.addStep('has', ['age', null]); + expect(gl.getGremlin(), contains('null')); + }); + + test('serialises EnumValue', () { + final gl = GremlinLang(); + gl.addStep('order', [order.asc]); + expect(gl.getGremlin(), contains('Order.asc')); + }); + + test('serialises P.within', () { + final gl = GremlinLang(); + gl.addStep('has', ['age', P.within([1, 2, 3])]); + expect(gl.getGremlin(), contains("within([1,2,3])")); + }); + + }); + + group('P predicates', () { + test('eq', () => expect(P.eq(1).toString(), "eq(1)")); + test('neq', () => expect(P.neq(1).toString(), "neq(1)")); + test('gt', () => expect(P.gt(5).toString(), "gt(5)")); + test('between', () => expect(P.between(1, 10).toString(), "between(1, 10)")); + test('within list', () => expect(P.within([1, 2]).toString(), contains('within'))); + }); + + group('TextP predicates', () { + test('containing', () => expect(TextP.containing('foo').toString(), "containing('foo')")); + test('startingWith', () => expect(TextP.startingWith('bar').toString(), "startingWith('bar')")); + }); + + group('RequestMessage', () { + test('build creates message with gremlin field', () { + final msg = RequestMessage.build('g.V()') + .addG('g') + .addBulkResults(true) + .create(); + expect(msg.gremlin, 'g.V()'); + expect(msg.g, 'g'); + expect(msg.bulkResults, true); + expect(msg.language, 'gremlin-lang'); + }); + + test('toJson includes all set fields', () { + final msg = RequestMessage.build('g.V()') + .addG('g') + .addTimeoutMillis(3000) + .create(); + final json = msg.toJson(); + expect(json['gremlin'], 'g.V()'); + expect(json['g'], 'g'); + expect(json['timeoutMs'], 3000); + }); + + }); + + group('GraphTraversal DSL', () { + late GraphTraversalSource g; + + setUp(() { + // Build a source without a remote connection for string-generation tests + g = GraphTraversalSource(Graph(), TraversalStrategies()); + }); + + test('V() generates g.V()', () { + expect(g.V().toString(), 'g.V()'); + }); + + test('V().hasLabel produces correct string', () { + expect(g.V().hasLabel('person').toString(), "g.V().hasLabel('person')"); + }); + + test('chained steps produce correct string', () { + final t = g.V().out(['knows']).values(['name']); + expect(t.toString(), "g.V().out('knows').values('name')"); + }); + + test('nested traversal via __', () { + final t = g.V().repeat(Anon.out(['knows'])).times(2); + expect(t.toString(), contains("repeat(__.out('knows'))")); + }); + + test('has with P predicate', () { + final t = g.V().has('age', P.gt(30)); + expect(t.toString(), contains("has('age',gt(30))")); + }); + }); +} diff --git a/gremlin-dart/tool/test_live.dart b/gremlin-dart/tool/test_live.dart new file mode 100644 index 00000000000..ce87691b8c9 --- /dev/null +++ b/gremlin-dart/tool/test_live.dart @@ -0,0 +1,51 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 '../lib/driver/connection.dart'; +import '../lib/driver/request_message.dart'; + +/// Manual smoke test — requires a running Gremlin Server (docker compose up). +/// Run with: dart run tool/test_live.dart +void main() async { + // Bindings + await _test('bind int', 'g.V(vid)', 'gmodern', bindings: {'vid': 1}); + await _test('bind string', 'g.V().has("name", n)', 'gmodern', + bindings: {'n': 'marko'}); + + // Sack with different numeric literals + await _test('sack 0.5f', 'g.withSack(2147483647i).inject(0.5f).sack(div).sack()', 'gmodern'); + await _test('sack 0.5D', 'g.withSack(2147483647i).inject(0.5D).sack(div).sack()', 'gmodern'); + + // Conjoin edge cases + await _test('conjoin [null,null]', 'g.inject([null,null]).conjoin("+")', 'ggraph'); + await _test('conjoin null,null', 'g.inject(null, null).conjoin("+")', 'ggraph'); +} + +Future _test(String name, String gremlin, String g, + {Map? bindings}) async { + final builder = RequestMessage.build(gremlin).addG(g).addBulkResults(true); + if (bindings != null) builder.addBindings(bindings); + final c = Connection('http://localhost:45940/gremlin'); + try { + final rs = await c.submit(builder.create()); + print('[$name] → ${rs.items}'); + } catch (e) { + print('[$name] ERROR: $e'); + } finally { + await c.close(); + } +} diff --git a/pom.xml b/pom.xml index 732f644dff9..8862c56dd73 100644 --- a/pom.xml +++ b/pom.xml @@ -566,6 +566,7 @@ limitations under the License. **/gremlinpython.egg-info/** **/docfx/** **/go.sum + **/pubspec.lock **/coverage.out **/gremlinconsoletest.egg-info/**