mirror of
https://github.com/ckaczor/sqltoolsservice.git
synced 2026-01-14 09:59:48 -05:00
The main goal of this feature is to enable a command that will 1) Generate a parameterized command for each edit that is in the session 2) Execute that command against the server 3) Update the cached results of the table/view that's being edited with the committed changes (including computed/identity columns) There's some secret sauce in here where I cheated around worrying about gaps in the updated results. This was accomplished by implementing an IComparable for row edit objects that ensures deletes are the *last* actions to occur and that they occur from the bottom of the list up (highest row ID to lowest). Thus, all other actions that are dependent on the row ID are performed first, then the largest row ID is deleted, then next largest, etc. Nevertheless, by the end of a commit the associated ResultSet is still the source of truth. It is expected that the results grid will need updating once changes are committed. Also worth noting, although this pull request supports a "many edits, one commit" approach, it will work just fine for a "one edit, one commit" approach. * WIP * Adding basic commit support. Deletions work! * Nailing down the commit logic, insert commits work! * Updates work! * Fixing bug in DbColumnWrapper IsReadOnly setting * Comments * ResultSet unit tests, fixing issue with seeking in mock writers * Unit tests for RowCreate commands * Unit tests for RowDelete * RowUpdate unit tests * Session and edit base tests * Fixing broken unit tests * Moving constants to constants file * Addressing code review feedback * Fixes from merge issues, string consts * Removing ad-hoc code * fixing as per @abist requests * Fixing a couple more issues
542 lines
20 KiB
C#
542 lines
20 KiB
C#
//
|
|
// 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.IO;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.SqlTools.ServiceLayer.Connection;
|
|
using Microsoft.SqlTools.ServiceLayer.EditData;
|
|
using Microsoft.SqlTools.ServiceLayer.EditData.UpdateManagement;
|
|
using Microsoft.SqlTools.ServiceLayer.QueryExecution;
|
|
using Microsoft.SqlTools.ServiceLayer.SqlContext;
|
|
using Microsoft.SqlTools.ServiceLayer.Test.Common;
|
|
using Microsoft.SqlTools.ServiceLayer.UnitTests.Utility;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Microsoft.SqlTools.ServiceLayer.UnitTests.EditData
|
|
{
|
|
public class SessionTests
|
|
{
|
|
#region Construction Tests
|
|
|
|
[Fact]
|
|
public void SessionConstructionNullQuery()
|
|
{
|
|
// If: I create a session object without a null query
|
|
// Then: It should throw an exception
|
|
Assert.Throws<ArgumentNullException>(() => new EditSession(null, Common.GetMetadata(new DbColumn[] {})));
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionConstructionNullMetadataProvider()
|
|
{
|
|
// If: I create a session object without a null metadata provider
|
|
// Then: It should throw an exception
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
Assert.Throws<ArgumentNullException>(() => new EditSession(rs, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionConstructionValid()
|
|
{
|
|
// If: I create a session object with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// Then:
|
|
// ... The edit cache should exist and be empty
|
|
Assert.NotNull(s.EditCache);
|
|
Assert.Empty(s.EditCache);
|
|
Assert.Null(s.CommitTask);
|
|
|
|
// ... The next row ID should be equivalent to the number of rows in the result set
|
|
Assert.Equal(q.Batches[0].ResultSets[0].RowCount, s.NextRowId);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Validate Tests
|
|
|
|
[Fact]
|
|
public void SessionValidateUnfinishedQuery()
|
|
{
|
|
// If: I create a session object with a query that hasn't finished execution
|
|
// Then: It should throw an exception
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
q.HasExecuted = false;
|
|
Assert.Throws<InvalidOperationException>(() => EditSession.ValidateQueryForSession(q));
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionValidateIncorrectResultSet()
|
|
{
|
|
// Setup: Create a query that yields >1 result sets
|
|
TestResultSet[] results =
|
|
{
|
|
QueryExecution.Common.StandardTestResultSet,
|
|
QueryExecution.Common.StandardTestResultSet
|
|
};
|
|
|
|
// @TODO: Fix when the connection service is fixed
|
|
ConnectionInfo ci = QueryExecution.Common.CreateConnectedConnectionInfo(results, false);
|
|
ConnectionService.Instance.OwnerToConnectionMap[ci.OwnerUri] = ci;
|
|
|
|
var fsf = MemoryFileSystem.GetFileStreamFactory();
|
|
Query query = new Query(Constants.StandardQuery, ci, new QueryExecutionSettings(), fsf);
|
|
query.Execute();
|
|
query.ExecutionTask.Wait();
|
|
|
|
// If: I create a session object with a query that has !=1 result sets
|
|
// Then: It should throw an exception
|
|
Assert.Throws<InvalidOperationException>(() => EditSession.ValidateQueryForSession(query));
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionValidateValidResultSet()
|
|
{
|
|
// If: I validate a query for a session with a valid query
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = EditSession.ValidateQueryForSession(q);
|
|
|
|
// Then: I should get the only result set back
|
|
Assert.NotNull(rs);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Create Row Tests
|
|
|
|
[Fact]
|
|
public void CreateRowAddFailure()
|
|
{
|
|
// NOTE: This scenario should theoretically never occur, but is tested for completeness
|
|
// Setup:
|
|
// ... Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// ... Add a mock edit to the edit cache to cause the .TryAdd to fail
|
|
var mockEdit = new Mock<RowEditBase>().Object;
|
|
s.EditCache[rs.RowCount] = mockEdit;
|
|
|
|
// If: I create a row in the session
|
|
// Then:
|
|
// ... An exception should be thrown
|
|
Assert.Throws<InvalidOperationException>(() => s.CreateRow());
|
|
|
|
// ... The mock edit should still exist
|
|
Assert.Equal(mockEdit, s.EditCache[rs.RowCount]);
|
|
|
|
// ... The next row ID should not have changes
|
|
Assert.Equal(rs.RowCount, s.NextRowId);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateRowSuccess()
|
|
{
|
|
// Setup: Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// If: I add a row to the session
|
|
long newId = s.CreateRow();
|
|
|
|
// Then:
|
|
// ... The new ID should be equal to the row count
|
|
Assert.Equal(rs.RowCount, newId);
|
|
|
|
// ... The next row ID should have been incremented
|
|
Assert.Equal(rs.RowCount + 1, s.NextRowId);
|
|
|
|
// ... There should be a new row create object in the cache
|
|
Assert.Contains(newId, s.EditCache.Keys);
|
|
Assert.IsType<RowCreate>(s.EditCache[newId]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
[Theory]
|
|
[MemberData(nameof(RowIdOutOfRangeData))]
|
|
public void RowIdOutOfRange(long rowId, Action<EditSession, long> testAction)
|
|
{
|
|
// Setup: Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// If: I delete a row that is out of range for the result set
|
|
// Then: I should get an exception
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => testAction(s, rowId));
|
|
}
|
|
|
|
public static IEnumerable<object> RowIdOutOfRangeData
|
|
{
|
|
get
|
|
{
|
|
// Delete Row
|
|
Action<EditSession, long> delAction = (s, l) => s.DeleteRow(l);
|
|
yield return new object[] { -1L, delAction };
|
|
yield return new object[] { 100L, delAction };
|
|
|
|
// Update Cell
|
|
Action<EditSession, long> upAction = (s, l) => s.UpdateCell(l, 0, null);
|
|
yield return new object[] { -1L, upAction };
|
|
yield return new object[] { 100L, upAction };
|
|
}
|
|
}
|
|
|
|
#region Delete Row Tests
|
|
|
|
[Fact]
|
|
public void DeleteRowAddFailure()
|
|
{
|
|
// Setup:
|
|
// ... Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// ... Add a mock edit to the edit cache to cause the .TryAdd to fail
|
|
var mockEdit = new Mock<RowEditBase>().Object;
|
|
s.EditCache[0] = mockEdit;
|
|
|
|
// If: I delete a row in the session
|
|
// Then:
|
|
// ... An exception should be thrown
|
|
Assert.Throws<InvalidOperationException>(() => s.DeleteRow(0));
|
|
|
|
// ... The mock edit should still exist
|
|
Assert.Equal(mockEdit, s.EditCache[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteRowSuccess()
|
|
{
|
|
// Setup: Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// If: I add a row to the session
|
|
s.DeleteRow(0);
|
|
|
|
// Then: There should be a new row delete object in the cache
|
|
Assert.Contains(0, s.EditCache.Keys);
|
|
Assert.IsType<RowDelete>(s.EditCache[0]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Revert Row Tests
|
|
|
|
[Fact]
|
|
public void RevertRowOutOfRange()
|
|
{
|
|
// Setup: Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// If: I revert a row that doesn't have any pending changes
|
|
// Then: I should get an exception
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => s.RevertRow(0));
|
|
}
|
|
|
|
[Fact]
|
|
public void RevertRowSuccess()
|
|
{
|
|
// Setup:
|
|
// ... Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// ... Add a mock edit to the edit cache to cause the .TryAdd to fail
|
|
var mockEdit = new Mock<RowEditBase>().Object;
|
|
s.EditCache[0] = mockEdit;
|
|
|
|
// If: I revert the row that has a pending update
|
|
s.RevertRow(0);
|
|
|
|
// Then:
|
|
// ... The edit cache should not contain a pending edit for the row
|
|
Assert.DoesNotContain(0, s.EditCache.Keys);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Update Cell Tests
|
|
|
|
[Fact]
|
|
public void UpdateCellExisting()
|
|
{
|
|
// Setup:
|
|
// ... Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// ... Add a mock edit to the edit cache to cause the .TryAdd to fail
|
|
var mockEdit = new Mock<RowEditBase>();
|
|
mockEdit.Setup(e => e.SetCell(It.IsAny<int>(), It.IsAny<string>()));
|
|
s.EditCache[0] = mockEdit.Object;
|
|
|
|
// If: I update a cell on a row that already has a pending edit
|
|
s.UpdateCell(0, 0, null);
|
|
|
|
// Then:
|
|
// ... The mock update should still be in the cache
|
|
// ... And it should have had set cell called on it
|
|
Assert.Contains(mockEdit.Object, s.EditCache.Values);
|
|
}
|
|
|
|
[Fact]
|
|
public void UpdateCellNew()
|
|
{
|
|
// Setup:
|
|
// ... Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// If: I update a cell on a row that does not have a pending edit
|
|
s.UpdateCell(0, 0, "");
|
|
|
|
// Then:
|
|
// ... A new update row edit should have been added to the cache
|
|
Assert.Contains(0, s.EditCache.Keys);
|
|
Assert.IsType<RowUpdate>(s.EditCache[0]);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Script Edits Tests
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" \t\r\n")]
|
|
public void ScriptNullOrEmptyOutput(string outputPath)
|
|
{
|
|
// Setup: Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// If: I try to script the edit cache with a null or whitespace output path
|
|
// Then: It should throw an exception
|
|
Assert.Throws<ArgumentNullException>(() => s.ScriptEdits(outputPath));
|
|
}
|
|
|
|
[Fact]
|
|
public void ScriptProvidedOutputPath()
|
|
{
|
|
// Setup:
|
|
// ... Create a session with a proper query and metadata
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
EditSession s = new EditSession(rs, etm);
|
|
|
|
// ... Add two mock edits that will generate a script
|
|
Mock<RowEditBase> edit = new Mock<RowEditBase>();
|
|
edit.Setup(e => e.GetScript()).Returns("test");
|
|
s.EditCache[0] = edit.Object;
|
|
s.EditCache[1] = edit.Object;
|
|
|
|
using (SelfCleaningTempFile file = new SelfCleaningTempFile())
|
|
{
|
|
// If: I script the edit cache to a local output path
|
|
string outputPath = s.ScriptEdits(file.FilePath);
|
|
|
|
// Then:
|
|
// ... The output path used should be the same as the one we provided
|
|
Assert.Equal(file.FilePath, outputPath);
|
|
|
|
// ... The written file should have two lines, one for each edit
|
|
Assert.Equal(2, File.ReadAllLines(outputPath).Length);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Commit Tests
|
|
|
|
[Fact]
|
|
public void CommitNullConnection()
|
|
{
|
|
// Setup: Create a basic session
|
|
EditSession s = GetBasicSession();
|
|
|
|
// If: I attempt to commit with a null connection
|
|
// Then: I should get an exception
|
|
Assert.Throws<ArgumentNullException>(
|
|
() => s.CommitEdits(null, () => Task.CompletedTask, e => Task.CompletedTask));
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitNullSuccessHandler()
|
|
{
|
|
// Setup:
|
|
// ... Create a basic session
|
|
EditSession s = GetBasicSession();
|
|
|
|
// ... Mock db connection
|
|
DbConnection conn = new TestSqlConnection(null);
|
|
|
|
// If: I attempt to commit with a null success handler
|
|
// Then: I should get an exception
|
|
Assert.Throws<ArgumentNullException>(() => s.CommitEdits(conn, null, e => Task.CompletedTask));
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitNullFailureHandler()
|
|
{
|
|
// Setup:
|
|
// ... Create a basic session
|
|
EditSession s = GetBasicSession();
|
|
|
|
// ... Mock db connection
|
|
DbConnection conn = new TestSqlConnection(null);
|
|
|
|
// If: I attempt to commit with a null success handler
|
|
// Then: I should get an exception
|
|
Assert.Throws<ArgumentNullException>(() => s.CommitEdits(conn, () => Task.CompletedTask, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CommitInProgress()
|
|
{
|
|
// Setup:
|
|
// ... Basic session and db connection
|
|
EditSession s = GetBasicSession();
|
|
DbConnection conn = new TestSqlConnection(null);
|
|
|
|
// ... Mock a task that has not completed
|
|
Task notCompleted = new Task(() => {});
|
|
s.CommitTask = notCompleted;
|
|
|
|
// If: I attempt to commit while a task is in progress
|
|
// Then: I should get an exception
|
|
Assert.Throws<InvalidOperationException>(
|
|
() => s.CommitEdits(conn, () => Task.CompletedTask, e => Task.CompletedTask));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitSuccess()
|
|
{
|
|
// Setup:
|
|
// ... Basic session and db connection
|
|
EditSession s = GetBasicSession();
|
|
DbConnection conn = new TestSqlConnection(null);
|
|
|
|
// ... Add a mock commands for fun
|
|
Mock<RowEditBase> edit = new Mock<RowEditBase>();
|
|
edit.Setup(e => e.GetCommand(It.IsAny<DbConnection>())).Returns<DbConnection>(dbc => dbc.CreateCommand());
|
|
edit.Setup(e => e.ApplyChanges(It.IsAny<DbDataReader>())).Returns(Task.FromResult(0));
|
|
s.EditCache[0] = edit.Object;
|
|
|
|
// If: I commit these changes (and await completion)
|
|
bool successCalled = false;
|
|
bool failureCalled = false;
|
|
s.CommitEdits(conn,
|
|
() => {
|
|
successCalled = true;
|
|
return Task.FromResult(0);
|
|
},
|
|
e => {
|
|
failureCalled = true;
|
|
return Task.FromResult(0);
|
|
});
|
|
await s.CommitTask;
|
|
|
|
// Then:
|
|
// ... The task should still exist
|
|
Assert.NotNull(s.CommitTask);
|
|
|
|
// ... The success handler should have been called (not failure)
|
|
Assert.True(successCalled);
|
|
Assert.False(failureCalled);
|
|
|
|
// ... The mock edit should have generated a command and applied changes
|
|
edit.Verify(e => e.GetCommand(conn), Times.Once);
|
|
edit.Verify(e => e.ApplyChanges(It.IsAny<DbDataReader>()), Times.Once);
|
|
|
|
// ... The edit cache should be empty
|
|
Assert.Empty(s.EditCache);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CommitFailure()
|
|
{
|
|
// Setup:
|
|
// ... Basic session and db connection
|
|
EditSession s = GetBasicSession();
|
|
DbConnection conn = new TestSqlConnection(null);
|
|
|
|
// ... Add a mock edit that will explode on generating a command
|
|
Mock<RowEditBase> edit = new Mock<RowEditBase>();
|
|
edit.Setup(e => e.GetCommand(It.IsAny<DbConnection>())).Throws<Exception>();
|
|
s.EditCache[0] = edit.Object;
|
|
|
|
// If: I commit these changes (and await completion)
|
|
bool successCalled = false;
|
|
bool failureCalled = false;
|
|
s.CommitEdits(conn,
|
|
() => {
|
|
successCalled = true;
|
|
return Task.FromResult(0);
|
|
},
|
|
e => {
|
|
failureCalled = true;
|
|
return Task.FromResult(0);
|
|
});
|
|
await s.CommitTask;
|
|
|
|
// Then:
|
|
// ... The task should still exist
|
|
Assert.NotNull(s.CommitTask);
|
|
|
|
// ... The error handler should have been called (not success)
|
|
Assert.False(successCalled);
|
|
Assert.True(failureCalled);
|
|
|
|
// ... The mock edit should have been asked to generate a command
|
|
edit.Verify(e => e.GetCommand(conn), Times.Once);
|
|
|
|
// ... The edit cache should not be empty
|
|
Assert.NotEmpty(s.EditCache);
|
|
}
|
|
|
|
#endregion
|
|
|
|
private static EditSession GetBasicSession()
|
|
{
|
|
Query q = QueryExecution.Common.GetBasicExecutedQuery();
|
|
ResultSet rs = q.Batches[0].ResultSets[0];
|
|
IEditTableMetadata etm = Common.GetMetadata(rs.Columns);
|
|
return new EditSession(rs, etm);
|
|
}
|
|
}
|
|
}
|