diff --git a/Lucene.Net.Linq.Tests/Integration/IntegrationTestBase.cs b/Lucene.Net.Linq.Tests/Integration/IntegrationTestBase.cs index 29eead45a..599641f46 100644 --- a/Lucene.Net.Linq.Tests/Integration/IntegrationTestBase.cs +++ b/Lucene.Net.Linq.Tests/Integration/IntegrationTestBase.cs @@ -31,11 +31,18 @@ protected virtual Analyzer GetAnalyzer(Version version) public class SampleDocument { + private static int count; + public SampleDocument() + { + Key = (count++).ToString(); + } + public string Name { get; set; } [Field(IndexMode.NotAnalyzed)] public string Id { get; set; } + [Field(Key = true)] public string Key { get; set; } public int Scalar { get; set; } @@ -53,7 +60,7 @@ public class SampleDocument public string Alias { get; set; } } - protected void AddDocument(T document) + protected void AddDocument(T document) where T : new() { using (var session = provider.OpenSession()) { diff --git a/Lucene.Net.Linq.Tests/Integration/SampleProgram.cs b/Lucene.Net.Linq.Tests/Integration/SampleProgram.cs index 4eb82b1a5..48c26cbbe 100644 --- a/Lucene.Net.Linq.Tests/Integration/SampleProgram.cs +++ b/Lucene.Net.Linq.Tests/Integration/SampleProgram.cs @@ -21,7 +21,6 @@ public static void Main() using (var session = provider.OpenSession
()) { session.Add(new Article { Author = "John Doe", BodyText = "some body text", PublishDate = DateTimeOffset.UtcNow }); - session.Commit(); } var articles = provider.AsQueryable
(); diff --git a/Lucene.Net.Linq.Tests/Integration/SessionTests.cs b/Lucene.Net.Linq.Tests/Integration/SessionTests.cs new file mode 100644 index 000000000..1d51661b1 --- /dev/null +++ b/Lucene.Net.Linq.Tests/Integration/SessionTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using NUnit.Framework; + +namespace Lucene.Net.Linq.Tests.Integration +{ + [TestFixture] + public class SessionTests : IntegrationTestBase + { + protected override Analysis.Analyzer GetAnalyzer(Net.Util.Version version) + { + return new LowercaseKeywordAnalyzer(); + } + + [SetUp] + public void AddDocuments() + { + AddDocument(new SampleDocument { Name = "c", Scalar = 3, Flag = true, Version = new Version(100, 0, 0) }); + AddDocument(new SampleDocument { Name = "a", Scalar = 1, Version = new Version(20, 0, 0) }); + AddDocument(new SampleDocument { Name = "b", Scalar = 2, Flag = true, Version = new Version(3, 0, 0) }); + } + + [Test] + public void Query() + { + var session = provider.OpenSession(); + + using (session) + { + var item = (from d in session.Query() where d.Name == "a" select d).Single(); + item.Scalar = 4; + } + + var result = (from d in session.Query() where d.Name == "a" select d).Single(); + Assert.That(result.Scalar, Is.EqualTo(4)); + } + } +} \ No newline at end of file diff --git a/Lucene.Net.Linq.Tests/Lucene.Net.Linq.Tests.csproj b/Lucene.Net.Linq.Tests/Lucene.Net.Linq.Tests.csproj index 68233464a..c1c198485 100644 --- a/Lucene.Net.Linq.Tests/Lucene.Net.Linq.Tests.csproj +++ b/Lucene.Net.Linq.Tests/Lucene.Net.Linq.Tests.csproj @@ -73,6 +73,7 @@ + diff --git a/Lucene.Net.Linq.Tests/LuceneSessionTests.cs b/Lucene.Net.Linq.Tests/LuceneSessionTests.cs index 67c3af22a..28580e89d 100644 --- a/Lucene.Net.Linq.Tests/LuceneSessionTests.cs +++ b/Lucene.Net.Linq.Tests/LuceneSessionTests.cs @@ -1,15 +1,16 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using Lucene.Net.Analysis; -using Lucene.Net.Analysis.Standard; using Lucene.Net.Documents; using Lucene.Net.Index; using Lucene.Net.Linq.Abstractions; using Lucene.Net.Linq.Mapping; using Lucene.Net.Search; -using Lucene.Net.Util; using NUnit.Framework; using Rhino.Mocks; +using Version = Lucene.Net.Util.Version; namespace Lucene.Net.Linq.Tests { @@ -27,11 +28,11 @@ public void SetUp() { mapper = MockRepository.GenerateStrictMock>(); writer = MockRepository.GenerateStrictMock(); - analyzer = new StandardAnalyzer(Version.LUCENE_29); + analyzer = new LowercaseKeywordAnalyzer(); context = MockRepository.GenerateStub(null, analyzer, Version.LUCENE_29, writer, new object()); - session = new LuceneSession(mapper, context); + session = new LuceneSession(mapper, context, null); mapper.Expect(m => m.ToKey(Arg.Is.NotNull)) .WhenCalled(mi => mi.ReturnValue = new DocumentKey(new Dictionary { { new FakeFieldMappingInfo { FieldName = "Id"}, ((Record)mi.Arguments[0]).Id } })) @@ -44,14 +45,14 @@ public void AddWithSameKeyReplaces() var r1 = new Record { Id = "11", Name = "A" }; var r2 = new Record { Id = "11", Name = "B" }; - mapper.Expect(m => m.ToDocument(Arg.Is.Same(r1), Arg.Is.NotNull)); mapper.Expect(m => m.ToDocument(Arg.Is.Same(r2), Arg.Is.NotNull)); session.Add(r1, r2); + var pendingAdditions = session.ConvertPendingAdditions(); Verify(); - - Assert.That(session.Additions.Count(), Is.EqualTo(1)); + + Assert.That(pendingAdditions.Count(), Is.EqualTo(1)); } [Test] @@ -94,51 +95,55 @@ public void Commit_Delete() } [Test] - public void Commit_Add() + public void Commit_Add_DeletesKey() { - var doc1 = new Document(); - var doc2 = new Document(); + var key = new DocumentKey(new Dictionary { { new FakeFieldMappingInfo { FieldName = "Id" }, 1 } }); - session.Add(new DocumentKey(), doc1); - session.Add(new DocumentKey(), doc2); + var record = new Record {Id = "1"}; - writer.Expect(w => w.AddDocument(doc1)); - writer.Expect(w => w.AddDocument(doc2)); + session.Add(record); + + mapper.Expect(m => m.ToDocument(Arg.Is.Same(record), Arg.Is.NotNull)); + writer.Expect(w => w.DeleteDocuments(new[] {key.ToQuery(context.Analyzer, context.Version)})); + writer.Expect(w => w.AddDocument(Arg.Is.NotNull)); writer.Expect(w => w.Commit()); session.Commit(); Verify(); - Assert.That(session.Additions, Is.Empty, "Commit should clear pending deletions."); + Assert.That(session.ConvertPendingAdditions, Is.Empty, "Commit should clear pending deletions."); } [Test] - public void Commit_Add_DeletesKey() + public void Commit_Add_ConvertsDocumentAndKeyLate() { - var doc1 = new Document(); - var key = new DocumentKey(new Dictionary { { new FakeFieldMappingInfo { FieldName = "Id" }, 1 } }); - - session.Add(key, doc1); + var record = new Record(); + var key = new DocumentKey(new Dictionary { { new FakeFieldMappingInfo { FieldName = "Id" }, "biully" } }); + var deleteQuery = key.ToQuery(context.Analyzer, Version.LUCENE_29); - writer.Expect(w => w.DeleteDocuments(new[] {key.ToQuery(context.Analyzer, context.Version)})); - writer.Expect(w => w.AddDocument(doc1)); + mapper.Expect(m => m.ToDocument(Arg.Is.Same(record), Arg.Is.NotNull)); + writer.Expect(w => w.DeleteDocuments(new[] { deleteQuery })); + writer.Expect(w => w.AddDocument(Arg.Is.NotNull));//Matches(doc => doc.GetValues("Name")[0] == "a name"))); writer.Expect(w => w.Commit()); + + session.Add(record); + + record.Id = "biully"; + record.Name = "a name"; session.Commit(); Verify(); - Assert.That(session.Additions, Is.Empty, "Commit should clear pending deletions."); + Assert.That(session.ConvertPendingAdditions, Is.Empty, "Commit should clear pending deletions."); } + [Test] public void Commit_ReloadsSearcher() { - var doc1 = new Document(); - - session.Add(new DocumentKey(), doc1); - - writer.Expect(w => w.AddDocument(doc1)); + session.DeleteAll(); + writer.Expect(w => w.DeleteAll()); writer.Expect(w => w.Commit()); session.Commit(); @@ -168,12 +173,11 @@ public void DeleteAll() public void DeleteAllClearsPendingAdditions() { var r1 = new Record(); - mapper.Expect(m => m.ToDocument(Arg.Is.Same(r1), Arg.Is.NotNull)); session.Add(r1); session.DeleteAll(); - - Assert.That(session.Additions, Is.Empty, "Additions"); + + Assert.That(session.ConvertPendingAdditions, Is.Empty, "Additions"); Verify(); } @@ -188,6 +192,17 @@ public void Delete() Assert.That(session.Deletions.Single().ToString(), Is.EqualTo("+Id:12")); } + [Test] + public void Delete_RemovesFromPendingAdditions() + { + var r1 = new Record { Id = "12" }; + session.Add(r1); + session.Delete(r1); + + Assert.That(session.Additions, Is.Empty); + Assert.That(session.Deletions.Single().ToString(), Is.EqualTo("+Id:12")); + } + [Test] public void Delete_SetsPendingChangesFlag() { @@ -197,8 +212,7 @@ public void Delete_SetsPendingChangesFlag() Assert.That(session.PendingChanges, Is.True, "PendingChanges"); } - - + [Test] public void Delete_ThrowsOnEmptyKey() { @@ -212,6 +226,62 @@ public void Delete_ThrowsOnEmptyKey() Assert.That(call, Throws.InvalidOperationException); } + [Test] + public void Query_Attaches() + { + var records = new Record[0].AsQueryable(); + var provider = MockRepository.GenerateStrictMock(); + var queryable = MockRepository.GenerateStrictMock>(); + queryable.Expect(q => q.Provider).Return(provider); + queryable.Expect(q => q.Expression).Return(Expression.Constant(records)); + provider.Expect(p => p.CreateQuery(Arg.Is.NotNull)).Return(records); + session = new LuceneSession(mapper, context, queryable); + + session.Query(); + + queryable.VerifyAllExpectations(); + provider.VerifyAllExpectations(); + } + + [Test] + public void PendingChanges_DirtyDocuments() + { + var record = new Record(); + var copy = new Record(); + mapper.Expect(m => m.Equals(record, copy)).Return(false); + mapper.Expect(m => m.ToDocument(Arg.Is.Same(record), Arg.Is.NotNull)); + session.DocumentTracker.TrackDocument(record, copy); + record.Id = "1"; + + session.StageModifiedDocuments(); + + Assert.That(session.PendingChanges, Is.True, "Should detect modified document."); + } + + [Test] + public void Dispose_Commits() + { + writer.Expect(w => w.DeleteAll()); + writer.Expect(w => w.Commit()); + + session.DeleteAll(); + session.Dispose(); + } + + [Test] + public void Commit_RollbackException_ThrowsAggregateException() + { + var ex1 = new Exception("ex1"); + var ex2 = new Exception("ex2"); + writer.Expect(w => w.DeleteAll()).Throw(ex1); + writer.Expect(w => w.Rollback()).Throw(ex2); + + session.DeleteAll(); + + var thrown = Assert.Throws(session.Commit); + Assert.That(thrown.InnerExceptions, Is.EquivalentTo(new[] {ex1, ex2})); + } + private void Verify() { mapper.VerifyAllExpectations(); diff --git a/Lucene.Net.Linq.Tests/Mapping/ReflectionDocumentMapperTests.cs b/Lucene.Net.Linq.Tests/Mapping/ReflectionDocumentMapperTests.cs index 365c58bd2..9abe5a55e 100644 --- a/Lucene.Net.Linq.Tests/Mapping/ReflectionDocumentMapperTests.cs +++ b/Lucene.Net.Linq.Tests/Mapping/ReflectionDocumentMapperTests.cs @@ -8,6 +8,9 @@ namespace Lucene.Net.Linq.Tests.Mapping [TestFixture] public class ReflectionDocumentMapperTests { + private ReflectedDocument item1 = new ReflectedDocument { Id = "1", Version = new Version("1.2.3.4"), Location = "New York", Name = "Fun things", Number = 12 }; + private ReflectedDocument item2 = new ReflectedDocument { Id = "1", Version = new Version("1.2.3.4"), Location = "New York", Name = "Fun things", Number = 12 }; + [Test] public void CtrFindsKeyFields() { @@ -50,6 +53,34 @@ public void ToKey_NotEqual() Assert.That(key1, Is.Not.EqualTo(key2)); } + [Test] + public void Documents_Equal() + { + var mapper = new ReflectionDocumentMapper(); + + Assert.That(mapper.Equals(item1, item2), Is.True); + } + + [Test] + public void Documents_Equal_IgnoredField() + { + var mapper = new ReflectionDocumentMapper(); + + item1.IgnoreMe = "different"; + + Assert.That(mapper.Equals(item1, item2), Is.True); + } + + [Test] + public void Documents_Equal_Not() + { + var mapper = new ReflectionDocumentMapper(); + + item1.Version = new Version("5.6.7.8"); + + Assert.That(mapper.Equals(item1, item2), Is.False); + } + public class ReflectedDocument { [Field(Key = true)] @@ -65,6 +96,9 @@ public class ReflectedDocument [NumericField(Key = true)] public int Number { get; set; } + + [IgnoreField] + public string IgnoreMe { get; set; } } } diff --git a/Lucene.Net.Linq.Tests/Translation/QueryModelTranslatorTests.cs b/Lucene.Net.Linq.Tests/Translation/QueryModelTranslatorTests.cs index a5d14aba4..c51327630 100644 --- a/Lucene.Net.Linq.Tests/Translation/QueryModelTranslatorTests.cs +++ b/Lucene.Net.Linq.Tests/Translation/QueryModelTranslatorTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq.Expressions; using Lucene.Net.Analysis; +using Lucene.Net.Linq.Clauses; using Lucene.Net.Linq.Clauses.Expressions; using Lucene.Net.Linq.Mapping; using Lucene.Net.Linq.Search; @@ -150,7 +151,6 @@ public void ConvertsToSort_MultipleClauses() mappingInfo.Expect(m => m.GetMappingInfo("Id")).Return(numericMappingInfo); nonNumericMappingInfo.Stub(i => i.FieldName).Return("Name"); numericMappingInfo.Stub(i => i.FieldName).Return("Id"); - var orderByClause = new OrderByClause(); orderByClause.Orderings.Add(new Ordering(new LuceneQueryFieldExpression(typeof(string), "Name"), OrderingDirection.Asc)); @@ -167,6 +167,15 @@ public void ConvertsToSort_MultipleClauses() AssertSortFieldEquals(transformer.Model.Sort.GetSort()[1], "Id", OrderingDirection.Desc, SortField.LONG); } + [Test] + public void SetsDocumentTracker() + { + var expr = Expression.Constant(this); + transformer.VisitTrackRetrievedDocumentsClause(new TrackRetrievedDocumentsClause(expr), queryModel, 0); + + Assert.That(transformer.Model.DocumentTracker, Is.SameAs(expr.Value)); + } + private void AssertSortFieldEquals(SortField sortField, string expectedFieldName, OrderingDirection expectedDirection, int expectedType) { Assert.That(sortField.GetField(), Is.EqualTo(expectedFieldName)); diff --git a/Lucene.Net.Linq/Clauses/BoostClause.cs b/Lucene.Net.Linq/Clauses/BoostClause.cs index eacd8e392..8d9c7dce4 100644 --- a/Lucene.Net.Linq/Clauses/BoostClause.cs +++ b/Lucene.Net.Linq/Clauses/BoostClause.cs @@ -1,46 +1,33 @@ -using System; -using System.Linq.Expressions; -using Lucene.Net.Linq.Translation; +using System.Linq.Expressions; using Remotion.Linq; using Remotion.Linq.Clauses; namespace Lucene.Net.Linq.Clauses { - internal class BoostClause : IBodyClause + internal class BoostClause : ExtensionClause { - private LambdaExpression boostFunction; - - internal BoostClause(LambdaExpression boostFunction) + public BoostClause(LambdaExpression expression) : base(expression) { - this.boostFunction = boostFunction; } public LambdaExpression BoostFunction { - get { return boostFunction; } + get { return expression; } } - public void TransformExpressions(Func transformation) + protected override void Accept(ILuceneQueryModelVisitor visitor, QueryModel queryModel, int index) { - boostFunction = transformation(boostFunction) as LambdaExpression; - } - - public void Accept(IQueryModelVisitor visitor, QueryModel queryModel, int index) - { - var customVisitor = visitor as ILuceneQueryModelVisitor; - if (customVisitor == null) return; - - customVisitor.VisitBoostClause(this, queryModel, index); + visitor.VisitBoostClause(this, queryModel, index); } - public IBodyClause Clone(CloneContext cloneContext) + public override IBodyClause Clone(CloneContext cloneContext) { - return new BoostClause(boostFunction); + return new BoostClause(expression); } public override string ToString() { - return "boost " + boostFunction; + return "boost " + expression; } } } diff --git a/Lucene.Net.Linq/Clauses/ExpressionNodes/BoostExpressionNode.cs b/Lucene.Net.Linq/Clauses/ExpressionNodes/BoostExpressionNode.cs index 8dc6974c4..00b2696d0 100644 --- a/Lucene.Net.Linq/Clauses/ExpressionNodes/BoostExpressionNode.cs +++ b/Lucene.Net.Linq/Clauses/ExpressionNodes/BoostExpressionNode.cs @@ -8,9 +8,10 @@ namespace Lucene.Net.Linq.Clauses.ExpressionNodes internal class BoostExpressionNode : MethodCallExpressionNodeBase { public static readonly MethodInfo[] SupportedMethods = new[] - { - GetSupportedMethod (() => LuceneMethods.BoostInternal (null, null)) - }; + { + GetSupportedMethod (() => LuceneMethods.BoostInternal (null, null)) + }; + private readonly LambdaExpression boostFunction; public BoostExpressionNode(MethodCallExpressionParseInfo parseInfo, LambdaExpression boostFunction) : base(parseInfo) diff --git a/Lucene.Net.Linq/Clauses/ExpressionNodes/TrackRetrievedDocumentsExpressionNode.cs b/Lucene.Net.Linq/Clauses/ExpressionNodes/TrackRetrievedDocumentsExpressionNode.cs new file mode 100644 index 000000000..85e163ad4 --- /dev/null +++ b/Lucene.Net.Linq/Clauses/ExpressionNodes/TrackRetrievedDocumentsExpressionNode.cs @@ -0,0 +1,34 @@ +using System.Linq.Expressions; +using System.Reflection; +using Remotion.Linq; +using Remotion.Linq.Parsing.Structure.IntermediateModel; + +namespace Lucene.Net.Linq.Clauses.ExpressionNodes +{ + internal class TrackRetrievedDocumentsExpressionNode : MethodCallExpressionNodeBase + { + public static readonly MethodInfo[] SupportedMethods = new[] + { + GetSupportedMethod (() => LuceneMethods.TrackRetrievedDocuments(null, null)) + }; + + private readonly ConstantExpression tracker; + + public TrackRetrievedDocumentsExpressionNode(MethodCallExpressionParseInfo parseInfo, ConstantExpression tracker) + : base(parseInfo) + { + this.tracker = tracker; + } + + public override Expression Resolve(ParameterExpression inputParameter, Expression expressionToBeResolved, ClauseGenerationContext clauseGenerationContext) + { + return Source.Resolve(inputParameter, expressionToBeResolved, clauseGenerationContext); + } + + protected override QueryModel ApplyNodeSpecificSemantics(QueryModel queryModel, ClauseGenerationContext clauseGenerationContext) + { + queryModel.BodyClauses.Add(new TrackRetrievedDocumentsClause(tracker)); + return queryModel; + } + } +} \ No newline at end of file diff --git a/Lucene.Net.Linq/Clauses/ExtensionClause.cs b/Lucene.Net.Linq/Clauses/ExtensionClause.cs new file mode 100644 index 000000000..c23645c01 --- /dev/null +++ b/Lucene.Net.Linq/Clauses/ExtensionClause.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq.Expressions; +using Remotion.Linq; +using Remotion.Linq.Clauses; + +namespace Lucene.Net.Linq.Clauses +{ + internal abstract class ExtensionClause : IBodyClause where T : Expression + { + protected T expression; + + internal ExtensionClause(T expression) + { + this.expression = expression; + } + + public void TransformExpressions(Func transformation) + { + expression = transformation(expression) as T; + } + + public void Accept(IQueryModelVisitor visitor, QueryModel queryModel, int index) + { + var customVisitor = visitor as ILuceneQueryModelVisitor; + if (customVisitor == null) return; + + Accept(customVisitor, queryModel, index); + } + + public abstract IBodyClause Clone(CloneContext cloneContext); + + protected abstract void Accept(ILuceneQueryModelVisitor visitor, QueryModel queryModel, int index); + } +} \ No newline at end of file diff --git a/Lucene.Net.Linq/Clauses/TrackRetrievedDocumentsClause.cs b/Lucene.Net.Linq/Clauses/TrackRetrievedDocumentsClause.cs new file mode 100644 index 000000000..0fcfe7f16 --- /dev/null +++ b/Lucene.Net.Linq/Clauses/TrackRetrievedDocumentsClause.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; +using Remotion.Linq; +using Remotion.Linq.Clauses; + +namespace Lucene.Net.Linq.Clauses +{ + internal class TrackRetrievedDocumentsClause : ExtensionClause + { + public TrackRetrievedDocumentsClause(ConstantExpression expression) + : base(expression) + { + } + + public ConstantExpression Tracker + { + get { return expression; } + } + + protected override void Accept(ILuceneQueryModelVisitor visitor, QueryModel queryModel, int index) + { + visitor.VisitTrackRetrievedDocumentsClause(this, queryModel, index); + } + + public override IBodyClause Clone(CloneContext cloneContext) + { + return new TrackRetrievedDocumentsClause(expression); + } + } +} \ No newline at end of file diff --git a/Lucene.Net.Linq/ILuceneQueryModelVisitor.cs b/Lucene.Net.Linq/ILuceneQueryModelVisitor.cs index 6f8800ca8..be92287e9 100644 --- a/Lucene.Net.Linq/ILuceneQueryModelVisitor.cs +++ b/Lucene.Net.Linq/ILuceneQueryModelVisitor.cs @@ -6,5 +6,6 @@ namespace Lucene.Net.Linq internal interface ILuceneQueryModelVisitor : IQueryModelVisitor { void VisitBoostClause(BoostClause boostClause, QueryModel queryModel, int index); + void VisitTrackRetrievedDocumentsClause(TrackRetrievedDocumentsClause trackRetrievedDocumentsClause, QueryModel queryModel, int index); } } \ No newline at end of file diff --git a/Lucene.Net.Linq/IRetrievedDocumentTracker.cs b/Lucene.Net.Linq/IRetrievedDocumentTracker.cs new file mode 100644 index 000000000..4a59693fc --- /dev/null +++ b/Lucene.Net.Linq/IRetrievedDocumentTracker.cs @@ -0,0 +1,7 @@ +namespace Lucene.Net.Linq +{ + internal interface IRetrievedDocumentTracker + { + void TrackDocument(T item, T hiddenCopy); + } +} \ No newline at end of file diff --git a/Lucene.Net.Linq/ISession.cs b/Lucene.Net.Linq/ISession.cs index 95535ba5f..816fcf9fe 100644 --- a/Lucene.Net.Linq/ISession.cs +++ b/Lucene.Net.Linq/ISession.cs @@ -1,10 +1,12 @@ using System; +using System.Linq; using Lucene.Net.Search; namespace Lucene.Net.Linq { - public interface ISession : IDisposable + public interface ISession : IDisposable { + IQueryable Query(); void Add(params T[] items); void Delete(params T[] items); void Delete(params Query[] items); diff --git a/Lucene.Net.Linq/Lucene.Net.Linq.csproj b/Lucene.Net.Linq/Lucene.Net.Linq.csproj index 540ead4bc..7be430036 100644 --- a/Lucene.Net.Linq/Lucene.Net.Linq.csproj +++ b/Lucene.Net.Linq/Lucene.Net.Linq.csproj @@ -63,6 +63,7 @@ + @@ -72,7 +73,10 @@ + + + @@ -111,7 +115,6 @@ - @@ -152,4 +155,4 @@ --> - + \ No newline at end of file diff --git a/Lucene.Net.Linq/Lucene.Net.Linq.nuspec b/Lucene.Net.Linq/Lucene.Net.Linq.nuspec index 1063e9a2f..7b3653117 100644 --- a/Lucene.Net.Linq/Lucene.Net.Linq.nuspec +++ b/Lucene.Net.Linq/Lucene.Net.Linq.nuspec @@ -14,7 +14,7 @@ lucene.net lucene linq odata search nosql Provides LINQ IQueryable interface over a Lucene.Net index. Execute LINQ queries on Lucene.Net complete with object to Document mapping. - Fixes thread safety and reuse issues. + ISession implements unit-of-work pattern, automatically flushing modified documents. diff --git a/Lucene.Net.Linq/LuceneDataProvider.cs b/Lucene.Net.Linq/LuceneDataProvider.cs index 438e8bc15..5232bf120 100644 --- a/Lucene.Net.Linq/LuceneDataProvider.cs +++ b/Lucene.Net.Linq/LuceneDataProvider.cs @@ -84,15 +84,24 @@ public IQueryable AsQueryable() where T : new() /// Factory method to instantiate new instances of T. public IQueryable AsQueryable(Func factory) { - var executor = new LuceneQueryExecutor(context, factory, new ReflectionDocumentMapper()); - return new LuceneQueryable(queryParser, executor); + return CreateQueryable(factory, new ReflectionDocumentMapper()); + } + + /// + /// Opens a session for staging changes and then committing them atomically. + /// + /// The type of object that will be mapped to . + public ISession OpenSession() where T : new() + { + return OpenSession(() => new T()); } /// /// Opens a session for staging changes and then committing them atomically. /// + /// Factory delegate that creates new instances of /// The type of object that will be mapped to . - public ISession OpenSession() + public ISession OpenSession(Func factory) { if (context.IsReadOnly) { @@ -100,7 +109,13 @@ public ISession OpenSession() } var mapper = new ReflectionDocumentMapper(); - return new LuceneSession(mapper, context); + return new LuceneSession(mapper, context, CreateQueryable(factory, mapper)); + } + + private LuceneQueryable CreateQueryable(Func factory, IDocumentMapper mapper) + { + var executor = new LuceneQueryExecutor(context, factory, mapper); + return new LuceneQueryable(queryParser, executor); } } -} \ No newline at end of file +} diff --git a/Lucene.Net.Linq/LuceneMethods.cs b/Lucene.Net.Linq/LuceneMethods.cs index f7ac78e91..1e4622be0 100644 --- a/Lucene.Net.Linq/LuceneMethods.cs +++ b/Lucene.Net.Linq/LuceneMethods.cs @@ -53,6 +53,13 @@ internal static IQueryable BoostInternal(this IQueryable source, Expres source.Expression, boostFunction)); } + internal static IQueryable TrackRetrievedDocuments(this IQueryable source, IRetrievedDocumentTracker tracker) + { + return source.Provider.CreateQuery( + Expression.Call(((MethodInfo)MethodBase.GetCurrentMethod()).MakeGenericMethod(typeof(T)), + source.Expression, Expression.Constant(tracker))); + } + /// /// Applies the provided Query. Enables queries to be constructed from outside of /// LINQ to be executed as part of a LINQ query. diff --git a/Lucene.Net.Linq/LuceneQueryExecutor.cs b/Lucene.Net.Linq/LuceneQueryExecutor.cs index ed650571d..047410e2b 100644 --- a/Lucene.Net.Linq/LuceneQueryExecutor.cs +++ b/Lucene.Net.Linq/LuceneQueryExecutor.cs @@ -138,9 +138,22 @@ public IEnumerable ExecuteCollection(QueryModel queryModel) if (skipResults < 0) yield break; } + var tracker = luceneQueryModel.DocumentTracker as IRetrievedDocumentTracker; + for (var i = skipResults; i < hits.ScoreDocs.Length; i++) { - itemHolder.Current = ConvertDocument(searcher.Doc(hits.ScoreDocs[i].doc), hits.ScoreDocs[i].score); + var doc = hits.ScoreDocs[i].doc; + var score = hits.ScoreDocs[i].score; + + var item = ConvertDocument(searcher.Doc(doc), score); + + if (tracker != null) + { + var copy = ConvertDocument(searcher.Doc(doc), score); + tracker.TrackDocument(item, copy); + } + + itemHolder.Current = item; yield return projector(itemHolder.Current); } } diff --git a/Lucene.Net.Linq/LuceneQueryModel.cs b/Lucene.Net.Linq/LuceneQueryModel.cs index db59d17fa..e00381c89 100644 --- a/Lucene.Net.Linq/LuceneQueryModel.cs +++ b/Lucene.Net.Linq/LuceneQueryModel.cs @@ -43,7 +43,9 @@ public Sort Sort public Expression SelectClause { get; set; } public StreamedSequenceInfo OutputDataInfo { get; set; } public ResultOperatorBase ResultSetOperator { get; private set; } - + + public object DocumentTracker { get; set; } + public void AddQuery(Query additionalQuery) { if (query == null) @@ -161,17 +163,17 @@ public void AddBoostFunction(LambdaExpression expression) { if (customScoreFunction == null) return null; - var invocationList = customScoreFunction.GetInvocationList(); + var invocationList = customScoreFunction.GetInvocationList().Cast>().ToArray(); if (invocationList.Length == 1) { - return (Func)customScoreFunction; + return invocationList[0]; } return delegate(TDocument document) { var score = 1.0f; - foreach (Func func in invocationList) + foreach (var func in invocationList) { score = score * func(document); } diff --git a/Lucene.Net.Linq/LuceneSession.cs b/Lucene.Net.Linq/LuceneSession.cs index b5029a210..39846a808 100644 --- a/Lucene.Net.Linq/LuceneSession.cs +++ b/Lucene.Net.Linq/LuceneSession.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Common.Logging; using Lucene.Net.Documents; using Lucene.Net.Linq.Mapping; using Lucene.Net.Linq.Util; @@ -10,40 +11,44 @@ namespace Lucene.Net.Linq { internal class LuceneSession : ISession { + private readonly ILog Log = LogManager.GetCurrentClassLogger(); + private readonly object sessionLock = new object(); private readonly IDocumentMapper mapper; private readonly Context context; - - private readonly IDictionary additions = new Dictionary(); + private readonly IQueryable queryable; + + private readonly List additions = new List(); private readonly List deleteQueries = new List(); private readonly ISet deleteKeys = new HashSet(); - public LuceneSession(IDocumentMapper mapper, Context context) + private readonly SessionDocumentTracker documentTracker; + + public LuceneSession(IDocumentMapper mapper, Context context, IQueryable queryable) { this.mapper = mapper; this.context = context; + this.queryable = queryable; + documentTracker = new SessionDocumentTracker(mapper); } - public void Add(params T[] items) + public IQueryable Query() { - lock (sessionLock) - { - foreach (var item in items) - { - var key = mapper.ToKey(item); - var doc = new Document(); + return queryable.TrackRetrievedDocuments(documentTracker); + } - mapper.ToDocument(item, doc); - - additions[key] = doc; - } - } + public void Add(params T[] items) + { + Add((IEnumerable) items); } - internal void Add(DocumentKey key, Document doc) + public void Add(IEnumerable items) { - additions[key] = doc; + lock (sessionLock) + { + additions.AddRange(items); + } } public void Delete(params T[] items) @@ -52,6 +57,7 @@ public void Delete(params T[] items) { foreach (var item in items) { + additions.Remove(item); var key = mapper.ToKey(item); if (key.Empty) { @@ -77,10 +83,17 @@ public void DeleteAll() } } + public void Dispose() + { + Commit(); + } + public void Commit() { lock (sessionLock) { + StageModifiedDocuments(); + if (!PendingChanges) { return; @@ -92,24 +105,40 @@ public void Commit() { CommitInternal(); } - catch (OutOfMemoryException) + catch (OutOfMemoryException ex) { - context.IndexWriter.Dispose(); + Log.Error(m => m("OutOfMemoryException while writing/committing to Lucene index. Closing writer."), ex); + try + { + context.IndexWriter.Dispose(); + } + catch (Exception ex2) + { + Log.Error(m => m("Exception in IndexWriter.Dispose"), ex2); + throw new AggregateException(ex, ex2); + } + throw; } - catch (Exception) + catch (Exception ex) { - context.IndexWriter.Rollback(); + Log.Error(m => m("Exception in commit"), ex); + try + { + context.IndexWriter.Rollback(); + } + catch (Exception ex2) + { + Log.Error(m => m("Exception in rollback"), ex2); + throw new AggregateException(ex, ex2); + } + throw; } } } } - public void Dispose() - { - } - private void CommitInternal() { var writer = context.IndexWriter; @@ -124,16 +153,18 @@ private void CommitInternal() deletes = Deletions; } - deletes = deletes.Union(additions.Keys.Where(k => !k.Empty).Select(k => k.ToQuery(context.Analyzer, context.Version))); + var additionMap = ConvertPendingAdditions(); + + deletes = deletes.Union(additionMap.Keys.Where(k => !k.Empty).Select(k => k.ToQuery(context.Analyzer, context.Version))); if (deletes.Any()) { writer.DeleteDocuments(deletes.Distinct().ToArray()); } - if (additions.Count > 0) + if (additionMap.Count > 0) { - additions.Values.Apply(writer.AddDocument); + additionMap.Values.Apply(writer.AddDocument); } writer.Commit(); @@ -143,6 +174,16 @@ private void CommitInternal() context.Reload(); } + internal void StageModifiedDocuments() + { + var docs = documentTracker.FindModifiedDocuments(); + foreach (var doc in docs) + { + Log.Debug(m => m("Flushing modified document " + doc)); + Add(doc); + } + } + private void ClearPendingChanges() { DeleteAllFlag = false; @@ -156,9 +197,61 @@ internal bool PendingChanges get { return DeleteAllFlag || additions.Count > 0 || deleteQueries.Count > 0 || deleteKeys.Count > 0; } } - internal bool DeleteAllFlag { get; private set; } + internal IRetrievedDocumentTracker DocumentTracker { get { return documentTracker; } } - internal IEnumerable Additions { get { return new List(additions.Values); } } + internal bool DeleteAllFlag { get; private set; } + internal IEnumerable Deletions { get { return deleteQueries.Union(deleteKeys.Select(k => k.ToQuery(context.Analyzer, context.Version))); } } + + internal List Additions + { + get { return additions; } + } + + internal IDictionary ConvertPendingAdditions() + { + var map = new Dictionary(); + var reverse = new List(additions); + reverse.Reverse(); + + foreach (var item in reverse) + { + var key = mapper.ToKey(item); + if (!map.ContainsKey(key)) + { + map[key] = ToDocument(item); + } + } + + return map; + } + + private Document ToDocument(T i) + { + var doc = new Document(); + mapper.ToDocument(i, doc); + return doc; + } + + internal class SessionDocumentTracker : IRetrievedDocumentTracker + { + private readonly IDocumentMapper mapper; + private readonly IList> items = new List>(); + + public SessionDocumentTracker(IDocumentMapper mapper) + { + this.mapper = mapper; + } + + public void TrackDocument(T item, T hiddenCopy) + { + items.Add(new Tuple(item, hiddenCopy)); + } + + public IEnumerable FindModifiedDocuments() + { + return items.Where(t => !mapper.Equals(t.Item1, t.Item2)).Select(t => t.Item1); + } + } } } \ No newline at end of file diff --git a/Lucene.Net.Linq/Mapping/ReflectionDocumentMapper.cs b/Lucene.Net.Linq/Mapping/ReflectionDocumentMapper.cs index 8f714260a..708c3f028 100644 --- a/Lucene.Net.Linq/Mapping/ReflectionDocumentMapper.cs +++ b/Lucene.Net.Linq/Mapping/ReflectionDocumentMapper.cs @@ -18,6 +18,7 @@ internal interface IDocumentMapper : IFieldMappingInfoProvider void ToObject(Document source, float score, T target); void ToDocument(T source, Document target); DocumentKey ToKey(T source); + bool Equals(T item1, T item2); bool EnableScoreTracking { get; } } @@ -94,5 +95,18 @@ public List> KeyFields { get { return new List>(keyFields); } } + + public bool Equals(T item1, T item2) + { + foreach (var field in fieldMap.Values) + { + var val1 = field.PropertyInfo.GetValue(item1, null); + var val2 = field.PropertyInfo.GetValue(item2, null); + + if (!Equals(val1, val2)) return false; + } + + return true; + } } } \ No newline at end of file diff --git a/Lucene.Net.Linq/Translation/QueryModelTranslator.cs b/Lucene.Net.Linq/Translation/QueryModelTranslator.cs index dee54cd8e..660f83618 100644 --- a/Lucene.Net.Linq/Translation/QueryModelTranslator.cs +++ b/Lucene.Net.Linq/Translation/QueryModelTranslator.cs @@ -75,5 +75,10 @@ public void VisitBoostClause(BoostClause boostClause, QueryModel queryModel, int { model.AddBoostFunction(boostClause.BoostFunction); } + + public void VisitTrackRetrievedDocumentsClause(TrackRetrievedDocumentsClause trackRetrievedDocumentsClause, QueryModel queryModel, int index) + { + model.DocumentTracker = trackRetrievedDocumentsClause.Tracker.Value; + } } } \ No newline at end of file diff --git a/README.markdown b/README.markdown index 10ee73169..183140d14 100644 --- a/README.markdown +++ b/README.markdown @@ -5,6 +5,8 @@ Lucene.Net.Linq is a .net library that enables LINQ queries to run natively on a * Automatically converts PONOs to Documents and back * Add, delete and update documents in atomic transaction +* Unit of Work pattern automatically tracks and flushes updated documents +* Update/replace documents with [Field(Key=true)] to prevent duplicates * Term queries * Prefix queries * Range queries and numeric range queries @@ -80,17 +82,14 @@ Next, create LuceneDataProvider and run some queries: using (var session = provider.OpenSession
()) { session.Add(new Article { Author = "John Doe", BodyText = "some body text", PublishDate = DateTimeOffset.UtcNow }); - session.Commit(); } - writer.Commit(); - var articles = provider.AsQueryable
(); var threshold = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromDays(30)); var articlesByJohn = from a in articles - where a.Author == "John Smith" && a.PublishDate > threshold + where a.Author == "John Doe" && a.PublishDate > threshold orderby a.Title select a; @@ -107,7 +106,6 @@ generally be analyzed using a stemming analyzer, but fields like Id, IssueNumber Upcoming features ----------------- -* Support "primary key" to replace existing documents to prevent duplicates * Ability to specify optional cache warming queries to run when searcher is reloaded * Support for more LINQ expressions * Optimize sorting complex types stored as string fields when the strings are sortable