Skip to content

Commit fb8eae4

Browse files
committed
Do not buffer @FileResult data into memory
1 parent 0e0ba5d commit fb8eae4

23 files changed

Lines changed: 411 additions & 117 deletions

src/Dibix.Dapper/DapperDatabaseAccessor.cs

Lines changed: 44 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ protected override async Task<IMultipleResultReader> QueryMultipleAsync(string c
8383
return new DapperGridResultReader(reader, commandText, commandType, parameters, DbProviderAdapter);
8484
}
8585

86+
protected override IEnumerable<TReturn> Parse<TReturn>(IDataReader reader) => reader.Parse<TReturn>();
87+
8688
protected override void DisposeConnection()
8789
{
8890
if (_onDispose != null)
@@ -97,59 +99,17 @@ protected object CollectParameters(ParametersVisitor parametersVisitor)
9799
{
98100
Guard.IsNotNull(parametersVisitor, nameof(parametersVisitor));
99101
DynamicParameters @params = new DynamicParameters();
100-
parametersVisitor.VisitInputParameters((name, dataType, value, size, isOutput, customInputType) =>
101-
{
102-
object normalizedValue = NormalizeParameterValue(value);
103-
DbType? dbType = NormalizeParameterDbType(dataType, customInputType);
104-
ParameterDirection? direction = isOutput ? ParameterDirection.Output : null;
105-
int? normalizedSize = NormalizeParameterSize(size, dbType, isOutput);
106-
@params.Add(name, value: normalizedValue, dbType, direction, normalizedSize);
107-
});
102+
DapperParameterCollector parameterCollector = new DapperParameterCollector(@params, DbProviderAdapter);
103+
parametersVisitor.VisitInputParameters(parameterCollector.VisitInputParameter);
108104
return new DynamicParametersWrapper(@params, parametersVisitor);
109105
}
110106
#endregion
111107

112108
#region Private Methods
113-
private object NormalizeParameterValue(object value)
114-
{
115-
if (value is StructuredType udt)
116-
return new DapperStructuredTypeParameter(udt, DbProviderAdapter);
117-
118-
return value;
119-
}
120-
121-
private static DbType? NormalizeParameterDbType(DbType dbType, CustomInputType customInputType)
122-
{
123-
if (dbType == DbType.Xml)
124-
return null; // You would guess DbType.Xml, but since Dapper treats .NET XML types (i.E. XElement) as custom types, DbType = null is expected
125-
126-
if (customInputType != default)
127-
return null; // Same weird logic like above. Dapper will only resolve the custom type handler, if the db type is null.
128-
129-
return dbType;
130-
}
131-
132-
private static int? NormalizeParameterSize(int? size, DbType? dbType, bool isOutput)
133-
{
134-
if (size.HasValue)
135-
return size;
136-
137-
switch (dbType)
138-
{
139-
case DbType.String when isOutput:
140-
case DbType.StringFixedLength when isOutput:
141-
case DbType.AnsiString when isOutput:
142-
case DbType.AnsiStringFixedLength when isOutput:
143-
return -1;
144-
145-
default:
146-
return null;
147-
}
148-
}
149-
150109
private static void ConfigureDapper()
151110
{
152111
SqlMapper.AddTypeHandler(new DapperUriTypeHandler());
112+
SqlMapper.SetTypeMap(FileEntityTypeMap.Type, new FileEntityTypeMap());
153113
}
154114
#endregion
155115

@@ -181,6 +141,45 @@ void SqlMapper.IParameterCallbacks.OnCompleted()
181141
_parameterCallbacks.OnCompleted();
182142
}
183143
}
144+
145+
private sealed class DapperParameterCollector : DbParameterCollector
146+
{
147+
private readonly DynamicParameters _dynamicParameters;
148+
149+
public DapperParameterCollector(DynamicParameters dynamicParameters, DbProviderAdapter dbProviderAdapter) : base(dbProviderAdapter)
150+
{
151+
_dynamicParameters = dynamicParameters;
152+
}
153+
154+
public override void VisitInputParameter(string name, DbType dataType, object value, int? size, bool isOutput, CustomInputType customInputType)
155+
{
156+
object normalizedValue = NormalizeParameterValue(value);
157+
DbType? dbType = NormalizeParameterDbType(dataType, customInputType);
158+
ParameterDirection? direction = isOutput ? ParameterDirection.Output : null;
159+
int? normalizedSize = NormalizeParameterSize(size, dbType, isOutput);
160+
_dynamicParameters.Add(name, value: normalizedValue, dbType, direction, normalizedSize);
161+
}
162+
163+
private object NormalizeParameterValue(object value)
164+
{
165+
if (value is StructuredType udt)
166+
return new DapperStructuredTypeParameter(udt, DbProviderAdapter);
167+
168+
return value;
169+
}
170+
171+
private static DbType? NormalizeParameterDbType(DbType dbType, CustomInputType customInputType)
172+
{
173+
if (dbType == DbType.Xml)
174+
return null; // You would guess DbType.Xml, but since Dapper treats .NET XML types (i.E. XElement) as custom types, DbType = null is expected
175+
176+
if (customInputType != default)
177+
return null; // Same weird logic like above. Dapper will only resolve the custom type handler, if the db type is null.
178+
179+
return dbType;
180+
}
181+
182+
}
184183
#endregion
185184
}
186185
}

src/Dibix.Dapper/DecoratedTypeMap.cs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@
44

55
namespace Dibix.Dapper
66
{
7-
internal sealed class DecoratedTypeMap : SqlMapper.ITypeMap
7+
internal class DecoratedTypeMap : SqlMapper.ITypeMap
88
{
99
#region Fields
1010
private readonly SqlMapper.ITypeMap _inner;
1111
private readonly Type _type;
1212
#endregion
1313

1414
#region Constructor
15-
private DecoratedTypeMap(SqlMapper.ITypeMap inner, Type type)
15+
private protected DecoratedTypeMap(SqlMapper.ITypeMap inner, Type type)
1616
{
17-
this._inner = inner;
18-
this._type = type;
17+
_inner = inner;
18+
_type = type;
1919
}
2020
#endregion
2121

@@ -46,20 +46,20 @@ private static void Register(Type type)
4646
#endregion
4747

4848
#region SqlMapper.ITypeMap Members
49-
ConstructorInfo SqlMapper.ITypeMap.FindConstructor(string[] names, Type[] types) => this._inner.FindConstructor(names, types);
49+
ConstructorInfo SqlMapper.ITypeMap.FindConstructor(string[] names, Type[] types) => _inner.FindConstructor(names, types);
5050

51-
ConstructorInfo SqlMapper.ITypeMap.FindExplicitConstructor() => this._inner.FindExplicitConstructor();
51+
ConstructorInfo SqlMapper.ITypeMap.FindExplicitConstructor() => _inner.FindExplicitConstructor();
5252

53-
SqlMapper.IMemberMap SqlMapper.ITypeMap.GetConstructorParameter(ConstructorInfo constructor, string columnName) => this._inner.GetConstructorParameter(constructor, columnName);
53+
SqlMapper.IMemberMap SqlMapper.ITypeMap.GetConstructorParameter(ConstructorInfo constructor, string columnName) => _inner.GetConstructorParameter(constructor, columnName);
5454

55-
SqlMapper.IMemberMap SqlMapper.ITypeMap.GetMember(string columnName)
55+
public virtual SqlMapper.IMemberMap GetMember(string columnName)
5656
{
5757
if (String.IsNullOrEmpty(columnName))
58-
throw new InvalidOperationException($"Column name was not specified, therefore it cannot be mapped to type '{this._type}'");
58+
throw new InvalidOperationException($"Column name was not specified, therefore it cannot be mapped to type '{_type}'");
5959

60-
SqlMapper.IMemberMap member = this._inner.GetMember(columnName);
60+
SqlMapper.IMemberMap member = _inner.GetMember(columnName);
6161
if (member == null)
62-
throw new InvalidOperationException($"Column '{columnName}' does not match a property on type '{this._type}'");
62+
throw new InvalidOperationException($"Column '{columnName}' does not match a property on type '{_type}'");
6363

6464
return member;
6565
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using System;
2+
using Dapper;
3+
4+
namespace Dibix.Dapper
5+
{
6+
internal sealed class FileEntityTypeMap : DecoratedTypeMap, SqlMapper.ITypeMap
7+
{
8+
public static readonly Type Type = typeof(FileEntity);
9+
10+
internal FileEntityTypeMap() : base(SqlMapper.GetTypeMap(Type), Type) { }
11+
12+
public override SqlMapper.IMemberMap GetMember(string columnName)
13+
{
14+
// Dapper currently doesn't support reading individual columns as a stream instead of buffering them into memory.
15+
// See: https://github.com/DapperLib/Dapper/issues/893
16+
// Therefore we skip the data property during the initial read/parse and read it manually using DbDataReader.GetStream().
17+
if (String.Equals(columnName, nameof(FileEntity.Data), StringComparison.OrdinalIgnoreCase))
18+
return null;
19+
20+
return base.GetMember(columnName);
21+
}
22+
}
23+
}

src/Dibix.Http.Host/Runtime/HttpResponseFormatter.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
using System;
2-
using System.IO;
32
using System.Text.Json;
43
using System.Threading;
54
using System.Threading.Tasks;
65
using Dibix.Http.Server;
76
using Microsoft.AspNetCore.Http;
7+
using Microsoft.AspNetCore.Http.Extensions;
88
using Microsoft.AspNetCore.Http.Headers;
99
using Microsoft.Net.Http.Headers;
1010

@@ -56,8 +56,18 @@ void AppendFileName(ResponseHeaders responseHeaders, string fileName)
5656
responseHeaders.ContentType = new MediaTypeHeaderValue(mediaType);
5757
AppendFileName(responseHeaders, fileEntity.FileName);
5858

59-
using MemoryStream memoryStream = new MemoryStream(fileEntity.Data);
60-
await memoryStream.CopyToAsync(_response.Body, cancellationToken).ConfigureAwait(false);
59+
if (fileEntity.Length != null)
60+
responseHeaders.ContentLength = fileEntity.Length;
61+
62+
// Taken from Microsoft.AspNetCore.Internal.FileResultHelper.WriteFileAsync
63+
try
64+
{
65+
await StreamCopyOperation.CopyToAsync(fileEntity.Data, _response.Body, count: null, bufferSize: 64 * 1024, cancellationToken).ConfigureAwait(false);
66+
}
67+
catch (OperationCanceledException)
68+
{
69+
_response.HttpContext.Abort();
70+
}
6171
break;
6272
}
6373

src/Dibix.Http.Server.AspNet/HttpResponseMessageFormatter.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,16 @@ private static object CreateFileResponse(object result, HttpRequestMessageDescri
3535
switch (result)
3636
{
3737
case FileEntity fileEntity:
38+
{
3839
mediaType = MimeTypes.IsRegistered(fileEntity.Type) ? fileEntity.Type : MimeTypes.GetMimeType(fileEntity.Type);
3940
fileName = fileEntity.FileName;
40-
content = new ByteArrayContent(fileEntity.Data);
41+
content = new StreamContent(fileEntity.Data);
42+
43+
if (fileEntity.Length != null)
44+
content.Headers.ContentLength = fileEntity.Length;
45+
4146
break;
47+
}
4248

4349
#if NETFRAMEWORK
4450
case IJsonFileMetadata jsonFileMetadata:

src/Dibix.Http.Server/Runtime/HttpActionInvokerBase.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Collections.Generic;
1+
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Threading;
45
using System.Threading.Tasks;
@@ -41,9 +42,18 @@ private static async Task<object> InvokeCore<TRequest>(HttpActionDefinition acti
4142
}
4243
}
4344

44-
object result = await Execute(action, request, arguments, controllerActivator, parameterDependencyResolver, cancellationToken).ConfigureAwait(false);
45-
object formattedResult = await responseFormatter.Format(result, request, action, cancellationToken).ConfigureAwait(false);
46-
return formattedResult;
45+
object result = null;
46+
try
47+
{
48+
result = await Execute(action, request, arguments, controllerActivator, parameterDependencyResolver, cancellationToken).ConfigureAwait(false);
49+
object formattedResult = await responseFormatter.Format(result, request, action, cancellationToken).ConfigureAwait(false);
50+
return formattedResult;
51+
}
52+
finally
53+
{
54+
if (result is IDisposable disposable)
55+
disposable.Dispose();
56+
}
4757
}
4858

4959
private static async Task<object> Execute(IHttpActionExecutionDefinition definition, IHttpRequestDescriptor request, IDictionary<string, object> arguments, IControllerActivator controllerActivator, IParameterDependencyResolver parameterDependencyResolver, CancellationToken cancellationToken)

src/Dibix.Sdk.CodeGeneration/Output/DaoExecutorWriter.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ private static void WriteSimpleResult(StringWriter writer, SqlStatementDefinitio
330330

331331
private static void WriteSimpleMethodCall(StringWriter writer, SqlStatementDefinition definition, SqlQueryResult singleResult, CodeGenerationContext context)
332332
{
333-
string methodName = singleResult != null ? GetExecutorMethodName(singleResult.ResultMode) : "Execute";
333+
string methodName = GetExecutorMethodName(definition, singleResult);
334334
WriteMethodCall(writer, definition, methodName, singleResult, context);
335335
}
336336

@@ -482,7 +482,7 @@ private static void WriteMethodCall(StringWriter writer, SqlStatementDefinition
482482
if (definition.Async)
483483
writer.WriteRaw("Async");
484484

485-
if (singleResult != null)
485+
if (singleResult != null && definition.FileResult == null)
486486
WriteGenericTypeArguments(writer, singleResult, context);
487487

488488
ICollection<string> parameters = new Collection<string>();
@@ -556,6 +556,20 @@ private static void WriteOutputParameterAssignment(StringWriter writer, SqlState
556556
}
557557
}
558558

559+
private static string GetExecutorMethodName(SqlStatementDefinition definition, SqlQueryResult singleResult)
560+
{
561+
if (definition.FileResult != null && definition.IsJsonFileResult == null)
562+
{
563+
return "QueryFile";
564+
}
565+
566+
if (singleResult != null)
567+
{
568+
return GetExecutorMethodName(singleResult.ResultMode);
569+
}
570+
571+
return "Execute";
572+
}
559573
private static string GetExecutorMethodName(SqlQueryResultMode mode)
560574
{
561575
switch (mode)

src/Dibix.Sdk.CodeGeneration/Parser/StatementOutputParser.cs

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ private static SqlQueryResult CreateBuiltInResult(SchemaDefinition schema, ISqlE
6565
}
6666
private static SqlQueryResult CreateBuiltInResult(TypeReference typeReference) => new SqlQueryResult
6767
{
68-
ResultMode = SqlQueryResultMode.SingleOrDefault,
6968
Types = { typeReference },
7069
ReturnType = typeReference
7170
};
@@ -77,33 +76,52 @@ private static void ValidateFileResult(SqlStatementDefinition definition, TSqlFr
7776
logger.LogError("When using the @FileResult option, no return declarations should be defined", source, node.StartLine, node.StartColumn);
7877
}
7978

80-
if (!IsValidFileResult(results))
79+
if (definition.MergeGridResult)
8180
{
82-
logger.LogError("When using the @FileResult option, the query should return only one output statement with the following schema: ([type] NVARCHAR, [data] VARBINARY, [filename] NVARCHAR NULL)", source, node.StartLine, node.StartColumn);
81+
logger.LogError("When using the @FileResult option, the @MergeGridResult option is invalid", source, node.StartLine, node.StartColumn);
8382
}
8483

85-
if (definition.MergeGridResult)
84+
if (results.Count != 1)
8685
{
87-
logger.LogError("When using the @FileResult option, the @MergeGridResult option is invalid", source, node.StartLine, node.StartColumn);
86+
logger.LogError("When using the @FileResult option, there should only be a single SELECT statement", source, node.StartLine, node.StartColumn);
87+
}
88+
89+
OutputSelectResult select = results[0];
90+
_ = ValidateFileResultColumn(select, "type", isRequired: true, source, logger, expectedTypes: [SqlDataType.NVarChar, SqlDataType.NChar]);
91+
OutputColumnResult dataColumn = ValidateFileResultColumn(select, "data", isRequired: true, source, logger, expectedTypes: SqlDataType.VarBinary);
92+
_ = ValidateFileResultColumn(select, "filename", isRequired: false, source, logger, expectedTypes: [SqlDataType.NVarChar, SqlDataType.NChar]);
93+
_ = ValidateFileResultColumn(select, "length", isRequired: false, source, logger, expectedTypes: SqlDataType.BigInt);
94+
95+
if (dataColumn != null)
96+
{
97+
int index = select.Columns.IndexOf(dataColumn);
98+
if (index != select.Columns.Count - 1)
99+
{
100+
logger.LogError("When using the @FileResult option, the column [data] should be the last column in the SELECT", source, dataColumn.PrimarySource.StartLine, dataColumn.PrimarySource.StartColumn);
101+
}
88102
}
89103
}
90104

91-
private static bool IsValidFileResult(IList<OutputSelectResult> results)
105+
private static OutputColumnResult ValidateFileResultColumn(OutputSelectResult select, string columnName, bool isRequired, string source, ILogger logger, params SqlDataType[] expectedTypes)
92106
{
93-
if (results.Count != 1)
94-
return false;
95-
96-
int columnCount = results[0].Columns.Count;
97-
if (columnCount is < 2 or > 3)
98-
return false;
107+
string expectedTypesString = String.Join("/", expectedTypes.Select(x => x.ToString().ToUpperInvariant()));
99108

100-
ICollection<OutputColumnResult> columns = results[0].Columns;
101-
bool hasRequiredColumns = columns.Any(x => ValidateColumn(x, "data", SqlDataType.Binary, SqlDataType.VarBinary))
102-
&& columns.Any(x => ValidateColumn(x, "type", SqlDataType.Char, SqlDataType.NChar, SqlDataType.VarChar, SqlDataType.NVarChar));
109+
OutputColumnResult column = select.Columns.FirstOrDefault(x => x.ColumnName.ToLowerInvariant() == columnName);
110+
if (column == null)
111+
{
112+
if (isRequired)
113+
{
114+
logger.LogError($"When using the @FileResult option, the SELECT statement should return the column [{columnName}] {expectedTypesString.ToString().ToUpperInvariant()}", source, select.Line, select.Column);
115+
}
116+
return null;
117+
}
103118

104-
bool isFileNameColumnValid = columnCount < 3 || columns.Any(x => ValidateColumn(x, "filename", SqlDataType.Char, SqlDataType.NChar, SqlDataType.VarChar, SqlDataType.NVarChar));
119+
if (!expectedTypes.Contains(column.DataType))
120+
{
121+
logger.LogError($"When using the @FileResult option, the column [{columnName}] should have the type {expectedTypesString.ToString().ToUpperInvariant()}", source, column.PrimarySource.StartLine, column.PrimarySource.StartColumn);
122+
}
105123

106-
return hasRequiredColumns && isFileNameColumnValid;
124+
return column;
107125
}
108126

109127
private static void ValidateJsonFileResult(SqlStatementDefinition definition, TSqlFragment node, string source, IList<ISqlElement> returnElements, IList<OutputSelectResult> results, ILogger logger)

0 commit comments

Comments
 (0)