diff --git a/CHANGELOG.md b/CHANGELOG.md index f26b69b3..f17c730c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 1.9.4-beta.1 + +- `DBSQLAdapterCapability`: added `statementsCache`. + +- `ConditionSQLEncoder`: + - Added property `forCachedStatements`: forces generation of SQL statements suitable for caching. + - Fix resolution of parameter values present only in `EncodingContext.encodingParameters` + (i.e., generated by the encoder, not passed as normal parameters). + +- New mixin `StatementCache` + - New `CachedStatement`. + +- `PostgreSQLConnectionWrapper` now has support for statements cache (with `StatementCache`). + ## 1.9.3 - `DBPostgreSQLAdapter`: diff --git a/lib/src/bones_api_base.dart b/lib/src/bones_api_base.dart index 0148a595..8c03da1c 100644 --- a/lib/src/bones_api_base.dart +++ b/lib/src/bones_api_base.dart @@ -42,7 +42,7 @@ typedef APILogger = void Function(APIRoot apiRoot, String type, String? message, /// Bones API Library class. class BonesAPI { // ignore: constant_identifier_names - static const String VERSION = '1.9.3'; + static const String VERSION = '1.9.4-beta.1'; static bool _boot = false; diff --git a/lib/src/bones_api_condition_encoder.dart b/lib/src/bones_api_condition_encoder.dart index 7eb894ae..528b599f 100644 --- a/lib/src/bones_api_condition_encoder.dart +++ b/lib/src/bones_api_condition_encoder.dart @@ -1158,6 +1158,10 @@ abstract class ConditionEncoder { if (valueAsList) { return _resolveParameterValueImpl(value, context, valueType, true) .resolveMapped((values) { + if (values is EncodingValue) { + return values.asEncodingValueList(); + } + var list = values is List ? values : (values is Iterable ? values.toList(growable: false) : [values]); @@ -1179,6 +1183,10 @@ abstract class ConditionEncoder { if (!context.parametersPlaceholders.containsKey(valueKey)) { return _resolveParameterValueImpl(value, context, valueType, false) .resolveMapped((val) { + if (val is EncodingValue) { + return val; + } + context.parametersPlaceholders[valueKey] ??= val; return EncodingPlaceholder( valueKey, valueType, placeholder, encodeEncodingPlaceholder); @@ -1198,6 +1206,10 @@ abstract class ConditionEncoder { namedParameters: context.namedParameters, encodingParameters: context.encodingParameters); + if (paramValue is EncodingValue) { + return paramValue; + } + if (valueType != null) { return resolveValueToType(paramValue, valueType, valueAsList: valueAsList); @@ -1273,7 +1285,7 @@ abstract class ConditionEncoder { value == list.first; } else { throw ArgumentError( - "Can't resolve a `List` with mutiple values to a primitive type: $valueTypeInfo >> $value"); + "Can't resolve a `List` with multiple values to a primitive type: $valueTypeInfo >> $value"); } } @@ -1354,6 +1366,18 @@ abstract class EncodingValue> { @override String toString() => encode.toString(); + + EncodingValueList asEncodingValueList() { + var values = this; + if (values is EncodingValueList) { + return values as EncodingValueList; + } else { + return EncodingValueList(values.key, values.type, [values], (p) { + var v = values.encode; + return '( $v )' as E; + }); + } + } } abstract class EncodingValueResolved> diff --git a/lib/src/bones_api_condition_sql.dart b/lib/src/bones_api_condition_sql.dart index 6766fdeb..5d440a01 100644 --- a/lib/src/bones_api_condition_sql.dart +++ b/lib/src/bones_api_condition_sql.dart @@ -9,10 +9,15 @@ final _log = logging.Logger('ConditionSQLEncoder'); /// A [Condition] encoder for SQL. class ConditionSQLEncoder extends ConditionEncoder { + /// The character used to quote identifiers in the SQL dialect. final String sqlElementQuote; + /// If `true`, forces generation of SQL statements suitable for caching by + /// avoiding inline values and using substitution values whenever possible. + final bool forCachedStatements; + ConditionSQLEncoder(SchemeProvider super.schemeProvider, - {required this.sqlElementQuote}); + {required this.sqlElementQuote, this.forCachedStatements = false}); @override String get groupOpener => '('; @@ -37,12 +42,26 @@ class ConditionSQLEncoder extends ConditionEncoder { var schemeProvider = this.schemeProvider; if (schemeProvider == null) { - var idKey = context.addEncodingParameter('id', c.idValue); + var idValue = c.idValue; + + var encodingValue = valueToParameterValue(context, idValue, + fieldKey: 'id', fieldType: int); + + var idKey = context.addEncodingParameter('id', encodingValue); + + if (forCachedStatements) { + var c2 = KeyConditionEQ( + [ConditionKeyField('id')], + ConditionParameter.key(idKey), + ); + return encodeKeyConditionEQ(c2, context); + } + var q = sqlElementQuote; var tableKey = '$q$tableAlias$q.$q$idKey$q'; return encodeConditionValuesWithOperator( - context, int, idKey, tableKey, '=', c.idValue, false); + context, int, idKey, tableKey, '=', encodingValue, false); } else { var tableSchemeRet = schemeProvider.getTableScheme(tableName); @@ -56,13 +75,26 @@ class ConditionSQLEncoder extends ConditionEncoder { var idFieldName = tableScheme.idFieldName ?? 'id'; var idType = tableScheme.fieldsTypes[idFieldName] ?? int; + var idValue = c.idValue; + + var encodingValue = valueToParameterValue(context, idValue, + fieldKey: idFieldName, fieldType: idType); + + var idKey = context.addEncodingParameter(idFieldName, encodingValue); + + if (forCachedStatements) { + var c2 = KeyConditionEQ( + [ConditionKeyField(idFieldName)], + ConditionParameter.key(idKey), + ); + return encodeKeyConditionEQ(c2, context); + } - var idKey = context.addEncodingParameter(idFieldName, c.idValue); var q = sqlElementQuote; var tableKey = '$q$tableAlias$q.$q$idKey$q'; return encodeConditionValuesWithOperator( - context, idType, idKey, tableKey, '=', c.idValue, false); + context, idType, idKey, tableKey, '=', encodingValue, false); }); } } @@ -75,12 +107,26 @@ class ConditionSQLEncoder extends ConditionEncoder { var schemeProvider = this.schemeProvider; if (schemeProvider == null) { - var idKey = context.addEncodingParameter('id', c.idsValues); + var idsValues = c.idsValues; + + var encodingValue = valueToParameterValue(context, idsValues, + fieldKey: 'id', fieldType: int, valueAsList: true); + + var idKey = context.addEncodingParameter('id', encodingValue); + + if (forCachedStatements) { + var c2 = KeyConditionIN( + [ConditionKeyField('id')], + ConditionParameter.key(idKey), + ); + return encodeKeyConditionIN(c2, context); + } + var q = sqlElementQuote; var tableKey = '$q$tableAlias$q.$q$idKey$q'; return encodeConditionValuesWithOperator( - context, int, idKey, tableKey, 'IN', c.idsValues, true); + context, int, idKey, tableKey, 'IN', encodingValue, true); } else { var tableSchemeRet = schemeProvider.getTableScheme(tableName); @@ -94,13 +140,26 @@ class ConditionSQLEncoder extends ConditionEncoder { var idFieldName = tableScheme.idFieldName ?? 'id'; var idType = tableScheme.fieldsTypes[idFieldName] ?? int; + var idsValues = c.idsValues; + + var encodingValue = valueToParameterValue(context, idsValues, + fieldKey: idFieldName, fieldType: idType, valueAsList: true); + + var idKey = context.addEncodingParameter(idFieldName, encodingValue); + + if (forCachedStatements) { + var c2 = KeyConditionIN( + [ConditionKeyField(idFieldName)], + ConditionParameter.key(idKey), + ); + return encodeKeyConditionIN(c2, context); + } - var idKey = context.addEncodingParameter(idFieldName, c.idsValues); var q = sqlElementQuote; var tableKey = '$q$tableAlias$q.$q$idKey$q'; return encodeConditionValuesWithOperator( - context, idType, idFieldName, tableKey, 'IN', c.idsValues, true); + context, idType, idFieldName, tableKey, 'IN', encodingValue, true); }); } } @@ -509,14 +568,18 @@ class ConditionSQLEncoder extends ConditionEncoder { } } - if (value is ConditionParameter) { + if (value is EncodingValue) { + return value; + } else if (value is ConditionParameter) { return conditionParameterToParameterValue( value, context, fieldKey, fieldType, valueAsList: valueAsList); } else if (value is List && value.whereType().isNotEmpty) { var parametersValues = value.map((v) { - if (v is ConditionParameter) { + if (value is EncodingValue) { + return value; + } else if (v is ConditionParameter) { return conditionParameterToParameterValue( value, context, fieldKey, fieldType, valueAsList: valueAsList); diff --git a/lib/src/bones_api_entity_db_memory.dart b/lib/src/bones_api_entity_db_memory.dart index cf4c282e..f6077a39 100644 --- a/lib/src/bones_api_entity_db_memory.dart +++ b/lib/src/bones_api_entity_db_memory.dart @@ -108,6 +108,7 @@ class DBSQLMemoryAdapter extends DBSQLAdapter transactions: true, transactionAbort: true, tableSQL: false, + statementsCache: false, constraintSupport: false, multiIsolateSupport: false, connectivity: DBAdapterCapabilityConnectivity.none), diff --git a/lib/src/bones_api_entity_db_mysql.dart b/lib/src/bones_api_entity_db_mysql.dart index f8bb8e15..81c51de3 100644 --- a/lib/src/bones_api_entity_db_mysql.dart +++ b/lib/src/bones_api_entity_db_mysql.dart @@ -104,6 +104,7 @@ class DBMySQLAdapter extends DBSQLAdapter transactions: true, transactionAbort: true, tableSQL: true, + statementsCache: false, constraintSupport: false, multiIsolateSupport: true, connectivity: DBAdapterCapabilityConnectivity.secureAndUnsecure), diff --git a/lib/src/bones_api_entity_db_postgres.dart b/lib/src/bones_api_entity_db_postgres.dart index 44a4eca1..c77b53f9 100644 --- a/lib/src/bones_api_entity_db_postgres.dart +++ b/lib/src/bones_api_entity_db_postgres.dart @@ -111,6 +111,7 @@ class DBPostgreSQLAdapter extends DBSQLAdapter transactions: true, transactionAbort: true, tableSQL: true, + statementsCache: true, constraintSupport: true, multiIsolateSupport: true, connectivity: DBAdapterCapabilityConnectivity.secureAndUnsecure), @@ -1093,13 +1094,13 @@ class DBPostgreSQLAdapter extends DBSQLAdapter return doInsertSQL( entityName, table, insertSQL, transaction, connection) - .resolveMapped((res) => _fixeTableSequence(transaction, entityName, + .resolveMapped((res) => _fixTableSequence(transaction, entityName, table, idFieldName, idFieldType, connection, res)); }); }); } - FutureOr _fixeTableSequence( + FutureOr _fixTableSequence( Transaction transaction, String entityName, String table, @@ -1215,7 +1216,8 @@ class DBPostgreSQLAdapter extends DBSQLAdapter } /// A [DBPostgreSQLAdapter] connection wrapper. -class PostgreSQLConnectionWrapper extends DBConnectionWrapper { +class PostgreSQLConnectionWrapper extends DBConnectionWrapper + with StatementCache { final String? username; final String host; final int port; @@ -1230,14 +1232,17 @@ class PostgreSQLConnectionWrapper extends DBConnectionWrapper { return "postgresql://$username@$host:$port/$database${secure ? '?sslmode=require' : ''}"; } + Future _executeWithCachedStatement( + String sql, Map? substitutionValues) async { + var statement = await prepareSQLCached(sql); + return statement.run(substitutionValues); + } + Future>> mappedResultsQuery(String sql, {Map? substitutionValues}) async { updateLastAccessTime(); - var rs = await nativeConnection.execute( - Sql.named(sql), - parameters: substitutionValues, - ); + var rs = await _executeWithCachedStatement(sql, substitutionValues); var mappedResult = rs.map((e) => e.toResultsMap()).toList(); @@ -1247,20 +1252,21 @@ class PostgreSQLConnectionWrapper extends DBConnectionWrapper { Future query(String sql, {Map? substitutionValues}) async { updateLastAccessTime(); - return nativeConnection.execute( - Sql.named(sql), - parameters: substitutionValues, - ); + + return _executeWithCachedStatement(sql, substitutionValues); } Future execute(String sql, {Map? substitutionValues}) async { updateLastAccessTime(); + + // Can't use `prepareSQLCached` for `ignoreRows: true`. var rs = await nativeConnection.execute( Sql.named(sql), parameters: substitutionValues, ignoreRows: true, ); + return rs.affectedRows; } @@ -1280,6 +1286,18 @@ class PostgreSQLConnectionWrapper extends DBConnectionWrapper { ); } + @override + Future> prepareSQLCachedImpl(String sql) async { + var sqlNamed = Sql.named(sql); + var statement = await nativeConnection.prepare(sqlNamed); + return CachedStatement(statement); + } + + @override + void disposeCachedStatement(Statement statement) { + statement.dispose(); + } + @override bool isClosedImpl() { final nativeConnection = this.nativeConnection; diff --git a/lib/src/bones_api_entity_db_sql.dart b/lib/src/bones_api_entity_db_sql.dart index cb45d443..9cedacf6 100644 --- a/lib/src/bones_api_entity_db_sql.dart +++ b/lib/src/bones_api_entity_db_sql.dart @@ -283,12 +283,17 @@ class DBSQLAdapterCapability extends DBAdapterCapability { /// See [DBSQLAdapter.populateTables]. final bool tableSQL; + /// `true` if the adapter supports caching of SQL statements. + /// See [ConditionSQLEncoder.forCachedStatements] + final bool statementsCache; + const DBSQLAdapterCapability( {required super.dialect, required super.transactions, required super.transactionAbort, required super.constraintSupport, required this.tableSQL, + required this.statementsCache, required super.multiIsolateSupport, required super.connectivity}); @@ -434,8 +439,11 @@ abstract class DBSQLAdapter extends DBRelationalAdapter _populateTables = populateTables { boot(); - _conditionSQLGenerator = - ConditionSQLEncoder(this, sqlElementQuote: dialect.elementQuote); + _conditionSQLGenerator = ConditionSQLEncoder( + this, + sqlElementQuote: dialect.elementQuote, + forCachedStatements: capability.statementsCache, + ); } static FutureOr fromConfig>( @@ -2950,3 +2958,190 @@ class DBSQLAdapterException extends DBAdapterException { super.operation, super.previousError}); } + +/// A mixin that provides SQL statement caching functionality. +/// +/// `StatementCache` manages a cache of prepared SQL statements, +/// automatically expiring and disposing of them after a configurable timeout. +abstract mixin class StatementCache { + /// Internal cache mapping SQL strings to prepared statements. + final Map>> _preparedSQLCache = {}; + + /// Clears all cached prepared statements. + void disposePreparedSQLCache() { + _preparedSQLCache.clear(); + } + + /// Prepares or retrieves a cached SQL statement. + /// + /// If the statement is already cached, it's reused. Otherwise, + /// [prepareSQLCachedImpl] is called to prepare a new statement, + /// which is cached and returned. + /// + /// If an error occurs during preparation, the entry is removed from the cache. + FutureOr prepareSQLCached(String sql) { + var cached = _preparedSQLCache[sql]; + if (cached != null) { + return cached.useStatement(); + } + + checkPreparedSQLsCachedAsync(); + + try { + var future = _preparedSQLCache[sql] = prepareSQLCachedImpl(sql); + + if (future is Future>) { + future.then((_) => null, onError: (_) { + _preparedSQLCache.remove(sql); + return null; + }); + } + + return future.statement; + } catch (_) { + _preparedSQLCache.remove(sql); + rethrow; + } + } + + /// Prepares and returns a [CachedStatement] for the given SQL. + /// + /// Must be implemented by the concrete class. + FutureOr> prepareSQLCachedImpl(String sql); + + /// Default timeout (10 min) used to determine statement expiration. + static const Duration defaultStatementCacheTimeout = Duration(minutes: 10); + + /// Configurable timeout for cached statements. Default: 10 min + Duration statementCacheTimeout = defaultStatementCacheTimeout; + + /// Schedules an asynchronous check for expired cached statements. + /// + /// [delay] specifies the delay before the check is performed. Default: 10 sec + /// [timeout] overrides the default expiration duration. + Future checkPreparedSQLsCachedAsync( + {Duration delay = const Duration(seconds: 10), Duration? timeout}) { + return Future.delayed( + delay, () => checkPreparedSQLsCached(timeout: timeout)); + } + + /// Minimum interval between automatic cache checks (1 min). + static final _checkPreparedSQLsCachedPeriod = const Duration(minutes: 1); + + /// Tracks the last time cache was checked. + DateTime _checkPreparedSQLsCachedTime = DateTime.now(); + + /// Checks for and removes expired cached statements. + /// + /// Returns the number of removed statements. + /// + /// Set [force] to `true` to force a check regardless of elapsed time. + int checkPreparedSQLsCached({bool force = false, Duration? timeout}) { + final now = DateTime.now(); + + if (!force) { + var checkElapsedTime = now.difference(_checkPreparedSQLsCachedTime); + if (checkElapsedTime < _checkPreparedSQLsCachedPeriod) { + return 0; + } + } + + _checkPreparedSQLsCachedTime = now; + + timeout ??= statementCacheTimeout; + + var expired = []; + + for (var e in _preparedSQLCache.entries) { + var cached = e.value; + if (cached is CachedStatement) { + if (cached.isExpired(now: now, timeout: timeout)) { + expired.add(e.key); + } + } + } + + for (var e in expired) { + var cached = _preparedSQLCache.remove(e); + if (cached is CachedStatement) { + disposeCachedStatement(cached.statement); + } + } + + return expired.length; + } + + /// Disposes of the given statement. + /// + /// Must be implemented to properly clean up resources. + void disposeCachedStatement(S statement); +} + +/// A wrapper for a cached prepared SQL statement of generic type `S`. +/// +/// Tracks usage statistics and expiration status. +class CachedStatement { + /// The actual prepared statement instance. + final S statement; + + int _usageCount = 1; + DateTime _lastUsageTime; + + /// Creates a [CachedStatement] for the given [statement]. + CachedStatement(this.statement) : _lastUsageTime = DateTime.now(); + + /// Timestamp of the last time the statement was used. + DateTime get lastUsageTime => _lastUsageTime; + + /// Number of times the statement was used. + int get usageCount => _usageCount; + + /// Marks the statement as used, updating the usage count and timestamp. + void markUsage() { + ++_usageCount; + _lastUsageTime = DateTime.now(); + } + + /// Returns the statement, marking it as used. + /// See [markUsage]. + S useStatement() { + markUsage(); + return statement; + } + + /// Returns `true` if the statement is considered expired. + /// + /// Uses [timeout] to determine expiration based on last usage time. + bool isExpired( + {DateTime? now, Duration timeout = const Duration(minutes: 10)}) { + now ??= DateTime.now(); + var elapsedTime = now.difference(_lastUsageTime); + return elapsedTime > timeout; + } +} + +extension FutureCachedStatementExtension on Future> { + Future get statement => then((r) => r.statement); + + Future useStatement() => then((r) => r.useStatement()); +} + +extension FutureOrCachedStatementExtension on FutureOr> { + FutureOr get statement { + var self = this; + if (self is Future>) { + return self.statement; + } else { + return self.statement; + } + } + + FutureOr useStatement() { + var self = this; + if (self is Future>) { + return self.useStatement(); + } else { + return self.useStatement(); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 7efda62f..ca5c0c4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: bones_api description: Bones_API - A powerful API backend framework for Dart. It comes with a built-in HTTP Server, route handler, entity handler, SQL translator, and DB adapters. -version: 1.9.3 +version: 1.9.4-beta.1 homepage: https://github.com/Colossus-Services/bones_api environment: diff --git a/test/bones_api_entity_db_tests_base.dart b/test/bones_api_entity_db_tests_base.dart index 711a9539..3479ed88 100644 --- a/test/bones_api_entity_db_tests_base.dart +++ b/test/bones_api_entity_db_tests_base.dart @@ -866,9 +866,19 @@ Future runAdapterTests( expect(await userAPIRepository.existsID(1), isTrue); expect(await userAPIRepository.existsID(id), isTrue); - expect(await userAPIRepository.existsID(123123123123), isFalse); - expect(await userAPIRepository.existIDs([1, id, 123123123123]), - equals([1, id])); + + // Named parameters in SQL: + if (sqlAdapter.capability.statementsCache) { + expect(await userAPIRepository.existsID(1231231231), isFalse); + expect(await userAPIRepository.existIDs([1, id, 1231231231]), + equals([1, id])); + } + // In-line values in SQL: + else { + expect(await userAPIRepository.existsID(123123123123), isFalse); + expect(await userAPIRepository.existIDs([1, id, 123123123123]), + equals([1, id])); + } expect( await userAPIRepository.selectIDsByQuery(' email == ? ',