diff --git a/codecov.yml b/codecov.yml
index c14f855b9f..9e1ee94add 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -32,3 +32,4 @@ ignore:
- "*.xaml"
- "*.xaml.cs"
- "**/SampleData/*"
+ - "src/GitHub.App/sqlite-net/*"
\ No newline at end of file
diff --git a/src/GitHub.App/Models/Drafts/CommentDraft.cs b/src/GitHub.App/Models/Drafts/CommentDraft.cs
new file mode 100644
index 0000000000..a55f23aab3
--- /dev/null
+++ b/src/GitHub.App/Models/Drafts/CommentDraft.cs
@@ -0,0 +1,15 @@
+using GitHub.ViewModels;
+
+namespace GitHub.Models.Drafts
+{
+ ///
+ /// Stores a draft for a
+ ///
+ public class CommentDraft
+ {
+ ///
+ /// Gets or sets the draft comment body.
+ ///
+ public string Body { get; set; }
+ }
+}
diff --git a/src/GitHub.App/Models/Drafts/PullRequestDraft.cs b/src/GitHub.App/Models/Drafts/PullRequestDraft.cs
new file mode 100644
index 0000000000..fa359f4c13
--- /dev/null
+++ b/src/GitHub.App/Models/Drafts/PullRequestDraft.cs
@@ -0,0 +1,20 @@
+using GitHub.ViewModels.GitHubPane;
+
+namespace GitHub.Models.Drafts
+{
+ ///
+ /// Stores a draft for a .
+ ///
+ public class PullRequestDraft
+ {
+ ///
+ /// Gets or sets the draft pull request title.
+ ///
+ public string Title { get; set; }
+
+ ///
+ /// Gets or sets the draft pull request body.
+ ///
+ public string Body { get; set; }
+ }
+}
diff --git a/src/GitHub.App/Models/Drafts/PullRequestReviewCommentDraft.cs b/src/GitHub.App/Models/Drafts/PullRequestReviewCommentDraft.cs
new file mode 100644
index 0000000000..e9e29be412
--- /dev/null
+++ b/src/GitHub.App/Models/Drafts/PullRequestReviewCommentDraft.cs
@@ -0,0 +1,21 @@
+using System;
+using GitHub.ViewModels;
+
+namespace GitHub.Models.Drafts
+{
+ ///
+ /// Stores a draft for a
+ ///
+ public class PullRequestReviewCommentDraft : CommentDraft
+ {
+ ///
+ /// Gets or sets the side of the diff that the draft comment was left on.
+ ///
+ public DiffSide Side { get; set; }
+
+ ///
+ /// Gets or sets the time that the draft was last updated.
+ ///
+ public DateTimeOffset UpdatedAt { get; set; }
+ }
+}
diff --git a/src/GitHub.App/Models/Drafts/PullRequestReviewDraft.cs b/src/GitHub.App/Models/Drafts/PullRequestReviewDraft.cs
new file mode 100644
index 0000000000..3dd3a891fb
--- /dev/null
+++ b/src/GitHub.App/Models/Drafts/PullRequestReviewDraft.cs
@@ -0,0 +1,15 @@
+using GitHub.ViewModels.GitHubPane;
+
+namespace GitHub.Models.Drafts
+{
+ ///
+ /// Stores a draft for a .
+ ///
+ public class PullRequestReviewDraft
+ {
+ ///
+ /// Gets or sets the draft review body.
+ ///
+ public string Body { get; set; }
+ }
+}
diff --git a/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs
index bc8f3955bb..cd282d81c7 100644
--- a/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs
+++ b/src/GitHub.App/SampleData/CommentThreadViewModelDesigner.cs
@@ -14,8 +14,8 @@ public class CommentThreadViewModelDesigner : ViewModelBase, ICommentThreadViewM
public IActorViewModel CurrentUser { get; set; }
= new ActorViewModel { Login = "shana" };
- public Task DeleteComment(int pullRequestId, int commentId) => Task.CompletedTask;
- public Task EditComment(string id, string body) => Task.CompletedTask;
- public Task PostComment(string body) => Task.CompletedTask;
+ public Task DeleteComment(ICommentViewModel comment) => Task.CompletedTask;
+ public Task EditComment(ICommentViewModel comment) => Task.CompletedTask;
+ public Task PostComment(ICommentViewModel comment) => Task.CompletedTask;
}
}
diff --git a/src/GitHub.InlineReviews/Services/InlineCommentPeekService.cs b/src/GitHub.App/Services/InlineCommentPeekService.cs
similarity index 72%
rename from src/GitHub.InlineReviews/Services/InlineCommentPeekService.cs
rename to src/GitHub.App/Services/InlineCommentPeekService.cs
index 892a7c47b1..463bfdef5e 100644
--- a/src/GitHub.InlineReviews/Services/InlineCommentPeekService.cs
+++ b/src/GitHub.App/Services/InlineCommentPeekService.cs
@@ -2,15 +2,9 @@
using System.ComponentModel.Composition;
using System.Linq;
using System.Reactive.Linq;
-using System.Threading.Tasks;
-using GitHub.Api;
using GitHub.Extensions;
-using GitHub.Factories;
-using GitHub.InlineReviews.Peek;
-using GitHub.InlineReviews.Tags;
using GitHub.Models;
-using GitHub.Primitives;
-using GitHub.Services;
+using GitHub.ViewModels;
using Microsoft.VisualStudio.Language.Intellisense;
using Microsoft.VisualStudio.Text;
using Microsoft.VisualStudio.Text.Differencing;
@@ -18,7 +12,7 @@
using Microsoft.VisualStudio.Text.Outlining;
using Microsoft.VisualStudio.Text.Projection;
-namespace GitHub.InlineReviews.Services
+namespace GitHub.Services
{
///
/// Shows inline comments in a peek view.
@@ -26,6 +20,7 @@ namespace GitHub.InlineReviews.Services
[Export(typeof(IInlineCommentPeekService))]
class InlineCommentPeekService : IInlineCommentPeekService
{
+ const string relationship = "GitHubCodeReview";
readonly IOutliningManagerService outliningService;
readonly IPeekBroker peekBroker;
readonly IUsageTracker usageTracker;
@@ -90,69 +85,46 @@ public void Hide(ITextView textView)
}
///
- public ITrackingPoint Show(ITextView textView, AddInlineCommentTag tag)
+ public ITrackingPoint Show(ITextView textView, DiffSide side, int lineNumber)
{
- Guard.ArgumentNotNull(tag, nameof(tag));
-
- var lineAndtrackingPoint = GetLineAndTrackingPoint(textView, tag);
+ var lineAndtrackingPoint = GetLineAndTrackingPoint(textView, side, lineNumber);
var line = lineAndtrackingPoint.Item1;
var trackingPoint = lineAndtrackingPoint.Item2;
var options = new PeekSessionCreationOptions(
textView,
- InlineCommentPeekRelationship.Instance.Name,
+ relationship,
trackingPoint,
defaultHeight: 0);
ExpandCollapsedRegions(textView, line.Extent);
var session = peekBroker.TriggerPeekSession(options);
- var item = session.PeekableItems.OfType().FirstOrDefault();
- item?.ViewModel.Close.Take(1).Subscribe(_ => session.Dismiss());
-
- return trackingPoint;
- }
-
- ///
- public ITrackingPoint Show(ITextView textView, ShowInlineCommentTag tag)
- {
- Guard.ArgumentNotNull(textView, nameof(textView));
- Guard.ArgumentNotNull(tag, nameof(tag));
-
- var lineAndtrackingPoint = GetLineAndTrackingPoint(textView, tag);
- var line = lineAndtrackingPoint.Item1;
- var trackingPoint = lineAndtrackingPoint.Item2;
- var options = new PeekSessionCreationOptions(
- textView,
- InlineCommentPeekRelationship.Instance.Name,
- trackingPoint,
- defaultHeight: 0);
-
- ExpandCollapsedRegions(textView, line.Extent);
-
- var session = peekBroker.TriggerPeekSession(options);
- var item = session.PeekableItems.OfType().FirstOrDefault();
- item?.ViewModel.Close.Take(1).Subscribe(_ => session.Dismiss());
-
+ var item = session.PeekableItems.OfType().FirstOrDefault();
+ item?.Closed.Take(1).Subscribe(_ => session.Dismiss());
+
return trackingPoint;
}
- Tuple GetLineAndTrackingPoint(ITextView textView, InlineCommentTag tag)
+ Tuple GetLineAndTrackingPoint(
+ ITextView textView,
+ DiffSide side,
+ int lineNumber)
{
var diffModel = (textView as IWpfTextView)?.TextViewModel as IDifferenceTextViewModel;
var snapshot = textView.TextSnapshot;
if (diffModel?.ViewType == DifferenceViewType.InlineView)
{
- snapshot = tag.DiffChangeType == DiffChangeType.Delete ?
+ snapshot = side == DiffSide.Left ?
diffModel.Viewer.DifferenceBuffer.LeftBuffer.CurrentSnapshot :
diffModel.Viewer.DifferenceBuffer.RightBuffer.CurrentSnapshot;
}
- var line = snapshot.GetLineFromLineNumber(tag.LineNumber);
+ var line = snapshot.GetLineFromLineNumber(lineNumber);
var trackingPoint = snapshot.CreateTrackingPoint(line.Start.Position, PointTrackingMode.Positive);
ExpandCollapsedRegions(textView, line.Extent);
- peekBroker.TriggerPeekSession(textView, trackingPoint, InlineCommentPeekRelationship.Instance.Name);
+ peekBroker.TriggerPeekSession(textView, trackingPoint, relationship);
usageTracker.IncrementCounter(x => x.NumberOfPRReviewDiffViewInlineCommentOpen).Forget();
diff --git a/src/GitHub.App/Services/PullRequestEditorService.cs b/src/GitHub.App/Services/PullRequestEditorService.cs
index 57b1dfff5e..3135c29730 100644
--- a/src/GitHub.App/Services/PullRequestEditorService.cs
+++ b/src/GitHub.App/Services/PullRequestEditorService.cs
@@ -5,9 +5,12 @@
using System.Linq;
using System.Reactive.Linq;
using System.Threading.Tasks;
+using EnvDTE;
using GitHub.Commands;
using GitHub.Extensions;
using GitHub.Models;
+using GitHub.Models.Drafts;
+using GitHub.ViewModels;
using GitHub.VisualStudio;
using Microsoft.VisualStudio;
using Microsoft.VisualStudio.Editor;
@@ -18,7 +21,6 @@
using Microsoft.VisualStudio.Text.Editor;
using Microsoft.VisualStudio.Text.Projection;
using Microsoft.VisualStudio.TextManager.Interop;
-using EnvDTE;
using Task = System.Threading.Tasks.Task;
namespace GitHub.Services
@@ -39,6 +41,8 @@ public class PullRequestEditorService : IPullRequestEditorService
readonly IStatusBarNotificationService statusBar;
readonly IGoToSolutionOrPullRequestFileCommand goToSolutionOrPullRequestFileCommand;
readonly IEditorOptionsFactoryService editorOptionsFactoryService;
+ readonly IMessageDraftStore draftStore;
+ readonly IInlineCommentPeekService peekService;
readonly IUsageTracker usageTracker;
[ImportingConstructor]
@@ -49,6 +53,8 @@ public PullRequestEditorService(
IStatusBarNotificationService statusBar,
IGoToSolutionOrPullRequestFileCommand goToSolutionOrPullRequestFileCommand,
IEditorOptionsFactoryService editorOptionsFactoryService,
+ IMessageDraftStore draftStore,
+ IInlineCommentPeekService peekService,
IUsageTracker usageTracker)
{
Guard.ArgumentNotNull(serviceProvider, nameof(serviceProvider));
@@ -58,6 +64,8 @@ public PullRequestEditorService(
Guard.ArgumentNotNull(goToSolutionOrPullRequestFileCommand, nameof(goToSolutionOrPullRequestFileCommand));
Guard.ArgumentNotNull(goToSolutionOrPullRequestFileCommand, nameof(editorOptionsFactoryService));
Guard.ArgumentNotNull(usageTracker, nameof(usageTracker));
+ Guard.ArgumentNotNull(peekService, nameof(peekService));
+ Guard.ArgumentNotNull(draftStore, nameof(draftStore));
this.serviceProvider = serviceProvider;
this.pullRequestService = pullRequestService;
@@ -65,6 +73,8 @@ public PullRequestEditorService(
this.statusBar = statusBar;
this.goToSolutionOrPullRequestFileCommand = goToSolutionOrPullRequestFileCommand;
this.editorOptionsFactoryService = editorOptionsFactoryService;
+ this.draftStore = draftStore;
+ this.peekService = peekService;
this.usageTracker = usageTracker;
}
@@ -129,7 +139,7 @@ public async Task OpenFile(
}
///
- public async Task OpenDiff(IPullRequestSession session, string relativePath, string headSha, bool scrollToFirstDiff)
+ public async Task OpenDiff(IPullRequestSession session, string relativePath, string headSha, bool scrollToFirstDraftOrDiff)
{
Guard.ArgumentNotNull(session, nameof(session));
Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath));
@@ -168,12 +178,37 @@ await pullRequestService.ExtractToTempFile(
var caption = $"Diff - {Path.GetFileName(file.RelativePath)}";
var options = __VSDIFFSERVICEOPTIONS.VSDIFFOPT_DetectBinaryFiles |
__VSDIFFSERVICEOPTIONS.VSDIFFOPT_LeftFileIsTemporary;
+ var openThread = (line: -1, side: DiffSide.Left);
+ var scrollToFirstDiff = false;
if (!workingDirectory)
{
options |= __VSDIFFSERVICEOPTIONS.VSDIFFOPT_RightFileIsTemporary;
}
+ if (scrollToFirstDraftOrDiff)
+ {
+ var (key, _) = PullRequestReviewCommentThreadViewModel.GetDraftKeys(
+ session.LocalRepository.CloneUrl.WithOwner(session.RepositoryOwner),
+ session.PullRequest.Number,
+ relativePath,
+ 0);
+ var drafts = (await draftStore.GetDrafts(key)
+ .ConfigureAwait(true))
+ .OrderByDescending(x => x.data.UpdatedAt)
+ .ToList();
+
+ if (drafts.Count > 0 && int.TryParse(drafts[0].secondaryKey, out var line))
+ {
+ openThread = (line, drafts[0].data.Side);
+ scrollToFirstDiff = false;
+ }
+ else
+ {
+ scrollToFirstDiff = true;
+ }
+ }
+
IVsWindowFrame frame;
using (OpenWithOption(DifferenceViewerOptions.ScrollToFirstDiffName, scrollToFirstDiff))
using (OpenInProvisionalTab())
@@ -228,6 +263,18 @@ await pullRequestService.ExtractToTempFile(
else
await usageTracker.IncrementCounter(x => x.NumberOfPRDetailsViewChanges);
+ if (openThread.line != -1)
+ {
+ var view = diffViewer.ViewMode == DifferenceViewMode.Inline ?
+ diffViewer.InlineView :
+ openThread.side == DiffSide.Left ? diffViewer.LeftView : diffViewer.RightView;
+
+ // HACK: We need to wait here for the view to initialize or the peek session won't appear.
+ // There must be a better way of doing this.
+ await Task.Delay(1500).ConfigureAwait(true);
+ peekService.Show(view, openThread.side, openThread.line);
+ }
+
return diffViewer;
}
catch (Exception e)
@@ -247,7 +294,7 @@ public async Task OpenDiff(
Guard.ArgumentNotEmptyString(relativePath, nameof(relativePath));
Guard.ArgumentNotNull(thread, nameof(thread));
- var diffViewer = await OpenDiff(session, relativePath, thread.CommitSha, scrollToFirstDiff: false);
+ var diffViewer = await OpenDiff(session, relativePath, thread.CommitSha, scrollToFirstDraftOrDiff: false);
var param = (object)new InlineCommentNavigationParams
{
diff --git a/src/GitHub.App/Services/SqliteMessageDraftStore.cs b/src/GitHub.App/Services/SqliteMessageDraftStore.cs
new file mode 100644
index 0000000000..08463e0d3b
--- /dev/null
+++ b/src/GitHub.App/Services/SqliteMessageDraftStore.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.Composition;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using GitHub.Extensions;
+using GitHub.Logging;
+using Newtonsoft.Json;
+using Rothko;
+using Serilog;
+using SQLite;
+
+namespace GitHub.Services
+{
+ ///
+ /// Stores drafts of messages in an SQL database.
+ ///
+ [Export(typeof(IMessageDraftStore))]
+ [PartCreationPolicy(CreationPolicy.Shared)]
+ public class SqliteMessageDraftStore : IMessageDraftStore
+ {
+ static readonly ILogger log = LogManager.ForContext();
+ readonly IOperatingSystem os;
+ SQLiteAsyncConnection connection;
+ bool initialized;
+
+ [ImportingConstructor]
+ public SqliteMessageDraftStore(IOperatingSystem os)
+ {
+ this.os = os;
+ }
+
+ public async Task GetDraft(string key, string secondaryKey) where T : class
+ {
+ Guard.ArgumentNotEmptyString(key, nameof(key));
+ Guard.ArgumentNotNull(secondaryKey, nameof(secondaryKey));
+
+ if (await Initialize().ConfigureAwait(false))
+ {
+ try
+ {
+ var result = await connection.Table().Where(
+ x => x.Key == key && x.SecondaryKey == secondaryKey)
+ .FirstOrDefaultAsync()
+ .ConfigureAwait(false);
+
+ if (result != null)
+ {
+ return JsonConvert.DeserializeObject(result.Data);
+ }
+ }
+ catch (Exception ex)
+ {
+ log.Error(ex, "Failed to load message draft into {Type}", typeof(T));
+ }
+ }
+
+ return null;
+ }
+
+ public async Task> GetDrafts(string key) where T : class
+ {
+ Guard.ArgumentNotEmptyString(key, nameof(key));
+
+ if (await Initialize().ConfigureAwait(false))
+ {
+ try
+ {
+ var result = await connection.Table().Where(x => x.Key == key)
+ .ToListAsync()
+ .ConfigureAwait(false);
+
+ return result.Select(x => (x.SecondaryKey, JsonConvert.DeserializeObject(x.Data)));
+ }
+ catch (Exception ex)
+ {
+ log.Error(ex, "Failed to load message drafts into {Type}", typeof(T));
+ }
+ }
+
+ return null;
+ }
+
+ public async Task UpdateDraft(string key, string secondaryKey, T data) where T : class
+ {
+ Guard.ArgumentNotEmptyString(key, nameof(key));
+ Guard.ArgumentNotNull(secondaryKey, nameof(secondaryKey));
+
+ if (!await Initialize().ConfigureAwait(false))
+ {
+ return;
+ }
+
+ try
+ {
+ var row = new Draft
+ {
+ Key = key,
+ SecondaryKey = secondaryKey,
+ Data = JsonConvert.SerializeObject(data),
+ };
+
+ await connection.InsertOrReplaceAsync(row).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ log.Error(ex, "Failed to update message draft");
+ }
+ }
+
+ public async Task DeleteDraft(string key, string secondaryKey)
+ {
+ Guard.ArgumentNotEmptyString(key, nameof(key));
+ Guard.ArgumentNotNull(secondaryKey, nameof(secondaryKey));
+
+ if (!await Initialize().ConfigureAwait(false))
+ {
+ return;
+ }
+
+ try
+ {
+ await connection.ExecuteAsync(
+ "DELETE FROM Drafts WHERE Key=? AND SecondaryKey=?",
+ key,
+ secondaryKey).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ log.Error(ex, "Failed to update message draft");
+ }
+ }
+
+ async Task Initialize()
+ {
+ if (!initialized)
+ {
+ var path = Path.Combine(os.Environment.GetApplicationDataPath(), "drafts.db");
+
+ try
+ {
+ connection = new SQLiteAsyncConnection(path);
+
+ var draftsTable = await connection.GetTableInfoAsync("Drafts").ConfigureAwait(false);
+
+ if (draftsTable.Count == 0)
+ {
+ await connection.ExecuteAsync(@"
+ CREATE TABLE `Drafts` (
+ `Key` TEXT,
+ `SecondaryKey` TEXT,
+ `Data` TEXT,
+ UNIQUE(`Key`,`SecondaryKey`)
+ );").ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ log.Error(ex, "Error opening drafts from {Path}.", path);
+ }
+ finally
+ {
+ initialized = true;
+ }
+ }
+
+ return connection != null;
+ }
+
+ [Table("Drafts")]
+ private class Draft
+ {
+ public string Key { get; set; }
+ public string SecondaryKey { get; set; }
+ public string Data { get; set; }
+ }
+ }
+}
diff --git a/src/GitHub.App/ViewModels/CommentThreadViewModel.cs b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs
index 1fae1484b2..bd5a9541a8 100644
--- a/src/GitHub.App/ViewModels/CommentThreadViewModel.cs
+++ b/src/GitHub.App/ViewModels/CommentThreadViewModel.cs
@@ -1,7 +1,14 @@
-using System.ComponentModel.Composition;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.Composition;
+using System.Reactive.Concurrency;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
using System.Threading.Tasks;
using GitHub.Extensions;
using GitHub.Models;
+using GitHub.Models.Drafts;
+using GitHub.Services;
using ReactiveUI;
namespace GitHub.ViewModels
@@ -12,24 +19,36 @@ namespace GitHub.ViewModels
public abstract class CommentThreadViewModel : ReactiveObject, ICommentThreadViewModel
{
readonly ReactiveList comments = new ReactiveList();
+ readonly Dictionary> draftThrottles =
+ new Dictionary>();
+ readonly IScheduler timerScheduler;
///
/// Initializes a new instance of the class.
///
+ /// The message draft store.
[ImportingConstructor]
- public CommentThreadViewModel()
+ public CommentThreadViewModel(IMessageDraftStore draftStore)
+ : this(draftStore, DefaultScheduler.Instance)
{
}
///
- /// Intializes a new instance of the class.
+ /// Initializes a new instance of the class.
///
- /// The current user.
- protected Task InitializeAsync(ActorModel currentUser)
+ /// The message draft store.
+ ///
+ /// The scheduler to use to apply a throttle to message drafts.
+ ///
+ [ImportingConstructor]
+ public CommentThreadViewModel(
+ IMessageDraftStore draftStore,
+ IScheduler timerScheduler)
{
- Guard.ArgumentNotNull(currentUser, nameof(currentUser));
- CurrentUser = new ActorViewModel(currentUser);
- return Task.CompletedTask;
+ Guard.ArgumentNotNull(draftStore, nameof(draftStore));
+
+ DraftStore = draftStore;
+ this.timerScheduler = timerScheduler;
}
///
@@ -41,13 +60,102 @@ protected Task InitializeAsync(ActorModel currentUser)
///
IReadOnlyReactiveList ICommentThreadViewModel.Comments => comments;
+ protected IMessageDraftStore DraftStore { get; }
+
///
- public abstract Task PostComment(string body);
+ public abstract Task PostComment(ICommentViewModel comment);
///
- public abstract Task EditComment(string id, string body);
+ public abstract Task EditComment(ICommentViewModel comment);
///
- public abstract Task DeleteComment(int pullRequestId, int commentId);
+ public abstract Task DeleteComment(ICommentViewModel comment);
+
+ ///
+ /// Adds a placeholder comment that will allow the user to enter a reply, and wires up
+ /// event listeners for saving drafts.
+ ///
+ /// The placeholder comment view model.
+ /// An object which when disposed will remove the event listeners.
+ protected IDisposable AddPlaceholder(ICommentViewModel placeholder)
+ {
+ Comments.Add(placeholder);
+
+ return placeholder.WhenAnyValue(
+ x => x.EditState,
+ x => x.Body,
+ (state, body) => (state, body))
+ .Subscribe(x => PlaceholderChanged(placeholder, x.state, x.body));
+ }
+
+ ///
+ /// Intializes a new instance of the class.
+ ///
+ /// The current user.
+ protected Task InitializeAsync(ActorModel currentUser)
+ {
+ Guard.ArgumentNotNull(currentUser, nameof(currentUser));
+ CurrentUser = new ActorViewModel(currentUser);
+ return Task.CompletedTask;
+ }
+
+ protected virtual CommentDraft BuildDraft(ICommentViewModel comment)
+ {
+ return !string.IsNullOrEmpty(comment.Body) ?
+ new CommentDraft { Body = comment.Body } :
+ null;
+ }
+
+ protected async Task DeleteDraft(ICommentViewModel comment)
+ {
+ if (draftThrottles.TryGetValue(comment, out var throttle))
+ {
+ throttle.OnCompleted();
+ draftThrottles.Remove(comment);
+ }
+
+ var (key, secondaryKey) = GetDraftKeys(comment);
+ await DraftStore.DeleteDraft(key, secondaryKey).ConfigureAwait(false);
+ }
+
+ protected abstract (string key, string secondaryKey) GetDraftKeys(ICommentViewModel comment);
+
+ void PlaceholderChanged(ICommentViewModel placeholder, CommentEditState state, string body)
+ {
+ if (state == CommentEditState.Editing)
+ {
+ if (!draftThrottles.TryGetValue(placeholder, out var throttle))
+ {
+ var subject = new Subject();
+ subject.Throttle(TimeSpan.FromSeconds(1), timerScheduler).Subscribe(UpdateDraft);
+ draftThrottles.Add(placeholder, subject);
+ throttle = subject;
+ }
+
+ throttle.OnNext(placeholder);
+ }
+ else if (state != CommentEditState.Editing)
+ {
+ DeleteDraft(placeholder).Forget();
+ }
+ }
+
+ void UpdateDraft(ICommentViewModel comment)
+ {
+ if (comment.EditState == CommentEditState.Editing)
+ {
+ var draft = BuildDraft(comment);
+ var (key, secondaryKey) = GetDraftKeys(comment);
+
+ if (draft != null)
+ {
+ DraftStore.UpdateDraft(key, secondaryKey, draft).Forget();
+ }
+ else
+ {
+ DraftStore.DeleteDraft(key, secondaryKey).Forget();
+ }
+ }
+ }
}
}
diff --git a/src/GitHub.App/ViewModels/CommentViewModel.cs b/src/GitHub.App/ViewModels/CommentViewModel.cs
index fade1742f0..259535f0c1 100644
--- a/src/GitHub.App/ViewModels/CommentViewModel.cs
+++ b/src/GitHub.App/ViewModels/CommentViewModel.cs
@@ -219,7 +219,7 @@ async Task DoDelete()
ErrorMessage = null;
IsSubmitting = true;
- await Thread.DeleteComment(PullRequestId, DatabaseId).ConfigureAwait(true);
+ await Thread.DeleteComment(this).ConfigureAwait(true);
}
catch (Exception e)
{
@@ -264,12 +264,14 @@ async Task DoCommitEdit()
if (Id == null)
{
- await Thread.PostComment(Body).ConfigureAwait(true);
+ await Thread.PostComment(this).ConfigureAwait(true);
}
else
{
- await Thread.EditComment(Id, Body).ConfigureAwait(true);
+ await Thread.EditComment(this).ConfigureAwait(true);
}
+
+ EditState = CommentEditState.None;
}
catch (Exception e)
{
diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs
index 8d2547c7a7..f72bc605fe 100644
--- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs
+++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs
@@ -5,6 +5,7 @@
using System.Globalization;
using System.Linq;
using System.Reactive;
+using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading.Tasks;
@@ -14,12 +15,15 @@
using GitHub.Factories;
using GitHub.Logging;
using GitHub.Models;
+using GitHub.Models.Drafts;
+using GitHub.Primitives;
using GitHub.Services;
using GitHub.Validation;
using Octokit;
using ReactiveUI;
using Serilog;
using IConnection = GitHub.Models.IConnection;
+using static System.FormattableString;
namespace GitHub.ViewModels.GitHubPane
{
@@ -33,6 +37,8 @@ public class PullRequestCreationViewModel : PanePageViewModelBase, IPullRequestC
readonly ObservableAsPropertyHelper isExecuting;
readonly IPullRequestService service;
readonly IModelServiceFactory modelServiceFactory;
+ readonly IMessageDraftStore draftStore;
+ readonly IScheduler timerScheduler;
readonly CompositeDisposable disposables = new CompositeDisposable();
ILocalRepositoryModel activeLocalRepo;
ObservableAsPropertyHelper githubRepository;
@@ -42,14 +48,29 @@ public class PullRequestCreationViewModel : PanePageViewModelBase, IPullRequestC
public PullRequestCreationViewModel(
IModelServiceFactory modelServiceFactory,
IPullRequestService service,
- INotificationService notifications)
+ INotificationService notifications,
+ IMessageDraftStore draftStore)
+ : this(modelServiceFactory, service, notifications, draftStore, DefaultScheduler.Instance)
+ {
+ }
+
+ public PullRequestCreationViewModel(
+ IModelServiceFactory modelServiceFactory,
+ IPullRequestService service,
+ INotificationService notifications,
+ IMessageDraftStore draftStore,
+ IScheduler timerScheduler)
{
Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory));
Guard.ArgumentNotNull(service, nameof(service));
Guard.ArgumentNotNull(notifications, nameof(notifications));
+ Guard.ArgumentNotNull(draftStore, nameof(draftStore));
+ Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler));
this.service = service;
this.modelServiceFactory = modelServiceFactory;
+ this.draftStore = draftStore;
+ this.timerScheduler = timerScheduler;
this.WhenAnyValue(x => x.Branches)
.WhereNotNull()
@@ -93,15 +114,22 @@ public PullRequestCreationViewModel(
TargetBranch.Repository.CloneUrl.ToRepositoryUrl().Append("pull/" + pr.Number)));
NavigateTo("/pulls?refresh=true");
Cancel.Execute();
+ draftStore.DeleteDraft(GetDraftKey(), string.Empty).Forget();
+ Close();
});
Cancel = ReactiveCommand.Create(() => { });
- Cancel.Subscribe(_ => Close());
+ Cancel.Subscribe(_ =>
+ {
+ Close();
+ draftStore.DeleteDraft(GetDraftKey(), string.Empty).Forget();
+ });
isExecuting = CreatePullRequest.IsExecuting.ToProperty(this, x => x.IsExecuting);
this.WhenAnyValue(x => x.Initialized, x => x.GitHubRepository, x => x.IsExecuting)
.Select(x => !(x.Item1 && x.Item2 != null && !x.Item3))
+ .ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(x => IsBusy = x);
}
@@ -146,6 +174,39 @@ public async Task InitializeAsync(ILocalRepositoryModel repository, IConnection
Initialized = true;
});
+ var draftKey = GetDraftKey();
+ await LoadInitialState(draftKey).ConfigureAwait(true);
+
+ this.WhenAnyValue(
+ x => x.PRTitle,
+ x => x.Description,
+ (t, d) => new PullRequestDraft { Title = t, Body = d })
+ .Throttle(TimeSpan.FromSeconds(1), timerScheduler)
+ .Subscribe(x => draftStore.UpdateDraft(draftKey, string.Empty, x));
+
+ Initialized = true;
+ }
+
+ async Task LoadInitialState(string draftKey)
+ {
+ if (activeLocalRepo.CloneUrl == null)
+ return;
+
+ var draft = await draftStore.GetDraft(draftKey, string.Empty).ConfigureAwait(true);
+
+ if (draft != null)
+ {
+ PRTitle = draft.Title;
+ Description = draft.Body;
+ }
+ else
+ {
+ LoadDescriptionFromCommits();
+ }
+ }
+
+ void LoadDescriptionFromCommits()
+ {
SourceBranch = activeLocalRepo.CurrentBranch;
var uniqueCommits = this.WhenAnyValue(
@@ -176,7 +237,7 @@ public async Task InitializeAsync(ILocalRepositoryModel repository, IConnection
Observable.CombineLatest(
this.WhenAnyValue(x => x.SourceBranch),
uniqueCommits,
- service.GetPullRequestTemplate(repository).DefaultIfEmpty(string.Empty),
+ service.GetPullRequestTemplate(activeLocalRepo).DefaultIfEmpty(string.Empty),
(compare, commits, template) => new { compare, commits, template })
.Subscribe(x =>
{
@@ -203,8 +264,6 @@ public async Task InitializeAsync(ILocalRepositoryModel repository, IConnection
PRTitle = prTitle;
Description = prDescription;
});
-
- Initialized = true;
}
void SetupValidators()
@@ -239,6 +298,20 @@ protected override void Dispose(bool disposing)
}
}
+ public static string GetDraftKey(
+ UriString cloneUri,
+ string branchName)
+ {
+ return Invariant($"pr|{cloneUri}|{branchName}");
+ }
+
+ protected string GetDraftKey()
+ {
+ return GetDraftKey(
+ activeLocalRepo.CloneUrl,
+ SourceBranch.Name);
+ }
+
public IRemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } }
bool IsExecuting { get { return isExecuting.Value; } }
diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs
index f26b4b9ab8..fc80765fd4 100644
--- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs
+++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs
@@ -3,12 +3,15 @@
using System.ComponentModel.Composition;
using System.Linq;
using System.Reactive;
+using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Threading.Tasks;
using GitHub.Extensions;
using GitHub.Factories;
using GitHub.Logging;
using GitHub.Models;
+using GitHub.Models.Drafts;
+using GitHub.Primitives;
using GitHub.Services;
using ReactiveUI;
using Serilog;
@@ -24,7 +27,9 @@ public class PullRequestReviewAuthoringViewModel : PanePageViewModelBase, IPullR
readonly IPullRequestEditorService editorService;
readonly IPullRequestSessionManager sessionManager;
+ readonly IMessageDraftStore draftStore;
readonly IPullRequestService pullRequestService;
+ readonly IScheduler timerScheduler;
IPullRequestSession session;
IDisposable sessionSubscription;
PullRequestReviewModel model;
@@ -39,15 +44,31 @@ public PullRequestReviewAuthoringViewModel(
IPullRequestService pullRequestService,
IPullRequestEditorService editorService,
IPullRequestSessionManager sessionManager,
+ IMessageDraftStore draftStore,
IPullRequestFilesViewModel files)
+ : this(pullRequestService, editorService, sessionManager,draftStore, files, DefaultScheduler.Instance)
+ {
+ }
+
+ public PullRequestReviewAuthoringViewModel(
+ IPullRequestService pullRequestService,
+ IPullRequestEditorService editorService,
+ IPullRequestSessionManager sessionManager,
+ IMessageDraftStore draftStore,
+ IPullRequestFilesViewModel files,
+ IScheduler timerScheduler)
{
Guard.ArgumentNotNull(editorService, nameof(editorService));
Guard.ArgumentNotNull(sessionManager, nameof(sessionManager));
+ Guard.ArgumentNotNull(draftStore, nameof(draftStore));
Guard.ArgumentNotNull(files, nameof(files));
+ Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler));
this.pullRequestService = pullRequestService;
this.editorService = editorService;
this.sessionManager = sessionManager;
+ this.draftStore = draftStore;
+ this.timerScheduler = timerScheduler;
canApproveRequestChanges = this.WhenAnyValue(
x => x.Model,
@@ -148,8 +169,25 @@ public async Task InitializeAsync(
{
LocalRepository = localRepository;
RemoteRepositoryOwner = owner;
- session = await sessionManager.GetSession(owner, repo, pullRequestNumber);
- await Load(session.PullRequest);
+ session = await sessionManager.GetSession(owner, repo, pullRequestNumber).ConfigureAwait(true);
+ await Load(session.PullRequest).ConfigureAwait(true);
+
+ if (LocalRepository?.CloneUrl != null)
+ {
+ var key = GetDraftKey();
+
+ if (string.IsNullOrEmpty(Body))
+ {
+ var draft = await draftStore.GetDraft(key, string.Empty)
+ .ConfigureAwait(true);
+ Body = draft?.Body;
+ }
+
+ this.WhenAnyValue(x => x.Body)
+ .Throttle(TimeSpan.FromSeconds(1), timerScheduler)
+ .Select(x => new PullRequestReviewDraft { Body = x })
+ .Subscribe(x => draftStore.UpdateDraft(key, string.Empty, x));
+ }
}
finally
{
@@ -182,6 +220,20 @@ public override async Task Refresh()
}
}
+ public static string GetDraftKey(
+ UriString cloneUri,
+ int pullRequestNumber)
+ {
+ return Invariant($"pr-review|{cloneUri}|{pullRequestNumber}");
+ }
+
+ protected string GetDraftKey()
+ {
+ return GetDraftKey(
+ LocalRepository.CloneUrl.WithOwner(RemoteRepositoryOwner),
+ PullRequestModel.Number);
+ }
+
async Task Load(PullRequestDetailModel pullRequest)
{
try
@@ -252,8 +304,9 @@ async Task DoSubmit(Octokit.PullRequestReviewEvent e)
try
{
- await session.PostReview(Body, e);
+ await session.PostReview(Body, e).ConfigureAwait(true);
Close();
+ await draftStore.DeleteDraft(GetDraftKey(), string.Empty).ConfigureAwait(true);
}
catch (Exception ex)
{
@@ -285,6 +338,7 @@ async Task DoCancel()
Close();
}
+ await draftStore.DeleteDraft(GetDraftKey(), string.Empty).ConfigureAwait(true);
}
catch (Exception ex)
{
diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs
index db225e9c88..db8bdd0f17 100644
--- a/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs
+++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentThreadViewModel.cs
@@ -1,12 +1,17 @@
using System;
using System.ComponentModel.Composition;
+using System.Globalization;
using System.Linq;
+using System.Reactive.Linq;
using System.Threading.Tasks;
using GitHub.Extensions;
using GitHub.Factories;
using GitHub.Models;
+using GitHub.Models.Drafts;
+using GitHub.Primitives;
using GitHub.Services;
using ReactiveUI;
+using static System.FormattableString;
namespace GitHub.ViewModels
{
@@ -25,9 +30,13 @@ public class PullRequestReviewCommentThreadViewModel : CommentThreadViewModel, I
///
/// Initializes a new instance of the class.
///
+ /// The message draft store.
/// The view model factory.
[ImportingConstructor]
- public PullRequestReviewCommentThreadViewModel(IViewViewModelFactory factory)
+ public PullRequestReviewCommentThreadViewModel(
+ IMessageDraftStore draftStore,
+ IViewViewModelFactory factory)
+ : base(draftStore)
{
Guard.ArgumentNotNull(factory, nameof(factory));
@@ -75,7 +84,7 @@ public async Task InitializeAsync(
{
Guard.ArgumentNotNull(session, nameof(session));
- await base.InitializeAsync(session.User).ConfigureAwait(false);
+ await base.InitializeAsync(session.User).ConfigureAwait(true);
Session = session;
File = file;
@@ -97,8 +106,23 @@ await vm.InitializeAsync(
if (addPlaceholder)
{
var vm = factory.CreateViewModel();
- await vm.InitializeAsPlaceholderAsync(session, this, false).ConfigureAwait(false);
- Comments.Add(vm);
+
+ await vm.InitializeAsPlaceholderAsync(
+ session,
+ this,
+ review.State == PullRequestReviewState.Pending,
+ false).ConfigureAwait(true);
+
+ var (key, secondaryKey) = GetDraftKeys(vm);
+ var draft = await DraftStore.GetDraft(key, secondaryKey).ConfigureAwait(true);
+
+ if (draft?.Side == Side)
+ {
+ await vm.BeginEdit.Execute();
+ vm.Body = draft.Body;
+ }
+
+ AddPlaceholder(vm);
}
}
@@ -121,13 +145,22 @@ public async Task InitializeNewAsync(
IsNewThread = true;
var vm = factory.CreateViewModel();
- await vm.InitializeAsPlaceholderAsync(session, this, isEditing).ConfigureAwait(false);
- Comments.Add(vm);
+ await vm.InitializeAsPlaceholderAsync(session, this, session.HasPendingReview, isEditing).ConfigureAwait(false);
+
+ var (key, secondaryKey) = GetDraftKeys(vm);
+ var draft = await DraftStore.GetDraft(key, secondaryKey).ConfigureAwait(true);
+
+ if (draft?.Side == side)
+ {
+ vm.Body = draft.Body;
+ }
+
+ AddPlaceholder(vm);
}
- public override async Task PostComment(string body)
+ public override async Task PostComment(ICommentViewModel comment)
{
- Guard.ArgumentNotNull(body, nameof(body));
+ Guard.ArgumentNotNull(comment, nameof(comment));
if (IsNewThread)
{
@@ -145,7 +178,7 @@ public override async Task PostComment(string body)
}
await Session.PostReviewComment(
- body,
+ comment.Body,
File.CommitSha,
File.RelativePath.Replace("\\", "/"),
File.Diff,
@@ -154,21 +187,54 @@ await Session.PostReviewComment(
else
{
var replyId = Comments[0].Id;
- await Session.PostReviewComment(body, replyId).ConfigureAwait(false);
+ await Session.PostReviewComment(comment.Body, replyId).ConfigureAwait(false);
}
+
+ await DeleteDraft(comment).ConfigureAwait(false);
}
- public override async Task EditComment(string id, string body)
+ public override async Task EditComment(ICommentViewModel comment)
{
- Guard.ArgumentNotNull(id, nameof(id));
- Guard.ArgumentNotNull(body, nameof(body));
+ Guard.ArgumentNotNull(comment, nameof(comment));
+
+ await Session.EditComment(comment.Id, comment.Body).ConfigureAwait(false);
+ }
- await Session.EditComment(id, body).ConfigureAwait(false);
+ public override async Task DeleteComment(ICommentViewModel comment)
+ {
+ Guard.ArgumentNotNull(comment, nameof(comment));
+
+ await Session.DeleteComment(comment.PullRequestId, comment.DatabaseId).ConfigureAwait(false);
+ }
+
+ public static (string key, string secondaryKey) GetDraftKeys(
+ UriString cloneUri,
+ int pullRequestNumber,
+ string relativePath,
+ int lineNumber)
+ {
+ relativePath = relativePath.Replace("\\", "/");
+ var key = Invariant($"pr-review-comment|{cloneUri}|{pullRequestNumber}|{relativePath}");
+ return (key, lineNumber.ToString(CultureInfo.InvariantCulture));
+ }
+
+ protected override CommentDraft BuildDraft(ICommentViewModel comment)
+ {
+ return new PullRequestReviewCommentDraft
+ {
+ Body = comment.Body,
+ Side = Side,
+ UpdatedAt = DateTimeOffset.UtcNow,
+ };
}
- public override async Task DeleteComment(int pullRequestId, int commentId)
+ protected override (string key, string secondaryKey) GetDraftKeys(ICommentViewModel comment)
{
- await Session.DeleteComment(pullRequestId, commentId).ConfigureAwait(false);
+ return GetDraftKeys(
+ Session.LocalRepository.CloneUrl.WithOwner(Session.RepositoryOwner),
+ Session.PullRequest.Number,
+ File.RelativePath,
+ LineNumber);
}
}
}
diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs
index 8a90eaaae3..dc92301ed0 100644
--- a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs
+++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs
@@ -67,6 +67,7 @@ public async Task InitializeAsync(
public async Task InitializeAsPlaceholderAsync(
IPullRequestSession session,
ICommentThreadViewModel thread,
+ bool isPending,
bool isEditing)
{
Guard.ArgumentNotNull(session, nameof(session));
@@ -77,6 +78,7 @@ await InitializeAsync(
null,
isEditing ? CommentEditState.Editing : CommentEditState.Placeholder).ConfigureAwait(true);
this.session = session;
+ IsPending = isPending;
}
///
@@ -101,7 +103,7 @@ async Task DoStartReview()
try
{
- await session.StartReview().ConfigureAwait(false);
+ await session.StartReview().ConfigureAwait(true);
await CommitEdit.Execute();
}
finally
diff --git a/src/GitHub.App/sqlite-net/SQLite.cs b/src/GitHub.App/sqlite-net/SQLite.cs
new file mode 100644
index 0000000000..6861b8c0eb
--- /dev/null
+++ b/src/GitHub.App/sqlite-net/SQLite.cs
@@ -0,0 +1,4523 @@
+//
+// Copyright (c) 2009-2018 Krueger Systems, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+//
+#if WINDOWS_PHONE && !USE_WP8_NATIVE_SQLITE
+#define USE_CSHARP_SQLITE
+#endif
+
+using System;
+using System.Collections;
+using System.Diagnostics;
+#if !USE_SQLITEPCL_RAW
+using System.Runtime.InteropServices;
+#endif
+using System.Collections.Generic;
+using System.Reflection;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Text;
+using System.Threading;
+
+#if USE_CSHARP_SQLITE
+using Sqlite3 = Community.CsharpSqlite.Sqlite3;
+using Sqlite3DatabaseHandle = Community.CsharpSqlite.Sqlite3.sqlite3;
+using Sqlite3Statement = Community.CsharpSqlite.Sqlite3.Vdbe;
+#elif USE_WP8_NATIVE_SQLITE
+using Sqlite3 = Sqlite.Sqlite3;
+using Sqlite3DatabaseHandle = Sqlite.Database;
+using Sqlite3Statement = Sqlite.Statement;
+#elif USE_SQLITEPCL_RAW
+using Sqlite3DatabaseHandle = SQLitePCL.sqlite3;
+using Sqlite3Statement = SQLitePCL.sqlite3_stmt;
+using Sqlite3 = SQLitePCL.raw;
+#else
+using Sqlite3DatabaseHandle = System.IntPtr;
+using Sqlite3Statement = System.IntPtr;
+#endif
+
+#pragma warning disable 1591 // XML Doc Comments
+
+namespace SQLite
+{
+ public class SQLiteException : Exception
+ {
+ public SQLite3.Result Result { get; private set; }
+
+ protected SQLiteException(SQLite3.Result r, string message) : base(message)
+ {
+ Result = r;
+ }
+
+ public static SQLiteException New(SQLite3.Result r, string message)
+ {
+ return new SQLiteException(r, message);
+ }
+ }
+
+ public class NotNullConstraintViolationException : SQLiteException
+ {
+ public IEnumerable Columns { get; protected set; }
+
+ protected NotNullConstraintViolationException(SQLite3.Result r, string message)
+ : this(r, message, null, null)
+ {
+
+ }
+
+ protected NotNullConstraintViolationException(SQLite3.Result r, string message, TableMapping mapping, object obj)
+ : base(r, message)
+ {
+ if (mapping != null && obj != null)
+ {
+ this.Columns = from c in mapping.Columns
+ where c.IsNullable == false && c.GetValue(obj) == null
+ select c;
+ }
+ }
+
+ public static new NotNullConstraintViolationException New(SQLite3.Result r, string message)
+ {
+ return new NotNullConstraintViolationException(r, message);
+ }
+
+ public static NotNullConstraintViolationException New(SQLite3.Result r, string message, TableMapping mapping, object obj)
+ {
+ return new NotNullConstraintViolationException(r, message, mapping, obj);
+ }
+
+ public static NotNullConstraintViolationException New(SQLiteException exception, TableMapping mapping, object obj)
+ {
+ return new NotNullConstraintViolationException(exception.Result, exception.Message, mapping, obj);
+ }
+ }
+
+ [Flags]
+ public enum SQLiteOpenFlags
+ {
+ ReadOnly = 1, ReadWrite = 2, Create = 4,
+ NoMutex = 0x8000, FullMutex = 0x10000,
+ SharedCache = 0x20000, PrivateCache = 0x40000,
+ ProtectionComplete = 0x00100000,
+ ProtectionCompleteUnlessOpen = 0x00200000,
+ ProtectionCompleteUntilFirstUserAuthentication = 0x00300000,
+ ProtectionNone = 0x00400000
+ }
+
+ [Flags]
+ public enum CreateFlags
+ {
+ ///
+ /// Use the default creation options
+ ///
+ None = 0x000,
+ ///
+ /// Create a primary key index for a property called 'Id' (case-insensitive).
+ /// This avoids the need for the [PrimaryKey] attribute.
+ ///
+ ImplicitPK = 0x001,
+ ///
+ /// Create indices for properties ending in 'Id' (case-insensitive).
+ ///
+ ImplicitIndex = 0x002,
+ ///
+ /// Create a primary key for a property called 'Id' and
+ /// create an indices for properties ending in 'Id' (case-insensitive).
+ ///
+ AllImplicit = 0x003,
+ ///
+ /// Force the primary key property to be auto incrementing.
+ /// This avoids the need for the [AutoIncrement] attribute.
+ /// The primary key property on the class should have type int or long.
+ ///
+ AutoIncPK = 0x004,
+ ///
+ /// Create virtual table using FTS3
+ ///
+ FullTextSearch3 = 0x100,
+ ///
+ /// Create virtual table using FTS4
+ ///
+ FullTextSearch4 = 0x200
+ }
+
+ ///
+ /// An open connection to a SQLite database.
+ ///
+ [Preserve(AllMembers = true)]
+ public partial class SQLiteConnection : IDisposable
+ {
+ private bool _open;
+ private TimeSpan _busyTimeout;
+ readonly static Dictionary _mappings = new Dictionary();
+ private System.Diagnostics.Stopwatch _sw;
+ private long _elapsedMilliseconds = 0;
+
+ private int _transactionDepth = 0;
+ private Random _rand = new Random();
+
+ public Sqlite3DatabaseHandle Handle { get; private set; }
+ static readonly Sqlite3DatabaseHandle NullHandle = default(Sqlite3DatabaseHandle);
+
+ ///
+ /// Gets the database path used by this connection.
+ ///
+ public string DatabasePath { get; private set; }
+
+ ///
+ /// Gets the SQLite library version number. 3007014 would be v3.7.14
+ ///
+ public int LibVersionNumber { get; private set; }
+
+ ///
+ /// Whether Trace lines should be written that show the execution time of queries.
+ ///
+ public bool TimeExecution { get; set; }
+
+ ///
+ /// Whether to writer queries to during execution.
+ ///
+ /// The tracer.
+ public bool Trace { get; set; }
+
+ ///
+ /// The delegate responsible for writing trace lines.
+ ///
+ /// The tracer.
+ public Action Tracer { get; set; }
+
+ ///
+ /// Whether to store DateTime properties as ticks (true) or strings (false).
+ ///
+ public bool StoreDateTimeAsTicks { get; private set; }
+
+#if USE_SQLITEPCL_RAW && !NO_SQLITEPCL_RAW_BATTERIES
+ static SQLiteConnection ()
+ {
+ SQLitePCL.Batteries_V2.Init ();
+ }
+#endif
+
+ ///
+ /// Constructs a new SQLiteConnection and opens a SQLite database specified by databasePath.
+ ///
+ ///
+ /// Specifies the path to the database file.
+ ///
+ ///
+ /// Specifies whether to store DateTime properties as ticks (true) or strings (false). You
+ /// absolutely do want to store them as Ticks in all new projects. The value of false is
+ /// only here for backwards compatibility. There is a *significant* speed advantage, with no
+ /// down sides, when setting storeDateTimeAsTicks = true.
+ /// If you use DateTimeOffset properties, it will be always stored as ticks regardingless
+ /// the storeDateTimeAsTicks parameter.
+ ///
+ ///
+ /// Specifies the encryption key to use on the database. Should be a string or a byte[].
+ ///
+ public SQLiteConnection(string databasePath, bool storeDateTimeAsTicks = true, object key = null)
+ : this(databasePath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create, storeDateTimeAsTicks, key: key)
+ {
+ }
+
+ ///
+ /// Constructs a new SQLiteConnection and opens a SQLite database specified by databasePath.
+ ///
+ ///
+ /// Specifies the path to the database file.
+ ///
+ ///
+ /// Flags controlling how the connection should be opened.
+ ///
+ ///
+ /// Specifies whether to store DateTime properties as ticks (true) or strings (false). You
+ /// absolutely do want to store them as Ticks in all new projects. The value of false is
+ /// only here for backwards compatibility. There is a *significant* speed advantage, with no
+ /// down sides, when setting storeDateTimeAsTicks = true.
+ /// If you use DateTimeOffset properties, it will be always stored as ticks regardingless
+ /// the storeDateTimeAsTicks parameter.
+ ///
+ ///
+ /// Specifies the encryption key to use on the database. Should be a string or a byte[].
+ ///
+ public SQLiteConnection(string databasePath, SQLiteOpenFlags openFlags, bool storeDateTimeAsTicks = true, object key = null)
+ {
+ if (databasePath == null)
+ throw new ArgumentException("Must be specified", nameof(databasePath));
+
+ DatabasePath = databasePath;
+
+ LibVersionNumber = SQLite3.LibVersionNumber();
+
+#if NETFX_CORE
+ SQLite3.SetDirectory(/*temp directory type*/2, Windows.Storage.ApplicationData.Current.TemporaryFolder.Path);
+#endif
+
+ Sqlite3DatabaseHandle handle;
+
+#if SILVERLIGHT || USE_CSHARP_SQLITE || USE_SQLITEPCL_RAW
+ var r = SQLite3.Open (databasePath, out handle, (int)openFlags, IntPtr.Zero);
+#else
+ // open using the byte[]
+ // in the case where the path may include Unicode
+ // force open to using UTF-8 using sqlite3_open_v2
+ var databasePathAsBytes = GetNullTerminatedUtf8(DatabasePath);
+ var r = SQLite3.Open(databasePathAsBytes, out handle, (int)openFlags, IntPtr.Zero);
+#endif
+
+ Handle = handle;
+ if (r != SQLite3.Result.OK)
+ {
+ throw SQLiteException.New(r, String.Format("Could not open database file: {0} ({1})", DatabasePath, r));
+ }
+ _open = true;
+
+ StoreDateTimeAsTicks = storeDateTimeAsTicks;
+
+ BusyTimeout = TimeSpan.FromSeconds(0.1);
+ Tracer = line => Debug.WriteLine(line);
+
+ if (key is string stringKey)
+ {
+ SetKey(stringKey);
+ }
+ else if (key is byte[] bytesKey)
+ {
+ SetKey(bytesKey);
+ }
+ else if (key != null)
+ {
+ throw new ArgumentException("Encryption keys must be strings or byte arrays", nameof(key));
+ }
+ if (openFlags.HasFlag(SQLiteOpenFlags.ReadWrite))
+ {
+ ExecuteScalar("PRAGMA journal_mode=WAL");
+ }
+ }
+
+ ///
+ /// Convert an input string to a quoted SQL string that can be safely used in queries.
+ ///
+ /// The quoted string.
+ /// The unsafe string to quote.
+ static string Quote(string unsafeString)
+ {
+ // TODO: Doesn't call sqlite3_mprintf("%Q", u) because we're waiting on https://github.com/ericsink/SQLitePCL.raw/issues/153
+ if (unsafeString == null) return "NULL";
+ var safe = unsafeString.Replace("'", "''");
+ return "'" + safe + "'";
+ }
+
+ ///
+ /// Sets the key used to encrypt/decrypt the database with "pragma key = ...".
+ /// This must be the first thing you call before doing anything else with this connection
+ /// if your database is encrypted.
+ /// This only has an effect if you are using the SQLCipher nuget package.
+ ///
+ /// Ecryption key plain text that is converted to the real encryption key using PBKDF2 key derivation
+ void SetKey(string key)
+ {
+ if (key == null) throw new ArgumentNullException(nameof(key));
+ var q = Quote(key);
+ Execute("pragma key = " + q);
+ }
+
+ ///
+ /// Sets the key used to encrypt/decrypt the database.
+ /// This must be the first thing you call before doing anything else with this connection
+ /// if your database is encrypted.
+ /// This only has an effect if you are using the SQLCipher nuget package.
+ ///
+ /// 256-bit (32 byte) ecryption key data
+ void SetKey(byte[] key)
+ {
+ if (key == null) throw new ArgumentNullException(nameof(key));
+ if (key.Length != 32) throw new ArgumentException("Key must be 32 bytes (256-bit)", nameof(key));
+ var s = String.Join("", key.Select(x => x.ToString("X2")));
+ Execute("pragma key = \"x'" + s + "'\"");
+ }
+
+ ///
+ /// Enable or disable extension loading.
+ ///
+ public void EnableLoadExtension(bool enabled)
+ {
+ SQLite3.Result r = SQLite3.EnableLoadExtension(Handle, enabled ? 1 : 0);
+ if (r != SQLite3.Result.OK)
+ {
+ string msg = SQLite3.GetErrmsg(Handle);
+ throw SQLiteException.New(r, msg);
+ }
+ }
+
+#if !USE_SQLITEPCL_RAW
+ static byte[] GetNullTerminatedUtf8(string s)
+ {
+ var utf8Length = System.Text.Encoding.UTF8.GetByteCount(s);
+ var bytes = new byte[utf8Length + 1];
+ utf8Length = System.Text.Encoding.UTF8.GetBytes(s, 0, s.Length, bytes, 0);
+ return bytes;
+ }
+#endif
+
+ ///
+ /// Sets a busy handler to sleep the specified amount of time when a table is locked.
+ /// The handler will sleep multiple times until a total time of has accumulated.
+ ///
+ public TimeSpan BusyTimeout
+ {
+ get { return _busyTimeout; }
+ set
+ {
+ _busyTimeout = value;
+ if (Handle != NullHandle)
+ {
+ SQLite3.BusyTimeout(Handle, (int)_busyTimeout.TotalMilliseconds);
+ }
+ }
+ }
+
+ ///
+ /// Returns the mappings from types to tables that the connection
+ /// currently understands.
+ ///
+ public IEnumerable TableMappings
+ {
+ get
+ {
+ lock (_mappings)
+ {
+ return new List(_mappings.Values);
+ }
+ }
+ }
+
+ ///
+ /// Retrieves the mapping that is automatically generated for the given type.
+ ///
+ ///
+ /// The type whose mapping to the database is returned.
+ ///
+ ///
+ /// Optional flags allowing implicit PK and indexes based on naming conventions
+ ///
+ ///
+ /// The mapping represents the schema of the columns of the database and contains
+ /// methods to set and get properties of objects.
+ ///
+ public TableMapping GetMapping(Type type, CreateFlags createFlags = CreateFlags.None)
+ {
+ TableMapping map;
+ var key = type.FullName;
+ lock (_mappings)
+ {
+ if (_mappings.TryGetValue(key, out map))
+ {
+ if (createFlags != CreateFlags.None && createFlags != map.CreateFlags)
+ {
+ map = new TableMapping(type, createFlags);
+ _mappings[key] = map;
+ }
+ }
+ else
+ {
+ map = new TableMapping(type, createFlags);
+ _mappings.Add(key, map);
+ }
+ }
+ return map;
+ }
+
+ ///
+ /// Retrieves the mapping that is automatically generated for the given type.
+ ///
+ ///
+ /// Optional flags allowing implicit PK and indexes based on naming conventions
+ ///
+ ///
+ /// The mapping represents the schema of the columns of the database and contains
+ /// methods to set and get properties of objects.
+ ///
+ public TableMapping GetMapping(CreateFlags createFlags = CreateFlags.None)
+ {
+ return GetMapping(typeof(T), createFlags);
+ }
+
+ private struct IndexedColumn
+ {
+ public int Order;
+ public string ColumnName;
+ }
+
+ private struct IndexInfo
+ {
+ public string IndexName;
+ public string TableName;
+ public bool Unique;
+ public List Columns;
+ }
+
+ ///
+ /// Executes a "drop table" on the database. This is non-recoverable.
+ ///
+ public int DropTable()
+ {
+ return DropTable(GetMapping(typeof(T)));
+ }
+
+ ///
+ /// Executes a "drop table" on the database. This is non-recoverable.
+ ///
+ ///
+ /// The TableMapping used to identify the table.
+ ///
+ public int DropTable(TableMapping map)
+ {
+ var query = string.Format("drop table if exists \"{0}\"", map.TableName);
+ return Execute(query);
+ }
+
+ ///
+ /// Executes a "create table if not exists" on the database. It also
+ /// creates any specified indexes on the columns of the table. It uses
+ /// a schema automatically generated from the specified type. You can
+ /// later access this schema by calling GetMapping.
+ ///
+ ///
+ /// Whether the table was created or migrated.
+ ///
+ public CreateTableResult CreateTable(CreateFlags createFlags = CreateFlags.None)
+ {
+ return CreateTable(typeof(T), createFlags);
+ }
+
+ ///
+ /// Executes a "create table if not exists" on the database. It also
+ /// creates any specified indexes on the columns of the table. It uses
+ /// a schema automatically generated from the specified type. You can
+ /// later access this schema by calling GetMapping.
+ ///
+ /// Type to reflect to a database table.
+ /// Optional flags allowing implicit PK and indexes based on naming conventions.
+ ///
+ /// Whether the table was created or migrated.
+ ///
+ public CreateTableResult CreateTable(Type ty, CreateFlags createFlags = CreateFlags.None)
+ {
+ var map = GetMapping(ty, createFlags);
+
+ // Present a nice error if no columns specified
+ if (map.Columns.Length == 0)
+ {
+ throw new Exception(string.Format("Cannot create a table without columns (does '{0}' have public properties?)", ty.FullName));
+ }
+
+ // Check if the table exists
+ var result = CreateTableResult.Created;
+ var existingCols = GetTableInfo(map.TableName);
+
+ // Create or migrate it
+ if (existingCols.Count == 0)
+ {
+
+ // Facilitate virtual tables a.k.a. full-text search.
+ bool fts3 = (createFlags & CreateFlags.FullTextSearch3) != 0;
+ bool fts4 = (createFlags & CreateFlags.FullTextSearch4) != 0;
+ bool fts = fts3 || fts4;
+ var @virtual = fts ? "virtual " : string.Empty;
+ var @using = fts3 ? "using fts3 " : fts4 ? "using fts4 " : string.Empty;
+
+ // Build query.
+ var query = "create " + @virtual + "table if not exists \"" + map.TableName + "\" " + @using + "(\n";
+ var decls = map.Columns.Select(p => Orm.SqlDecl(p, StoreDateTimeAsTicks));
+ var decl = string.Join(",\n", decls.ToArray());
+ query += decl;
+ query += ")";
+ if (map.WithoutRowId)
+ {
+ query += " without rowid";
+ }
+
+ Execute(query);
+ }
+ else
+ {
+ result = CreateTableResult.Migrated;
+ MigrateTable(map, existingCols);
+ }
+
+ var indexes = new Dictionary();
+ foreach (var c in map.Columns)
+ {
+ foreach (var i in c.Indices)
+ {
+ var iname = i.Name ?? map.TableName + "_" + c.Name;
+ IndexInfo iinfo;
+ if (!indexes.TryGetValue(iname, out iinfo))
+ {
+ iinfo = new IndexInfo
+ {
+ IndexName = iname,
+ TableName = map.TableName,
+ Unique = i.Unique,
+ Columns = new List()
+ };
+ indexes.Add(iname, iinfo);
+ }
+
+ if (i.Unique != iinfo.Unique)
+ throw new Exception("All the columns in an index must have the same value for their Unique property");
+
+ iinfo.Columns.Add(new IndexedColumn
+ {
+ Order = i.Order,
+ ColumnName = c.Name
+ });
+ }
+ }
+
+ foreach (var indexName in indexes.Keys)
+ {
+ var index = indexes[indexName];
+ var columns = index.Columns.OrderBy(i => i.Order).Select(i => i.ColumnName).ToArray();
+ CreateIndex(indexName, index.TableName, columns, index.Unique);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Executes a "create table if not exists" on the database for each type. It also
+ /// creates any specified indexes on the columns of the table. It uses
+ /// a schema automatically generated from the specified type. You can
+ /// later access this schema by calling GetMapping.
+ ///
+ ///
+ /// Whether the table was created or migrated for each type.
+ ///
+ public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None)
+ where T : new()
+ where T2 : new()
+ {
+ return CreateTables(createFlags, typeof(T), typeof(T2));
+ }
+
+ ///
+ /// Executes a "create table if not exists" on the database for each type. It also
+ /// creates any specified indexes on the columns of the table. It uses
+ /// a schema automatically generated from the specified type. You can
+ /// later access this schema by calling GetMapping.
+ ///
+ ///
+ /// Whether the table was created or migrated for each type.
+ ///
+ public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None)
+ where T : new()
+ where T2 : new()
+ where T3 : new()
+ {
+ return CreateTables(createFlags, typeof(T), typeof(T2), typeof(T3));
+ }
+
+ ///
+ /// Executes a "create table if not exists" on the database for each type. It also
+ /// creates any specified indexes on the columns of the table. It uses
+ /// a schema automatically generated from the specified type. You can
+ /// later access this schema by calling GetMapping.
+ ///
+ ///
+ /// Whether the table was created or migrated for each type.
+ ///
+ public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None)
+ where T : new()
+ where T2 : new()
+ where T3 : new()
+ where T4 : new()
+ {
+ return CreateTables(createFlags, typeof(T), typeof(T2), typeof(T3), typeof(T4));
+ }
+
+ ///
+ /// Executes a "create table if not exists" on the database for each type. It also
+ /// creates any specified indexes on the columns of the table. It uses
+ /// a schema automatically generated from the specified type. You can
+ /// later access this schema by calling GetMapping.
+ ///
+ ///
+ /// Whether the table was created or migrated for each type.
+ ///
+ public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None)
+ where T : new()
+ where T2 : new()
+ where T3 : new()
+ where T4 : new()
+ where T5 : new()
+ {
+ return CreateTables(createFlags, typeof(T), typeof(T2), typeof(T3), typeof(T4), typeof(T5));
+ }
+
+ ///
+ /// Executes a "create table if not exists" on the database for each type. It also
+ /// creates any specified indexes on the columns of the table. It uses
+ /// a schema automatically generated from the specified type. You can
+ /// later access this schema by calling GetMapping.
+ ///
+ ///
+ /// Whether the table was created or migrated for each type.
+ ///
+ public CreateTablesResult CreateTables(CreateFlags createFlags = CreateFlags.None, params Type[] types)
+ {
+ var result = new CreateTablesResult();
+ foreach (Type type in types)
+ {
+ var aResult = CreateTable(type, createFlags);
+ result.Results[type] = aResult;
+ }
+ return result;
+ }
+
+ ///
+ /// Creates an index for the specified table and columns.
+ ///
+ /// Name of the index to create
+ /// Name of the database table
+ /// An array of column names to index
+ /// Whether the index should be unique
+ public int CreateIndex(string indexName, string tableName, string[] columnNames, bool unique = false)
+ {
+ const string sqlFormat = "create {2} index if not exists \"{3}\" on \"{0}\"(\"{1}\")";
+ var sql = String.Format(sqlFormat, tableName, string.Join("\", \"", columnNames), unique ? "unique" : "", indexName);
+ return Execute(sql);
+ }
+
+ ///
+ /// Creates an index for the specified table and column.
+ ///
+ /// Name of the index to create
+ /// Name of the database table
+ /// Name of the column to index
+ /// Whether the index should be unique
+ public int CreateIndex(string indexName, string tableName, string columnName, bool unique = false)
+ {
+ return CreateIndex(indexName, tableName, new string[] { columnName }, unique);
+ }
+
+ ///
+ /// Creates an index for the specified table and column.
+ ///
+ /// Name of the database table
+ /// Name of the column to index
+ /// Whether the index should be unique
+ public int CreateIndex(string tableName, string columnName, bool unique = false)
+ {
+ return CreateIndex(tableName + "_" + columnName, tableName, columnName, unique);
+ }
+
+ ///
+ /// Creates an index for the specified table and columns.
+ ///
+ /// Name of the database table
+ /// An array of column names to index
+ /// Whether the index should be unique
+ public int CreateIndex(string tableName, string[] columnNames, bool unique = false)
+ {
+ return CreateIndex(tableName + "_" + string.Join("_", columnNames), tableName, columnNames, unique);
+ }
+
+ ///
+ /// Creates an index for the specified object property.
+ /// e.g. CreateIndex<Client>(c => c.Name);
+ ///
+ /// Type to reflect to a database table.
+ /// Property to index
+ /// Whether the index should be unique
+ public int CreateIndex(Expression> property, bool unique = false)
+ {
+ MemberExpression mx;
+ if (property.Body.NodeType == ExpressionType.Convert)
+ {
+ mx = ((UnaryExpression)property.Body).Operand as MemberExpression;
+ }
+ else
+ {
+ mx = (property.Body as MemberExpression);
+ }
+ var propertyInfo = mx.Member as PropertyInfo;
+ if (propertyInfo == null)
+ {
+ throw new ArgumentException("The lambda expression 'property' should point to a valid Property");
+ }
+
+ var propName = propertyInfo.Name;
+
+ var map = GetMapping();
+ var colName = map.FindColumnWithPropertyName(propName).Name;
+
+ return CreateIndex(map.TableName, colName, unique);
+ }
+
+ [Preserve(AllMembers = true)]
+ public class ColumnInfo
+ {
+ // public int cid { get; set; }
+
+ [Column("name")]
+ public string Name { get; set; }
+
+ // [Column ("type")]
+ // public string ColumnType { get; set; }
+
+ public int notnull { get; set; }
+
+ // public string dflt_value { get; set; }
+
+ // public int pk { get; set; }
+
+ public override string ToString()
+ {
+ return Name;
+ }
+ }
+
+ ///
+ /// Query the built-in sqlite table_info table for a specific tables columns.
+ ///
+ /// The columns contains in the table.
+ /// Table name.
+ public List GetTableInfo(string tableName)
+ {
+ var query = "pragma table_info(\"" + tableName + "\")";
+ return Query(query);
+ }
+
+ void MigrateTable(TableMapping map, List existingCols)
+ {
+ var toBeAdded = new List();
+
+ foreach (var p in map.Columns)
+ {
+ var found = false;
+ foreach (var c in existingCols)
+ {
+ found = (string.Compare(p.Name, c.Name, StringComparison.OrdinalIgnoreCase) == 0);
+ if (found)
+ break;
+ }
+ if (!found)
+ {
+ toBeAdded.Add(p);
+ }
+ }
+
+ foreach (var p in toBeAdded)
+ {
+ var addCol = "alter table \"" + map.TableName + "\" add column " + Orm.SqlDecl(p, StoreDateTimeAsTicks);
+ Execute(addCol);
+ }
+ }
+
+ ///
+ /// Creates a new SQLiteCommand. Can be overridden to provide a sub-class.
+ ///
+ ///
+ protected virtual SQLiteCommand NewCommand()
+ {
+ return new SQLiteCommand(this);
+ }
+
+ ///
+ /// Creates a new SQLiteCommand given the command text with arguments. Place a '?'
+ /// in the command text for each of the arguments.
+ ///
+ ///
+ /// The fully escaped SQL.
+ ///
+ ///
+ /// Arguments to substitute for the occurences of '?' in the command text.
+ ///
+ ///
+ /// A
+ ///
+ public SQLiteCommand CreateCommand(string cmdText, params object[] ps)
+ {
+ if (!_open)
+ throw SQLiteException.New(SQLite3.Result.Error, "Cannot create commands from unopened database");
+
+ var cmd = NewCommand();
+ cmd.CommandText = cmdText;
+ foreach (var o in ps)
+ {
+ cmd.Bind(o);
+ }
+ return cmd;
+ }
+
+ ///
+ /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?'
+ /// in the command text for each of the arguments and then executes that command.
+ /// Use this method instead of Query when you don't expect rows back. Such cases include
+ /// INSERTs, UPDATEs, and DELETEs.
+ /// You can set the Trace or TimeExecution properties of the connection
+ /// to profile execution.
+ ///
+ ///
+ /// The fully escaped SQL.
+ ///
+ ///
+ /// Arguments to substitute for the occurences of '?' in the query.
+ ///
+ ///
+ /// The number of rows modified in the database as a result of this execution.
+ ///
+ public int Execute(string query, params object[] args)
+ {
+ var cmd = CreateCommand(query, args);
+
+ if (TimeExecution)
+ {
+ if (_sw == null)
+ {
+ _sw = new Stopwatch();
+ }
+ _sw.Reset();
+ _sw.Start();
+ }
+
+ var r = cmd.ExecuteNonQuery();
+
+ if (TimeExecution)
+ {
+ _sw.Stop();
+ _elapsedMilliseconds += _sw.ElapsedMilliseconds;
+ Tracer?.Invoke(string.Format("Finished in {0} ms ({1:0.0} s total)", _sw.ElapsedMilliseconds, _elapsedMilliseconds / 1000.0));
+ }
+
+ return r;
+ }
+
+ ///
+ /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?'
+ /// in the command text for each of the arguments and then executes that command.
+ /// Use this method when return primitive values.
+ /// You can set the Trace or TimeExecution properties of the connection
+ /// to profile execution.
+ ///
+ ///
+ /// The fully escaped SQL.
+ ///
+ ///
+ /// Arguments to substitute for the occurences of '?' in the query.
+ ///
+ ///
+ /// The number of rows modified in the database as a result of this execution.
+ ///
+ public T ExecuteScalar(string query, params object[] args)
+ {
+ var cmd = CreateCommand(query, args);
+
+ if (TimeExecution)
+ {
+ if (_sw == null)
+ {
+ _sw = new Stopwatch();
+ }
+ _sw.Reset();
+ _sw.Start();
+ }
+
+ var r = cmd.ExecuteScalar();
+
+ if (TimeExecution)
+ {
+ _sw.Stop();
+ _elapsedMilliseconds += _sw.ElapsedMilliseconds;
+ Tracer?.Invoke(string.Format("Finished in {0} ms ({1:0.0} s total)", _sw.ElapsedMilliseconds, _elapsedMilliseconds / 1000.0));
+ }
+
+ return r;
+ }
+
+ ///
+ /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?'
+ /// in the command text for each of the arguments and then executes that command.
+ /// It returns each row of the result using the mapping automatically generated for
+ /// the given type.
+ ///
+ ///
+ /// The fully escaped SQL.
+ ///
+ ///
+ /// Arguments to substitute for the occurences of '?' in the query.
+ ///
+ ///
+ /// An enumerable with one result for each row returned by the query.
+ ///
+ public List Query(string query, params object[] args) where T : new()
+ {
+ var cmd = CreateCommand(query, args);
+ return cmd.ExecuteQuery();
+ }
+
+ ///
+ /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?'
+ /// in the command text for each of the arguments and then executes that command.
+ /// It returns each row of the result using the mapping automatically generated for
+ /// the given type.
+ ///
+ ///
+ /// The fully escaped SQL.
+ ///
+ ///
+ /// Arguments to substitute for the occurences of '?' in the query.
+ ///
+ ///
+ /// An enumerable with one result for each row returned by the query.
+ /// The enumerator (retrieved by calling GetEnumerator() on the result of this method)
+ /// will call sqlite3_step on each call to MoveNext, so the database
+ /// connection must remain open for the lifetime of the enumerator.
+ ///
+ public IEnumerable DeferredQuery(string query, params object[] args) where T : new()
+ {
+ var cmd = CreateCommand(query, args);
+ return cmd.ExecuteDeferredQuery();
+ }
+
+ ///
+ /// Creates a SQLiteCommand given the command text (SQL) with arguments. Place a '?'
+ /// in the command text for each of the arguments and then executes that command.
+ /// It returns each row of the result using the specified mapping. This function is
+ /// only used by libraries in order to query the database via introspection. It is
+ /// normally not used.
+ ///
+ ///
+ /// A to use to convert the resulting rows
+ /// into objects.
+ ///
+ ///
+ /// The fully escaped SQL.
+ ///
+ ///
+ /// Arguments to substitute for the occurences of '?' in the query.
+ ///
+ ///
+ /// An enumerable with one result for each row returned by the query.
+ ///
+ public List