diff --git a/Directory.Packages.props b/Directory.Packages.props index 9a1c0fb..f323841 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ --> 5.9.0 5.9.0 - 1.66.27 + 1.68.4 1.0.0-prerelease.42 10.0.12 diff --git a/README.md b/README.md index e04ca40..4c3e763 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,13 @@ var urlSchema = Z.String().Url(); var urlResult = urlSchema.Validate("https://example.com"); // UUID validation -var uuidSchema = Z.String().Uuid(); +var uuidSchema = Z.String().UUID(); var uuidResult = uuidSchema.Validate("550e8400-e29b-41d4-a716-446655440000"); +// Version-specific UUID validation (e.g. RFC 9562 version 7) +var uuidV7Schema = Z.String().UUID(UuidVersion.V7); +var uuidV7Result = uuidV7Schema.Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); + // String transformations var trimmedSchema = Z.String().Trim(); var upperSchema = Z.String().ToUpper(); diff --git a/docs/wiki/JsonSchema-Import.md b/docs/wiki/JsonSchema-Import.md index 2ff526f..69e965c 100644 --- a/docs/wiki/JsonSchema-Import.md +++ b/docs/wiki/JsonSchema-Import.md @@ -39,7 +39,7 @@ var result = userSchema.Validate(userData); - `type` — `string` / `number` / `integer` / `boolean` / `null` / `object` / `array`. - `enum` → `ZodUnion` of literals (a single member becomes a literal); `const` → literal. - `anyOf` / `oneOf` → `ZodUnion`; `allOf` → first schema. -- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). +- String constraints — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The `uuid`/`guid` format maps to the versionless `.UUID()`; JSON Schema has no versioned `uuid` format, so a versioned `.UUID(UuidVersion.V7)` schema exports back as plain `format: "uuid"`. - Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. - Objects — `required` and optional fields via `Z.Object().Field(...)`. - Arrays — `items`, `minItems`, `maxItems`. diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index 58a40c9..d37548d 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -98,6 +98,24 @@ All valid-input paths are zero-allocation: | ValidateObject_Allocations | 94.28 ns | 2.06 | | ValidateArray_Allocations | 1,180.24 ns | 25.82 | +## UUID validation (`UuidPerformanceTests`) + +UUID validation uses a zero-allocation char-scan (version nibble at position 14, variant nibble at position 19) instead of a regex. Measured against the previous compiled regex: + +| Scenario | Mean | Allocated | +|---|---|---| +| Rule_CharScan_Valid (`.UUID()`) | 21.99 ns | 0 B | +| Rule_LegacyRegex_Valid (previous implementation) | 27.50 ns | 0 B | +| Rule_CharScan_Invalid | < 1 ns | 0 B | +| Rule_LegacyRegex_Invalid | 14.22 ns | 0 B | +| Rule_CharScan_Nil | 20.67 ns | 0 B | +| Rule_CharScanV7_Valid (`.UUID(UuidVersion.V7)`) | 20.89 ns | 0 B | +| Rule_CharScanV7_Mismatch | 20.11 ns | 0 B | +| Schema_UUID_Valid | 29.80 ns | 0 B | +| Schema_UUIDV7_Valid | 26.64 ns | 0 B | + +The char-scan is ~20% faster than the previous regex on the valid path, is version-aware at no extra cost, and rejects wrong-length strings in under a nanosecond. + ## Optimizations that make it fast 1. **Struct-based rules** — every rule is a `readonly record struct` implementing `IValidationRule`, so there is no per-validation object allocation. diff --git a/docs/wiki/String-Validation.md b/docs/wiki/String-Validation.md index 9443e63..d597f7e 100644 --- a/docs/wiki/String-Validation.md +++ b/docs/wiki/String-Validation.md @@ -22,7 +22,8 @@ var result = schema.Validate("user@example.com"); | `Phone` | `Phone(string? message)` | `PhoneRule` — digits plus `() .+-`, at least one digit | | `CreditCard` | `CreditCard(string? message)` | `CreditCardRule` — Luhn algorithm | | `Base64String` | `Base64String(string? message)` | `Base64StringRule` — `Convert.FromBase64String` | -| `UUID` | `UUID(string? message)` | `UUIDRule` — `xxxxxxxx-xxxx-...` regex | +| `UUID` | `UUID(string? message)` | `UUIDRule` — char-scan, RFC 9562 versions 1-8, variant nibble `8-9/a-b`, plus nil and max | +| `UUID` | `UUID(UuidVersion version, string? message)` | `UUIDRule` — requires a specific version (e.g. `V7`), variant nibble `8-9/a-b`, nil/max rejected | | `StartsWith` | `StartsWith(string prefix, string? message)` | `StartsWithRule` — ordinal comparison | | `EndsWith` | `EndsWith(string suffix, string? message)` | `EndsWithRule` — ordinal comparison | | `ToLower` | `ToLower()` | wraps a transform (`ToLowerInvariant`), returns a `ZodString` | @@ -42,6 +43,8 @@ var url = Z.String().Url().Validate("https://example.com"); var uuid = Z.String().UUID().Validate("550e8400-e29b-41d4-a716-446655440000"); +var uuidV7 = Z.String().UUID(UuidVersion.V7).Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); + var prefix = Z.String().StartsWith("https://"); var suffix = Z.String().EndsWith(".com"); diff --git a/global.json b/global.json index ec26fcf..e512622 100644 --- a/global.json +++ b/global.json @@ -3,9 +3,9 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.DotNetProjectSdk": "1.0.0-prerelease.54" + "Purview.DotNetProjectSdk": "1.0.0-prerelease.55" }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 998724e..906dbc7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zodsharp", - "version": "2.0.0-prerelease.6", + "version": "2.0.0-prerelease.7", "private": true, "license": "MIT", "author": { @@ -28,4 +28,4 @@ "bun": ">=1.4.2" }, "packageManager": "bun@1.4.2" -} \ No newline at end of file +} diff --git a/src/src/Benchmarks/UuidPerformanceTests.cs b/src/src/Benchmarks/UuidPerformanceTests.cs new file mode 100644 index 0000000..3225e78 --- /dev/null +++ b/src/src/Benchmarks/UuidPerformanceTests.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; +using BenchmarkDotNet.Attributes; +using ZodSharp.Core; +using ZodSharp.Rules; +using ZodSharp.Schemas; + +namespace ZodSharp; + +/// +/// Performance tests for UUID validation: char-scan rule vs the legacy compiled regex, +/// plus the schema-level validation paths. +/// +[MemoryDiagnoser] +[SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] +public class UuidPerformanceTests +{ + static readonly string ValidUuidV4 = "550e8400-e29b-41d4-a716-446655440000"; + static readonly string ValidUuidV7 = "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"; + static readonly string InvalidUuid = "550e8400-e29b-41d4-a716"; + static readonly string NilUuid = "00000000-0000-0000-0000-000000000000"; + + static readonly Regex LegacyUuidRegex = new( + @"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100) + ); + + readonly UUIDRule _uuidRule; + readonly UUIDRule _uuidV7Rule; + readonly ZodString _uuidSchema; + readonly ZodString _uuidV7Schema; + + public UuidPerformanceTests() + { + _uuidRule = new(); + _uuidV7Rule = new(UuidVersion.V7); + _uuidSchema = Z.String().UUID(); + _uuidV7Schema = Z.String().UUID(UuidVersion.V7); + } + + [Benchmark] + public bool Rule_CharScan_Valid() => _uuidRule.IsValid(ValidUuidV4); + + [Benchmark] + public bool Rule_LegacyRegex_Valid() => LegacyUuidRegex.IsMatch(ValidUuidV4); + + [Benchmark] + public bool Rule_CharScan_Invalid() => _uuidRule.IsValid(InvalidUuid); + + [Benchmark] + public bool Rule_LegacyRegex_Invalid() => LegacyUuidRegex.IsMatch(InvalidUuid); + + [Benchmark] + public bool Rule_CharScan_Nil() => _uuidRule.IsValid(NilUuid); + + [Benchmark] + public bool Rule_CharScanV7_Valid() => _uuidV7Rule.IsValid(ValidUuidV7); + + [Benchmark] + public bool Rule_CharScanV7_Mismatch() => _uuidV7Rule.IsValid(ValidUuidV4); + + [Benchmark] + public ValidationResult Schema_UUID_Valid() => _uuidSchema.Validate(ValidUuidV4); + + [Benchmark] + public ValidationResult Schema_UUIDV7_Valid() => _uuidV7Schema.Validate(ValidUuidV7); +} diff --git a/src/src/Examples.CLI/AdvancedExamples.cs b/src/src/Examples.CLI/AdvancedExamples.cs index f895394..4d11d16 100644 --- a/src/src/Examples.CLI/AdvancedExamples.cs +++ b/src/src/Examples.CLI/AdvancedExamples.cs @@ -36,6 +36,10 @@ static void StringAdvancedExamples() var uuidResult = uuidSchema.Validate("550e8400-e29b-41d4-a716-446655440000"); Console.WriteLine($"UUID validation: {uuidResult.IsSuccess}"); + var uuidV7Schema = Z.String().UUID(UuidVersion.V7); + var uuidV7Result = uuidV7Schema.Validate("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c"); + Console.WriteLine($"UUID v7 validation: {uuidV7Result.IsSuccess}"); + var prefixSchema = Z.String().StartsWith("https://"); var prefixResult = prefixSchema.Validate("https://example.com"); Console.WriteLine($"StartsWith validation: {prefixResult.IsSuccess}"); diff --git a/src/src/ZodSharp/Rules/UuidRule.cs b/src/src/ZodSharp/Rules/UuidRule.cs index 734a1e0..892cdca 100644 --- a/src/src/ZodSharp/Rules/UuidRule.cs +++ b/src/src/ZodSharp/Rules/UuidRule.cs @@ -1,5 +1,3 @@ -using System.Text.RegularExpressions; - namespace ZodSharp.Rules; /// @@ -8,20 +6,31 @@ namespace ZodSharp.Rules; /// public readonly record struct UUIDRule : Core.IValidationRule { - static readonly Regex UUIDRegex = new( - @"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", - RegexOptions.Compiled | RegexOptions.IgnoreCase, - TimeSpan.FromMilliseconds(100) - ); - readonly string? _message; + readonly UuidVersion? _version; /// - /// Initializes a new instance of the UuidRule struct. + /// Initializes a new instance of the UuidRule struct with Zod-parity semantics + /// (version 1-8, variant 8-9/a-b, plus the nil and max UUIDs). /// /// Optional error message public UUIDRule(string? message = null) { + _version = null; + _message = message.OrNull(); + } + + /// + /// Initializes a new instance of the UuidRule struct that requires a specific RFC 9562 version. + /// + /// The required UUID version + /// Optional error message + public UUIDRule(UuidVersion version, string? message = null) + { + if (version == UuidVersion.None) + throw new ArgumentOutOfRangeException(nameof(version), version, "UUID version must be between V1 and V8."); + + _version = version; _message = message.OrNull(); } @@ -30,12 +39,64 @@ public UUIDRule(string? message = null) /// /// The value to validate /// True if valid, false otherwise - public bool IsValid(in string value) => !string.IsNullOrWhiteSpace(value) && UUIDRegex.IsMatch(value); + public bool IsValid(in string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length != 36) + return false; + + if (!HasValidStructure(value)) + return false; + + if (_version is UuidVersion version) + return value[14] == (char)('0' + (int)version) && IsValidVariant(value[19]); + + return IsValidVersionless(value); + } /// /// Gets the error message for a failed validation. /// /// The value that failed validation /// The error message - public string GetErrorMessage(in string value) => _message ?? $"Invalid UUID format: {value}"; + public string GetErrorMessage(in string value) => + _message + ?? ( + _version is UuidVersion version + ? $"Invalid UUID v{(int)version} format: {value}" + : $"Invalid UUID format: {value}" + ); + + static bool IsValidVersionless(string value) + { + // nil and max are allowed regardless of version/variant (Zod parity). + if (value == "00000000-0000-0000-0000-000000000000") + return true; + if (value == "ffffffff-ffff-ffff-ffff-ffffffffffff") + return true; + + var version = value[14]; + if (version is < '1' or > '8') + return false; + + return IsValidVariant(value[19]); + } + + static bool HasValidStructure(string value) + { + if (value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-') + return false; + + for (var i = 0; i < 36; i++) + { + if (i is 8 or 13 or 18 or 23) + continue; + + if (!char.IsAsciiHexDigit(value[i])) + return false; + } + + return true; + } + + static bool IsValidVariant(char c) => c is '8' or '9' or 'a' or 'b' or 'A' or 'B'; } diff --git a/src/src/ZodSharp/Schemas/ZodString.cs b/src/src/ZodSharp/Schemas/ZodString.cs index 9b07e68..e65f628 100644 --- a/src/src/ZodSharp/Schemas/ZodString.cs +++ b/src/src/ZodSharp/Schemas/ZodString.cs @@ -158,7 +158,8 @@ public ZodString Base64String(string? message = null) } /// - /// Adds a UUID format validation. + /// Adds a UUID format validation. Accepts RFC 9562 versions 1-8 with the RFC + /// variant nibble, plus the nil and max UUIDs. /// /// Optional error message /// This schema for method chaining @@ -168,6 +169,18 @@ public ZodString UUID(string? message = null) return this; } + /// + /// Adds a UUID format validation requiring a specific RFC 9562 version. + /// + /// The required UUID version + /// Optional error message + /// This schema for method chaining + public ZodString UUID(UuidVersion version, string? message = null) + { + AddRule(new UUIDRule(version, message)); + return this; + } + /// /// Adds a validation that the string must start with the specified prefix. /// diff --git a/src/src/ZodSharp/UuidVersion.cs b/src/src/ZodSharp/UuidVersion.cs new file mode 100644 index 0000000..84fc62b --- /dev/null +++ b/src/src/ZodSharp/UuidVersion.cs @@ -0,0 +1,34 @@ +namespace ZodSharp; + +/// +/// RFC 9562 UUID versions supported by . +/// +public enum UuidVersion +{ + /// No version (only valid for the versionless UUID() overload). + None = 0, + + /// Version 1 — time-based UUID. + V1 = 1, + + /// Version 2 — DCE security UUID. + V2 = 2, + + /// Version 3 — name-based (MD5) UUID. + V3 = 3, + + /// Version 4 — random UUID. + V4 = 4, + + /// Version 5 — name-based (SHA-1) UUID. + V5 = 5, + + /// Version 6 — reordered time-based UUID. + V6 = 6, + + /// Version 7 — Unix time-based UUID. + V7 = 7, + + /// Version 8 — custom UUID. + V8 = 8, +} diff --git a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs index db56bce..8909952 100644 --- a/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs +++ b/src/tests/ZodSharp.UnitTests/Schemas/ZodStringTests.cs @@ -74,6 +74,18 @@ public async Task StringUrl_GivenValue_ReturnsExpectedResult(string value, bool [Test] [Arguments("550e8400-e29b-41d4-a716-446655440000", true)] + [Arguments("550e8400-e29b-11d4-a716-446655440000", true)] + [Arguments("0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c", true)] + [Arguments("550E8400-E29B-41D4-A716-446655440000", true)] + [Arguments("00000000-0000-0000-0000-000000000000", true)] + [Arguments("ffffffff-ffff-ffff-ffff-ffffffffffff", true)] + [Arguments("550e8400-e29b-01d4-a716-446655440000", false)] + [Arguments("550e8400-e29b-91d4-a716-446655440000", false)] + [Arguments("550e8400-e29b-f1d4-a716-446655440000", false)] + [Arguments("550e8400-e29b-41d4-0716-446655440000", false)] + [Arguments("550e8400-e29b-41d4-5716-446655440000", false)] + [Arguments("550e8400-e29b-41d4-c716-446655440000", false)] + [Arguments("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", false)] [Arguments("not-a-uuid", false)] [Arguments("550e8400-e29b-41d4-a716", false)] public async Task StringUuid_GivenValue_ReturnsExpectedResult(string value, bool expected) @@ -83,6 +95,21 @@ public async Task StringUuid_GivenValue_ReturnsExpectedResult(string value, bool await Assert.That(result.IsSuccess).IsEqualTo(expected); } + [Test] + [Arguments(UuidVersion.V7, "0192b4c1-7a9b-7f5e-9a3c-2d4e6f8a0b1c", true)] + [Arguments(UuidVersion.V7, "550e8400-e29b-41d4-a716-446655440000", false)] + [Arguments(UuidVersion.V7, "0192b4c1-7a9b-7f5e-03a3-2d4e6f8a0b1c", false)] + [Arguments(UuidVersion.V4, "550e8400-e29b-41d4-a716-446655440000", true)] + [Arguments(UuidVersion.V4, "00000000-0000-0000-0000-000000000000", false)] + [Arguments(UuidVersion.V4, "ffffffff-ffff-ffff-ffff-ffffffffffff", false)] + [Arguments(UuidVersion.V8, "550e8400-e29b-81d4-a716-446655440000", true)] + public async Task StringUuid_GivenVersion_ReturnsExpectedResult(UuidVersion version, string value, bool expected) + { + var result = Z.String().UUID(version).Validate(value); + + await Assert.That(result.IsSuccess).IsEqualTo(expected); + } + [Test] public async Task StringStartsWith_GivenMatchingPrefix_ReturnsSuccess() {