Skip to content

A SqlStreamWrapper and column mapper that does away with all the hard coded SQL statements

License

Notifications You must be signed in to change notification settings

RupertAvery/WrappedSqlFileStream

Repository files navigation

Latest version

WrappedSqlFileStream is a library that helps remove boilerplate code around using a SQL FILESTREAM and the .NET SqlFileStream API to provide typed access to the table row containing the FILESTREAM and any other columns associated with it.

To avoid confusion in this document, FILESTREAM refers to a SQL FILESTREAM. SqlFileStream is the C# class used to access a FILESTREAM. A FileStream is a regular C# FileStream.

What is FILESTREAM

SQL Server's FILESTREAM is a way to "store" and associate large binary data with a row in a database. However, instead of storing it in the database pages, it stores it in the NTFS file system. SQL Server then allows you to access the underlying file as a Stream, so you don't have to load it into a byte array which would consume memory, but access it as you would a regular FileStream.

.NET provides the SqlFileStream class to expose the FILESTREAM as a Stream object, allowing you to read from or write to the stream in chunks, and perform seeks as any regular Stream.

Using SqlFileStream requires querying the database to retrieve the FILESTREAM handle, and leads to a lot of boilerplate code.

Wrapping the SqlFileStream

WrappedSqlFileStream uses reflection with provided mapping information to do away with all the hard-coded SQL and boiler plate code, and provides the necessary wrapper to pass the stream over WCF while mapping the columns in the table to a .NET class.

One caveat when using SqlFileStream is that you need to call Commit() on the SqlTransaction used when creating the SqlFileStream when disposing it. This can pose a problem when passing the stream directly to an API endpoint or WCF. The .NET Framework will call Dispose() on any IDisposable it returns, however this will not normally call Commit() on the SqlTransaction.

WrappedSqlFileStream solves this issue by keeping a reference to the SqlTransaction and overriding the Dispose() method, allowing you to call Commit() when the .NET framework disposes of the stream when the API endpoint or WCF connection is closed.

Example and Usage

You will need a SQL Server 2008 or higher database. You will also need to enable FILESTREAM.

https://learn.microsoft.com/en-us/sql/relational-databases/blob/enable-and-configure-filestream?view=sql-server-ver16

To run the Sample project, make sure you have enabled FILESTREAM, then run the Setup.sql script from the Samples project on your SQL database. Modify the connection string in the App.config as necessary.

Mapping a Table to a Class Type

First of all, we need a table with a FILESTREAM column in the database as a class to map the table to.

In our sample, we have the table defined as:

CREATE TABLE WrappedSqlFileStreamSample.dbo.Files
(
	[Id] [uniqueidentifier] ROWGUIDCOL NOT NULL UNIQUE,
	[FileName] VARCHAR(255),
	[File] VARBINARY(MAX) FILESTREAM NULL
)

And the class to be mapped as:

public class Files
{
	public virtual Guid Id { get; set; }
	public virtual string FileName { get; set; }
	public virtual byte[] File { get; set; }
}

Note that the properties do not have to be declared virtual as the framework does not construct a proxy class, however, if you use NHibernate for mapping, it will require the properties to be virtual.

It's important to note the the File property is not used to store the FILESTREAM. It is used to store a handle to the FILESTREAM.

Next we create an instance of an IMappingProvider. This provides mapping between the table and class, and column names and property names.

We will use the DefaultMappingProvider<T> class which generates mapping on the assumption that class and property names are the same as table and column names. The constructor has the following signature:

DefaultMappingProvider(string schema, Expression<Func<T>> identifier);

The generic type parameter T corresponds to the type we are mapping to that represents the table.

The first parameter of the constructor is the schema name of the table, and the second parameter is an expression that returns the property that is mapped to the table's FILESTREAM column.

var mappingProvider = new DefaultMappingProvider<Files>("dbo", x => x.File);

Creating a Context

The WrappedSqlFileStreamContext class contains the references to the SqlConnection and SqlTransaction which will be used to create the SqlFileStream, and which must be closed when the SqlFileStream is disposed.

There are two ways of using the context. One, which we will use in this example, creates the connection and transaction internally. The other allows you to use externally-provided transaction and connection objects.

We pass the IMappingProvider object and a connection string to our context constructor:

var context = new WrappedSqlFileStreamContext<Files>(mappingProvider, connectionstring);

Creating a new FILESTREAM for writing

To create a new FILESTREAM, a row must first be created on the table. To do this we use the WrappedSqlFileStreamContext's CreateRow() method.

public void CreateRow(Expression<Func<T, byte[]>> fileStreamFieldExpression, Expression<Func<T>> newObjectExpression)

The first parameter takes an expression that identifies the byte array property that is mapped to the FILESTREAM column. This byte array will not contain the actual FILESTREAM data, it is only used to tell SqlFileStream which column stores the FILESTREAM. In the context of CreateRow, it tells the library which column will be set to zero bytes when creating the new row.

The second parameter should be an object instantiation expression, containing only the properties you want to include in the insert statement that creates the new row.

context.CreateRow(files => files.File,
() => new Files()
{
	Id = id,
	FileName = filename
});

This statement tells the library to create a new row on the table mapped to the Files type as specified by our IMappingProvider, and sets the values for the columns mapped to the properties Id and FileName only. Internally, the column mapped to the File property is set to zero bytes.

The SQL statement generated by this code would be:

INSERT INTO dbo.Files (File, Id, FileName) VALUES (0x, @Id, @FileName)

The mapped property values are parameterized, avoiding any SQL injection vulnerabilities.

This statement is executed immediately. Now that the row exists in the table, we can use a SqlFileStream to access the FILESTREAM.

Creating and writing to a FILESTREAM

Create a WrappedSqlFileStream<T> with type parameter Files and the following parameters:

  • our WrappedSqlFileStreamContext
  • an expression that corresponds to the property mapped to the FILESTREAM we wish to write to, in this case Files.File
  • an expression identifying the row
  • the FileAccess mode FileAccess.Write
new WrappedSqlFileStream<Files>(context, files => files.File, files => files.Id == id, FileAccess.Write)

This will generate the following SQL:

SELECT File.PathName(), GET_FILESTREAM_TRANSACTION_CONTEXT() Id, FileName
FROM dbo.Files WHERE Id = @id;

The first two fields returned by this query will be used internally to instantiate a SqlFileStream object.

The WHERE clause is generated from the expression identifying the row using a custom expression walker and the mapping stored in the context. The expression walker is very basic and supports only simple expressions.

The created WrappedSqlFileStream can now be accessed as any other Stream object.

Here is the full code needed to map and write to a FILESTREAM:

We use CopyTo to write a filestream to our WrappedSqlFileStream.

var mappingProvider = new DefaultMappingProvider<Files, Guid>("dbo", x => x.Id);

var context = new WrappedSqlFileStreamContext<Files>(mappingProvider, connectionstring);

using (var fs = new FileStream("foo.pdf", FileMode.Open, FileAccess.Read))
using (var sfs = new WrappedSqlFileStream<Files>(context, files => files.File, files => files.Id == id, FileAccess.Write))
{
	fs.CopyTo(sfs);
}

Remember to put the stream in a using statement, or call Dispose when you are done. This will dispose the underlying SqlFileStream and call Commit() on the SqlTransaction and Dispose() on the SqlConnection.

Opening an existing FILESTREAM for reading

The steps for opening an existing FILESTREAM are much simpler than creating one. The mapping can be reused, instantiating it only once either in the constructor, or creating it once in the application lifetime and injecting it in. Create a WrappedSqlFileStream but pass FileAccess.Read as the FileAccess mode.

var stream = new WrappedSqlFileStream<Files>(context, files => files.File, files => files.Id == id, FileAccess.Read);

This stream can be sent over an API endpoint or WCF, and when the stream is disposed where it is consumed, the WrappedSqlFileStream will be disposed of as well, triggering the transaction commit and connection close.

The stream also has an OnDispose() property which allows you to call your own code when the stream is disposed, but before the context is commited.

Retrieving associated columns

The WrappedSqlFileStream<T> object has a RowData property that is of type T. When creating a WrappedFileStream with FileAccess.Read, the RowData object will be populated with data from columns using the supplied mapping in the context.

In the previous example, stream.RowData would be of type Files and would contain the values of the other fields in the row.

var stream = new WrappedSqlFileStream<Files>(context, files => files.Id == id, FileMode.Open, FileAccess.Read);

return new FilesDTO()
{
	File = stream,
	FileName = stream.RowData.FileName,
	Id = stream.RowData.Id
};

Reusing ORM Mappings

If you use NHibernate, you can leverage the ORM’s mapping to get the table and field mappings.

Create an instance of an NHibernateMappingProvider and pass the ISessionFactory.

var mappingProvider = new NHibernateMappingProvider<Files>(_sessionFactory);

It is important to note that NHibernate is only used for the mapping. The actual query is still run in a separate connextion and transaction.

An EntityFramework mapping provider is not yet implemented.

Using an external SqlConnection and SqlTransaction

If you find the need to use an existing connection and transaction, use the WrappedSqlFileStreamContext constructor that accepts those parameters.

public WrappedSqlFileStreamContext(IMappingProvider mappingProvider, SqlConnection connection, SqlTransaction transaction)

In this case, committing of the transaction and disposing of the connection will not occur when the stream is disposed, allowing you use the WrappedSqlFileStream in a using block and still perform operations on the transaction before committing.

License

WrappedSqlFileStream is available under the MIT License.

Troubleshooting

FILESTREAM feature is disabled

You get the error:

FILESTREAM feature is disabled.

Make sure you enable FILESTREAM in SQL Server Configuration Manager, and restart the SQL Server service.

https://learn.microsoft.com/en-us/sql/relational-databases/blob/enable-and-configure-filestream?view=sql-server-ver16

  • In the SQL Server Configuration Manager snap-in, locate the instance of SQL Server on which you want to enable FILESTREAM.
  • Right-click the instance, and then click Properties.
  • In the SQL Server Properties dialog box, click the FILESTREAM tab.
  • Select the Enable FILESTREAM for Transact-SQL access check box.
  • Execute the following SQL statements in SQL Server Management Studio
EXEC sp_configure filestream_access_level, 2  
RECONFIGURE  

If you can't find SQL Server Configuration Manager, try running the following in the Run dialog

sqlservermanager15.msc

FILESTREAM feature doesn't have file system access enabled

You get the error:

Microsoft.Data.SqlClient.SqlException: 'FILESTREAM feature doesn't have file system access enabled.'

Execute the following SQL statements:

sp_configure 'filestream access level', 2

RECONFIGURE WITH OVERRIDE

About

A SqlStreamWrapper and column mapper that does away with all the hard coded SQL statements

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published