Recently I had a problem on a project where we needed to securely store documents. It was the first time for the customer to do such a thing. Telling them this was not so hard, I convinced them to use SQL file tables to store these documents securely in an SQL server database. After validating the SQL server database version used, I let the DBA’s create the file stream storage group and file, changing the database and server properties and created the file table. So far, no issues arose and we were ready for development.
At that point we encountered a serious problem. The application development standard prevented us from using direct SQL statements, and all database access had to be done using .NET Entity Framework (EF). The result was not as expected, it looked as if EF did not import the file table definition correctly. Looking at it in more detail we found that EF didn’t recognise the HIERARCHYID column type. Looking for hours on the msdn blogs, we found several tweaks to force EF to handle the file table meta correctly into the model schema. However, we still couldn’t insert data into the table without writing SQL statement ourselves.
What did I do to solve this? In fact the statement to use only ANSI standard SQL was not that bad, so we left that part as the standard and decided to create an SQL layer between the .NET and the table that managed all the non-standard ANSI code. In other words, preventing the technical specification (non ANSI sql part) from showing to the ‘outside’ world. That solved the whole problem. For EF all that was left was to follow the standard implementation of EF. Finally, everybody was happy and in no time thousands of documents where written.
Please find the complete insight of my solution below.
CREATE TABLE [dbo].[Document] AS FILETABLE ON [PRIMARY] FILESTREAM_ON [db_FS]
WITH
(
FILETABLE_DIRECTORY = N'dboDocumentTable', FILETABLE_COLLATE_FILENAME = Latin1_General_CI_AS
)
CREATE VIEW [dbo].[vDocument]
AS
SELECT [stream_id]
,[file_stream]
,[name]
,CONVERT(VARCHAR(4000),[path_locator]) AS [path_locator]
,CONVERT(VARCHAR(4000),[parent_path_locator]) AS [parent_path_locator]
,[file_type]
,[cached_file_size]
,[creation_time]
,[last_write_time]
,[last_access_time]
,[is_directory]
,[is_offline]
,[is_hidden]
,[is_readonly]
,[is_archive]
,[is_system]
,[is_temporary]
FROM [dbo].[Document]
;
Depending on the directory structure you use, the varchar(4000) can be to small, but change it when needed to a longer field.
CREATE TABLE [dbo].[LinkedDocuments](
[ID] [INT] IDENTITY(1,1) NOT NULL,
[OtherEntityID] [INT] NOT NULL,
[DocumentID] [VARCHAR](4000) NULL,
[InsertDT] [DATETIME] NOT NULL DEFAULT (GETDATE()),
[UpdateDT] [DATETIME] NULL,
[InsertedBy] [VARCHAR](255) NOT NULL DEFAULT (SUSER_SNAME()),
[UpdatedBy] [VARCHAR](255) NULL,
PRIMARY KEY CLUSTERED
(
[ID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, FILLFACTOR = 90) ON [PRIMARY]
) ON [PRIMARY]
The procedure will create the complete directory structure for you when it’s not present yet. Then adding the document stream to the file location, and returns the character representation of the path_locator. (this I the unique identifier of the stream)
Ex. /45134823255421.163030830980382.1528000315/
what is the same as hierarchyid
0xFCA4278A369913CFE5113258908A3EF96C4D3566E0
You write this value to the LinkedDocuments table in the documentID field.
CREATE PROC [dbo].[AddDocument]
(@relativePath varchar(max)=null
,@name varchar(512)=null
,@stream VARBINARY(MAX)=NULL
,@path_locator VARCHAR(4000)=null OUTPUT
)
AS
BEGIN
SET NOCOUNT ON
DECLARE @path_Locator_for_file HIERARCHYID; SET @path_Locator_for_file=NULL;
BEGIN TRY
BEGIN TRAN adddocument
DECLARE @directories TABLE(ID int IDENTITY(1,1), Name varchar(MAX),Parent varchar(MAX),Path_Locator HIERARCHYID, seq int);
-- test input
IF COALESCE(@name,'') =''
THROW 50001, 'no input name given', 1;
-- test/create directory
IF @relativePath IS NOT NULL
BEGIN
DECLARE @rows INT;
;WITH directories(Name, Pname, seq) AS
(SELECT @relativePath AS Name
, CASE WHEN LEFT(@relativePath,1)='\' THEN SUBSTRING(@relativePath,2,LEN(@relativePath)-1) ELSE @relativePath END AS PName
, 0 AS seq
UNION ALL
SELECT CASE WHEN CHARINDEX('\',REVERSE(PName)) > 0 THEN RIGHT(PName,CHARINDEX('\',REVERSE(PName))-1) ELSE PName END AS Name
, CASE WHEN CHARINDEX('\',REVERSE(PName)) > 0 THEN LEFT(PName,LEN(PName)-CHARINDEX('\',REVERSE(PName))) ELSE '' END AS PName
, seq+1 AS seq
FROM directories recurs
WHERE PName>''
)
,dirs AS
(
SELECT Name, PName, LAG(Name) OVER(ORDER BY seq DESC) AS Parent, ROW_NUMBER() OVER(ORDER BY seq desc) AS Seq
FROM directories
WHERE seq > 0 AND name > ''
)
INSERT INTO @directories(Name, Parent, Path_Locator, seq)
SELECT Name
,Parent
,null
,seq
FROM dirs
;
SET @rows=@@ROWCOUNT;
DECLARE @loop INT; SET @loop=1;
DECLARE @lname VARCHAR(MAX);
DECLARE @lparent VARCHAR(MAX);
DECLARE @lPath_Locator HIERARCHYID;
DECLARE @lParent_Path_Locator HIERARCHYID;
DECLARE @lnew_Path_Locator HIERARCHYID;
WHILE @loop <= @rows BEGIN SELECT @lPath_Locator=Path_Locator, @lname=Name, @lparent=Parent, @lParent_Path_Locator=null FROM @directories WHERE ID=@loop; IF (@lPath_Locator) IS NULL BEGIN IF @lparent IS NOT NULL BEGIN SET @lParent_Path_Locator = (SELECT path_locator FROM @directories WHERE id=@loop-1); SET @lPath_Locator=(SELECT path_locator FROM dbo.Document WHERE name=@lName AND parent_path_locator=@lParent_Path_Locator); IF @lPath_Locator IS NULL BEGIN SELECT @lPath_Locator = @lParent_Path_Locator.ToString() + CONVERT(VARCHAR(20), CONVERT(BIGINT, SUBSTRING(CONVERT(BINARY(16), NEWID()), 1, 6))) + '.' + CONVERT(VARCHAR(20), CONVERT(BIGINT, SUBSTRING(CONVERT(BINARY(16), NEWID()), 7, 6))) + '.' + CONVERT(VARCHAR(20), CONVERT(BIGINT, SUBSTRING(CONVERT(BINARY(16), NEWID()), 13, 4))) + '/'; INSERT INTO dbo.Document(name, is_directory, path_locator) VALUES( @lName, 1, @lPath_Locator ); End END ELSE BEGIN SET @lPath_Locator=(SELECT path_locator FROM dbo.Document WHERE name=@lName); IF @lPath_Locator IS NULL BEGIN INSERT INTO dbo.Document(name, is_directory) VALUES( @lName, 1 ); SET @lPath_Locator = (SELECT path_locator FROM dbo.Document WHERE name=@lName); End END UPDATE @directories SET Path_Locator=@lPath_Locator WHERE ID=@loop; END SET @loop=@loop+1; end SET @path_Locator_for_file=@lPath_Locator; SELECT @path_Locator_for_file = @lPath_Locator.ToString() + CONVERT(VARCHAR(20), CONVERT(BIGINT, SUBSTRING(CONVERT(BINARY(16), NEWID()), 1, 6))) + '.' + CONVERT(VARCHAR(20), CONVERT(BIGINT, SUBSTRING(CONVERT(BINARY(16), NEWID()), 7, 6))) + '.' + CONVERT(VARCHAR(20), CONVERT(BIGINT, SUBSTRING(CONVERT(BINARY(16), NEWID()), 13, 4))) + '/'; -- create file in subdir INSERT INTO dbo.Document (name, file_stream, path_locator) SELECT @name, @stream, @path_Locator_for_file; END ELSE BEGIN -- create file IN ROOT INSERT INTO dbo.Document (name, file_stream) SELECT @name, @stream; SELECT @path_Locator_for_file=path_locator FROM dbo.document WHERE name=@name; END SET @path_locator=CONVERT(VARCHAR(max),@path_Locator_for_file); COMMIT TRAN adddocument; END TRY BEGIN CATCH IF @@TRANCOUNT>0
ROLLBACK TRAN adddocument;
THROW;
END CATCH
END
CREATE PROC [dbo].[UpdDocument]
(@relativePath varchar(max)=null
,@name varchar(4000)=null
,@stream VARBINARY(MAX)=NULL
,@path_locator VARCHAR(512)=null
)
AS
BEGIN
DECLARE @path_locator_hid HIERARCHYID;
BEGIN TRY
BEGIN TRAN upddocument
-- test input
IF @path_locator IS null AND (COALESCE(@relativePath,'')='' OR COALESCE(@name,'')='')
THROW 50001, 'no input given', 1;
IF coalesce(@relativePath,'') > ''
BEGIN
SET @relativePath=LTRIM(RTRIM(@relativePath));
IF LEFT(@relativePath,1)='\'
SET @relativePath=SUBSTRING(@relativePath, 2, LEN(@relativePath)-1);
IF RIGHT(@relativePath,1)='\'
SET @relativePath=LEFT(@relativePath, LEN(@relativePath)-1);
END
-- find pathlocator for doc
IF @path_Locator IS NULL
begin
SELECT @path_locator_hid=getPathLocator(FileTableRootPath('dbo.document')+'\'+@relativePath+'\'+@name);
END
ELSE
BEGIN
SET @path_locator_hid=CONVERT(HIERARCHYID,@path_locator);
end
-- update doc
UPDATE dbo.Document
SET file_stream=@stream
WHERE path_locator=@path_locator_hid;
IF @@ROWCOUNT=0
THROW 50002, 'document not found', 1;
COMMIT TRAN updocument;
END TRY
BEGIN CATCH
IF @@TRANCOUNT>0
ROLLBACK TRAN upddocument;
THROW;
END CATCH
END
Input parameters are the same as in the addDocument procedure. In this case the document must exists otherwise it returns an error.
Only the data stream is replaced the meta data left the same.
CREATE PROC [dbo].[DelDocument]
(@relativePath varchar(MAX)=NULL
,@name varchar(512)=NULL
,@path_locator VARCHAR(4000)=NULL
)
AS
BEGIN
SET NOCOUNT ON
DECLARE @path_locator_hid HIERARCHYID;
BEGIN TRY
BEGIN TRAN deldocument
-- test input
IF @path_locator IS null AND (COALESCE(@relativePath,'')='' OR COALESCE(@name,'')='')
THROW 50001, 'no input given', 1;
IF coalesce(@relativePath,'') > ''
BEGIN
SET @relativePath=LTRIM(RTRIM(@relativePath));
IF LEFT(@relativePath,1)='\'
SET @relativePath=SUBSTRING(@relativePath, 2, LEN(@relativePath)-1);
IF RIGHT(@relativePath,1)='\'
SET @relativePath=LEFT(@relativePath, LEN(@relativePath)-1);
END
-- delete doc
IF @path_Locator IS NULL
begin
SELECT @path_locator_hid=getPathLocator(FileTableRootPath('dbo.document')+'\'+@relativePath+'\'+@name);
END
ELSE
BEGIN
SET @path_locator_hid=CONVERT(HIERARCHYID,@path_locator);
end
DELETE FROM [dbo].[Document]
WHERE path_locator=@path_locator_hid;
IF @@ROWCOUNT=0
THROW 50002, 'document not found', 1;
COMMIT TRAN deldocument;
END TRY
BEGIN CATCH
IF @@TRANCOUNT>0
ROLLBACK TRAN deldocument;
THROW;
END CATCH
END
That’s all you have to do. Now you can just calling the procedures AddDocument,DelDocument and UpdDocument to manage your files. If you need to search for a file and/or link the file table, just using the vDocument view (joined or not).
this is just a little work around to use file tables combined with EF. It’s not perfect, and can be optimised at some point, but for the moment it works for me.
© 2023 Kohera
Crafted by
© 2022 Kohera
Crafted by
Cookie | Duration | Description |
---|---|---|
ARRAffinity | session | ARRAffinity cookie is set by Azure app service, and allows the service to choose the right instance established by a user to deliver subsequent requests made by that user. |
ARRAffinitySameSite | session | This cookie is set by Windows Azure cloud, and is used for load balancing to make sure the visitor page requests are routed to the same server in any browsing session. |
cookielawinfo-checkbox-advertisement | 1 year | Set by the GDPR Cookie Consent plugin, this cookie records the user consent for the cookies in the "Advertisement" category. |
cookielawinfo-checkbox-analytics | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Analytics". |
cookielawinfo-checkbox-functional | 11 months | The cookie is set by GDPR cookie consent to record the user consent for the cookies in the category "Functional". |
cookielawinfo-checkbox-necessary | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookies is used to store the user consent for the cookies in the category "Necessary". |
cookielawinfo-checkbox-others | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Other. |
cookielawinfo-checkbox-performance | 11 months | This cookie is set by GDPR Cookie Consent plugin. The cookie is used to store the user consent for the cookies in the category "Performance". |
CookieLawInfoConsent | 1 year | CookieYes sets this cookie to record the default button state of the corresponding category and the status of CCPA. It works only in coordination with the primary cookie. |
elementor | never | The website's WordPress theme uses this cookie. It allows the website owner to implement or change the website's content in real-time. |
viewed_cookie_policy | 11 months | The cookie is set by the GDPR Cookie Consent plugin and is used to store whether or not user has consented to the use of cookies. It does not store any personal data. |
Cookie | Duration | Description |
---|---|---|
__cf_bm | 30 minutes | Cloudflare set the cookie to support Cloudflare Bot Management. |
pll_language | 1 year | Polylang sets this cookie to remember the language the user selects when returning to the website and get the language information when unavailable in another way. |
Cookie | Duration | Description |
---|---|---|
_ga | 1 year 1 month 4 days | Google Analytics sets this cookie to calculate visitor, session and campaign data and track site usage for the site's analytics report. The cookie stores information anonymously and assigns a randomly generated number to recognise unique visitors. |
_ga_* | 1 year 1 month 4 days | Google Analytics sets this cookie to store and count page views. |
_gat_gtag_UA_* | 1 minute | Google Analytics sets this cookie to store a unique user ID. |
_gid | 1 day | Google Analytics sets this cookie to store information on how visitors use a website while also creating an analytics report of the website's performance. Some of the collected data includes the number of visitors, their source, and the pages they visit anonymously. |
ai_session | 30 minutes | This is a unique anonymous session identifier cookie set by Microsoft Application Insights software to gather statistical usage and telemetry data for apps built on the Azure cloud platform. |
CONSENT | 2 years | YouTube sets this cookie via embedded YouTube videos and registers anonymous statistical data. |
vuid | 1 year 1 month 4 days | Vimeo installs this cookie to collect tracking information by setting a unique ID to embed videos on the website. |
Cookie | Duration | Description |
---|---|---|
ai_user | 1 year | Microsoft Azure sets this cookie as a unique user identifier cookie, enabling counting of the number of users accessing the application over time. |
VISITOR_INFO1_LIVE | 5 months 27 days | YouTube sets this cookie to measure bandwidth, determining whether the user gets the new or old player interface. |
YSC | session | Youtube sets this cookie to track the views of embedded videos on Youtube pages. |
yt-remote-connected-devices | never | YouTube sets this cookie to store the user's video preferences using embedded YouTube videos. |
yt-remote-device-id | never | YouTube sets this cookie to store the user's video preferences using embedded YouTube videos. |
yt.innertube::nextId | never | YouTube sets this cookie to register a unique ID to store data on what videos from YouTube the user has seen. |
yt.innertube::requests | never | YouTube sets this cookie to register a unique ID to store data on what videos from YouTube the user has seen. |
Cookie | Duration | Description |
---|---|---|
WFESessionId | session | No description available. |