Unable to pass empty string into non-null database field
Asked Answered
J

2

7

I'm stumped on something which should be very straight-forward. I have a SQL Server database, and I'm trying to update a non-nullable varchar or nvarchar field with an empty string. I know it's possible, because an empty string '' is not the same thing as NULL. However, using the TADOQuery, it is not allowing me to do this.

I'm trying to update an existing record like so:

ADOQuery1.Edit;
ADOQuery1['NonNullFieldName']:= '';
//or
ADOQuery1.FieldByName('NonNullFieldName').AsString:= '';
ADOQuery1.Post; //<-- Exception raised while posting

If there is anything in the string, even just a single space, it saves just fine, as expected. But, if it is an empty string, it fails:

Non-nullable column cannot be updated to Null.

But it's not null. It's an empty string, which should work just fine. I swear I've passed empty strings many, many times in the past.

Why am I getting this error, and what should I do to resolve it?


Additional details:

  • Database: Microsoft SQL Server 2014 Express
  • Language: Delphi 10 Seattle Update 1
  • Database drivers: SQLOLEDB.1
  • Field being updated: nvarchar(MAX) NOT NULL
Jennifer answered 26/1, 2018 at 22:45 Comment(0)
A
13

I can reproduce your reported problem using the code below with SS2014, the OLEDB driver and Seattle and the difference in behaviour when the table has been created with MAX as the column size and a specific number (4096 in my case). I thought I would post this is as an alternative answer because it not only shows how to investigate this difference systematically but also identifies why this difference arises (and hence how to avoid it in future).

Please refer to and execute the code below, as written, i.e. with the UseMAX define active.

Turning on "Use Debug DCUs" in the the project options before executing the code, immediately reveals that the described exception occurs in Data.Win.ADODB at line 4920

Recordset.Fields[TField(FModifiedFields[I]).FieldNo-1].Value := Data

of TCustomADODataSet.InternalPost and the Debug evaluation window reveals that Data at this point is Null.

Next, notice that

update jdtest set NonNullFieldName = ''

executes in an SSMS2014 Query window without complaint (Command(s) completed successfully.), so it seems that the fact that Data is Null at line 4920 is what is causing the problem and the next question is "Why?"

Well, the first thing to notice is that the form's caption is displaying ftMemo

Next, comment out the UseMAX define, recompile and execute. Result: No exception snd notice that the form's caption is now displaying ftString.

And that's the reason: Using a specific number for the column size means that the table metadata retrieved by the RTL causes the client-side Field to be created as a TStringField, whose value you can set by a string assignment statement.

OTOH, when you specify MAX, the resulting client-side Field is of type ftMemo, which is one of Delphi's BLOB types and when you assign string values to an ftMemo field, you are at the mercy of code in Data.DB.Pas , which does all the reading (and writing) to the record buffer using a TBlobStream. The problem with that is that as far as I can see, after a lot of experiments and tracing through the code, the way a TMemoField uses a BlobStream fails to properly distinguish between updating the field contents to '' and setting the field's value to Null (as in System.Variants).

In short, whenever you try to set a TMemoField's value to an empty string, what actually happens is that the field's state is set to Null, and this is what causes the exception in the q. AFAICS, this is unavoidable, so no work-around is obvious, to me at any rate.

I have not investigated whether the choice between ftMemo and ftString is made by the Delphi RTL code or the MDAC(Ado) layer it sits upon: I would expect it is actually determined by the RecordSet TAdoQuery uses.

QED. Notice that this systematic approach to debugging has revealed the problem & cause with very little effort and zero trial and error, which was what I was trying to suggest in my comments on the q.

Another point is that this problem could be tracked down entirely without resorting to server-side tools including the SMSS profiler. There wasn't any need to use the profiler to inspect what the client was sending to the server because there was no reason to suppose that the error returned by the server was incorrect. That confirms what I said about starting investigation at the client side.

Also, using a table created on the fly using IfDefed Sql enabled the problem effectively to be isolated in a single step by simple observation of two runs of the app.

Code

uses [...] TypInfo;
[...]
implementation[...]

const
   //  The following consts are to create the table and insert a single row
   //
   //  The difference between them is that scSqlSetUp1 specifies
   //  the size of the NonNullFieldName to 'MAX' whereas scSqlSetUp2 specifies a size of 4096

   scSqlSetUp1 =
  'CREATE TABLE [dbo].[JDTest]('#13#10
   + '  [ID] [int] NOT NULL primary key,'#13#10
   + '  [NonNullFieldName] VarChar(MAX) NOT NULL'#13#10
   + ') ON [PRIMARY]'#13#10
   + ';'#13#10
   + 'Insert JDTest (ID, [NonNullFieldName]) values (1, ''a'')'#13#10
   + ';'#13#10
   + 'SET ANSI_PADDING OFF'#13#10
   + ';';

   scSqlSetUp2 =
  'CREATE TABLE [dbo].[JDTest]('#13#10
   + '  [ID] [int] NOT NULL primary key,'#13#10
   + '  [NonNullFieldName] VarChar(4096) NOT NULL'#13#10
   + ') ON [PRIMARY]'#13#10
   + ';'#13#10
   + 'Insert JDTest (ID, [NonNullFieldName]) values (1, ''a'')'#13#10
   + ';'#13#10
   + 'SET ANSI_PADDING OFF'#13#10
   + ';';

   scSqlDropTable = 'drop table [dbo].[jdtest]';

procedure TForm1.Test1;
var
  AField : TField;
  S : String;
begin

//  Following creates the table.  The define determines the size of the NonNullFieldName

{$define UseMAX}
{$ifdef UseMAX}
  S := scSqlSetUp1;
{$else}
  S := scSqlSetUp2;
{$endif}

  ADOConnection1.Execute(S);
  try
    ADOQuery1.Open;
    try
      ADOQuery1.Edit;

      // Get explicit reference to the NonNullFieldName
      //  field to make working with it and investigating it easier

      AField := ADOQuery1.FieldByName('NonNullFieldName');

      //  The following, which requires the `TypInfo` unit in the `USES` list is to find out which exact type
      //  AField is.  Answer:  ftMemo, or ftString, depending on UseMAX.  
      //  Of course, we could get this info by inspection in the IDE
      //  by creating persistent fields

      S := GetEnumName(TypeInfo(TFieldType), Ord(AField.DataType));
      Caption := S;  // Displays `ftMemo` or `ftString`, of course

      AField.AsString:= '';
      ADOQuery1.Post; //<-- Exception raised while posting
    finally
      ADOQuery1.Close;
    end;
  finally
    //  Tidy up
    ADOConnection1.Execute(scSqlDropTable);
  end;
end;

procedure TForm1.Button1Click(Sender: TObject);
begin
  Test1;
end;
Adamic answered 27/1, 2018 at 11:19 Comment(9)
So what is the verdict? a no-null n/vacrchar(MAX) column can not be set to empty string via TADODataSet Edit/Post? (I can't test this right now and never used a non-null nvarchar(MAX) in my projects)Insignia
@kobik: The jury is still out. So far my attempts to set the field to an empty string have all failed, mostly with the same error as the OP reported, including trying an equivalent to line 4920 of AdoDB but specifying an empty string instead of the Null it uses, attempting to set the underlying recordset field to #0,etc. Attempting to use an AdoBlobStream created with mode bmWrite executes w/o complaint but doesn't actually update the server field. Annoyingly, doing a set NonNullFieldName = '' works fine, and its DataLength is then reported as 0, so SSMS evidently knows a way to do it,Adamic
@kobik: I'm beginning to think that this error is being generated in the ADO layer on the client rather than by the server. According to the SSMS Profiler, when the column is being updated to a non-null value, the Profiler shows the usual invocation of a parameterized UPDATE statement as you'd expect, but when trying to set the column value to '', the exception error occurs on the client, and the Profiler shows nothing being sent to the server. Hmm ...Adamic
@MartynA, This is definitely not in the ADO layer. Updating the ADO recordset directly with empty string works just fine with memo fields. I would consider this a BUG in the ADODB Delphi layer. ftMemo simply can't be updated to empty string via TADODataSet - like you said, empty string transforms into NULL when updating. I did not have much time to test where the problem actually is in the code.Insignia
Also, Are you saying that Recordset.Fields[TField(FModifiedFields[I]).FieldNo-1].Value := '' did not worked? or did you not tried this?Insignia
@kobik: Sorry, when I said "being generated in the ADO layer", I didn't mean that the ADO layer was where the error is occurring, but rather that it is detecting the error and reporting it back to the app without snding the update to the server. Personally, I think the bug is most likely in the blo-handling routines used by TMemoField.Adamic
@kobik: "Are you saying that Recordset.Fields[TField(FModifiedFields[I]).FieldNo-1].Value := '' did not worked?" Yes I tried it and it did not work - I will have to remind myself what error it produced.Adamic
@MartynA, strange... As I said when I tried rs.Fields['NonNullFieldName'].Value := ''; rs.Update; (where rs is the TADODataSet.Recordset) the database was updated with empty string. I tested this in ms-access but I don't think it matters because the same behaviour is happening as OP described with ftMemo.Insignia
The bug is in BufferToVar -> Data := Variant(Buffer^);.Insignia
J
2

The problem occurs when using MAX in the data type. Both varchar(MAX) and nvarchar(MAX) exploit this behavior. When removing MAX and replacing it with a large number, such as 5000, then it allows empty strings.

Jennifer answered 26/1, 2018 at 23:36 Comment(5)
FYI, the SQLOLEDB driver is an old deprecated provider. You might try the SQL Server 2012 native client OLE DB provider (SQLNCLI11).Bordelon
@Dan Indeed, the project is actually designed to accept any arbitrary connection string to any ADO compatible database. That includes things such as Excel, as well as older technologies. This particular project is actually a data pump to migrate data from any older database into a different new one.Jennifer
@DanGuzman Apparently, as I recently read, Microsoft abandoned SQLNCLI and rolled back support for OLEDB. blogs.msdn.microsoft.com/sqlnativeclient/2017/10/06/…Jennifer
The blog post you referenced states OLE DB, as an API for relational database access is undeprecated, as MS had previously announced it was deprecated. The latest driver, MSSOLEDBSQL, is alive and well. However, the SQLOLEDB provider included with Windows provided only for backwards compatibility and should be avoided for new applications.Bordelon
@DanGuzman Yes, I saw that. I didn't recall many details of it before finding it and reading it again. In any case though, SQLNCLI was abandoned, even before this post.Jennifer

© 2022 - 2024 — McMap. All rights reserved.