Revive repo: CI, Tests and type system (partial) (closes #3) - #5
Conversation
nielspardon
left a comment
There was a problem hiding this comment.
Thanks for picking this up — the repo genuinely needed reviving, and modelling the type system on substrait-java's TypeCreator/Type split is the right call.
Disclosure: C# isn't my main language, and I used an AI assistant to work through this diff. I've tried to keep the two kinds of claim separate below — the compile-behaviour ones come with repros you can check in under a minute, and the API-shape ones are opinions to weigh against your own plans for the library. None of it is build-verified, since CI hasn't run on the PR yet. Corrections welcome, particularly on the spec bounds in the TypeCreator comment.
Three things I'd want changed before merge, plus one naming decision I'd rather put to you and the maintainers than assert.
1. ITypeVisitor<TResult> has no fallback
All 25 overloads are required, so adding a kind is a source-breaking change for every implementor — and kinds are still coming. Missing today: UserDefined (not deprecated, and needed for extensions), NSTRUCT, and the deprecated timestamp/timestamp_tz/time. Skipping the deprecated three is defensible for construction, but they still appear on the wire, so you'll need them if this ever deserializes existing plans. Worth noting the PR description says "every Simple/Compound type kind", which overstates it slightly.
Default interface implementations fix the extensibility problem for free right now, and it's breaking to retrofit later. Suggestion inline on the file.
2. No tests for the type system
Struct.Equals/GetHashCode and Func.Equals/GetHashCode are the only hand-written non-trivial logic in the PR, and they're the only part untested. Starter file in a follow-up comment below. One of those tests fails against the current code and pins a real bug — see the Struct.cs comment.
3. CI has never run on this PR
gh pr checks 5 shows only the pending CLA, so net10.0 + Grpc.Tools 2.83.0 + a 57-minor-version proto jump is currently unverified by anything.
Related, and worth knowing before you rely on a green build: Substrait.Core.csproj:11 sets CompileOutputs="false", so the protos are generated but never compiled into the assembly. That means (a) dotnet build cannot catch a breaking proto change, so the submodule bump is unvalidated by construction; (b) the Google.Protobuf reference is unused at compile time; (c) every <see cref="Protobuf.XxxRel"/> in Relation/* points at a type absent from the compilation, and since GenerateDocumentationFile is off, nothing warns. All pre-existing — but if the plan is to flip CompileOutputs to true soon, better to find out now whether v0.99 protos actually compile.
4. A question about the Substrait.Core.Type namespace
One consequence that I don't think is obvious up front: C# resolves a simple name against enclosing namespace members before using imports, so within Substrait.* the name Type now binds to the namespace rather than System.Type. That makes this a compile error in the new test project, because Substrait.Core.Tests sits inside Substrait.Core:
namespace Substrait.Core.Tests;
public class Demo
{
// CS0118: 'Type' is a namespace but is used like a type
private readonly List<Type> _kinds = [];
}If you'd rather confirm the lookup behaviour than take my word for it: paste that into the test project and run dotnet build. Thirty seconds, and it either errors or it doesn't.
Scope is narrower than I first assumed, so to be clear about what this is not: it doesn't reach consumers, because using imports types and not nested namespaces — an app in its own namespace never sees Type shadowed. It affects this library, the test project, and any future sibling project under Substrait.*, which I'd guess includes the proto conversion layer. The String/Decimal shadowing that forced the aliases atop ITypeVisitor.cs and TypeCreator.cs is separate and mostly cosmetic — the string/decimal keywords are immune, and that's what most code uses.
Options as I see them:
Substrait.Core.Types— one-word change, removes the hazard entirely, keeps every type name as-is.- Keep
Type, absorb the cost — reasonable if cross-language consistency matters more; the other implementations usetypeas the package/module name (io.substrait.typein substrait-java), and matching them has real value for anyone reading two bindings side by side. Cost isSystem.Typefully qualified in this repo forever. Noteglobal usingaliases won't help consumers — they don't cross assembly references, so each project declares its own. - Suffix the type names (
ListType,DecimalType) — orthogonal to the above, and lower stakes given the keyword immunity noted above.
I lean 1. If matching the other bindings' package naming is deliberate here, 2 is defensible and I'd drop it. Either way I'd rather settle it before the type system lands than after, since it's a namespace change on a public API.
On scope
45 files mixing repo hygiene, a submodule bump, a breaking namespace rename, and a new feature makes a future bisect painful, and the PR claims to close #3 (CI only) while doing considerably more. Your commits already separate cleanly — splitting into CI + editorconfig + slnx / TFM + submodule / namespace rename / type system would let the uncontroversial 80% land immediately. Also worth noting the branch is your fork's main, which will make follow-up rebases awkward.
Detailed comments inline. (Minor: "namsepacing" typo in the description.)
|
A starting point for point 2 of my review —
Note that using Substrait.Core.Type;
using Substrait.Core.Type.Compound;
using Substrait.Core.Type.Simple;
using Decimal = Substrait.Core.Type.Compound.Decimal;
using String = Substrait.Core.Type.Simple.String;
namespace Substrait.Core.Tests;
public class TypeCreatorTests
{
[Fact]
public void Required_ProducesNonNullableTypes() => Assert.False(TypeCreator.Required.I32.Nullable);
[Fact]
public void Nullable_ProducesNullableTypes() => Assert.True(TypeCreator.Nullable.I32.Nullable);
[Fact]
public void Of_ReturnsTheMatchingSingleton()
{
Assert.Same(TypeCreator.Nullable, TypeCreator.Of(nullable: true));
Assert.Same(TypeCreator.Required, TypeCreator.Of(nullable: false));
}
[Fact]
public void AsNullable_PreservesTypeParameters()
{
var required = (Decimal)TypeCreator.Required.Decimal(precision: 10, scale: 2);
var nullable = (Decimal)TypeCreator.AsNullable(required);
Assert.True(nullable.Nullable);
Assert.Equal(10, nullable.Precision);
Assert.Equal(2, nullable.Scale);
}
[Fact]
public void AsNotNullable_RoundTripsToTheOriginal()
{
var original = TypeCreator.Required.VarChar(255);
Assert.Equal(original, TypeCreator.AsNotNullable(TypeCreator.AsNullable(original)));
}
}
public class TypeEqualityTests
{
[Fact]
public void Struct_WithEqualFields_IsEqualAndHashesEqually()
{
var a = TypeCreator.Required.Struct(TypeCreator.Required.I32, TypeCreator.Nullable.String);
var b = TypeCreator.Required.Struct(TypeCreator.Required.I32, TypeCreator.Nullable.String);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void Struct_WithReorderedFields_IsNotEqual()
{
var a = TypeCreator.Required.Struct(TypeCreator.Required.I32, TypeCreator.Required.I64);
var b = TypeCreator.Required.Struct(TypeCreator.Required.I64, TypeCreator.Required.I32);
Assert.NotEqual(a, b);
}
[Fact]
public void Struct_DifferingOnlyInNullability_IsNotEqual()
{
var required = TypeCreator.Required.Struct(TypeCreator.Required.I32);
var nullable = TypeCreator.Nullable.Struct(TypeCreator.Required.I32);
Assert.NotEqual(required, nullable);
}
[Fact]
public void Struct_IsNotMutatedByCallerOwnedArray()
{
var fields = new[] { TypeCreator.Required.I32 };
var type = TypeCreator.Required.Struct(fields);
var hashBefore = type.GetHashCode();
fields[0] = TypeCreator.Required.I64;
Assert.Equal(hashBefore, type.GetHashCode());
}
[Fact]
public void Func_WithEqualSignatures_IsEqualAndHashesEqually()
{
var a = TypeCreator.Required.Func([TypeCreator.Required.I32], TypeCreator.Required.Bool);
var b = TypeCreator.Required.Func([TypeCreator.Required.I32], TypeCreator.Required.Bool);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void Func_WithDifferentReturnType_IsNotEqual()
{
var a = TypeCreator.Required.Func([TypeCreator.Required.I32], TypeCreator.Required.Bool);
var b = TypeCreator.Required.Func([TypeCreator.Required.I32], TypeCreator.Required.I64);
Assert.NotEqual(a, b);
}
[Fact]
public void NestedTypes_CompareStructurally()
{
var a = TypeCreator.Required.Map(
TypeCreator.Required.String,
TypeCreator.Nullable.List(TypeCreator.Required.I32));
var b = TypeCreator.Required.Map(
TypeCreator.Required.String,
TypeCreator.Nullable.List(TypeCreator.Required.I32));
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void DifferentKindsWithIdenticalShape_AreNotEqual() =>
Assert.NotEqual(
TypeCreator.Required.PrecisionTime(6),
TypeCreator.Required.PrecisionTimestamp(6));
}
public class TypeVisitorTests
{
[Fact]
public void EveryTypeClass_HasAVisitorOverload()
{
var kinds = typeof(TypeClass)
.Assembly.GetTypes()
.Where(t => !t.IsAbstract && t.IsSubclassOf(typeof(TypeClass)))
.ToList();
var covered = typeof(ITypeVisitor<>)
.GetMethods()
.Where(m => m.Name == "Visit")
.Select(m => m.GetParameters()[0].ParameterType)
.ToHashSet();
var missing = kinds.Where(k => !covered.Contains(k)).Select(k => k.Name).Order().ToList();
Assert.True(missing.Count == 0, $"No ITypeVisitor overload for: {string.Join(", ", missing)}");
}
[Fact]
public void Accept_DispatchesOnTheRuntimeKind()
{
var visitor = new KindNameVisitor();
Assert.Equal(nameof(Decimal), TypeCreator.Required.Decimal(10, 2).Accept(visitor));
Assert.Equal(nameof(List), TypeCreator.Required.List(TypeCreator.Required.I32).Accept(visitor));
}
private sealed class KindNameVisitor : ITypeVisitor<string>
{
public string VisitFallback(TypeClass type) => type.GetType().Name;
}
} |
|
One more thing, and it's new information rather than a further review point — I should have flagged it upfront: I've been working on substrait-io/substrait-packaging#56 in parallel. It adds a This shouldn't block anything here. It's still draft and gated on two org-side settings, and there's no release to depend on yet — so your v0.99 bump is the bridge until there is one, not wasted work. It does put real evidence behind point 4 though, and I'd rather surface it before you decide than after. From So Adjacent trap while the names are still in play: if the conversion layer lands in Which is the nicer half of this — those crefs are dead today, per the Last thing, correcting myself: I suggested Happy to coordinate on sequencing. And if you'd rather own the conversion layer yourself, say so and I'll stay clear of it. |
Co-authored-by: Niels Pardon <mail@niels-pardon.de>
Co-authored-by: Niels Pardon <mail@niels-pardon.de>
Co-authored-by: Niels Pardon <mail@niels-pardon.de>
…tNullException.ThrowIfNull
…of the base TypeClass
…preventing caller array mutation from corrupting hash/equality
|
|
@nielspardon I also updated the PR description. And I have also added the missing tests that I had previously forgotten to |
Summary
This repo has had no real activity since 2022. I'm reviving it incrementally — this PR now covers three things.
Build/repo hygiene
substraitsubmodule v0.42.1 → v0.99.0; retargetSubstrait.Coreto .NET 10 (LTS).editorconfig,.gitattributes,Directory.Build.props(real code-style analysis in the build, not just whitespace),global.json.slnx; remove deadx64/x86platform entriespermissions,concurrencycancellation, SHA-pinned actions,workflow_dispatch)CompileOutputstotrue— the generated protobuf code is now actually compiled and build-verified, not just generated and ignoredSubstraitRelVisitor: it wasabstractwith zerovirtualmembers, so every subclass threw on every call — the visitor pattern was non-functional. Now overridable.Relation/*Closes #3.
Core type system
TypeClass+ITypeVisitor(with aVisitFallbackdefault so adding a kind isn't a breaking change) +TypeCreator, covering everySimple/Compoundkind exceptUserDefined/NSTRUCT(deliberatelydeferred, see comment below)
Substrait.Core.Type→Substrait.Core.Typesto avoid theSystem.Typeshadowing problem raised in reviewTypeCreatorvalidates constructor arguments against the spec's boundsStruct/Func: both stored the caller's array reference directly, so mutating it after construction silently changed the record's hash and could corrupt a dictionary/set itwas keyed into
TypeClasshas a matchingITypeVisitoroverload(Correcting the original text here, which overstated this as "every Simple/Compound type kind.")
Still open
UserDefined/NSTRUCT— deliberately deferred, see comment below for why