feat(mqtt): vendor Tahu sparkplug_b.proto + Grpc.Tools codegen
Vendors the NORMATIVE (proto2) Eclipse Tahu sparkplug_b.proto and compiles it
in .Contracts with build-time Grpc.Tools, message-only (GrpcServices="None") --
Sparkplug rides MQTT, there is no gRPC service, so no Grpc.Core.Api is pulled in.
Provenance rides in the file header: repo, path, pinned commit
5736e404889d4b95910613040a99ba79589ffb13, permalink, git blob SHA-1
bf72ab5f..., content SHA-256 4432c5c4..., EPL-2.0, and a re-verify recipe. The
body below line 38 is byte-for-byte upstream; the blob SHA-1 matches the tree
entry at that commit, so the copy is provably genuine and not reconstructed.
Chose the proto2 file over upstream's sibling sparkplug_b_c_sharp.proto: the
sibling is a lossy proto3 restatement that drops explicit presence, and protoc
generates valid C# from proto2 into the identical namespace
(Org.Eclipse.Tahu.Protobuf, PascalCased from the package -- no
csharp_namespace option). Presence matters downstream: an NBIRTH legitimately
carries seq = 0 and a DATA metric legitimately omits `name`, so Has{Seq,Name,
Alias,IsNull,Datatype} is the difference between "absent" and "present, zero".
.Contracts stays transport-free: Google.Protobuf is a serialization dependency
with a framework-only graph, Grpc.Tools is PrivateAssets=all, and the resolved
graph is exactly {Google.Protobuf, Grpc.Tools, Core.Abstractions} -- no MQTTnet.
Codegen tests pin the round-trip, the namespace, the Sparkplug field numbers,
the DataType spec indices, and protoc's enum-name mangling (UInt64 -> Uint64,
UUID -> Uuid) that Task 17's datatype map has to spell correctly.
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
// =====================================================================================================
|
||||||
|
// VENDORED THIRD-PARTY FILE — DO NOT EDIT. Re-vendor from upstream instead (recipe below).
|
||||||
|
//
|
||||||
|
// Upstream project Eclipse Tahu — the Sparkplug B reference implementation
|
||||||
|
// Upstream repo https://github.com/eclipse-tahu/tahu
|
||||||
|
// Upstream path sparkplug_b/sparkplug_b.proto
|
||||||
|
// Pinned commit 5736e404889d4b95910613040a99ba79589ffb13 (master, 2023-11-06)
|
||||||
|
// Permalink https://raw.githubusercontent.com/eclipse-tahu/tahu/5736e404889d4b95910613040a99ba79589ffb13/sparkplug_b/sparkplug_b.proto
|
||||||
|
// git blob SHA-1 bf72ab5f09a333afabcb40fd45362ffbb0c8c5bd (matches the tree entry at that commit)
|
||||||
|
// content SHA-256 4432c5c483b7fb9732d0594c98a2e97dca5e517e39c5374a8b918d837f0b4a19 (8330 bytes)
|
||||||
|
// last modified 46f25e79f34234e6145d11108660dfd9133ae50d (2022-05-16, template_ref comment fix)
|
||||||
|
// License Eclipse Public License 2.0 (EPL-2.0) — https://www.eclipse.org/legal/epl-2.0/
|
||||||
|
// Copyright (c) Cirrus Link Solutions and others. The upstream copyright + SPDX
|
||||||
|
// header is preserved verbatim as the first lines of the copied body below.
|
||||||
|
//
|
||||||
|
// Everything from line 38 down ("// * Copyright (c) 2015, 2018 Cirrus Link Solutions…") is a
|
||||||
|
// BYTE-FOR-BYTE copy of the upstream file. Only these header lines were added. To re-verify:
|
||||||
|
//
|
||||||
|
// curl -sSL <permalink above> -o /tmp/upstream.proto
|
||||||
|
// tail -n +38 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/Protos/sparkplug_b.proto > /tmp/vendored.proto
|
||||||
|
// diff /tmp/upstream.proto /tmp/vendored.proto && shasum -a 256 /tmp/vendored.proto
|
||||||
|
//
|
||||||
|
// WHY VENDORED rather than a NuGet package: SparkplugNet is the only .NET Sparkplug library and it is
|
||||||
|
// stale (1.3.10, 2024-07-02), transitively pins MQTTnet 4.3.6.1152 against this repo's MQTTnet 5.2.0,
|
||||||
|
// and ships net6.0/net8.0 only — no net10.0 TFM. So the schema is vendored and decoded directly with
|
||||||
|
// Google.Protobuf, over the same single MQTTnet-5 client the plain-MQTT path already uses.
|
||||||
|
//
|
||||||
|
// WHY THE proto2 FILE and not the sibling sparkplug_b_c_sharp.proto that upstream also ships: this is
|
||||||
|
// the NORMATIVE Sparkplug B schema (the C# sibling is a lossy proto3 restatement that drops explicit
|
||||||
|
// presence and rewrites the `extensions` ranges as `google.protobuf.Any`). protoc from Grpc.Tools
|
||||||
|
// generates valid C# from proto2, and the generated namespace is identical (Org.Eclipse.Tahu.Protobuf,
|
||||||
|
// PascalCased from `package org.eclipse.tahu.protobuf` — there is no `option csharp_namespace`). Keeping
|
||||||
|
// proto2 buys explicit presence — Has{Name,Alias,Seq,IsNull,Datatype} — which the decoder needs to tell
|
||||||
|
// "field absent" from "field present and zero" (an NBIRTH legitimately carries seq = 0, and a DATA
|
||||||
|
// metric legitimately omits `name` and carries only `alias`).
|
||||||
|
// =====================================================================================================
|
||||||
|
|
||||||
|
// * Copyright (c) 2015, 2018 Cirrus Link Solutions and others
|
||||||
|
// *
|
||||||
|
// * This program and the accompanying materials are made available under the
|
||||||
|
// * terms of the Eclipse Public License 2.0 which is available at
|
||||||
|
// * http://www.eclipse.org/legal/epl-2.0.
|
||||||
|
// *
|
||||||
|
// * SPDX-License-Identifier: EPL-2.0
|
||||||
|
// *
|
||||||
|
// * Contributors:
|
||||||
|
// * Cirrus Link Solutions - initial implementation
|
||||||
|
|
||||||
|
//
|
||||||
|
// To compile:
|
||||||
|
// cd client_libraries/java
|
||||||
|
// protoc --proto_path=../../ --java_out=src/main/java ../../sparkplug_b.proto
|
||||||
|
//
|
||||||
|
|
||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
package org.eclipse.tahu.protobuf;
|
||||||
|
|
||||||
|
option java_package = "org.eclipse.tahu.protobuf";
|
||||||
|
option java_outer_classname = "SparkplugBProto";
|
||||||
|
|
||||||
|
enum DataType {
|
||||||
|
// Indexes of Data Types
|
||||||
|
|
||||||
|
// Unknown placeholder for future expansion.
|
||||||
|
Unknown = 0;
|
||||||
|
|
||||||
|
// Basic Types
|
||||||
|
Int8 = 1;
|
||||||
|
Int16 = 2;
|
||||||
|
Int32 = 3;
|
||||||
|
Int64 = 4;
|
||||||
|
UInt8 = 5;
|
||||||
|
UInt16 = 6;
|
||||||
|
UInt32 = 7;
|
||||||
|
UInt64 = 8;
|
||||||
|
Float = 9;
|
||||||
|
Double = 10;
|
||||||
|
Boolean = 11;
|
||||||
|
String = 12;
|
||||||
|
DateTime = 13;
|
||||||
|
Text = 14;
|
||||||
|
|
||||||
|
// Additional Metric Types
|
||||||
|
UUID = 15;
|
||||||
|
DataSet = 16;
|
||||||
|
Bytes = 17;
|
||||||
|
File = 18;
|
||||||
|
Template = 19;
|
||||||
|
|
||||||
|
// Additional PropertyValue Types
|
||||||
|
PropertySet = 20;
|
||||||
|
PropertySetList = 21;
|
||||||
|
|
||||||
|
// Array Types
|
||||||
|
Int8Array = 22;
|
||||||
|
Int16Array = 23;
|
||||||
|
Int32Array = 24;
|
||||||
|
Int64Array = 25;
|
||||||
|
UInt8Array = 26;
|
||||||
|
UInt16Array = 27;
|
||||||
|
UInt32Array = 28;
|
||||||
|
UInt64Array = 29;
|
||||||
|
FloatArray = 30;
|
||||||
|
DoubleArray = 31;
|
||||||
|
BooleanArray = 32;
|
||||||
|
StringArray = 33;
|
||||||
|
DateTimeArray = 34;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Payload {
|
||||||
|
|
||||||
|
message Template {
|
||||||
|
|
||||||
|
message Parameter {
|
||||||
|
optional string name = 1;
|
||||||
|
optional uint32 type = 2;
|
||||||
|
|
||||||
|
oneof value {
|
||||||
|
uint32 int_value = 3;
|
||||||
|
uint64 long_value = 4;
|
||||||
|
float float_value = 5;
|
||||||
|
double double_value = 6;
|
||||||
|
bool boolean_value = 7;
|
||||||
|
string string_value = 8;
|
||||||
|
ParameterValueExtension extension_value = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ParameterValueExtension {
|
||||||
|
extensions 1 to max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optional string version = 1; // The version of the Template to prevent mismatches
|
||||||
|
repeated Metric metrics = 2; // Each metric includes a name, datatype, and optionally a value
|
||||||
|
repeated Parameter parameters = 3;
|
||||||
|
optional string template_ref = 4; // MUST be a reference to a template definition if this is an instance (i.e. the name of the template definition) - MUST be omitted for template definitions
|
||||||
|
optional bool is_definition = 5;
|
||||||
|
extensions 6 to max;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DataSet {
|
||||||
|
|
||||||
|
message DataSetValue {
|
||||||
|
|
||||||
|
oneof value {
|
||||||
|
uint32 int_value = 1;
|
||||||
|
uint64 long_value = 2;
|
||||||
|
float float_value = 3;
|
||||||
|
double double_value = 4;
|
||||||
|
bool boolean_value = 5;
|
||||||
|
string string_value = 6;
|
||||||
|
DataSetValueExtension extension_value = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DataSetValueExtension {
|
||||||
|
extensions 1 to max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message Row {
|
||||||
|
repeated DataSetValue elements = 1;
|
||||||
|
extensions 2 to max; // For third party extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
optional uint64 num_of_columns = 1;
|
||||||
|
repeated string columns = 2;
|
||||||
|
repeated uint32 types = 3;
|
||||||
|
repeated Row rows = 4;
|
||||||
|
extensions 5 to max; // For third party extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
message PropertyValue {
|
||||||
|
|
||||||
|
optional uint32 type = 1;
|
||||||
|
optional bool is_null = 2;
|
||||||
|
|
||||||
|
oneof value {
|
||||||
|
uint32 int_value = 3;
|
||||||
|
uint64 long_value = 4;
|
||||||
|
float float_value = 5;
|
||||||
|
double double_value = 6;
|
||||||
|
bool boolean_value = 7;
|
||||||
|
string string_value = 8;
|
||||||
|
PropertySet propertyset_value = 9;
|
||||||
|
PropertySetList propertysets_value = 10; // List of Property Values
|
||||||
|
PropertyValueExtension extension_value = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PropertyValueExtension {
|
||||||
|
extensions 1 to max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message PropertySet {
|
||||||
|
repeated string keys = 1; // Names of the properties
|
||||||
|
repeated PropertyValue values = 2;
|
||||||
|
extensions 3 to max;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PropertySetList {
|
||||||
|
repeated PropertySet propertyset = 1;
|
||||||
|
extensions 2 to max;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MetaData {
|
||||||
|
// Bytes specific metadata
|
||||||
|
optional bool is_multi_part = 1;
|
||||||
|
|
||||||
|
// General metadata
|
||||||
|
optional string content_type = 2; // Content/Media type
|
||||||
|
optional uint64 size = 3; // File size, String size, Multi-part size, etc
|
||||||
|
optional uint64 seq = 4; // Sequence number for multi-part messages
|
||||||
|
|
||||||
|
// File metadata
|
||||||
|
optional string file_name = 5; // File name
|
||||||
|
optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc)
|
||||||
|
optional string md5 = 7; // md5 of data
|
||||||
|
|
||||||
|
// Catchalls and future expansion
|
||||||
|
optional string description = 8; // Could be anything such as json or xml of custom properties
|
||||||
|
extensions 9 to max;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Metric {
|
||||||
|
|
||||||
|
optional string name = 1; // Metric name - should only be included on birth
|
||||||
|
optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages
|
||||||
|
optional uint64 timestamp = 3; // Timestamp associated with data acquisition time
|
||||||
|
optional uint32 datatype = 4; // DataType of the metric/tag value
|
||||||
|
optional bool is_historical = 5; // If this is historical data and should not update real time tag
|
||||||
|
optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag
|
||||||
|
optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes.
|
||||||
|
optional MetaData metadata = 8; // Metadata for the payload
|
||||||
|
optional PropertySet properties = 9;
|
||||||
|
|
||||||
|
oneof value {
|
||||||
|
uint32 int_value = 10;
|
||||||
|
uint64 long_value = 11;
|
||||||
|
float float_value = 12;
|
||||||
|
double double_value = 13;
|
||||||
|
bool boolean_value = 14;
|
||||||
|
string string_value = 15;
|
||||||
|
bytes bytes_value = 16; // Bytes, File
|
||||||
|
DataSet dataset_value = 17;
|
||||||
|
Template template_value = 18;
|
||||||
|
MetricValueExtension extension_value = 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MetricValueExtension {
|
||||||
|
extensions 1 to max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
optional uint64 timestamp = 1; // Timestamp at message sending time
|
||||||
|
repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs
|
||||||
|
optional uint64 seq = 3; // Sequence number
|
||||||
|
optional string uuid = 4; // UUID to track message type in terms of schema definitions
|
||||||
|
optional bytes body = 5; // To optionally bypass the whole definition above
|
||||||
|
extensions 6 to max; // For third party extensions
|
||||||
|
}
|
||||||
+25
@@ -8,4 +8,29 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Sparkplug B (P2): the vendored Eclipse Tahu schema is compiled here, in the one assembly all
|
||||||
|
four MQTT projects reference. Message-only codegen (GrpcServices="None") — Sparkplug rides MQTT,
|
||||||
|
there is no gRPC service, so no Grpc.Core.Api runtime reference is needed (Commons, this repo's
|
||||||
|
other locally-compiled proto, takes one only because it generates service stubs).
|
||||||
|
|
||||||
|
Google.Protobuf is a SERIALIZATION dependency, not a transport one, so .Contracts stays
|
||||||
|
transport-free: it still has no MQTTnet reference (dropped in Task 1) and Google.Protobuf's own
|
||||||
|
graph is framework-only. Grpc.Tools is build-time (PrivateAssets=all) and flows to no consumer.
|
||||||
|
|
||||||
|
Build-platform note (inherited, not new): Grpc.Tools' bundled linux_arm64 protoc segfaults
|
||||||
|
(exit 139) under Apple-Silicon Docker, which is why docker-dev/Dockerfile pins its build stage
|
||||||
|
to --platform=linux/amd64. That pin already exists for the Commons proto; this project adds a
|
||||||
|
second .proto to the same already-pinned build, not a new constraint. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Google.Protobuf"/>
|
||||||
|
<PackageReference Include="Grpc.Tools">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Protobuf Include="Protos\sparkplug_b.proto" GrpcServices="None"/>
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using Org.Eclipse.Tahu.Protobuf;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the vendored Eclipse Tahu <c>sparkplug_b</c> protobuf codegen: the generated types exist,
|
||||||
|
/// land in the namespace the rest of P2 is written against, and carry the Sparkplug-B field numbers
|
||||||
|
/// on the wire. Nothing here decodes a real payload — that is Task 16 (<c>SparkplugCodec</c> +
|
||||||
|
/// golden vectors); this suite exists so a broken/renamed/mangled vendored proto fails at the
|
||||||
|
/// codegen seam rather than as a mysterious decode failure three tasks later.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SparkplugProtoCodegenTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GeneratedPayload_RoundTrips()
|
||||||
|
{
|
||||||
|
var p = new Payload { Seq = 3 };
|
||||||
|
p.Metrics.Add(new Payload.Types.Metric { Name = "Temperature", Alias = 5 });
|
||||||
|
|
||||||
|
var back = Payload.Parser.ParseFrom(p.ToByteArray());
|
||||||
|
|
||||||
|
back.Seq.ShouldBe(3ul);
|
||||||
|
back.Metrics[0].Name.ShouldBe("Temperature");
|
||||||
|
back.Metrics[0].Alias.ShouldBe(5ul);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tasks 16 / 20 / 25 name <c>Org.Eclipse.Tahu.Protobuf</c> explicitly, and the namespace is
|
||||||
|
/// derived (not declared) — protoc PascalCases the proto <c>package</c> because the vendored file
|
||||||
|
/// carries no <c>option csharp_namespace</c>. Editing the package line to "tidy" it would silently
|
||||||
|
/// rehome every generated type.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void GeneratedTypes_LiveInTheTahuPackageNamespace()
|
||||||
|
{
|
||||||
|
typeof(Payload).Namespace.ShouldBe("Org.Eclipse.Tahu.Protobuf");
|
||||||
|
Payload.Descriptor.File.Package.ShouldBe("org.eclipse.tahu.protobuf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Field numbers are the wire contract with every Sparkplug edge node in the plant; a vendored
|
||||||
|
/// proto that was hand-edited or reconstructed rather than copied from upstream would most likely
|
||||||
|
/// drift here first.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PayloadAndMetric_CarrySparkplugBFieldNumbers()
|
||||||
|
{
|
||||||
|
Payload.TimestampFieldNumber.ShouldBe(1);
|
||||||
|
Payload.MetricsFieldNumber.ShouldBe(2);
|
||||||
|
Payload.SeqFieldNumber.ShouldBe(3);
|
||||||
|
Payload.UuidFieldNumber.ShouldBe(4);
|
||||||
|
Payload.BodyFieldNumber.ShouldBe(5);
|
||||||
|
|
||||||
|
Payload.Types.Metric.NameFieldNumber.ShouldBe(1);
|
||||||
|
Payload.Types.Metric.AliasFieldNumber.ShouldBe(2);
|
||||||
|
Payload.Types.Metric.TimestampFieldNumber.ShouldBe(3);
|
||||||
|
Payload.Types.Metric.DatatypeFieldNumber.ShouldBe(4);
|
||||||
|
Payload.Types.Metric.IsNullFieldNumber.ShouldBe(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 17's datatype map keys off these indices. They are Sparkplug-spec constants, not protobuf
|
||||||
|
/// tags, so they are worth pinning independently of the field numbers above.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DataTypeEnum_MatchesSparkplugSpecIndices()
|
||||||
|
{
|
||||||
|
((int)DataType.Int8).ShouldBe(1);
|
||||||
|
((int)DataType.Int32).ShouldBe(3);
|
||||||
|
((int)DataType.Uint64).ShouldBe(8);
|
||||||
|
((int)DataType.Float).ShouldBe(9);
|
||||||
|
((int)DataType.Double).ShouldBe(10);
|
||||||
|
((int)DataType.Boolean).ShouldBe(11);
|
||||||
|
((int)DataType.String).ShouldBe(12);
|
||||||
|
((int)DataType.DateTime).ShouldBe(13);
|
||||||
|
((int)DataType.DataSet).ShouldBe(16);
|
||||||
|
((int)DataType.Template).ShouldBe(19);
|
||||||
|
((int)DataType.Int32Array).ShouldBe(24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// protoc's C# generator RE-CASES enum member names: the proto's <c>UInt64</c> becomes
|
||||||
|
/// <c>Uint64</c> and <c>UUID</c> becomes <c>Uuid</c> (only the leading character of each
|
||||||
|
/// underscore-separated word survives as a capital). Task 17's datatype map has to spell them
|
||||||
|
/// the mangled way, so pin the mangling rather than letting it be rediscovered as a build error.
|
||||||
|
/// The wire-facing spelling is preserved on <c>OriginalName</c>, which is what
|
||||||
|
/// <c>DataType.Parser</c>-style JSON round-trips use.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DataTypeEnum_CSharpNamesAreProtocMangled_NotTheProtoSpelling()
|
||||||
|
{
|
||||||
|
Enum.GetNames<DataType>().ShouldContain("Uint64");
|
||||||
|
Enum.GetNames<DataType>().ShouldNotContain("UInt64");
|
||||||
|
Enum.GetNames<DataType>().ShouldContain("Uuid");
|
||||||
|
Enum.GetNames<DataType>().ShouldNotContain("UUID");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user