// // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. // using System; using System.Collections.Generic; using System.Data.Common; using Microsoft.Data.SqlClient; using System.Linq; using System.Threading.Tasks; using Microsoft.SqlTools.ServiceLayer.EditData.Contracts; using Microsoft.SqlTools.ServiceLayer.QueryExecution; using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts; using Microsoft.SqlTools.ServiceLayer.Utility.SqlScriptFormatters; namespace Microsoft.SqlTools.ServiceLayer.EditData.UpdateManagement { /// /// Base class for row edit operations. Provides basic information and helper functionality /// that all RowEdit implementations can use. Defines functionality that must be implemented /// in all child classes. Implements a custom IComparable to enable sorting by type of the edit /// and then by an overrideable /// public abstract class RowEditBase : IComparable { /// /// Internal parameterless constructor, required for mocking /// // ReSharper disable once UnusedMember.Global protected internal RowEditBase() { } /// /// Base constructor for a row edit. Stores the state that should be available to all row /// edit implementations. /// /// The internal ID of the row that is being edited /// The result set that will be updated /// Metadata provider for the object to edit protected RowEditBase(long rowId, ResultSet associatedResultSet, EditTableMetadata associatedMetadata) { if (!associatedMetadata.HasExtendedProperties) { throw new ArgumentException(SR.EditDataMetadataNotExtended); } RowId = rowId; AssociatedObjectMetadata = associatedMetadata; AssociatedResultSet = associatedResultSet; AssociatedObjectMetadata.Columns = EditTableMetadata.FilterColumnMetadata(AssociatedObjectMetadata.Columns, AssociatedResultSet.Columns); } #region Properties /// /// The internal ID of the row to which this edit applies, relative to the result set /// public long RowId { get; } /// /// The result set that is associated with this row edit /// public ResultSet AssociatedResultSet { get; } /// /// The metadata for the table this edit is associated to /// public EditTableMetadata AssociatedObjectMetadata { get; } /// /// Sort ID for a row edit. Ensures that when a collection of RowEditBase objects are /// sorted, the appropriate types are sorted to the top. /// protected abstract int SortId { get; } #endregion #region Abstract Methods /// /// Applies the changes to the associated result set /// /// /// Data reader from execution of the command to commit the change to the db /// public abstract Task ApplyChanges(DbDataReader dataReader); /// /// Gets a command that will commit the change to the db /// /// The connection to associate the command to /// Command to commit the change to the db public abstract DbCommand GetCommand(DbConnection connection); /// /// Generates a row that has the pending update applied. The dirty status of the row is /// reflected in the returned EditRow. /// /// The original, cached row values /// An EditRow with the pending changes applied public abstract EditRow GetEditRow(DbCellValue[] cachedRow); /// /// Converts the row edit into a SQL statement /// /// A SQL statement public abstract string GetScript(); /// /// Reverts a specific cell in row with pending edits /// /// Ordinal ID of the column to revert /// String value of the original value of the cell public abstract EditRevertCellResult RevertCell(int columnId); /// /// Changes the value a cell in the row. /// /// Ordinal of the column in the row to update /// The new value for the cell /// The value of the cell after applying validation logic public abstract EditUpdateCellResult SetCell(int columnId, string newValue); #endregion #region Protected Helper Methods /// /// Performs validation of column ID and if column can be updated. /// /// /// If is less than 0 or greater than the number of columns /// in the row /// /// If the column is not updatable /// Ordinal of the column to update protected void ValidateColumnIsUpdatable(int columnId) { // Sanity check that the column ID is within the range of columns if (columnId >= AssociatedResultSet.Columns.Length || columnId < 0) { throw new ArgumentOutOfRangeException(nameof(columnId), SR.EditDataColumnIdOutOfRange); } DbColumnWrapper column = AssociatedResultSet.Columns[columnId]; if (!column.IsUpdatable) { throw new InvalidOperationException(SR.EditDataColumnCannotBeEdited); } } /// /// Generates a WHERE clause that uses the key columns of the table to uniquely identity /// the row that will be updated. /// /// /// Whether or not to generate a parameterized where clause. If true verbatim values /// will be replaced with paremeters (like @Param12). The parameters must be added to the /// SqlCommand used to execute the commit. /// /// A object protected WhereClause GetWhereClause(bool parameterize) { WhereClause output = new WhereClause(); if (!AssociatedObjectMetadata.KeyColumns.Any()) { throw new InvalidOperationException(SR.EditDataColumnNoKeyColumns); } IList row = AssociatedResultSet.GetRow(RowId); foreach (EditColumnMetadata col in AssociatedObjectMetadata.KeyColumns) { // Put together a clause for the value of the cell DbCellValue cellData = row[col.Ordinal]; string cellDataClause; if (cellData.IsNull) { cellDataClause = "IS NULL"; } else { if (cellData.RawObject is byte[] || col.DbColumn.DataTypeName.Equals("TEXT", StringComparison.OrdinalIgnoreCase) || col.DbColumn.DataTypeName.Equals("NTEXT", StringComparison.OrdinalIgnoreCase)) { // Special cases for byte[] and TEXT/NTEXT types cellDataClause = "IS NOT NULL"; } else { // General case is to just use the value from the cell if (parameterize) { // Add a parameter and parameterized clause component // NOTE: We include the row ID to make sure the parameter is unique if // we execute multiple row edits at once. string paramName = $"@Param{RowId}{col.Ordinal}"; cellDataClause = $"= {paramName}"; SqlParameter parameter = new SqlParameter(paramName, col.DbColumn.SqlDbType) { Value = cellData.RawObject }; output.Parameters.Add(parameter); } else { // Add the clause component with the formatted value cellDataClause = $"= {ToSqlScript.FormatValue(cellData, col.DbColumn)}"; } } } string completeComponent = $"({col.EscapedName} {cellDataClause})"; output.ClauseComponents.Add(completeComponent); } return output; } #endregion #region IComparable Implementation /// /// Compares a row edit against another row edit. If they are the same type, then we /// compare using an overrideable "same type" comparer. If they are different types, they /// are sorted by their sort indexes. /// /// In general, RowCreate and RowUpdates are sorted to the top. RowDeletes are sorted last. /// If there are ties, default behavior is to sort by row ID ascending. /// /// The other row edit to compare against /// /// A positive value if this edit should go first, a negative value if the other edit /// should go first. 0 is returned if there is a tie. /// public int CompareTo(RowEditBase other) { // If the other is null, this one will come out on top if (other == null) { return 1; } // If types are the same, use the type's tiebreaking sorter if (GetType() == other.GetType()) { return CompareToSameType(other); } // If the type's sort index is the same, use our tiebreaking sorter // If they are different, use that as the comparison int sortIdComparison = SortId.CompareTo(other.SortId); return sortIdComparison == 0 ? CompareByRowId(other) : sortIdComparison; } /// /// Default behavior for sorting if the two compared row edits are the same type. Sorts /// by row ID ascending. /// /// The other row edit to compare against protected virtual int CompareToSameType(RowEditBase rowEdit) { return CompareByRowId(rowEdit); } /// /// Compares two row edits by their row ID ascending. /// /// The other row edit to compare against private int CompareByRowId(RowEditBase rowEdit) { return RowId.CompareTo(rowEdit.RowId); } #endregion /// /// Represents a WHERE clause that can be used for identifying a row in a table. /// protected class WhereClause { /// /// Constructs and initializes a new where clause /// public WhereClause() { Parameters = new List(); ClauseComponents = new List(); } /// /// SqlParameters used in a parameterized query. If this object was generated without /// parameterization, this will be an empty list /// public List Parameters { get; } /// /// Strings that make up the WHERE clause, such as "([col1] = 'something')" /// public List ClauseComponents { get; } /// /// Total text of the WHERE clause that joins all the components with AND /// public string CommandText => $"WHERE {string.Join(" AND ", ClauseComponents)}"; } } }