Skip to content

Commit 43feca4

Browse files
authored
Merge pull request #98 from tomasfabian/96-enable-the-specification-of-pseudocolumns-using-the-fluent-apis-model-builder
96 enable the specification of pseudocolumns using the fluent apis model builder
2 parents 10df090 + 0f8d9f5 commit 43feca4

File tree

11 files changed

+285
-10
lines changed

11 files changed

+285
-10
lines changed

Tests/ksqlDB.RestApi.Client.Tests/FluentAPI/Builders/ModelBuilderTests.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,9 +367,29 @@ public void AsStruct()
367367
var entityMetadata = ((IMetadataProvider)builder).GetEntities().FirstOrDefault(c => c.Type == typeof(Record));
368368
entityMetadata.Should().NotBeNull();
369369

370-
var metadata = entityMetadata!.FieldsMetadata.First(c => c.IsStruct && c.Path == "Headers");
370+
var metadata = entityMetadata!.FieldsMetadata.First(c => c is {Path: nameof(Record.Headers)});
371371
metadata.IsStruct.Should().BeTrue();
372372
}
373+
374+
[Test]
375+
public void AsPseudoColumn()
376+
{
377+
//Arrange
378+
379+
//Act
380+
var fieldTypeBuilder = builder.Entity<Record>()
381+
.Property(b => b.Headers)
382+
.AsPseudoColumn();
383+
384+
//Assert
385+
fieldTypeBuilder.Should().NotBeNull();
386+
387+
var entityMetadata = ((IMetadataProvider)builder).GetEntities().FirstOrDefault(c => c.Type == typeof(Record));
388+
entityMetadata.Should().NotBeNull();
389+
390+
var metadata = entityMetadata!.FieldsMetadata.First(c => c is {Path: nameof(Record.Headers)});
391+
metadata.IsPseudoColumn.Should().BeTrue();
392+
}
373393
}
374394

375395
internal record Payment

Tests/ksqlDB.RestApi.Client.Tests/FluentAPI/FieldTypeBuilderTests.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
using System.Linq.Expressions;
12
using FluentAssertions;
23
using ksqlDb.RestApi.Client.FluentAPI.Builders;
4+
using ksqlDB.RestApi.Client.KSql.Query;
35
using ksqlDb.RestApi.Client.Metadata;
46
using ksqlDb.RestApi.Client.Tests.Models;
57
using NUnit.Framework;
8+
using Assert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
69

710
namespace ksqlDb.RestApi.Client.Tests.FluentAPI
811
{
@@ -32,6 +35,7 @@ public void InitState()
3235

3336
fieldMetadata.HasHeaders.Should().BeFalse();
3437
fieldMetadata.IsStruct.Should().BeFalse();
38+
fieldMetadata.IsPseudoColumn.Should().BeFalse();
3539
fieldMetadata.ColumnName.Should().BeNull();
3640
}
3741

@@ -93,6 +97,44 @@ public void AsStruct()
9397
fieldMetadata.IsStruct.Should().BeTrue();
9498
}
9599

100+
[Test]
101+
public void AsPseudoColumn_IsValid()
102+
{
103+
//Arrange
104+
Expression<Func<Record, long>> expression = c => c.RowTime;
105+
fieldMetadata = new()
106+
{
107+
MemberInfo = ((MemberExpression)expression.Body).Member
108+
};
109+
builder = new(fieldMetadata);
110+
111+
//Act
112+
var fieldTypeBuilder = builder.AsPseudoColumn();
113+
114+
//Assert
115+
fieldTypeBuilder.Should().NotBeNull();
116+
fieldMetadata.IsPseudoColumn.Should().BeTrue();
117+
}
118+
119+
[Test]
120+
public void AsPseudoColumn_IsNotValid()
121+
{
122+
//Arrange
123+
Expression<Func<Tweet, double>> expression = c => c.Amount;
124+
fieldMetadata = new()
125+
{
126+
MemberInfo = ((MemberExpression)expression.Body).Member
127+
};
128+
builder = new(fieldMetadata);
129+
130+
//Act
131+
Assert.ThrowsException<InvalidOperationException>(() =>
132+
{
133+
//Act
134+
builder.AsPseudoColumn();
135+
});
136+
}
137+
96138
[Test]
97139
public void HasColumnName()
98140
{

Tests/ksqlDB.RestApi.Client.Tests/KSql/RestApi/Parsers/IdentifierUtilTests.cs

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
using System.Linq.Expressions;
2+
using ksqlDb.RestApi.Client.FluentAPI.Builders;
13
using ksqlDB.RestApi.Client.KSql.RestApi.Enums;
24
using ksqlDb.RestApi.Client.KSql.RestApi.Parsers;
35
using NUnit.Framework;
6+
using FluentAssertions;
47

58
namespace ksqlDb.RestApi.Client.Tests.KSql.RestApi.Parsers
69
{
@@ -22,14 +25,94 @@ public class IdentifierUtilTests
2225
[TestCase(SystemColumns.ROWTIME, IdentifierEscaping.Always, ExpectedResult = $"`{SystemColumns.ROWTIME}`")]
2326
[TestCase(SystemColumns.ROWOFFSET, IdentifierEscaping.Keywords, ExpectedResult = $"`{SystemColumns.ROWOFFSET}`")]
2427
[TestCase(SystemColumns.ROWOFFSET, IdentifierEscaping.Always, ExpectedResult = $"`{SystemColumns.ROWOFFSET}`")]
25-
[TestCase(SystemColumns.ROWPARTITION, IdentifierEscaping.Keywords,
26-
ExpectedResult = $"`{SystemColumns.ROWPARTITION}`")]
28+
[TestCase(SystemColumns.ROWPARTITION, IdentifierEscaping.Keywords, ExpectedResult = $"`{SystemColumns.ROWPARTITION}`")]
2729
[TestCase(SystemColumns.ROWPARTITION, IdentifierEscaping.Always, ExpectedResult = $"`{SystemColumns.ROWPARTITION}`")]
2830
[TestCase(SystemColumns.WINDOWSTART, IdentifierEscaping.Keywords, ExpectedResult = $"`{SystemColumns.WINDOWSTART}`")]
2931
[TestCase(SystemColumns.WINDOWSTART, IdentifierEscaping.Always, ExpectedResult = $"`{SystemColumns.WINDOWSTART}`")]
3032
[TestCase(SystemColumns.WINDOWEND, IdentifierEscaping.Keywords, ExpectedResult = $"`{SystemColumns.WINDOWEND}`")]
3133
[TestCase(SystemColumns.WINDOWEND, IdentifierEscaping.Always, ExpectedResult = $"`{SystemColumns.WINDOWEND}`")]
3234
public string ShouldBeFormatted(string identifier, IdentifierEscaping escaping) =>
3335
IdentifierUtil.Format(identifier, escaping);
36+
37+
private ModelBuilder builder = null!;
38+
39+
[SetUp]
40+
public void TestInitialize()
41+
{
42+
builder = new();
43+
}
44+
45+
private class Record
46+
{
47+
public long RowTime { get; }
48+
}
49+
50+
[TestCase(IdentifierEscaping.Keywords)]
51+
[TestCase(IdentifierEscaping.Always)]
52+
public void Format_MemberExpression_AsPseudoColumn(IdentifierEscaping escaping)
53+
{
54+
//Arrange
55+
builder.Entity<Record>()
56+
.Property(c => c.RowTime)
57+
.AsPseudoColumn();
58+
59+
Expression<Func<Record, long>> expression = c => c.RowTime;
60+
var memberExpression = (MemberExpression) expression.Body;
61+
62+
//Act
63+
var formattedIdentifier = IdentifierUtil.Format(memberExpression, escaping, builder);
64+
65+
//Assert
66+
formattedIdentifier.Should().Be(nameof(Record.RowTime));
67+
}
68+
69+
[TestCase(IdentifierEscaping.Keywords)]
70+
[TestCase(IdentifierEscaping.Always)]
71+
public void Format_MemberExpression_PseudoColumnAttribute(IdentifierEscaping escaping)
72+
{
73+
//Arrange
74+
Expression<Func<ksqlDB.RestApi.Client.KSql.Query.Record, short?>> expression = c => c.RowPartition;
75+
var memberExpression = (MemberExpression)expression.Body;
76+
77+
//Act
78+
var formattedIdentifier = IdentifierUtil.Format(memberExpression, escaping, builder);
79+
80+
//Assert
81+
formattedIdentifier.Should().Be(nameof(ksqlDB.RestApi.Client.KSql.Query.Record.RowPartition));
82+
}
83+
84+
[TestCase(IdentifierEscaping.Keywords)]
85+
[TestCase(IdentifierEscaping.Always)]
86+
public void Format_MemberInfo_AsPseudoColumn(IdentifierEscaping escaping)
87+
{
88+
//Arrange
89+
builder.Entity<Record>()
90+
.Property(c => c.RowTime)
91+
.AsPseudoColumn();
92+
93+
Expression<Func<Record, long>> expression = c => c.RowTime;
94+
var memberInfo = ((MemberExpression)expression.Body).Member;
95+
96+
//Act
97+
var formattedIdentifier = IdentifierUtil.Format(memberInfo, escaping, builder);
98+
99+
//Assert
100+
formattedIdentifier.Should().Be(nameof(Record.RowTime));
101+
}
102+
103+
[TestCase(IdentifierEscaping.Keywords)]
104+
[TestCase(IdentifierEscaping.Always)]
105+
public void Format_MemberInfo_AsPseudoColumnAttribute(IdentifierEscaping escaping)
106+
{
107+
//Arrange
108+
Expression<Func<ksqlDB.RestApi.Client.KSql.Query.Record, long?>> expression = c => c.RowOffset;
109+
var memberInfo = ((MemberExpression)expression.Body).Member;
110+
111+
//Act
112+
var formattedIdentifier = IdentifierUtil.Format(memberInfo, escaping, builder);
113+
114+
//Assert
115+
formattedIdentifier.Should().Be(nameof(ksqlDB.RestApi.Client.KSql.Query.Record.RowOffset));
116+
}
34117
}
35118
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using NUnit.Framework;
2+
using FluentAssertions;
3+
using ksqlDb.RestApi.Client.KSql.RestApi.Parsers;
4+
using ksqlDB.RestApi.Client.KSql.RestApi.Validation;
5+
6+
namespace ksqlDb.RestApi.Client.Tests.KSql.RestApi.Validation
7+
{
8+
public class PseudoColumnNameValidatorTests
9+
{
10+
private PseudoColumnValidator validator = null!;
11+
12+
[SetUp]
13+
public void TestInitialize()
14+
{
15+
validator = new();
16+
}
17+
18+
[TestCase("Headers")]
19+
[TestCase(nameof(SystemColumns.ROWTIME))]
20+
[TestCase(nameof(SystemColumns.ROWOFFSET))]
21+
[TestCase(nameof(SystemColumns.ROWPARTITION))]
22+
public void IsValid_ForPseudoColumns_ReturnsTrue(string columnName)
23+
{
24+
//Arrange
25+
26+
//Act
27+
var isValid = validator.IsValid(columnName);
28+
29+
//Assert
30+
isValid.Should().BeTrue();
31+
}
32+
33+
[Test]
34+
public void IsValid_ForNonPseudoColumns_ReturnsFalse()
35+
{
36+
//Arrange
37+
38+
//Act
39+
var isValid = validator.IsValid("foo");
40+
41+
//Assert
42+
isValid.Should().BeFalse();
43+
}
44+
}
45+
}

docs/modelbuilder.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,30 @@ public class Record
391391
var modelBuilder = new ModelBuilder();
392392
modelBuilder.Entity<Record>();
393393
```
394+
395+
396+
### AsPseudoColumn
397+
**v6.5.0**
398+
399+
The `AsPseudoColumn` function designates fields or properties in entity types as `ksqlDB` [pseudocolumns](https://docs.ksqldb.io/en/latest/reference/sql/data-definition/#pseudocolumns)..
400+
Pseudocolumn [identifiers](https://docs.ksqldb.io/en/latest/reference/sql/syntax/lexical-structure/#identifiers)
401+
are not **backticked** in case of `IdentifierEscaping.Always` or `IdentifierEscaping.Keywords`.
402+
403+
```C#
404+
builder.Entity<Record>()
405+
.Property(c => c.RowTime)
406+
.AsPseudoColumn();
407+
```
408+
409+
```C#
410+
public class Record
411+
{
412+
public long RowTime { get; }
413+
}
414+
```
415+
416+
Valid pseudocolumn names are:
417+
- Headers
418+
- RowOffset
419+
- RowPartition
420+
- RowTime

ksqlDb.RestApi.Client/ChangeLog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# ksqlDB.RestApi.Client
22

3+
# 6.5.0
4+
- added the `AsPseudoColumn` function to the Fluent API for mapping of C# fields or properties as `ksqldb` [pseudocolumns](https://docs.ksqldb.io/en/latest/reference/sql/data-definition/#pseudocolumns).
5+
36
# 6.4.0
47
- added the `IgnoreInDML` function to the Fluent API to exclude fields from INSERT statements #90 (proposed by @mrt181)
58
- added `IgnoreAttribute` to prevent properties or fields from being included in both DDL and DML statement

ksqlDb.RestApi.Client/FluentAPI/Builders/FieldTypeBuilder.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Linq.Expressions;
1+
using ksqlDB.RestApi.Client.Infrastructure.Extensions;
2+
using ksqlDB.RestApi.Client.KSql.RestApi.Validation;
23
using ksqlDb.RestApi.Client.Metadata;
34

45
namespace ksqlDb.RestApi.Client.FluentAPI.Builders
@@ -45,6 +46,12 @@ public interface IFieldTypeBuilder<TProperty>
4546
/// </summary>
4647
/// <returns>The field type builder for chaining additional configuration.</returns>
4748
IFieldTypeBuilder<TProperty> AsStruct();
49+
50+
/// <summary>
51+
/// Marks the field as a ksqldb pseudocolumn.
52+
/// </summary>
53+
/// <returns>The field type builder for chaining additional configuration.</returns>
54+
IFieldTypeBuilder<TProperty> AsPseudoColumn();
4855
}
4956

5057
internal class FieldTypeBuilder<TProperty>(FieldMetadata fieldMetadata)
@@ -62,6 +69,19 @@ public IFieldTypeBuilder<TProperty> AsStruct()
6269
return this;
6370
}
6471

72+
private readonly PseudoColumnValidator pseudoColumnValidator = new();
73+
74+
public IFieldTypeBuilder<TProperty> AsPseudoColumn()
75+
{
76+
var columnName = fieldMetadata.ColumnName ?? fieldMetadata.MemberInfo.GetMemberName(default(EntityMetadata?));
77+
78+
if (!pseudoColumnValidator.IsValid(columnName))
79+
throw new InvalidOperationException($"{columnName} is not a valid pseudocolumn name");
80+
81+
fieldMetadata.IsPseudoColumn = true;
82+
return this;
83+
}
84+
6585
public IFieldTypeBuilder<TProperty> Ignore()
6686
{
6787
fieldMetadata.Ignore = true;

ksqlDb.RestApi.Client/KSql/RestApi/Parsers/IdentifierUtil.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ internal static string Format(MemberExpression memberExpression, IdentifierEscap
5656
return escaping switch
5757
{
5858
Never => memberExpression.GetMemberName(metadataProvider),
59-
Keywords when memberExpression.Member.GetCustomAttribute<PseudoColumnAttribute>() != null => memberExpression.Member.Name,
59+
Keywords when IsPseudoColumn(memberExpression.Member, metadataProvider) => memberExpression.Member.Name,
6060
Keywords when IsValid(memberExpression.GetMemberName(metadataProvider)) && SystemColumns.IsValid(memberExpression.GetMemberName(metadataProvider)) => memberExpression.GetMemberName(metadataProvider),
6161
Keywords => string.Concat("`", memberExpression.GetMemberName(metadataProvider), "`"),
62-
Always when memberExpression.Member.GetCustomAttribute<PseudoColumnAttribute>() != null => memberExpression.Member.Name,
62+
Always when IsPseudoColumn(memberExpression.Member, metadataProvider) => memberExpression.Member.Name,
6363
Always => string.Concat("`", memberExpression.GetMemberName(metadataProvider), "`"),
6464
_ => throw new ArgumentOutOfRangeException(nameof(escaping), escaping, "Non-exhaustive match.")
6565
};
@@ -82,13 +82,26 @@ public static string Format(MemberInfo memberInfo, IdentifierEscaping escaping,
8282
return escaping switch
8383
{
8484
Never => memberInfo.GetMemberName(modelBuilder),
85-
Keywords when memberInfo.GetCustomAttribute<PseudoColumnAttribute>() != null => memberInfo.Name,
85+
Keywords when IsPseudoColumn(memberInfo, modelBuilder) => memberInfo.Name,
8686
Keywords when IsValid(memberInfo.GetMemberName(modelBuilder)) && SystemColumns.IsValid(memberInfo.GetMemberName(modelBuilder)) => memberInfo.GetMemberName(modelBuilder),
8787
Keywords => string.Concat("`", memberInfo.GetMemberName(modelBuilder), "`"),
88-
Always when memberInfo.GetCustomAttribute<PseudoColumnAttribute>() != null => memberInfo.Name,
88+
Always when IsPseudoColumn(memberInfo, modelBuilder) => memberInfo.Name,
8989
Always => string.Concat("`", memberInfo.GetMemberName(modelBuilder), "`"),
9090
_ => throw new ArgumentOutOfRangeException(nameof(escaping), escaping, "Non-exhaustive match.")
9191
};
9292
}
93+
94+
private static bool IsPseudoColumn(MemberInfo memberInfo, IMetadataProvider? metadataProvider)
95+
{
96+
if (memberInfo.GetCustomAttribute<PseudoColumnAttribute>() != null)
97+
return true;
98+
99+
var entityMetadata = metadataProvider?.GetEntities().FirstOrDefault(c => c.Type == memberInfo.DeclaringType);
100+
101+
var fieldMetadata =
102+
entityMetadata?.FieldsMetadata.FirstOrDefault(c => c.MemberInfo.Name == memberInfo.Name);
103+
104+
return fieldMetadata is {IsPseudoColumn: true};
105+
}
93106
}
94107
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using ksqlDb.RestApi.Client.KSql.RestApi.Parsers;
2+
using ksqlDB.RestApi.Client.Infrastructure.Extensions;
3+
4+
namespace ksqlDB.RestApi.Client.KSql.RestApi.Validation
5+
{
6+
internal class PseudoColumnValidator
7+
{
8+
private readonly string[] allowedPseudoColumnNames =
9+
[
10+
"Headers".ToUpper(),
11+
nameof(SystemColumns.ROWOFFSET).ToUpper(),
12+
nameof(SystemColumns.ROWPARTITION).ToUpper(),
13+
nameof(SystemColumns.ROWTIME).ToUpper()
14+
];
15+
16+
internal bool IsValid(string columnName)
17+
{
18+
return columnName.ToUpper().IsOneOfFollowing(allowedPseudoColumnNames);
19+
}
20+
}
21+
}

0 commit comments

Comments
 (0)