From 66dc2d7104c0a1a4ae5674493e117b9e916ba03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 11:02:27 +0200 Subject: [PATCH 1/3] test: the EF family's probes run one check per obligation E01 to E16 ran as one check named `claim`, so no listed obligation of an EF claim earned credit and every per-obligation seed of theirs could only be missed. Each probe now runs one Row.Check per obligation its row's axes select, named by the claim's obligation id and given its baseline: the wrapped twin under plain EF (Twin), the documented refusal (E05), memory (E05, E06, E13), or the docs sentence where it is the only baseline (E04's char and Guid defaults, E06, E07, E08, E09, E10, E11, E12). No outcome note key is called any more. Where a side throws, EfSides decides: both alike is inconclusive (uncited), the twin alone is inconclusive, the value object alone or both differently is a violation. NoOperator (E05) and E12's uncited inapplicable became inconclusive; E16's both-refuse expected refusal too. E01 applies a length or a precision to every wrapped type, as configured, where it substituted `none` and so compared nothing for facet-kept. Four obligations for rows no obligation selected, each with its seed: E04 not-on-efs-list and own-configuration-wins, E12 underlying-type-does-not-reach, E13 contains. Co-Authored-By: Claude Opus 5.5 --- tools/probe/corpus/claims.json | 45 +++++ tools/probe/corpus/harness/EfHarness.cs | 25 +++ .../corpus/probes/E01ColumnParityProbe.cs | 86 ++++++--- .../corpus/probes/E02ValueTranslatesProbe.cs | 49 +++-- .../corpus/probes/E03ColumnRoundTripProbe.cs | 33 ++-- .../probe/corpus/probes/E04IntegerKeyProbe.cs | 32 +++- .../corpus/probes/E05OwnCompareToProbe.cs | 167 ++++++++++-------- .../probes/E06OwnConverterNoValueProbe.cs | 80 +++++---- .../corpus/probes/E07ClosedSetNameProbe.cs | 63 +++++-- .../probes/E08OptionResultNotColumnsProbe.cs | 29 ++- .../probe/corpus/probes/E09OrNoneInDbProbe.cs | 15 +- .../probes/E10ReadBackSkipsValidationProbe.cs | 107 +++++++---- .../corpus/probes/E11UseTypeKitOrderProbe.cs | 82 +++++---- .../corpus/probes/E12ConventionByTypeProbe.cs | 32 ++-- .../probes/E13PrimitiveCollectionProbe.cs | 22 ++- .../corpus/probes/E14InheritanceKeyProbe.cs | 112 ++++++------ .../corpus/probes/E15CompositeKeyProbe.cs | 142 +++++++-------- .../probes/E16OwnConfigurationWinsProbe.cs | 91 +++++----- tools/probe/corpus/registry.json | 20 +++ ...nteger-key-generated-not-on-efs-list.patch | 13 ++ ...key-generated-own-configuration-wins.patch | 18 ++ ...-type-underlying-type-does-not-reach.patch | 35 ++++ .../E13-primitive-collections-contains.patch | 22 +++ 23 files changed, 879 insertions(+), 441 deletions(-) create mode 100644 tools/probe/corpus/seeds/E04-integer-key-generated-not-on-efs-list.patch create mode 100644 tools/probe/corpus/seeds/E04-integer-key-generated-own-configuration-wins.patch create mode 100644 tools/probe/corpus/seeds/E12-convention-by-vo-type-underlying-type-does-not-reach.patch create mode 100644 tools/probe/corpus/seeds/E13-primitive-collections-contains.patch diff --git a/tools/probe/corpus/claims.json b/tools/probe/corpus/claims.json index 401f9afe..e9840317 100644 --- a/tools/probe/corpus/claims.json +++ b/tools/probe/corpus/claims.json @@ -12805,6 +12805,33 @@ "default" ] } + }, + { + "id": "not-on-efs-list", + "sentence": "That's EF's own list: `int`, `long`, `short`, `sbyte`, `uint`, `ulong` and `ushort`, and not `byte`.", + "axes": { + "shape.wrapped": [ + "string" + ], + "ef.key-config": [ + "default" + ] + } + }, + { + "id": "own-configuration-wins", + "sentence": "A configuration you write yourself always wins over these defaults.", + "axes": { + "shape.wrapped": [ + "char", + "Guid", + "string" + ], + "ef.key-config": [ + "ValueGeneratedOnAdd", + "ValueGeneratedNever" + ] + } } ], "not-promised": [ @@ -13349,6 +13376,15 @@ "value-object" ] } + }, + { + "id": "underlying-type-does-not-reach", + "sentence": "So `configurationBuilder.Properties().HavePrecision(18, 2)` reaches `decimal` properties, and not an `Amount` that wraps one.", + "axes": { + "ef.convention-type": [ + "underlying" + ] + } } ], "not-promised": [ @@ -13389,6 +13425,15 @@ "shape.kind": "db.Orders.Where(o => o.Tags.Contains(tag));" }, "obligations": [ + { + "id": "contains", + "sentence": "db.Orders.Where(o => o.Tags.Contains(tag));", + "axes": { + "ef.query": [ + "contains" + ] + } + }, { "id": "element-value", "sentence": "db.Orders.Where(o => o.Tags.Any(t => t.Value == \"x\"));", diff --git a/tools/probe/corpus/harness/EfHarness.cs b/tools/probe/corpus/harness/EfHarness.cs index 70a5c657..c6de75a1 100644 --- a/tools/probe/corpus/harness/EfHarness.cs +++ b/tools/probe/corpus/harness/EfHarness.cs @@ -149,6 +149,31 @@ public static string FirstDiff(string a, string b) } } +/// +/// How a check of a two-sided EF row ends where a side threw: the value object's context under UseTypeKit() and the +/// wrapped twin's under plain EF ran the same step, named by what. Neither threw: false, compare on. The value object +/// alone: its violation. Both, with exceptions of one type: nothing to compare, inconclusive, since no fact or sentence says why +/// the framework refuses it. Both, otherwise: a violation, since the value object did not fail as its twin does. The twin alone: +/// inconclusive, no twin to compare with. +/// +public static class EfSides +{ + public static bool Threw(Check c, Exception? vo, Exception? plain, string what, out string? failure) + { + failure = null; + if (vo is null && plain is null) return false; + if (vo is not null && plain is not null) + failure = vo.GetType() == plain.GetType() + ? c.Inconclusive($"both sides refuse {what} alike: {vo.GetType().Name}: {Short(vo.Message)}") + : $"{what}: the value object's side threw {vo.GetType().Name}: {Short(vo.Message)}; the wrapped type's {plain.GetType().Name}: {Short(plain.Message)}"; + else if (plain is not null) failure = c.Inconclusive($"the wrapped type's side refuses {what}, so there is no twin to compare with: {plain.GetType().Name}: {Short(plain.Message)}"); + else failure = $"{what} with the value object threw {vo!.GetType().Name}: {Short(vo.Message)}; the wrapped type's did not"; + return true; + } + + static string Short(string s) { s = s.Split('\n')[0]; return s.Length > 300 ? s[..300] : s; } +} + public static class Q { public static IQueryable Call(string name, Type[] typeArgs, IQueryable src, params Expression[] args) diff --git a/tools/probe/corpus/probes/E01ColumnParityProbe.cs b/tools/probe/corpus/probes/E01ColumnParityProbe.cs index af6f8735..c7720746 100644 --- a/tools/probe/corpus/probes/E01ColumnParityProbe.cs +++ b/tools/probe/corpus/probes/E01ColumnParityProbe.cs @@ -1,6 +1,10 @@ // Claim E01-column-parity (docs/ef-core.md#the-model): "Every property maps as a column of the underlying type, nullable where the property is. A facet you configure on it is kept: a precision, a length, a column type." // Oracle: differential. The EF model and the CREATE script of an entity declared with the value object (UseTypeKit) equal those of the same entity declared with the wrapped type (plain EF), in every mapping, shape and facet. -// Expected: no failure for any row; a model both refuse is an expected refusal; a plain type EF cannot map is inapplicable. +// Obligations (claims.json), one check each where the row's axes select it, each against the wrapped twin (Baseline.Twin): +// column-type (ef.mapping property, key, owned, complex): "Every property maps as a column of the underlying type, nullable where the property is." +// nullable (ef.mapping nullable): "nullable where the property is" +// facet-kept (ef.facet HasMaxLength, HavePrecision, HasColumnType): "A facet you configure on it is kept: a precision, a length, a column type." +// Expected: no failure for any row; where a side refuses the model, EfSides says how the check ends. using Microsoft.EntityFrameworkCore; using Probes.Ef; using Probes.Shapes; @@ -16,15 +20,16 @@ public sealed class E01ColumnParityProbe var e = Probes.Ef3.E4.At(r, r["shape.kind"], r["shape.wrapped"], r["shape.site"]); var mapping = r["ef.mapping"]; var facet = r["ef.facet"]; - Action? apply = null; - switch (facet) + // The facet as configured, on any wrapped type: EF keeps a length or a precision on a property of any type, and the + // wrapped twin shows what it keeps for that type. + var columnType = EfModel.ColumnTypes[e.Wrapped]; + Action? apply = facet switch { - case "HasMaxLength" when e.Wrapped is "string" or "Uri": apply = p => p.SetMaxLength(5); break; - case "HavePrecision" when e.Wrapped == "decimal": apply = p => { p.SetPrecision(18); p.SetScale(2); }; break; - case "HasColumnType": var ct = EfModel.ColumnTypes[e.Wrapped]; apply = p => p.SetColumnType(ct); break; - case "none": break; - default: r.Substituted("ef.facet", "none", "the facet does not apply to the wrapped type"); break; - } + "HasMaxLength" => p => p.SetMaxLength(5), + "HavePrecision" => p => { p.SetPrecision(18); p.SetScale(2); }, + "HasColumnType" => p => p.SetColumnType(columnType), + _ => null, + }; var integerKey = mapping == "key" && e.Wrapped is "int" or "long" or "ulong" && facet != "HasColumnType"; var voEntity = EfModel.Entity(mapping, e.Vo); var plainEntity = EfModel.Entity(mapping, e.Plain); @@ -34,20 +39,55 @@ public sealed class E01ColumnParityProbe try { voModel = EfModel.Describe(vo.Context); voScript = vo.Context.Database.GenerateCreateScript(); } catch (Exception ex) { voEx = ex; } await using (var plain = new EfDb(EfModel.Configure(mapping, plainEntity, apply, false), typeKit: false)) try { plainModel = EfModel.Describe(plain.Context); plainScript = plain.Context.Database.GenerateCreateScript(); } catch (Exception ex) { plainEx = ex; } - if (voEx is not null && plainEx is not null) { r.Note(NoteKeys.BothThrew, voEx.GetType().Name); return null; } - if (plainEx is not null) { r.Note(NoteKeys.PlainRefused, plainEx.GetType().Name); return null; } - if (voEx is not null) return $"model/script with the value object threw {voEx.GetType().Name}: {Short(voEx.Message)}; the wrapped type's did not"; - if (mapping == "key" && e.Wrapped is "char" or "Guid") + + // A key over char or Guid is never generated where EF generates the twin's (README#keys, below): the script's AUTOINCREMENT + // is that generation, compared apart by column-type, so neither check compares it in the script. + var ownKey = mapping == "key" && e.Wrapped is "char" or "Guid"; + if (ownKey) { voScript = voScript?.Replace(" AUTOINCREMENT", ""); plainScript = plainScript?.Replace(" AUTOINCREMENT", ""); } + + // The column the facet is configured on, as EfModel.Describe names it, and the fields the facet sets there. + var column = mapping switch { "key" => "t.Id: ", "complex" => "t.C.P: ", _ => "t.P: " }; + string[] facetFields = facet switch { "HasMaxLength" => ["max"], "HavePrecision" => ["prec", "scale"], "HasColumnType" => ["type"], _ => [] }; + + // column-type and nullable: every column of the model and the script, as the wrapped twin's, with the facet's own fields + // left to facet-kept (and the script too, where the facet is the column type the script spells). + var mapped = r.Check(mapping == "nullable" ? "nullable" : "column-type", Baseline.Twin, c => { - // README#keys: "A key over `char` is yours to assign. It's never generated, on any provider" and "A key over a `Guid` is yours to assign" (EF's own plain char and Guid keys are generated on add). - if (!voModel!.Contains("pk=True gen=Never")) return "a key over char or Guid is not ValueGeneratedNever: " + voModel; - voScript = voScript!.Replace(" AUTOINCREMENT", ""); plainScript = plainScript!.Replace(" AUTOINCREMENT", ""); - voModel = voModel.Replace("gen=Never", "gen=*"); plainModel = plainModel!.Replace("gen=OnAdd", "gen=*").Replace("gen=Never", "gen=*"); - r.Note(NoteKeys.DocumentedCharGuidKeyNeverGeneratedChecked, "generation compared apart: not-promised char-key-not-generated, guid-key-not-generated"); - } - if (voModel != plainModel) return "model differs: " + EfModel.FirstDiff(voModel!, plainModel!); - if (voScript != plainScript) return "create script differs: " + EfModel.FirstDiff(voScript!, plainScript!); - return null; + if (EfSides.Threw(c, voEx, plainEx, "the model or the create script", out var refused)) return refused; + string vm = Mask(voModel!, column, facetFields), pm = Mask(plainModel!, column, facetFields), vs = voScript!, ps = plainScript!; + if (ownKey) + { + // README#keys: "A key over `char` is yours to assign. It's never generated, on any provider" and "A key over a `Guid` is yours to assign" (EF's own plain char and Guid keys are generated on add). + if (!voModel!.Contains("pk=True gen=Never")) return "a key over char or Guid is not ValueGeneratedNever: " + voModel; + vm = vm.Replace("gen=Never", "gen=*"); pm = pm.Replace("gen=OnAdd", "gen=*").Replace("gen=Never", "gen=*"); + c.Note(NoteKeys.DocumentedCharGuidKeyNeverGeneratedChecked, "generation compared apart: not-promised char-key-not-generated, guid-key-not-generated"); + } + if (vm != pm) return "model differs: " + EfModel.FirstDiff(vm, pm); + if (facet != "HasColumnType" && vs != ps) return "create script differs: " + EfModel.FirstDiff(vs, ps); + c.Note(NoteKeys.Compared, facet == "HasColumnType" ? "the model" : "the model and the create script"); + return null; + }); + if (facet == "none") return mapped; + + // facet-kept: the facet's own fields of the configured column, and for a column type the script, as the wrapped twin keeps them. + var kept = r.Check("facet-kept", Baseline.Twin, c => + { + if (EfSides.Threw(c, voEx, plainEx, "the model or the create script", out var refused)) return refused; + var vl = Line(voModel!, column); var pl = Line(plainModel!, column); + foreach (var f in facetFields) + if (Field(vl, f) != Field(pl, f)) return $"{facet} not kept: the value object's column has {f}={Field(vl, f)}, the wrapped type's {f}={Field(pl, f)} ({vl})"; + if (facet == "HasColumnType" && voScript != plainScript) return "create script differs: " + EfModel.FirstDiff(voScript!, plainScript!); + c.Note(NoteKeys.Compared, string.Join(",", facetFields.Select(f => $"{f}={Field(pl, f)}"))); + return null; + }); + return mapped ?? kept; } - static string Short(string s) => s.Length > 300 ? s[..300] : s; + + /// The line EfModel.Describe writes for ; a model without it is the harness's mistake. + static string Line(string model, string column) => + model.Split('\n').FirstOrDefault(l => l.StartsWith(column, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"no column {column.TrimEnd(' ', ':')} in the model: {model}"); + static string Field(string line, string field) => System.Text.RegularExpressions.Regex.Match(line, $@"(?:^|\s){field}=(\S*)").Groups[1].Value; + /// The model with the given fields of 's line masked. + static string Mask(string model, string column, string[] fields) => string.Join("\n", model.Split('\n').Select(l => + !l.StartsWith(column, StringComparison.Ordinal) ? l : fields.Aggregate(l, (x, f) => System.Text.RegularExpressions.Regex.Replace(x, $@"(^|\s){f}=\S*", $"$1{f}=*")))); } diff --git a/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs b/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs index c88d93c5..9287e380 100644 --- a/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs +++ b/tools/probe/corpus/probes/E02ValueTranslatesProbe.cs @@ -1,6 +1,10 @@ // Claim E02-value-translates-to-column (src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries): ".Value, GetValue() and ValueOrNull() translate to the column itself" (column parity: same SQL as the query over the wrapped property). // Oracle: differential. For each query shape the SQL (ToQueryString) of the query over the value object's accessor equals the SQL of the same query over the wrapped-type property, and both return the same rows. -// Expected: no failure for any row; both sides refusing is an expected refusal. +// Obligations (claims.json), each against the wrapped twin (Baseline.Twin): +// value, get-value, value-or-null (by ef.accessor): "`.Value`, `GetValue()` and `ValueOrNull()` translate to the column itself.": the rows the query answers +// column-not-cast (every row): "A table's column is re-typed, not cast, so an index on it still serves the query.": the SQL +// Expected: no failure for any row; where a side refuses the query, EfSides says how the check ends. Where SQLite cannot compare or +// order the wrapped type, every obligation declares itself inapplicable (claims.json, fact KB-039). using System.Linq.Expressions; using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -68,9 +72,11 @@ IQueryable Build(DbContext db, Type entity, bool vo) } } - async Task<(string? Sql, List? Rows, Exception? Ex)> Side(Type entity, bool vo) + // One side's query: its SQL, or the exception translating it; the rows it answers, or the exception running it. + async Task<(string? Sql, Exception? Translating, List? Rows, Exception? Running)> Side(Type entity, bool vo) { await using var db = new EfDb(EfModel.Configure(mapping, entity, null, false), typeKit: vo); + IQueryable query; try { await db.Context.Database.EnsureCreatedAsync(); @@ -80,22 +86,40 @@ IQueryable Build(DbContext db, Type entity, bool vo) db.Context.Add(row); await db.Context.SaveChangesAsync(); db.Context.ChangeTracker.Clear(); - var query = Build(db.Context, entity, vo); - var sql = query.ToQueryString(); + query = Build(db.Context, entity, vo); + } + catch (Exception ex) { return (null, ex, null, ex); } + string sql; + try { sql = query.ToQueryString(); } + catch (Exception ex) { return (null, ex, null, ex); } + try + { var list = new List(); foreach (var x in (System.Collections.IEnumerable)query) list.Add(Norm(x)); - return (sql, list, null); + return (sql, null, list, null); } - catch (Exception ex) { return (null, null, ex); } + catch (Exception ex) { return (sql, null, null, ex); } } var v = await Side(voEntity, true); var pl = await Side(plainEntity, false); - if (v.Ex is not null && pl.Ex is not null) { r.Note(NoteKeys.BothThrew, v.Ex.GetType().Name); return null; } - if (pl.Ex is not null) { r.Note(NoteKeys.PlainRefused, pl.Ex.GetType().Name); return null; } - if (v.Ex is not null) return $"the query over the value object threw {v.Ex.GetType().Name}: {Trim(v.Ex.Message)}; the same query over the wrapped type did not"; - if (v.Sql != pl.Sql) return $"SQL differs.\nVO:\n{v.Sql}\nplain:\n{pl.Sql}"; - if (!v.Rows!.SequenceEqual(pl.Rows!)) return $"rows differ: VO [{string.Join(",", v.Rows!)}] plain [{string.Join(",", pl.Rows!)}]"; - return null; + + // The accessor's obligation: the query over it answers what the same query over the wrapped property answers. + var answered = r.Check(acc switch { "Value" => "value", "GetValue" => "get-value", _ => "value-or-null" }, Baseline.Twin, c => + { + if (EfSides.Threw(c, v.Running, pl.Running, "the query", out var refused)) return refused; + if (!v.Rows!.SequenceEqual(pl.Rows!)) return $"rows differ: VO [{string.Join(",", v.Rows!)}] plain [{string.Join(",", pl.Rows!)}]"; + c.Note(NoteKeys.Compared, $"{v.Rows!.Count} row(s)"); + return null; + }); + // column-not-cast: the SQL is the wrapped property's, so the column is re-typed and not cast. + var sqlOf = r.Check("column-not-cast", Baseline.Twin, c => + { + if (EfSides.Threw(c, v.Translating, pl.Translating, "translating the query", out var refused)) return refused; + if (v.Sql != pl.Sql) return $"SQL differs.\nVO:\n{v.Sql}\nplain:\n{pl.Sql}"; + c.Note(NoteKeys.Compared, "the SQL"); + return null; + }); + return answered ?? sqlOf; } /// A result as comparable text: an entity by its P, a value object by the value it wraps. static string Norm(object? x) @@ -106,5 +130,4 @@ static string Norm(object? x) if (t.GetProperty("Value") is { } v && v.DeclaringType == t && t.Namespace == "Probes.Ef" || t.Name == "V" || t.Name.StartsWith("E_")) return Norm(t.GetProperty("Value")!.GetValue(x)); return Convert.ToString(x, System.Globalization.CultureInfo.InvariantCulture) ?? ""; } - static string Trim(string s) => s.Length > 400 ? s[..400] : s; } diff --git a/tools/probe/corpus/probes/E03ColumnRoundTripProbe.cs b/tools/probe/corpus/probes/E03ColumnRoundTripProbe.cs index a5852250..7dc53d48 100644 --- a/tools/probe/corpus/probes/E03ColumnRoundTripProbe.cs +++ b/tools/probe/corpus/probes/E03ColumnRoundTripProbe.cs @@ -1,7 +1,10 @@ // Claim E03-column-round-trip (src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#what-maps): "Keys, foreign keys, nullable properties, primitive collections and query parameters all map as plain columns of the underlying type." // Oracle: metamorphic plus differential. A value object saved in a property / nullable property / key / owned property / complex property and read back by a fresh query equals what was saved (and its Value is the wrapped value); // the stored column (SQLite type and quoted content) equals what the wrapped type stored by plain EF holds. A nullable property also round-trips null. -// Expected: no failure for any row; a model both sides refuse is an expected refusal. +// Obligations (claims.json), one per row by ef.mapping, against the wrapped twin (Baseline.Twin): stored-as-underlying (property, owned, +// complex): "Every value object is stored in a plain column of its underlying type"; keys (key) and nullable-properties (nullable): +// "Keys, foreign keys, nullable properties, primitive collections and query parameters all map as plain columns of the underlying type." +// Expected: no failure for any row; where a side refuses, EfSides says how the check ends. using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Probes.Ef; @@ -59,19 +62,21 @@ static async Task Run(string mapping, Type entity, Func value, bo var original = e.Valid(); var voSide = await Run(mapping, EfModel.Entity(mapping, e.Vo), () => e.MakeVo(original), vo: true); var plainSide = await Run(mapping, EfModel.Entity(mapping, e.Plain), () => original, vo: false); - if (voSide.Ex is not null && plainSide.Ex is not null) { r.Note(NoteKeys.BothThrew, voSide.Ex.GetType().Name); return null; } - if (plainSide.Ex is not null) { r.Note(NoteKeys.PlainRefused, plainSide.Ex.GetType().Name); return null; } - if (voSide.Ex is not null) return $"the value object in a {mapping} failed with {voSide.Ex.GetType().Name}: {voSide.Ex.Message.Split('\n')[0]}; the wrapped type did not"; - if (voSide.Read.Count != plainSide.Read.Count) return $"read {voSide.Read.Count} rows, the wrapped type read {plainSide.Read.Count}"; - for (var i = 0; i < voSide.Read.Count; i++) + return r.Check(mapping switch { "key" => "keys", "nullable" => "nullable-properties", _ => "stored-as-underlying" }, Baseline.Twin, c => { - var v = voSide.Read[i]; var p = plainSide.Read[i]; - if (p is null != v is null) return $"row {i}: the value object read back {(v is null ? "null" : "a value")}, the wrapped type {(p is null ? "null" : "a value")}"; - if (v is null) continue; - if (!Equals(Plainify(v), p)) return $"row {i}: read back Value {Plainify(v)}, the wrapped type reads {p}"; - if (!Equals(v, e.MakeVo(p!))) return $"row {i}: the read-back value object does not equal the one built from the stored value"; - } - if (voSide.Raw != plainSide.Raw) return $"stored column differs: value object [{voSide.Raw}] vs wrapped type [{plainSide.Raw}]"; - return null; + if (EfSides.Threw(c, voSide.Ex, plainSide.Ex, $"saving and reading a {mapping}", out var refused)) return refused; + if (voSide.Read.Count != plainSide.Read.Count) return $"read {voSide.Read.Count} rows, the wrapped type read {plainSide.Read.Count}"; + for (var i = 0; i < voSide.Read.Count; i++) + { + var v = voSide.Read[i]; var p = plainSide.Read[i]; + if (p is null != v is null) return $"row {i}: the value object read back {(v is null ? "null" : "a value")}, the wrapped type {(p is null ? "null" : "a value")}"; + if (v is null) continue; + if (!Equals(Plainify(v), p)) return $"row {i}: read back Value {Plainify(v)}, the wrapped type reads {p}"; + if (!Equals(v, e.MakeVo(p!))) return $"row {i}: the read-back value object does not equal the one built from the stored value"; + } + if (voSide.Raw != plainSide.Raw) return $"stored column differs: value object [{voSide.Raw}] vs wrapped type [{plainSide.Raw}]"; + c.Note(NoteKeys.Compared, $"{voSide.Read.Count} row(s) read back, the stored column {voSide.Raw}"); + return null; + }); } } diff --git a/tools/probe/corpus/probes/E04IntegerKeyProbe.cs b/tools/probe/corpus/probes/E04IntegerKeyProbe.cs index 39bbbf1e..db254607 100644 --- a/tools/probe/corpus/probes/E04IntegerKeyProbe.cs +++ b/tools/probe/corpus/probes/E04IntegerKeyProbe.cs @@ -1,5 +1,9 @@ // Claim E04-integer-key-generated (README#keys): "A single-column key over an integer is generated on add, like a key of that integer type ... not byte. A key over char is yours to assign: never generated. A key over a Guid is yours to assign ... To have EF generate it instead, configure ValueGeneratedOnAdd()." // Oracle: differential against the same key declared with the wrapped type (two entities added with default keys, SaveChanges, the keys read back), except the documented char and Guid keys, which must not be generated by default. +// Obligations (claims.json), one per row by shape.wrapped and ef.key-config: +// generated-on-add (int, long, ulong), byte-not-generated (byte), not-on-efs-list (string, default): Baseline.Twin +// char-key-yours, guid-key-yours (char, Guid, default): the README's "yours to assign" (Baseline.Docs) +// own-configuration-wins (char, Guid, string, ValueGeneratedOnAdd or ValueGeneratedNever): Baseline.Twin using Microsoft.EntityFrameworkCore; using Probes.Ef; using Probes.Shapes; @@ -45,13 +49,27 @@ string Norm(object? k) var voOut = await Side(e.Vo, true); var plainOut = await Side(e.Plain, false); if (config == "default" && e.Wrapped is "char" or "Guid") + return r.Check(e.Wrapped == "char" ? "char-key-yours" : "guid-key-yours", Baseline.Docs("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#keys"), c => + { + // documented: yours to assign, never generated: the model declares the key never generated, and the second default + // key collides, or both stay default (not-promised meaningful-char-key-unchecked) + c.Note(NoteKeys.DocumentedCharGuidNotGeneratedChecked, "the model declares it " + voGenerated); + if (voGenerated != "Never") return $"{e.Wrapped} key is declared {voGenerated}, not never generated (plain: {plainOut})"; + return voOut.StartsWith("threw") || voOut is "keys=[empty,empty]" or "keys=[\0,\0]" ? null : $"{e.Wrapped} key was generated by default: {voOut} (plain: {plainOut})"; + }); + // the rest: as the same key declared with the wrapped type, under the same configuration + var obligation = (e.Wrapped, config) switch { - // documented: yours to assign, never generated (obligations char-key-yours, guid-key-yours): the model declares the key never - // generated, and the second default key collides, or both stay default (not-promised meaningful-char-key-unchecked) - r.Note(NoteKeys.DocumentedCharGuidNotGeneratedChecked, "the model declares it " + voGenerated); - if (voGenerated != "Never") return $"{e.Wrapped} key is declared {voGenerated}, not never generated (plain: {plainOut})"; - return voOut.StartsWith("threw") || voOut is "keys=[empty,empty]" or "keys=[\0,\0]" ? null : $"{e.Wrapped} key was generated by default: {voOut} (plain: {plainOut})"; - } - return voOut == plainOut ? null : $"VO key: {voOut}; plain key: {plainOut}"; + ("int" or "long" or "ulong", _) => "generated-on-add", + ("byte", _) => "byte-not-generated", + ("string", "default") => "not-on-efs-list", + _ => "own-configuration-wins", + }; + return r.Check(obligation, Baseline.Twin, c => + { + if (voOut != plainOut) return $"VO key: {voOut}; plain key: {plainOut}"; + c.Note(NoteKeys.Compared, voOut); + return null; + }); } } diff --git a/tools/probe/corpus/probes/E05OwnCompareToProbe.cs b/tools/probe/corpus/probes/E05OwnCompareToProbe.cs index 7acd5176..80c90646 100644 --- a/tools/probe/corpus/probes/E05OwnCompareToProbe.cs +++ b/tools/probe/corpus/probes/E05OwnCompareToProbe.cs @@ -1,5 +1,7 @@ // Claim E05-own-compareto-rejected (README#queries): "Your own CompareTo(TSelf) can't run in the database ... these queries throw an InvalidOperationException naming the value object: a comparison (<, >, <=, >=, CompareTo); an ordering (OrderBy, ThenBy, their descending forms, Order); Min or Max. ... Equality isn't ordering, and stays allowed. A query filter (HasQueryFilter, named or not) that does the same fails when the model is built." // Oracle: exception. A value object with a descending CompareTo, in each such query, must throw InvalidOperationException naming it (never return rows); equality and ordering by .Value return what memory returns. +// Obligations (claims.json), one per row by ef.op: comparison, ordering, min-max and query-filter against the documented refusal +// (Baseline.Refusal); equality-allowed and value-order-allowed against memory (Baseline.Model). using System.Linq.Expressions; using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -16,89 +18,102 @@ public sealed class E05OwnCompareToProbe static async Task Check(Probes.Shapes.Row r) { - var h = HandOrdered.Of(r["ef.vo"]); var op = r["ef.op"]; - var entity = typeof(EntP<>).MakeGenericType(h.Vo); - var filter = op is "HasQueryFilter" or "named-query-filter"; - var box = Expression.Constant(Q.NewBox(h.Vo, HandOrdered.Make(h, h.Values[1]))); - Expression Const() => Expression.Field(box, "Value"); - var p = Expression.Parameter(entity, "e"); - var prop = Expression.Property(p, "P"); - LambdaExpression Lam(Expression body) => Expression.Lambda(body, p); - Expression? predicate = null; - try + var obligation = op switch { - predicate = op switch - { - "lt" => Expression.LessThan(prop, Const()), - "gt" => Expression.GreaterThan(prop, Const()), - "le" => Expression.LessThanOrEqual(prop, Const()), - "ge" => Expression.GreaterThanOrEqual(prop, Const()), - "CompareTo" => Expression.GreaterThan(Expression.Call(prop, h.Vo.GetMethod("CompareTo", [h.Vo])!, Const()), Expression.Constant(0)), - "equals" => Expression.Equal(prop, Const()), - "HasQueryFilter" or "named-query-filter" => Expression.GreaterThan(prop, Const()), - _ => null, - }; - } - catch (InvalidOperationException ex) { r.Note(NoteKeys.NoOperator, ex.Message); return null; } - - Action cfg = mb => - { - var eb = mb.Entity(entity); - eb.ToTable("t"); eb.HasKey("Id"); - if (op == "HasQueryFilter") eb.HasQueryFilter(Lam(predicate!)); - if (op == "named-query-filter") eb.HasQueryFilter("AboveTwo", Lam(predicate!)); + "lt" or "gt" or "le" or "ge" or "CompareTo" => "comparison", + "OrderBy" or "OrderByDescending" or "ThenBy" or "Order" => "ordering", + "Min" or "Max" => "min-max", + "equals" => "equality-allowed", + "value-order" => "value-order-allowed", + _ => "query-filter", }; - await using var db = new EfDb(cfg, typeKit: true); - object[] Seed() => h.Values.Select(v => HandOrdered.Make(h, v)).ToArray(); - Exception? thrown = null; - List result = []; - try + // a refused query is held to the documented refusal; an allowed one to what memory answers + return await r.Check(obligation, Refused.Contains(op) || obligation == "query-filter" ? Baseline.Refusal : Baseline.Model, async c => { - await db.Context.Database.EnsureCreatedAsync(); - foreach (var v in Seed()) { var row = Activator.CreateInstance(entity)!; entity.GetProperty("P")!.SetValue(row, v); db.Context.Add(row); } - await db.Context.SaveChangesAsync(); - db.Context.ChangeTracker.Clear(); - var src = Q.SetOf(db.Context, entity); - object? scalar = null; - IQueryable? query = null; - switch (op) + var h = HandOrdered.Of(r["ef.vo"]); + var entity = typeof(EntP<>).MakeGenericType(h.Vo); + var filter = op is "HasQueryFilter" or "named-query-filter"; + var box = Expression.Constant(Q.NewBox(h.Vo, HandOrdered.Make(h, h.Values[1]))); + Expression Const() => Expression.Field(box, "Value"); + var p = Expression.Parameter(entity, "e"); + var prop = Expression.Property(p, "P"); + LambdaExpression Lam(Expression body) => Expression.Lambda(body, p); + Expression? predicate = null; + try { - case "lt" or "gt" or "le" or "ge" or "CompareTo" or "equals": query = Q.Call("Where", [entity], src, Lam(predicate!)); break; - case "OrderBy": query = Q.Call("OrderBy", [entity, h.Vo], src, Lam(prop)); break; - case "OrderByDescending": query = Q.Call("OrderByDescending", [entity, h.Vo], src, Lam(prop)); break; - case "ThenBy": query = Q.Call("ThenBy", [entity, h.Vo], Q.Call("OrderBy", [entity, typeof(int)], src, Lam(Expression.Property(p, "Id"))), Lam(prop)); break; - case "value-order": - var value = h.Vo.GetProperty("Value")!; - query = Q.Call("OrderBy", [entity, h.Plain], src, Lam(Expression.Property(prop, value))); break; - case "Order" or "Min" or "Max": - var sel = Q.Call("Select", [entity, h.Vo], src, Lam(prop)); - var m = typeof(Queryable).GetMethods().First(x => x.Name == op && x.IsGenericMethodDefinition && x.GetParameters().Length == 1 && x.GetGenericArguments().Length == 1).MakeGenericMethod(h.Vo); - try { var res = m.Invoke(null, [sel]); if (op == "Order") query = (IQueryable)res!; else scalar = res; } - catch (TargetInvocationException tie) { throw tie.InnerException!; } - break; - case "HasQueryFilter" or "named-query-filter": - query = src; break; - default: throw new ArgumentException(op); + predicate = op switch + { + "lt" => Expression.LessThan(prop, Const()), + "gt" => Expression.GreaterThan(prop, Const()), + "le" => Expression.LessThanOrEqual(prop, Const()), + "ge" => Expression.GreaterThanOrEqual(prop, Const()), + "CompareTo" => Expression.GreaterThan(Expression.Call(prop, h.Vo.GetMethod("CompareTo", [h.Vo])!, Const()), Expression.Constant(0)), + "equals" => Expression.Equal(prop, Const()), + "HasQueryFilter" or "named-query-filter" => Expression.GreaterThan(prop, Const()), + _ => null, + }; } - if (query is not null) foreach (var x in (System.Collections.IEnumerable)query) result.Add(Plain(x)); - else result.Add(Plain(scalar)); - } - catch (Exception ex) { thrown = ex; } + catch (InvalidOperationException ex) { return c.Inconclusive($"{op} cannot be written over {h.Vo.Name}: {ex.Message}"); } - if (Refused.Contains(op) || filter) - { - if (thrown is null) return $"{op} on {h.Vo.Name} returned [{string.Join(",", result)}] instead of throwing"; - if (thrown is not InvalidOperationException) return $"{op}: expected InvalidOperationException, got {thrown.GetType().Name}: {Trim(thrown.Message)}"; - if (!thrown.Message.Contains(h.Vo.Name)) return $"{op}: the exception does not name {h.Vo.Name}: {Trim(thrown.Message)}"; - if (filter && !thrown.Message.Contains("query filter")) return $"{op}: the exception does not say it is the query filter: {Trim(thrown.Message)}"; - if (!filter && !thrown.Message.Contains(".Value")) return $"{op}: the exception does not point to .Value: {Trim(thrown.Message)}"; - return null; - } - if (thrown is not null) return $"{op} (allowed) threw {thrown.GetType().Name}: {Trim(thrown.Message)}"; - // allowed: equals keeps the one equal row; value-order sorts by the wrapped value, ascending - var expected = op == "equals" ? new[] { Plain(h.Values[1]) } : h.Values.OrderBy(v => v, Comparer.Create((a, b) => ((IComparable)a).CompareTo(b))).Select(Plain).ToArray(); - return result.SequenceEqual(expected) ? null : $"{op}: got [{string.Join(",", result)}], memory gives [{string.Join(",", expected)}]"; + Action cfg = mb => + { + var eb = mb.Entity(entity); + eb.ToTable("t"); eb.HasKey("Id"); + if (op == "HasQueryFilter") eb.HasQueryFilter(Lam(predicate!)); + if (op == "named-query-filter") eb.HasQueryFilter("AboveTwo", Lam(predicate!)); + }; + await using var db = new EfDb(cfg, typeKit: true); + object[] Seed() => h.Values.Select(v => HandOrdered.Make(h, v)).ToArray(); + Exception? thrown = null; + List result = []; + try + { + await db.Context.Database.EnsureCreatedAsync(); + foreach (var v in Seed()) { var row = Activator.CreateInstance(entity)!; entity.GetProperty("P")!.SetValue(row, v); db.Context.Add(row); } + await db.Context.SaveChangesAsync(); + db.Context.ChangeTracker.Clear(); + var src = Q.SetOf(db.Context, entity); + object? scalar = null; + IQueryable? query = null; + switch (op) + { + case "lt" or "gt" or "le" or "ge" or "CompareTo" or "equals": query = Q.Call("Where", [entity], src, Lam(predicate!)); break; + case "OrderBy": query = Q.Call("OrderBy", [entity, h.Vo], src, Lam(prop)); break; + case "OrderByDescending": query = Q.Call("OrderByDescending", [entity, h.Vo], src, Lam(prop)); break; + case "ThenBy": query = Q.Call("ThenBy", [entity, h.Vo], Q.Call("OrderBy", [entity, typeof(int)], src, Lam(Expression.Property(p, "Id"))), Lam(prop)); break; + case "value-order": + var value = h.Vo.GetProperty("Value")!; + query = Q.Call("OrderBy", [entity, h.Plain], src, Lam(Expression.Property(prop, value))); break; + case "Order" or "Min" or "Max": + var sel = Q.Call("Select", [entity, h.Vo], src, Lam(prop)); + var m = typeof(Queryable).GetMethods().First(x => x.Name == op && x.IsGenericMethodDefinition && x.GetParameters().Length == 1 && x.GetGenericArguments().Length == 1).MakeGenericMethod(h.Vo); + try { var res = m.Invoke(null, [sel]); if (op == "Order") query = (IQueryable)res!; else scalar = res; } + catch (TargetInvocationException tie) { throw tie.InnerException!; } + break; + case "HasQueryFilter" or "named-query-filter": + query = src; break; + default: throw new ArgumentException(op); + } + if (query is not null) foreach (var x in (System.Collections.IEnumerable)query) result.Add(Plain(x)); + else result.Add(Plain(scalar)); + } + catch (Exception ex) { thrown = ex; } + + if (Refused.Contains(op) || filter) + { + if (thrown is null) return $"{op} on {h.Vo.Name} returned [{string.Join(",", result)}] instead of throwing"; + if (thrown is not InvalidOperationException) return $"{op}: expected InvalidOperationException, got {thrown.GetType().Name}: {Trim(thrown.Message)}"; + if (!thrown.Message.Contains(h.Vo.Name)) return $"{op}: the exception does not name {h.Vo.Name}: {Trim(thrown.Message)}"; + if (filter && !thrown.Message.Contains("query filter")) return $"{op}: the exception does not say it is the query filter: {Trim(thrown.Message)}"; + if (!filter && !thrown.Message.Contains(".Value")) return $"{op}: the exception does not point to .Value: {Trim(thrown.Message)}"; + return null; + } + if (thrown is not null) return $"{op} (allowed) threw {thrown.GetType().Name}: {Trim(thrown.Message)}"; + // allowed: equals keeps the one equal row; value-order sorts by the wrapped value, ascending + var expected = op == "equals" ? new[] { Plain(h.Values[1]) } : h.Values.OrderBy(v => v, Comparer.Create((a, b) => ((IComparable)a).CompareTo(b))).Select(Plain).ToArray(); + return result.SequenceEqual(expected) ? null : $"{op}: got [{string.Join(",", result)}], memory gives [{string.Join(",", expected)}]"; + }); } static string Plain(object? x) { diff --git a/tools/probe/corpus/probes/E06OwnConverterNoValueProbe.cs b/tools/probe/corpus/probes/E06OwnConverterNoValueProbe.cs index 16259607..2b58f044 100644 --- a/tools/probe/corpus/probes/E06OwnConverterNoValueProbe.cs +++ b/tools/probe/corpus/probes/E06OwnConverterNoValueProbe.cs @@ -1,4 +1,6 @@ // Claim E06-own-converter-no-value (src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries): ".Value translates only where the column holds the underlying value ... A property your application converts itself (HasConversion(c => "C:" + c.Value, ...), a converter type, a conversion in ConfigureConventions) doesn't hold .Value. So a query that reads .Value there fails with EF's 'could not be translated', rather than comparing against what the column really holds. Comparing the value object itself (o.Code == code) goes through that converter and still works." +// Obligations (claims.json), one per row by ef.op: value-not-translated (value-access), against the README (Baseline.Docs), and +// value-object-comparison (vo-equality), against the row memory matches (Baseline.Model). // Oracle: claim. With the application's own converter (three ways to declare it) the column holds the converter's text; a query on .Value throws InvalidOperationException "could not be translated" (never returns rows or an empty result); a query comparing the value object returns exactly the matching row. using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; @@ -26,43 +28,49 @@ static Action Model(string form) where T : notnull => mb => mb. static async Task Check(Probes.Shapes.Row r) { var form = r["ef.converter"]; var op = r["ef.op"]; var w = r["shape.wrapped"]; - var e = EfX.Top(EfX.Kinds[r.Index % EfX.Kinds.Length], w); - OwnConvs.Setup(e, vo: true); - var entity = typeof(EntP<>).MakeGenericType(e.Vo); - var cfg = (Action)typeof(E06OwnConverterNoValueProbe).GetMethod(nameof(Model), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!.MakeGenericMethod(e.Vo).Invoke(null, [form])!; - var conv = (Action?)typeof(E06OwnConverterNoValueProbe).GetMethod(nameof(Conventions), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!.MakeGenericMethod(e.Vo).Invoke(null, [form]); - await using var db = new EfDb2(cfg, "after-provider", conv); - await db.Context.Database.EnsureCreatedAsync(); - foreach (var i in new[] { 0, 1 }) { var x = EfX.New(entity); EfX.Set(x, "P", e.MakeVo(EfX.Sample(w, i))); db.Context.Add(x); } - await db.Context.SaveChangesAsync(); - db.Context.ChangeTracker.Clear(); + // value-access: the README's "could not be translated" (no twin can show it); vo-equality: the one row memory matches + var valueAccess = op == "value-access"; + return await r.Check(valueAccess ? "value-not-translated" : "value-object-comparison", + valueAccess ? Baseline.Docs("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#queries") : Baseline.Model, async c => + { + var e = EfX.Top(EfX.Kinds[r.Index % EfX.Kinds.Length], w); + OwnConvs.Setup(e, vo: true); + var entity = typeof(EntP<>).MakeGenericType(e.Vo); + var cfg = (Action)typeof(E06OwnConverterNoValueProbe).GetMethod(nameof(Model), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!.MakeGenericMethod(e.Vo).Invoke(null, [form])!; + var conv = (Action?)typeof(E06OwnConverterNoValueProbe).GetMethod(nameof(Conventions), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!.MakeGenericMethod(e.Vo).Invoke(null, [form]); + await using var db = new EfDb2(cfg, "after-provider", conv); + await db.Context.Database.EnsureCreatedAsync(); + foreach (var i in new[] { 0, 1 }) { var x = EfX.New(entity); EfX.Set(x, "P", e.MakeVo(EfX.Sample(w, i))); db.Context.Add(x); } + await db.Context.SaveChangesAsync(); + db.Context.ChangeTracker.Clear(); - var stored = await EfX2.RawScalar(db.Connection, "SELECT group_concat(P, ',') FROM (SELECT P FROM t ORDER BY Id)"); - var expected = "C:" + OwnConv.Fmt(EfX.Sample(w, 0)) + ",C:" + OwnConv.Fmt(EfX.Sample(w, 1)); - if (stored != expected) return $"{form}: the column holds {stored}, expected the application's converter output {expected}"; + var stored = await EfX2.RawScalar(db.Connection, "SELECT group_concat(P, ',') FROM (SELECT P FROM t ORDER BY Id)"); + var expected = "C:" + OwnConv.Fmt(EfX.Sample(w, 0)) + ",C:" + OwnConv.Fmt(EfX.Sample(w, 1)); + if (stored != expected) return $"{form}: the column holds {stored}, expected the application's converter output {expected}"; - var p = Expression.Parameter(entity, "e"); - var prop = Expression.Property(p, "P"); - var src = Q.SetOf(db.Context, entity); - if (op == "vo-equality") - { - var box = Q.NewBox(e.Vo, e.MakeVo(EfX.Sample(w, 1))); - var pred = Expression.Lambda(Expression.Equal(prop, Expression.Field(Expression.Constant(box), "Value")), p); - List got; - // a refusal here is the claim failing, not the harness: comparing the value object goes through the application's converter - try { got = EfX.Enumerate(Q.Call("Where", [entity], src, pred)).ToList(); } - catch (InvalidOperationException ex) { return $"{form}: comparing the value object failed with {ex.GetType().Name}: {ex.Message[..Math.Min(200, ex.Message.Length)]}"; } - if (got.Count != 1 || EfX.Plainify(EfX.Get(got[0], "P")) != EfX.Plainify(EfX.Sample(w, 1))) return $"{form}: comparing the value object returned {got.Count} row(s), expected the one that matches"; - return null; - } - var pbox = Q.NewBox(e.Plain, EfX.Sample(w, 1)); - var vpred = Expression.Lambda(Expression.Equal(Expression.Property(prop, "Value"), Expression.Field(Expression.Constant(pbox), "Value")), p); - try - { - var got = EfX.Enumerate(Q.Call("Where", [entity], src, vpred)).ToList(); - return $"{form}: a query on .Value of a property the application converts itself returned {got.Count} row(s) instead of failing as untranslatable"; - } - catch (InvalidOperationException ex) when (ex.Message.Contains("could not be translated")) { return null; } - catch (Exception ex) { return $"{form}: the .Value query failed with {ex.GetType().Name}: {ex.Message[..Math.Min(200, ex.Message.Length)]}, not EF's 'could not be translated'"; } + var p = Expression.Parameter(entity, "e"); + var prop = Expression.Property(p, "P"); + var src = Q.SetOf(db.Context, entity); + if (op == "vo-equality") + { + var box = Q.NewBox(e.Vo, e.MakeVo(EfX.Sample(w, 1))); + var pred = Expression.Lambda(Expression.Equal(prop, Expression.Field(Expression.Constant(box), "Value")), p); + List got; + // a refusal here is the claim failing, not the harness: comparing the value object goes through the application's converter + try { got = EfX.Enumerate(Q.Call("Where", [entity], src, pred)).ToList(); } + catch (InvalidOperationException ex) { return $"{form}: comparing the value object failed with {ex.GetType().Name}: {ex.Message[..Math.Min(200, ex.Message.Length)]}"; } + if (got.Count != 1 || EfX.Plainify(EfX.Get(got[0], "P")) != EfX.Plainify(EfX.Sample(w, 1))) return $"{form}: comparing the value object returned {got.Count} row(s), expected the one that matches"; + return null; + } + var pbox = Q.NewBox(e.Plain, EfX.Sample(w, 1)); + var vpred = Expression.Lambda(Expression.Equal(Expression.Property(prop, "Value"), Expression.Field(Expression.Constant(pbox), "Value")), p); + try + { + var got = EfX.Enumerate(Q.Call("Where", [entity], src, vpred)).ToList(); + return $"{form}: a query on .Value of a property the application converts itself returned {got.Count} row(s) instead of failing as untranslatable"; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("could not be translated")) { return null; } + catch (Exception ex) { return $"{form}: the .Value query failed with {ex.GetType().Name}: {ex.Message[..Math.Min(200, ex.Message.Length)]}, not EF's 'could not be translated'"; } + }); } } diff --git a/tools/probe/corpus/probes/E07ClosedSetNameProbe.cs b/tools/probe/corpus/probes/E07ClosedSetNameProbe.cs index eec62c9d..16bf3562 100644 --- a/tools/probe/corpus/probes/E07ClosedSetNameProbe.cs +++ b/tools/probe/corpus/probes/E07ClosedSetNameProbe.cs @@ -1,4 +1,6 @@ // Claim E07-closed-set-stored-as-name (README#closed-sets): "A closed set is stored as a text column of the member's name, and read back as the member itself. That holds in a table, in a primitive collection of names and in a JSON column alike ... A name the set no longer has throws when it's read, rather than reading as null. An instance that isn't a member throws when it's written." +// Obligations (claims.json), against the README's section (Baseline.Docs): stored-as-name (round-trip), in-collections-and-json +// (round-trip in a primitive collection or a JSON column), unknown-name-throws (unknown-name-on-read), non-member-throws (non-member-on-write). // Oracle: claim. Round trip: the stored text is the member's name and the read value is the member itself (reference-equal); an unknown stored name throws on read (never null/default); a non-member instance throws on write. using Microsoft.EntityFrameworkCore; using Probes.Ef; @@ -50,6 +52,7 @@ object Entity(params PSet[] members) _ => ((Own)EfX.Get(e, "O")!).P.Name(), }; + var docs = Baseline.Docs("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#closed-sets"); await using var db = new EfDb(cfg, typeKit: true); await db.Context.Database.EnsureCreatedAsync(); switch (ev) @@ -60,15 +63,35 @@ object Entity(params PSet[] members) db.Context.Add(e); await db.Context.SaveChangesAsync(); db.Context.ChangeTracker.Clear(); var stored = await EfX.RawScalar(db, $"SELECT {column} FROM t"); var expectStored = site switch { "table" => "Beta", "primitive-collection" => "[\"Beta\",\"Alpha\"]", _ => "{\"P\":\"Beta\"}" }; - if (stored != expectStored) return $"stored text is {stored}, expected {expectStored}"; - var back = EfX.Enumerate(Q.SetOf(db.Context, entity)).Single(); - if (site == "primitive-collection") + // stored-as-name: a table's text is the member's name, and every site reads back the member itself + var named = r.Check("stored-as-name", docs, c => { - var l = (List)EfX.Get(back, "Items")!; - return l.Count == 2 && ReferenceEquals(l[0], PSet.Beta) && ReferenceEquals(l[1], PSet.Alpha) ? null : "read back " + Members(back); - } - var got = site == "table" ? (PSet)EfX.Get(back, "P")! : ((Own)EfX.Get(back, "O")!).P; - return ReferenceEquals(got, PSet.Beta) ? null : "read back is not the member itself: " + got; + if (site == "table" && stored != expectStored) return $"stored text is {stored}, expected {expectStored}"; + object back; + try { back = EfX.Enumerate(Q.SetOf(db.Context, entity)).Single(); } + catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return $"reading the stored {stored} back threw {ex.GetType().Name}: {ex.Message.Split('\n')[0]}"; } + if (site == "primitive-collection") + { + var l = (List)EfX.Get(back, "Items")!; + if (!(l.Count == 2 && ReferenceEquals(l[0], PSet.Beta) && ReferenceEquals(l[1], PSet.Alpha))) return "read back " + Members(back); + } + else + { + var got = site == "table" ? (PSet)EfX.Get(back, "P")! : ((Own)EfX.Get(back, "O")!).P; + if (!ReferenceEquals(got, PSet.Beta)) return "read back is not the member itself: " + got; + } + c.Note(NoteKeys.Compared, site == "table" ? "the stored name and the member read back" : "the members read back"); + return null; + }); + if (site == "table") return named; + // in-collections-and-json: a primitive collection holds the names as a JSON array, a JSON column the name as the property's value + var inJson = r.Check("in-collections-and-json", docs, c => + { + if (stored != expectStored) return $"stored text is {stored}, expected {expectStored}"; + c.Note(NoteKeys.Compared, stored); + return null; + }); + return named ?? inJson; } case "unknown-name-on-read": { @@ -76,21 +99,27 @@ object Entity(params PSet[] members) db.Context.Add(e); await db.Context.SaveChangesAsync(); db.Context.ChangeTracker.Clear(); var bad = site switch { "table" => "'Gamma'", "primitive-collection" => "'[\"Alpha\",\"Gamma\"]'", _ => "'{\"P\":\"Gamma\"}'" }; await EfX.Raw(db, $"UPDATE t SET {column} = {bad}"); - try + return r.Check("unknown-name-throws", docs, c => { - var back = EfX.Enumerate(Q.SetOf(db.Context, entity)).ToList(); - return "an unknown stored name read without throwing: " + (back.Count == 1 ? Safe(() => Members(back[0])) : back.Count + " rows"); - } - catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return null; } + try + { + var back = EfX.Enumerate(Q.SetOf(db.Context, entity)).ToList(); + return "an unknown stored name read without throwing: " + (back.Count == 1 ? Safe(() => Members(back[0])) : back.Count + " rows"); + } + catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { c.Note(NoteKeys.Compared, ex.GetType().Name); return null; } + }); } case "non-member-on-write": { var e = Entity(NonMember()); db.Context.Add(e); - try { await db.Context.SaveChangesAsync(); } - catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return null; } - var stored = await EfX.RawScalar(db, $"SELECT {column} FROM t"); - return "a non-member instance was written without throwing; stored: " + stored; + return await r.Check("non-member-throws", docs, async c => + { + try { await db.Context.SaveChangesAsync(); } + catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { c.Note(NoteKeys.Compared, ex.GetType().Name); return null; } + var stored = await EfX.RawScalar(db, $"SELECT {column} FROM t"); + return "a non-member instance was written without throwing; stored: " + stored; + }); } default: throw new ArgumentException(ev); } diff --git a/tools/probe/corpus/probes/E08OptionResultNotColumnsProbe.cs b/tools/probe/corpus/probes/E08OptionResultNotColumnsProbe.cs index 78b05235..06908358 100644 --- a/tools/probe/corpus/probes/E08OptionResultNotColumnsProbe.cs +++ b/tools/probe/corpus/probes/E08OptionResultNotColumnsProbe.cs @@ -1,6 +1,8 @@ // Claim E08-option-result-not-columns (docs/ef-core.md#option-and-result): "EF can't map an Option or a Result property, and building the model fails with an error that says so." // Oracle: claim. Building the model (UseTypeKit on) of an entity with an Option/Result as property, key, list element or owned property throws, and the message names the Option/Result type (not a silent column). -// Expected: every row throws InvalidOperationException whose message mentions "Option" or "Result". A model that builds, or a message that does not name the type, is a violation. +// Obligations (claims.json), both on every row, against the docs page's section (Baseline.Docs): model-fails ("EF can't map an `Option` +// or a `Result` property": InvalidOperationException, never a column) and error-says-so ("building the model fails with an error that +// says so": the message names the type). A model that builds, or a message that does not name the type, is a violation. using Microsoft.EntityFrameworkCore; using CodoMetis.TypeKit; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -44,16 +46,31 @@ public sealed class E08OptionResultNotColumnsProbe if (pos == "owned-property") eb.OwnsOne(typeof(Own<>).MakeGenericType(t), "O"); }; await using var db = new EfDb(cfg, typeKit: true); + Exception? thrown = null; try { _ = db.Context.GetService().Model; await db.Context.Database.EnsureCreatedAsync(); } - catch (InvalidOperationException ex) + catch (Exception ex) { thrown = ex; } + var docs = Baseline.Docs("docs/ef-core.md#option-and-result"); + var built = $"the model with {t.Name} as {pos} built and created the table"; + // model-fails: building the model is refused (InvalidOperationException), never a silent column + var fails = r.Check("model-fails", docs, c => { - r.Note(NoteKeys.RefusalNamesTheType, ex.Message.Split('\n')[0]); var friendly = t.Name.Split('`')[0] + "<"; return ex.Message.Contains(friendly) ? null : $"model build failed but the message does not name the type: {ex.Message.Split('\n')[0]}"; - } - catch (Exception ex) { return $"model build threw {ex.GetType().Name}, not InvalidOperationException: {ex.Message.Split('\n')[0]}"; } - return $"the model with {t.Name} as {pos} built and created the table"; + if (thrown is null) return built; + if (thrown is not InvalidOperationException) return $"model build threw {thrown.GetType().Name}, not InvalidOperationException: {thrown.Message.Split('\n')[0]}"; + c.Note(NoteKeys.Compared, thrown.GetType().Name); + return null; + }); + // error-says-so: the refusal names the Option or Result type + var says = r.Check("error-says-so", docs, c => + { + if (thrown is null) return built + ", with no error to say so"; + c.Note(NoteKeys.RefusalNamesTheType, thrown.Message.Split('\n')[0]); + var friendly = t.Name.Split('`')[0] + "<"; + return thrown.Message.Contains(friendly) ? null : $"model build failed but the message does not name the type: {thrown.Message.Split('\n')[0]}"; + }); + return fails ?? says; } } diff --git a/tools/probe/corpus/probes/E09OrNoneInDbProbe.cs b/tools/probe/corpus/probes/E09OrNoneInDbProbe.cs index c77abd98..9f18dd6b 100644 --- a/tools/probe/corpus/probes/E09OrNoneInDbProbe.cs +++ b/tools/probe/corpus/probes/E09OrNoneInDbProbe.cs @@ -1,5 +1,7 @@ // Claim E09-or-none-in-database (src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#an-option-for-nothing-found): "These ask the database for one row and let it run the condition: FirstOrNoneAsync, FirstOrNone, FirstOrError read one row (WHERE ... LIMIT); SingleOrNone(Async) ask for two rows and throw for more than one; ElementAtOrNone skips to its row (OFFSET)." // Oracle: claim. Over a table of value-object keys the operator returns what an in-memory evaluation of the same condition gives (none / the first match / the error), the executed SELECT carries the condition (WHERE) and LIMIT (OFFSET for ElementAtOrNone), and SingleOrNone throws InvalidOperationException for more than one match. +// Obligations (claims.json), one per row by ef.operator, against the README (Baseline.Docs): first-reads-one-row (FirstOrNoneAsync, +// FirstOrNone, FirstOrError), single-asks-for-two (SingleOrNoneAsync, SingleOrNone), element-at-skips (ElementAtOrNone). // Expected: no failure for any row. The condition is written as a lambda at the call or held as an Expression> variable. #nullable enable #pragma warning disable CMTK0014, CS8618, CS1591, CMTK0005, CMTK1000, CMTK0003, CMTK0006, CA1000 @@ -26,7 +28,18 @@ public sealed class E09OrNoneInDbProbe [Theory, MemberData(nameof(Data))] public Task Row(string probe, int idx) => Rec.Run(probe, idx, Check); - static Task Check(Probes.Shapes.Row r) => r["shape.wrapped"] switch { "int" => Run_int(r), "Guid" => Run_Guid(r), "string" => Run_string(r), var w => throw new ArgumentException(w) }; + /// One check per row, the operator's obligation, held to the README's SQL and to the in-memory answer of the same condition. + static Task Check(Probes.Shapes.Row r) + { + var obligation = r["ef.operator"] switch + { + "FirstOrNoneAsync" or "FirstOrNone" or "FirstOrError" => "first-reads-one-row", + "SingleOrNoneAsync" or "SingleOrNone" => "single-asks-for-two", + _ => "element-at-skips", + }; + return r.Check(obligation, Baseline.Docs("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#an-option-for-nothing-found"), c => + c["shape.wrapped"] switch { "int" => Run_int(r), "Guid" => Run_Guid(r), "string" => Run_string(r), var w => throw new ArgumentException(w) }); + } static string Flat(string s) => s.Replace("\n", " "); static string? Verify(string op, string data, string got, string? thrown, List matching, SqlCap cap) diff --git a/tools/probe/corpus/probes/E10ReadBackSkipsValidationProbe.cs b/tools/probe/corpus/probes/E10ReadBackSkipsValidationProbe.cs index b85dfa32..5daa5fc1 100644 --- a/tools/probe/corpus/probes/E10ReadBackSkipsValidationProbe.cs +++ b/tools/probe/corpus/probes/E10ReadBackSkipsValidationProbe.cs @@ -1,5 +1,7 @@ // Claim E10-read-back-skips-validation (docs/ef-core.md#reading-back-without-validation): "EF reads a column back without validation, through the value object's Materialize rather than Create." Revalidate() finds the rows today's rules reject. // Oracle: claim. A row holding a value the value object's Create refuses (written by a plain-typed twin of the entity into the same table) is read back by EF with UseTypeKit: no exception, .Value is the stored value, Revalidate() is an Error. +// Obligations (claims.json), both on every row, against the docs page (Baseline.Docs): reads-without-create (the row reads back, +// .Value as stored) and revalidate-finds (Revalidate() is an Error). // Expected: no failure for any row. using System.Reflection; using Microsoft.EntityFrameworkCore; @@ -30,46 +32,89 @@ public sealed class E10ReadBackSkipsValidationProbe var voEntity = EfModel.Entity(mapping, e.Vo); var plainEntity = EfModel.Entity(mapping, e.Plain); await using var vo = new EfDb(EfModel.Configure(mapping, voEntity, null, false), typeKit: true); - try { await vo.Context.Database.EnsureCreatedAsync(); } catch (Exception ex) { r.Note(NoteKeys.BothThrew, "model " + ex.GetType().Name); return null; } - // a plain-typed twin on the same connection writes the row (nothing validates a plain value) - var twinCfg = EfModel.Configure(mapping, plainEntity, p => { if (mapping == "key") p.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; }, false); - var twinOpts = new DbContextOptionsBuilder().UseSqlite(vo.Connection).ReplaceService().Options; - await using (var twin = new DynCtx(twinOpts, twinCfg)) + Exception? voModel = null, plainModel = null, written = null; + try { await vo.Context.Database.EnsureCreatedAsync(); } + catch (Exception ex) { - var row = Activator.CreateInstance(plainEntity)!; - if (mapping == "key") { EfX.Set(row, "Id", refused); EfX.Set(row, "Tag", "t"); } - else - { - EfX.Set(row, "Id", 1); - object? holder = row; - if (mapping is "owned" or "complex") { var prop = mapping == "owned" ? "O" : "C"; holder = EfX.Get(row, prop); } - EfX.Set(holder!, "P", refused); - } - twin.Add(row); - try { await twin.SaveChangesAsync(); } catch (Exception ex) { r.Note(NoteKeys.PlainRefused, ex.GetType().Name); return null; } + voModel = ex; + // the wrapped twin's table on its own, to tell a model the framework refuses from one the value object breaks + await using var alone = new EfDb(EfModel.Configure(mapping, plainEntity, null, false), typeKit: false); + try { await alone.Context.Database.EnsureCreatedAsync(); } catch (Exception pex) { plainModel = pex; } } - object? read; - try + object? read = null; string? readFailed = null; + if (voModel is null) { - var list = EfX.Enumerate(Q.SetOf(vo.Context, voEntity)).ToList(); - if (list.Count != 1) return $"expected 1 row, read {list.Count}"; - read = list[0]; + // a plain-typed twin on the same connection writes the row (nothing validates a plain value) + var twinCfg = EfModel.Configure(mapping, plainEntity, p => { if (mapping == "key") p.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never; }, false); + var twinOpts = new DbContextOptionsBuilder().UseSqlite(vo.Connection).ReplaceService().Options; + await using (var twin = new DynCtx(twinOpts, twinCfg)) + { + var row = Activator.CreateInstance(plainEntity)!; + if (mapping == "key") { EfX.Set(row, "Id", refused); EfX.Set(row, "Tag", "t"); } + else + { + EfX.Set(row, "Id", 1); + object? holder = row; + if (mapping is "owned" or "complex") { var prop = mapping == "owned" ? "O" : "C"; holder = EfX.Get(row, prop); } + EfX.Set(holder!, "P", refused); + } + twin.Add(row); + try { await twin.SaveChangesAsync(); } catch (Exception ex) { written = ex; } + } + if (written is null) + try + { + var list = EfX.Enumerate(Q.SetOf(vo.Context, voEntity)).ToList(); + if (list.Count != 1) readFailed = $"expected 1 row, read {list.Count}"; + else read = list[0]; + } + catch (Exception ex) { readFailed = $"reading the row back threw {ex.GetType().Name}: {ex.Message.Split('\n')[0]}"; } } - catch (Exception ex) { return $"reading the row back threw {ex.GetType().Name}: {ex.Message.Split('\n')[0]}"; } - object? vobj = mapping switch + object? vobj = read is null ? null : mapping switch { "key" => EfX.Get(read, "Id"), "owned" => EfX.Get(EfX.Get(read, "O")!, "P"), "complex" => EfX.Get(EfX.Get(read, "C")!, "P"), _ => EfX.Get(read, "P"), }; - if (vobj is null) return "the value object read back is null"; - var stored = vobj.GetType().GetProperty("Value")!.GetValue(vobj); - if (!Equals(stored, refused)) return $"read-back Value is {stored}, the row holds {refused}"; - var reval = vobj.GetType().GetMethod("Revalidate", Type.EmptyTypes); - if (reval is null) return "no Revalidate() on " + vobj.GetType().Name; - var res = reval.Invoke(vobj, null)!; - var state = res.GetType().GetProperty("State")!.GetValue(res)!.ToString(); - return state == "Error" ? null : $"Revalidate() of the stored refused value is {state}, expected Error"; + var docs = Baseline.Docs("docs/ef-core.md#reading-back-without-validation"); + // Where the row could not be written, neither check has anything to read: the model refused (compared with the twin's), or the + // twin's write of the refused value (nothing documents why a plain value would be refused). + string? Unwritten(Probes.Shapes.Check c, out bool ended) + { + ended = true; + if (voModel is not null) return EfSides.Threw(c, voModel, plainModel, "creating the table", out var refused) ? refused : null; + if (written is not null) return c.Inconclusive($"the plain twin could not write the value Create refuses: {written.GetType().Name}: {written.Message.Split('\n')[0]}"); + ended = false; + return null; + } + + // reads-without-create: the stored value Create refuses is read back, as it is stored + var reads = r.Check("reads-without-create", docs, c => + { + var unwritten = Unwritten(c, out var ended); + if (ended) return unwritten; + if (readFailed is not null) return readFailed; + if (vobj is null) return "the value object read back is null"; + var stored = vobj.GetType().GetProperty("Value")!.GetValue(vobj); + if (!Equals(stored, refused)) return $"read-back Value is {stored}, the row holds {refused}"; + c.Note(NoteKeys.StoredFactoryReadUnvalidated, $"{refused}"); + return null; + }); + // revalidate-finds: Revalidate() of what was read back is the refusal Create gives today + var revalidates = r.Check("revalidate-finds", docs, c => + { + var unwritten = Unwritten(c, out var ended); + if (ended) return unwritten; + if (vobj is null) return c.Inconclusive("nothing was read back to revalidate: " + (readFailed ?? "the value object read back is null")); + var reval = vobj.GetType().GetMethod("Revalidate", Type.EmptyTypes); + if (reval is null) return "no Revalidate() on " + vobj.GetType().Name; + var res = reval.Invoke(vobj, null)!; + var state = res.GetType().GetProperty("State")!.GetValue(res)!.ToString(); + if (state != "Error") return $"Revalidate() of the stored refused value is {state}, expected Error"; + c.Note(NoteKeys.Compared, "Revalidate() is an Error"); + return null; + }); + return reads ?? revalidates; } } diff --git a/tools/probe/corpus/probes/E11UseTypeKitOrderProbe.cs b/tools/probe/corpus/probes/E11UseTypeKitOrderProbe.cs index 56d7a3be..df2415f4 100644 --- a/tools/probe/corpus/probes/E11UseTypeKitOrderProbe.cs +++ b/tools/probe/corpus/probes/E11UseTypeKitOrderProbe.cs @@ -1,4 +1,5 @@ // Claim E11-usetypekit-order (src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#setup): "UseTypeKit() adds plugins ... It replaces nothing. So it works alongside a provider ... and it doesn't matter whether you call it before or after UseNpgsql or UseSqlite." +// Obligation (claims.json): order-does-not-matter, on every row, held to the README's sentence (Baseline.Docs) by the differential below. // Oracle: differential between the two orders (and, where a plain type maps the same way, the wrapped type): the model, the CREATE script, the raw stored row and the values read back are equal in every mapping. using System.Data.Common; using Microsoft.EntityFrameworkCore; @@ -66,49 +67,52 @@ static async Task RunUnguarded(string order, string mapping, Type entity, F static async Task Check(Probes.Shapes.Row r) { - var mapping = r["ef.mapping"]; var order = r["ef.order"]; - var other = order == "before-provider" ? "after-provider" : "before-provider"; - string w = mapping switch { "integer-key" => r.Index % 2 == 0 ? "int" : "long", "key" => r.Index % 2 == 0 ? "Guid" : "string", "closed-set" => "-", _ => Ws[r.Index % Ws.Length] }; - Out a, b; Out? plain = null; - if (mapping == "closed-set") + return await r.Check("order-does-not-matter", Baseline.Docs("src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#setup"), async c => { - var entity = typeof(EntP); - Action cfg = mb => { var eb = mb.Entity(entity); eb.ToTable("t"); eb.HasKey("Id"); }; - a = await Run(order, mapping, entity, _ => PSet.Beta, cfg, null); - b = await Run(other, mapping, entity, _ => PSet.Beta, cfg, null); - if (!a.Raw.Contains("String:Beta")) return "closed set not stored as its name text: " + a.Raw; - } - else - { - var kind = EfX.Kinds[r.Index % EfX.Kinds.Length]; - var e = mapping is "key" or "integer-key" ? KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == w) : EfX.Top(kind, w); - var map = mapping == "integer-key" ? "key" : mapping; - var entity = EfModel.Entity(map, e.Vo); - var cfg = EfModel.Configure(map, entity, null, false); - Func value = i => e.MakeVo(EfX.Sample(w, i)); - a = await Run(order, mapping, entity, value, cfg, w); - b = await Run(other, mapping, entity, value, cfg, w); - if (mapping is "property" or "nullable" or "owned" or "complex") + var mapping = r["ef.mapping"]; var order = r["ef.order"]; + var other = order == "before-provider" ? "after-provider" : "before-provider"; + string w = mapping switch { "integer-key" => r.Index % 2 == 0 ? "int" : "long", "key" => r.Index % 2 == 0 ? "Guid" : "string", "closed-set" => "-", _ => Ws[r.Index % Ws.Length] }; + Out a, b; Out? plain = null; + if (mapping == "closed-set") { - var pe = EfModel.Entity(map, e.Plain); - plain = await Run("none", mapping, pe, i => EfX.Sample(w, i), EfModel.Configure(map, pe, null, false), w); + var entity = typeof(EntP); + Action cfg = mb => { var eb = mb.Entity(entity); eb.ToTable("t"); eb.HasKey("Id"); }; + a = await Run(order, mapping, entity, _ => PSet.Beta, cfg, null); + b = await Run(other, mapping, entity, _ => PSet.Beta, cfg, null); + if (!a.Raw.Contains("String:Beta")) return "closed set not stored as its name text: " + a.Raw; } - else if (mapping == "integer-key") + else { - var ids = a.Read.Split(';'); - if (ids.Distinct().Count() != 2 || ids.Any(x => x is "0" or "")) return "integer key not generated after " + order + ": " + a.Read; + var kind = EfX.Kinds[r.Index % EfX.Kinds.Length]; + var e = mapping is "key" or "integer-key" ? KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == w) : EfX.Top(kind, w); + var map = mapping == "integer-key" ? "key" : mapping; + var entity = EfModel.Entity(map, e.Vo); + var cfg = EfModel.Configure(map, entity, null, false); + Func value = i => e.MakeVo(EfX.Sample(w, i)); + a = await Run(order, mapping, entity, value, cfg, w); + b = await Run(other, mapping, entity, value, cfg, w); + if (mapping is "property" or "nullable" or "owned" or "complex") + { + var pe = EfModel.Entity(map, e.Plain); + plain = await Run("none", mapping, pe, i => EfX.Sample(w, i), EfModel.Configure(map, pe, null, false), w); + } + else if (mapping == "integer-key") + { + var ids = a.Read.Split(';'); + if (ids.Distinct().Count() != 2 || ids.Any(x => x is "0" or "")) return "integer key not generated after " + order + ": " + a.Read; + } } - } - if (a.Model != b.Model) return $"model differs between {order} and {other}: " + EfModel.FirstDiff(a.Model, b.Model); - if (a.Model.StartsWith("threw ", StringComparison.Ordinal)) return $"the value object's entity is refused in both orders: {a.Model}"; - if (a.Script != b.Script) return $"create script differs between {order} and {other}: " + EfModel.FirstDiff(a.Script, b.Script); - if (a.Raw != b.Raw) return $"stored row differs between {order} and {other}: {a.Raw} | {b.Raw}"; - if (a.Read != b.Read) return $"values read differ between {order} and {other}: {a.Read} | {b.Read}"; - if (plain is not null) - { - if (a.Model != plain.Model) return "model differs from the wrapped type's: " + EfModel.FirstDiff(a.Model, plain.Model); - if (a.Raw != plain.Raw) return $"stored row differs from the wrapped type's: {a.Raw} | {plain.Raw}"; - } - return null; + if (a.Model != b.Model) return $"model differs between {order} and {other}: " + EfModel.FirstDiff(a.Model, b.Model); + if (a.Model.StartsWith("threw ", StringComparison.Ordinal)) return $"the value object's entity is refused in both orders: {a.Model}"; + if (a.Script != b.Script) return $"create script differs between {order} and {other}: " + EfModel.FirstDiff(a.Script, b.Script); + if (a.Raw != b.Raw) return $"stored row differs between {order} and {other}: {a.Raw} | {b.Raw}"; + if (a.Read != b.Read) return $"values read differ between {order} and {other}: {a.Read} | {b.Read}"; + if (plain is not null) + { + if (a.Model != plain.Model) return "model differs from the wrapped type's: " + EfModel.FirstDiff(a.Model, plain.Model); + if (a.Raw != plain.Raw) return $"stored row differs from the wrapped type's: {a.Raw} | {plain.Raw}"; + } + return null; + }); } } diff --git a/tools/probe/corpus/probes/E12ConventionByTypeProbe.cs b/tools/probe/corpus/probes/E12ConventionByTypeProbe.cs index 80daf055..0c2581b9 100644 --- a/tools/probe/corpus/probes/E12ConventionByTypeProbe.cs +++ b/tools/probe/corpus/probes/E12ConventionByTypeProbe.cs @@ -1,4 +1,6 @@ // Claim E12-convention-by-vo-type (src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#model-wide-conventions-follow-the-propertys-type): "EF applies ConfigureConventions by CLR type. So Properties().HavePrecision(18, 2) reaches decimal properties, and not an Amount that wraps one. Configure the value object instead: Properties().HavePrecision(18, 2)." +// Obligations (claims.json), one per row by ef.convention-type: value-object-type-reaches (value-object), against the same convention on +// the wrapped type and a plain property (Baseline.Twin); underlying-type-does-not-reach (underlying), against the README (Baseline.Docs). // Oracle: differential + claim. A convention on the value object type reaches the value object's property exactly as the same convention on the wrapped type reaches a plain property; a convention on the wrapped type does not reach the value object's property (its model equals the model without the convention) while it does reach the plain one. using Microsoft.EntityFrameworkCore; using Probes.Ef; @@ -43,17 +45,27 @@ static async Task Line(Type t, string order, Action { - var reached = await Line(e.Vo, "after-provider", Conv(facet, e.Plain, w)); - return (reached == voNone ? null : $"a convention on {w} reached the value object's property: {reached} (without it: {voNone}); the plain property got {plainConv}"); - } - var voConv = await Line(e.Vo, "after-provider", Conv(facet, e.Vo, w)); - if (voConv == voNone) return ($"a convention on the value object type {e.Vo.Name} did not reach its property: {voConv}"); - if (voConv != plainConv) return ($"the convention on the value object gave {voConv}; on the plain {w} {plainConv}"); - return null; + if (plainConv == plainNone) return c.Inconclusive($"{facet} changes nothing on a plain {w} property either, so there is nothing to compare"); + var voNone = await Line(e.Vo, "after-provider", null); + if (underlying) + { + var reached = await Line(e.Vo, "after-provider", Conv(facet, e.Plain, w)); + if (reached != voNone) return $"a convention on {w} reached the value object's property: {reached} (without it: {voNone}); the plain property got {plainConv}"; + c.Note(NoteKeys.Compared, $"{facet} on {w} reaches the plain property ({plainConv}), not the value object's ({reached})"); + return null; + } + var voConv = await Line(e.Vo, "after-provider", Conv(facet, e.Vo, w)); + if (voConv == voNone) return $"a convention on the value object type {e.Vo.Name} did not reach its property: {voConv}"; + if (voConv != plainConv) return $"the convention on the value object gave {voConv}; on the plain {w} {plainConv}"; + c.Note(NoteKeys.Compared, voConv); + return null; + }); } } diff --git a/tools/probe/corpus/probes/E13PrimitiveCollectionProbe.cs b/tools/probe/corpus/probes/E13PrimitiveCollectionProbe.cs index 8f9475b8..342ac09e 100644 --- a/tools/probe/corpus/probes/E13PrimitiveCollectionProbe.cs +++ b/tools/probe/corpus/probes/E13PrimitiveCollectionProbe.cs @@ -1,4 +1,6 @@ // Claim E13-primitive-collections (README#queries): "db.Orders.Where(o => o.Tags.Contains(tag)); db.Orders.Where(o => o.Tags.Any(t => t.Value == "x"));" over a List primitive collection. +// Obligations (claims.json), one per row by ef.query, against the ids memory computes (Baseline.Model): element-value (any-value-equals, +// "db.Orders.Where(o => o.Tags.Any(t => t.Value == "x"));") and contains ("db.Orders.Where(o => o.Tags.Contains(tag));"). // Oracle: claim plus differential. The ids the query returns equal the ids whose collection holds the value (ground truth computed in memory), and equal what the same query returns over List. using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; @@ -67,9 +69,21 @@ public sealed class E13PrimitiveCollectionProbe } var truth = string.Join(" ", needles.Select(n => $"{n}:" + string.Join(",", Enumerable.Range(0, data.Length).Where(i => data[i].Contains(n)).Select(i => i + 1)))); var v = await Side(true); - if (v.err is null) return v.res == truth ? null : $"{qk} returned [{v.res}], the collections hold [{truth}]"; - var p = await Side(false); - if (p.err is not null) { r.Note(NoteKeys.BothThrew, v.err.Split(':')[0] + "/" + p.err.Split(':')[0]); return null; } - return $"{qk} over List<{w} value object> threw ({v.err}) where List<{w}> returned [{p.res}]"; + // element-value (Any over .Value) and contains (Contains of a value object): the ids memory finds in the same collections + return await r.Check(qk == "contains" ? "contains" : "element-value", Baseline.Model, async c => + { + if (v.err is null) + { + if (v.res != truth) return $"{qk} returned [{v.res}], the collections hold [{truth}]"; + c.Note(NoteKeys.Compared, truth); + return null; + } + // the value object's query failed: the same query over List tells the framework's refusal from the value object's + var p = await Side(false); + if (p.err is null) return $"{qk} over List<{w} value object> threw ({v.err}) where List<{w}> returned [{p.res}]"; + return v.err.Split(':')[0] == p.err.Split(':')[0] + ? c.Inconclusive($"both sides refuse {qk} alike: {v.err}") + : $"{qk}: the value object's side threw {v.err}, the wrapped type's {p.err}"; + }); } } diff --git a/tools/probe/corpus/probes/E14InheritanceKeyProbe.cs b/tools/probe/corpus/probes/E14InheritanceKeyProbe.cs index 04a98c08..e30500c5 100644 --- a/tools/probe/corpus/probes/E14InheritanceKeyProbe.cs +++ b/tools/probe/corpus/probes/E14InheritanceKeyProbe.cs @@ -1,6 +1,6 @@ // Claim E14-inheritance-key-generation (README#keys): "The root of an inheritance hierarchy follows the same rules under TPH and TPT. Under TPC, as for an int key, the key is generated only where the provider numbers the whole hierarchy from a sequence (PostgreSQL, SQL Server) ... SQLite has no such sequence, so there you assign it." Key over Guid: yours to assign. // Oracle: differential against the same hierarchy keyed by the wrapped type (default keys on two leaf types, SaveChanges, the keys read back; an int/long key over TPH/TPT must be generated, distinct and non-zero), -// plus the claim for assigned keys: every hierarchy round-trips assigned keys polymorphically, and a Guid key is never generated by default. +// plus the claim for assigned keys (obligations tph-tpt-root for TPH and TPT, tpc-assign-on-sqlite for TPC, Baseline.Twin): every hierarchy round-trips assigned keys polymorphically, and a Guid key is never generated by default. using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Probes.Ef; @@ -26,66 +26,70 @@ public sealed class E14InheritanceKeyProbe static async Task Check(Probes.Shapes.Row r) { - var w = r["shape.wrapped"]; var strategy = r["ef.inheritance"]; - var kind = EfX.Kinds[r.Index % EfX.Kinds.Length]; - var e = KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == w); - - // phase A: default keys on one leaf of each type - async Task Default(bool vo) + // tph-tpt-root (TPH, TPT) and tpc-assign-on-sqlite (TPC), each against the same hierarchy keyed by the wrapped type + return await r.Check(r["ef.inheritance"] == "TPC" ? "tpc-assign-on-sqlite" : "tph-tpt-root", Baseline.Twin, async c => { - var kt = vo ? e.Vo : e.Plain; - Type root = typeof(RootE<>).MakeGenericType(kt), a = typeof(LeafA<>).MakeGenericType(kt), b = typeof(LeafB<>).MakeGenericType(kt); - await using var db = new EfDb(Cfg(strategy, root, a, b), typeKit: vo); - try + var w = r["shape.wrapped"]; var strategy = r["ef.inheritance"]; + var kind = EfX.Kinds[r.Index % EfX.Kinds.Length]; + var e = KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == w); + + // phase A: default keys on one leaf of each type + async Task Default(bool vo) + { + var kt = vo ? e.Vo : e.Plain; + Type root = typeof(RootE<>).MakeGenericType(kt), a = typeof(LeafA<>).MakeGenericType(kt), b = typeof(LeafB<>).MakeGenericType(kt); + await using var db = new EfDb(Cfg(strategy, root, a, b), typeKit: vo); + try + { + await db.Context.Database.EnsureCreatedAsync(); + var gen = db.Context.Model.FindEntityType(root)!.FindProperty("Id")!.ValueGenerated; + var items = new[] { EfX.New(a), EfX.New(b), EfX.New(a) }; + foreach (var x in items) db.Context.Add(x); + await db.Context.SaveChangesAsync(); + return $"gen={gen} keys=[" + string.Join(",", items.Select(x => EfX.Plainify(EfX.Get(x, "Id")))) + "]"; + } + catch (Exception ex) { return "threw:" + ex.GetType().Name; } + } + // phase B: assigned keys, polymorphic read back + async Task Assigned(bool vo) { + var kt = vo ? e.Vo : e.Plain; + Type root = typeof(RootE<>).MakeGenericType(kt), a = typeof(LeafA<>).MakeGenericType(kt), b = typeof(LeafB<>).MakeGenericType(kt); + await using var db = new EfDb(Cfg(strategy, root, a, b), typeKit: vo); await db.Context.Database.EnsureCreatedAsync(); - var gen = db.Context.Model.FindEntityType(root)!.FindProperty("Id")!.ValueGenerated; - var items = new[] { EfX.New(a), EfX.New(b), EfX.New(a) }; - foreach (var x in items) db.Context.Add(x); + object K(int i) { var p = EfX.Sample(w, i); return vo ? e.MakeVo(p) : p; } + var items = new[] { (a, 0, "a0"), (b, 1, "b1"), (a, 2, "a2") }; + foreach (var (t, i, name) in items) { var x = EfX.New(t); EfX.Set(x, "Id", K(i)); EfX.Set(x, "Name", name); db.Context.Add(x); } await db.Context.SaveChangesAsync(); - return $"gen={gen} keys=[" + string.Join(",", items.Select(x => EfX.Plainify(EfX.Get(x, "Id")))) + "]"; + db.Context.ChangeTracker.Clear(); + var all = EfX.Enumerate(Q.SetOf(db.Context, root)).Select(x => $"{x.GetType().Name.Split('`')[0]}:{EfX.Get(x, "Name")}:{EfX.Plainify(EfX.Get(x, "Id"))}").OrderBy(s => s, StringComparer.Ordinal); + db.Context.ChangeTracker.Clear(); + var found = await db.Context.FindAsync(root, K(1)); + return string.Join(",", all) + " find1=" + (found is null ? "null" : (string)EfX.Get(found, "Name")!); } - catch (Exception ex) { return "threw:" + ex.GetType().Name; } - } - // phase B: assigned keys, polymorphic read back - async Task Assigned(bool vo) - { - var kt = vo ? e.Vo : e.Plain; - Type root = typeof(RootE<>).MakeGenericType(kt), a = typeof(LeafA<>).MakeGenericType(kt), b = typeof(LeafB<>).MakeGenericType(kt); - await using var db = new EfDb(Cfg(strategy, root, a, b), typeKit: vo); - await db.Context.Database.EnsureCreatedAsync(); - object K(int i) { var p = EfX.Sample(w, i); return vo ? e.MakeVo(p) : p; } - var items = new[] { (a, 0, "a0"), (b, 1, "b1"), (a, 2, "a2") }; - foreach (var (t, i, name) in items) { var x = EfX.New(t); EfX.Set(x, "Id", K(i)); EfX.Set(x, "Name", name); db.Context.Add(x); } - await db.Context.SaveChangesAsync(); - db.Context.ChangeTracker.Clear(); - var all = EfX.Enumerate(Q.SetOf(db.Context, root)).Select(x => $"{x.GetType().Name.Split('`')[0]}:{EfX.Get(x, "Name")}:{EfX.Plainify(EfX.Get(x, "Id"))}").OrderBy(s => s, StringComparer.Ordinal); - db.Context.ChangeTracker.Clear(); - var found = await db.Context.FindAsync(root, K(1)); - return string.Join(",", all) + " find1=" + (found is null ? "null" : (string)EfX.Get(found, "Name")!); - } - var vA = await Default(true); - var vB = await Assigned(true); - var pB = await Assigned(false); - if (vB != pB) return $"assigned keys differ from the plain hierarchy: VO {vB} | plain {pB}"; - if (!vB.Contains("find1=b1")) return "Find by the assigned key failed: " + vB; + var vA = await Default(true); + var vB = await Assigned(true); + var pB = await Assigned(false); + if (vB != pB) return $"assigned keys differ from the plain hierarchy: VO {vB} | plain {pB}"; + if (!vB.Contains("find1=b1")) return "Find by the assigned key failed: " + vB; - if (w == "Guid") - { - // documented: yours to assign, never generated by default (EF generates a plain Guid key; TypeKit's is Never): not-promised guid-key-not-generated - if (!vA.StartsWith("gen=Never") && !vA.StartsWith("threw")) return "Guid key under " + strategy + " is not 'yours to assign': " + vA; + if (w == "Guid") + { + // documented: yours to assign, never generated by default (EF generates a plain Guid key; TypeKit's is Never): not-promised guid-key-not-generated + if (!vA.StartsWith("gen=Never") && !vA.StartsWith("threw")) return "Guid key under " + strategy + " is not 'yours to assign': " + vA; + return null; + } + var pA = await Default(false); + if (vA != pA) return $"default keys differ from the plain hierarchy under {strategy}: VO {vA} | plain {pA}"; + if (strategy is "TPH" or "TPT") + { + // documented: the root of a TPH or TPT hierarchy is generated + var keys = vA.Substring(vA.IndexOf('[') + 1).TrimEnd(']').Split(','); + if (!vA.StartsWith("gen=OnAdd") || keys.Distinct().Count() != 3 || keys.Any(k => k is "0" or "")) return $"{strategy} root key is not generated on add: {vA}"; + } + else r.Note(NoteKeys.TpcSqliteNoSequenceAssignChecked); return null; - } - var pA = await Default(false); - if (vA != pA) return $"default keys differ from the plain hierarchy under {strategy}: VO {vA} | plain {pA}"; - if (strategy is "TPH" or "TPT") - { - // documented: the root of a TPH or TPT hierarchy is generated - var keys = vA.Substring(vA.IndexOf('[') + 1).TrimEnd(']').Split(','); - if (!vA.StartsWith("gen=OnAdd") || keys.Distinct().Count() != 3 || keys.Any(k => k is "0" or "")) return $"{strategy} root key is not generated on add: {vA}"; - } - else r.Note(NoteKeys.TpcSqliteNoSequenceAssignChecked); - return null; + }); } } diff --git a/tools/probe/corpus/probes/E15CompositeKeyProbe.cs b/tools/probe/corpus/probes/E15CompositeKeyProbe.cs index d194a599..a6e1a5d2 100644 --- a/tools/probe/corpus/probes/E15CompositeKeyProbe.cs +++ b/tools/probe/corpus/probes/E15CompositeKeyProbe.cs @@ -1,4 +1,5 @@ // Claim E15-composite-key (docs/ef-core.md#keys): "A composite key of value objects is HasKey(x => new { x.Order, x.Line }), as for primitives." +// Obligation (claims.json): as-for-primitives, on every row, against the composite key over the wrapped types (Baseline.Twin). // Oracle: differential against the same composite key declared over the wrapped types: same model description (key, column types, generation), // same Find / equality-query / ordered results, same refusal of a duplicate key; and the claim's own ground truth for the lookups. using System.Linq.Expressions; @@ -14,78 +15,81 @@ public sealed class E15CompositeKeyProbe static async Task Check(Probes.Shapes.Row r) { - var w = r["shape.wrapped"]; - var first = r["ef.key-position"] == "first"; - var kind = EfX.Kinds[r.Index % EfX.Kinds.Length]; - var ew = KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == w); - var ei = KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == "int"); - // the wrapped-type key under test sits at the requested position; the other element is a value object over int (a "Line") - object WPlain(int i) => EfX.Sample(w, i); - object IPlain(int i) => 1 + i; - - async Task<(string desc, string truth)> Side(bool vo) + return await r.Check("as-for-primitives", Baseline.Twin, async c => { - Type tw = vo ? ew.Vo : ew.Plain, ti = vo ? ei.Vo : ei.Plain; - var (ta, tb) = first ? (tw, ti) : (ti, tw); - var entity = typeof(EntComp<,>).MakeGenericType(ta, tb); - object W(int i) => vo ? ew.MakeVo(WPlain(i)) : WPlain(i); - object I(int i) => vo ? ei.MakeVo(IPlain(i)) : IPlain(i); - (object a, object b) Key(int wi, int ii) => first ? (W(wi), I(ii)) : (I(ii), W(wi)); - Action cfg = mb => - { - var eb = mb.Entity(entity); eb.ToTable("t"); eb.HasKey("A", "B"); - }; - await using var db = new EfDb(cfg, typeKit: vo); - await db.Context.Database.EnsureCreatedAsync(); - var model = EfModel.Describe(db.Context); - var pk = string.Join(",", db.Context.Model.FindEntityType(entity)!.FindPrimaryKey()!.Properties.Select(p => p.Name)); - (int wi, int ii)[] seeds = [(0, 0), (0, 1), (1, 0)]; - foreach (var (wi, ii) in seeds) - { - var e = EfX.New(entity); var (a, b) = Key(wi, ii); - EfX.Set(e, "A", a); EfX.Set(e, "B", b); EfX.Set(e, "Tag", $"t{wi}{ii}"); - db.Context.Add(e); - } - await db.Context.SaveChangesAsync(); - db.Context.ChangeTracker.Clear(); - var lines = new List { "pk=" + pk, model }; - // Find by the whole key - foreach (var (wi, ii) in new[] { (0, 0), (0, 1), (1, 0), (1, 1) }) + var w = r["shape.wrapped"]; + var first = r["ef.key-position"] == "first"; + var kind = EfX.Kinds[r.Index % EfX.Kinds.Length]; + var ew = KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == w); + var ei = KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == "int"); + // the wrapped-type key under test sits at the requested position; the other element is a value object over int (a "Line") + object WPlain(int i) => EfX.Sample(w, i); + object IPlain(int i) => 1 + i; + + async Task<(string desc, string truth)> Side(bool vo) { - var (a, b) = Key(wi, ii); - var found = await db.Context.FindAsync(entity, a, b); - lines.Add($"find{wi}{ii}=" + (found is null ? "null" : EfX.Get(found, "Tag"))); + Type tw = vo ? ew.Vo : ew.Plain, ti = vo ? ei.Vo : ei.Plain; + var (ta, tb) = first ? (tw, ti) : (ti, tw); + var entity = typeof(EntComp<,>).MakeGenericType(ta, tb); + object W(int i) => vo ? ew.MakeVo(WPlain(i)) : WPlain(i); + object I(int i) => vo ? ei.MakeVo(IPlain(i)) : IPlain(i); + (object a, object b) Key(int wi, int ii) => first ? (W(wi), I(ii)) : (I(ii), W(wi)); + Action cfg = mb => + { + var eb = mb.Entity(entity); eb.ToTable("t"); eb.HasKey("A", "B"); + }; + await using var db = new EfDb(cfg, typeKit: vo); + await db.Context.Database.EnsureCreatedAsync(); + var model = EfModel.Describe(db.Context); + var pk = string.Join(",", db.Context.Model.FindEntityType(entity)!.FindPrimaryKey()!.Properties.Select(p => p.Name)); + (int wi, int ii)[] seeds = [(0, 0), (0, 1), (1, 0)]; + foreach (var (wi, ii) in seeds) + { + var e = EfX.New(entity); var (a, b) = Key(wi, ii); + EfX.Set(e, "A", a); EfX.Set(e, "B", b); EfX.Set(e, "Tag", $"t{wi}{ii}"); + db.Context.Add(e); + } + await db.Context.SaveChangesAsync(); db.Context.ChangeTracker.Clear(); + var lines = new List { "pk=" + pk, model }; + // Find by the whole key + foreach (var (wi, ii) in new[] { (0, 0), (0, 1), (1, 0), (1, 1) }) + { + var (a, b) = Key(wi, ii); + var found = await db.Context.FindAsync(entity, a, b); + lines.Add($"find{wi}{ii}=" + (found is null ? "null" : EfX.Get(found, "Tag"))); + db.Context.ChangeTracker.Clear(); + } + // equality query on the first key column, and on the second + foreach (var col in new[] { "A", "B" }) + { + var (a, b) = Key(0, 0); + var box = Expression.Constant(Q.NewBox(col == "A" ? ta : tb, col == "A" ? a : b)); + var p = Expression.Parameter(entity, "e"); + var pred = Expression.Equal(Expression.Property(p, col), Expression.Field(box, "Value")); + var q = Q.Call("Where", [entity], Q.SetOf(db.Context, entity), Expression.Lambda(pred, p)); + lines.Add($"where{col}=" + string.Join("|", EfX.Enumerate(q).Select(x => (string)EfX.Get(x, "Tag")!).OrderBy(x => x, StringComparer.Ordinal))); + } + db.Context.ChangeTracker.Clear(); + // a duplicate whole key is refused + var dup = EfX.New(entity); var (da, dbk) = Key(0, 1); + EfX.Set(dup, "A", da); EfX.Set(dup, "B", dbk); EfX.Set(dup, "Tag", "dup"); + db.Context.Add(dup); + try { await db.Context.SaveChangesAsync(); lines.Add("dup=accepted"); } catch (Exception ex) { lines.Add("dup=" + ex.GetType().Name); } + db.Context.ChangeTracker.Clear(); + var all = string.Join(",", EfX.Enumerate(Q.SetOf(db.Context, entity)).Select(x => (string)EfX.Get(x, "Tag")!).OrderBy(x => x, StringComparer.Ordinal)); + lines.Add("rows=" + all); + var truth = string.Join(";", lines.Where(l => l.StartsWith("find") || l.StartsWith("where") || l.StartsWith("dup") || l.StartsWith("rows"))); + return (string.Join("\n", lines), truth); } - // equality query on the first key column, and on the second - foreach (var col in new[] { "A", "B" }) - { - var (a, b) = Key(0, 0); - var box = Expression.Constant(Q.NewBox(col == "A" ? ta : tb, col == "A" ? a : b)); - var p = Expression.Parameter(entity, "e"); - var pred = Expression.Equal(Expression.Property(p, col), Expression.Field(box, "Value")); - var q = Q.Call("Where", [entity], Q.SetOf(db.Context, entity), Expression.Lambda(pred, p)); - lines.Add($"where{col}=" + string.Join("|", EfX.Enumerate(q).Select(x => (string)EfX.Get(x, "Tag")!).OrderBy(x => x, StringComparer.Ordinal))); - } - db.Context.ChangeTracker.Clear(); - // a duplicate whole key is refused - var dup = EfX.New(entity); var (da, dbk) = Key(0, 1); - EfX.Set(dup, "A", da); EfX.Set(dup, "B", dbk); EfX.Set(dup, "Tag", "dup"); - db.Context.Add(dup); - try { await db.Context.SaveChangesAsync(); lines.Add("dup=accepted"); } catch (Exception ex) { lines.Add("dup=" + ex.GetType().Name); } - db.Context.ChangeTracker.Clear(); - var all = string.Join(",", EfX.Enumerate(Q.SetOf(db.Context, entity)).Select(x => (string)EfX.Get(x, "Tag")!).OrderBy(x => x, StringComparer.Ordinal)); - lines.Add("rows=" + all); - var truth = string.Join(";", lines.Where(l => l.StartsWith("find") || l.StartsWith("where") || l.StartsWith("dup") || l.StartsWith("rows"))); - return (string.Join("\n", lines), truth); - } - var v = await Side(true); - var p = await Side(false); - const string Expected = "find00=t00;find01=t01;find10=t10;find11=null;whereA=t00|t01;whereB=t00|t10;dup=DbUpdateException;rows=t00,t01,t10"; - // whereA/whereB depend on the position: A is the first key element - var expected = first ? Expected : Expected.Replace("whereA=t00|t01;whereB=t00|t10", "whereA=t00|t10;whereB=t00|t01"); - if (v.truth != expected) return $"VO side: {v.truth}; expected {expected}"; - if (v.desc != p.desc) return "model/results differ from the plain composite key: " + EfModel.FirstDiff(v.desc, p.desc); - return null; + var v = await Side(true); + var p = await Side(false); + const string Expected = "find00=t00;find01=t01;find10=t10;find11=null;whereA=t00|t01;whereB=t00|t10;dup=DbUpdateException;rows=t00,t01,t10"; + // whereA/whereB depend on the position: A is the first key element + var expected = first ? Expected : Expected.Replace("whereA=t00|t01;whereB=t00|t10", "whereA=t00|t10;whereB=t00|t01"); + if (v.truth != expected) return $"VO side: {v.truth}; expected {expected}"; + if (v.desc != p.desc) return "model/results differ from the plain composite key: " + EfModel.FirstDiff(v.desc, p.desc); + return null; + }); } } diff --git a/tools/probe/corpus/probes/E16OwnConfigurationWinsProbe.cs b/tools/probe/corpus/probes/E16OwnConfigurationWinsProbe.cs index cadc6e56..5fba4b73 100644 --- a/tools/probe/corpus/probes/E16OwnConfigurationWinsProbe.cs +++ b/tools/probe/corpus/probes/E16OwnConfigurationWinsProbe.cs @@ -1,4 +1,5 @@ // Claim E16-own-configuration-wins (src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#keys): "A configuration you write yourself always wins over these defaults." (the key defaults: generated on add for an integer, never for char and Guid) +// Obligations (claims.json), one per row by ef.config, against the wrapped twin (Baseline.Twin): never, on-add, column-type, conversion, optional. // Oracle: differential against the same key declared with the wrapped type under the same configuration, plus the claim itself: ValueGeneratedNever / ValueGeneratedOnAdd are what the model says; HasConversion stores what the application's converter writes; // HasColumnType is the column's type; IsRequired(false) is refused for a key as for a plain key, and on a non-key property of the same value object makes the column nullable and null round-trips. using Microsoft.EntityFrameworkCore; @@ -90,49 +91,57 @@ static async Task Side(EfEntry e, bool vo, string config) var e = KeyRegistry.All.First(x => x.Kind == kind && x.Wrapped == w); var v = await Side(e, true, config); var p = await Side(e, false, config); - - if (config == "IsRequired-false") - { - // the key attempt: EF refuses an optional key, and must refuse the same way for the value object - if (v.Model.StartsWith("threw") != p.Model.StartsWith("threw")) return $"IsRequired(false) on a key: VO {v.Model.Split('\n')[0]} | plain {p.Model.Split('\n')[0]}"; - if (v.Model.StartsWith("threw") && v.Model != p.Model) return $"IsRequired(false) on a key is refused differently: VO {v.Model} | plain {p.Model}"; - return await OptionalProperty(r, w); - } - - if (v.Model.StartsWith("threw") || p.Model.StartsWith("threw")) - { - if (v.Model == p.Model) { r.Outcome("expected-refusal", "both refuse the model: " + v.Model); return null; } - return $"model refused for one side only: VO {v.Model} | plain {p.Model}"; - } - // the claim: the configuration is what the model says - if (config == "ValueGeneratedNever" && !v.Model.Contains("pk=True gen=Never")) return "ValueGeneratedNever not kept: " + v.Model; - if (config == "ValueGeneratedOnAdd" && !v.Model.Contains("pk=True gen=OnAdd")) return "ValueGeneratedOnAdd not kept: " + v.Model; - if (config == "HasColumnType" && !v.Model.Contains("type=" + EfModel.ColumnTypes[w])) return $"HasColumnType({EfModel.ColumnTypes[w]}) not kept: " + v.Model; - if (config == "HasConversion") + var obligation = config switch { - if (!v.Model.Contains("type=TEXT")) return "HasConversion to text: the column is not text: " + v.Model; - var expect = "C:" + OwnConv.Fmt(Sample(w, 0)) + ",C:" + OwnConv.Fmt(Sample(w, 1)); - if (v.Raw != expect) return $"stored values are not the application's converter output: {v.Raw}, expected {expect}"; - } - var vm = v.Model; var pm = p.Model; - if (config is "HasColumnType" or "HasConversion") { vm = vm.Replace("gen=Never", "gen=*").Replace("gen=OnAdd", "gen=*"); pm = pm.Replace("gen=Never", "gen=*").Replace("gen=OnAdd", "gen=*"); r.Note(NoteKeys.DocumentedCharGuidDefaultGenerationNormalized, "not-promised char-key-not-generated, guid-key-not-generated"); } - if (vm != pm) return "model differs from the wrapped type's: " + EfModel.FirstDiff(vm, pm); - if (StripScript(v.Script) != StripScript(p.Script)) return "create script differs from the wrapped type's: " + EfModel.FirstDiff(StripScript(v.Script), StripScript(p.Script)); - if (p.Assigned.StartsWith("threw") && !v.Assigned.StartsWith("threw")) - { - // the wrapped type's own key fails on SQLite (EF writes AUTOINCREMENT on a BIGINT / TEXT key): the value object's must still do what it was configured to - var expectRead = Norm(Sample(w, 0)) + "/t0," + Norm(Sample(w, 1)) + "/t1 find1=t1"; - var sorted = string.Join(",", expectRead.Split(" find1=")[0].Split(',').OrderBy(x => x, StringComparer.Ordinal)) + " find1=t1"; - if (v.Assigned != sorted) return $"assigned keys read back as {v.Assigned}, expected {sorted} (the plain key threw: {p.Assigned})"; - r.Note(NoteKeys.PlainKeyThrowsOnSqliteVoCheckedAgainstExpected); - } - else + "ValueGeneratedNever" => "never", "ValueGeneratedOnAdd" => "on-add", "HasColumnType" => "column-type", "HasConversion" => "conversion", + _ => "optional", + }; + // each configuration's obligation, against the same key declared with the wrapped type under the same configuration + return await r.Check(obligation, Baseline.Twin, async c => { - if (v.Assigned != p.Assigned) return $"assigned keys differ: VO {v.Assigned} | plain {p.Assigned}"; - if (v.Raw != p.Raw) return $"stored keys differ: VO {v.Raw} | plain {p.Raw}"; - } - if (config is "ValueGeneratedNever" or "ValueGeneratedOnAdd" && Kind(v.DefaultAdd) != Kind(p.DefaultAdd)) return $"default keys differ under {config}: VO {v.DefaultAdd} | plain {p.DefaultAdd}"; - return null; + if (config == "IsRequired-false") + { + // the key attempt: EF refuses an optional key, and must refuse the same way for the value object + if (v.Model.StartsWith("threw") != p.Model.StartsWith("threw")) return $"IsRequired(false) on a key: VO {v.Model.Split('\n')[0]} | plain {p.Model.Split('\n')[0]}"; + if (v.Model.StartsWith("threw") && v.Model != p.Model) return $"IsRequired(false) on a key is refused differently: VO {v.Model} | plain {p.Model}"; + return await OptionalProperty(r, w); + } + + if (v.Model.StartsWith("threw") || p.Model.StartsWith("threw")) + { + if (v.Model == p.Model) return c.Inconclusive("both refuse the model alike: " + v.Model); + return $"model refused for one side only: VO {v.Model} | plain {p.Model}"; + } + // the claim: the configuration is what the model says + if (config == "ValueGeneratedNever" && !v.Model.Contains("pk=True gen=Never")) return "ValueGeneratedNever not kept: " + v.Model; + if (config == "ValueGeneratedOnAdd" && !v.Model.Contains("pk=True gen=OnAdd")) return "ValueGeneratedOnAdd not kept: " + v.Model; + if (config == "HasColumnType" && !v.Model.Contains("type=" + EfModel.ColumnTypes[w])) return $"HasColumnType({EfModel.ColumnTypes[w]}) not kept: " + v.Model; + if (config == "HasConversion") + { + if (!v.Model.Contains("type=TEXT")) return "HasConversion to text: the column is not text: " + v.Model; + var expect = "C:" + OwnConv.Fmt(Sample(w, 0)) + ",C:" + OwnConv.Fmt(Sample(w, 1)); + if (v.Raw != expect) return $"stored values are not the application's converter output: {v.Raw}, expected {expect}"; + } + var vm = v.Model; var pm = p.Model; + if (config is "HasColumnType" or "HasConversion") { vm = vm.Replace("gen=Never", "gen=*").Replace("gen=OnAdd", "gen=*"); pm = pm.Replace("gen=Never", "gen=*").Replace("gen=OnAdd", "gen=*"); r.Note(NoteKeys.DocumentedCharGuidDefaultGenerationNormalized, "not-promised char-key-not-generated, guid-key-not-generated"); } + if (vm != pm) return "model differs from the wrapped type's: " + EfModel.FirstDiff(vm, pm); + if (StripScript(v.Script) != StripScript(p.Script)) return "create script differs from the wrapped type's: " + EfModel.FirstDiff(StripScript(v.Script), StripScript(p.Script)); + if (p.Assigned.StartsWith("threw") && !v.Assigned.StartsWith("threw")) + { + // the wrapped type's own key fails on SQLite (EF writes AUTOINCREMENT on a BIGINT / TEXT key): the value object's must still do what it was configured to + var expectRead = Norm(Sample(w, 0)) + "/t0," + Norm(Sample(w, 1)) + "/t1 find1=t1"; + var sorted = string.Join(",", expectRead.Split(" find1=")[0].Split(',').OrderBy(x => x, StringComparer.Ordinal)) + " find1=t1"; + if (v.Assigned != sorted) return $"assigned keys read back as {v.Assigned}, expected {sorted} (the plain key threw: {p.Assigned})"; + r.Note(NoteKeys.PlainKeyThrowsOnSqliteVoCheckedAgainstExpected); + } + else + { + if (v.Assigned != p.Assigned) return $"assigned keys differ: VO {v.Assigned} | plain {p.Assigned}"; + if (v.Raw != p.Raw) return $"stored keys differ: VO {v.Raw} | plain {p.Raw}"; + } + if (config is "ValueGeneratedNever" or "ValueGeneratedOnAdd" && Kind(v.DefaultAdd) != Kind(p.DefaultAdd)) return $"default keys differ under {config}: VO {v.DefaultAdd} | plain {p.DefaultAdd}"; + return null; + }); } // IsRequired(false) cannot apply to a key at all; on a non-key property of the same wrapped type it must win over the required default. diff --git a/tools/probe/corpus/registry.json b/tools/probe/corpus/registry.json index e85adb23..c4c1f763 100644 --- a/tools/probe/corpus/registry.json +++ b/tools/probe/corpus/registry.json @@ -893,6 +893,16 @@ "obligation": "guid-key-yours", "patch": "seeds/E04-integer-key-generated-guid-key-yours.patch", "defect": "A key over a Guid is declared generated on add, so EF invents a Guid for an entity added without one." + }, + { + "obligation": "not-on-efs-list", + "patch": "seeds/E04-integer-key-generated-not-on-efs-list.patch", + "defect": "A key over a string is declared generated on add, as a key over an integer is, so EF invents a Guid's text for an entity added without one, where a plain string key is never generated." + }, + { + "obligation": "own-configuration-wins", + "patch": "seeds/E04-integer-key-generated-own-configuration-wins.patch", + "defect": "A key over char or Guid is declared never generated as an explicit configuration, so it overrides the ValueGeneratedOnAdd() the application wrote." } ] }, @@ -1046,6 +1056,11 @@ "obligation": "value-object-type-reaches", "patch": "seeds/E12-convention-by-vo-type-value-object-type-reaches.patch", "defect": "A finalizing convention clears the max length, precision and scale of value-object properties, so a convention on the value object's type does not reach its property." + }, + { + "obligation": "underlying-type-does-not-reach", + "patch": "seeds/E12-convention-by-vo-type-underlying-type-does-not-reach.patch", + "defect": "A value-object property is configured as the type it wraps is, so a ConfigureConventions convention on decimal reaches an Amount property." } ] }, @@ -1057,6 +1072,11 @@ "obligation": "element-value", "patch": "seeds/E13-primitive-collections-element-value.patch", "defect": ".Value on an element of a primitive collection of value objects is not translated, so Any(t => t.Value == x) fails." + }, + { + "obligation": "contains", + "patch": "seeds/E13-primitive-collections-contains.patch", + "defect": "The query interceptor rewrites Contains of a value object in a primitive collection as false, so the query matches no row." } ] }, diff --git a/tools/probe/corpus/seeds/E04-integer-key-generated-not-on-efs-list.patch b/tools/probe/corpus/seeds/E04-integer-key-generated-not-on-efs-list.patch new file mode 100644 index 00000000..8791b695 --- /dev/null +++ b/tools/probe/corpus/seeds/E04-integer-key-generated-not-on-efs-list.patch @@ -0,0 +1,13 @@ +diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +index c64d73e..afedaa5 100644 +--- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs ++++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +@@ -77,7 +77,7 @@ internal sealed class ValueObjectKeyValueGenerationConvention : IModelFinalizing + /// + private static bool IsGeneratedByTheDatabase(Type wrapped) => + wrapped == typeof(int) || wrapped == typeof(long) || wrapped == typeof(short) || wrapped == typeof(sbyte) +- || wrapped == typeof(uint) || wrapped == typeof(ulong) || wrapped == typeof(ushort); ++ || wrapped == typeof(uint) || wrapped == typeof(ulong) || wrapped == typeof(ushort) || wrapped == typeof(string); + } + + /// diff --git a/tools/probe/corpus/seeds/E04-integer-key-generated-own-configuration-wins.patch b/tools/probe/corpus/seeds/E04-integer-key-generated-own-configuration-wins.patch new file mode 100644 index 00000000..a76b9d10 --- /dev/null +++ b/tools/probe/corpus/seeds/E04-integer-key-generated-own-configuration-wins.patch @@ -0,0 +1,18 @@ +diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +index 934f884..6b023d9 100644 +--- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs ++++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs +@@ -52,8 +52,13 @@ internal sealed class ValueObjectKeyValueGenerationConvention : IModelFinalizing + public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext context) + { + foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()) ++ { ++ if (entityType.FindPrimaryKey() is { Properties: [var key] } ++ && ValueObjectTypes.Describe(key.ClrType) is { WrappedType: var keyWrapped } && (keyWrapped == typeof(char) || keyWrapped == typeof(Guid))) ++ ((IMutableProperty)key).ValueGenerated = ValueGenerated.Never; + if (GeneratedKey(entityType) is { } property) + property.Builder.ValueGenerated(ValueGenerated.OnAdd); ++ } + } + + /// The entity type's key, where it is a value object over an integer EF generates a key of. diff --git a/tools/probe/corpus/seeds/E12-convention-by-vo-type-underlying-type-does-not-reach.patch b/tools/probe/corpus/seeds/E12-convention-by-vo-type-underlying-type-does-not-reach.patch new file mode 100644 index 00000000..f98d2beb --- /dev/null +++ b/tools/probe/corpus/seeds/E12-convention-by-vo-type-underlying-type-does-not-reach.patch @@ -0,0 +1,35 @@ +diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs +index f0ec2c3..05e6d4c 100644 +--- a/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs ++++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/TypeKitConventionSetPlugin.cs +@@ -1,3 +1,4 @@ ++using Microsoft.EntityFrameworkCore.Metadata.Builders; + using Microsoft.EntityFrameworkCore.Metadata.Conventions; + using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; + +@@ -16,6 +17,8 @@ internal sealed class TypeKitConventionSetPlugin : IConventionSetPlugin + { + var finalizing = conventionSet.ModelFinalizingConventions; + ++ conventionSet.PropertyAddedConventions.Add(new WrappedTypeConfigurationConvention()); ++ + // After the last of EF's finalizing conventions that change what its value-generation convention + // re-reads, and so before a provider's value-generation strategy (an identity column on PostgreSQL + // and SQL Server), which is chosen for keys already generated on add and comes after them. +@@ -52,3 +55,16 @@ internal sealed class TypeKitConventionSetPlugin : IConventionSetPlugin + return last + 1; + } + } ++ ++/// A value-object property configured as the type it wraps is: ConfigureConventions by the wrapped type reaches it. ++internal sealed class WrappedTypeConfigurationConvention : IPropertyAddedConvention ++{ ++ public void ProcessPropertyAdded(IConventionPropertyBuilder propertyBuilder, IConventionContext context) ++ { ++ var property = propertyBuilder.Metadata; ++ if (ValueObjectTypes.Describe(Nullable.GetUnderlyingType(property.ClrType) ?? property.ClrType) is not { WrappedType: var wrapped }) return; ++#pragma warning disable EF1001 ++ ((Microsoft.EntityFrameworkCore.Metadata.Internal.Model)property.DeclaringType.Model).Configuration?.FindProperty(wrapped)?.Apply((Microsoft.EntityFrameworkCore.Metadata.IMutableProperty)property); ++#pragma warning restore EF1001 ++ } ++} diff --git a/tools/probe/corpus/seeds/E13-primitive-collections-contains.patch b/tools/probe/corpus/seeds/E13-primitive-collections-contains.patch new file mode 100644 index 00000000..f2c676d9 --- /dev/null +++ b/tools/probe/corpus/seeds/E13-primitive-collections-contains.patch @@ -0,0 +1,22 @@ +diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +index 09a9e8c..2e137c8 100644 +--- a/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs ++++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/HandWrittenOrderingInterceptor.cs +@@ -36,7 +36,16 @@ internal sealed class HandWrittenOrderingInterceptor : IQueryExpressionIntercept + { + HandWrittenOrdering.RefuseIn(queryExpression); + +- return queryExpression; ++ return new ContainsOfValueObjects().Visit(queryExpression); ++ } ++ ++ /// A value object's Contains in a collection, as a match of nothing. ++ private sealed class ContainsOfValueObjects : ExpressionVisitor ++ { ++ protected override Expression VisitMethodCall(MethodCallExpression node) => ++ node.Method.Name == nameof(Enumerable.Contains) && node.Arguments.Count > 0 && ValueObjectTypes.IsValueObject(node.Arguments[^1].Type) ++ ? Expression.Constant(false) ++ : base.VisitMethodCall(node); + } + } + From e0f9a63ce4fa2c951611b61a4663af014a52e133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 11:55:32 +0200 Subject: [PATCH 2/3] docs: KB-039, EF's SQLite provider neither compares nor orders DateTimeOffset, TimeSpan or ulong E02's 12 rows over those types under where-value-compare and order-by failed on both sides, the wrapped twin under plain EF included: ">=" is "could not be translated" (InvalidOperationException) and ORDER BY a NotSupportedException. Triage 2: a framework behaviour, pinned. EF's "Query limitations" page lists decimal beside them, which EF Core 10 compares and orders; the test holds that as the control. The Facts project gains the Microsoft.EntityFrameworkCore.Sqlite reference for it. Co-Authored-By: Claude Opus 5.5 --- ...orders-datetimeoffset-timespan-or-ulong.md | 26 +++++ design/knowledge/README.md | 1 + .../CodoMetis.TypeKit.Facts.csproj | 1 + test/CodoMetis.TypeKit.Facts/KB039.cs | 106 ++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 design/knowledge/KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md create mode 100644 test/CodoMetis.TypeKit.Facts/KB039.cs diff --git a/design/knowledge/KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md b/design/knowledge/KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md new file mode 100644 index 00000000..b3d7ce67 --- /dev/null +++ b/design/knowledge/KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md @@ -0,0 +1,26 @@ +--- +verified: 2026-10-17 +pins: + - Microsoft.EntityFrameworkCore.Sqlite 10.0.12 + - Microsoft.EntityFrameworkCore.Relational 10.0.12 +source: the Query limitations section of SQLite Database Provider - Limitations (learn.microsoft.com, dotnet/EntityFramework.Docs providers/sqlite/limitations.md), which says "SQLite doesn't natively support the following data types. EF Core can read and write values of these types, and querying for equality (`where e.Property == value`) is also supported. Other operations, however, like comparison and ordering will require evaluation on the client." and lists DateTimeOffset, decimal, TimeSpan and ulong; measured with plain EF Core and its SQLite provider, where decimal is compared and ordered +turned-on: 12 rows of E02ValueTranslatesProbe (claim E02-value-translates-to-column), a value object over DateTimeOffset, TimeSpan or ulong under where-value-compare or order-by, where both sides refused the query (WS2 step 4, the EF family); E02's obligations declare those pairs inapplicable +test: test/CodoMetis.TypeKit.Facts/KB039.cs +--- + +# KB-039. EF Core's SQLite provider neither compares nor orders a `DateTimeOffset`, a `TimeSpan` or a `ulong` + +Over a plain column of one of these types, with no TypeKit: + +| Query | `DateTimeOffset`, `TimeSpan`, `ulong` | `decimal` | +|---|---|---| +| `Where(r => r.P >= v)` | `InvalidOperationException`: "The LINQ expression ... could not be translated" | translated, the rows at or above `v` | +| `OrderBy(r => r.P)` | `NotSupportedException`: "SQLite does not support expressions of type '...' in ORDER BY clauses" | translated, in order | +| `Where(r => r.P == v)` | translated | translated | + +EF's page lists `decimal` beside the three. EF Core 10 translates its comparisons and orderings on SQLite, so the +table above, not the page's list, is what this repository relies on. + +So on SQLite a query that compares or orders such a column fails for the plain type as it does for a value object +over it, before any value-object translation is asked anything: E02's comparison and ordering rows over these types +have nothing to check there, and its obligations declare them inapplicable. PostgreSQL translates both. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index 7698b2df..3fc9c317 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -55,3 +55,4 @@ entry's frontmatter, write the test first where a test can observe it, and add a | [KB-036](KB-036-a-char-key-gets-an-identity-postgresql-refuses.md) | A plain `char` key gets an identity column on Npgsql, which PostgreSQL refuses | PostgreSQL 17.11, Npgsql EF 10.0.3, EF Core 10.0.12 | `KB036`, with `TYPEKIT_PG` | | [KB-037](KB-037-mvc-under-max-validation-depth-1-runs-an-unbound-action.md) | Under `MaxValidationDepth = 1`, MVC runs an action whose parameter it could not bind, with the parameter at its default | SDK 10.0.4xx | `KB037` | | [KB-038](KB-038-a-byte-array-is-a-base64-string.md) | System.Text.Json writes a `byte[]` as one base64 string, and every other collection as a JSON array | SDK 10.0.4xx | `KB038` | +| [KB-039](KB-039-sqlite-neither-compares-nor-orders-datetimeoffset-timespan-or-ulong.md) | EF Core's SQLite provider neither compares nor orders a `DateTimeOffset`, a `TimeSpan` or a `ulong`, and does both for a `decimal` | EF Core 10.0.12, its SQLite provider 10.0.12 | `KB039` | diff --git a/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj b/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj index 84bdc895..605f86c3 100644 --- a/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj +++ b/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj @@ -24,6 +24,7 @@ --> + diff --git a/test/CodoMetis.TypeKit.Facts/KB039.cs b/test/CodoMetis.TypeKit.Facts/KB039.cs new file mode 100644 index 00000000..7c6552c2 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB039.cs @@ -0,0 +1,106 @@ +using System.Linq.Expressions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-039: EF Core's SQLite provider translates no comparison and no ORDER BY of a +/// , a or a column: >= throws "could not be +/// translated", and an ordering throws . Equality is translated. A , +/// which EF's page lists beside them, is compared and ordered. +/// +public sealed class KB039 +{ + public sealed class Row + { + public int Id { get; set; } + + public T P { get; set; } = default!; + } + + private sealed class Db(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity>().ToTable("t"); + } + + private sealed class PerType : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) => (context.GetType(), designTime); + } + + private static async Task<(SqliteConnection Connection, Db Db)> Open(T low, T high) + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(TestContext.Current.CancellationToken); + var db = new Db(new DbContextOptionsBuilder().UseSqlite(connection).ReplaceService().Options); + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + db.AddRange(new Row { P = high }, new Row { P = low }); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + db.ChangeTracker.Clear(); + return (connection, db); + } + + public static TheoryData Refused => ["DateTimeOffset", "TimeSpan", "ulong"]; + + [Theory, MemberData(nameof(Refused))] + public Task A_comparison_is_not_translated(string type) => type switch + { + "DateTimeOffset" => NotTranslated(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddDays(1), r => r.P >= DateTimeOffset.UnixEpoch.AddDays(1)), + "TimeSpan" => NotTranslated(TimeSpan.FromHours(1), TimeSpan.FromHours(2), r => r.P >= TimeSpan.FromHours(2)), + _ => NotTranslated(1UL, 2UL, r => r.P >= 2UL), + }; + + [Theory, MemberData(nameof(Refused))] + public Task An_ordering_is_not_supported(string type) => type switch + { + "DateTimeOffset" => NotOrdered(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddDays(1), "DateTimeOffset"), + "TimeSpan" => NotOrdered(TimeSpan.FromHours(1), TimeSpan.FromHours(2), "TimeSpan"), + _ => NotOrdered(1UL, 2UL, "ulong"), + }; + + [Theory, MemberData(nameof(Refused))] + public Task Equality_is_translated(string type) => type switch + { + "DateTimeOffset" => Equal(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddDays(1), r => r.P == DateTimeOffset.UnixEpoch), + "TimeSpan" => Equal(TimeSpan.FromHours(1), TimeSpan.FromHours(2), r => r.P == TimeSpan.FromHours(1)), + _ => Equal(1UL, 2UL, r => r.P == 1UL), + }; + + [Fact] + public async Task A_decimal_is_compared_and_ordered() + { + var (connection, db) = await Open(1.5m, 2.5m); + await using var _ = connection; + await using var __ = db; + (await db.Set>().Where(r => r.P >= 2m).Select(r => r.P).ToListAsync(TestContext.Current.CancellationToken)).ShouldBe([2.5m]); + (await db.Set>().OrderBy(r => r.P).Select(r => r.P).ToListAsync(TestContext.Current.CancellationToken)).ShouldBe([1.5m, 2.5m]); + } + + private static async Task NotTranslated(T low, T high, Expression, bool>> atLeastHigh) + { + var (connection, db) = await Open(low, high); + await using var _ = connection; + await using var __ = db; + var refused = await Should.ThrowAsync(() => db.Set>().Where(atLeastHigh).ToListAsync(TestContext.Current.CancellationToken)); + refused.Message.ShouldContain("could not be translated"); + } + + private static async Task NotOrdered(T low, T high, string name) + { + var (connection, db) = await Open(low, high); + await using var _ = connection; + await using var __ = db; + var refused = await Should.ThrowAsync(() => db.Set>().OrderBy(r => r.P).ToListAsync(TestContext.Current.CancellationToken)); + refused.Message.ShouldContain($"SQLite does not support expressions of type '{name}' in ORDER BY clauses"); + } + + private static async Task Equal(T low, T high, Expression, bool>> isLow) + { + var (connection, db) = await Open(low, high); + await using var _ = connection; + await using var __ = db; + (await db.Set>().Where(isLow).Select(r => r.P).ToListAsync(TestContext.Current.CancellationToken)).ShouldBe([low]); + } +} From 845e1c054b5ae8c6dc3beee72fdcb9b6406c55fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 17 Oct 2026 11:55:32 +0200 Subject: [PATCH 3/3] test: E02 declares the shapes SQLite cannot compare or order inapplicable, citing KB-039 Each of E02's four obligations declares shape.wrapped DateTimeOffset, TimeSpan or ulong with ef.query where-value-compare or order-by inapplicable (fact:KB-039). The excuse depends on the axes alone, so it is a declaration (#50), not code: the six pairs are wanted by no obligation. Co-Authored-By: Claude Opus 5.5 --- tools/probe/corpus/claims.json | 72 ++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/tools/probe/corpus/claims.json b/tools/probe/corpus/claims.json index e9840317..7c4ce6dd 100644 --- a/tools/probe/corpus/claims.json +++ b/tools/probe/corpus/claims.json @@ -12598,7 +12598,23 @@ "ef.accessor": [ "Value" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.wrapped": [ + "DateTimeOffset", + "TimeSpan", + "ulong" + ], + "ef.query": [ + "where-value-compare", + "order-by" + ] + }, + "cite": "fact:KB-039" + } + ] }, { "id": "get-value", @@ -12607,7 +12623,23 @@ "ef.accessor": [ "GetValue" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.wrapped": [ + "DateTimeOffset", + "TimeSpan", + "ulong" + ], + "ef.query": [ + "where-value-compare", + "order-by" + ] + }, + "cite": "fact:KB-039" + } + ] }, { "id": "value-or-null", @@ -12616,11 +12648,43 @@ "ef.accessor": [ "ValueOrNull" ] - } + }, + "inapplicable": [ + { + "axes": { + "shape.wrapped": [ + "DateTimeOffset", + "TimeSpan", + "ulong" + ], + "ef.query": [ + "where-value-compare", + "order-by" + ] + }, + "cite": "fact:KB-039" + } + ] }, { "id": "column-not-cast", - "sentence": "A table's column is re-typed, not cast, so an index on it still serves the query." + "sentence": "A table's column is re-typed, not cast, so an index on it still serves the query.", + "inapplicable": [ + { + "axes": { + "shape.wrapped": [ + "DateTimeOffset", + "TimeSpan", + "ulong" + ], + "ef.query": [ + "where-value-compare", + "order-by" + ] + }, + "cite": "fact:KB-039" + } + ] } ], "not-promised": [