Skip to content

Commit 3dd93f6

Browse files
Improve items filtering (#167)
1 parent 1c30102 commit 3dd93f6

File tree

11 files changed

+1048
-40
lines changed

11 files changed

+1048
-40
lines changed

src/ByteSync.Client/Business/Filtering/Parsing/FilterParser.cs

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,35 +26,81 @@ public ParseResult TryParse(string filterText)
2626
_tokenizer.Initialize(filterText ?? string.Empty);
2727
CurrentToken = null;
2828
NextToken();
29-
29+
3030
if (string.IsNullOrWhiteSpace(filterText))
31+
{
3132
return ParseResult.Success(new TrueExpression());
33+
}
3234

33-
// Split by whitespace for simple text search
35+
// Check if this looks like a complex expression vs simple text search
36+
if (IsComplexExpression(filterText))
37+
{
38+
// Parse as complex expression
39+
return TryParseExpression();
40+
}
41+
else
42+
{
43+
// Handle as simple text search
44+
return CreateTextSearchExpression(filterText);
45+
}
46+
}
47+
48+
/// <summary>
49+
/// Determines if the filter text contains patterns that indicate a complex expression
50+
/// rather than a simple text search
51+
/// </summary>
52+
private bool IsComplexExpression(string filterText)
53+
{
54+
// Split by whitespace to analyze individual terms
3455
var terms = filterText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
3556

36-
// Check if there are any special expressions
37-
if (!terms.Any(t => t.Contains(":") || t.Contains(".") || t.Contains("(") ||
38-
t.StartsWith(Identifiers.OPERATOR_ACTIONS, StringComparison.OrdinalIgnoreCase) ||
39-
t.StartsWith(Identifiers.OPERATOR_NAME, StringComparison.OrdinalIgnoreCase) ||
40-
t.StartsWith(Identifiers.OPERATOR_PATH, StringComparison.OrdinalIgnoreCase) ||
41-
t.Equals("AND", StringComparison.OrdinalIgnoreCase) ||
42-
t.Equals("OR", StringComparison.OrdinalIgnoreCase) ||
43-
t.Equals("NOT", StringComparison.OrdinalIgnoreCase)))
44-
{
45-
// Simple text search
46-
FilterExpression compositeExpression = new TrueExpression();
47-
foreach (var term in terms)
48-
{
49-
var textExpression = new TextSearchExpression(term);
50-
compositeExpression = new AndExpression(compositeExpression, textExpression);
51-
}
57+
return terms.Any(term =>
58+
// Property access patterns
59+
term.Contains(':') ||
60+
term.Contains('.') ||
61+
62+
// Grouping
63+
term.Contains('(') ||
64+
term.Contains(')') ||
65+
66+
// Comparison operators (key improvement)
67+
term.Contains("==") ||
68+
term.Contains("!=") ||
69+
term.Contains(">=") ||
70+
term.Contains("<=") ||
71+
term.Contains('>') ||
72+
term.Contains('<') ||
73+
term.Contains("=~") ||
74+
term.Contains("<>") ||
75+
term.Contains('=') ||
76+
77+
// Special operators/keywords
78+
term.StartsWith(Identifiers.OPERATOR_ACTIONS, StringComparison.OrdinalIgnoreCase) ||
79+
term.StartsWith(Identifiers.OPERATOR_NAME, StringComparison.OrdinalIgnoreCase) ||
80+
term.StartsWith(Identifiers.OPERATOR_PATH, StringComparison.OrdinalIgnoreCase) ||
81+
82+
// Logical operators
83+
term.Equals("AND", StringComparison.OrdinalIgnoreCase) ||
84+
term.Equals("OR", StringComparison.OrdinalIgnoreCase) ||
85+
term.Equals("NOT", StringComparison.OrdinalIgnoreCase)
86+
);
87+
}
5288

53-
return ParseResult.Success(compositeExpression);
89+
/// <summary>
90+
/// Creates a text search expression for simple text queries
91+
/// </summary>
92+
private ParseResult CreateTextSearchExpression(string filterText)
93+
{
94+
var terms = filterText.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
95+
96+
FilterExpression compositeExpression = new TrueExpression();
97+
foreach (var term in terms)
98+
{
99+
var textExpression = new TextSearchExpression(term);
100+
compositeExpression = new AndExpression(compositeExpression, textExpression);
54101
}
55102

56-
// Otherwise, parse the expression
57-
return TryParseExpression();
103+
return ParseResult.Success(compositeExpression);
58104
}
59105

60106
private ParseResult TryParseExpression()
@@ -435,6 +481,13 @@ private ParseResult TryParseFactor()
435481
}
436482
else
437483
{
484+
// Check if this identifier is followed by an operator
485+
// This would indicate an incomplete property comparison (missing data source)
486+
if (CurrentToken?.Type == FilterTokenType.Operator)
487+
{
488+
return ParseResult.Incomplete($"Property '{identifier}' requires a data source prefix (e.g., A1.{identifier})");
489+
}
490+
438491
// Simple text search
439492
return ParseResult.Success(new TextSearchExpression(identifier));
440493
}

src/ByteSync.Client/Business/Filtering/Parsing/FilterTokenizer.cs

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using ByteSync.Interfaces.Services.Filtering;
2-
using ByteSync.Interfaces.Services.Sessions;
32

43
namespace ByteSync.Business.Filtering.Parsing;
54

@@ -8,7 +7,7 @@ public class FilterTokenizer : IFilterTokenizer
87
private string _filterText = null!;
98
private int _position;
109

11-
public void Initialize(string filterText)
10+
public void Initialize(string? filterText)
1211
{
1312
_filterText = filterText ?? string.Empty;
1413
_position = 0;
@@ -197,17 +196,13 @@ public FilterToken GetNextToken()
197196
var currentToken = _filterText.Substring(start, _position - start);
198197
FilterTokenType currentTokenType;
199198

200-
if (currentToken.Equals("AND", StringComparison.OrdinalIgnoreCase) ||
201-
currentToken.Equals("OR", StringComparison.OrdinalIgnoreCase) ||
202-
currentToken.Equals("NOT", StringComparison.OrdinalIgnoreCase) ||
203-
currentToken == "&&" || currentToken == "||")
199+
if (IsLogicalOperator(currentToken))
204200
{
205201
currentTokenType = FilterTokenType.LogicalOperator;
206202
}
207203
else
208204
{
209-
if ((char.IsLetter(currentToken[0]) &&
210-
(currentToken.Length == 1 || currentToken.Skip(1).All(char.IsDigit))) ||
205+
if (IsDataPartOrDataSourceIdentifier(currentToken) ||
211206
Identifiers.All().Any(name => currentToken.ToLower().Equals(name.ToLower())))
212207
{
213208
currentTokenType = FilterTokenType.Identifier;
@@ -225,4 +220,37 @@ public FilterToken GetNextToken()
225220
};
226221
}
227222
}
223+
224+
private static bool IsLogicalOperator(string token)
225+
{
226+
return token.Equals("AND", StringComparison.OrdinalIgnoreCase) ||
227+
token.Equals("OR", StringComparison.OrdinalIgnoreCase) ||
228+
token.Equals("NOT", StringComparison.OrdinalIgnoreCase) ||
229+
token == "&&" || token == "||";
230+
}
231+
232+
private static bool IsDataPartOrDataSourceIdentifier(string token)
233+
{
234+
if (string.IsNullOrEmpty(token) || token.Length > 3)
235+
{
236+
return false;
237+
}
238+
239+
if (!char.IsLetter(token[0]))
240+
{
241+
return false;
242+
}
243+
244+
if (token.Length == 1)
245+
{
246+
return true;
247+
}
248+
249+
if (token.Length == 2)
250+
{
251+
return char.IsLetter(token[1]) || char.IsDigit(token[1]);
252+
}
253+
254+
return char.IsLetter(token[1]) && char.IsDigit(token[2]);
255+
}
228256
}

src/ByteSync.Client/Interfaces/Services/Filtering/IFilterTokenizer.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ namespace ByteSync.Interfaces.Services.Filtering;
44

55
public interface IFilterTokenizer
66
{
7-
void Initialize(string filterText);
7+
void Initialize(string? filterText);
88

99
FilterToken GetNextToken();
1010
}

tests/ByteSync.Client.IntegrationTests/Business/Filtering/BaseTestFiltering.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ protected ComparisonItem PrepareComparisonWithOneContent(
234234
{
235235
var comparisonItem = CreateBasicComparisonItem(FileSystemTypes.File, "/" + fileName.TrimStart('/'), fileName);
236236

237-
string letter = dataPartId[0].ToString();
237+
var letter = new string(dataPartId.TakeWhile(char.IsLetter).ToArray());
238238

239239
var (fileDesc, inventoryPart) = CreateFileDescription(
240240
$"Id_{letter}",

tests/ByteSync.Client.IntegrationTests/Business/Filtering/TestFiltering.cs

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ public void TestParse_Complete_Expression()
3535
parseResult.Expression.Should().BeOfType<PropertyComparisonExpression>();
3636
}
3737

38+
[Test]
39+
public void TestParse_Complete_Expression_MultiDataNode()
40+
{
41+
// Arrange
42+
var filterText = "Aa1.contents==Ba1.contents";
43+
44+
ConfigureDataPartIndexer("Aa", "Ba");
45+
46+
// Act
47+
var parseResult = _filterParser.TryParse(filterText);
48+
49+
// Assert
50+
parseResult.IsComplete.Should().BeTrue();
51+
parseResult.Expression.Should().NotBeNull();
52+
parseResult.Expression.Should().BeOfType<PropertyComparisonExpression>();
53+
}
54+
3855
[Test]
3956
public void TestParse_CompleteLowerCase_Expression()
4057
{
@@ -276,19 +293,67 @@ public void TestFilterService_BuildFilter_ListWithIncompleteExpressions()
276293
filter2(comparisonItem).Should().BeTrue();
277294
}
278295

279-
private void ConfigureDataPartIndexer()
296+
[Test]
297+
public void TestParse_IncompleteExpression_WithOperator()
280298
{
299+
// Arrange
300+
var filterText = "size>300kb"; // Missing data source, should be detected as incomplete expression
301+
302+
ConfigureDataPartIndexer();
303+
304+
// Act
305+
var parseResult = _filterParser.TryParse(filterText);
306+
307+
// Assert
308+
parseResult.IsComplete.Should().BeFalse();
309+
parseResult.ErrorMessage.Should().NotBeNull();
310+
parseResult.ErrorMessage.Should().Contain("requires a data source prefix");
311+
}
312+
313+
[Test]
314+
public void TestParse_CompleteExpression_WithSource()
315+
{
316+
// Arrange
317+
var filterText = "A1.size>300kb"; // Complete expression with source
318+
319+
ConfigureDataPartIndexer();
320+
321+
// Act
322+
var parseResult = _filterParser.TryParse(filterText);
323+
324+
// Assert
325+
parseResult.IsComplete.Should().BeTrue();
326+
parseResult.Expression.Should().BeOfType<PropertyComparisonExpression>();
327+
}
328+
329+
private void ConfigureDataPartIndexer(string inventoryACode = "A", string inventoryBCode = "B")
330+
{
331+
inventoryACode = inventoryACode.ToUpperInvariant();
332+
inventoryBCode = inventoryBCode.ToUpperInvariant();
333+
281334
var mockDataPartIndexer = Container.Resolve<Mock<IDataPartIndexer>>();
282-
var inventoryA = new Inventory { InventoryId = "Id_A", Code = "A" };
283-
var inventoryB = new Inventory { InventoryId = "Id_B", Code = "B" };
335+
var inventoryA = new Inventory
336+
{
337+
InventoryId = $"Id_{inventoryACode}",
338+
Code = inventoryACode
339+
};
340+
var inventoryB = new Inventory
341+
{
342+
InventoryId = $"Id_{inventoryBCode}",
343+
Code = inventoryBCode
344+
};
284345

285-
var inventoryPartA = new InventoryPart(inventoryA, "/testRootA", FileSystemTypes.Directory);
286-
var inventoryPartB = new InventoryPart(inventoryB, "/testRootB", FileSystemTypes.Directory);
346+
var inventoryPartA = new InventoryPart(inventoryA, $"/testRoot{inventoryACode}",
347+
FileSystemTypes.Directory);
348+
var inventoryPartB = new InventoryPart(inventoryB, $"/testRoot{inventoryBCode}",
349+
FileSystemTypes.Directory);
287350

288-
var dataPartA = new DataPart("A1", inventoryPartA);
289-
var dataPartB = new DataPart("B1", inventoryPartB);
351+
var dataPartAName = $"{inventoryACode}1";
352+
var dataPartBName = $"{inventoryBCode}1";
353+
var dataPartA = new DataPart(dataPartAName, inventoryPartA);
354+
var dataPartB = new DataPart(dataPartBName, inventoryPartB);
290355

291-
mockDataPartIndexer.Setup(m => m.GetDataPart("A1")).Returns(dataPartA);
292-
mockDataPartIndexer.Setup(m => m.GetDataPart("B1")).Returns(dataPartB);
356+
mockDataPartIndexer.Setup(m => m.GetDataPart(dataPartAName)).Returns(dataPartA);
357+
mockDataPartIndexer.Setup(m => m.GetDataPart(dataPartBName)).Returns(dataPartB);
293358
}
294359
}

0 commit comments

Comments
 (0)