From 01eeb45a021044e8f6c02f6a13b7ab0756edcde6 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:05:17 +0100 Subject: [PATCH 1/5] Draft of public API surface --- .../SqlMetaData.xml | 18 +++++++++++++++++- .../Data/SqlClient/Server/SqlMetaData.cs | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient.Server/SqlMetaData.xml b/doc/snippets/Microsoft.Data.SqlClient.Server/SqlMetaData.xml index cbabfd8cb0..20c53a702b 100644 --- a/doc/snippets/Microsoft.Data.SqlClient.Server/SqlMetaData.xml +++ b/doc/snippets/Microsoft.Data.SqlClient.Server/SqlMetaData.xml @@ -1,4 +1,4 @@ - + @@ -1490,6 +1490,22 @@ The is . + + + Indicates if this column is computed by the server. + + + A value. + + + + The default is . + + + This property can only be set from an object initializer immediately after construction of the instance. + + + Indicates if the column in the table-valued parameter is unique. diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SqlMetaData.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SqlMetaData.cs index 2c3c6acaab..7ab3dd2f99 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SqlMetaData.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SqlMetaData.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -505,6 +505,9 @@ public string TypeName /// public bool UseServerDefault => _useServerDefault; + /// + public bool IsComputed { get; init; } + /// public string XmlSchemaCollectionDatabase => _xmlSchemaCollectionDatabase; From 60f516e7fd3096fe5714d552f7f1818f691789ef Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:07:36 +0100 Subject: [PATCH 2/5] Feed IsComputed into SmiMetaDataProperty instances Note that a computed column will (by definition) always have a default value, so setting IsComputed also implies the same effect as UseServerDefault. --- .../Data/SqlClient/Server/SmiMetaData.cs | 1 + .../SqlClient/Server/SmiMetaDataProperty.cs | 68 ++++++++++++++++++- .../Microsoft/Data/SqlClient/SqlParameter.cs | 20 ++++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs index f2d430f2e2..5a7ca32754 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaData.cs @@ -349,6 +349,7 @@ SmiMetaDataPropertyCollection extendedProperties ((SmiDefaultFieldsProperty)_extendedProperties[SmiPropertySelector.DefaultFields]).CheckCount(_fieldMetaData.Count); ((SmiOrderProperty)_extendedProperties[SmiPropertySelector.SortOrder]).CheckCount(_fieldMetaData.Count); ((SmiUniqueKeyProperty)_extendedProperties[SmiPropertySelector.UniqueKey]).CheckCount(_fieldMetaData.Count); + ((SmiComputedFieldsProperty)_extendedProperties[SmiPropertySelector.ComputedFields]).CheckCount(_fieldMetaData.Count); #endif } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaDataProperty.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaDataProperty.cs index 1fbc0ade2f..294ea1051e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaDataProperty.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/Server/SmiMetaDataProperty.cs @@ -18,12 +18,13 @@ internal enum SmiPropertySelector DefaultFields = 0x0, SortOrder = 0x1, UniqueKey = 0x2, + ComputedFields = 0x3, } // Simple collection for properties. Could extend to IDictionary support if needed in future. internal class SmiMetaDataPropertyCollection { - private const int SelectorCount = 3; // number of elements in SmiPropertySelector + private const int SelectorCount = 4; // number of elements in SmiPropertySelector private readonly SmiMetaDataProperty[] _properties; private bool _isReadOnly; @@ -32,6 +33,7 @@ internal class SmiMetaDataPropertyCollection private static readonly SmiDefaultFieldsProperty s_emptyDefaultFields = new SmiDefaultFieldsProperty(new List()); private static readonly SmiOrderProperty s_emptySortOrder = new SmiOrderProperty(new List()); private static readonly SmiUniqueKeyProperty s_emptyUniqueKey = new SmiUniqueKeyProperty(new List()); + private static readonly SmiComputedFieldsProperty s_emptyIsComputedField = new SmiComputedFieldsProperty(new List()); internal static readonly SmiMetaDataPropertyCollection s_emptyInstance = CreateEmptyInstance(); @@ -49,6 +51,7 @@ internal SmiMetaDataPropertyCollection() _properties[(int)SmiPropertySelector.DefaultFields] = s_emptyDefaultFields; _properties[(int)SmiPropertySelector.SortOrder] = s_emptySortOrder; _properties[(int)SmiPropertySelector.UniqueKey] = s_emptyUniqueKey; + _properties[(int)SmiPropertySelector.ComputedFields] = s_emptyIsComputedField; } internal SmiMetaDataProperty this[SmiPropertySelector key] @@ -275,4 +278,67 @@ internal override string TraceString() #endregion } + + internal class SmiComputedFieldsProperty : SmiMetaDataProperty + { + #region private fields + + private readonly IList _computed; + + #endregion + + #region internal interface + + internal SmiComputedFieldsProperty(IList computedFields) => _computed = new System.Collections.ObjectModel.ReadOnlyCollection(computedFields); + + internal bool this[int ordinal] + { + get + { + if (_computed.Count <= ordinal) + { + return false; + } + else + { + return _computed[ordinal]; + } + } + } + + [Conditional("DEBUG")] + internal void CheckCount(int countToMatch) + { + Debug.Assert(0 == _computed.Count || countToMatch == _computed.Count, + "SmiComputedFieldsProperty.CheckCount: ComputedFieldsProperty size (" + _computed.Count + + ") not equal to checked size (" + countToMatch + ")"); + } + + internal override string TraceString() + { + string returnValue = "ComputedFields("; + bool delimit = false; + for (int columnOrd = 0; columnOrd < _computed.Count; columnOrd++) + { + if (delimit) + { + returnValue += ","; + } + else + { + delimit = true; + } + + if (_computed[columnOrd]) + { + returnValue += columnOrd; + } + } + returnValue += ")"; + + return returnValue; + } + + #endregion + } } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs index f9e342d4c6..427f2b0ec9 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlParameter.cs @@ -1334,9 +1334,11 @@ private void GetActualFieldsAndProperties(out List fields, bool[] keyCols = new bool[fieldCount]; bool[] defaultFields = new bool[fieldCount]; bool[] sortOrdinalSpecified = new bool[fieldCount]; + bool[] computedFields = new bool[fieldCount]; int maxSortOrdinal = -1; // largest sort ordinal seen, used to optimize locating holes in the list bool hasKey = false; bool hasDefault = false; + bool hasComputedFields = false; int sortCount = 0; SmiOrderProperty.SmiColumnOrder[] sort = new SmiOrderProperty.SmiColumnOrder[fieldCount]; fields = new List(fieldCount); @@ -1356,6 +1358,16 @@ private void GetActualFieldsAndProperties(out List fields, hasDefault = true; } + if (colMeta.IsComputed) + { + // A computed column will always have a default value, so skip writing + // its values out to the TDS stream. + defaultFields[i] = true; + computedFields[i] = true; + hasDefault = true; + hasComputedFields = true; + } + sort[i]._order = colMeta.SortOrder; if (SortOrder.Unspecified != colMeta.SortOrder) { @@ -1400,6 +1412,14 @@ private void GetActualFieldsAndProperties(out List fields, props[SmiPropertySelector.DefaultFields] = new SmiDefaultFieldsProperty(new List(defaultFields)); } + if (hasComputedFields) + { + // We've already created props list in default value handling + Debug.Assert(props is not null); + + props[SmiPropertySelector.ComputedFields] = new SmiComputedFieldsProperty(new List(computedFields)); + } + if (0 < sortCount) { // validate monotonically increasing sort order. From e6b9d2b2dcc6ae1b6c41c2bf3b88dc9e24276f58 Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:09:02 +0100 Subject: [PATCH 3/5] Use SmiMetaDataProperty to set the fComputed bit in TVP definition Note that the previous commit means that IsComputed will also result in the fDefault bit being set. --- .../src/Microsoft/Data/SqlClient/TdsEnums.cs | 1 + .../src/Microsoft/Data/SqlClient/TdsParser.cs | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs index 2590c28690..c1dada821f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsEnums.cs @@ -445,6 +445,7 @@ public enum ActiveDirectoryWorkflow : byte public const byte TVP_ORDER_UNIQUE_TOKEN = 0x10; // TvpColumnMetaData flags + public const int TVP_COMPUTED_COLUMN = 0x20; public const int TVP_DEFAULT_COLUMN = 0x200; // TVP_ORDER_UNIQUE_TOKEN flags diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs index d8c904f1e0..0511693873 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/TdsParser.cs @@ -11261,9 +11261,10 @@ private void WriteTvpTypeInfo(SmiExtendedMetaData metaData, TdsParserStateObject // TvpColumnMetaData for each column (look for defaults in this loop SmiDefaultFieldsProperty defaults = (SmiDefaultFieldsProperty)metaData.ExtendedProperties[SmiPropertySelector.DefaultFields]; + SmiComputedFieldsProperty computedFields = (SmiComputedFieldsProperty)metaData.ExtendedProperties[SmiPropertySelector.ComputedFields]; for (int i = 0; i < metaData.FieldMetaData.Count; i++) { - WriteTvpColumnMetaData(metaData.FieldMetaData[i], defaults[i], stateObj); + WriteTvpColumnMetaData(metaData.FieldMetaData[i], defaults[i], computedFields[i], stateObj); } // optional OrderUnique metadata @@ -11275,7 +11276,7 @@ private void WriteTvpTypeInfo(SmiExtendedMetaData metaData, TdsParserStateObject } // Write a single TvpColumnMetaData stream to the server - private void WriteTvpColumnMetaData(SmiExtendedMetaData md, bool isDefault, TdsParserStateObject stateObj) + private void WriteTvpColumnMetaData(SmiExtendedMetaData md, bool isDefault, bool isComputed, TdsParserStateObject stateObj) { // User Type if (SqlDbType.Timestamp == md.SqlDbType) @@ -11293,6 +11294,10 @@ private void WriteTvpColumnMetaData(SmiExtendedMetaData md, bool isDefault, TdsP { status |= TdsEnums.TVP_DEFAULT_COLUMN; } + if (isComputed) + { + status |= TdsEnums.TVP_COMPUTED_COLUMN; + } WriteUnsignedShort(status, stateObj); // Type info From 531545cf5669e3f0387c5ecdb93bccd01985ed6e Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:09:54 +0100 Subject: [PATCH 4/5] Add tests --- .../ComputedFieldTests.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/TableValuedParameter/ComputedFieldTests.cs diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/TableValuedParameter/ComputedFieldTests.cs b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/TableValuedParameter/ComputedFieldTests.cs new file mode 100644 index 0000000000..5f22801dd3 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/TableValuedParameter/ComputedFieldTests.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Data; +using System.Runtime.CompilerServices; +using Microsoft.Data.SqlClient.Server; +using Microsoft.Data.SqlClient.Tests.Common.Fixtures.DatabaseObjects; +using Xunit; + +namespace Microsoft.Data.SqlClient.ManualTesting.Tests.TableValuedParameter; + +[Trait("Set", "3")] +public class ComputedFieldTests +{ + [ConditionalTheory(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + [InlineData(true)] + [InlineData(false)] + public void ComputedFieldValues_ReturnedByServer(bool explicitlySpecifyValue) => + SendComputedFieldsAndAssert(explicitlySpecifyValue, recordCount: 20, markFieldAsComputed: true); + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public void UnmarkedComputedFieldValues_RejectedByServer() + { + Action sendTVP = () => SendComputedFieldsAndAssert(explicitlySpecifyValue: false, recordCount: 20, markFieldAsComputed: false); + + SqlException serverException = Assert.Throws(sendTVP); + + Assert.Equal(271, serverException.Number); + Assert.Equal(1, serverException.State); + Assert.Contains("The column \"Sum\" cannot be modified because it is either a computed column or is the result of a UNION operator.", serverException.Message); + Assert.Contains("The column \"CastSum\" cannot be modified because it is either a computed column or is the result of a UNION operator.", serverException.Message); + } + + [ConditionalFact(typeof(DataTestUtility), nameof(DataTestUtility.AreConnStringsSetup))] + public void MismatchedComputedFieldLength_IgnoredByServer() => + SendComputedFieldsAndAssert(explicitlySpecifyValue: false, recordCount: 20, markFieldAsComputed: true, nvarcharMaxLength: 1); + + private void SendComputedFieldsAndAssert( + bool explicitlySpecifyValue, + int recordCount, + bool markFieldAsComputed, + int nvarcharMaxLength = 50, + [CallerMemberName] string callerName = nameof(SendComputedFieldsAndAssert)) + { + // TombstoneValue must always be a value which is never equal to [i + i + 1] for any "i" value + // between 0 and recordCount - 1. This is to ensure that the computed column value is not equal + // to the tombstone value when explicitly specified. + const int TombstoneValue = 0; + + // Arrange + using SqlConnection conn = new(DataTestUtility.TCPConnectionString); + using UserDefinedType udt = new(conn, callerName, @" + TABLE + ( + Value1 INT NOT NULL, + Sum AS (Value1 + Value2), + Value2 INT NOT NULL, + CastSum AS (CAST((Value1 + Value2) AS NVARCHAR(50))) + )"); + using StoredProcedure sp = new(conn, callerName, $@" + @PrecedingValue INT, + @tvp {udt.Name} READONLY, + @SubsequentValue INT + AS + BEGIN + SELECT Value1, Value2, Sum, CastSum FROM @tvp; + SELECT @PrecedingValue AS PrecedingValue, @SubsequentValue AS SubsequentValue; + END"); + SqlMetaData[] metaDatas = [ + new SqlMetaData("Value1", SqlDbType.Int), + new SqlMetaData("Sum", SqlDbType.Int) { IsComputed = markFieldAsComputed }, + new SqlMetaData("Value2", SqlDbType.Int), + new SqlMetaData("CastSum", SqlDbType.NVarChar, maxLength: nvarcharMaxLength) { IsComputed = markFieldAsComputed } + ]; + List records = []; + + for (int i = 0; i < recordCount; i++) + { + SqlDataRecord record = new(metaDatas); + record.SetInt32(0, i); + record.SetInt32(2, i + 1); + + if (explicitlySpecifyValue) + { + record.SetInt32(1, TombstoneValue); + record.SetString(3, TombstoneValue.ToString()); + } + + records.Add(record); + } + + using SqlCommand cmd = new(sp.Name, conn) { CommandType = CommandType.StoredProcedure }; + cmd.Parameters.AddWithValue("@PrecedingValue", 50); + + SqlParameter tvpParam = cmd.Parameters.Add("@tvp", SqlDbType.Structured); + tvpParam.TypeName = udt.Name; + tvpParam.Value = records; + + cmd.Parameters.AddWithValue("@SubsequentValue", 100); + + // Act + using SqlDataReader reader = cmd.ExecuteReader(); + int returnedRecordCount = 0; + + // Assert + while (reader.Read()) + { + int value1 = reader.GetInt32(0); + int value2 = reader.GetInt32(1); + int sum = reader.GetInt32(2); + string castSum = reader.GetString(3); + + Assert.Equal(value1 + value2, sum); + Assert.Equal((value1 + value2).ToString(), castSum); + + if (explicitlySpecifyValue) + { + Assert.NotEqual(TombstoneValue, sum); + Assert.NotEqual(TombstoneValue.ToString(), castSum); + } + returnedRecordCount++; + } + + bool nextResult = reader.NextResult(); + Assert.True(nextResult); + + nextResult = reader.Read(); + Assert.True(nextResult); + + Assert.Equal(cmd.Parameters["@PrecedingValue"].Value, reader.GetInt32(0)); + Assert.Equal(cmd.Parameters["@SubsequentValue"].Value, reader.GetInt32(1)); + + Assert.Equal(recordCount, returnedRecordCount); + } +} From bf5aeaf436b020a4e228d135d5656ea451d4796b Mon Sep 17 00:00:00 2001 From: Edward Neal <55035479+edwardneal@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:41:14 +0100 Subject: [PATCH 5/5] Add draft public API to ref assembly This is an init property, so requires a shim on net462 and netstandard. --- .../ref/Microsoft.Data.SqlClient.Server.cs | 4 +++- .../ref/System.Runtime.CompilerServices.cs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Data.SqlClient/ref/System.Runtime.CompilerServices.cs diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.Server.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.Server.cs index bd2c844c95..b6f8ce3d90 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.Server.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.Server.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. @@ -215,6 +215,8 @@ public SqlMetaData(string name, System.Data.SqlDbType dbType, System.Type userDe public System.Data.SqlTypes.SqlCompareOptions CompareOptions { get { throw null; } } /// public System.Data.DbType DbType { get { throw null; } } + /// + public bool IsComputed { get { throw null; } init { } } /// public bool IsUniqueKey { get { throw null; } } /// diff --git a/src/Microsoft.Data.SqlClient/ref/System.Runtime.CompilerServices.cs b/src/Microsoft.Data.SqlClient/ref/System.Runtime.CompilerServices.cs new file mode 100644 index 0000000000..6d440228ab --- /dev/null +++ b/src/Microsoft.Data.SqlClient/ref/System.Runtime.CompilerServices.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#if !NET + +namespace System.Runtime.CompilerServices; + +/// +/// Reserved to be used by the compiler for tracking metadata. +/// This class should not be used by developers in source code. +/// +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +internal static class IsExternalInit +{ +} + +#endif