Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
<RepositoryType>git</RepositoryType>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>autopoco;testdata;test-data;poco;generator;fluent;fake-data</PackageTags>
<PackageReleaseNotes>6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource&lt;T&gt; and NullableNumberSource&lt;T&gt; with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once. IDataSource&lt;T&gt; is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details.</PackageReleaseNotes>
<PackageReleaseNotes>6.0: Lambda data sources (FuncSource, Setup(...).From(() => ...), Source(expr, () => ...) at generation time). Opt-in nullability-aware null generation via RespectNullableAnnotations(threshold). Generic NumberSource&lt;T&gt; and NullableNumberSource&lt;T&gt; with min/max for every built-in numeric type (integers uniform and inclusive, floating point and decimal overflow safe). Records and immutable types are constructed through their constructor, parameters are fed from the registered members with the same name, no parameterless constructor needed anymore; unconfigured struct parameters stay default instead of throwing. Seeds: UseSeed(seed) per factory, CreateSession(recursionLimit, seed) per session, every member gets its own stream derived from the seed. Own deterministic generator (StableRandom, xoshiro256**) instead of System.Random, sequences are stable across .NET versions. Breaking: AutoPocoGlobalSettings removed (AutoPocoDefaults constants instead), every generated sequence changed once. Index-aware Impose on lists and selections (value from the position, or from position and item), Impose(member, item => value) on a single generator. IDataSource&lt;T&gt; is covariant. Bug fixes: Nullable* sources honor an explicit null creation threshold (null positions of stable sequences changed), DateTime/DateOnly/TimeOnly/DateOfBirth sources cover their whole range including the upper bound (sequences changed), RandomUtfTextSource no longer loops forever. Build and packaging modernized: GitHub Actions CI, trusted publishing to nuget.org, MinVer tag based versioning, central package management, xunit.v3 tests on net8/9/10. FluentAssertions and Moq are no longer dependencies of the library. See README for details.</PackageReleaseNotes>
</PropertyGroup>

<PropertyGroup Label="Package build">
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ var other = factory.CreateSession(5, 4711); // seed 4711, same configuratio
* Own random number generator: every source draws from `StableRandom` (xoshiro256** seeded through SplitMix64), a `Random` subclass owned by AutoPoco, instead of `System.Random`. The sequence for a seed is defined by AutoPoco's code alone, so test data stays the same across .NET versions; Microsoft explicitly does not promise that for a seeded `System.Random`. The generator is pinned by tests. Rule from now on: catalogs (names, cities, zip codes, ...) and source algorithms are frozen within a major version, a change to either is a major version bump.
* Breaking: `AutoPocoGlobalSettings` is gone, it was mutable process-wide state that leaked between test fixtures. The defaults are read-only constants in `AutoPocoDefaults` (`Seed`, `NullCreationThreshold`, `RecursionLimit`). Set a seed with `UseSeed`, null thresholds per source or with `RespectNullableAnnotations(threshold)`.
* Breaking: because of the own generator and the per-member seeds, every generated sequence changed once with 6.0. Tests that pin generated values need new expectations, this is the last time.
* Index-aware `Impose`: on a list or a selection the imposed value can depend on the position of the item (0 based), and on the item as generated so far. The position is the one in the whole list, also inside `First`/`Next` and after `Random`. A single generator gets `Impose(member, item => value)` for values that depend on other members.

```CSHARP
var users = session.List<SimpleUser>(100)
.Impose(u => u.Id, i => i + 1) // 1..100
.Impose(u => u.EmailAddress, (i, u) => $"{u.FirstName}.{u.LastName}{i}@example.test")
.First(10).Impose(u => u.City, i => $"Branch {i}")
.All()
.Get();

var admin = session.Single<SimpleUser>()
.Impose(u => u.LastName, "Ashton")
.Impose(u => u.EmailAddress, u => $"{u.FirstName}.{u.LastName}@example.test")
.Get();
```

The item lambda runs after the configured sources and after every `Impose` registered before it, so it sees those values.
* `IDataSource<T>` is covariant now, so a source of `string` (e.g. `CitySource`) can be used for a `string?` member without a nullability warning.
* Bug fix: every `Nullable*` source ignored an explicit null creation threshold. The fixed array and dictionary based sources (names, companies, cities, capitals, countries, states, zip codes, urls) even used the threshold as their random seed. The threshold is honored now. As a consequence the null positions in the stable sequences of these sources changed, the data itself comes in the same order as before.
* Bug fix: `DateTimeSource`, `DateOnlySource`, `TimeOnlySource` and `DateOfBirthSource` never produced the upper end of their ranges: no December, no 31st, no 23:00, no minute or second 59, and the maximum year of a date of birth was never reached. Ranges within a single year could produce values outside the range. All four sources now pick uniformly from the whole range, both bounds inclusive, and throw an `ArgumentOutOfRangeException` when the maximum lies before the minimum. `TimeOnlySource` supports ranges that wrap around midnight (22:00 to 02:00). `DateTimeSource` keeps the `DateTimeKind` of the minimum date. The generated sequences for a given seed changed.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using Shouldly;
using Moq;
using System.Linq.Expressions;
using Xunit;
using holonsoft.AutoPoco.Actions;
using holonsoft.AutoPoco.Configuration;
using holonsoft.AutoPoco.Engine;
using holonsoft.AutoPoco.Engine.Interfaces;
using holonsoft.AutoPoco.Tests.Common;

namespace holonsoft.AutoPoco.Tests.Functionality.Tests.Engine;

public class IndexAwareImposeTests {
private static readonly Expression<Func<SimpleUser, string>> _lastName = x => x.LastName;

private static List<Mock<IObjectGenerator<SimpleUser>>> Mocks(int count)
=> Enumerable.Range(0, count).Select(_ => new Mock<IObjectGenerator<SimpleUser>>()).ToList();

[Fact]
public void CollectionImposeWithIndexPassesThePositionOfEveryItem() {
var mocks = Mocks(5);
var context = new CollectionContext<SimpleUser, List<SimpleUser>>(mocks.Select(m => m.Object));

context.Impose(_lastName, i => $"item-{i}").ShouldBeSameAs(context);

for (var i = 0; i < 5; i++)
mocks[i].Verify(m => m.Impose(_lastName, $"item-{i}"), Times.Once());
}

[Fact]
public void CollectionImposeWithIndexAndItemRegistersAFactoryPerItem() {
var mocks = Mocks(3);
var context = new CollectionContext<SimpleUser, List<SimpleUser>>(mocks.Select(m => m.Object));
var captured = new List<Func<SimpleUser, string>>();
foreach (var mock in mocks)
mock.Setup(m => m.Impose(_lastName, It.IsAny<Func<SimpleUser, string>>()))
.Callback((Expression<Func<SimpleUser, string>> _, Func<SimpleUser, string> f) => captured.Add(f));

context.Impose(_lastName, (i, user) => $"{user.FirstName}-{i}");

captured.Count.ShouldBe(3);
var user = new SimpleUser { FirstName = "Rob", LastName = "", EmailAddress = "", Role = new SimpleUserRole { Name = "" }, Properties = [] };
captured.Select(f => f(user)).ShouldBe(["Rob-0", "Rob-1", "Rob-2"]);
}

[Fact]
public void SelectionImposeWithIndexUsesThePositionInTheWholeCollectionAlsoAfterRandom() {
var mocks = Mocks(6);
var context = new CollectionContext<SimpleUser, List<SimpleUser>>(mocks.Select(m => m.Object));

context.Random(6).Impose(_lastName, i => $"pos-{i}");

for (var i = 0; i < 6; i++)
mocks[i].Verify(m => m.Impose(_lastName, $"pos-{i}"), Times.Once());
}

[Fact]
public void SelectionImposeWithIndexOnlyTouchesTheSelection() {
var mocks = Mocks(6);
var context = new CollectionContext<SimpleUser, List<SimpleUser>>(mocks.Select(m => m.Object));

context.First(2).Next(2).Impose(_lastName, i => $"pos-{i}");

mocks[0].Verify(m => m.Impose(_lastName, It.IsAny<string>()), Times.Never());
mocks[2].Verify(m => m.Impose(_lastName, "pos-2"), Times.Once());
mocks[3].Verify(m => m.Impose(_lastName, "pos-3"), Times.Once());
mocks[5].Verify(m => m.Impose(_lastName, It.IsAny<string>()), Times.Never());
}

[Fact]
public void SelectionImposeWithIndexAndItemRegistersAFactory() {
var mocks = Mocks(2);
var context = new CollectionContext<SimpleUser, List<SimpleUser>>(mocks.Select(m => m.Object));

context.First(2).Impose(_lastName, (i, user) => $"{i}");

mocks[0].Verify(m => m.Impose(_lastName, It.IsAny<Func<SimpleUser, string>>()), Times.Once());
mocks[1].Verify(m => m.Impose(_lastName, It.IsAny<Func<SimpleUser, string>>()), Times.Once());
}

[Fact]
public void NullFactoriesAreRejected() {
var context = new CollectionContext<SimpleUser, List<SimpleUser>>(Mocks(1).Select(m => m.Object));

Should.Throw<ArgumentNullException>(() => context.Impose(_lastName, (Func<int, string>) null!));
Should.Throw<ArgumentNullException>(() => context.Impose(_lastName, (Func<int, SimpleUser, string>) null!));
Should.Throw<ArgumentNullException>(() => context.First(1).Impose(_lastName, (Func<int, string>) null!));
Should.Throw<ArgumentNullException>(() => context.First(1).Impose(_lastName, (Func<int, SimpleUser, string>) null!));
}

[Fact]
public void MemberSetFromFuncActionSetsPropertiesAndFields() {
var propertyAction = new ObjectMemberSetFromFuncAction<SimpleUserRole, string>(
new EngineTypePropertyMember(typeof(SimpleUserRole).GetProperty(nameof(SimpleUserRole.Name))!), x => x.Name + "p");
var fieldAction = new ObjectMemberSetFromFuncAction<SimpleFieldClass, string?>(
new EngineTypeFieldMember(typeof(SimpleFieldClass).GetField(nameof(SimpleFieldClass.SomeField))!), x => x.SomeOtherField + "f");
var role = new SimpleUserRole { Name = "role" };
var fields = new SimpleFieldClass { SomeOtherField = "other" };

propertyAction.Enact(null, role);
fieldAction.Enact(null, fields);

role.Name.ShouldBe("rolep");
fields.SomeField.ShouldBe("otherf");
}

[Fact]
public void MemberSetFromFuncActionRejectsMethodsAndNulls() {
var method = new EngineTypeMethodMember(typeof(SimpleUser).GetMethod(nameof(SimpleUser.SetPassword))!);

Should.Throw<ArgumentException>(() => new ObjectMemberSetFromFuncAction<SimpleUser, string>(method, x => ""));
Should.Throw<ArgumentNullException>(() => new ObjectMemberSetFromFuncAction<SimpleUser, string>(null!, x => ""));
Should.Throw<ArgumentNullException>(() => new ObjectMemberSetFromFuncAction<SimpleUser, string>(
new EngineTypePropertyMember(typeof(SimpleUser).GetProperty(nameof(SimpleUser.LastName))!), null!));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Shouldly;
using Xunit;
using holonsoft.AutoPoco.DataSources.Business;
using holonsoft.AutoPoco.Engine.Interfaces;
using holonsoft.AutoPoco.Extensions;
using holonsoft.AutoPoco.Tests.Common;

namespace holonsoft.AutoPoco.Tests.Integration.Tests.Complete;

public class WhenImposingByIndex {
private static IGenerationSession CreateSession()
=> AutoPocoContainer.Configure(x => {
x.Conventions(c => c.UseDefaultConventions());
x.Include<SimpleUser>()
.Setup(c => c.FirstName).Use<FirstNameSource>()
.Setup(c => c.LastName).Use<LastNameSource>();
}).CreateSession();

[Fact]
public void TheIndexGivesEveryItemItsOwnValue() {
var users = CreateSession().List<SimpleUser>(10)
.Impose(u => u.Id, i => (Int128) (i + 1))
.Impose(u => u.City, i => $"City {i}")
.Get();

users.Select(u => u.Id).ShouldBe(Enumerable.Range(1, 10).Select(i => (Int128) i));
users.Select(u => u.City).ShouldBe(Enumerable.Range(0, 10).Select(i => $"City {i}"));
}

[Fact]
public void TheItemFactorySeesTheGeneratedMembers() {
var users = CreateSession().List<SimpleUser>(5)
.Impose(u => u.EmailAddress, (i, u) => $"{u.FirstName}.{u.LastName}.{i}@example.test".ToLowerInvariant())
.Get();

users.ShouldAllBe(u => u.EmailAddress.StartsWith(u.FirstName.ToLowerInvariant() + "."));
users.Select(u => u.EmailAddress).ShouldBe(users.Select((u, i) => $"{u.FirstName}.{u.LastName}.{i}@example.test".ToLowerInvariant()));
}

[Fact]
public void TheItemFactorySeesEarlierImposedValues() {
var users = CreateSession().List<SimpleUser>(3)
.Impose(u => u.FirstName, "Rob")
.Impose(u => u.EmailAddress, (i, u) => $"{u.FirstName}{i}@example.test")
.Get();

users.Select(u => u.EmailAddress).ShouldBe(["Rob0@example.test", "Rob1@example.test", "Rob2@example.test"]);
}

[Fact]
public void ASelectionUsesThePositionInTheWholeList() {
var users = CreateSession().List<SimpleUser>(10)
.First(3).Impose(u => u.City, i => $"first {i}")
.Next(3).Impose(u => u.City, (i, u) => $"next {i}")
.All()
.Get();

users.Take(3).Select(u => u.City).ShouldBe(["first 0", "first 1", "first 2"]);
users.Skip(3).Take(3).Select(u => u.City).ShouldBe(["next 3", "next 4", "next 5"]);
users.Skip(6).ShouldAllBe(u => u.City != null && !u.City.StartsWith("first") && !u.City.StartsWith("next"));
}

[Fact]
public void ARandomSelectionKeepsTheOriginalPositions() {
var users = CreateSession().List<SimpleUser>(20)
.Random(8).Impose(u => u.Id, i => (Int128) i)
.Impose(u => u.City, i => "picked")
.All()
.Get();

var picked = users.Select((u, i) => (u, i)).Where(t => t.u.City == "picked").ToList();
picked.Count.ShouldBe(8);
picked.ShouldAllBe(t => t.u.Id == t.i);
}

[Fact]
public void ASingleGeneratorCanImposeFromTheObject() {
var user = CreateSession().Single<SimpleUser>()
.Impose(u => u.LastName, "Ashton")
.Impose(u => u.EmailAddress, u => $"{u.FirstName}.{u.LastName}@example.test")
.Get();

user.EmailAddress.ShouldBe($"{user.FirstName}.Ashton@example.test");
}

[Fact]
public void FieldsWorkAsWell() {
var items = CreateSession().List<SimpleFieldClass>(4)
.Impose(c => c.SomeField, i => $"field {i}")
.Get();

items.Select(c => c.SomeField).ShouldBe(["field 0", "field 1", "field 2", "field 3"]);
}

[Fact]
public void TheClassicImposeStillWorksNextToTheNewOverloads() {
var users = CreateSession().List<SimpleUser>(3)
.Impose(u => u.FirstName, "Rob")
.Impose(u => u.Id, i => (Int128) i)
.Get();

users.ShouldAllBe(u => u.FirstName == "Rob");
users.Select(u => u.Id).ShouldBe([(Int128) 0, 1, 2]);
}
}
36 changes: 36 additions & 0 deletions holonsoft.AutoPoco/Actions/ObjectMemberSetFromFuncAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using holonsoft.AutoPoco.Configuration;
using holonsoft.AutoPoco.Engine.Interfaces;

namespace holonsoft.AutoPoco.Actions;

/// <summary>
/// Sets a property or field from a lambda that receives the object as generated so far, so the value
/// can depend on other members (or on the position in a collection, captured by the lambda).
/// </summary>
public class ObjectMemberSetFromFuncAction<TPoco, TMember> : IObjectAction {
private readonly EngineTypeMember _member;
private readonly Func<TPoco, TMember> _valueFactory;

public ObjectMemberSetFromFuncAction(EngineTypeMember member, Func<TPoco, TMember> valueFactory) {
ArgumentNullException.ThrowIfNull(member);
ArgumentNullException.ThrowIfNull(valueFactory);
if (member is not (EngineTypePropertyMember or EngineTypeFieldMember))
throw new ArgumentException($"Member '{member.Name}' is neither a property nor a field.", nameof(member));

_member = member;
_valueFactory = valueFactory;
}

public void Enact(IGenerationContext? context, object target) {
var value = _valueFactory((TPoco) target);

switch (_member) {
case EngineTypePropertyMember property:
property.PropertyInfo.SetValue(target, value, null);
break;
case EngineTypeFieldMember field:
field.FieldInfo.SetValue(target, value);
break;
}
}
}
Loading