Edit Data Service (#241)

This is a very large change. I'll try to outline what's going on.

1. This adds the **EditDataService** which manages editing **Sessions**.
    1. Each session has a **ResultSet** (from the QueryExecutionService) which has the rows of the table and basic metadata about the columns 
    2. Each session also has an **IEditTableMetadata** implementation which is derived from SMO metadata which provides more in-depth and trustworthy data about the table than SqlClient alone can.
    3. Each session holds a list of **RowEditBase** abstract class implementations
        1. **RowUpdate** - Update cells in a row (generates `UPDATE` statement)
        2. **RowDelete** - Delete an entire row (generates `DELETE` statement)
        3. **RowCreate** - Add a new row (generates `INSERT INTO` statement)
    4. Row edits have a collection of **CellUpdates** that hold updates for individual cells (except for RowDelete)
        1. Cell updates are generated from text
     5. RowEditBase offers some baseline functionality
        1. Generation of `WHERE` clauses (which can be parameterized)
        2. Validation of whether a column can be updated
2. New API Actions
    1. edit/initialize - Queries for the contents of a table/view, builds SMO metadata, sets up a session
    2. edit/createRow - Adds a new RowCreate to the Session
    3. edit/deleteRow - Adds a new RowDelete to the Session
    4. edit/updateCell - Adds a CellUpdate to a RowCreate or RowUpdate in the Session
    5. edit/revertRow - Removes a RowCreate, RowDelete, or RowUpdate from the Session
    6. edit/script - Generates a script for the changes in the Session and stores to disk
    7. edit/dispose - Removes a Session and releases the query
3. Smaller updates (unit test mock improvements, tweaks to query execution service)

**There are more updates planned -- this is just to get eyeballs on the main body of code**

* Initial stubs for edit data service

* Stubbing out update management code

* Adding rudimentary dispose request

* More stubbing out of update row code

* Adding complete edit command contracts, stubbing out request handlers

* Adding basic implementation of get script

* More in progress work to implement base of row edits

* More in progress work to implement base of row edits

* Adding string => object conversion logic and various cleanup

* Adding a formatter for using values in scripts

* Splitting IMessageSender into IEventSender and IRequestSender

* Adding inter-service method for executing queries

* Adding inter-service method for disposing of a query

* Changing edit contract to include the object to edit

* Fully fleshing out edit session initialization

* Generation of delete scripts is working

* Adding scripter for update statements

* Adding scripting functionality for INSERT statements

* Insert, Update, and Delete all working with SMO metadata

* Polishing for SqlScriptFormatter

* Unit tests and reworked byte[] conversion

* Replacing the awful and inflexible Dictionary<string, string>[][] with a much better test data set class

* Fixing syntax error in generated UPDATE statements

* Adding unit tests for RowCreate

* Adding tests for the row edit base class

* Adding row delete tests

* Adding RowUpdate tests, validation for number of key columns

* Adding tests for the unit class

* Adding get script tests for the session

* Service integration tests, except initialization tests

* Service integration tests, except initialization tests

* Adding messages to sr.strings

* Adding messages to sr.strings

* Fixing broken unit tests

* Adding factory pattern for SMO metadata provider

* Copyright and other comments

* Addressing first round of comments

* Refactoring EditDataService to have a single method for handling
session-dependent operations
* Refactoring Edit Data contracts to inherit from a Session and Row
operation params base class
* Copyright additions
* Small tweak to strings
* Updated unit tests to test the refactors

* More revisions as per pull request comments
This commit is contained in:
Benjamin Russell
2017-02-22 17:32:57 -08:00
committed by GitHub
parent 2b15890b00
commit 795eba3da6
49 changed files with 4370 additions and 137 deletions

View File

@@ -0,0 +1,321 @@
//
// 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 System.Text.RegularExpressions;
using Microsoft.SqlTools.ServiceLayer.QueryExecution.Contracts;
using Microsoft.SqlTools.ServiceLayer.Utility;
using Xunit;
namespace Microsoft.SqlTools.ServiceLayer.Test.Utility
{
public class SqlScriptFormatterTests
{
#region Format Identifier Tests
[Fact]
public void FormatIdentifierNull()
{
// If: I attempt to format null as an identifier
// Then: I should get an exception thrown
Assert.Throws<ArgumentNullException>(() => SqlScriptFormatter.FormatIdentifier(null));
}
[Theory]
[InlineData("test", "[test]")] // No escape characters
[InlineData("]test", "[]]test]")] // Escape character at beginning
[InlineData("te]st", "[te]]st]")] // Escape character in middle
[InlineData("test]", "[test]]]")] // Escape character at end
[InlineData("t]]est", "[t]]]]est]")] // Multiple escape characters
public void FormatIdentifierTest(string value, string expectedOutput)
{
// If: I attempt to format a value as an identifier
string output = SqlScriptFormatter.FormatIdentifier(value);
// Then: The output should match the expected output
Assert.Equal(expectedOutput, output);
}
[Theory]
[InlineData("test", "[test]")] // No splits, no escape characters
[InlineData("test.test", "[test].[test]")] // One split, no escape characters
[InlineData("test.te]st", "[test].[te]]st]")] // One split, one escape character
[InlineData("test.test.test", "[test].[test].[test]")] // Two splits, no escape characters
public void FormatMultipartIdentifierTest(string value, string expectedOutput)
{
// If: I attempt to format a value as a multipart identifier
string output = SqlScriptFormatter.FormatMultipartIdentifier(value);
// Then: The output should match the expected output
Assert.Equal(expectedOutput, output);
}
[Theory]
[MemberData(nameof(GetMultipartIdentifierArrays))]
public void FormatMultipartIdentifierArrayTest(string expectedOutput, string[] splits)
{
// If: I attempt to format a value as a multipart identifier
string output = SqlScriptFormatter.FormatMultipartIdentifier(splits);
// Then: The output should match the expected output
Assert.Equal(expectedOutput, output);
}
public static IEnumerable<object[]> GetMultipartIdentifierArrays
{
get
{
yield return new object[] {"[test]", new[] {"test"}}; // No splits, no escape characters
yield return new object[] {"[test].[test]", new[] {"test", "test"}}; // One split, no escape characters
yield return new object[] {"[test].[te]]st]", new[] {"test", "te]st"}}; // One split, one escape character
yield return new object[] {"[test].[test].[test]", new[] {"test", "test", "test"}}; // Two splits, no escape characters
}
}
#endregion
#region FormatValue Tests
[Fact]
public void NullDbCellTest()
{
// If: I attempt to format a null db cell
// Then: It should throw
Assert.Throws<ArgumentNullException>(() => SqlScriptFormatter.FormatValue(null, new FormatterTestDbColumn(null)));
}
[Fact]
public void NullDbColumnTest()
{
// If: I attempt to format a null db column
// Then: It should throw
Assert.Throws<ArgumentNullException>(() => SqlScriptFormatter.FormatValue(new DbCellValue(), null));
}
public void UnsupportedColumnTest()
{
// If: I attempt to format an unsupported datatype
// Then: It should throw
DbColumn column = new FormatterTestDbColumn("unsupported");
Assert.Throws<ArgumentOutOfRangeException>(() => SqlScriptFormatter.FormatValue(new DbCellValue(), column));
}
[Fact]
public void NullTest()
{
// If: I attempt to format a db cell that contains null
// Then: I should get the null string back
string formattedString = SqlScriptFormatter.FormatValue(new DbCellValue(), new FormatterTestDbColumn(null));
Assert.Equal(SqlScriptFormatter.NullString, formattedString);
}
[Theory]
[InlineData("BIGINT")]
[InlineData("INT")]
[InlineData("SMALLINT")]
[InlineData("TINYINT")]
public void IntegerNumericTest(string dataType)
{
// Setup: Build a column and cell for the integer type column
DbColumn column = new FormatterTestDbColumn(dataType);
DbCellValue cell = new DbCellValue { RawObject = (long)123 };
// If: I attempt to format an integer type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should be able to be converted back into a long
Assert.Equal(cell.RawObject, long.Parse(output));
}
[Theory]
[InlineData("MONEY", "MONEY", null, null)]
[InlineData("SMALLMONEY", "SMALLMONEY", null, null)]
[InlineData("NUMERIC", @"NUMERIC\(\d+, \d+\)", 18, 0)]
[InlineData("DECIMAL", @"DECIMAL\(\d+, \d+\)", 18, 0)]
public void DecimalTest(string dataType, string regex, int? precision, int? scale)
{
// Setup: Build a column and cell for the decimal type column
DbColumn column = new FormatterTestDbColumn(dataType, precision, scale);
DbCellValue cell = new DbCellValue { RawObject = 123.45m };
// If: I attempt to format a decimal type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: It should match a something like CAST(123.45 AS MONEY)
Regex castRegex = new Regex($@"CAST\([\d\.]+ AS {regex}", RegexOptions.IgnoreCase);
Assert.True(castRegex.IsMatch(output));
}
[Fact]
public void DoubleTest()
{
// Setup: Build a column and cell for the approx numeric type column
DbColumn column = new FormatterTestDbColumn("FLOAT");
DbCellValue cell = new DbCellValue { RawObject = 3.14159d };
// If: I attempt to format a approx numeric type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should be able to be converted back into a double
Assert.Equal(cell.RawObject, double.Parse(output));
}
[Fact]
public void FloatTest()
{
// Setup: Build a column and cell for the approx numeric type column
DbColumn column = new FormatterTestDbColumn("REAL");
DbCellValue cell = new DbCellValue { RawObject = (float)3.14159 };
// If: I attempt to format a approx numeric type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should be able to be converted back into a double
Assert.Equal(cell.RawObject, float.Parse(output));
}
[Theory]
[InlineData("SMALLDATETIME")]
[InlineData("DATETIME")]
[InlineData("DATETIME2")]
[InlineData("DATE")]
public void DateTimeTest(string dataType)
{
// Setup: Build a column and cell for the datetime type column
DbColumn column = new FormatterTestDbColumn(dataType);
DbCellValue cell = new DbCellValue { RawObject = DateTime.Now };
// If: I attempt to format a datetime type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should be able to be converted back into a datetime
Regex dateTimeRegex = new Regex("N'(.*)'");
DateTime outputDateTime;
Assert.True(DateTime.TryParse(dateTimeRegex.Match(output).Groups[1].Value, out outputDateTime));
}
[Fact]
public void DateTimeOffsetTest()
{
// Setup: Build a column and cell for the datetime offset type column
DbColumn column = new FormatterTestDbColumn("DATETIMEOFFSET");
DbCellValue cell = new DbCellValue { RawObject = DateTimeOffset.Now };
// If: I attempt to format a datetime offset type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should be able to be converted back into a datetime offset
Regex dateTimeRegex = new Regex("N'(.*)'");
DateTimeOffset outputDateTime;
Assert.True(DateTimeOffset.TryParse(dateTimeRegex.Match(output).Groups[1].Value, out outputDateTime));
}
[Fact]
public void TimeTest()
{
// Setup: Build a column and cell for the time type column
DbColumn column = new FormatterTestDbColumn("TIME");
DbCellValue cell = new DbCellValue { RawObject = TimeSpan.FromHours(12) };
// If: I attempt to format a time type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should be able to be converted back into a timespan
Regex dateTimeRegex = new Regex("N'(.*)'");
TimeSpan outputDateTime;
Assert.True(TimeSpan.TryParse(dateTimeRegex.Match(output).Groups[1].Value, out outputDateTime));
}
[Theory]
[InlineData("", "N''")] // Make sure empty string works
[InlineData(" \t\r\n", "N' \t\r\n'")] // Test for whitespace
[InlineData("some text \x9152", "N'some text \x9152'")] // Test unicode (UTF-8 and UTF-16)
[InlineData("'", "N''''")] // Test with escaped character
public void StringFormattingTest(string input, string expectedOutput)
{
// Setup: Build a column and cell for the string type column
// NOTE: We're using VARCHAR because it's very general purpose.
DbColumn column = new FormatterTestDbColumn("VARCHAR");
DbCellValue cell = new DbCellValue { RawObject = input };
// If: I attempt to format a string type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should be quoted and escaped properly
Assert.Equal(expectedOutput, output);
}
[Theory]
[InlineData("CHAR")]
[InlineData("NCHAR")]
[InlineData("VARCHAR")]
[InlineData("TEXT")]
[InlineData("NTEXT")]
[InlineData("XML")]
public void StringTypeTest(string datatype)
{
// Setup: Build a column and cell for the string type column
DbColumn column = new FormatterTestDbColumn(datatype);
DbCellValue cell = new DbCellValue { RawObject = "test string" };
// If: I attempt to format a string type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should match the output string
Assert.Equal("N'test string'", output);
}
[Theory]
[InlineData("BINARY")]
[InlineData("VARBINARY")]
[InlineData("IMAGE")]
public void BinaryTest(string datatype)
{
// Setup: Build a column and cell for the string type column
DbColumn column = new FormatterTestDbColumn(datatype);
DbCellValue cell = new DbCellValue
{
RawObject = new byte[] { 0x42, 0x45, 0x4e, 0x49, 0x53, 0x43, 0x4f, 0x4f, 0x4c }
};
// If: I attempt to format a string type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should match the output string
Regex regex = new Regex("0x[0-9A-F]+", RegexOptions.IgnoreCase);
Assert.True(regex.IsMatch(output));
}
[Fact]
public void GuidTest()
{
// Setup: Build a column and cell for the string type column
DbColumn column = new FormatterTestDbColumn("UNIQUEIDENTIFIER");
DbCellValue cell = new DbCellValue { RawObject = Guid.NewGuid() };
// If: I attempt to format a string type column
string output = SqlScriptFormatter.FormatValue(cell, column);
// Then: The output string should match the output string
Regex regex = new Regex(@"N'[0-9A-F]{8}(-[0-9A-F]{4}){3}-[0-9A-F]{12}'", RegexOptions.IgnoreCase);
Assert.True(regex.IsMatch(output));
}
#endregion
private class FormatterTestDbColumn : DbColumn
{
public FormatterTestDbColumn(string dataType, int? precision = null, int? scale = null)
{
DataTypeName = dataType;
NumericPrecision = precision;
NumericScale = scale;
}
}
}
}