diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fee26d9..374af53 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,7 +19,12 @@ permissions:
env:
PROJECT_PATH: 'ZWebAPI/ZWebAPI.csproj'
PACKAGE_OUTPUT_DIRECTORY: ${{ github.workspace }}/output
- DOTNET_VERSION: 9.0.x
+ # Every target framework the library ships, so "dotnet test" can run the suite
+ # on each of them. The newest SDK installed is the one that drives the build.
+ DOTNET_VERSION: |
+ 8.0.x
+ 9.0.x
+ 10.0.x
# Cancel superseded PR runs, but never cancel a release in progress;
# queueing runs on main also prevents two merges from racing the same version.
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..c23d862
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,26 @@
+
+
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+ 8.0.11
+ 9.0.0
+ 10.0.0
+
+
+
diff --git a/README.md b/README.md
index f9af268..dadae3a 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,20 @@
# ZWebAPI
ZWebAPI is a library that brings additional functionalities for web API projects.
+
+## Supported frameworks
+
+ZWebAPI ships assets for .NET 8, .NET 9 and .NET 10.
+
+| Target framework | Notes |
+| --- | --- |
+| `net10.0` | LTS, supported until November 2028 |
+| `net9.0` | STS, out of support since May 2026 |
+| `net8.0` | LTS, out of support after November 2026 |
+
+ZWebAPI depends on [ZDatabase](https://github.com/RicardoZambon/ZDatabase) and
+[ZSecurity](https://github.com/RicardoZambon/ZSecurity), which ship the same three targets.
+ZDatabase carries the EF Core dependency for each of them.
+
+The `net9.0` and `net8.0` targets exist for compatibility with applications that have not yet
+moved to .NET 10. Both of those runtimes are at or near end of support, so new projects should
+target `net10.0`.
diff --git a/ZWebAPI.UnitTests/Exporters/ColumnMetadataResolverTests.cs b/ZWebAPI.UnitTests/Exporters/ColumnMetadataResolverTests.cs
new file mode 100644
index 0000000..6e7a4be
--- /dev/null
+++ b/ZWebAPI.UnitTests/Exporters/ColumnMetadataResolverTests.cs
@@ -0,0 +1,203 @@
+using ZWebAPI.Exporters;
+using ZWebAPI.UnitTests.Fakes.ExportersFake;
+
+namespace ZWebAPI.UnitTests.Exporters
+{
+ ///
+ /// Unit tests for .
+ ///
+ public class ColumnMetadataResolverTests
+ {
+ ///
+ /// Test the Resolve should throw when the row type is null.
+ ///
+ [Fact]
+ public void Resolve_Fail_ThrowsWhenRowTypeIsNull()
+ {
+ // Arrange
+ Type? rowType = null;
+
+ // Act
+ Action act = () => ColumnMetadataResolver.Resolve(rowType!);
+
+ // Assert
+ act.Should().Throw()
+ .WithParameterName("rowType");
+ }
+
+ ///
+ /// Test the Resolve should use the property name when the attribute is absent.
+ ///
+ [Fact]
+ public void Resolve_Pass_UsesPropertyNameWhenAttributeIsAbsent()
+ {
+ // Arrange
+ Type rowType = typeof(ExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Should().Contain(x => x.Header == nameof(ExportRowFake.ID));
+ }
+
+ ///
+ /// Test the Resolve should use the header declared on the attribute.
+ ///
+ [Fact]
+ public void Resolve_Pass_UsesCustomHeaderFromAttribute()
+ {
+ // Arrange
+ Type rowType = typeof(ExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Should().Contain(x => x.Header == "Full name");
+ columns.Should().NotContain(x => x.Header == nameof(ExportRowFake.Name));
+ }
+
+ ///
+ /// Test the Resolve should fall back to the property name when the attribute header is blank.
+ ///
+ [Fact]
+ public void Resolve_Pass_FallsBackToPropertyNameWhenHeaderIsBlank()
+ {
+ // Arrange
+ Type rowType = typeof(BlankHeaderExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Should().ContainSingle();
+ columns[0].Header.Should().Be(nameof(BlankHeaderExportRowFake.Blank));
+ }
+
+ ///
+ /// Test the Resolve should skip properties flagged as ignored.
+ ///
+ [Fact]
+ public void Resolve_Pass_SkipsIgnoredProperties()
+ {
+ // Arrange
+ Type rowType = typeof(ExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Should().NotContain(x => x.Header == nameof(ExportRowFake.Secret));
+ columns.Should().HaveCount(7);
+ }
+
+ ///
+ /// Test the Resolve should skip write-only properties.
+ ///
+ [Fact]
+ public void Resolve_Pass_SkipsWriteOnlyProperties()
+ {
+ // Arrange
+ Type rowType = typeof(WriteOnlyExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Should().ContainSingle();
+ columns[0].Header.Should().Be(nameof(WriteOnlyExportRowFake.Readable));
+ }
+
+ ///
+ /// Test the Resolve should order explicitly ordered columns first and keep declaration order otherwise.
+ ///
+ [Fact]
+ public void Resolve_Pass_OrdersByExplicitOrderThenDeclaration()
+ {
+ // Arrange
+ Type rowType = typeof(OrderedExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Select(x => x.Header).Should().ContainInOrder(
+ nameof(OrderedExportRowFake.First),
+ nameof(OrderedExportRowFake.Second),
+ nameof(OrderedExportRowFake.Third),
+ nameof(OrderedExportRowFake.Fourth));
+ }
+
+ ///
+ /// Test the Resolve should return an empty list for a type without exportable properties.
+ ///
+ [Fact]
+ public void Resolve_Pass_ReturnsEmptyForTypeWithoutProperties()
+ {
+ // Arrange
+ Type rowType = typeof(EmptyExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Should().BeEmpty();
+ }
+
+ ///
+ /// Test the Resolve should default the column type to Text and honor the declared type otherwise.
+ ///
+ [Fact]
+ public void Resolve_Pass_UsesDeclaredColumnTypeOrDefaultsToText()
+ {
+ // Arrange
+ Type rowType = typeof(ExportRowFake);
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ columns.Single(x => x.Header == nameof(ExportRowFake.ID)).Type.Should().Be(ExportColumnType.Text);
+ columns.Single(x => x.Header == nameof(ExportRowFake.Amount)).Type.Should().Be(ExportColumnType.Number);
+ columns.Single(x => x.Header == nameof(ExportRowFake.Price)).Type.Should().Be(ExportColumnType.Currency);
+ columns.Single(x => x.Header == nameof(ExportRowFake.CreatedOn)).Type.Should().Be(ExportColumnType.Date);
+ columns.Single(x => x.Header == nameof(ExportRowFake.ChangedOn)).Type.Should().Be(ExportColumnType.DateTime);
+ columns.Single(x => x.Header == nameof(ExportRowFake.IsActive)).Type.Should().Be(ExportColumnType.Boolean);
+ }
+
+ ///
+ /// Test the Resolve should build selectors that read the value from the row instance.
+ ///
+ [Fact]
+ public void Resolve_Pass_BuildsSelectorReadingTheRowValue()
+ {
+ // Arrange
+ ExportRowFake row = new() { ID = 7, Name = "Row name" };
+
+ // Act
+ IReadOnlyList columns = ColumnMetadataResolver.Resolve(typeof(ExportRowFake));
+
+ // Assert
+ columns.Single(x => x.Header == nameof(ExportRowFake.ID)).ValueSelector(row).Should().Be(7L);
+ columns.Single(x => x.Header == "Full name").ValueSelector(row).Should().Be("Row name");
+ }
+
+ ///
+ /// Test the Resolve should serve the cached metadata on subsequent calls for the same type.
+ ///
+ [Fact]
+ public void Resolve_Pass_ReturnsCachedResultForTheSameType()
+ {
+ // Arrange
+ Type rowType = typeof(ExportRowFake);
+
+ // Act
+ IReadOnlyList first = ColumnMetadataResolver.Resolve(rowType);
+ IReadOnlyList second = ColumnMetadataResolver.Resolve(rowType);
+
+ // Assert
+ second.Should().BeSameAs(first);
+ }
+ }
+}
diff --git a/ZWebAPI.UnitTests/Exporters/Exceptions/ExportRowLimitExceededExceptionTests.cs b/ZWebAPI.UnitTests/Exporters/Exceptions/ExportRowLimitExceededExceptionTests.cs
new file mode 100644
index 0000000..c51b4e7
--- /dev/null
+++ b/ZWebAPI.UnitTests/Exporters/Exceptions/ExportRowLimitExceededExceptionTests.cs
@@ -0,0 +1,43 @@
+using ZWebAPI.Exporters;
+using ZWebAPI.Exporters.Exceptions;
+
+namespace ZWebAPI.UnitTests.Exporters.Exceptions
+{
+ ///
+ /// Unit tests for .
+ ///
+ public class ExportRowLimitExceededExceptionTests
+ {
+ ///
+ /// Test the constructor should expose the configured limit and describe it in the message.
+ ///
+ [Fact]
+ public void Constructor_Pass_ExposesTheConfiguredLimit()
+ {
+ // Arrange
+ int maxRows = 1234;
+
+ // Act
+ ExportRowLimitExceededException exception = new(maxRows);
+
+ // Assert
+ exception.MaxRows.Should().Be(maxRows);
+ exception.Message.Should().Be($"The export result exceeds the configured limit of {maxRows} rows.");
+ }
+
+ ///
+ /// Test the exception should carry the shared export row limit when raised by the library.
+ ///
+ [Fact]
+ public void Constructor_Pass_CarriesTheSharedRowLimit()
+ {
+ // Arrange & Act
+ ExportRowLimitExceededException exception = new(ExportConstants.MaxExportRows);
+
+ // Assert
+ ExportConstants.MaxExportRows.Should().Be(50_000);
+ exception.MaxRows.Should().Be(50_000);
+ exception.Should().BeAssignableTo();
+ }
+ }
+}
diff --git a/ZWebAPI.UnitTests/Exporters/ExportColumnAttributeTests.cs b/ZWebAPI.UnitTests/Exporters/ExportColumnAttributeTests.cs
new file mode 100644
index 0000000..c45a4e7
--- /dev/null
+++ b/ZWebAPI.UnitTests/Exporters/ExportColumnAttributeTests.cs
@@ -0,0 +1,67 @@
+using ZWebAPI.Exporters;
+
+namespace ZWebAPI.UnitTests.Exporters
+{
+ ///
+ /// Unit tests for .
+ ///
+ public class ExportColumnAttributeTests
+ {
+ ///
+ /// Test the parameterless constructor should leave every override at its default.
+ ///
+ [Fact]
+ public void Constructor_Pass_LeavesDefaultsWhenNoArgumentIsProvided()
+ {
+ // Arrange & Act
+ ExportColumnAttribute attribute = new();
+
+ // Assert
+ attribute.Header.Should().BeNull();
+ attribute.Ignore.Should().BeFalse();
+ attribute.Order.Should().Be(int.MaxValue);
+ attribute.Type.Should().Be(ExportColumnType.Text);
+ }
+
+ ///
+ /// Test the header constructor should store the provided header.
+ ///
+ [Fact]
+ public void Constructor_Pass_StoresTheProvidedHeader()
+ {
+ // Arrange
+ string header = "Custom";
+
+ // Act
+ ExportColumnAttribute attribute = new(header);
+
+ // Assert
+ attribute.Header.Should().Be(header);
+ attribute.Ignore.Should().BeFalse();
+ attribute.Order.Should().Be(int.MaxValue);
+ attribute.Type.Should().Be(ExportColumnType.Text);
+ }
+
+ ///
+ /// Test every override should be settable through the object initializer.
+ ///
+ [Fact]
+ public void Properties_Pass_AcceptEveryOverride()
+ {
+ // Arrange & Act
+ ExportColumnAttribute attribute = new()
+ {
+ Header = "Header",
+ Ignore = true,
+ Order = 3,
+ Type = ExportColumnType.Date,
+ };
+
+ // Assert
+ attribute.Header.Should().Be("Header");
+ attribute.Ignore.Should().BeTrue();
+ attribute.Order.Should().Be(3);
+ attribute.Type.Should().Be(ExportColumnType.Date);
+ }
+ }
+}
diff --git a/ZWebAPI.UnitTests/Exporters/ExportColumnTests.cs b/ZWebAPI.UnitTests/Exporters/ExportColumnTests.cs
new file mode 100644
index 0000000..581f103
--- /dev/null
+++ b/ZWebAPI.UnitTests/Exporters/ExportColumnTests.cs
@@ -0,0 +1,31 @@
+using ZWebAPI.Exporters;
+
+namespace ZWebAPI.UnitTests.Exporters
+{
+ ///
+ /// Unit tests for .
+ ///
+ public class ExportColumnTests
+ {
+ ///
+ /// Test the constructor should expose every value it received.
+ ///
+ [Fact]
+ public void Constructor_Pass_ExposesTheProvidedMetadata()
+ {
+ // Arrange
+ string header = "Header";
+ ExportColumnType type = ExportColumnType.Currency;
+ Func