Skip to content

Commit 40cd997

Browse files
committed
added nullable
1 parent f5d0682 commit 40cd997

File tree

14 files changed

+207
-16
lines changed

14 files changed

+207
-16
lines changed

example/Dto/UserDto.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public record UserDto
99
public string Lastname { get; set; } = string.Empty;
1010
public int Age { get; set; }
1111
public double Test { get; set; }
12+
public int? NullableInt { get; set; }
1213
public DateTime DateOfBirthUtc { get; set; }
1314
public DateTime DateOfBirthTz { get; set; }
1415
}

example/Entities/User.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public record User
88
public int Age { get; set; }
99
public bool IsDeleted { get; set; }
1010
public double Test { get; set; }
11+
public int? NullableInt { get; set; }
1112

1213
[Column(TypeName = "timestamp with time zone")]
1314
public DateTime DateOfBirthUtc { get; set; }

example/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
.RuleFor(x => x.Age, f => f.Random.Int(0, 100))
4242
.RuleFor(x => x.IsDeleted, f => f.Random.Bool())
4343
.RuleFor(x => x.Test, f => f.Random.Double())
44+
.RuleFor(x => x.NullableInt, f => f.Random.Bool() ? f.Random.Int(1, 100) : null)
4445
.Rules((f, u) =>
4546
{
4647
var timeZone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");

example/example.csproj

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
4-
<TargetFramework>net8.0</TargetFramework>
4+
<TargetFramework>net9.0</TargetFramework>
55
<Nullable>enable</Nullable>
66
<ImplicitUsings>enable</ImplicitUsings>
77
</PropertyGroup>
88

99
<ItemGroup>
1010
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
11-
<PackageReference Include="Bogus" Version="35.5.1" />
12-
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
13-
<PackageReference Include="Testcontainers" Version="3.8.0" />
14-
<PackageReference Include="Testcontainers.PostgreSql" Version="3.8.0" />
11+
<PackageReference Include="Bogus" Version="35.6.3" />
12+
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
13+
<PackageReference Include="Testcontainers" Version="4.5.0" />
14+
<PackageReference Include="Testcontainers.PostgreSql" Version="4.5.0" />
1515
</ItemGroup>
1616

1717
<ItemGroup>

src/GoatQuery/src/Ast/Literals.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,11 @@ public DateLiteral(Token token, DateTime value) : base(token)
7878
{
7979
Value = value;
8080
}
81+
}
82+
83+
public sealed class NullLiteral : QueryExpression
84+
{
85+
public NullLiteral(Token token) : base(token)
86+
{
87+
}
8188
}

src/GoatQuery/src/Evaluator/FilterEvaluator.cs

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,32 @@ public static Result<Expression> Evaluate(QueryExpression expression, ParameterE
5252
value = Expression.Constant(literal.Value, property.Type);
5353
break;
5454
case DateLiteral literal:
55-
property = property.Type == typeof(DateTime?) ?
56-
Expression.Property(Expression.Property(property, "Value"), "Date") :
57-
Expression.Property(property, "Date");
58-
59-
value = Expression.Constant(literal.Value.Date, property.Type);
55+
if (property.Type == typeof(DateTime?))
56+
{
57+
// For nullable DateTime, we need to handle this differently in the operator switch
58+
// Just set up the date value for now
59+
value = Expression.Constant(literal.Value.Date, typeof(DateTime));
60+
}
61+
else
62+
{
63+
property = Expression.Property(property, "Date");
64+
value = Expression.Constant(literal.Value.Date, property.Type);
65+
}
66+
break;
67+
case NullLiteral _:
68+
value = Expression.Constant(null, property.Type);
6069
break;
6170
default:
6271
return Result.Fail($"Unsupported literal type: {exp.Right.GetType().Name}");
6372
}
6473

74+
// Check if we need special nullable handling
75+
var specialComparison = CreateNullableComparison(property, value, exp.Right, exp.Operator);
76+
if (specialComparison != null)
77+
{
78+
return specialComparison;
79+
}
80+
6581
switch (exp.Operator)
6682
{
6783
case Keywords.Eq:
@@ -118,6 +134,54 @@ public static Result<Expression> Evaluate(QueryExpression expression, ParameterE
118134
return null;
119135
}
120136

137+
/// <summary>
138+
/// Creates special nullable property comparisons for types that need property transformation.
139+
/// Returns null if no special handling is needed.
140+
/// </summary>
141+
private static Expression CreateNullableComparison(MemberExpression property, ConstantExpression value, QueryExpression rightExpression, string operatorKeyword)
142+
{
143+
// Handle nullable DateTime with DateLiteral - requires .Date property access
144+
if (property.Type == typeof(DateTime?) && rightExpression is DateLiteral)
145+
{
146+
return CreateNullableDateComparison(property, value, operatorKeyword);
147+
}
148+
149+
// Future extensibility: Add other type transformations here
150+
// if (property.Type == typeof(TimeOnly?) && rightExpression is TimeLiteral)
151+
// {
152+
// return CreateNullableTimeComparison(property, value, operatorKeyword);
153+
// }
154+
155+
return null;
156+
}
157+
158+
/// <summary>
159+
/// Creates nullable DateTime comparisons that safely access the .Date property.
160+
/// </summary>
161+
private static Expression CreateNullableDateComparison(MemberExpression property, ConstantExpression value, string operatorKeyword)
162+
{
163+
var hasValueProperty = Expression.Property(property, "HasValue");
164+
var valueProperty = Expression.Property(property, "Value");
165+
var dateProperty = Expression.Property(valueProperty, "Date");
166+
167+
Expression dateComparison = operatorKeyword switch
168+
{
169+
Keywords.Eq => Expression.Equal(dateProperty, value),
170+
Keywords.Ne => Expression.NotEqual(dateProperty, value),
171+
Keywords.Lt => Expression.LessThan(dateProperty, value),
172+
Keywords.Lte => Expression.LessThanOrEqual(dateProperty, value),
173+
Keywords.Gt => Expression.GreaterThan(dateProperty, value),
174+
Keywords.Gte => Expression.GreaterThanOrEqual(dateProperty, value),
175+
_ => throw new ArgumentException($"Unsupported operator for nullable date comparison: {operatorKeyword}")
176+
};
177+
178+
// For inequality, we want: !HasValue OR HasValue && comparison != true
179+
// For others, we want: HasValue && comparison == true
180+
return operatorKeyword == Keywords.Ne
181+
? Expression.OrElse(Expression.Not(hasValueProperty), dateComparison)
182+
: Expression.AndAlso(hasValueProperty, dateComparison);
183+
}
184+
121185
private static Result<ConstantExpression> GetIntegerExpressionConstant(int value, Type targetType)
122186
{
123187
try

src/GoatQuery/src/GoatQuery.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
<ItemGroup>
2323
<PackageReference Include="FluentResults" Version="3.16.0" />
24-
<PackageReference Include="System.Text.Json" Version="8.0.4" />
24+
<PackageReference Include="System.Text.Json" Version="9.0.6" />
2525
</ItemGroup>
2626

2727
</Project>

src/GoatQuery/src/Lexer/Lexer.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ public Token NextToken()
6262
return token;
6363
}
6464

65+
if (token.Literal.Equals(Keywords.Null, StringComparison.OrdinalIgnoreCase))
66+
{
67+
token.Type = TokenType.NULL;
68+
return token;
69+
}
70+
6571
if (IsDigit(token.Literal[0]))
6672
{
6773
if (IsDate(token.Literal))

src/GoatQuery/src/Parser/Parser.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ private Result<InfixExpression> ParseFilterStatement()
140140

141141
var statement = new InfixExpression(_currentToken, identifier, _currentToken.Literal);
142142

143-
if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE))
143+
if (!PeekTokenIn(TokenType.STRING, TokenType.INT, TokenType.GUID, TokenType.DATETIME, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATE, TokenType.NULL))
144144
{
145145
return Result.Fail("Invalid value type within filter");
146146
}
@@ -152,6 +152,11 @@ private Result<InfixExpression> ParseFilterStatement()
152152
return Result.Fail("Value must be a string when using 'contains' operand");
153153
}
154154

155+
if (statement.Operator.Equals(Keywords.Contains) && _currentToken.Type == TokenType.NULL)
156+
{
157+
return Result.Fail("Cannot use 'contains' operand with null value");
158+
}
159+
155160
if (statement.Operator.In(Keywords.Lt, Keywords.Lte, Keywords.Gt, Keywords.Gte) && !CurrentTokenIn(TokenType.INT, TokenType.DECIMAL, TokenType.FLOAT, TokenType.DOUBLE, TokenType.DATETIME, TokenType.DATE))
156161
{
157162
return Result.Fail($"Value must be an integer when using '{statement.Operator}' operand");
@@ -210,6 +215,9 @@ private Result<InfixExpression> ParseFilterStatement()
210215
statement.Right = new DateLiteral(_currentToken, dateValue);
211216
}
212217
break;
218+
case TokenType.NULL:
219+
statement.Right = new NullLiteral(_currentToken);
220+
break;
213221
}
214222

215223
return statement;

src/GoatQuery/src/Token/Token.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public enum TokenType
1111
GUID,
1212
DATETIME,
1313
DATE,
14+
NULL,
1415
LPAREN,
1516
RPAREN,
1617
}
@@ -28,6 +29,7 @@ public static class Keywords
2829
internal const string Gte = "gte";
2930
internal const string And = "and";
3031
internal const string Or = "or";
32+
internal const string Null = "null";
3133
}
3234

3335
public sealed class Token

0 commit comments

Comments
 (0)