diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 153f368f..d0d96b44 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -1,4 +1,5 @@ import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// MongoDB connection configuration and state. class MongoConnection { @@ -119,7 +120,7 @@ class MongoConnection { } try { - final uri = buildConnectionUri(); + final uri = await _effectiveMongoUri(); _db = await Db.create(uri); await _db!.open(); _isConnected = true; @@ -130,6 +131,34 @@ class MongoConnection { } } + Future _effectiveMongoUri() async { + final base = buildConnectionUri(); + final parsed = Uri.parse(base); + final paths = extractSslCertificatePaths(parsed); + final params = Map.from(parsed.queryParameters); + params.remove(kSslRootCertParam); + params.remove(kSslCertParam); + params.remove(kSslKeyParam); + + if (paths.rootCert != null && paths.rootCert!.trim().isNotEmpty) { + params[kMongoTlsCaFileParam] = paths.rootCert!.trim(); + } + final clientPem = await resolveMongoTlsCertificateKeyFile( + clientCert: paths.clientCert, + clientKey: paths.clientKey, + ); + if (clientPem != null) { + params[kMongoTlsCertificateKeyFileParam] = clientPem; + } + if (useSSL || paths.hasAny) { + params['ssl'] = 'true'; + } + + return parsed + .replace(queryParameters: params.isEmpty ? null : params) + .toString(); + } + /// Disconnects from MongoDB server. Future disconnect() async { _isConnected = false; diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 80cd70b9..c7a3700e 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// Replaces the database in a `mysql://` / `mariadb://` URI (path or `database=`). String replaceDatabaseInMysqlConnectionString( @@ -98,16 +99,22 @@ class MysqlConnection { ) : connectionString!.trim(); final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); + final sslPaths = extractSslCertificatePathsFromString(uriStr); + final securityContext = buildSecurityContext(sslPaths); _conn = await MySQLConnection.createConnection( host: parsed.host, port: parsed.port, userName: parsed.userName, password: parsed.password, - secure: parsed.secure, + secure: parsed.secure || sslPaths.hasAny, databaseName: parsed.databaseName, + securityContext: securityContext, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { + final securityContext = buildSecurityContext( + extractSslCertificatePathsFromString(connectionString), + ); _conn = await MySQLConnection.createConnection( host: host, port: port, @@ -115,6 +122,7 @@ class MysqlConnection { password: pass, secure: useSSL, databaseName: database, + securityContext: securityContext, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } @@ -174,6 +182,11 @@ class MysqlConnection { if (ssl == 'require' || ssl == 'verify_ca' || ssl == 'verify_identity') { secure = true; } + if (q.containsKey(kSslRootCertParam) || + q.containsKey(kSslCertParam) || + q.containsKey(kSslKeyParam)) { + secure = true; + } return ( host: hostStr, @@ -185,6 +198,15 @@ class MysqlConnection { ); } + /// Whether [connectionString] implies a TLS session (including cert query params). + @visibleForTesting + static bool connectionStringRequiresSsl( + String connectionString, { + bool fallbackSsl = true, + }) { + return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl).secure; + } + Future disconnect() async { _isConnected = false; final c = _conn; diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 451e1bba..c9548386 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,4 +1,8 @@ +import 'dart:io'; + import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:redis/redis.dart' as redis; /// Redis connection using the Dart redis package (no Java/JRE). @@ -10,14 +14,57 @@ class RedisConnection { this.port = 6379, this.username, this.password, + this.useSSL = false, + this.connectionString, }); + factory RedisConnection.fromConnectionRow(ConnectionRow row) { + final uriText = row.connectionString?.trim(); + if (uriText != null && uriText.isNotEmpty) { + final parsed = Uri.parse(uriText); + final info = parsed.userInfo; + String? user; + String? pass; + if (info.isNotEmpty) { + final colon = info.indexOf(':'); + if (colon >= 0) { + user = Uri.decodeComponent(info.substring(0, colon)); + pass = Uri.decodeComponent(info.substring(colon + 1)); + } else { + user = Uri.decodeComponent(info); + } + } + return RedisConnection( + id: row.id ?? 0, + name: row.name, + host: parsed.host.isEmpty ? (row.host ?? 'localhost') : parsed.host, + port: parsed.hasPort ? parsed.port : (row.port ?? 6379), + username: user ?? row.username, + password: pass ?? row.password, + useSSL: row.useSSL || parsed.scheme == 'rediss', + connectionString: uriText, + ); + } + return RedisConnection( + id: row.id ?? 0, + name: row.name, + host: row.host ?? 'localhost', + port: row.port ?? 6379, + username: row.username, + password: row.password, + useSSL: row.useSSL, + connectionString: row.connectionString, + ); + } + final int id; final String name; final String host; final int port; final String? username; final String? password; + final bool useSSL; + final String? connectionString; redis.RedisConnection? _conn; redis.Command? _command; @@ -28,7 +75,19 @@ class RedisConnection { Future connect() async { if (_isConnected && _command != null) return; _conn = redis.RedisConnection(); - _command = await _conn!.connect(host, port); + final sslPaths = extractSslCertificatePathsFromString(connectionString); + final secure = useSSL || sslPaths.hasAny; + if (secure) { + final context = buildSecurityContext(sslPaths); + final socket = await SecureSocket.connect( + host, + port, + context: context, + ); + _command = await _conn!.connectWithSocket(socket); + } else { + _command = await _conn!.connect(host, port); + } if (password != null && password!.isNotEmpty) { if (username != null && username!.trim().isNotEmpty) { await _command!.send_object(['AUTH', username!.trim(), password!]); @@ -308,7 +367,12 @@ class RedisConnectionTestFake extends RedisConnection { this.firstScanKeys = const ['alpha', 'beta'], this.secondScanKeys = const [], this.dbSizeResult = 2, - }) : super(id: -1, name: 'test-fake', host: 'localhost', port: 6379); + }) : super( + id: -1, + name: 'test-fake', + host: 'localhost', + port: 6379, + ); final List firstScanKeys; final List secondScanKeys; diff --git a/lib/core/database/redis_service.dart b/lib/core/database/redis_service.dart index e8251ba4..0b7bf2c2 100644 --- a/lib/core/database/redis_service.dart +++ b/lib/core/database/redis_service.dart @@ -23,14 +23,7 @@ class RedisService { existing.disconnect(); // fire-and-forget; disconnect is safe } - final conn = RedisConnection( - id: id, - name: row.name, - host: row.host ?? 'localhost', - port: row.port ?? 6379, - username: row.username, - password: row.password, - ); + final conn = RedisConnection.fromConnectionRow(row); _connections[id] = conn; return conn; } diff --git a/lib/features/connections/ssl_certificate_support.dart b/lib/features/connections/ssl_certificate_support.dart new file mode 100644 index 00000000..3d304331 --- /dev/null +++ b/lib/features/connections/ssl_certificate_support.dart @@ -0,0 +1,171 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; + +/// Querya-standard SSL certificate query parameters (aligned with PostgreSQL). +const kSslRootCertParam = 'sslrootcert'; +const kSslCertParam = 'sslcert'; +const kSslKeyParam = 'sslkey'; + +/// MongoDB driver-native TLS file parameters. +const kMongoTlsCaFileParam = 'tlsCAFile'; +const kMongoTlsCertificateKeyFileParam = 'tlsCertificateKeyFile'; + +class SslCertificatePaths { + const SslCertificatePaths({ + this.rootCert, + this.clientCert, + this.clientKey, + }); + + final String? rootCert; + final String? clientCert; + final String? clientKey; + + bool get hasAny => + _nonEmpty(rootCert) || _nonEmpty(clientCert) || _nonEmpty(clientKey); + + static bool _nonEmpty(String? value) => value != null && value.trim().isNotEmpty; +} + +SslCertificatePaths extractSslCertificatePaths(Uri uri) { + return SslCertificatePaths( + rootCert: uri.queryParameters[kSslRootCertParam], + clientCert: uri.queryParameters[kSslCertParam], + clientKey: uri.queryParameters[kSslKeyParam], + ); +} + +SslCertificatePaths extractSslCertificatePathsFromString(String? raw) { + if (raw == null || raw.trim().isEmpty) return const SslCertificatePaths(); + final uri = Uri.tryParse(raw.trim()); + if (uri == null) return const SslCertificatePaths(); + return extractSslCertificatePaths(uri); +} + +Map sslCertificateQueryParams(SslCertificatePaths paths) { + final params = {}; + if (SslCertificatePaths._nonEmpty(paths.rootCert)) { + params[kSslRootCertParam] = paths.rootCert!.trim(); + } + if (SslCertificatePaths._nonEmpty(paths.clientCert)) { + params[kSslCertParam] = paths.clientCert!.trim(); + } + if (SslCertificatePaths._nonEmpty(paths.clientKey)) { + params[kSslKeyParam] = paths.clientKey!.trim(); + } + return params; +} + +Uri applySslCertificatePaths(Uri uri, SslCertificatePaths paths) { + final params = Map.from(uri.queryParameters); + for (final key in [kSslRootCertParam, kSslCertParam, kSslKeyParam]) { + params.remove(key); + } + params.addAll(sslCertificateQueryParams(paths)); + return uri.replace(queryParameters: params.isEmpty ? null : params); +} + +void setOrRemoveSslParam( + Map params, + String key, + String value, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + params.remove(key); + } else { + params[key] = trimmed; + } +} + +Uri syncSslParamsIntoUri(String uriText, SslCertificatePaths paths) { + final parsed = Uri.tryParse(uriText.trim()); + if (parsed == null) return Uri(); + return applySslCertificatePaths(parsed, paths); +} + +SecurityContext? buildSecurityContext(SslCertificatePaths paths) { + if (!paths.hasAny) return null; + final context = SecurityContext(); + if (SslCertificatePaths._nonEmpty(paths.clientCert)) { + context.useCertificateChain(paths.clientCert!.trim()); + } + if (SslCertificatePaths._nonEmpty(paths.clientKey)) { + context.usePrivateKey(paths.clientKey!.trim()); + } + if (SslCertificatePaths._nonEmpty(paths.rootCert)) { + context.setTrustedCertificates(paths.rootCert!.trim()); + } + return context; +} + +Future pickSslCertificateFile({ + required void Function(String path) onPicked, +}) async { + const typeGroup = XTypeGroup( + label: 'PEM files', + extensions: ['pem', 'crt', 'key', 'cer'], + ); + final file = await openFile(acceptedTypeGroups: const [typeGroup]); + if (file == null) return; + onPicked(file.path); +} + +/// Maps Querya [sslrootcert]/[sslcert]/[sslkey] params to mongo_dart URI params. +Uri translateQueryaSslParamsForMongo(Uri uri) { + final params = Map.from(uri.queryParameters); + final root = params.remove(kSslRootCertParam); + final cert = params.remove(kSslCertParam); + final key = params.remove(kSslKeyParam); + if (root != null && root.isNotEmpty) { + params[kMongoTlsCaFileParam] = root; + } + if (cert != null && cert.isNotEmpty) { + params[kMongoTlsCertificateKeyFileParam] = cert; + } + if (key != null && key.isNotEmpty) { + params[kSslKeyParam] = key; + } + return uri.replace(queryParameters: params.isEmpty ? null : params); +} + +/// Resolves a client PEM path for mongo_dart when cert and key are separate files. +Future resolveMongoTlsCertificateKeyFile({ + required String? clientCert, + required String? clientKey, +}) async { + final certPath = clientCert?.trim(); + final keyPath = clientKey?.trim(); + if (certPath == null || certPath.isEmpty) return null; + if (keyPath == null || keyPath.isEmpty) return certPath; + + final certBytes = await File(certPath).readAsString(); + final keyBytes = await File(keyPath).readAsString(); + final dir = await Directory.systemTemp.createTemp('querya_mongo_tls_'); + final merged = File('${dir.path}/client.pem'); + await merged.writeAsString('$certBytes\n$keyBytes\n'); + return merged.path; +} + +String buildRedisConnectionUri({ + required String host, + required int port, + String? username, + String? password, + bool useSSL = false, + SslCertificatePaths sslPaths = const SslCertificatePaths(), +}) { + final userInfoParts = [ + if (username != null && username.isNotEmpty) Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + ]; + final queryParams = sslCertificateQueryParams(sslPaths); + return Uri( + scheme: useSSL ? 'rediss' : 'redis', + userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), + host: host, + port: port, + queryParameters: queryParams.isEmpty ? null : queryParams, + ).toString(); +} diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 23566d10..dd553dc8 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// MongoDB connection form data. @@ -75,6 +77,9 @@ class _MongoConnectionFormContentState final _databaseController = material.TextEditingController(); final _authSourceController = material.TextEditingController(); final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); bool _useConnectionString = false; bool _useSSL = false; @@ -96,12 +101,85 @@ class _MongoConnectionFormContentState ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } + void _populateSslFieldsFromUri() { + populateSslControllersFromUri( + _connectionStringController.text, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + void _syncUriSslParams() { + syncSslControllersIntoUri( + _connectionStringController, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + _formValidNotifier.seed(); + } + + bool _hasSslCertificateFields() { + return hasSslCertificateControllerValues( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + String _buildConnectionUri() { + final paths = sslPathsFromControllers( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + final user = _usernameController.text.trim(); + final pass = _passwordController.text; + final db = _databaseController.text.trim(); + final authSource = _authSourceController.text.trim(); + final userInfoParts = [ + if (user.isNotEmpty) Uri.encodeComponent(user), + if (pass.isNotEmpty) Uri.encodeComponent(pass), + ]; + final params = { + ...sslCertificateQueryParams(paths), + if (authSource.isNotEmpty) 'authSource': authSource, + if (_useSSL || paths.hasAny) 'ssl': 'true', + }; + return Uri( + scheme: 'mongodb', + userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 27017, + path: db.isEmpty ? null : '/$db', + queryParameters: params.isEmpty ? null : params, + ).toString(); + } + + String? _effectiveConnectionString() { + final uri = _connectionStringController.text.trim(); + if (_useConnectionString) { + return uri.isEmpty ? null : uri; + } + if (_hasSslCertificateFields()) return _buildConnectionUri(); + return null; + } + @override void dispose() { _dismissTimer?.cancel(); + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); for (final c in [ _nameController, _hostController, @@ -119,6 +197,9 @@ class _MongoConnectionFormContentState _databaseController.dispose(); _authSourceController.dispose(); _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -137,10 +218,8 @@ class _MongoConnectionFormContentState authSource: _authSourceController.text.trim().isEmpty ? null : _authSourceController.text.trim(), - useSSL: _useSSL, - connectionString: _connectionStringController.text.trim().isEmpty - ? null - : _connectionStringController.text.trim(), + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: _effectiveConnectionString(), ); void _showTestResult(String result) { @@ -193,6 +272,7 @@ class _MongoConnectionFormContentState } void _save() { + _syncUriSslParams(); final data = _formData; if (!data.isValid) return; @@ -292,6 +372,27 @@ class _MongoConnectionFormContentState 'mongodb://username:password@host:port/database'), maxLines: 2, ), + const Gap(16), + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ] else ...[ // Connection name const Text('Connection Name').small().semiBold(), @@ -432,6 +533,15 @@ class _MongoConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], ], ), diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 7eef3b28..b597ef75 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows MySQL / MariaDB connection form dialog. @@ -42,6 +44,9 @@ class _MysqlConnectionFormContentState final _usernameController = material.TextEditingController(text: 'root'); final _passwordController = material.TextEditingController(); final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); bool _useSSL = true; bool _showPassword = false; @@ -64,9 +69,74 @@ class _MysqlConnectionFormContentState ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } + void _populateSslFieldsFromUri() { + populateSslControllersFromUri( + _connectionStringController.text, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + void _syncUriSslParams() { + syncSslControllersIntoUri( + _connectionStringController, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + _formValidNotifier.seed(); + } + + bool _hasSslCertificateFields() { + return hasSslCertificateControllerValues( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + String _buildConnectionUri() { + final paths = sslPathsFromControllers( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + final user = _usernameController.text.trim(); + final pass = _passwordController.text; + final db = _databaseController.text.trim(); + final userInfoParts = [ + if (user.isNotEmpty) Uri.encodeComponent(user), + if (pass.isNotEmpty) Uri.encodeComponent(pass), + ]; + final params = { + ...sslCertificateQueryParams(paths), + if (!_useSSL) 'ssl-mode': 'disable', + }; + return Uri( + scheme: 'mysql', + userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 3306, + path: db.isEmpty ? null : '/$db', + queryParameters: params.isEmpty ? null : params, + ).toString(); + } + + String _effectiveConnectionUri() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return uri; + if (!_hasSslCertificateFields()) return ''; + return _buildConnectionUri(); + } + bool _looksLikeMysqlUri(String s) { final t = s.trim().toLowerCase(); return t.startsWith('mysql://') || t.startsWith('mariadb://'); @@ -107,7 +177,7 @@ class _MysqlConnectionFormContentState _testResult = null; }); try { - final uri = _connectionStringController.text.trim(); + final uri = _effectiveConnectionUri(); final dbText = _databaseController.text.trim(); final conn = MysqlConnection( id: 0, @@ -122,7 +192,7 @@ class _MysqlConnectionFormContentState : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, - useSSL: _useSSL, + useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, ); final ok = await conn.testConnection(); @@ -134,11 +204,12 @@ class _MysqlConnectionFormContentState void _save() { if (!_formValidNotifier.value) return; + _syncUriSslParams(); final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 3306; final database = _databaseController.text.trim(); - final uri = _connectionStringController.text.trim(); + final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : (uri.isNotEmpty @@ -156,7 +227,7 @@ class _MysqlConnectionFormContentState _passwordController.text.isEmpty ? null : _passwordController.text, databaseName: uri.isNotEmpty ? null : (database.isEmpty ? null : database), - useSSL: _useSSL, + useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, folderId: widget.folderId, createdAt: DateTime.now().toUtc().toIso8601String(), @@ -167,6 +238,10 @@ class _MysqlConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); for (final c in [ _nameController, _hostController, @@ -185,6 +260,9 @@ class _MysqlConnectionFormContentState _usernameController.dispose(); _passwordController.dispose(); _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -261,7 +339,8 @@ class _MysqlConnectionFormContentState ).muted().small(), const Gap(4), const Text( - 'Query params: ssl-mode (disable, require), database.', + 'Query params: ssl-mode (disable, require), database, ' + 'sslrootcert, sslcert, sslkey.', ).muted().small(), const Gap(8), TextField( @@ -364,6 +443,15 @@ class _MysqlConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], ), ), diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index eca2fd61..cc92c05e 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows Redis connection form dialog. Returns ConnectionRow if saved, null if cancelled. @@ -39,7 +41,12 @@ class _RedisConnectionFormContentState final _portController = material.TextEditingController(text: '6379'); final _usernameController = material.TextEditingController(); final _passwordController = material.TextEditingController(); + final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); + bool _useSSL = false; bool _showPassword = false; bool _isTesting = false; String? _testResult; @@ -50,13 +57,82 @@ class _RedisConnectionFormContentState void initState() { super.initState(); _formValidNotifier = FormValidityNotifier(_computeFormValid); - for (final c in [_nameController, _hostController, _portController]) { + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } + bool _looksLikeRedisUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('redis://') || t.startsWith('rediss://'); + } + + void _populateSslFieldsFromUri() { + populateSslControllersFromUri( + _connectionStringController.text, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty && _looksLikeRedisUri(uri)) { + final parsed = Uri.parse(uri); + if (parsed.scheme == 'rediss') _useSSL = true; + } + } + + void _syncUriSslParams() { + syncSslControllersIntoUri( + _connectionStringController, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + _formValidNotifier.seed(); + } + + bool _hasSslCertificateFields() { + return hasSslCertificateControllerValues( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + String _effectiveConnectionUri() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return uri; + if (!_useSSL && !_hasSslCertificateFields()) return ''; + return buildRedisConnectionUri( + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 6379, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: + _passwordController.text.isEmpty ? null : _passwordController.text, + useSSL: _useSSL || _hasSslCertificateFields(), + sslPaths: sslPathsFromControllers( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ), + ); + } + bool _computeFormValid() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return _looksLikeRedisUri(uri); final host = _hostController.text.trim(); return host.isNotEmpty && (_nameController.text.trim().isNotEmpty || host.isNotEmpty); @@ -88,18 +164,21 @@ class _RedisConnectionFormContentState _testResult = null; }); try { + final uri = _effectiveConnectionUri(); final conn = RedisConnection( id: 0, name: _nameController.text.trim().isEmpty ? 'test' : _nameController.text.trim(), - host: _hostController.text.trim(), + host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), port: int.tryParse(_portController.text.trim()) ?? 6379, username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: uri.isEmpty ? null : uri, ); final ok = await conn.testConnection(); if (mounted) _showTestResult(ok ? 'success' : 'failed'); @@ -110,20 +189,24 @@ class _RedisConnectionFormContentState void _save() { if (!_formValidNotifier.value) return; + _syncUriSslParams(); final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 6379; + final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; final row = ConnectionRow( type: 'redis', name: displayName, - host: host, - port: port, + host: uri.isNotEmpty ? null : host, + port: uri.isNotEmpty ? null : port, username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: uri.isEmpty ? null : uri, folderId: widget.folderId, createdAt: DateTime.now().toUtc().toIso8601String(), ); @@ -133,7 +216,16 @@ class _RedisConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - for (final c in [_nameController, _hostController, _portController]) { + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { _formValidNotifier.unlistenFrom(c); } _formValidNotifier.dispose(); @@ -142,6 +234,10 @@ class _RedisConnectionFormContentState _portController.dispose(); _usernameController.dispose(); _passwordController.dispose(); + _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -154,7 +250,7 @@ class _RedisConnectionFormContentState constraints: WindowLayout.dialogConstraints( context, maxWidth: 600, - maxHeight: 560, + maxHeight: 640, ), decoration: material.BoxDecoration( color: theme.popover, @@ -198,6 +294,18 @@ class _RedisConnectionFormContentState placeholder: const Text('My Redis Server'), ), const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + const Text( + 'Use redis:// or rediss://. Query params: sslrootcert, ' + 'sslcert, sslkey.', + ).muted().small(), + const Gap(8), + TextField( + controller: _connectionStringController, + placeholder: const Text('rediss://user:pass@host:6379'), + ), + const Gap(16), material.Row( children: [ material.Expanded( @@ -276,6 +384,27 @@ class _RedisConnectionFormContentState ), ], ), + const Gap(16), + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], ), ), diff --git a/lib/shared/widgets/ssl_certificate_fields.dart b/lib/shared/widgets/ssl_certificate_fields.dart new file mode 100644 index 00000000..7b21480f --- /dev/null +++ b/lib/shared/widgets/ssl_certificate_fields.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Optional SSL certificate path fields (Root CA, client cert, client key). +class SslCertificateFields extends material.StatelessWidget { + const SslCertificateFields({ + super.key, + required this.rootCertController, + required this.clientCertController, + required this.clientKeyController, + this.onChanged, + }); + + final material.TextEditingController rootCertController; + final material.TextEditingController clientCertController; + final material.TextEditingController clientKeyController; + final VoidCallback? onChanged; + + @override + material.Widget build(material.BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('SSL Certificates (optional)').small().semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are appended to the ' + 'connection URI.', + ).muted().small(), + const Gap(8), + _SslFileField( + label: 'Root CA / SSL Root Certificate', + controller: rootCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Certificate', + controller: clientCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Key', + controller: clientKeyController, + onChanged: onChanged, + ), + ], + ); + } +} + +class _SslFileField extends material.StatelessWidget { + const _SslFileField({ + required this.label, + required this.controller, + this.onChanged, + }); + + final String label; + final material.TextEditingController controller; + final VoidCallback? onChanged; + + @override + material.Widget build(material.BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).xSmall().muted(), + const Gap(4), + material.Row( + children: [ + material.Expanded( + child: TextField( + key: Key(label), + controller: controller, + placeholder: const Text('/path/to/file.pem'), + onChanged: onChanged == null ? null : (_) => onChanged!(), + ), + ), + const Gap(8), + GhostButton( + onPressed: () async { + await pickSslCertificateFile( + onPicked: (path) { + controller.text = path; + onChanged?.call(); + }, + ); + }, + child: const Icon(material.Icons.folder_open_rounded), + ), + ], + ), + ], + ); + } +} + +void populateSslControllersFromUri( + String uriText, { + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + if (uriText.trim().isEmpty) return; + final parsed = Uri.tryParse(uriText.trim()); + if (parsed == null) return; + final paths = extractSslCertificatePaths(parsed); + rootCertController.text = paths.rootCert ?? ''; + clientCertController.text = paths.clientCert ?? ''; + clientKeyController.text = paths.clientKey ?? ''; +} + +bool hasSslCertificateControllerValues({ + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + return rootCertController.text.trim().isNotEmpty || + clientCertController.text.trim().isNotEmpty || + clientKeyController.text.trim().isNotEmpty; +} + +SslCertificatePaths sslPathsFromControllers({ + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + return SslCertificatePaths( + rootCert: rootCertController.text.trim(), + clientCert: clientCertController.text.trim(), + clientKey: clientKeyController.text.trim(), + ); +} + +void syncSslControllersIntoUri( + material.TextEditingController connectionStringController, { + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + final uriText = connectionStringController.text.trim(); + if (uriText.isEmpty) return; + final parsed = Uri.tryParse(uriText); + if (parsed == null) return; + final paths = sslPathsFromControllers( + rootCertController: rootCertController, + clientCertController: clientCertController, + clientKeyController: clientKeyController, + ); + connectionStringController.text = + applySslCertificatePaths(parsed, paths).toString(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 6fc38a73..771e5f7b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,9 @@ dev_dependencies: path_provider_platform_interface: ^2.1.2 dependency_overrides: + # Patched mysql_client: optional SecurityContext for client TLS certificates. + mysql_client: + path: third_party/mysql_client # Patched ToastLayer (fixes InheritedNotifier crash on resize / hot reload). shadcn_flutter: path: third_party/shadcn_flutter diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index d3dfbec3..614ab618 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -114,6 +114,19 @@ void main() { expect(uri, contains('ssl=true')); }); + test('connectionString with Querya SSL params is preserved', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: + 'mongodb://localhost/app?sslrootcert=%2Fca.pem&sslcert=%2Fclient.crt', + ); + final uri = conn.buildConnectionUri(); + expect(uri, contains('sslrootcert')); + expect(uri, contains('sslcert')); + }); + test('multiple query params are joined with &', () { final conn = MongoConnection( id: 1, diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index 70f19680..d3a4f6bf 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -2,6 +2,27 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; void main() { + group('MysqlConnection SSL URI parsing', () { + test('enables secure when ssl certificate params are present', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://user:pass@db.example.com:3306/mydb?sslrootcert=%2Fca.pem', + ), + isTrue, + ); + }); + + test('certificate params enable secure even when ssl-mode=disable', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=disable&sslrootcert=%2Fca.pem', + fallbackSsl: true, + ), + isTrue, + ); + }); + }); + group('replaceDatabaseInMysqlConnectionString', () { test('replaces path segment', () { expect( diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index 3d7dee87..ea608692 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; void main() { group('RedisConnection initial state', () { @@ -94,6 +95,30 @@ void main() { }); }); + group('RedisConnection.fromConnectionRow', () { + test('parses rediss URI and SSL flag', () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + id: 3, + type: 'redis', + name: 'secure-redis', + host: 'localhost', + port: 6379, + useSSL: true, + connectionString: + 'rediss://user:pass@cache.example.com:6380?sslrootcert=%2Fca.pem', + createdAt: '0', + ), + ); + expect(conn.useSSL, isTrue); + expect(conn.host, 'cache.example.com'); + expect(conn.port, 6380); + expect(conn.username, 'user'); + expect(conn.password, 'pass'); + expect(conn.connectionString, contains('sslrootcert')); + }); + }); + group('RedisConnectionException', () { test('stores message and toString returns it', () { final ex = RedisConnectionException('something went wrong'); diff --git a/test/features/connections/ssl_certificate_support_test.dart b/test/features/connections/ssl_certificate_support_test.dart new file mode 100644 index 00000000..f9309ad0 --- /dev/null +++ b/test/features/connections/ssl_certificate_support_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; + +void main() { + group('ssl_certificate_support', () { + test('extracts and applies Querya SSL params', () { + const paths = SslCertificatePaths( + rootCert: '/ca.pem', + clientCert: '/client.crt', + clientKey: '/client.key', + ); + final uri = applySslCertificatePaths( + Uri.parse('mongodb://localhost:27017/app'), + paths, + ); + expect(uri.queryParameters[kSslRootCertParam], '/ca.pem'); + expect(uri.queryParameters[kSslCertParam], '/client.crt'); + expect(uri.queryParameters[kSslKeyParam], '/client.key'); + final extracted = extractSslCertificatePaths(uri); + expect(extracted.rootCert, '/ca.pem'); + expect(extracted.clientCert, '/client.crt'); + expect(extracted.clientKey, '/client.key'); + }); + + test('buildRedisConnectionUri uses rediss scheme when SSL enabled', () { + final uri = buildRedisConnectionUri( + host: 'cache.example.com', + port: 6380, + useSSL: true, + sslPaths: const SslCertificatePaths(rootCert: '/ca.pem'), + ); + expect(uri, startsWith('rediss://')); + expect(uri, contains('sslrootcert')); + }); + + test('buildSecurityContext returns null when no cert paths', () { + expect(buildSecurityContext(const SslCertificatePaths()), isNull); + }); + }); +} diff --git a/third_party/mysql_client/CHANGELOG.md b/third_party/mysql_client/CHANGELOG.md new file mode 100644 index 00000000..03693309 --- /dev/null +++ b/third_party/mysql_client/CHANGELOG.md @@ -0,0 +1,123 @@ +## 0.0.27 + +- Add timeoutMs param to pool constructor + +## 0.0.26 + +- Change default charset to ut8mb4 (fix emojies) +- Add **timeoutMs** option to connect() method +- Increase default timeout from 5 seconds to 10 seconds + +## 0.0.25 + +- Add support for unix socket connection. See example/main_unix_socket.dart + +## 0.0.24 + +- Fix colByName and typedColByName: ignore column name case + +## 0.0.23 + +- Fix caching_sha2_password auth plugin + +## 0.0.22 + +- Check server supports SSL +- Add support for multiple statements + +## 0.0.21 + +- Fix _lastError reset in _forceClose() and used after + +## 0.0.20 + +- Refactor error handling +- Add section about error handling to README.md +- Fix connection pool bugs +- Fix mysql protocol string parsing (ascii instead of utf8) + +## 0.0.19 + +- Expose mysql server error code in MySQLServerException + +## 0.0.18 + +- Remove general Exception class. Add custom exception classes + +## 0.0.17 + +- Fix string encoding in prepared statements + +## 0.0.16 + +- Fix in transaction flag + +## 0.0.15 + +- Fix capability flags parsing + +## 0.0.14 + +- Fix prepared statement select with params (handle two EOF packets if numOfCols and numOfParams are both > 0) + +## 0.0.13 + +- Fix decoding long strings + +## 0.0.12 + +- Add info about typed access to readme and examples + +## 0.0.11 + +- Implement typed access to column data +- Add tests + +## 0.0.10 + +- Add more docs and examples + +## 0.0.9 + +- Use utf8 charset by default +- Encode all data using utf8.encode() and utf8.decode() + +## 0.0.8 + +- Improve error handling +- Add handling of incomplete packets in _spliPackets() method +- Fix parameters substitution +- Add mysql_client tests + +## 0.0.7 + +- Add doc comments and example + +## 0.0.6 + +- Implement iterable result sets + +## 0.0.5 + +- Implement caching_sha2_password auth plugin +- Refactor data packets handling +- Split data packets +- Fix some bugs + +## 0.0.4 + +- Implement SSL connection +- Fix bug with hardcoded host and port + +## 0.0.3 + +- Implement prepared statements +- Add more tests + +## 0.0.2 + +- Fix readme and docs + +## 0.0.1 + +- Initial version. diff --git a/third_party/mysql_client/LICENSE b/third_party/mysql_client/LICENSE new file mode 100644 index 00000000..ab8dbee9 --- /dev/null +++ b/third_party/mysql_client/LICENSE @@ -0,0 +1,26 @@ +Copyright 2022, Georgiy Uvarov. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/third_party/mysql_client/README.md b/third_party/mysql_client/README.md new file mode 100644 index 00000000..7f5a120a --- /dev/null +++ b/third_party/mysql_client/README.md @@ -0,0 +1,207 @@ +### Native MySQL client written in Dart for Dart + +See [example](example/) directory for examples and usage + +Tested with: + * MySQL Percona Server 5.7 and 8 versions + * MariaDB 10 version + +### Roadmap + +* [x] Auth with mysql_native_password +* [x] Basic connection +* [x] Connection pool +* [x] Query placeholders +* [x] Transactions +* [x] Prepared statements (real, not emulated) +* [x] SSL connection +* [x] Auth using caching_sha2_password (default since MySQL 8) +* [x] Iterating large result sets +* [x] Typed data access +* [ ] Send data in binary form when using prepared stmts (do not convert all into strings) +* [x] Multiple resul sets + +### Usage + +#### Create connection pool + +```dart +final pool = MySQLConnectionPool( + host: '127.0.0.1', + port: 3306, + userName: 'your_user', + password: 'your_password', + maxConnections: 10, + databaseName: 'your_database_name', // optional, +); +``` + +#### Or single connection + +```dart +final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional +); + +// actually connect to database +await conn.connect(); +``` + +**Warning** +By default connection is secure. If you don't want to use SSL (TLS) connection, pass *secure: false* + +#### Query database + +```dart +var result = await pool.execute("SELECT * FROM book WHERE id = :id", {"id": 1}); +``` + +#### Print result +```dart + for (final row in result.rows) { + print(row.assoc()); + } +``` + +There are two groups of methods to access column data. +First group returns result as strings. +Second one (methods starting with **typed** prefix) performs conversion to specified type. + +F.e.: +```dart +row.colAt(0); // returns first column as String +row.typedColAt(0); // returns first column as int +``` + +Look at [example/main_simple_conn.dart](example/main_simple_conn.dart) for other ways of getting column data, including typed data access. + +### Prepared statements + +This library supports real prepared statements (using binary protocol). + +#### Prepare statement + +```dart +var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", +); +``` + +#### Execute with params + +```dart +await stmt.execute([null, 'Some book 1', 120, '2022-01-01']); +await stmt.execute([null, 'Some book 2', 10, '2022-01-01']); +``` + +#### Deallocate prepared statement + +```dart +await stmt.deallocate(); +``` + +### Transactions + +To execute queries in transaction, you can use *transactional()* method on *connection* or *pool* object +Example: + +```dart +await pool.transactional((conn) async { + await conn.execute("UPDATE book SET price = :price", {"price": 300}); + await conn.execute("UPDATE book_author SET name = :name", {"name": "John Doe"}); +}); +``` + +In case of exception, transaction will roll back automatically. + +### Iterating large result sets + +In case you need to process large result sets, you can use iterable result set. +To use iterable result set, pass iterable = true, to execute() or prepare() methods. +In this case rows will be ready as soon as they are delivered from the network. +This allows you to process large amount of rows, one by one, in Stream fashion. + +When using iterable result set, you need to use **result.rowsStream.listen** instead of **result.rows** to get access to rows. + +Example: + +```dart +// make query (notice third parameter, iterable=true) +var result = await conn.execute("SELECT * FROM book", {}, true); + +result.rowsStream.listen((row) { + print(row.assoc()); +}); +``` + +### Multiple statements queries +This library supports multiple statements in query() method. +If your query contains multiple statements, result will contain **next** property, which will point to the next result set. + +IResulSet class implements Iterable interface, so you can iterate throw all result sets using for..in loop. + +**Multple statements are not supported for prepared statements and iterable result sets.** + +For example: + +```dart +final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", +); + +assert(resultSets.next != null); + +for (final result in resultSets) { + // for every result set + for (final row in result.rows) { + // for every row in result set + print(row.assoc()); + } +} +``` + +### Tests + +To run tests execute + +```bash +dart test +``` + +### Error handling + +This library throws tree types of exceptions: MySQLServerException, MySQLClientException and MySQLProtocolException. +See api reference for description of each type. + +When exception is thrown, connection can be left in **connected** or **closed** state. + +As a general rule, if cause of exception is MySQL server error packet, connection will be left in connected state and can be reused. If cause of exception is logical error, such as unexpected packet or something inside parsing of mysql protocol, connection will be closed and can not be used anymore. + +It's up to developer to check connection state after catching exception. +Inside your catch block, you can check connection status using **conn.connected** getter and decide what to do next. + +### Troubleshooting + +There is separate **logging** branch of mysql_client. This branch will stay in sync with **main** branch of this repository, with one main difference - it has logging enabled. + +If you have issues, you can temporary switch to logging branch, run your app with **--enable-asserts** and check log messages. + +Here is how you can switch to logging branch in your pubspec.yaml file: + +```yaml + mysql_client: + git: + url: https://github.com/zim32/mysql.dart.git + ref: logging +``` + +Don't forget to switch back again, when you're done with debugging. + + +### Support the author πŸ‡ΊπŸ‡¦ + +If you like this project and want to support the author, you can [donate](https://www.paypal.com/donate/?hosted_button_id=HTNVERGX58MCQ) me via paypal donations service. \ No newline at end of file diff --git a/third_party/mysql_client/analysis_options.yaml b/third_party/mysql_client/analysis_options.yaml new file mode 100644 index 00000000..bc273d53 --- /dev/null +++ b/third_party/mysql_client/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - camel_case_types + - unawaited_futures + - await_only_futures + - avoid_void_async + - void_checks + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/third_party/mysql_client/example/example.md b/third_party/mysql_client/example/example.md new file mode 100644 index 00000000..0d273def --- /dev/null +++ b/third_party/mysql_client/example/example.md @@ -0,0 +1,66 @@ +See [example](../example/) directory for nore examples + +```dart +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // insert some rows + res = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "New title", + "price": 200, + "created": "2022-02-02", + }, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + // print(row.colAt(0)); + // print(row.colByName("title")); + + // print all rows as Map + print(row.assoc()); + } + + // close all connections + await conn.close(); +} + +``` + diff --git a/third_party/mysql_client/example/lib/main.dart b/third_party/mysql_client/example/lib/main.dart new file mode 100644 index 00000000..6674986e --- /dev/null +++ b/third_party/mysql_client/example/lib/main.dart @@ -0,0 +1,53 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + // print(row.colAt(0)); + // print(row.colByName("title")); + + // print all rows as Map + print(row.assoc()); + } + + // or you can use stream interface (which is required for iterable results) + + result.rowsStream.listen((row) { + print(row.assoc()); + }); + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_iterable_result_set.dart b/third_party/mysql_client/example/main_iterable_result_set.dart new file mode 100644 index 00000000..7d02b80d --- /dev/null +++ b/third_party/mysql_client/example/main_iterable_result_set.dart @@ -0,0 +1,35 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // make query (notice third parameter, iterable=true) + var result = await conn.execute("SELECT * FROM book", {}, true); + + // print some result data + // (numOfRows is not available when using iterable result set) + print(result.numOfColumns); + print(result.lastInsertID); + print(result.affectedRows); + + // get rows, one by one + result.rowsStream.listen((row) { + print(row.assoc()); + }); + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_multiple_stmts.dart b/third_party/mysql_client/example/main_multiple_stmts.dart new file mode 100644 index 00000000..a7b61c7a --- /dev/null +++ b/third_party/mysql_client/example/main_multiple_stmts.dart @@ -0,0 +1,35 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", + ); + + assert(resultSets.next != null); + + for (final result in resultSets) { + // for every result set + for (final row in result.rows) { + // for every row in result set + print(row.assoc()); + } + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_pool.dart b/third_party/mysql_client/example/main_pool.dart new file mode 100644 index 00000000..49593eb3 --- /dev/null +++ b/third_party/mysql_client/example/main_pool.dart @@ -0,0 +1,58 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + // create connections pool + final pool = MySQLConnectionPool( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + maxConnections: 10, + ); + + // update table (inside transaction) and get total number of affected rows + final updateResult = await pool.transactional((conn) async { + int totalAffectedRows = 0; + + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 300}, + ); + + totalAffectedRows += res.affectedRows.toInt(); + + res = await conn.execute( + "UPDATE book_author SET name = :name", + {"name": "John Doe"}, + ); + + totalAffectedRows += res.affectedRows.toInt(); + + return totalAffectedRows; + }); + + // show total number of updated rows + print(updateResult); + + // make query + var result = await pool.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + // print(row.colAt(0)); + // print(row.colByName("title")); + + // print all rows as Map + print(row.assoc()); + } + + // close all connections + await pool.close(); +} diff --git a/third_party/mysql_client/example/main_prepared_stmt.dart b/third_party/mysql_client/example/main_prepared_stmt.dart new file mode 100644 index 00000000..f24300de --- /dev/null +++ b/third_party/mysql_client/example/main_prepared_stmt.dart @@ -0,0 +1,39 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // insert some data + var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", + ); + + await stmt.execute([null, 'Some book 1', 120, '2022-01-01']); + await stmt.execute([null, 'Some book 2', 10, '2022-01-01']); + await stmt.deallocate(); + + // select data + stmt = await conn.prepare("SELECT * FROM book"); + var result = await stmt.execute([]); + await stmt.deallocate(); + + for (final row in result.rows) { + print(row.assoc()); + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_simple_conn.dart b/third_party/mysql_client/example/main_simple_conn.dart new file mode 100644 index 00000000..29bc3cde --- /dev/null +++ b/third_party/mysql_client/example/main_simple_conn.dart @@ -0,0 +1,66 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // insert some rows + res = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "New title", + "price": 200, + "created": "2022-02-02", + }, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + print(row.colAt(0)); // get id as String + print(row.colByName("title")); // get title as String + + print(row.typedColAt(0)); // get id as int + print(row.typedColByName("price")); // get price as double + + // print all rows as Map + print(row.assoc()); + + // autodetect best Dart type based on column type and return Map + print(row.typedAssoc()); + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_unix_socket.dart b/third_party/mysql_client/example/main_unix_socket.dart new file mode 100644 index 00000000..11b3a1fd --- /dev/null +++ b/third_party/mysql_client/example/main_unix_socket.dart @@ -0,0 +1,67 @@ +import 'package:mysql_client/mysql_client.dart'; +import 'dart:io'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: InternetAddress('/tmp/mysql.sock', type: InternetAddressType.unix), + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // insert some rows + res = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "New title", + "price": 200, + "created": "2022-02-02", + }, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + print(row.colAt(0)); // get id as String + print(row.colByName("title")); // get title as String + + print(row.typedColAt(0)); // get id as int + print(row.typedColByName("price")); // get price as double + + // print all rows as Map + print(row.assoc()); + + // autodetect best Dart type based on column type and return Map + print(row.typedAssoc()); + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/lib/exception.dart b/third_party/mysql_client/lib/exception.dart new file mode 100644 index 00000000..b0d44f19 --- /dev/null +++ b/third_party/mysql_client/lib/exception.dart @@ -0,0 +1,51 @@ +/// Base class for all exceptions in this library +abstract class MySQLException implements Exception { + final String message; + + const MySQLException(this.message); + + String get _prefix; + + @override + String toString() { + return '$_prefix: $message'; + } +} + +/// Class for errors generated by mysql server itself +/// +/// Extends [MySQLException]. MySQL error code can be read from [errorCode] +class MySQLServerException extends MySQLException { + /// MySQL server error code + final int errorCode; + + const MySQLServerException(String message, this.errorCode) : super(message); + + @override + String toString() { + return '$_prefix [$errorCode]: $message'; + } + + @override + String get _prefix => 'MySQLServerException'; +} + +/// Class for exceptions generated by this library +/// +/// Extends [MySQLException] +class MySQLClientException extends MySQLException { + const MySQLClientException(String message) : super(message); + + @override + String get _prefix => 'MySQLClientException'; +} + +/// Class for mysql protocol specific exceptions +/// +/// Extends [MySQLClientException] +class MySQLProtocolException extends MySQLClientException { + const MySQLProtocolException(String message) : super(message); + + @override + String get _prefix => 'MySQLProtocolException'; +} diff --git a/third_party/mysql_client/lib/mysql_client.dart b/third_party/mysql_client/lib/mysql_client.dart new file mode 100644 index 00000000..42779e0a --- /dev/null +++ b/third_party/mysql_client/lib/mysql_client.dart @@ -0,0 +1,2 @@ +export 'src/mysql_client/connection.dart'; +export 'src/mysql_client/pool.dart'; diff --git a/third_party/mysql_client/lib/mysql_protocol.dart b/third_party/mysql_client/lib/mysql_protocol.dart new file mode 100644 index 00000000..ef624187 --- /dev/null +++ b/third_party/mysql_client/lib/mysql_protocol.dart @@ -0,0 +1,20 @@ +export 'src/mysql_protocol/mysql_packet.dart'; +export 'src/mysql_protocol/mysql_comm_packet.dart'; +export 'src/mysql_protocol/mysql_column_type.dart'; +export 'src/mysql_protocol/packet/packet_auth_switch_request.dart'; +export 'src/mysql_protocol/packet/packet_auth_switch_response.dart'; +export 'src/mysql_protocol/packet/packet_column_count.dart'; +export 'src/mysql_protocol/packet/packet_error.dart'; +export 'src/mysql_protocol/packet/packet_handshake_response_41.dart'; +export 'src/mysql_protocol/packet/packet_initial_handshake.dart'; +export 'src/mysql_protocol/packet/packet_ok.dart'; +export 'src/mysql_protocol/packet/packet_eof.dart'; +export 'src/mysql_protocol/packet/packet_ssl_request.dart'; +export 'src/mysql_protocol/packet/packet_stmt_prepare_ok.dart'; +export 'src/mysql_protocol/packet/packet_column_definition.dart'; +export 'src/mysql_protocol/packet/packet_result_set.dart'; +export 'src/mysql_protocol/packet/packet_result_set_row.dart'; +export 'src/mysql_protocol/packet/packet_binary_result_set.dart'; +export 'src/mysql_protocol/packet/packet_binary_result_set_row.dart'; +export 'src/mysql_protocol/packet/packet_extra_auth_data.dart'; +export 'src/mysql_protocol/packet/packet_extra_auth_data_response.dart'; diff --git a/third_party/mysql_client/lib/mysql_protocol_extension.dart b/third_party/mysql_client/lib/mysql_protocol_extension.dart new file mode 100644 index 00000000..e8ed8f57 --- /dev/null +++ b/third_party/mysql_client/lib/mysql_protocol_extension.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/exception.dart'; +import 'package:tuple/tuple.dart'; + +extension MySQLUint8ListExtension on Uint8List { + Tuple2 getUtf8NullTerminatedString(int startOffset) { + final tmp = Uint8List.sublistView(this, startOffset) + .takeWhile((value) => value != 0); + + return Tuple2(utf8.decode(tmp.toList()), tmp.length + 1); + } + + String getUtf8StringEOF(int startOffset) { + final tmp = Uint8List.sublistView(this, startOffset); + return utf8.decode(tmp); + } + + Tuple2 getUtf8LengthEncodedString(int startOffset) { + final tmp = Uint8List.sublistView(this, startOffset); + final bd = ByteData.sublistView(tmp); + + final strLength = bd.getVariableEncInt(0); + + final tmp2 = Uint8List.sublistView( + tmp, + strLength.item2, + strLength.item2 + strLength.item1.toInt(), + ); + + return Tuple2(utf8.decode(tmp2), strLength.item2 + strLength.item1.toInt()); + } +} + +extension MySQLByteDataExtension on ByteData { + Tuple2 getVariableEncInt(int startOffset) { + int firstByte = getUint8(startOffset); + + if (firstByte < 0xfb) { + return Tuple2(BigInt.from(firstByte), 1); + } + + if (firstByte == 0xfc) { + String radix = + getUint8(startOffset + 2).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 1).toRadixString(16).padLeft(2, '0'); + + return Tuple2(BigInt.parse(radix, radix: 16), 3); + } + + if (firstByte == 0xfd) { + String radix = + getUint8(startOffset + 3).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 2).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 1).toRadixString(16).padLeft(2, '0'); + + return Tuple2(BigInt.parse(radix, radix: 16), 4); + } + + if (firstByte == 0xfe) { + String radix = + getUint8(startOffset + 8).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 7).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 6).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 5).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 4).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 3).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 2).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 1).toRadixString(16).padLeft(2, '0'); + + return Tuple2(BigInt.parse(radix, radix: 16), 9); + } + + throw MySQLProtocolException( + "Wrong first byte, while decoding getVariableEncInt"); + } + + int getInt2(int startOffset) { + final bd = ByteData(2); + bd.setUint8(0, getUint8(startOffset)); + bd.setUint8(1, getUint8(startOffset + 1)); + + return bd.getUint16(0, Endian.little); + } + + int getInt3(int startOffset) { + final bd = ByteData(4); + bd.setUint8(0, getUint8(startOffset)); + bd.setUint8(1, getUint8(startOffset + 1)); + bd.setUint8(2, getUint8(startOffset + 2)); + bd.setUint8(3, 0); + + return bd.getUint32(0, Endian.little); + } +} + +extension MySQLByteWriterExtension on ByteDataWriter { + writeVariableEncInt(int value) { + if (value < 251) { + writeUint8(value); + } else if (value >= 251 && value < 65536) { + writeUint8(0xfc); + writeInt16(value); + } else if (value >= 65536 && value < 16777216) { + writeUint8(0xfd); + final bd = ByteData(4); + bd.setInt32(0, value, Endian.little); + write(bd.buffer.asUint8List().sublist(0, 3)); + } else if (value >= 16777216) { + writeUint8(0xfe); + writeInt64(value); + } + } +} diff --git a/third_party/mysql_client/lib/src/mysql_client/connection.dart b/third_party/mysql_client/lib/src/mysql_client/connection.dart new file mode 100644 index 00000000..821b0f4c --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_client/connection.dart @@ -0,0 +1,1632 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/exception.dart'; + +enum _MySQLConnectionState { + fresh, + waitInitialHandshake, + initialHandshakeResponseSend, + connectionEstablished, + waitingCommandResponse, + quitCommandSend, + closed +} + +/// Main class to interact with MySQL database +/// +/// Use [MySQLConnection.createConnection] to create connection +class MySQLConnection { + Socket _socket; + bool _connected = false; + StreamSubscription? _socketSubscription; + _MySQLConnectionState _state = _MySQLConnectionState.fresh; + final String _username; + final String _password; + final String _collation; + final String? _databaseName; + Future Function(Uint8List data)? _responseCallback; + final List _onCloseCallbacks = []; + bool _inTransaction = false; + final bool _secure; + final SecurityContext? _securityContext; + final List _incompleteBufferData = []; + Object? _lastError; + int _serverCapabilities = 0; + String? _activeAuthPluginName; + int _timeoutMs = 10000; + + MySQLConnection._({ + required Socket socket, + required String username, + required String password, + required String collation, + bool secure = true, + String? databaseName, + SecurityContext? securityContext, + }) : _socket = socket, + _username = username, + _password = password, + _databaseName = databaseName, + _secure = secure, + _securityContext = securityContext, + _collation = collation; + + /// Creates connection with provided options. + /// + /// Keep in mind, **this is async** function. So you need to await result. + /// Don't forget to call [MySQLConnection.connect] to actually connect to database, or you will get errors. + /// See examples directory for code samples. + /// + /// [host] host to connect to. Can be String or InternetAddress. + /// [userName] database user name. + /// [password] user password. + /// [secure] If true - TLS will be used, if false - ordinary TCL connection. + /// [databaseName] Optional database name to connect to. + /// [collation] Optional collaction to use. + /// + /// By default after connection is established, this library executes query to switch connection charset and collation: + /// + /// ``` + /// SET @@collation_connection=$_collation, @@character_set_client=utf8mb4, @@character_set_connection=utf8mb4, @@character_set_results=utf8mb4 + /// ``` + static Future createConnection({ + required dynamic host, + required int port, + required String userName, + required String password, + bool secure = true, + String? databaseName, + String collation = 'utf8mb4_general_ci', + SecurityContext? securityContext, + }) async { + final Socket socket = await Socket.connect(host, port); + + if (socket.address.type != InternetAddressType.unix) { + // no support for extensions on sockets + socket.setOption(SocketOption.tcpNoDelay, true); + } + + final client = MySQLConnection._( + socket: socket, + username: userName, + password: password, + databaseName: databaseName, + secure: secure, + securityContext: securityContext, + collation: collation, + ); + + return client; + } + + /// Returns true if this connection can be used to interact with database + bool get connected { + return _connected; + } + + /// Registers callack to be executed when this connection is closed + void onClose(void Function() callback) { + _onCloseCallbacks.add(callback); + } + + /// Initiate connection to database. To close connection, invoke [MySQLConnection.close] method. + /// + /// Default [timeoutMs] is 10000 milliseconds + Future connect({int timeoutMs = 10000}) async { + if (_state != _MySQLConnectionState.fresh) { + throw MySQLClientException("Can not connect: status is not fresh"); + } + + _timeoutMs = timeoutMs; + + _state = _MySQLConnectionState.waitInitialHandshake; + + _socketSubscription = _socket.listen((data) { + for (final chunk in _splitPackets(data)) { + _processSocketData(chunk) + .onError((error, stackTrace) => _lastError = error); + } + }); + + _socketSubscription!.onDone(() { + _handleSocketClose(); + }); + + // wait for connection established + await Future.doWhile(() async { + if (_lastError != null) { + final err = _lastError; + _forceClose(); + throw err!; + } + + if (_state == _MySQLConnectionState.connectionEstablished) { + return false; + } + + await Future.delayed(Duration(milliseconds: 100)); + + return true; + }).timeout(Duration( + milliseconds: timeoutMs, + )); + + // set connection charset + await execute( + 'SET @@collation_connection=$_collation, @@character_set_client=utf8mb4, @@character_set_connection=utf8mb4, @@character_set_results=utf8mb4', + ); + } + + void _handleSocketClose() { + _connected = false; + _socket.destroy(); + + for (var element in _onCloseCallbacks) { + element(); + } + _onCloseCallbacks.clear(); + } + + Future _processSocketData(Uint8List data) async { + if (_state == _MySQLConnectionState.closed) { + // don't process any data if state is closed + return; + } + + if (_state == _MySQLConnectionState.waitInitialHandshake) { + await _processInitialHandshake(data); + return; + } + + if (_state == _MySQLConnectionState.initialHandshakeResponseSend) { + // check for auth switch request + try { + final authSwitchPacket = + MySQLPacket.decodeAuthSwitchRequestPacket(data); + + final payload = + authSwitchPacket.payload as MySQLPacketAuthSwitchRequest; + + _activeAuthPluginName = payload.authPluginName; + + switch (payload.authPluginName) { + case 'mysql_native_password': + final responsePayload = + MySQLPacketAuthSwitchResponse.createWithNativePassword( + password: _password, + challenge: payload.authPluginData.sublist(0, 20), + ); + final responsePacket = MySQLPacket( + sequenceID: authSwitchPacket.sequenceID + 1, + payload: responsePayload, + payloadLength: 0, + ); + + _socket.add(responsePacket.encode()); + return; + default: + throw MySQLClientException( + "Unsupported auth plugin name: ${payload.authPluginName}"); + } + } catch (e) { + // not auth switch request packet, continue packet processing + } + + MySQLPacket packet; + + try { + packet = MySQLPacket.decodeGenericPacket(data); + } catch (e) { + rethrow; + } + + if (packet.payload is MySQLPacketExtraAuthData) { + assert(_activeAuthPluginName != null); + + if (_activeAuthPluginName != 'caching_sha2_password') { + throw MySQLClientException( + "Unexpected auth plugin name $_activeAuthPluginName, while receiving MySQLPacketExtraAuthData packet"); + } + + if (_secure == false) { + throw MySQLClientException( + "Auth plugin caching_sha2_password is supported only with secure connections. Pass secure: true or use another auth method"); + } + + final payload = packet.payload as MySQLPacketExtraAuthData; + final status = payload.pluginData.codeUnitAt(0); + + if (status == 3) { + // server has password cache. just ignore + return; + } else if (status == 4) { + // send password to the server + final authExtraDataResponse = MySQLPacket( + sequenceID: packet.sequenceID + 1, + payload: MySQLPacketExtraAuthDataResponse( + data: Uint8List.fromList(utf8.encode(_password)), + ), + payloadLength: 0, + ); + + _socket.add(authExtraDataResponse.encode()); + return; + } else { + throw MySQLClientException("Unsupported extra auth data: $data"); + } + } + + if (packet.isErrorPacket()) { + final errorPayload = packet.payload as MySQLPacketError; + throw MySQLServerException( + errorPayload.errorMessage, errorPayload.errorCode); + } + + if (packet.isOkPacket()) { + _state = _MySQLConnectionState.connectionEstablished; + _connected = true; + } + + return; + } + + if (_state == _MySQLConnectionState.waitingCommandResponse) { + _processCommandResponse(data); + return; + } + + throw MySQLClientException( + "Skipping socket data, because of connection bad state\nState: ${_state.name}\nData: $data", + ); + } + + Iterable _splitPackets(Uint8List data) sync* { + if (_incompleteBufferData.isNotEmpty) { + final tmp = Uint8List.fromList(_incompleteBufferData + data.toList()); + data = tmp; + _incompleteBufferData.clear(); + } + + Uint8List view = data; + + while (true) { + // if packet size is less then 4 bytes, we can not even detect payload length and total packet size + // so just append data to incomplete buffer + if (view.length < 4) { + _incompleteBufferData.addAll(view); + break; + } + + final packetLength = MySQLPacket.getPacketLength(view); + + if (view.lengthInBytes < packetLength) { + // incomplete packet + _incompleteBufferData.addAll(view); + break; + } + + final chunk = Uint8List.sublistView(view, 0, packetLength); + + yield chunk; + + view = Uint8List.sublistView(view, packetLength); + + if (view.isEmpty) { + break; + } + } + } + + Future _processInitialHandshake(Uint8List data) async { + // First packet can be error packet + if (MySQLPacket.detectPacketType(data) == MySQLGenericPacketType.error) { + final packet = MySQLPacket.decodeGenericPacket(data); + final payload = packet.payload as MySQLPacketError; + throw MySQLServerException(payload.errorMessage, payload.errorCode); + } + + final packet = MySQLPacket.decodeInitialHandshake(data); + final payload = packet.payload; + + if (payload is! MySQLPacketInitialHandshake) { + throw MySQLClientException("Expected MySQLPacketInitialHandshake packet"); + } + + _serverCapabilities = payload.capabilityFlags; + + if (_secure && (_serverCapabilities & mysqlCapFlagClientSsl == 0)) { + throw MySQLClientException( + "Server does not support SSL connection. Pass secure: false to createConnection or enable SSL support", + ); + } + + if (_secure) { + // it secure = true, initiate ssl connection + Future initiateSSL() async { + final responsePayload = MySQLPacketSSLRequest.createDefault( + initialHandshakePayload: payload, + connectWithDB: _databaseName != null, + ); + + final responsePacket = MySQLPacket( + sequenceID: 1, + payload: responsePayload, + payloadLength: 0, + ); + + _socket.add(responsePacket.encode()); + + _socketSubscription?.pause(); + + final secureSocket = await SecureSocket.secure( + _socket, + context: _securityContext, + onBadCertificate: (certificate) => true, + ); + + // switch socket + _socket = secureSocket; + + _socketSubscription = _socket.listen((data) { + for (final chunk in _splitPackets(data)) { + _processSocketData(chunk) + .onError((error, stackTrace) => _lastError = error); + } + }); + + _socketSubscription!.onDone(() { + _handleSocketClose(); + }); + } + + await initiateSSL(); + } + + final authPluginName = payload.authPluginName; + _activeAuthPluginName = authPluginName; + + switch (authPluginName) { + case 'mysql_native_password': + final responsePayload = + MySQLPacketHandshakeResponse41.createWithNativePassword( + username: _username, + password: _password, + initialHandshakePayload: payload, + ); + + responsePayload.database = _databaseName; + + final responsePacket = MySQLPacket( + payload: responsePayload, + sequenceID: _secure ? 2 : 1, + payloadLength: 0, + ); + + _state = _MySQLConnectionState.initialHandshakeResponseSend; + _socket.add(responsePacket.encode()); + break; + case 'caching_sha2_password': + final responsePayload = + MySQLPacketHandshakeResponse41.createWithCachingSha2Password( + username: _username, + password: _password, + initialHandshakePayload: payload, + ); + + responsePayload.database = _databaseName; + + final responsePacket = MySQLPacket( + payload: responsePayload, + sequenceID: _secure ? 2 : 1, + payloadLength: 0, + ); + + _state = _MySQLConnectionState.initialHandshakeResponseSend; + _socket.add(responsePacket.encode()); + break; + default: + throw MySQLClientException( + "Unsupported auth plugin name: $authPluginName"); + } + } + + void _processCommandResponse(Uint8List data) { + assert(_responseCallback != null); + _responseCallback!(data); + } + + /// Executes given [query] + /// + /// [execute] can be used to make any query type (SELECT, INSERT, UPDATE) + /// You can pass named parameters using [params] + /// Pass [iterable] true if you want to receive rows one by one in Stream fashion + Future execute( + String query, [ + Map? params, + bool iterable = false, + ]) async { + if (!_connected) { + throw MySQLClientException("Can not execute query: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + _state = _MySQLConnectionState.waitingCommandResponse; + + if (params != null && params.isNotEmpty) { + try { + query = _substitureParams(query, params); + } catch (e) { + _state = _MySQLConnectionState.connectionEstablished; + rethrow; + } + } + + final payload = MySQLPacketCommQuery(query: query); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + final completer = Completer(); + + /** + * 0 - initial + * 1 - columnCount decoded + * 2 - columnDefs parsed + * 3 - eofParsed + * 4 - rowsParsed + */ + int state = 0; + int colsCount = 0; + List colDefs = []; + List resultSetRows = []; + + // support for iterable result set + IterableResultSet? iterableResultSet; + StreamSink? sink; + + // used as a pointer to handle multiple result sets + IResultSet? currentResultSet; + IResultSet? firstResultSet; + + _responseCallback = (data) async { + try { + MySQLPacket? packet; + + switch (state) { + case 0: + // if packet is OK packet, there is no data + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.ok) { + final okPacket = MySQLPacket.decodeGenericPacket(data); + _state = _MySQLConnectionState.connectionEstablished; + completer.complete( + EmptyResultSet(okPacket: okPacket.payload as MySQLPacketOK), + ); + + return; + } + + packet = MySQLPacket.decodeColumnCountPacket(data); + break; + case 1: + packet = MySQLPacket.decodeColumnDefPacket(data); + break; + case 2: + packet = MySQLPacket.decodeGenericPacket(data); + if (packet.isEOFPacket()) { + state = 3; + } + break; + case 3: + if (iterable) { + if (iterableResultSet == null) { + iterableResultSet = IterableResultSet._( + columns: colDefs, + ); + + sink = iterableResultSet!._sink; + completer.complete(iterableResultSet); + } + + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + state = 4; + + _state = _MySQLConnectionState.connectionEstablished; + await sink!.close(); + return; + } + + packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + final values = (packet.payload as MySQLResultSetRowPacket).values; + sink!.add(ResultSetRow._(colDefs: colDefs, values: values)); + packet = null; + break; + } else { + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + final resultSetPacket = MySQLPacketResultSet( + columnCount: BigInt.from(colsCount), + columns: colDefs, + rows: resultSetRows, + ); + + final resultSet = ResultSet._(resultSetPacket: resultSetPacket); + + if (currentResultSet != null) { + currentResultSet!.next = resultSet; + } else { + firstResultSet = resultSet; + } + currentResultSet = resultSet; + + final eofPacket = MySQLPacket.decodeGenericPacket(data); + final eofPayload = eofPacket.payload as MySQLPacketEOF; + + if (eofPayload.statusFlags & mysqlServerFlagMoreResultsExists != + 0) { + state = 0; + colsCount = 0; + colDefs = []; + resultSetRows = []; + return; + } else { + // there is no more results, just return + state = 4; + _state = _MySQLConnectionState.connectionEstablished; + completer.complete(firstResultSet); + return; + } + } + + packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + break; + } + } + + if (packet != null) { + final payload = packet.payload; + + if (payload is MySQLPacketError) { + completer.completeError( + MySQLServerException(payload.errorMessage, payload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else if (payload is MySQLPacketOK || payload is MySQLPacketEOF) { + // do nothing + } else if (payload is MySQLPacketColumnCount) { + state = 1; + colsCount = payload.columnCount.toInt(); + return; + } else if (payload is MySQLColumnDefinitionPacket) { + colDefs.add(payload); + if (colDefs.length == colsCount) { + state = 2; + } + } else if (payload is MySQLResultSetRowPacket) { + assert(iterable == false); + resultSetRows.add(payload); + } else { + completer.completeError( + MySQLClientException( + "Unexpected payload received in response to COMM_QUERY request", + ), + StackTrace.current, + ); + _forceClose(); + return; + } + } + } catch (e) { + completer.completeError(e, StackTrace.current); + _forceClose(); + } + }; + + _socket.add(packet.encode()); + + return completer.future; + } + + /// Execute [callback] inside database transaction + /// + /// If MySQLClientException is thrown inside [callback] function, transaction is rolled back + Future transactional( + FutureOr Function(MySQLConnection conn) callback) async { + // prevent double transaction + if (_inTransaction) { + throw MySQLClientException("Already in transaction"); + } + _inTransaction = true; + + await execute("START TRANSACTION"); + + try { + final result = await callback(this); + await execute("COMMIT"); + _inTransaction = false; + return result; + } catch (e) { + await execute("ROLLBACK"); + _inTransaction = false; + rethrow; + } + } + + String _substitureParams(String query, Map params) { + // convert params to string + Map convertedParams = {}; + + for (final param in params.entries) { + String value; + + if (param.value == null) { + value = "NULL"; + } else if (param.value is String) { + value = "'" + _escapeString(param.value) + "'"; + } else if (param.value is num) { + value = param.value.toString(); + } else if (param.value is bool) { + value = param.value ? "TRUE" : "FALSE"; + } else { + value = "'" + _escapeString(param.value.toString()) + "'"; + } + + convertedParams[param.key] = value; + } + + // find all :placeholders, which can be substituted + final pattern = RegExp(r":(\w+)"); + + final matches = pattern.allMatches(query).where((match) { + final subString = query.substring(0, match.start); + + int count = "'".allMatches(subString).length; + if (count > 0 && count.isOdd) { + return false; + } + + count = '"'.allMatches(subString).length; + if (count > 0 && count.isOdd) { + return false; + } + + return true; + }).toList(); + + int lengthShift = 0; + + for (final match in matches) { + final paramName = match.group(1); + + // check param exists + if (false == convertedParams.containsKey(paramName)) { + throw MySQLClientException( + "There is no parameter with name: $paramName"); + } + + final newQuery = query.replaceFirst( + match.group(0)!, + convertedParams[paramName]!, + match.start + lengthShift, + ); + + lengthShift += newQuery.length - query.length; + query = newQuery; + } + + return query; + } + + /// Prepares given [query] + /// + /// Returns [PreparedStmt] which can be used to execute prepared statement multiple times with different parameters + /// See [PreparedStmt.execute] + /// You shoud call [PreparedStmt.deallocate] when you don't need prepared statement anymore to prevent memory leaks + /// + /// Pass [iterable] true if you want to iterable result set. See [execute] for details + Future prepare(String query, [bool iterable = false]) async { + if (!_connected) { + throw MySQLClientException("Can not prepare stmt: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + _state = _MySQLConnectionState.waitingCommandResponse; + + final payload = MySQLPacketCommStmtPrepare(query: query); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + final completer = Completer(); + + /** + * 0 - initial + * 1 - first packet decoded + * 2 - eof decoded + */ + int state = 0; + int numOfEofPacketsParsed = 0; + MySQLPacketStmtPrepareOK? preparedPacket; + + _responseCallback = (data) async { + try { + MySQLPacket? packet; + + switch (state) { + case 0: + packet = MySQLPacket.decodeCommPrepareStmtResponsePacket(data); + state = 1; + break; + default: + packet = null; + + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + numOfEofPacketsParsed++; + + var done = false; + + assert(preparedPacket != null); + + if (preparedPacket!.numOfCols > 0 && + preparedPacket!.numOfParams > 0) { + // there should be two EOF packets in this case + if (numOfEofPacketsParsed == 2) { + done = true; + } + } else { + // there should be only one EOF packet otherwise + done = true; + } + + if (done) { + state = 2; + + completer.complete(PreparedStmt._( + preparedPacket: preparedPacket!, + connection: this, + iterable: iterable, + )); + + _state = _MySQLConnectionState.connectionEstablished; + + return; + } + } + + break; + } + + if (packet != null) { + final payload = packet.payload; + + if (payload is MySQLPacketStmtPrepareOK) { + preparedPacket = payload; + } else if (payload is MySQLPacketError) { + completer.completeError( + MySQLServerException(payload.errorMessage, payload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else { + completer.completeError( + MySQLClientException( + "Unexpected payload received in response to COMM_STMT_PREPARE request", + ), + StackTrace.current, + ); + _forceClose(); + return; + } + } + } catch (e) { + completer.completeError(e, StackTrace.current); + _forceClose(); + } + }; + + _socket.add(packet.encode()); + + return completer.future; + } + + Future _executePreparedStmt( + PreparedStmt stmt, + List params, + bool iterable, + ) async { + if (!_connected) { + throw MySQLClientException( + "Can not execute prepared stmt: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + _state = _MySQLConnectionState.waitingCommandResponse; + + final payload = MySQLPacketCommStmtExecute( + stmtID: stmt._preparedPacket.stmtID, + params: params, + ); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + final completer = Completer(); + + /** + * 0 - initial + * 1 - columnCount decoded + * 2 - columnDefs parsed + * 3 - eofParsed + * 4 - rowsParsed + */ + int state = 0; + int colsCount = 0; + List colDefs = []; + List resultSetRows = []; + + // support for iterable result set + IterablePreparedStmtResultSet? iterableResultSet; + StreamSink? sink; + + _responseCallback = (data) async { + try { + MySQLPacket? packet; + + switch (state) { + case 0: + // if packet is OK packet, there is no data + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.ok) { + final okPacket = MySQLPacket.decodeGenericPacket(data); + _state = _MySQLConnectionState.connectionEstablished; + + completer.complete( + EmptyResultSet(okPacket: okPacket.payload as MySQLPacketOK), + ); + + return; + } + + packet = MySQLPacket.decodeColumnCountPacket(data); + break; + case 1: + packet = MySQLPacket.decodeColumnDefPacket(data); + break; + case 2: + packet = MySQLPacket.decodeGenericPacket(data); + if (packet.isEOFPacket()) { + state = 3; + } else if (packet.isErrorPacket()) { + final errorPayload = packet.payload as MySQLPacketError; + completer.completeError( + MySQLServerException( + errorPayload.errorMessage, errorPayload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else { + completer.completeError( + MySQLClientException("Unexcpected packet type"), + StackTrace.current, + ); + _forceClose(); + return; + } + break; + case 3: + if (iterable) { + if (iterableResultSet == null) { + iterableResultSet = IterablePreparedStmtResultSet._( + columns: colDefs, + ); + + sink = iterableResultSet!._sink; + completer.complete(iterableResultSet); + } + + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + state = 4; + + _state = _MySQLConnectionState.connectionEstablished; + await sink!.close(); + return; + } + + packet = + MySQLPacket.decodeBinaryResultSetRowPacket(data, colDefs); + final values = + (packet.payload as MySQLBinaryResultSetRowPacket).values; + sink!.add(ResultSetRow._(colDefs: colDefs, values: values)); + packet = null; + break; + } else { + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + state = 4; + + final resultSetPacket = MySQLPacketBinaryResultSet( + columnCount: BigInt.from(colsCount), + columns: colDefs, + rows: resultSetRows, + ); + + _state = _MySQLConnectionState.connectionEstablished; + + completer.complete( + PreparedStmtResultSet._(resultSetPacket: resultSetPacket), + ); + + return; + } + + packet = + MySQLPacket.decodeBinaryResultSetRowPacket(data, colDefs); + + break; + } + } + + if (packet != null) { + final payload = packet.payload; + + if (payload is MySQLPacketError) { + completer.completeError( + MySQLServerException(payload.errorMessage, payload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else if (payload is MySQLPacketOK || payload is MySQLPacketEOF) { + // do nothing + } else if (payload is MySQLPacketColumnCount) { + state = 1; + colsCount = payload.columnCount.toInt(); + return; + } else if (payload is MySQLColumnDefinitionPacket) { + colDefs.add(payload); + if (colDefs.length == colsCount) { + state = 2; + } + } else if (payload is MySQLBinaryResultSetRowPacket) { + resultSetRows.add(payload); + } else { + completer.completeError( + MySQLClientException( + "Unexpected payload received in response to COMM_QUERY request", + ), + StackTrace.current, + ); + _forceClose(); + return; + } + } + } catch (e) { + completer.completeError(e, StackTrace.current); + _forceClose(); + } + }; + + _socket.add(packet.encode()); + + return completer.future; + } + + Future _deallocatePreparedStmt(PreparedStmt stmt) async { + if (!_connected) { + throw MySQLClientException("Can not execute query: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + final payload = MySQLPacketCommStmtClose( + stmtID: stmt._preparedPacket.stmtID, + ); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + _socket.add(packet.encode()); + } + + String _escapeString(String value) { + value = value.replaceAll(r"\", r'\\'); + value = value.replaceAll(r"'", r"''"); + return value; + } + + /// Close this connection gracefully + /// + /// This is an error to use this connection after connection has been closed + Future close() async { + final packet = MySQLPacket( + sequenceID: 0, + payload: MySQLPacketCommQuit(), + payloadLength: 0, + ); + + if (_state != _MySQLConnectionState.connectionEstablished) { + throw MySQLClientException( + "Can not close connection. Connection state is not in connectionEstablished state", + ); + } + + _socket.add(packet.encode()); + _state = _MySQLConnectionState.quitCommandSend; + + await _closeSocketAndCallHandlers(); + } + + Future _closeSocketAndCallHandlers() async { + if (_socketSubscription != null) { + await _socketSubscription!.cancel(); + } + + await _socket.flush(); + await Future.delayed(Duration(milliseconds: 10)); + await _socket.close(); + _socket.destroy(); + + _incompleteBufferData.clear(); + + _connected = false; + _state = _MySQLConnectionState.closed; + + for (var element in _onCloseCallbacks) { + element(); + } + + _onCloseCallbacks.clear(); + _responseCallback = null; + _inTransaction = false; + _incompleteBufferData.clear(); + _lastError = null; + } + + void _forceClose() { + if (_socketSubscription != null) { + _socketSubscription!.cancel(); + } + + _socket.destroy(); + _incompleteBufferData.clear(); + + _connected = false; + _state = _MySQLConnectionState.closed; + + for (var element in _onCloseCallbacks) { + element(); + } + + _onCloseCallbacks.clear(); + _responseCallback = null; + _inTransaction = false; + _incompleteBufferData.clear(); + _lastError = null; + } + + Future _waitForState(_MySQLConnectionState state) async { + if (_state == state) { + return; + } + + await Future.doWhile(() async { + if (_state == state) { + return false; + } + + await Future.delayed(Duration(microseconds: 100)); + return true; + }); + } +} + +/// Base class to represent result of calling [MySQLConnection.execute] and [PreparedStmt.execute] +abstract class IResultSet + with IterableMixin + implements Iterator, Iterable { + /// Number of colums in this result if any + int get numOfColumns; + + /// Number of rows in this result if any (unavailable for iterable results) + int get numOfRows; + + /// Number of affected rows + BigInt get affectedRows; + + /// Last insert ID + BigInt get lastInsertID; + + /// Next result set, if any. + /// Prepared statements and iterable result sets does not supprot this + IResultSet? next; + + IResultSet? _current; + + @override + Iterator get iterator => this; + + @override + IResultSet get current { + if (_current != null) { + return _current!; + } else { + throw RangeError("Trying to access past the end value"); + } + } + + @override + bool moveNext() { + if (_current == null) { + _current = this; + return true; + } else { + if (_current!.next != null) { + _current = _current!.next; + return true; + } else { + return false; + } + } + } + + /// Provides access to data rows (unavailable for iterable results) + Iterable get rows; + + /// Use [cols] to get info about returned columns + Iterable get cols; + + /// Provides Stream like access to data rows. Use [rowsStream] to get rows from iterable results + Stream get rowsStream => Stream.fromIterable(rows); +} + +/// Represents result of [MySQLConnection.execute] method +class ResultSet extends IResultSet { + final MySQLPacketResultSet _resultSetPacket; + + ResultSet._({ + required MySQLPacketResultSet resultSetPacket, + }) : _resultSetPacket = resultSetPacket; + + @override + int get numOfColumns => _resultSetPacket.columns.length; + + @override + int get numOfRows => _resultSetPacket.rows.length; + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get rows sync* { + for (final _row in _resultSetPacket.rows) { + yield ResultSetRow._( + colDefs: _resultSetPacket.columns, + values: _row.values, + ); + } + } + + @override + Iterable get cols { + return _resultSetPacket.columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } +} + +/// Represents result of [MySQLConnection.execute] method when passing iterable = true +class IterableResultSet with IterableMixin implements IResultSet { + final List _columns; + late StreamController _controller; + + IterableResultSet._({ + required List columns, + }) : _columns = columns { + _controller = StreamController(); + } + + @override + IResultSet? get next => throw UnimplementedError(); + + @override + set next(val) => throw UnimplementedError(); + + @override + Iterator get iterator => throw UnimplementedError(); + + @override + IResultSet? _current; + + @override + IResultSet get current => throw UnimplementedError(); + + @override + bool moveNext() => throw UnimplementedError(); + + StreamSink get _sink => _controller.sink; + + @override + Stream get rowsStream => _controller.stream; + + @override + int get numOfColumns => _columns.length; + + @override + int get numOfRows => throw MySQLClientException( + "numOfRows is not implemented for IterableResultSet", + ); + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get cols { + return _columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } + + @override + Iterable get rows => throw MySQLClientException( + "Use rowsStream to get rows from IterableResultSet", + ); +} + +/// Represents result of [PreparedStmt.execute] method +class PreparedStmtResultSet extends IResultSet { + final MySQLPacketBinaryResultSet _resultSetPacket; + + PreparedStmtResultSet._({ + required MySQLPacketBinaryResultSet resultSetPacket, + }) : _resultSetPacket = resultSetPacket; + + @override + int get numOfColumns => _resultSetPacket.columns.length; + + @override + int get numOfRows => _resultSetPacket.rows.length; + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get rows sync* { + for (final _row in _resultSetPacket.rows) { + yield ResultSetRow._( + colDefs: _resultSetPacket.columns, + values: _row.values, + ); + } + } + + @override + Iterable get cols { + return _resultSetPacket.columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } +} + +/// Represents result of [PreparedStmt.execute] method when using iterable = true +class IterablePreparedStmtResultSet extends IResultSet { + final List _columns; + late StreamController _controller; + + IterablePreparedStmtResultSet._({ + required List columns, + }) : _columns = columns { + _controller = StreamController(); + } + + StreamSink get _sink => _controller.sink; + + @override + int get numOfColumns => _columns.length; + + @override + int get numOfRows => throw MySQLClientException( + "numOfRows is not implemented for IterableResultSet", + ); + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get rows => throw MySQLClientException( + "Use rowsStream to get rows from IterablePreparedStmtResultSet", + ); + + @override + Stream get rowsStream => _controller.stream; + + @override + Iterable get cols { + return _columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } +} + +/// Represents empty result set +class EmptyResultSet extends IResultSet { + final MySQLPacketOK _okPacket; + + EmptyResultSet({required MySQLPacketOK okPacket}) : _okPacket = okPacket; + + @override + int get numOfColumns => 0; + + @override + int get numOfRows => 0; + + @override + BigInt get affectedRows => _okPacket.affectedRows; + + @override + BigInt get lastInsertID => _okPacket.lastInsertID; + + @override + Iterable get rows => List.empty(); + + @override + Iterable get cols => List.empty(); +} + +/// Represents result set row data +class ResultSetRow { + final List _colDefs; + final List _values; + + ResultSetRow._({ + required List colDefs, + required List values, + }) : _colDefs = colDefs, + _values = values; + + /// Get number of columns for this row + int get numOfColumns => _colDefs.length; + + /// Get column data by column index (starting form 0) + String? colAt(int colIndex) { + if (colIndex >= _values.length) { + throw MySQLClientException("Column index is out of range"); + } + + final value = _values[colIndex]; + + return value; + } + + /// Same as [colAt] but performs conversion of string data, into provided type [T], if possible + /// + /// Conversion is "typesafe", meaning that actual MySQL column type will be checked, + /// to decide is it possible to make such a conversion + /// + /// Throws [MySQLClientException] if conversion is not possible + T? typedColAt(int colIndex) { + final value = colAt(colIndex); + final colDef = _colDefs[colIndex]; + + return colDef.type + .convertStringValueToProvidedType(value, colDef.columnLength); + } + + /// Get column data by column name + String? colByName(String columnName) { + final colIndex = _colDefs.indexWhere( + (element) => element.name.toLowerCase() == columnName.toLowerCase(), + ); + + if (colIndex == -1) { + throw MySQLClientException("There is no column with name: $columnName"); + } + + if (colIndex >= _values.length) { + throw MySQLClientException("Column index is out of range"); + } + + final value = _values[colIndex]; + + return value; + } + + /// Same as [colByName] but performs conversion of string data, into provided type [T], if possible + /// + /// Conversion is "typesafe", meaning that actual MySQL column type will be checked, + /// to decide is it possible to make such a conversion + /// + /// Throws [MySQLClientException] if conversion is not possible + T? typedColByName(String columnName) { + final value = colByName(columnName); + + final colIndex = _colDefs.indexWhere( + (element) => element.name.toLowerCase() == columnName.toLowerCase(), + ); + + final colDef = _colDefs[colIndex]; + + return colDef.type + .convertStringValueToProvidedType(value, colDef.columnLength); + } + + /// Get data for all columns + Map assoc() { + final result = {}; + + int colIndex = 0; + + for (final colDef in _colDefs) { + result[colDef.name] = _values[colIndex]; + colIndex++; + } + + return result; + } + + /// Same as [assoc] but detects best dart type for columns, and converts string data into appropriate types + Map typedAssoc() { + final result = {}; + + int colIndex = 0; + + for (final colDef in _colDefs) { + final value = _values[colIndex]; + + if (value == null) { + result[colDef.name] = null; + colIndex++; + continue; + } + + final dartType = colDef.type.getBestMatchDartType(colDef.columnLength); + + dynamic decodedValue; + + switch (dartType) { + case int: + decodedValue = int.parse(value); + break; + case double: + decodedValue = double.parse(value); + break; + case num: + decodedValue = num.parse(value); + break; + case bool: + decodedValue = int.parse(value) > 0; + break; + case String: + decodedValue = value; + break; + default: + decodedValue = value; + break; + } + + result[colDef.name] = decodedValue; + + colIndex++; + } + + return result; + } +} + +/// Represents column definition +class ResultSetColumn { + String name; + MySQLColumnType type; + int length; + + ResultSetColumn({ + required this.name, + required this.type, + required this.length, + }); +} + +/// Prepared statement class +class PreparedStmt { + final MySQLPacketStmtPrepareOK _preparedPacket; + final MySQLConnection _connection; + final bool _iterable; + + PreparedStmt._({ + required MySQLPacketStmtPrepareOK preparedPacket, + required MySQLConnection connection, + required bool iterable, + }) : _preparedPacket = preparedPacket, + _connection = connection, + _iterable = iterable; + + int get numOfParams => _preparedPacket.numOfParams; + + /// Executes this prepared statement with given [params] + Future execute(List params) async { + if (numOfParams != params.length) { + throw MySQLClientException( + "Can not execute prepared stmt: number of passed params != number of prepared params", + ); + } + + return _connection._executePreparedStmt(this, params, _iterable); + } + + /// Deallocates this prepared statement + /// + /// Use this method to prevent memory leaks for long running connections + /// All prepared statements are automatically deallocated by database when connection is closed + Future deallocate() { + return _connection._deallocatePreparedStmt(this); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_client/pool.dart b/third_party/mysql_client/lib/src/mysql_client/pool.dart new file mode 100644 index 00000000..7ccab02c --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_client/pool.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'package:mysql_client/mysql_client.dart'; + +/// Class to create and manage pool of database connections +class MySQLConnectionPool { + final String host; + final int port; + final String userName; + final String _password; + final int maxConnections; + final String? databaseName; + final bool secure; + final String collation; + final int timeoutMs; + + final List _activeConnections = []; + final List _idleConnections = []; + + /// Creates new pool + /// + /// Almost all parameters are identical to [MySQLConnection.createConnection] + /// Pass [maxConnections] to tell pool maximum number of connections it can use + /// You can specify [timeoutMs], it will be passed to [MySQLConnection.connect] method when creating new connections + MySQLConnectionPool({ + required this.host, + required this.port, + required this.userName, + required password, + required this.maxConnections, + this.databaseName, + this.secure = true, + this.collation = 'utf8_general_ci', + this.timeoutMs = 10000, + }) : _password = password; + + /// Number of active connections in this pool + /// Active are connections which are currently interacting with the database + int get activeConnectionsQty => _activeConnections.length; + + /// Number of idle connections in this pool + /// Idle are connections which are currently not interacting with the database and ready to be used + int get idleConnectionsQty => _idleConnections.length; + + /// Active + Idle connections + int get allConnectionsQty => activeConnectionsQty + idleConnectionsQty; + + List get _allConnections => + _idleConnections + _activeConnections; + + /// See [MySQLConnection.execute] + Future execute( + String query, [ + Map? params, + bool iterable = false, + ]) async { + final conn = await _getFreeConnection(); + try { + final result = await conn.execute(query, params, iterable); + _releaseConnection(conn); + return result; + } catch (e) { + _releaseConnection(conn); + rethrow; + } + } + + /// Closes all connections in this pool and frees resources + Future close() async { + for (final conn in _allConnections) { + await conn.close(); + } + _idleConnections.clear(); + _activeConnections.clear(); + } + + /// See [MySQLConnection.prepare] + Future prepare(String query, [bool iterable = false]) async { + final conn = await _getFreeConnection(); + try { + final stmt = conn.prepare(query, iterable); + _releaseConnection(conn); + return stmt; + } catch (e) { + _releaseConnection(conn); + rethrow; + } + } + + /// Get free connection from this pool (possibly new connection) and invoke callback function with this connection + /// + /// After callback completes, connection is returned into pool as idle connection + /// This function returns callback result + FutureOr withConnection( + FutureOr Function(MySQLConnection conn) callback) async { + final conn = await _getFreeConnection(); + final result = await callback(conn); + _releaseConnection(conn); + return result; + } + + /// See [MySQLConnection.transactional] + Future transactional( + FutureOr Function(MySQLConnection conn) callback) async { + return withConnection((conn) { + return conn.transactional(callback); + }); + } + + Future _getFreeConnection() async { + // if there is idle connection, return it + if (_idleConnections.isNotEmpty) { + final conn = _idleConnections.first; + _idleConnections.remove(conn); + _activeConnections.add(conn); + return conn; + } + + if (allConnectionsQty < maxConnections) { + final conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: userName, + password: _password, + databaseName: databaseName, + secure: secure, + collation: collation, + ); + + await conn.connect(timeoutMs: timeoutMs); + _activeConnections.add(conn); + + // remove connection from pool, if connection is closed + conn.onClose(() { + _idleConnections.remove(conn); + _activeConnections.remove(conn); + }); + + return conn; + } else { + // wait for idle connection + await Future.doWhile(() => idleConnectionsQty == 0); + final conn = _idleConnections.first; + _idleConnections.remove(conn); + _activeConnections.add(conn); + return conn; + } + } + + void _releaseConnection(MySQLConnection conn) { + // remove from active + _activeConnections.remove(conn); + _idleConnections.add(conn); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart new file mode 100644 index 00000000..6b07e1d5 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart @@ -0,0 +1,342 @@ +import 'dart:typed_data'; +import 'package:tuple/tuple.dart'; +import 'package:mysql_client/exception.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +const mysqlColumnTypeDecimal = 0x00; +const mysqlColumnTypeTiny = 0x01; +const mysqlColumnTypeShort = 0x02; +const mysqlColumnTypeLong = 0x03; +const mysqlColumnTypeFloat = 0x04; +const mysqlColumnTypeDouble = 0x05; +const mysqlColumnTypeNull = 0x06; +const mysqlColumnTypeTimestamp = 0x07; +const mysqlColumnTypeLongLong = 0x08; +const mysqlColumnTypeInt24 = 0x09; +const mysqlColumnTypeDate = 0x0a; +const mysqlColumnTypeTime = 0x0b; +const mysqlColumnTypeDateTime = 0x0c; +const mysqlColumnTypeYear = 0x0d; +const mysqlColumnTypeNewDate = 0x0e; +const mysqlColumnTypeVarChar = 0x0f; +const mysqlColumnTypeBit = 0x10; +const mysqlColumnTypeTimestamp2 = 0x11; +const mysqlColumnTypeDateTime2 = 0x12; +const mysqlColumnTypeTime2 = 0x13; +const mysqlColumnTypeNewDecimal = 0xf6; +const mysqlColumnTypeEnum = 0xf7; +const mysqlColumnTypeSet = 0xf8; +const mysqlColumnTypeTinyBlob = 0xf9; +const mysqlColumnTypeMediumBlob = 0xfa; +const mysqlColumnTypeLongBlob = 0xfb; +const mysqlColumnTypeBlob = 0xfc; +const mysqlColumnTypeVarString = 0xfd; +const mysqlColumnTypeString = 0xfe; +const mysqlColumnTypeGeometry = 0xff; + +class MySQLColumnType { + final int _value; + + const MySQLColumnType._(int value) : _value = value; + factory MySQLColumnType.create(int value) => MySQLColumnType._(value); + int get intVal => _value; + + static const decimalType = MySQLColumnType._(mysqlColumnTypeDecimal); + static const tinyType = MySQLColumnType._(mysqlColumnTypeTiny); + static const shortType = MySQLColumnType._(mysqlColumnTypeShort); + static const longType = MySQLColumnType._(mysqlColumnTypeLong); + static const floatType = MySQLColumnType._(mysqlColumnTypeFloat); + static const doubleType = MySQLColumnType._(mysqlColumnTypeDouble); + static const nullType = MySQLColumnType._(mysqlColumnTypeNull); + static const timestampType = MySQLColumnType._(mysqlColumnTypeTimestamp); + static const longLongType = MySQLColumnType._(mysqlColumnTypeLongLong); + static const int24Type = MySQLColumnType._(mysqlColumnTypeInt24); + static const dateType = MySQLColumnType._(mysqlColumnTypeDate); + static const timeType = MySQLColumnType._(mysqlColumnTypeTime); + static const dateTimeType = MySQLColumnType._(mysqlColumnTypeDateTime); + static const yearType = MySQLColumnType._(mysqlColumnTypeYear); + static const newDateType = MySQLColumnType._(mysqlColumnTypeNewDate); + static const vatChartType = MySQLColumnType._(mysqlColumnTypeVarChar); + static const bitType = MySQLColumnType._(mysqlColumnTypeBit); + static const timestamp2Type = MySQLColumnType._(mysqlColumnTypeTimestamp2); + static const dateTime2Type = MySQLColumnType._(mysqlColumnTypeDateTime2); + static const time2Type = MySQLColumnType._(mysqlColumnTypeTime2); + static const newDecimalType = MySQLColumnType._(mysqlColumnTypeNewDecimal); + static const enumType = MySQLColumnType._(mysqlColumnTypeEnum); + static const setType = MySQLColumnType._(mysqlColumnTypeSet); + static const tinyBlobType = MySQLColumnType._(mysqlColumnTypeTinyBlob); + static const mediumBlobType = MySQLColumnType._(mysqlColumnTypeMediumBlob); + static const longBlobType = MySQLColumnType._(mysqlColumnTypeLongBlob); + static const blocType = MySQLColumnType._(mysqlColumnTypeBlob); + static const varStringType = MySQLColumnType._(mysqlColumnTypeVarString); + static const stringType = MySQLColumnType._(mysqlColumnTypeString); + static const geometryType = MySQLColumnType._(mysqlColumnTypeGeometry); + + T? convertStringValueToProvidedType(String? value, [int? columnLength]) { + if (value == null) { + return null; + } + + if (T == String || T == dynamic) { + return value as T; + } + + if (T == bool) { + if (_value == mysqlColumnTypeTiny && columnLength == 1) { + return int.parse(value) > 0 as T; + } else { + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type bool", + ); + } + } + + // convert to int + if (T == int) { + switch (_value) { + // types convertible to dart int + case mysqlColumnTypeTiny: + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + case mysqlColumnTypeYear: + return int.parse(value) as T; + default: + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type int", + ); + } + } + + if (T == double) { + switch (_value) { + case mysqlColumnTypeTiny: + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + case mysqlColumnTypeFloat: + case mysqlColumnTypeDouble: + return double.parse(value) as T; + default: + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type double", + ); + } + } + + if (T == num) { + switch (_value) { + case mysqlColumnTypeTiny: + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + case mysqlColumnTypeFloat: + case mysqlColumnTypeDouble: + return num.parse(value) as T; + default: + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type num", + ); + } + } + + throw MySQLProtocolException( + "Can not convert MySQL type ${T.runtimeType} to requested type int", + ); + } + + Type getBestMatchDartType(int columnLength) { + switch (_value) { + case mysqlColumnTypeString: + case mysqlColumnTypeVarString: + case mysqlColumnTypeVarChar: + case mysqlColumnTypeEnum: + case mysqlColumnTypeSet: + case mysqlColumnTypeLongBlob: + case mysqlColumnTypeMediumBlob: + case mysqlColumnTypeBlob: + case mysqlColumnTypeTinyBlob: + case mysqlColumnTypeGeometry: + case mysqlColumnTypeBit: + case mysqlColumnTypeDecimal: + case mysqlColumnTypeNewDecimal: + return String; + case mysqlColumnTypeTiny: + if (columnLength == 1) { + return bool; + } else { + return int; + } + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + return int; + case mysqlColumnTypeFloat: + case mysqlColumnTypeDouble: + return double; + default: + return String; + } + } +} + +Tuple2 parseBinaryColumnData( + int columnType, + ByteData data, + Uint8List buffer, + int startOffset, +) { + switch (columnType) { + case mysqlColumnTypeTiny: + final value = data.getInt8(startOffset); + return Tuple2(value.toString(), 1); + case mysqlColumnTypeShort: + final value = data.getInt16(startOffset, Endian.little); + return Tuple2(value.toString(), 2); + case mysqlColumnTypeLong: + case mysqlColumnTypeInt24: + final value = data.getInt32(startOffset, Endian.little); + return Tuple2(value.toString(), 4); + case mysqlColumnTypeLongLong: + final value = data.getInt64(startOffset, Endian.little); + return Tuple2(value.toString(), 8); + case mysqlColumnTypeFloat: + final value = data.getFloat32(startOffset, Endian.little); + return Tuple2(value.toString(), 4); + case mysqlColumnTypeDouble: + final value = data.getFloat64(startOffset, Endian.little); + return Tuple2(value.toString(), 8); + case mysqlColumnTypeDate: + case mysqlColumnTypeDateTime: + case mysqlColumnTypeTimestamp: + final initialOffset = startOffset; + + // read number of bytes (0, 4, 7, 11) + final numOfBytes = data.getUint8(startOffset); + startOffset += 1; + + if (numOfBytes == 0) { + return Tuple2("0000-00-00 00:00:00", 1); + } + + var year = 0; + var month = 0; + var day = 0; + var hour = 0; + var minute = 0; + var second = 0; + var microSecond = 0; + + if (numOfBytes >= 4) { + year = data.getUint16(startOffset, Endian.little); + startOffset += 2; + + month = data.getUint8(startOffset); + startOffset += 1; + + day = data.getUint8(startOffset); + startOffset += 1; + } + + if (numOfBytes >= 7) { + hour = data.getUint8(startOffset); + startOffset += 1; + + minute = data.getUint8(startOffset); + startOffset += 1; + + second = data.getUint8(startOffset); + startOffset += 1; + } + + if (numOfBytes >= 11) { + microSecond = data.getUint32(startOffset, Endian.little); + startOffset += 4; + } + + final result = StringBuffer(); + result.write(year.toString() + '-'); + result.write(month.toString().padLeft(2, '0') + '-'); + result.write(day.toString().padLeft(2, '0') + ' '); + result.write(hour.toString().padLeft(2, '0') + ':'); + result.write(minute.toString().padLeft(2, '0') + ':'); + result.write(second.toString().padLeft(2, '0') + '.'); + result.write(microSecond.toString()); + + return Tuple2(result.toString(), startOffset - initialOffset); + case mysqlColumnTypeTime: + final initialOffset = startOffset; + + // read number of bytes (0, 8, 12) + final numOfBytes = data.getUint8(startOffset); + startOffset += 1; + + if (numOfBytes == 0) { + return Tuple2("00:00:00", 1); + } + + var isNegative = false; + var days = 0; + var hours = 0; + var minutes = 0; + var seconds = 0; + var microSecond = 0; + + if (numOfBytes >= 8) { + isNegative = data.getUint8(startOffset) > 0; + startOffset += 1; + + days = data.getUint32(startOffset, Endian.little); + startOffset += 4; + + hours = data.getUint8(startOffset); + startOffset += 1; + + minutes = data.getUint8(startOffset); + startOffset += 1; + + seconds = data.getUint8(startOffset); + startOffset += 1; + } + + if (numOfBytes >= 12) { + microSecond = data.getUint32(startOffset, Endian.little); + startOffset += 4; + } + + hours += days * 24; + + final result = StringBuffer(); + if (isNegative) { + result.write("-"); + } + result.write(hours.toString().padLeft(2, '0') + ':'); + result.write(minutes.toString().padLeft(2, '0') + ':'); + result.write(seconds.toString().padLeft(2, '0') + '.'); + result.write(microSecond.toString()); + + return Tuple2(result.toString(), startOffset - initialOffset); + case mysqlColumnTypeString: + case mysqlColumnTypeVarString: + case mysqlColumnTypeVarChar: + case mysqlColumnTypeEnum: + case mysqlColumnTypeSet: + case mysqlColumnTypeLongBlob: + case mysqlColumnTypeMediumBlob: + case mysqlColumnTypeBlob: + case mysqlColumnTypeTinyBlob: + case mysqlColumnTypeGeometry: + case mysqlColumnTypeBit: + case mysqlColumnTypeDecimal: + case mysqlColumnTypeNewDecimal: + return buffer.getUtf8LengthEncodedString(startOffset); + } + + throw MySQLProtocolException( + "Can not parse binary column data: column type $columnType is not implemented", + ); +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_comm_packet.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_comm_packet.dart new file mode 100644 index 00000000..7b58bf96 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_comm_packet.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart' show ByteDataWriter; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketCommInitDB extends MySQLPacketPayload { + String schemaName; + + MySQLPacketCommInitDB({ + required this.schemaName, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(2); + buffer.write(utf8.encode(schemaName)); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommQuery extends MySQLPacketPayload { + String query; + + MySQLPacketCommQuery({ + required this.query, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(3); + buffer.write(utf8.encode(query)); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommStmtPrepare extends MySQLPacketPayload { + String query; + + MySQLPacketCommStmtPrepare({ + required this.query, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(0x16); + buffer.write(utf8.encode(query)); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommStmtExecute extends MySQLPacketPayload { + int stmtID; + List params; // (type, value) + + MySQLPacketCommStmtExecute({ + required this.stmtID, + required this.params, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(0x17); + // stmt id + buffer.writeUint32(stmtID, Endian.little); + // flags + buffer.writeUint8(0); + // iteration count (always 1) + buffer.writeUint32(1, Endian.little); + + // params + if (params.isNotEmpty) { + // create null-bitmap + final bitmapSize = ((params.length + 7) / 8).floor(); + final nullBitmap = Uint8List(bitmapSize); + + // write null values into null bitmap + int paramIndex = 0; + for (final param in params) { + if (param == null) { + final paramByteIndex = ((paramIndex) / 8).floor(); + final paramBitIndex = ((paramIndex) % 8); + nullBitmap[paramByteIndex] = + nullBitmap[paramByteIndex] | (1 << paramBitIndex); + } + paramIndex++; + } + + // write null bitmap + buffer.write(nullBitmap); + + // write new-param-bound flag + buffer.writeUint8(1); + + // write not null values + + // write param types + for (final param in params) { + if (param != null) { + buffer.writeUint8(mysqlColumnTypeVarString); + // unsigned flag + buffer.writeUint8(0); + } else { + buffer.writeUint8(mysqlColumnTypeNull); + buffer.writeUint8(0); + } + } + // write param values + for (final param in params) { + if (param != null) { + final String value = param.toString(); + final encodedData = utf8.encode(value); + buffer.writeVariableEncInt(encodedData.length); + buffer.write(encodedData); + } + } + } + + return buffer.toBytes(); + } +} + +class MySQLPacketCommQuit extends MySQLPacketPayload { + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(1); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommStmtClose extends MySQLPacketPayload { + int stmtID; + + MySQLPacketCommStmtClose({ + required this.stmtID, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(0x19); + buffer.writeUint32(stmtID); + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart new file mode 100644 index 00000000..c24c6e9c --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart @@ -0,0 +1,395 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart' show ByteDataWriter; +import 'package:crypto/crypto.dart' as crypto; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/exception.dart'; +import 'package:tuple/tuple.dart' show Tuple2; + +const mysqlCapFlagClientLongPassword = 0x00000001; +const mysqlCapFlagClientFoundRows = 0x00000002; +const mysqlCapFlagClientLongFlag = 0x00000004; +const mysqlCapFlagClientConnectWithDB = 0x00000008; +const mysqlCapFlagClientNoSchema = 0x00000010; +const mysqlCapFlagClientCompress = 0x00000020; +const mysqlCapFlagClientODBC = 0x00000040; +const mysqlCapFlagClientLocalFiles = 0x00000080; +const mysqlCapFlagClientIgnoreSpace = 0x00000100; +const mysqlCapFlagClientProtocol41 = 0x00000200; +const mysqlCapFlagClientInteractive = 0x00000400; +const mysqlCapFlagClientSsl = 0x00000800; +const mysqlCapFlagClientIgnoreSigPipe = 0x00001000; +const mysqlCapFlagClientTransactions = 0x00002000; +const mysqlCapFlagClientReserved = 0x00004000; +const mysqlCapFlagClientSecureConnection = 0x00008000; +const mysqlCapFlagClientMultiStatements = 0x00010000; +const mysqlCapFlagClientMultiResults = 0x00020000; +const mysqlCapFlagClientPsMultiResults = 0x00040000; +const mysqlCapFlagClientPluginAuth = 0x00080000; +const mysqlCapFlagClientPluginAuthLenEncClientData = 0x00200000; +const mysqlCapFlagClientDeprecateEOF = 0x01000000; + +const mysqlServerFlagMoreResultsExists = 0x0008; + +enum MySQLGenericPacketType { ok, error, eof, other } + +abstract class MySQLPacketPayload { + Uint8List encode(); +} + +class MySQLPacket { + int sequenceID; + int payloadLength; + MySQLPacketPayload payload; + + MySQLPacket({ + required this.sequenceID, + required this.payload, + required this.payloadLength, + }); + + static int getPacketLength(Uint8List buffer) { + // payloadLength + var db = ByteData(4) + ..setUint8(0, buffer[0]) + ..setUint8(1, buffer[1]) + ..setUint8(2, buffer[2]) + ..setUint8(3, 0); + + final payloadLength = db.getUint32(0, Endian.little); + + return payloadLength + 4; + } + + static Tuple2 decodePacketHeader(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + // payloadLength + var db = ByteData(4) + ..setUint8(0, buffer[0]) + ..setUint8(1, buffer[1]) + ..setUint8(2, buffer[2]) + ..setUint8(3, 0); + + final payloadLength = db.getUint32(0, Endian.little); + offset += 3; + + // sequence number + final sequenceNumber = byteData.getUint8(offset); + + return Tuple2(payloadLength, sequenceNumber); + } + + static MySQLGenericPacketType detectPacketType(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + + final payloadLength = header.item1; + final type = byteData.getUint8(offset); + + if (type == 0x00 && payloadLength >= 7) { + // OK packet + return MySQLGenericPacketType.ok; + } else if (type == 0xfe && payloadLength < 9) { + // EOF packet + return MySQLGenericPacketType.eof; + } else if (type == 0xff) { + return MySQLGenericPacketType.error; + } else { + return MySQLGenericPacketType.other; + } + } + + factory MySQLPacket.decodeInitialHandshake(Uint8List buffer) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLPacketInitialHandshake.decode( + Uint8List.sublistView(buffer, offset), + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeAuthSwitchRequestPacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + if (type != 0xfe) { + throw MySQLProtocolException( + "Can not decode AuthSwitchResponse packet: type is not 0xfe"); + } + + final payload = MySQLPacketAuthSwitchRequest.decode( + Uint8List.sublistView(buffer, offset)); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeGenericPacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + MySQLPacketPayload payload; + + if (type == 0x00 && payloadLength >= 7) { + // OK packet + payload = MySQLPacketOK.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xfe && payloadLength < 9) { + // EOF packet + payload = MySQLPacketEOF.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xff) { + payload = MySQLPacketError.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0x01) { + payload = MySQLPacketExtraAuthData.decode( + Uint8List.sublistView(buffer, offset)); + } else { + throw MySQLProtocolException("Unsupported generic packet: $buffer"); + } + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeColumnCountPacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + MySQLPacketPayload payload; + + if (type == 0x00) { + // OK packet + payload = MySQLPacketOK.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xff) { + payload = MySQLPacketError.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xfb) { + throw MySQLProtocolException( + "COM_QUERY_RESPONSE of type 0xfb is not implemented", + ); + } else { + payload = + MySQLPacketColumnCount.decode(Uint8List.sublistView(buffer, offset)); + } + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeColumnDefPacket(Uint8List buffer) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLColumnDefinitionPacket.decode( + Uint8List.sublistView(buffer, offset), + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeResultSetRowPacket( + Uint8List buffer, + int numOfCols, + ) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLResultSetRowPacket.decode( + Uint8List.sublistView(buffer, offset), + numOfCols, + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeBinaryResultSetRowPacket( + Uint8List buffer, + List colDefs, + ) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLBinaryResultSetRowPacket.decode( + Uint8List.sublistView(buffer, offset), + colDefs, + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeCommPrepareStmtResponsePacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + MySQLPacketPayload payload; + + if (type == 0x00) { + // OK packet + payload = MySQLPacketStmtPrepareOK.decode( + Uint8List.sublistView(buffer, offset), + ); + } else if (type == 0xff) { + payload = MySQLPacketError.decode(Uint8List.sublistView(buffer, offset)); + } else { + throw MySQLProtocolException( + "Unexpected header type while decoding COM_STMT_PREPARE response: $header", + ); + } + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + bool isOkPacket() { + final _payload = payload; + + return _payload is MySQLPacketOK; + } + + bool isErrorPacket() { + final _payload = payload; + return _payload is MySQLPacketError; + } + + bool isEOFPacket() { + final _payload = payload; + + if (_payload is MySQLPacketEOF) { + return true; + } + + return _payload is MySQLPacketOK && + _payload.header == 0xfe && + payloadLength < 9; + } + + Uint8List encode() { + final payloadData = payload.encode(); + + final byteData = ByteData(4); + byteData.setInt32(0, payloadData.lengthInBytes, Endian.little); + byteData.setInt8(3, sequenceID); + + final buffer = ByteDataWriter(endian: Endian.little); + buffer.write(byteData.buffer.asUint8List()); + buffer.write(payloadData); + + return buffer.toBytes(); + } +} + +List sha1(List data) { + return crypto.sha1.convert(data).bytes; +} + +List sha256(List data) { + return crypto.sha256.convert(data).bytes; +} + +Uint8List xor(List aList, List bList) { + final a = Uint8List.fromList(aList); + final b = Uint8List.fromList(bList); + + if (a.lengthInBytes == 0 || b.lengthInBytes == 0) { + throw ArgumentError.value( + "lengthInBytes of Uint8List arguments must be > 0"); + } + + bool aIsBigger = a.lengthInBytes > b.lengthInBytes; + int length = aIsBigger ? a.lengthInBytes : b.lengthInBytes; + + Uint8List buffer = Uint8List(length); + + for (int i = 0; i < length; i++) { + int aa, bb; + try { + aa = a.elementAt(i); + } catch (e) { + aa = 0; + } + try { + bb = b.elementAt(i); + } catch (e) { + bb = 0; + } + + buffer[i] = aa ^ bb; + } + + return buffer; +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_request.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_request.dart new file mode 100644 index 00000000..4465fe84 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_request.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketAuthSwitchRequest extends MySQLPacketPayload { + int header; + String authPluginName; + Uint8List authPluginData; + + MySQLPacketAuthSwitchRequest({ + required this.header, + required this.authPluginData, + required this.authPluginName, + }); + + factory MySQLPacketAuthSwitchRequest.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final authPluginName = buffer.getUtf8NullTerminatedString(offset); + offset += authPluginName.item2; + + final authPluginData = Uint8List.sublistView(buffer, offset); + + return MySQLPacketAuthSwitchRequest( + header: header, + authPluginData: authPluginData, + authPluginName: authPluginName.item1, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_response.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_response.dart new file mode 100644 index 00000000..bf0d2656 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_response.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketAuthSwitchResponse extends MySQLPacketPayload { + Uint8List authData; + + MySQLPacketAuthSwitchResponse({ + required this.authData, + }); + + factory MySQLPacketAuthSwitchResponse.createWithNativePassword({ + required String password, + required Uint8List challenge, + }) { + assert(challenge.length == 20); + final passwordBytes = utf8.encode(password); + + final authData = + xor(sha1(passwordBytes), sha1(challenge + sha1(sha1(passwordBytes)))); + + return MySQLPacketAuthSwitchResponse( + authData: authData, + ); + } + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + buffer.write(authData); + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set.dart new file mode 100644 index 00000000..e5119719 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketBinaryResultSet extends MySQLPacketPayload { + BigInt columnCount; + List columns; + List rows; + + MySQLPacketBinaryResultSet({ + required this.columnCount, + required this.columns, + required this.rows, + }); + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart new file mode 100644 index 00000000..c696dce6 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart @@ -0,0 +1,74 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/exception.dart'; + +class MySQLBinaryResultSetRowPacket extends MySQLPacketPayload { + List values; + + MySQLBinaryResultSetRowPacket({ + required this.values, + }); + + factory MySQLBinaryResultSetRowPacket.decode( + Uint8List buffer, + List colDefs, + ) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + // packet header (always should by 0x00) + final type = byteData.getUint8(offset); + offset += 1; + + if (type != 0) { + throw MySQLProtocolException( + "Can not decode MySQLBinaryResultSetRowPacket: packet type is not 0x00", + ); + } + + List values = []; + + // parse null bitmap + int nullBitmapSize = ((colDefs.length + 9) / 8).floor(); + + final nullBitmap = Uint8List.sublistView( + buffer, + offset, + offset + nullBitmapSize, + ); + + offset += nullBitmapSize; + + // parse binary data + for (int x = 0; x < colDefs.length; x++) { + // check null bitmap first + final bitmapByteIndex = ((x + 2) / 8).floor(); + final bitmapBitIndex = (x + 2) % 8; + + final byteToCheck = nullBitmap[bitmapByteIndex]; + final isNull = (byteToCheck & (1 << bitmapBitIndex)) != 0; + + if (isNull) { + values.add(null); + } else { + final parseResult = parseBinaryColumnData( + colDefs[x].type.intVal, + byteData, + buffer, + offset, + ); + offset += parseResult.item2; + values.add(parseResult.item1); + } + } + + return MySQLBinaryResultSetRowPacket( + values: values, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_count.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_count.dart new file mode 100644 index 00000000..8efc9c04 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_count.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketColumnCount extends MySQLPacketPayload { + BigInt columnCount; + + MySQLPacketColumnCount({ + required this.columnCount, + }); + + factory MySQLPacketColumnCount.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + final columnCount = byteData.getVariableEncInt(0); + + return MySQLPacketColumnCount( + columnCount: columnCount.item1, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_definition.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_definition.dart new file mode 100644 index 00000000..b8cc3e01 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_definition.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLColumnDefinitionPacket extends MySQLPacketPayload { + String catalog; + String schema; + String table; + String orgTable; + String name; + String orgName; + int charset; + int columnLength; + MySQLColumnType type; + + MySQLColumnDefinitionPacket({ + required this.catalog, + required this.schema, + required this.table, + required this.orgTable, + required this.name, + required this.orgName, + required this.charset, + required this.columnLength, + required this.type, + }); + + factory MySQLColumnDefinitionPacket.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final catalog = buffer.getUtf8LengthEncodedString(offset); + offset += catalog.item2; + + final schema = buffer.getUtf8LengthEncodedString(offset); + offset += schema.item2; + + final table = buffer.getUtf8LengthEncodedString(offset); + offset += table.item2; + + final orgTable = buffer.getUtf8LengthEncodedString(offset); + offset += orgTable.item2; + + final name = buffer.getUtf8LengthEncodedString(offset); + offset += name.item2; + + final orgName = buffer.getUtf8LengthEncodedString(offset); + offset += orgName.item2; + + final lengthOfFixedLengthFields = byteData.getVariableEncInt(offset); + offset += lengthOfFixedLengthFields.item2; + + final charset = byteData.getUint16(offset, Endian.little); + offset += 2; + + final columnLength = byteData.getUint32(offset, Endian.little); + offset += 4; + + final type = byteData.getUint8(offset); + offset += 1; + + return MySQLColumnDefinitionPacket( + catalog: catalog.item1, + charset: charset, + columnLength: columnLength, + name: name.item1, + orgName: orgName.item1, + orgTable: orgTable.item1, + schema: schema.item1, + table: table.item1, + type: MySQLColumnType.create(type), + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_empty_payload.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_empty_payload.dart new file mode 100644 index 00000000..16fd1146 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_empty_payload.dart @@ -0,0 +1,9 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketEmptyPayload extends MySQLPacketPayload { + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_eof.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_eof.dart new file mode 100644 index 00000000..b627a90b --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_eof.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketEOF extends MySQLPacketPayload { + int header; + int statusFlags; + + MySQLPacketEOF({ + required this.header, + required this.statusFlags, + }); + + factory MySQLPacketEOF.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + // skip warnings count + offset += 2; + + final statusFlags = byteData.getUint16(offset, Endian.little); + offset += 2; + + return MySQLPacketEOF(header: header, statusFlags: statusFlags); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_error.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_error.dart new file mode 100644 index 00000000..09494c78 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_error.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketError extends MySQLPacketPayload { + int header; + int errorCode; + String errorMessage; + + MySQLPacketError({ + required this.header, + required this.errorCode, + required this.errorMessage, + }); + + factory MySQLPacketError.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final errorCode = byteData.getInt2(offset); + offset += 2; + + // skip sql_state_marker and sql_state + offset += 6; + + // error message + final errorMessage = buffer.getUtf8StringEOF(offset); + + return MySQLPacketError( + header: header, + errorCode: errorCode, + errorMessage: errorMessage, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data.dart new file mode 100644 index 00000000..2851a597 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data.dart @@ -0,0 +1,30 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketExtraAuthData extends MySQLPacketPayload { + int header; + String pluginData; + + MySQLPacketExtraAuthData({ + required this.header, + required this.pluginData, + }); + + factory MySQLPacketExtraAuthData.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + String pluginData = buffer.getUtf8StringEOF(offset); + + return MySQLPacketExtraAuthData(header: header, pluginData: pluginData); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data_response.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data_response.dart new file mode 100644 index 00000000..bcac679e --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data_response.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketExtraAuthDataResponse extends MySQLPacketPayload { + Uint8List data; + + MySQLPacketExtraAuthDataResponse({ + required this.data, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + buffer.write(data); + buffer.writeUint8(0); + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_handshake_response_41.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_handshake_response_41.dart new file mode 100644 index 00000000..52fd600d --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_handshake_response_41.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +const _supportedCapabitilies = mysqlCapFlagClientProtocol41 | + mysqlCapFlagClientSecureConnection | + mysqlCapFlagClientPluginAuth | + mysqlCapFlagClientPluginAuthLenEncClientData | + mysqlCapFlagClientMultiStatements | + mysqlCapFlagClientMultiResults; + +class MySQLPacketHandshakeResponse41 extends MySQLPacketPayload { + int capabilityFlags; + int maxPacketSize; + int characterSet; + Uint8List authResponse; + String authPluginName; + String username; + String? database; + + MySQLPacketHandshakeResponse41({ + required this.capabilityFlags, + required this.maxPacketSize, + required this.characterSet, + required this.authResponse, + required this.authPluginName, + required this.username, + this.database, + }); + + factory MySQLPacketHandshakeResponse41.createWithNativePassword({ + required String username, + required String password, + required MySQLPacketInitialHandshake initialHandshakePayload, + }) { + assert(initialHandshakePayload.authPluginDataPart2 != null); + assert(initialHandshakePayload.authPluginName != null); + + final challenge = initialHandshakePayload.authPluginDataPart1 + + initialHandshakePayload.authPluginDataPart2!.sublist(0, 12); + + assert(challenge.length == 20); + + final passwordBytes = utf8.encode(password); + + final authData = xor( + sha1(passwordBytes), + sha1(challenge + sha1(sha1(passwordBytes))), + ); + + return MySQLPacketHandshakeResponse41( + capabilityFlags: _supportedCapabitilies, + maxPacketSize: 50 * 1024 * 1024, + authPluginName: initialHandshakePayload.authPluginName!, + characterSet: initialHandshakePayload.charset, + authResponse: authData, + username: username, + ); + } + + factory MySQLPacketHandshakeResponse41.createWithCachingSha2Password({ + required String username, + required String password, + required MySQLPacketInitialHandshake initialHandshakePayload, + }) { + final challenge = initialHandshakePayload.authPluginDataPart1 + + initialHandshakePayload.authPluginDataPart2!.sublist(0, 12); + + assert(challenge.length == 20); + + final passwordBytes = utf8.encode(password); + + final authData = xor( + sha256(passwordBytes), + sha256(sha256(sha256(passwordBytes)) + challenge), + ); + + return MySQLPacketHandshakeResponse41( + capabilityFlags: _supportedCapabitilies, + maxPacketSize: 50 * 1024 * 1024, + authPluginName: initialHandshakePayload.authPluginName!, + characterSet: initialHandshakePayload.charset, + authResponse: authData, + username: username, + ); + } + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + if (database != null) { + capabilityFlags = capabilityFlags | mysqlCapFlagClientConnectWithDB; + } + + buffer.writeUint32(capabilityFlags); + buffer.writeUint32(maxPacketSize); + buffer.writeUint8(characterSet); + buffer.write(List.filled(23, 0)); + buffer.write(utf8.encode(username)); + buffer.writeUint8(0); + + if (capabilityFlags & mysqlCapFlagClientSecureConnection != 0) { + buffer.writeVariableEncInt(authResponse.lengthInBytes); + buffer.write(authResponse); + } + + if (database != null && + capabilityFlags & mysqlCapFlagClientConnectWithDB != 0) { + buffer.write(utf8.encode(database!)); + buffer.writeUint8(0); + } + + if (capabilityFlags & mysqlCapFlagClientPluginAuth != 0) { + buffer.write(utf8.encode(authPluginName)); + buffer.writeUint8(0); + } + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_initial_handshake.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_initial_handshake.dart new file mode 100644 index 00000000..2b5dd421 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_initial_handshake.dart @@ -0,0 +1,116 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketInitialHandshake extends MySQLPacketPayload { + int protocolVersion; + String serverVersion; + int connectionID; + Uint8List authPluginDataPart1; + int capabilityFlags; + int charset; + Uint8List statusFlags; + Uint8List? authPluginDataPart2; + String? authPluginName; + + MySQLPacketInitialHandshake({ + required this.protocolVersion, + required this.serverVersion, + required this.connectionID, + required this.authPluginDataPart1, + required this.authPluginDataPart2, + required this.capabilityFlags, + required this.charset, + required this.statusFlags, + required this.authPluginName, + }); + + factory MySQLPacketInitialHandshake.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + // protocol version + final protocolVersion = byteData.getUint8(offset); + offset += 1; + + // server version + final serverVersion = buffer.getUtf8NullTerminatedString(offset); + offset += serverVersion.item2; + + // connection id + final connectionID = byteData.getUint32(offset, Endian.little); + offset += 4; + + // auth-plugin-data-part-1 + final authPluginDataPart1 = + Uint8List.sublistView(buffer, offset, offset + 8); + offset += 9; // 8 + filler; + + // capability flags (lower 2 bytes) + final capabilitiesBytesData = ByteData(4); + capabilitiesBytesData.setUint8(3, buffer[offset]); + capabilitiesBytesData.setUint8(2, buffer[offset + 1]); + offset += 2; + + // character set + final charset = byteData.getUint8(offset); + offset += 1; + + final statusFlags = Uint8List.sublistView(buffer, offset, offset + 2); + offset += 2; + + // capability flags (upper 2 bytes) + capabilitiesBytesData.setUint8(1, buffer[offset]); + capabilitiesBytesData.setUint8(0, buffer[offset + 1]); + offset += 2; + + final capabilityFlags = capabilitiesBytesData.getUint32(0, Endian.big); + + // length of auth-plugin-data + int authPluginDataLength = 0; + + if (capabilityFlags & mysqlCapFlagClientPluginAuth != 0) { + authPluginDataLength = byteData.getUint8(offset); + } + + offset += 1; + + // reserved + offset += 10; + + Uint8List? authPluginDataPart2; + + if (capabilityFlags & mysqlCapFlagClientSecureConnection != 0) { + int length = max(13, authPluginDataLength - 8); + + authPluginDataPart2 = + Uint8List.sublistView(buffer, offset, offset + length); + + offset += length; + } + + String? authPluginName; + + if (capabilityFlags & mysqlCapFlagClientPluginAuth != 0) { + authPluginName = buffer.getUtf8NullTerminatedString(offset).item1; + } + + return MySQLPacketInitialHandshake( + authPluginDataPart1: authPluginDataPart1, + authPluginDataPart2: authPluginDataPart2, + authPluginName: authPluginName, + capabilityFlags: capabilityFlags, + charset: charset, + connectionID: connectionID, + protocolVersion: protocolVersion, + serverVersion: serverVersion.item1, + statusFlags: statusFlags, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ok.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ok.dart new file mode 100644 index 00000000..935304b6 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ok.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketOK extends MySQLPacketPayload { + int header; + BigInt affectedRows; + BigInt lastInsertID; + + MySQLPacketOK({ + required this.header, + required this.affectedRows, + required this.lastInsertID, + }); + + factory MySQLPacketOK.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final affectedRows = byteData.getVariableEncInt(offset); + offset += affectedRows.item2; + + final lastInsertID = byteData.getVariableEncInt(offset); + offset += lastInsertID.item2; + + return MySQLPacketOK( + header: header, + affectedRows: affectedRows.item1, + lastInsertID: lastInsertID.item1, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set.dart new file mode 100644 index 00000000..395b40ec --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketResultSet extends MySQLPacketPayload { + BigInt columnCount; + List columns; + List rows; + + MySQLPacketResultSet({ + required this.columnCount, + required this.columns, + required this.rows, + }); + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart new file mode 100644 index 00000000..9c45cc67 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; +import 'package:tuple/tuple.dart'; + +class MySQLResultSetRowPacket extends MySQLPacketPayload { + List values; + + MySQLResultSetRowPacket({ + required this.values, + }); + + factory MySQLResultSetRowPacket.decode(Uint8List buffer, int numOfCols) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + List values = []; + + for (int x = 0; x < numOfCols; x++) { + Tuple2 value; + final nextByte = byteData.getUint8(offset); + + if (nextByte == 0xfb) { + values.add(null); + offset += 1; + } else { + value = buffer.getUtf8LengthEncodedString(offset); + values.add(value.item1); + offset += value.item2; + } + } + + return MySQLResultSetRowPacket( + values: values, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ssl_request.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ssl_request.dart new file mode 100644 index 00000000..800e6ad0 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ssl_request.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; + +const _supportedCapabitilies = mysqlCapFlagClientProtocol41 | + mysqlCapFlagClientSecureConnection | + mysqlCapFlagClientPluginAuth | + mysqlCapFlagClientPluginAuthLenEncClientData | + mysqlCapFlagClientMultiStatements | + mysqlCapFlagClientMultiResults | + mysqlCapFlagClientSsl; + +class MySQLPacketSSLRequest extends MySQLPacketPayload { + int capabilityFlags; + int maxPacketSize; + int characterSet; + bool connectWithDB; + + MySQLPacketSSLRequest._({ + required this.capabilityFlags, + required this.maxPacketSize, + required this.characterSet, + required this.connectWithDB, + }); + + factory MySQLPacketSSLRequest.createDefault({ + required MySQLPacketInitialHandshake initialHandshakePayload, + required bool connectWithDB, + }) { + return MySQLPacketSSLRequest._( + capabilityFlags: _supportedCapabitilies, + maxPacketSize: 50 * 1024 * 1024, + characterSet: initialHandshakePayload.charset, + connectWithDB: connectWithDB, + ); + } + + @override + Uint8List encode() { + if (connectWithDB) { + capabilityFlags = capabilityFlags | mysqlCapFlagClientConnectWithDB; + } + + final buffer = ByteDataWriter(endian: Endian.little); + + buffer.writeUint32(capabilityFlags); + buffer.writeUint32(maxPacketSize); + buffer.writeUint8(characterSet); + buffer.write(List.filled(23, 0)); + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_stmt_prepare_ok.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_stmt_prepare_ok.dart new file mode 100644 index 00000000..b2ae9cdd --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_stmt_prepare_ok.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketStmtPrepareOK extends MySQLPacketPayload { + int header; + int stmtID; + int numOfCols; + int numOfParams; + int numOfWarnings; + + MySQLPacketStmtPrepareOK({ + required this.header, + required this.stmtID, + required this.numOfCols, + required this.numOfParams, + required this.numOfWarnings, + }); + + factory MySQLPacketStmtPrepareOK.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final statementID = byteData.getUint32(offset, Endian.little); + offset += 4; + + final numColumns = byteData.getUint16(offset, Endian.little); + offset += 2; + + final numParams = byteData.getUint16(offset, Endian.little); + offset += 2; + + // filler + offset += 1; + + final numWarnings = byteData.getUint16(offset, Endian.little); + offset += 2; + + return MySQLPacketStmtPrepareOK( + header: header, + stmtID: statementID, + numOfCols: numColumns, + numOfParams: numParams, + numOfWarnings: numWarnings, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/pubspec.yaml b/third_party/mysql_client/pubspec.yaml new file mode 100644 index 00000000..ee95c17a --- /dev/null +++ b/third_party/mysql_client/pubspec.yaml @@ -0,0 +1,24 @@ +name: mysql_client +description: Native MySQL client written in Dart. Tested with MySQL Percona Server (5.7, 8), MariaDB (10). Supports TLS. +version: 0.0.27 +homepage: https://github.com/zim32/mysql.dart +repository: https://github.com/zim32/mysql.dart +platforms: + android: + ios: + linux: + macos: + windows: + +environment: + sdk: '>=2.16.0 <3.0.0' + + +dev_dependencies: + hex: ^0.2.0 + lints: ^1.0.0 + test: ^1.20.1 +dependencies: + buffer: ^1.1.1 + crypto: ^3.0.1 + tuple: ^2.0.0 \ No newline at end of file diff --git a/third_party/mysql_client/test/column_type_test.dart b/third_party/mysql_client/test/column_type_test.dart new file mode 100644 index 00000000..7770552d --- /dev/null +++ b/third_party/mysql_client/test/column_type_test.dart @@ -0,0 +1,542 @@ +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + test( + "testing decimal type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDecimal); + + dynamic result = + sqlType.convertStringValueToProvidedType('10.00'); + result = sqlType.convertStringValueToProvidedType('-10.00'); + expect(result, '-10.00'); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, '0'); + result = sqlType.convertStringValueToProvidedType('9999.99'); + expect(result, '9999.99'); + result = sqlType.convertStringValueToProvidedType('1000123'); + expect(result, '1000123'); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + }, + ); + + test( + "testing tiny type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeTiny); + + dynamic result = sqlType.convertStringValueToProvidedType('1', 1); + expect(result, true); + result = sqlType.convertStringValueToProvidedType('0', 1); + expect(result, false); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, true); + result = sqlType.convertStringValueToProvidedType('1', 1); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0', 1); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2', 1); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1', 2), + throwsException, + ); + }, + ); + + test( + "testing short type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeShort); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing long type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeLong); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing long long type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeLongLong); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing int24 type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeLongLong); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing float type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeFloat); + + dynamic result = + sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('-10.00'); + expect(result, -10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, '10.00'); + + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0.0'), + throwsException, + ); + }, + ); + + test( + "testing double type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDouble); + + dynamic result = + sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('-10.00'); + expect(result, -10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, '10.00'); + + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0.0'), + throwsException, + ); + }, + ); + + test( + "testing timestamp type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeTimestamp); + + dynamic result = + sqlType.convertStringValueToProvidedType('123451234'); + expect(result, '123451234'); + + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + }, + ); + + test( + "testing date type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDate); + + dynamic result = + sqlType.convertStringValueToProvidedType('2022-01-02'); + expect(result, '2022-01-02'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + }, + ); + + test( + "testing time type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDate); + + dynamic result = + sqlType.convertStringValueToProvidedType('02:00:34'); + expect(result, '02:00:34'); + + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + }, + ); + + test( + "testing datetime type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDate); + + dynamic result = sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'); + expect(result, '2022-01-05 02:00:34'); + + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + }, + ); + + test( + "testing year type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeYear); + + dynamic result = sqlType.convertStringValueToProvidedType('2022'); + expect(result, '2022'); + result = sqlType.convertStringValueToProvidedType('2022'); + expect(result, 2022); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing varchar type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeVarChar); + + dynamic result = + sqlType.convertStringValueToProvidedType('Some text'); + expect(result, 'Some text'); + + result = + sqlType.convertStringValueToProvidedType('Какой-Ρ‚ΠΎ тСкст'); + expect(result, 'Какой-Ρ‚ΠΎ тСкст'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing string type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeString); + + dynamic result = + sqlType.convertStringValueToProvidedType('Some text'); + expect(result, 'Some text'); + + result = + sqlType.convertStringValueToProvidedType('Какой-Ρ‚ΠΎ тСкст'); + expect(result, 'Какой-Ρ‚ΠΎ тСкст'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing var string type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeVarString); + + dynamic result = + sqlType.convertStringValueToProvidedType('Some text'); + expect(result, 'Some text'); + + result = + sqlType.convertStringValueToProvidedType('Какой-Ρ‚ΠΎ тСкст'); + expect(result, 'Какой-Ρ‚ΠΎ тСкст'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing enum type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeEnum); + + dynamic result = + sqlType.convertStringValueToProvidedType('process'); + expect(result, 'process'); + + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + }, + ); + + test( + "testing set type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeSet); + + dynamic result = + sqlType.convertStringValueToProvidedType('process'); + expect(result, 'process'); + + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + }, + ); +} diff --git a/third_party/mysql_client/test/mysql_client.dart b/third_party/mysql_client/test/mysql_client.dart new file mode 100644 index 00000000..91a0af25 --- /dev/null +++ b/third_party/mysql_client/test/mysql_client.dart @@ -0,0 +1,475 @@ +import 'dart:io'; +import 'package:mysql_client/exception.dart'; +import 'package:mysql_client/mysql_client.dart'; +import 'package:test/test.dart'; + +void main() { + final host = '127.0.0.1'; + final port = 3306; + final user = 'your_user'; + final pass = 'your_password'; + final db = 'testdb'; + + late MySQLConnection conn; + + setUpAll( + () async { + stdout.writeln("\n!!!!!!!!!!!!!!!!!!!!!"); + stdout.writeln( + "Warning this test will execute real queries to database in host: $host, port: $port, dbname: $db. Continue? y/n"); + stdout.writeln("!!!!!!!!!!!!!!!!!!!!!"); + + final response = stdin.readLineSync(); + + if (response != 'y') { + exit(0); + } + + conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: user, + password: pass, + secure: true, + ); + + expect(conn.connected, false); + await conn.connect(); + expect(conn.connected, true); + + await conn.execute("DROP DATABASE IF EXISTS $db"); + await conn.execute( + "CREATE DATABASE $db CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci", + ); + await conn.execute("USE $db"); + await conn.execute(""" +create table book +( + id int auto_increment primary key, + author_id int null, + title varchar(255) not null, + price int default 0 not null, + created_at datetime not null, + some_time time null +) +"""); + }, + ); + + tearDownAll( + () async { + int counter = 0; + + conn.onClose(() => counter++); + conn.onClose(() => counter++); + + await conn.close(); + expect(conn.connected, false); + expect(counter, 2); + }, + ); + + test( + "testing bad connection", + () async { + try { + final localConn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: 'fake', + password: 'fake', + secure: true, + ); + + await localConn.connect(); + + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing insert", + () async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "Новая ΠΊΠ½ΠΈΠ³Π° 😁", + "price": 100, + "created": "2020-01-01 01:00:15", + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 1); + }, + ); + + test( + "testing select", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "1"); + expect(row.colAt(1), null); + expect(row.colAt(2), "Новая ΠΊΠ½ΠΈΠ³Π° 😁"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), null); + expect(row.typedColAt(0), 1); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "1"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "Новая ΠΊΠ½ΠΈΠ³Π° 😁"); + expect(row.colByName('Title'), "Новая ΠΊΠ½ΠΈΠ³Π° 😁"); + expect(row.colByName('PrIce'), "100"); + expect(row.typedColByName('price'), 100); + expect(row.typedColByName('price'), 100.00); + expect(row.typedColByName('Price'), 100); + expect(row.typedColByName('pRice'), 100.00); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), null); + expect(row.colByName('Some_Time'), null); + + expect(row.assoc(), { + "id": "1", + "author_id": null, + "title": "Новая ΠΊΠ½ΠΈΠ³Π° 😁", + "price": "100", + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + + expect(row.typedAssoc(), { + "id": 1, + "author_id": null, + "title": "Новая ΠΊΠ½ΠΈΠ³Π° 😁", + "price": 100, + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + }, + ); + + test( + "testing error is thrown if syntax error", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERES ASD id = :id", + { + "id": 1, + }, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if null passed for not-null column", + () async { + try { + await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": null, + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if syntax error in prepared stmt", + () async { + try { + await conn.prepare( + "INSERT INTO book (author_id, title) VA_LUESD (?, ?)", + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing delete", + () async { + final result = await conn.execute( + "DELETE FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 0); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing transaction", + () async { + await conn.transactional((conn) async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": "New book", + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 2); + }); + }, + ); + + test( + "testing select after transaction", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 2, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "2"); + expect(row.colAt(1), null); + expect(row.colAt(2), "New book"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), "01:15:25"); + expect(row.typedColAt(0), 2); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "2"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "New book"); + expect(row.colByName('price'), "100"); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), "01:15:25"); + }, + ); + + test("testing double transaction", () async { + try { + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + } catch (e) { + fail("Exception is thrown"); + } + }); + + test("testing error is thrown if prevent double transaction", () async { + try { + await Future.wait([ + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + ]); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + expect(e.toString(), "MySQLClientException: Already in transaction"); + } + }); + + test( + "testing missing param", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERE id = :id", + {"foo": "bar"}, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing prepared statement", + () async { + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + expect(stmt.numOfParams, 3); + + var result = + await stmt.execute(['Some title 1', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 3); + + result = await stmt.execute(['Some title 2', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 4); + + await stmt.deallocate(); + + // check throws error + try { + result = await stmt.execute( + ['Some title 2', 200, '2022-04-02 00:00:00'], + ); + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + + // check rows + result = await conn.execute('SELECT COUNT(id) FROM book'); + expect(result.rows.first.colAt(0), '3'); + }, + ); + + test("testing string encoding in prepared statements", () async { + var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", + ); + + var result = await stmt.execute([null, 'δΈ­ζ–‡ζ ‡ι’˜', 120, '2022-01-01']); + await stmt.deallocate(); + + expect(result.affectedRows.toInt(), 1); + }); + + test("testing prepared stmt select", () async { + final stmt = await conn.prepare( + 'SELECT * FROM book WHERE title = ?', + ); + + final result = await stmt.execute(['Some title 2']); + + expect(result.numOfRows, 1); + expect(result.affectedRows.toInt(), 0); + }); + + test( + "testing empty result set", + () async { + final result = await conn.execute("SELECT * FROM book WHERE id = 99999"); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing empty result for prepared statement", + () async { + final stmt = await conn.prepare("SELECT * FROM book WHERE id = 99999"); + final result = await stmt.execute([]); + expect(result.numOfRows, 0); + await stmt.deallocate(); + }, + ); + + test( + "testing multiple statements", + () async { + final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", + ); + + expect(resultSets.next, isNotNull); + + final resultSetsList = resultSets.toList(); + expect(resultSetsList.length, 2); + + expect(resultSetsList[0].rows.first.colByName("val_1_1"), "1"); + expect(resultSetsList[1].rows.first.colByName("val_2_1"), "2"); + expect(resultSetsList[1].rows.first.colByName("val_2_2"), "3"); + }, + ); + + test( + "stress test: insert 5000 rows", + () async { + await conn.execute('TRUNCATE TABLE book'); + + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + print("Inserting 5000 rows..."); + + for (int x = 0; x < 5000; x++) { + await stmt.execute( + ['Some title $x', x, '2022-04-02 00:00:00'], + ); + } + + await stmt.deallocate(); + + // check rows + var result = await conn.execute('SELECT * FROM book', {}, true); + + int receivedRows = 0; + + await for (final _ in result.rowsStream) { + receivedRows++; + } + + expect(receivedRows, 5000); + }, + timeout: Timeout(Duration(seconds: 60)), + ); +} diff --git a/third_party/mysql_client/test/mysql_client_socket.dart b/third_party/mysql_client/test/mysql_client_socket.dart new file mode 100644 index 00000000..7f9c57ca --- /dev/null +++ b/third_party/mysql_client/test/mysql_client_socket.dart @@ -0,0 +1,476 @@ +import 'dart:io'; +import 'package:mysql_client/exception.dart'; +import 'package:mysql_client/mysql_client.dart'; +import 'package:test/test.dart'; + +void main() { + final host = + InternetAddress('/tmp/mysql.sock', type: InternetAddressType.unix); + final port = 3306; + final user = 'your_user'; + final pass = 'your_password'; + final db = 'testdb'; + + late MySQLConnection conn; + + setUpAll( + () async { + stdout.writeln("\n!!!!!!!!!!!!!!!!!!!!!"); + stdout.writeln( + "Warning this test will execute real queries to database on Socket: $host, port: $port, dbname: $db. Continue? y/n"); + stdout.writeln("!!!!!!!!!!!!!!!!!!!!!"); + + final response = stdin.readLineSync(); + + if (response != 'y') { + exit(0); + } + + conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: user, + password: pass, + secure: true, + ); + + expect(conn.connected, false); + await conn.connect(); + expect(conn.connected, true); + + await conn.execute("DROP DATABASE IF EXISTS $db"); + await conn.execute( + "CREATE DATABASE $db CHARACTER SET utf8 COLLATE utf8_general_ci", + ); + await conn.execute("USE $db"); + await conn.execute(""" +create table book +( + id int auto_increment primary key, + author_id int null, + title varchar(255) not null, + price int default 0 not null, + created_at datetime not null, + some_time time null +) +"""); + }, + ); + + tearDownAll( + () async { + int counter = 0; + + conn.onClose(() => counter++); + conn.onClose(() => counter++); + + await conn.close(); + expect(conn.connected, false); + expect(counter, 2); + }, + ); + + test( + "testing bad connection", + () async { + try { + final localConn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: 'fake', + password: 'fake', + secure: true, + ); + + await localConn.connect(); + + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing insert", + () async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "Новая ΠΊΠ½ΠΈΠ³Π°", + "price": 100, + "created": "2020-01-01 01:00:15", + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 1); + }, + ); + + test( + "testing select", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "1"); + expect(row.colAt(1), null); + expect(row.colAt(2), "Новая ΠΊΠ½ΠΈΠ³Π°"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), null); + expect(row.typedColAt(0), 1); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "1"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "Новая ΠΊΠ½ΠΈΠ³Π°"); + expect(row.colByName('Title'), "Новая ΠΊΠ½ΠΈΠ³Π°"); + expect(row.colByName('PrIce'), "100"); + expect(row.typedColByName('price'), 100); + expect(row.typedColByName('price'), 100.00); + expect(row.typedColByName('Price'), 100); + expect(row.typedColByName('pRice'), 100.00); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), null); + expect(row.colByName('Some_Time'), null); + + expect(row.assoc(), { + "id": "1", + "author_id": null, + "title": "Новая ΠΊΠ½ΠΈΠ³Π°", + "price": "100", + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + + expect(row.typedAssoc(), { + "id": 1, + "author_id": null, + "title": "Новая ΠΊΠ½ΠΈΠ³Π°", + "price": 100, + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + }, + ); + + test( + "testing error is thrown if syntax error", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERES ASD id = :id", + { + "id": 1, + }, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if null passed for not-null column", + () async { + try { + await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": null, + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if syntax error in prepared stmt", + () async { + try { + await conn.prepare( + "INSERT INTO book (author_id, title) VA_LUESD (?, ?)", + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing delete", + () async { + final result = await conn.execute( + "DELETE FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 0); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing transaction", + () async { + await conn.transactional((conn) async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": "New book", + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 2); + }); + }, + ); + + test( + "testing select after transaction", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 2, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "2"); + expect(row.colAt(1), null); + expect(row.colAt(2), "New book"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), "01:15:25"); + expect(row.typedColAt(0), 2); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "2"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "New book"); + expect(row.colByName('price'), "100"); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), "01:15:25"); + }, + ); + + test("testing double transaction", () async { + try { + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + } catch (e) { + fail("Exception is thrown"); + } + }); + + test("testing error is thrown if prevent double transaction", () async { + try { + await Future.wait([ + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + ]); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + expect(e.toString(), "MySQLClientException: Already in transaction"); + } + }); + + test( + "testing missing param", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERE id = :id", + {"foo": "bar"}, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing prepared statement", + () async { + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + expect(stmt.numOfParams, 3); + + var result = + await stmt.execute(['Some title 1', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 3); + + result = await stmt.execute(['Some title 2', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 4); + + await stmt.deallocate(); + + // check throws error + try { + result = await stmt.execute( + ['Some title 2', 200, '2022-04-02 00:00:00'], + ); + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + + // check rows + result = await conn.execute('SELECT COUNT(id) FROM book'); + expect(result.rows.first.colAt(0), '3'); + }, + ); + + test("testing string encoding in prepared statements", () async { + var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", + ); + + var result = await stmt.execute([null, 'δΈ­ζ–‡ζ ‡ι’˜', 120, '2022-01-01']); + await stmt.deallocate(); + + expect(result.affectedRows.toInt(), 1); + }); + + test("testing prepared stmt select", () async { + final stmt = await conn.prepare( + 'SELECT * FROM book WHERE title = ?', + ); + + final result = await stmt.execute(['Some title 2']); + + expect(result.numOfRows, 1); + expect(result.affectedRows.toInt(), 0); + }); + + test( + "testing empty result set", + () async { + final result = await conn.execute("SELECT * FROM book WHERE id = 99999"); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing empty result for prepared statement", + () async { + final stmt = await conn.prepare("SELECT * FROM book WHERE id = 99999"); + final result = await stmt.execute([]); + expect(result.numOfRows, 0); + await stmt.deallocate(); + }, + ); + + test( + "testing multiple statements", + () async { + final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", + ); + + expect(resultSets.next, isNotNull); + + final resultSetsList = resultSets.toList(); + expect(resultSetsList.length, 2); + + expect(resultSetsList[0].rows.first.colByName("val_1_1"), "1"); + expect(resultSetsList[1].rows.first.colByName("val_2_1"), "2"); + expect(resultSetsList[1].rows.first.colByName("val_2_2"), "3"); + }, + ); + + test( + "stress test: insert 5000 rows", + () async { + await conn.execute('TRUNCATE TABLE book'); + + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + print("Inserting 5000 rows..."); + + for (int x = 0; x < 5000; x++) { + await stmt.execute( + ['Some title $x', x, '2022-04-02 00:00:00'], + ); + } + + await stmt.deallocate(); + + // check rows + var result = await conn.execute('SELECT * FROM book', {}, true); + + int receivedRows = 0; + + await for (final _ in result.rowsStream) { + receivedRows++; + } + + expect(receivedRows, 5000); + }, + timeout: Timeout(Duration(seconds: 60)), + ); +} diff --git a/third_party/mysql_client/test/mysql_packet_test.dart b/third_party/mysql_client/test/mysql_packet_test.dart new file mode 100644 index 00000000..4e8d98ca --- /dev/null +++ b/third_party/mysql_client/test/mysql_packet_test.dart @@ -0,0 +1,640 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:hex/hex.dart'; +import 'package:test/test.dart'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +void main() { + group("testing variable length int", () { + group('test decoding one byte ints', () { + test("decoding int value 16", () { + var buff = ByteData.sublistView(Uint8List.fromList([16])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 16); + expect(actual.item2, 1); + }); + test("decoding int value 0", () { + var buff = ByteData.sublistView(Uint8List.fromList([0])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 0); + expect(actual.item2, 1); + }); + test("decoding int value 250", () { + var buff = ByteData.sublistView(Uint8List.fromList([250])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 250); + expect(actual.item2, 1); + }); + }); + + group('test decoding two byte ints', () { + test("decoding int value 251", () { + var buff = ByteData.sublistView(Uint8List.fromList([0xfc, 0xfb, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 251); + expect(actual.item2, 3); + }); + test("decoding int value 252", () { + var buff = ByteData.sublistView(Uint8List.fromList([0xfc, 0xfc, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 252); + expect(actual.item2, 3); + }); + }); + + group('test decoding three byte ints', () { + test("decoding int value 0", () { + var buff = + ByteData.sublistView(Uint8List.fromList([0xfd, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 0); + expect(actual.item2, 4); + }); + test("decoding int value 1048576", () { + var buff = + ByteData.sublistView(Uint8List.fromList([0xfd, 0x00, 0x00, 0x10])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 1048576); + expect(actual.item2, 4); + }); + test("decoding int value 1048613", () { + var buff = + ByteData.sublistView(Uint8List.fromList([0xfd, 0x25, 0x00, 0x10])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 1048613); + expect(actual.item2, 4); + }); + }); + group('test decoding eight byte ints', () { + test("decoding int value 0", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 0); + expect(actual.item2, 9); + }); + test("decoding int value 21", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 21); + expect(actual.item2, 9); + }); + test("decoding int value 4294967295", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 4294967295); + expect(actual.item2, 9); + }); + test("decoding int value 1099511627775", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toString(), '1099511627775'); + expect(actual.item2, 9); + }); + test("test encoding int value 0", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(0); + expect(writer.toBytes(), [0x00]); + }); + test("test encoding int value 1", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(1); + expect(writer.toBytes(), [0x01]); + }); + test("test encoding int value 250", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(250); + expect(writer.toBytes(), [0xfa]); + }); + test("test encoding int value 251", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(251); + expect(writer.toBytes(), [0xfc, 0xfb, 0x00]); + }); + test("test encoding int value 252", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(252); + expect(writer.toBytes(), [0xfc, 0xfc, 0x00]); + }); + test("test encoding int value 65536", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(65536); + expect(writer.toBytes(), [0xfd, 0x00, 0x00, 0x01]); + }); + test("test encoding int value 65537", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(65537); + expect(writer.toBytes(), [0xfd, 0x01, 0x00, 0x01]); + }); + test("test encoding int value 16777216", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(16777216); + expect(writer.toBytes(), + [0xfe, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]); + }); + test("test encoding int value 16777217", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(16777217); + expect(writer.toBytes(), + [0xfe, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]); + }); + test("test encoding int value 9223372036854775807", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(9223372036854775807); + expect(writer.toBytes(), + [0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f]); + }); + }); + }); + + group("testing string parsing", () { + test("testing getNullTerminatedString 1", () { + final buffer = Uint8List.fromList([0x61, 0x62, 0x00]); + final actual = buffer.getUtf8NullTerminatedString(0); + expect(actual.item1, "ab"); + expect(actual.item2, 3); + }); + test("testing getNullTerminatedString 2", () { + final buffer = Uint8List.fromList([0x10, 0x61, 0x62, 0x00, 0x12, 0xff]); + final actual = buffer.getUtf8NullTerminatedString(1); + expect(actual.item1, "ab"); + expect(actual.item2, 3); + }); + test("testing getNullTerminatedString multibyte 1", () { + final buffer = Uint8List.fromList([ + 0xd1, + 0x82, + 0xd0, + 0xb5, + 0xd1, + 0x81, + 0xd1, + 0x82, + 0x00, + ]); + final actual = buffer.getUtf8NullTerminatedString(0); + expect(actual.item1, "тСст"); + expect(actual.item2, 9); + }); + test("testing getNullTerminatedString multibyte 2", () { + final buffer = Uint8List.fromList([ + 0x01, + 0x02, + 0xd1, + 0x82, + 0xd0, + 0xb5, + 0xd1, + 0x81, + 0xd1, + 0x82, + 0x00, + 0x01, + 0x02, + ]); + final actual = buffer.getUtf8NullTerminatedString(2); + expect(actual.item1, "тСст"); + expect(actual.item2, 9); + }); + test("testing getStringEOF 1", () { + final buffer = Uint8List.fromList([0x61, 0x62]); + final actual = buffer.getUtf8StringEOF(0); + expect(actual, "ab"); + }); + test("testing getStringEOF 2", () { + final buffer = Uint8List.fromList([0xff, 0xff, 0x61, 0x62]); + final actual = buffer.getUtf8StringEOF(2); + expect(actual, "ab"); + }); + test("testing getStringEOF multibyte 1", () { + final buffer = + Uint8List.fromList([0xd1, 0x82, 0xd0, 0xb5, 0xd1, 0x81, 0xd1, 0x82]); + final actual = buffer.getUtf8StringEOF(0); + expect(actual, "тСст"); + }); + test("testing getStringEOF multibyte 2", () { + final buffer = Uint8List.fromList( + [0x00, 0x01, 0xd1, 0x82, 0xd0, 0xb5, 0xd1, 0x81, 0xd1, 0x82]); + final actual = buffer.getUtf8StringEOF(2); + expect(actual, "тСст"); + }); + test("testing getLengthEncodedString 1", () { + final buffer = Uint8List.fromList([0x03, 0x64, 0x65, 0x66]); + final actual = buffer.getUtf8LengthEncodedString(0); + expect(actual.item1, "def"); + expect(actual.item2, 4); + }); + test("testing getLengthEncodedString 2", () { + final buffer = Uint8List.fromList([0x03, 0x64, 0x65, 0x66, 0xff, 0xcc]); + final actual = buffer.getUtf8LengthEncodedString(0); + expect(actual.item1, "def"); + expect(actual.item2, 4); + }); + test("testing getLengthEncodedString 3", () { + final buffer = + Uint8List.fromList([0xff, 0xde, 0x03, 0x64, 0x65, 0x66, 0xff, 0xcc]); + final actual = buffer.getUtf8LengthEncodedString(2); + expect(actual.item1, "def"); + expect(actual.item2, 4); + }); + test("testing getLengthEncodedString for long string", () { + final buffer = Uint8List.fromList([ + 0xfc, + 0x40, + 0x01, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65 + ]); + final actual = buffer.getUtf8LengthEncodedString(0); + expect(actual.item1, + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + expect(actual.item2, 323); + }); + }); + + group("testing packets parsing", () { + test("testing initial handshake packet", () { + final buffer = Uint8List.fromList( + HEX.decode( + '4d0000000a352e372e33352d3338007b000000181e73526349597c00ffff080200ffc1150000000000000000000007317a2531721d587825181d006d7973716c5f6e61746976655f70617373776f726400', + ), + ); + + final packet = MySQLPacket.decodeInitialHandshake(buffer); + expect(packet.payload, isA()); + expect(packet.sequenceID, 0); + expect(packet.payloadLength, 77); + + final payload = packet.payload as MySQLPacketInitialHandshake; + expect(payload.protocolVersion, 10); + expect(payload.serverVersion, "5.7.35-38"); + expect(payload.connectionID, 123); + expect( + payload.authPluginDataPart1, + Uint8List.fromList(HEX.decode('181e73526349597c')), + ); + expect( + payload.authPluginDataPart2, + Uint8List.fromList(HEX.decode('07317a2531721d587825181d00')), + ); + + expect(payload.authPluginName, "mysql_native_password"); + + //actual network data 0xffffffc1 + expect(payload.capabilityFlags, 0xc1ffffff); + + expect( + payload.capabilityFlags & mysqlCapFlagClientMultiStatements, + greaterThan(0), + ); + expect( + payload.capabilityFlags & mysqlCapFlagClientMultiResults, + greaterThan(0), + ); + expect( + payload.capabilityFlags & mysqlCapFlagClientPluginAuth, + greaterThan(0), + ); + expect( + payload.capabilityFlags & mysqlCapFlagClientPluginAuth, + greaterThan(0), + ); + }); + + test("testing response ok packet", () { + final buffer = Uint8List.fromList(HEX.decode('0700000200000002000000')); + final packet = MySQLPacket.decodeGenericPacket(buffer); + expect(packet.payload, isA()); + expect(packet.payloadLength, 7); + expect(packet.sequenceID, 2); + expect(packet.isOkPacket(), true); + expect(packet.isEOFPacket(), false); + expect(packet.isErrorPacket(), false); + final payload = packet.payload as MySQLPacketOK; + expect(payload.header, 0x00); + expect(payload.affectedRows.toInt(), 0); + }); + }); +} diff --git a/third_party/mysql_client/test/test.dart b/third_party/mysql_client/test/test.dart new file mode 100644 index 00000000..ce9b141f --- /dev/null +++ b/third_party/mysql_client/test/test.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +Future faledFunction() async { + final completer = Completer(); + + await Future.delayed(Duration(seconds: 3)); + + completer.completeError("Test error", StackTrace.current); + + return completer.future; +} + +void main() async { + print("start"); + try { + await faledFunction(); + } catch (e) { + print("Catched"); + return; + } + + print("end"); +}