13

I am using c# with .NET 4.5.2, pushing to SQL Server 2017 14.0.1000.169

In my database, I have a table with a DateAdded field, of type DateTimeOffset.

I am attempting to BulkCopy with the following code:

private Maybe BulkCopy(SqlSchemaTable table, System.Data.DataTable dt, bool identityInsertOn)
{
    try
    {
        var options = SqlBulkCopyOptions.TableLock | SqlBulkCopyOptions.FireTriggers | SqlBulkCopyOptions.UseInternalTransaction; //  | SqlBulkCopyOptions.CheckConstraints; // Tried CheckConstraints, but it didn't change anything.
        if (identityInsertOn) options |= SqlBulkCopyOptions.KeepIdentity;
        using (var conn = new SqlConnection(_connString))
        using (var bulkCopy = new SqlBulkCopy(conn, options, null))
        {
            bulkCopy.DestinationTableName = table.TableName;
            dt.Columns.Cast<System.Data.DataColumn>().ToList()
                .ForEach(x => bulkCopy.ColumnMappings.Add(new SqlBulkCopyColumnMapping(x.ColumnName, x.ColumnName)));

            try
            {
                conn.Open();
                bulkCopy.WriteToServer(dt);
            }
            catch (Exception ex)
            {
                return Maybe.Failure(ex);
            }
        }
    }
    catch (Exception ex)
    {
        return Maybe.Failure(ex);
    }

    return Maybe.Success();
}

The two possible reasons I know of for the does not allow DBNull error are:

  1. Columns are in the wrong order, which is solved by either putting them in the same order as their Database Ordinal, or by performing a Column Mapping.
  2. KeepNulls is enabled, and DBNull.Value (or null?) are set in the DataTable.

But I am Mapping correctly and NOT ever setting KeepNulls.

Yet I am receiving the error:

Column DateAdded does not allow DBNull.Value

EDIT I also tried just NOT SETTING anything, including null, DBNull.Value, and DefaultValue... just literally not setting that column at all.

Also, if I Remove the DateAdded column from the DataTable, it Works. But I don't want that. Of the 100,000 records, maybe 20 of them have data. So in my batches of 500, sometimes None have data in the DateAdded field, sometimes one or two have data.

So I'd like to keep the column in my DataTable but let it use the DefaultValue.

One last note: I have alternated between setting the DataColumn's Value to DBNull.Value versus dt.Columns[x.ColumnName].DefaultValue. Both ways give the same error.

Edit 2

This is the code I'm using to populate the data in my Data Table:

foreach (var column in table)
{
    System.Data.DataRow newRow = dt.NewRow();
    foreach (var field in column)
    {
        if (!IsNull(field.Value) && !IsEmptyDateOrNumber(field.ColumnType, field.Value))
        {
            // For DateAdded, this is not hit on the first batch, though there are columns Before and After DateAdded on the same row which do have value.
            // But it WILL be hit once or twice a few batches later.  So I don't want to completely remove the definition from the DataTable.
            newRow[field.ColumnName] = field.Value;
        }
        else
        {
            // newRow[field.ColumnName] = dt.Columns[field.ColumnName].DefaultValue;
            // newRow[field.ColumnName] = DBNull.Value;
            // dt.Columns[field.ColumnName].AllowDBNull = true;
        }
    }
    dt.Rows.Add(newRow);
}

IsNull() returns TRUE if the value is null or the string "null", as is required for my business requirements.

IsEmptyDateOrNumber() will return TRUE if the field is a numeric or date type, and the value is null or empty "". Because while empty is valid for many string-like fields, it is never a valid numeric value.

The condition to assign the field a value is hit exactly 0 percent of the time for this particular column. Thus nothing is set.

Suamere
  • 5,691
  • 2
  • 44
  • 58
  • It's very likely that the column DateAdded in the DB is set to not allow nulls and is almost always defaulted to getdate(). If the default is properly set then do not attempt to even insert to that column. – KeithL Jan 09 '19 at 19:23
  • @KeithL Thank you. That is pretty much toni's answer. We tried that and it still gives the error. I updated my question to show we've tried that. – Suamere Jan 11 '19 at 15:12
  • I still think the table does not allow nulls to be inserted into that column. In other words it needs a value. – KeithL Jan 11 '19 at 15:30
  • Have you tried setting the CheckConstraints SQLBulkCopy Option? – Steve Ford Jan 12 '19 at 13:09
  • It looks like SQLBulkCopy only correctly handles this if you exclude the column. i.e. all rows would have a default value for this column. Here's the source code for SQLBulkCopy http://www.dotnetframework.org/default.aspx/4@0/4@0/DEVDIV_TFS/Dev10/Releases/RTMRel/ndp/fx/src/Data/System/Data/SqlClient/SqlBulkCopy@cs/1305376/SqlBulkCopy@cs – Steve Ford Jan 12 '19 at 13:32
  • I would perhaps split your bulk copy into two statements, one that inserts the rows with values in the dateadded column and one where these columns are null and you could exclude them, You should be able to accomplish this by some simple linq queries on your datatable. – Steve Ford Jan 12 '19 at 13:35
  • @SteveFord That is good information, if not disheartening. Also, for some tables with *a bunch* of columns, though perhaps 4 or 5 `Date`-type columns, that is a lot of permutations of different Bulk Copy paths that would have to be taken. I just really hope that isn't the case. – Suamere Jan 12 '19 at 14:18
  • @Suamere you haven't stated what the default value is, could you not add this in load code or at least use a value you can identify later so that you can perform an update table set datefield = default where datefile = '2999-12-31' for instance. – Steve Ford Jan 12 '19 at 15:18
  • @SteveFord If I query the DataBase, the Default Value `syscomments.text` is 'NULL'. But when I do an `INSERT` statement with either the column omitted or with the insert keyword `DEFAULT`, it becomes the value of GETDATE(). So the default for the column is `GETDATE()`? Perhaps `syscomments` isn't the place to check for the default value? – Suamere Jan 12 '19 at 15:54
  • @Suamere you can check for defaults through SSMS column properties, you can also script the table out (right-click->script) to see defaults and other constraints. If the default is getdate(), you could just add the current date & time in your code. – Steve Ford Jan 12 '19 at 16:11

3 Answers3

8

Simply put, you can't do what you want. The best reference for how the BulkCopy works with default values is This Answer by Rutzky.

The problem is, BulkCopy includes a step where it queries the target database and determines the structure of the table. If it determines that the target column is NOT NULLable, and you are passing null or DBNull, it throws an exception before even trying to pass the data.

If you use SQL Profiler, you'll see the BCP Calls, but not the data (The data would never show up anyway). All you'll see is the call to define the Column List and Flags.

When the BulkCopy finally decides to pass the data along. If the column exists, and the field is NULLable, and the value is DBNull.Value, and the column has a Default Value; Bulk Copy essentially passes the DEFAULT flag along for that column. But some decision was made such that those conditions, except if the field is NOT NULLable, that the default value should not be used and instead an exception should be thrown.

As far as I can tell this is a bug or oversight by Microsoft.

The common workaround, as some other answers state, is to just handle those values manually by calculating what the value should be in-code. Of course, if you calculate default values, then the DBA changes the actual SQL Default Value for a field, your systems won't match. The next step is to add a subsystem to your system that queries and/or tracks/caches the currently specified default values from the SQL Server you're hitting, and assigning those. That's way more work than should be required.

TLDR: You can't do what you want. But there are sub-optimal workarounds that others have specified.

MasterHman
  • 356
  • 1
  • 2
1

This combination does not work for SqlBulkCopy:

  1. The date field in the SQL table is configured as not null.
  2. Your C# DataTable has records with unset date values (or has set values but they are not actual date/time values).

This combination does work:

  1. The date field in the SQL table is configured to allow nulls but use a default value.
  2. Your C# DataTable has records with unset date values (SQL will use the default value when the date field is not specified in the C# DataTable).

If the date field in your table MUST be configured to not accept null, then you have more work to do (create a staging table that accepts nulls, bulk insert into the staging table, call a stored proc that you write that inserts from the staging table into your regular table).

The simplest path to what I think you're looking for is this:

Your table should allow NULL for the date field but with a default:

CREATE TABLE [dbo].[MyTable](
    [Field1] [varchar](50) NULL,
    [MyDateField] [datetime] NULL,
) ON [PRIMARY]
GO

ALTER TABLE [dbo].[MyTable] ADD  CONSTRAINT [DF_Table_1_MyDateField] DEFAULT (getdate()) FOR [MyDateField]
GO

Then in C#:

        DataTable dt = new DataTable("dbo.MyTable");

        dt.Columns.Add("Field1");
        dt.Columns.Add("MyDateField");

        DataRow row1 = dt.NewRow();
        row1["Field1"] = "test row 1";
        row1["MyDateField"] = DateTime.Now; //specify a value for the date field in C#
        dt.Rows.Add(row1);

        DataRow row2 = dt.NewRow();
        row2["Field1"] = "test row 2";
        //do not specify a value for the date field - SQL will use the default value
        dt.Rows.Add(row2);

        do the bulk copy
toni
  • 133
  • 1
  • 10
  • I previously marked this as an answer, but have un-marked it. What I did was completely skip the Assignment of a Value for that column, though the column still existed in the DataTable definition. It seemed to work, but turned out I hadn't removed my prior test code that had actually been removing the column from the DataTable definition. So it was a false positive on fixing the issue. I'll put some time into looking at the FireTriggers and re-apply this as the answer if that helps solve the issue. – Suamere Jan 10 '19 at 23:29
  • I removed FireTriggers and it still said `Column DateAdded does not allow DBNull.Value`. But thank you for the path for me to attempt. – Suamere Jan 10 '19 at 23:43
  • Does your table have an insert trigger? Not certain but I believe a insert from any source will fire a trigger. Another thing to verify is that the field in your table does allow nulls. From your description however, it sounds like it certainly does. – toni Jan 11 '19 at 23:30
  • No, the field in my table is `NOT NULL`. e.g.: Does not allow nulls. That's why I'm trying to send the signal to use "Default" value. I just want to tell the Bulk Insert to use the Default Value for that Column. And, just like with a regular `INSERT` statement, if I omit the column, SQL has no choice but to use `DEFAULT`, and it works. However, I do **NOT** want to Omit the column. Because in bulk of dozens of thousands, batched by 500, occasionally there is a value. – Suamere Jan 12 '19 at 01:45
  • I realized this information is missing - what value do you want to be in the date field in the sql table when there is no valid date/time? null? some default? the min sql date (which is '1900-01-01 00:00:00.000')? – toni Jan 13 '19 at 15:17
  • Whatever the specified `default` is. If I Create Table to Script, it is `GETUTCDATE()`. But this question isn't limited to this table and column. So I don't want to hard-code that. I just want it to use the default value. – Suamere Jan 14 '19 at 03:54
1

Of the 100,000 records, maybe 20 of them have data. So in my batches of 500, sometimes None have data in the DateAdded field, sometimes one or two have data.

I'm guessing some of your records have nulls in DateAdded, while the DateAdded is configured to not aceept nulls.

To overcome this, you have multiple ways to do it :

  1. (Easier one) is just to alter column DateAdded to accept null.
  2. Assign a default value to column DateAdded (from MSSQL).
  3. Manage the nulls on the records from your code.

If you don't have permissions or your work requirment specified that DateAdded cannot accept nulls or have a default value from MSSQL OR 1 & 2 didn't solve your problem. Then, you can manage the nulls on DateAdded column on each batch before you copy it to the server.

In this case, in your code, while you're populating the data, you should add a condition to this column with something like this :

if(field.ColumnName == "DateAdded")
{
    // Do something to handle this column if it's null 

    // Set a default value 
    DateTimeOffset dtoffset = DateTimeOffset.Parse("2019-01-01 00:00:00.0000000 -00:00", CultureInfo.InvariantCulture); // change it to the required offset 
    string defaultDateAdded = dtoffset.ToString("yyyy-MM-dd HH:mm:ss.fffffff zzz", CultureInfo.InvariantCulture);               

    // Add the default value 
    newRow[field.ColumnName] = defaultDateAdded;
}
else
{
    // Do something to handle the rest of columns if they're null 

}   

then when you're done from handling all columns, you just add the new row into your datatable, then copy the finalized datatable to the server.

iSR5
  • 3,274
  • 2
  • 14
  • 13
  • This is pretty much what I'm doing right now to get around it. You're right that I don't have the access to change the target database. Your answer solves the situation, but not the exact solution to the exact question. This is great information for somebody else who is looking for this path, but I'm hoping there are other options and I'm hoping those options will appear by tomorrow. – Suamere Jan 13 '19 at 23:22
  • @Suamere I think the appropriate option for your case is to solve it from the source or the target databases, since they have schema differences. So, either the source should be set to not have nulls, or your target table should accept it. This would solve it permanently. – iSR5 Jan 13 '19 at 23:57
  • @Suamere After digging into the `SqlBulkCopy` class source code, I have to say that unfortunately there is a code which prevents passing null value to a database table column which does not allow NULL. They are retrieving the metadata information for the destination table from the database, and there is no mapping/controlling option to avoid that check. Which is bug in my opinion, but it is what it is - so no exact solution to the exact question, you have to accept some of the workarounds. – Ivan Stoev Jan 14 '19 at 05:32
  • 1
    Thanks for the update, Ivan. I actually found that same thing. I also used SQL Profiler and found that I can't see the resulting call, other than some basic BCP metadata. I may have to just suck it up and force a code-calculated Default. – Suamere Jan 14 '19 at 14:40