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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
-->
<RoslynCompilerVersion>5.9.0</RoslynCompilerVersion>
<RoslynAnalyzersVersion>5.9.0</RoslynAnalyzersVersion>
<TUnitVersion>1.66.27</TUnitVersion>
<TUnitVersion>1.68.4</TUnitVersion>
<PurviewSourceGenFramework>1.0.0-prerelease.42</PurviewSourceGenFramework>
<DotnetRuntimeVersion>10.0.12</DotnetRuntimeVersion>
</PropertyGroup>
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/JsonSchema-Import.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
18 changes: 18 additions & 0 deletions docs/wiki/Performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, so there is no per-validation object allocation.
Expand Down
5 changes: 4 additions & 1 deletion docs/wiki/String-Validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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");

Expand Down
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
"version": "2.0.0-prerelease.6",
"version": "2.0.0-prerelease.7",
"private": true,
"license": "MIT",
"author": {
Expand Down Expand Up @@ -28,4 +28,4 @@
"bun": ">=1.4.2"
},
"packageManager": "bun@1.4.2"
}
}
67 changes: 67 additions & 0 deletions src/src/Benchmarks/UuidPerformanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using ZodSharp.Core;
using ZodSharp.Rules;
using ZodSharp.Schemas;

namespace ZodSharp;

/// <summary>
/// Performance tests for UUID validation: char-scan rule vs the legacy compiled regex,
/// plus the schema-level validation paths.
/// </summary>
[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<string> Schema_UUID_Valid() => _uuidSchema.Validate(ValidUuidV4);

[Benchmark]
public ValidationResult<string> Schema_UUIDV7_Valid() => _uuidV7Schema.Validate(ValidUuidV7);
}
4 changes: 4 additions & 0 deletions src/src/Examples.CLI/AdvancedExamples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down
83 changes: 72 additions & 11 deletions src/src/ZodSharp/Rules/UuidRule.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Text.RegularExpressions;

namespace ZodSharp.Rules;

/// <summary>
Expand All @@ -8,20 +6,31 @@ namespace ZodSharp.Rules;
/// </summary>
public readonly record struct UUIDRule : Core.IValidationRule<string>
{
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;

/// <summary>
/// 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).
/// </summary>
/// <param name="message">Optional error message</param>
public UUIDRule(string? message = null)
{
_version = null;
_message = message.OrNull();
}

/// <summary>
/// Initializes a new instance of the UuidRule struct that requires a specific RFC 9562 version.
/// </summary>
/// <param name="version">The required UUID version</param>
/// <param name="message">Optional error message</param>
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();
}

Expand All @@ -30,12 +39,64 @@ public UUIDRule(string? message = null)
/// </summary>
/// <param name="value">The value to validate</param>
/// <returns>True if valid, false otherwise</returns>
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);
}

/// <summary>
/// Gets the error message for a failed validation.
/// </summary>
/// <param name="value">The value that failed validation</param>
/// <returns>The error message</returns>
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';
}
15 changes: 14 additions & 1 deletion src/src/ZodSharp/Schemas/ZodString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ public ZodString Base64String(string? message = null)
}

/// <summary>
/// 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.
/// </summary>
/// <param name="message">Optional error message</param>
/// <returns>This schema for method chaining</returns>
Expand All @@ -168,6 +169,18 @@ public ZodString UUID(string? message = null)
return this;
}

/// <summary>
/// Adds a UUID format validation requiring a specific RFC 9562 version.
/// </summary>
/// <param name="version">The required UUID version</param>
/// <param name="message">Optional error message</param>
/// <returns>This schema for method chaining</returns>
public ZodString UUID(UuidVersion version, string? message = null)
{
AddRule(new UUIDRule(version, message));
return this;
}

/// <summary>
/// Adds a validation that the string must start with the specified prefix.
/// </summary>
Expand Down
34 changes: 34 additions & 0 deletions src/src/ZodSharp/UuidVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace ZodSharp;

/// <summary>
/// RFC 9562 UUID versions supported by <see cref="Schemas.ZodString.UUID(UuidVersion, string?)"/>.
/// </summary>
public enum UuidVersion
{
/// <summary>No version (only valid for the versionless <c>UUID()</c> overload).</summary>
None = 0,

/// <summary>Version 1 — time-based UUID.</summary>
V1 = 1,

/// <summary>Version 2 — DCE security UUID.</summary>
V2 = 2,

/// <summary>Version 3 — name-based (MD5) UUID.</summary>
V3 = 3,

/// <summary>Version 4 — random UUID.</summary>
V4 = 4,

/// <summary>Version 5 — name-based (SHA-1) UUID.</summary>
V5 = 5,

/// <summary>Version 6 — reordered time-based UUID.</summary>
V6 = 6,

/// <summary>Version 7 — Unix time-based UUID.</summary>
V7 = 7,

/// <summary>Version 8 — custom UUID.</summary>
V8 = 8,
}
Loading