mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
Publish - 2026-05-21 20:13:30 UTC
This commit is contained in:
parent
adae547d62
commit
1dbf96b919
316 changed files with 35735 additions and 0 deletions
9
storage/Directory.Build.props
Normal file
9
storage/Directory.Build.props
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..\'))" />
|
||||
<PropertyGroup>
|
||||
<AssemblyName>Duende.$(MSBuildProjectName)</AssemblyName>
|
||||
<RootNamespace>Duende.$(MSBuildProjectName)</RootNamespace>
|
||||
<PackageId>Duende.$(MSBuildProjectName)</PackageId>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
9
storage/Directory.Build.targets
Normal file
9
storage/Directory.Build.targets
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..\'))" />
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="**\*.g.cs">
|
||||
<DependentUpon>$([System.String]::Copy('%(Filename)').Replace('.g', '.cs'))</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
10
storage/src/Directory.Build.props
Normal file
10
storage/src/Directory.Build.props
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)..\'))" />
|
||||
<Import Project="../../src.props" />
|
||||
<PropertyGroup>
|
||||
<MinVerMinimumMajorMinor>0.1</MinVerMinimumMajorMinor>
|
||||
<MinVerTagPrefix>storage-</MinVerTagPrefix>
|
||||
<Product>Duende Storage</Product>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
6
storage/src/Directory.Build.targets
Normal file
6
storage/src/Directory.Build.targets
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.targets', '$(MSBuildThisFileDirectory)..\'))" />
|
||||
<Import Project="../../src.targets" />
|
||||
<Import Project="../../shared/ValueObjectsGenerator/ValueObjectsGenerator.targets"
|
||||
Condition="'$(NCrunch)' != '1'"/>
|
||||
</Project>
|
||||
7
storage/src/Storage.CliPlugin/AssemblyInfo.cs
Normal file
7
storage/src/Storage.CliPlugin/AssemblyInfo.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Cli.PluginAbstractions;
|
||||
using Duende.Storage.CliPlugin;
|
||||
|
||||
[assembly: CliPlugin(typeof(StorageCliPlugin))]
|
||||
23
storage/src/Storage.CliPlugin/Commands/MigrateCommand.cs
Normal file
23
storage/src/Storage.CliPlugin/Commands/MigrateCommand.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.CommandLine;
|
||||
|
||||
namespace Duende.Storage.CliPlugin.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the <c>duende storage migrate</c> command.
|
||||
/// </summary>
|
||||
internal static class MigrateCommand
|
||||
{
|
||||
internal static Command Create()
|
||||
{
|
||||
var command = new Command("migrate", "Apply pending Duende Storage schema migrations.");
|
||||
command.SetAction(async (_, ct) =>
|
||||
{
|
||||
Console.WriteLine("Storage migrate: not yet implemented.");
|
||||
await Task.CompletedTask;
|
||||
});
|
||||
return command;
|
||||
}
|
||||
}
|
||||
47
storage/src/Storage.CliPlugin/Storage.CliPlugin.csproj
Normal file
47
storage/src/Storage.CliPlugin/Storage.CliPlugin.csproj
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Override AssemblyName and namespace — this project is Duende.Storage.CliPlugin -->
|
||||
<AssemblyName>Duende.Storage.CliPlugin</AssemblyName>
|
||||
<RootNamespace>Duende.Storage.CliPlugin</RootNamespace>
|
||||
<PackageId>Duende.Storage.CliPlugin</PackageId>
|
||||
|
||||
<!--
|
||||
Fat-package: copy all transitive dependencies to the output directory so they
|
||||
can be included in the NuGet package. The CLI host loads plugins via an isolated
|
||||
AssemblyLoadContext and does not run NuGet restore, so the plugin package must
|
||||
be self-contained (minus host-shared assemblies).
|
||||
-->
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Duende.Cli.PluginAbstractions" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Storage.MsSql\Storage.MsSql.csproj" />
|
||||
<ProjectReference Include="..\Storage.PostgreSql\Storage.PostgreSql.csproj" />
|
||||
<ProjectReference Include="..\Storage.Sqlite\Storage.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Fat-package target: after build, collect all non-shared dependency DLLs from the
|
||||
output directory and mark them for inclusion in the NuGet package alongside the
|
||||
plugin assembly. Host-shared assemblies (PluginAbstractions, System.CommandLine,
|
||||
Microsoft.Extensions.Logging.Abstractions) are excluded because the CLI host
|
||||
already loads them in the default AssemblyLoadContext.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<TargetsForTfmSpecificBuildOutput>$(TargetsForTfmSpecificBuildOutput);CollectPluginDependencies</TargetsForTfmSpecificBuildOutput>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="CollectPluginDependencies">
|
||||
<ItemGroup>
|
||||
<BuildOutputInPackage Include="$(OutputPath)*.dll"
|
||||
Exclude="$(OutputPath)$(AssemblyName).dll;$(OutputPath)Duende.Cli.PluginAbstractions.dll;$(OutputPath)System.CommandLine.dll;$(OutputPath)Microsoft.Extensions.Logging.Abstractions.dll" />
|
||||
<!-- AssemblyDependencyResolver needs the deps.json to resolve transitive assemblies -->
|
||||
<BuildOutputInPackage Include="$(OutputPath)$(AssemblyName).deps.json" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
26
storage/src/Storage.CliPlugin/StorageCliPlugin.cs
Normal file
26
storage/src/Storage.CliPlugin/StorageCliPlugin.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.CommandLine;
|
||||
using Duende.Cli.PluginAbstractions;
|
||||
using Duende.Storage.CliPlugin.Commands;
|
||||
|
||||
namespace Duende.Storage.CliPlugin;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the Duende Storage CLI plugin.
|
||||
/// Provides the <c>storage</c> subcommand tree to the <c>duende</c> CLI host.
|
||||
/// </summary>
|
||||
public sealed class StorageCliPlugin : ICliPlugin
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string Name => "storage";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Command GetCommand()
|
||||
{
|
||||
var storageCommand = new Command("storage", "Commands for managing Duende Storage.");
|
||||
storageCommand.Subcommands.Add(MigrateCommand.Create());
|
||||
return storageCommand;
|
||||
}
|
||||
}
|
||||
12
storage/src/Storage.MsSql/CreateSqlConnection.cs
Normal file
12
storage/src/Storage.MsSql/CreateSqlConnection.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Duende.Storage.MsSql;
|
||||
|
||||
/// <summary>
|
||||
/// A delegate that creates an unopened <see cref="SqlConnection"/>.
|
||||
/// Register as a keyed service matching the store's service key.
|
||||
/// </summary>
|
||||
public delegate SqlConnection CreateSqlConnection();
|
||||
90
storage/src/Storage.MsSql/Internal/Log.cs
Normal file
90
storage/src/Storage.MsSql/Internal/Log.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.Storage.MsSql.Internal;
|
||||
|
||||
internal static partial class Log
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Checking schema version")]
|
||||
internal static partial void CheckingSchemaVersion(ILogger logger);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Creating schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void CreatingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Migrating schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void MigratingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Verifying schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void VerifyingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Executing migration step V{{{Parameters.FromVersion}}}→V{{{Parameters.ToVersion}}}")]
|
||||
internal static partial void ExecutingMigrationStep(ILogger logger, int fromVersion, int toVersion);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = $"Error While creating schema")]
|
||||
internal static partial void ErrorWhileCreatingSchema(ILogger logger, Exception e);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = $"Executing sql {{{Parameters.Sql}}}")]
|
||||
internal static partial void ExecutingSql(ILogger logger, string sql);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Creating DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}, {nameof(Parameters.DsoSchemaVersion)}={{{Parameters.DsoSchemaVersion}}}")]
|
||||
internal static partial void CreatingDso(ILogger logger, EntityType entityType, Guid id, uint dsoSchemaVersion);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Deleting DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}")]
|
||||
internal static partial void DeletingDso(ILogger logger, EntityType entityType, Guid id);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Reading DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}")]
|
||||
internal static partial void ReadingDso(ILogger logger, EntityType entityType, Guid id);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Reading DSOs: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Count)}={{{Parameters.Count}}}")]
|
||||
internal static partial void ReadingDsos(ILogger logger, EntityType entityType, int count);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Updating DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}, {nameof(Parameters.DsoSchemaVersion)}={{{Parameters.DsoSchemaVersion}}}, {nameof(Parameters.ExpectedEntityVersion)}={{{Parameters.ExpectedEntityVersion}}}")]
|
||||
internal static partial void UpdatingDso(ILogger logger, EntityType entityType, Guid id, uint dsoSchemaVersion, int expectedEntityVersion);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Querying DSOs: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Skip)}={{{Parameters.Skip}}}, {nameof(Parameters.Take)}={{{Parameters.Take}}}")]
|
||||
internal static partial void QueryingDsos(ILogger logger, EntityType entityType, int skip, int take);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Querying DSO fields: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.FieldCount)}={{{Parameters.FieldCount}}}, {nameof(Parameters.Skip)}={{{Parameters.Skip}}}, {nameof(Parameters.Take)}={{{Parameters.Take}}}")]
|
||||
internal static partial void QueryingFieldsDsos(ILogger logger, EntityType entityType, int fieldCount, int skip, int take);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Executing query: {nameof(Parameters.Query)}={{{Parameters.Query}}}")]
|
||||
internal static partial void ExecutingQuery(ILogger logger, string query);
|
||||
|
||||
private static class Parameters
|
||||
{
|
||||
internal const string FromVersion = nameof(FromVersion);
|
||||
internal const string ToVersion = nameof(ToVersion);
|
||||
internal const string Count = nameof(Count);
|
||||
internal const string DsoSchemaVersion = nameof(DsoSchemaVersion);
|
||||
internal const string EntityType = nameof(EntityType);
|
||||
internal const string ExpectedEntityVersion = nameof(ExpectedEntityVersion);
|
||||
internal const string FieldCount = nameof(FieldCount);
|
||||
internal const string Id = nameof(Id);
|
||||
internal const string Skip = nameof(Skip);
|
||||
internal const string Take = nameof(Take);
|
||||
internal const string Query = nameof(Query);
|
||||
internal const string SchemaName = nameof(SchemaName);
|
||||
internal const string Sql = nameof(Sql);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
46
storage/src/Storage.MsSql/Internal/MigrationScriptLoader.cs
Normal file
46
storage/src/Storage.MsSql/Internal/MigrationScriptLoader.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Duende.Storage.MsSql.Internal;
|
||||
|
||||
internal static class MigrationScriptLoader
|
||||
{
|
||||
private static readonly Regex VersionPattern = new(@"\.Migrations\.V(\d+)_", RegexOptions.Compiled);
|
||||
|
||||
public static IEnumerable<(int TargetVersion, string Sql)> GetScripts(
|
||||
Assembly assembly,
|
||||
DatabaseSchemaVersion fromVersion,
|
||||
string schemaName)
|
||||
{
|
||||
var assemblyName = assembly.GetName().Name;
|
||||
var prefix = $"{assemblyName}.Migrations.V";
|
||||
|
||||
return assembly.GetManifestResourceNames()
|
||||
.Where(name => name.StartsWith(prefix, StringComparison.Ordinal) && name.EndsWith(".sql", StringComparison.Ordinal))
|
||||
.Select(name => (Name: name, Version: ParseVersion(name)))
|
||||
.Where(x => x.Version > fromVersion.Value)
|
||||
.OrderBy(x => x.Version)
|
||||
.Select(x => (x.Version, ApplySchema(ReadResource(assembly, x.Name), schemaName)));
|
||||
}
|
||||
|
||||
private static int ParseVersion(string resourceName)
|
||||
{
|
||||
var match = VersionPattern.Match(resourceName);
|
||||
return match.Success ? int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : 0;
|
||||
}
|
||||
|
||||
private static string ReadResource(Assembly assembly, string resourceName)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static string ApplySchema(string sql, string schemaName) =>
|
||||
sql.Replace("[[schemaname]]", schemaName, StringComparison.Ordinal);
|
||||
}
|
||||
57
storage/src/Storage.MsSql/Internal/MsSqlDialect.cs
Normal file
57
storage/src/Storage.MsSql/Internal/MsSqlDialect.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using Duende.Storage.Internal.Querying;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Duende.Storage.MsSql.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server-specific SQL dialect implementation.
|
||||
/// </summary>
|
||||
internal sealed class MsSqlDialect : ISqlDialect
|
||||
{
|
||||
public string CaseInsensitiveLikeOperator => "LIKE";
|
||||
|
||||
public string TrueLiteral => "1=1";
|
||||
|
||||
public string FalseLiteral => "1=0";
|
||||
|
||||
public string EscapeLikeWildcards(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// SQL Server uses brackets to escape special characters
|
||||
return value
|
||||
.Replace("[", "[[]", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%", "[%]", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("_", "[_]", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public void AddParameter(DbCommand command, string name, object value)
|
||||
{
|
||||
var sqlCommand = (SqlCommand)command;
|
||||
|
||||
// Handle DateTimeOffset with explicit type
|
||||
if (value is DateTimeOffset dto)
|
||||
{
|
||||
var param = sqlCommand.Parameters.AddWithValue(name, dto);
|
||||
param.SqlDbType = SqlDbType.DateTimeOffset;
|
||||
}
|
||||
else if (value is DateTime dt)
|
||||
{
|
||||
// Convert to DateTimeOffset assuming UTC
|
||||
var param = sqlCommand.Parameters.AddWithValue(name, new DateTimeOffset(dt, TimeSpan.Zero));
|
||||
param.SqlDbType = SqlDbType.DateTimeOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = sqlCommand.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
2812
storage/src/Storage.MsSql/Internal/MsSqlStore.cs
Normal file
2812
storage/src/Storage.MsSql/Internal/MsSqlStore.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.Storage.MsSql.Internal;
|
||||
|
||||
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
|
||||
public sealed class MsSqlStoreOptionsValidator(string name) : DataAnnotationValidateOptions<MsSqlStoreOptions>(name);
|
||||
65
storage/src/Storage.MsSql/Internal/SqlServerGuidConverter.cs
Normal file
65
storage/src/Storage.MsSql/Internal/SqlServerGuidConverter.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.MsSql.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Converts between standard (RFC 9562) UUIDv7 byte order and a SQL Server–optimized
|
||||
/// byte layout that preserves chronological sort order in <c>UNIQUEIDENTIFIER</c> columns.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SQL Server sorts <c>UNIQUEIDENTIFIER</c> values using the byte-comparison semantics
|
||||
/// implemented in <see cref="System.Data.SqlTypes.SqlGuid"/>: the last six bytes (10-15)
|
||||
/// are compared first (left-to-right), then bytes 8-9, 7, 6, 3, 2, 1, 0, 5, 4.
|
||||
/// UUIDv7 places the 48-bit timestamp in bytes 0-5, which SQL Server compares last.
|
||||
/// This means UUIDv7 values do not sort chronologically in SQL Server indexes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This converter swaps the byte layout so that:
|
||||
/// <list type="bullet">
|
||||
/// <item>The 48-bit timestamp (originally bytes 0-5) is placed into bytes 10-15 (sorted first).</item>
|
||||
/// <item>The version + rand_a (originally bytes 6-7) goes into bytes 8-9 (sorted second).</item>
|
||||
/// <item>The variant + rand_b high (originally bytes 8-9) goes into bytes 6-7 (sorted third).</item>
|
||||
/// <item>The rand_b low (originally bytes 10-15) goes into bytes 0-5 (sorted last).</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Since SQL Server evaluates bytes 10-15 first, placing the timestamp there ensures
|
||||
/// that chronological order is preserved in clustered indexes and sort operations.
|
||||
/// The conversion is its own inverse — applying it twice returns the original value.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SqlServerGuidConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a standard UUIDv7 to the SQL Server–optimized byte layout.
|
||||
/// </summary>
|
||||
internal static Guid ToSqlServer(Guid uuidV7)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
_ = uuidV7.TryWriteBytes(bytes, bigEndian: true, out _);
|
||||
|
||||
Span<byte> result = stackalloc byte[16];
|
||||
|
||||
// Timestamp (bytes 0-5) → SQL Server high-priority position (bytes 10-15)
|
||||
bytes[..6].CopyTo(result[10..]);
|
||||
|
||||
// Version + rand_a (bytes 6-7) → bytes 8-9
|
||||
bytes[6..8].CopyTo(result[8..]);
|
||||
|
||||
// Variant + rand_b high (bytes 8-9) → bytes 6-7
|
||||
bytes[8..10].CopyTo(result[6..]);
|
||||
|
||||
// Rand_b low (bytes 10-15) → bytes 0-5
|
||||
bytes[10..].CopyTo(result[..6]);
|
||||
|
||||
return new Guid(result, bigEndian: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a SQL Server–optimized GUID back to the standard UUIDv7 byte layout.
|
||||
/// The swap is its own inverse — the same byte permutation reverses itself.
|
||||
/// </summary>
|
||||
internal static Guid ToUuidV7(Guid sqlServerGuid) => ToSqlServer(sqlServerGuid);
|
||||
}
|
||||
226
storage/src/Storage.MsSql/Migrations/V001_InitialCreate.sql
Normal file
226
storage/src/Storage.MsSql/Migrations/V001_InitialCreate.sql
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
-- Copyright (c) Duende Software. All rights reserved.
|
||||
-- See LICENSE in the project root for license information.
|
||||
|
||||
-- V001: Initial schema creation
|
||||
-- Uses [[schemaname]] as a placeholder replaced at runtime.
|
||||
|
||||
DECLARE @compatLevel INT;
|
||||
SELECT @compatLevel = CAST(compatibility_level AS INT) FROM sys.databases WHERE database_id = DB_ID();
|
||||
IF @compatLevel < 140
|
||||
BEGIN
|
||||
DECLARE @msg NVARCHAR(500) = CONCAT(
|
||||
'SQL Server database compatibility level ', @compatLevel,
|
||||
' is not supported. A minimum compatibility level of 140 (SQL Server 2017) is required.');
|
||||
THROW 50001, @msg, 1;
|
||||
END
|
||||
|
||||
-- Create schema if it doesn't exist (prerequisite for version check)
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'[[schemaname]]')
|
||||
BEGIN
|
||||
EXEC('CREATE SCHEMA [[[schemaname]]]');
|
||||
END
|
||||
|
||||
DECLARE @current_version INT = ISNULL((
|
||||
SELECT CAST(JSON_VALUE(CAST(value AS NVARCHAR(MAX)), '$.Version') AS INT)
|
||||
FROM sys.extended_properties ep
|
||||
WHERE ep.class = 3
|
||||
AND ep.name = N'SchemaVersion'
|
||||
AND ep.major_id = SCHEMA_ID('[[schemaname]]')
|
||||
), 0);
|
||||
|
||||
IF @current_version < 1
|
||||
BEGIN
|
||||
-- entities table
|
||||
CREATE TABLE [[[schemaname]]].[entities]
|
||||
(
|
||||
pool_id INT NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
entity_id UNIQUEIDENTIFIER NOT NULL,
|
||||
original_entity_id UNIQUEIDENTIFIER NOT NULL,
|
||||
entity_type_name NVARCHAR(255) NOT NULL,
|
||||
value NVARCHAR(MAX) NOT NULL,
|
||||
dso_type_schema_version INT NOT NULL,
|
||||
value_version INT NOT NULL,
|
||||
created_at DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
|
||||
last_updated_at DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
|
||||
expires_at DATETIMEOFFSET NULL,
|
||||
CONSTRAINT PK_[[schemaname]]_entities PRIMARY KEY (pool_id, entity_type_id, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entities_expires_at
|
||||
ON [[[schemaname]]].[entities] (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entities_entity_type_name
|
||||
ON [[[schemaname]]].[entities] (entity_type_name);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entities_created_at
|
||||
ON [[[schemaname]]].[entities] (pool_id, entity_type_id, created_at);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entities_last_updated_at
|
||||
ON [[[schemaname]]].[entities] (pool_id, entity_type_id, last_updated_at);
|
||||
|
||||
-- entity_keys table
|
||||
CREATE TABLE [[[schemaname]]].[entity_keys]
|
||||
(
|
||||
pool_id INT NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
key_type_id INT NOT NULL,
|
||||
entity_id UNIQUEIDENTIFIER NOT NULL,
|
||||
key_type_name NVARCHAR(255) NOT NULL,
|
||||
key_type_version INT NOT NULL,
|
||||
key_value UNIQUEIDENTIFIER NOT NULL,
|
||||
key_json NVARCHAR(MAX) NULL,
|
||||
timestamp DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
|
||||
CONSTRAINT PK_[[schemaname]]_entity_keys PRIMARY KEY (pool_id, entity_type_id, key_type_id, key_type_version, key_value),
|
||||
CONSTRAINT FK_[[schemaname]]_entity_keys_entities FOREIGN KEY (pool_id, entity_type_id, entity_id)
|
||||
REFERENCES [[[schemaname]]].[entities] (pool_id, entity_type_id, entity_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entity_keys_entity_type_id_entity_id
|
||||
ON [[[schemaname]]].[entity_keys] (entity_type_id, entity_id);
|
||||
|
||||
-- search_values table
|
||||
CREATE TABLE [[[schemaname]]].[search_values]
|
||||
(
|
||||
pool_id INT NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
entity_id UNIQUEIDENTIFIER NOT NULL,
|
||||
field_path UNIQUEIDENTIFIER NOT NULL,
|
||||
field_path_text NVARCHAR(500) NOT NULL,
|
||||
item_index INT NOT NULL,
|
||||
string_value NVARCHAR(500) NULL,
|
||||
number_value DECIMAL(38,18) NULL,
|
||||
datetime_value DATETIMEOFFSET NULL,
|
||||
boolean_value BIT NULL,
|
||||
guid_value UNIQUEIDENTIFIER NULL,
|
||||
CONSTRAINT PK_[[schemaname]]_search_values PRIMARY KEY (pool_id, entity_type_id, entity_id, field_path, item_index),
|
||||
CONSTRAINT FK_[[schemaname]]_search_values_entities FOREIGN KEY (pool_id, entity_type_id, entity_id)
|
||||
REFERENCES [[[schemaname]]].[entities] (pool_id, entity_type_id, entity_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_string_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, field_path, string_value)
|
||||
WHERE string_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_number_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, field_path, number_value)
|
||||
WHERE number_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_datetime_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, field_path, datetime_value)
|
||||
WHERE datetime_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_boolean_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, field_path, boolean_value)
|
||||
WHERE boolean_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_array_string_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, entity_id, field_path, item_index, string_value)
|
||||
WHERE string_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_array_number_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, entity_id, field_path, item_index, number_value)
|
||||
WHERE number_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_array_datetime_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, entity_id, field_path, item_index, datetime_value)
|
||||
WHERE datetime_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_search_values_array_boolean_value
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, entity_id, field_path, item_index, boolean_value)
|
||||
WHERE boolean_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_[[schemaname]]_search_values_guid_value]
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, field_path, guid_value)
|
||||
WHERE item_index = -1 AND guid_value IS NOT NULL;
|
||||
|
||||
CREATE NONCLUSTERED INDEX [IX_[[schemaname]]_search_values_array_guid_value]
|
||||
ON [[[schemaname]]].[search_values] (pool_id, entity_type_id, entity_id, field_path, item_index, guid_value)
|
||||
WHERE item_index >= 0 AND guid_value IS NOT NULL;
|
||||
|
||||
-- entity_links table
|
||||
CREATE TABLE [[[schemaname]]].[entity_links]
|
||||
(
|
||||
pool_id INT NOT NULL,
|
||||
link_type_id INT NOT NULL,
|
||||
left_entity_type_id INT NOT NULL,
|
||||
left_entity_id UNIQUEIDENTIFIER NOT NULL,
|
||||
right_entity_type_id INT NOT NULL,
|
||||
right_entity_id UNIQUEIDENTIFIER NOT NULL,
|
||||
created_at DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
|
||||
CONSTRAINT PK_[[schemaname]]_entity_links PRIMARY KEY (pool_id, link_type_id, left_entity_id, right_entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entity_links_left_entity
|
||||
ON [[[schemaname]]].[entity_links] (pool_id, link_type_id, left_entity_id);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entity_links_right_entity
|
||||
ON [[[schemaname]]].[entity_links] (pool_id, link_type_id, right_entity_id);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entity_links_left_cascade
|
||||
ON [[[schemaname]]].[entity_links] (pool_id, left_entity_id);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_entity_links_right_cascade
|
||||
ON [[[schemaname]]].[entity_links] (pool_id, right_entity_id);
|
||||
|
||||
-- outbox_subscriber_queue table
|
||||
CREATE TABLE [[[schemaname]]].[outbox_subscriber_queue]
|
||||
(
|
||||
sequence_number BIGINT IDENTITY(1,1) NOT NULL,
|
||||
message_id UNIQUEIDENTIFIER NOT NULL,
|
||||
event_id UNIQUEIDENTIFIER NOT NULL,
|
||||
timestamp DATETIMEOFFSET NOT NULL,
|
||||
event_name NVARCHAR(500) NOT NULL,
|
||||
subject_id UNIQUEIDENTIFIER NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
entity_type_name NVARCHAR(255) NOT NULL,
|
||||
pool_id INT NOT NULL,
|
||||
payload NVARCHAR(MAX) NOT NULL,
|
||||
subscriber_name NVARCHAR(255) NOT NULL,
|
||||
CONSTRAINT PK_[[schemaname]]_outbox_subscriber_queue PRIMARY KEY CLUSTERED (sequence_number),
|
||||
CONSTRAINT UQ_[[schemaname]]_outbox_subscriber_queue_message_id UNIQUE (message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IX_[[schemaname]]_outbox_subscriber_queue_subscriber
|
||||
ON [[[schemaname]]].[outbox_subscriber_queue] (subscriber_name, sequence_number);
|
||||
|
||||
-- TVP types
|
||||
CREATE TYPE [[[schemaname]]].[KeyTableType] AS TABLE (
|
||||
key_type_id INT NOT NULL,
|
||||
key_type_name NVARCHAR(255) NOT NULL,
|
||||
key_type_version INT NOT NULL,
|
||||
key_value UNIQUEIDENTIFIER NOT NULL,
|
||||
key_json NVARCHAR(MAX) NULL
|
||||
);
|
||||
|
||||
CREATE TYPE [[[schemaname]]].[SearchValueTableType] AS TABLE (
|
||||
field_path UNIQUEIDENTIFIER NOT NULL,
|
||||
field_path_text NVARCHAR(500) NOT NULL,
|
||||
item_index INT NOT NULL,
|
||||
string_value NVARCHAR(500) NULL,
|
||||
number_value DECIMAL(38,18) NULL,
|
||||
datetime_value DATETIMEOFFSET NULL,
|
||||
boolean_value BIT NULL,
|
||||
guid_value UNIQUEIDENTIFIER NULL
|
||||
);
|
||||
|
||||
CREATE TYPE [[[schemaname]]].[EntityIdTableType] AS TABLE (
|
||||
entity_id UNIQUEIDENTIFIER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TYPE [[[schemaname]]].[ExpiredEntityKeyTableType] AS TABLE (
|
||||
pool_id INT NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
entity_id UNIQUEIDENTIFIER NOT NULL
|
||||
);
|
||||
|
||||
-- Version bump
|
||||
EXEC sys.sp_addextendedproperty
|
||||
@name = N'SchemaVersion',
|
||||
@value = N'{"Version":1}',
|
||||
@level0type = N'SCHEMA',
|
||||
@level0name = N'[[schemaname]]';
|
||||
END
|
||||
20
storage/src/Storage.MsSql/MsSqlStoreOptions.cs
Normal file
20
storage/src/Storage.MsSql/MsSqlStoreOptions.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Duende.Storage.MsSql;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the SQL Server store.
|
||||
/// </summary>
|
||||
public sealed class MsSqlStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The database schema name to use for tables.
|
||||
/// Default is "dbo".
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression(@"^[a-zA-Z0-9_\-\#\@]+$", ErrorMessage = "Schema name must contain only alphanumeric characters, underscores, hyphens, hashes, and at signs.")]
|
||||
public string SchemaName { get; set; } = "dbo";
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Duende.Storage.Internal;
|
||||
using Duende.Storage.Internal.Builder;
|
||||
using Duende.Storage.MsSql.Internal;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.Storage.MsSql;
|
||||
|
||||
public static class MsSqlStoreServiceCollectionExtensions
|
||||
{
|
||||
extension(IStorageBuilder builder)
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a SQL Server store with the specified service key for multi-store scenarios.
|
||||
/// The caller must register a keyed <see cref="CreateSqlConnection"/> with the same service key.
|
||||
/// </summary>
|
||||
internal IStorageBuilder AddMsSqlStore(object serviceKey, Action<MsSqlStoreOptions> configure)
|
||||
{
|
||||
var services = builder.Services;
|
||||
_ = services.AddStore<MsSqlStore>(serviceKey);
|
||||
_ = services.AddKeyedTransient<MsSqlStore>(serviceKey, (sp, _) =>
|
||||
{
|
||||
var createConnection = sp.GetRequiredKeyedService<CreateSqlConnection>(serviceKey);
|
||||
var outboxSubscribers = sp.GetRequiredKeyedService<OutboxSubscribers>(serviceKey);
|
||||
return BuildStore(sp, createConnection, outboxSubscribers, configure);
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a SQL Server store without a service key for single-store scenarios.
|
||||
/// The caller must register an unkeyed <see cref="CreateSqlConnection"/>.
|
||||
/// </summary>
|
||||
public IStorageBuilder AddMsSqlStore(Action<MsSqlStoreOptions> configure)
|
||||
{
|
||||
var services = builder.Services;
|
||||
_ = services.AddStore<MsSqlStore>();
|
||||
_ = services.AddTransient<MsSqlStore>(sp =>
|
||||
{
|
||||
var createConnection = sp.GetRequiredService<CreateSqlConnection>();
|
||||
var outboxSubscribers = sp.GetRequiredService<OutboxSubscribers>();
|
||||
return BuildStore(sp, createConnection, outboxSubscribers, configure);
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
private static MsSqlStore BuildStore(
|
||||
IServiceProvider sp,
|
||||
CreateSqlConnection createConnection,
|
||||
OutboxSubscribers outboxSubscribers,
|
||||
Action<MsSqlStoreOptions> configure)
|
||||
{
|
||||
var options = new MsSqlStoreOptions();
|
||||
configure(options);
|
||||
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
|
||||
return new MsSqlStore(
|
||||
createConnection,
|
||||
options,
|
||||
sp.GetRequiredService<DataStorageTypeRegistry>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
outboxSubscribers,
|
||||
sp.GetRequiredService<ILogger<MsSqlStore>>());
|
||||
}
|
||||
}
|
||||
22
storage/src/Storage.MsSql/Storage.MsSql.csproj
Normal file
22
storage/src/Storage.MsSql/Storage.MsSql.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Storage\Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Duende.Platform.Storage.MsSql" />
|
||||
<InternalsVisibleTo Include="Duende.Storage.MsSql.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
82
storage/src/Storage.PostgreSql/Internal/Log.cs
Normal file
82
storage/src/Storage.PostgreSql/Internal/Log.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.Storage.PostgreSql.Internal;
|
||||
|
||||
internal static partial class Log
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Checking schema version")]
|
||||
internal static partial void CheckingSchemaVersion(ILogger logger);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Creating schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void CreatingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Migrating schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void MigratingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Executing migration step V{{{Parameters.FromVersion}}} → V{{{Parameters.ToVersion}}}")]
|
||||
internal static partial void ExecutingMigrationStep(ILogger logger, int fromVersion, int toVersion);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = $"Executing sql {{{Parameters.Sql}}}")]
|
||||
internal static partial void ExecutingSql(ILogger logger, string sql);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Creating DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}, {nameof(Parameters.DsoSchemaVersion)}={{{Parameters.DsoSchemaVersion}}}")]
|
||||
internal static partial void CreatingDso(ILogger logger, EntityType entityType, Guid id, uint dsoSchemaVersion);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Deleting DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}")]
|
||||
internal static partial void DeletingDso(ILogger logger, EntityType entityType, Guid id);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Reading DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}")]
|
||||
internal static partial void ReadingDso(ILogger logger, EntityType entityType, Guid id);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Reading DSOs: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Count)}={{{Parameters.Count}}}")]
|
||||
internal static partial void ReadingDsos(ILogger logger, EntityType entityType, int count);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Updating DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}, {nameof(Parameters.DsoSchemaVersion)}={{{Parameters.DsoSchemaVersion}}}, {nameof(Parameters.ExpectedEntityVersion)}={{{Parameters.ExpectedEntityVersion}}}")]
|
||||
internal static partial void UpdatingDso(ILogger logger, EntityType entityType, Guid id, uint dsoSchemaVersion, int expectedEntityVersion);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Querying DSOs: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.PageNumber)}={{{Parameters.PageNumber}}}, {nameof(Parameters.PageSize)}={{{Parameters.PageSize}}}")]
|
||||
internal static partial void QueryingDsos(ILogger logger, EntityType entityType, int pageNumber, int pageSize);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Querying DSO fields: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.FieldCount)}={{{Parameters.FieldCount}}}, {nameof(Parameters.PageNumber)}={{{Parameters.PageNumber}}}, {nameof(Parameters.PageSize)}={{{Parameters.PageSize}}}")]
|
||||
internal static partial void QueryingFieldsDsos(ILogger logger, EntityType entityType, int fieldCount, int pageNumber, int pageSize);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Executing query: {nameof(Parameters.Query)}={{{Parameters.Query}}}")]
|
||||
internal static partial void ExecutingQuery(ILogger logger, string query);
|
||||
|
||||
private static class Parameters
|
||||
{
|
||||
internal const string FromVersion = nameof(FromVersion);
|
||||
internal const string ToVersion = nameof(ToVersion);
|
||||
internal const string Count = nameof(Count);
|
||||
internal const string DsoSchemaVersion = nameof(DsoSchemaVersion);
|
||||
internal const string EntityType = nameof(EntityType);
|
||||
internal const string ExpectedEntityVersion = nameof(ExpectedEntityVersion);
|
||||
internal const string FieldCount = nameof(FieldCount);
|
||||
internal const string Id = nameof(Id);
|
||||
internal const string PageNumber = nameof(PageNumber);
|
||||
internal const string PageSize = nameof(PageSize);
|
||||
internal const string Query = nameof(Query);
|
||||
internal const string SchemaName = nameof(SchemaName);
|
||||
internal const string Sql = nameof(Sql);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Duende.Storage.PostgreSql.Internal;
|
||||
|
||||
internal static class MigrationScriptLoader
|
||||
{
|
||||
private static readonly Regex VersionPattern = new(@"\.Migrations\.V(\d+)_", RegexOptions.Compiled);
|
||||
|
||||
public static IEnumerable<(int TargetVersion, string Sql)> GetScripts(
|
||||
Assembly assembly,
|
||||
DatabaseSchemaVersion fromVersion,
|
||||
string schemaName)
|
||||
{
|
||||
var assemblyName = assembly.GetName().Name;
|
||||
var prefix = $"{assemblyName}.Migrations.V";
|
||||
|
||||
return assembly.GetManifestResourceNames()
|
||||
.Where(name => name.StartsWith(prefix, StringComparison.Ordinal) && name.EndsWith(".sql", StringComparison.Ordinal))
|
||||
.Select(name => (Name: name, Version: ParseVersion(name)))
|
||||
.Where(x => x.Version > fromVersion.Value)
|
||||
.OrderBy(x => x.Version)
|
||||
.Select(x => (x.Version, ApplySchema(ReadResource(assembly, x.Name), schemaName)));
|
||||
}
|
||||
|
||||
private static int ParseVersion(string resourceName)
|
||||
{
|
||||
var match = VersionPattern.Match(resourceName);
|
||||
return match.Success ? int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : 0;
|
||||
}
|
||||
|
||||
private static string ReadResource(Assembly assembly, string resourceName)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private static string ApplySchema(string sql, string schemaName) =>
|
||||
sql.Replace("[[schemaname]]", schemaName, StringComparison.Ordinal);
|
||||
}
|
||||
55
storage/src/Storage.PostgreSql/Internal/PostgreSqlDialect.cs
Normal file
55
storage/src/Storage.PostgreSql/Internal/PostgreSqlDialect.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Data.Common;
|
||||
using Duende.Storage.Internal.Querying;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
|
||||
namespace Duende.Storage.PostgreSql.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-specific SQL dialect implementation.
|
||||
/// </summary>
|
||||
internal sealed class PostgreSqlDialect : ISqlDialect
|
||||
{
|
||||
public string CaseInsensitiveLikeOperator => "ILIKE";
|
||||
|
||||
public string TrueLiteral => "TRUE";
|
||||
|
||||
public string FalseLiteral => "FALSE";
|
||||
|
||||
public string EscapeLikeWildcards(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// PostgreSQL uses backslash as escape character
|
||||
// Replace backslash first to avoid double-escaping
|
||||
return value
|
||||
.Replace("\\", "\\\\", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%", "\\%", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("_", "\\_", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public void AddParameter(DbCommand command, string name, object value)
|
||||
{
|
||||
var npgsqlCommand = (NpgsqlCommand)command;
|
||||
|
||||
// Handle DateTimeOffset and DateTime with explicit type
|
||||
if (value is DateTime dt)
|
||||
{
|
||||
_ = npgsqlCommand.Parameters.AddWithValue(name, NpgsqlDbType.TimestampTz, dt);
|
||||
}
|
||||
else if (value is DateTimeOffset dto)
|
||||
{
|
||||
_ = npgsqlCommand.Parameters.AddWithValue(name, NpgsqlDbType.TimestampTz, dto.UtcDateTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = npgsqlCommand.Parameters.AddWithValue(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
2485
storage/src/Storage.PostgreSql/Internal/PostgreSqlStore.cs
Normal file
2485
storage/src/Storage.PostgreSql/Internal/PostgreSqlStore.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.Storage.PostgreSql.Internal;
|
||||
|
||||
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
|
||||
public sealed class PostgreSqlStoreOptionsValidator(string? name) : DataAnnotationValidateOptions<PostgreSqlStoreOptions>(name);
|
||||
175
storage/src/Storage.PostgreSql/Migrations/V001_InitialCreate.sql
Normal file
175
storage/src/Storage.PostgreSql/Migrations/V001_InitialCreate.sql
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
|
||||
-- This statement has to be outside of the DO block to ensure the schema exists before we
|
||||
-- attempt to read its comment for versioning
|
||||
CREATE SCHEMA IF NOT EXISTS [[schemaname]];
|
||||
|
||||
-- Migration V0 → V1: initial schema creation
|
||||
DO $$
|
||||
DECLARE
|
||||
current_version INT;
|
||||
BEGIN
|
||||
-- Read current version from schema comment
|
||||
SELECT COALESCE(
|
||||
(SELECT (obj_description('[[schemaname]]'::regnamespace)::jsonb->>'Version')::int
|
||||
WHERE obj_description('[[schemaname]]'::regnamespace) IS NOT NULL
|
||||
AND obj_description('[[schemaname]]'::regnamespace) NOT IN ('standard public schema', '')),
|
||||
0)
|
||||
INTO current_version;
|
||||
|
||||
current_version := COALESCE(current_version, 0);
|
||||
|
||||
IF current_version < 1 THEN
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS [[schemaname]];
|
||||
|
||||
CREATE TABLE [[schemaname]].entities
|
||||
(
|
||||
pool_id INTEGER NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
entity_type_name TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
dso_type_schema_version INT NOT NULL,
|
||||
value_version INT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
PRIMARY KEY (pool_id, entity_type_id, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX entities_expires_at_index
|
||||
ON [[schemaname]].entities (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX entities_created_at_index
|
||||
ON [[schemaname]].entities (pool_id, entity_type_id, created_at);
|
||||
|
||||
CREATE INDEX entities_last_updated_at_index
|
||||
ON [[schemaname]].entities (pool_id, entity_type_id, last_updated_at);
|
||||
|
||||
CREATE TABLE [[schemaname]].entity_keys
|
||||
(
|
||||
pool_id INTEGER NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
key_type_id INT NOT NULL,
|
||||
key_type_version INT NOT NULL,
|
||||
key_type_name TEXT NOT NULL,
|
||||
key_value UUID NOT NULL,
|
||||
key_json JSONB NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (pool_id, entity_type_id, key_type_id, key_type_version, key_value),
|
||||
FOREIGN KEY (pool_id, entity_type_id, entity_id)
|
||||
REFERENCES [[schemaname]].entities (pool_id, entity_type_id, entity_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX entity_keys_entity_type_id_entity_id_index
|
||||
ON [[schemaname]].entity_keys (entity_type_id, entity_id);
|
||||
|
||||
CREATE TABLE [[schemaname]].search_values
|
||||
(
|
||||
entity_type_id INT NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
field_path UUID NOT NULL,
|
||||
field_path_text TEXT NOT NULL,
|
||||
item_index INT NOT NULL,
|
||||
string_value TEXT NULL,
|
||||
number_value NUMERIC NULL,
|
||||
datetime_value TIMESTAMP WITH TIME ZONE NULL,
|
||||
boolean_value BOOLEAN NULL,
|
||||
guid_value UUID NULL,
|
||||
pool_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (pool_id, entity_type_id, entity_id, field_path, item_index),
|
||||
FOREIGN KEY (pool_id, entity_type_id, entity_id)
|
||||
REFERENCES [[schemaname]].entities (pool_id, entity_type_id, entity_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX search_values_string_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, field_path, string_value)
|
||||
WHERE string_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX search_values_number_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, field_path, number_value)
|
||||
WHERE number_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX search_values_datetime_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, field_path, datetime_value)
|
||||
WHERE datetime_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX search_values_boolean_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, field_path, boolean_value)
|
||||
WHERE boolean_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX search_values_array_string_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, entity_id, field_path, item_index, string_value)
|
||||
WHERE string_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX search_values_array_number_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, entity_id, field_path, item_index, number_value)
|
||||
WHERE number_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX search_values_array_datetime_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, entity_id, field_path, item_index, datetime_value)
|
||||
WHERE datetime_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX search_values_array_boolean_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, entity_id, field_path, item_index, boolean_value)
|
||||
WHERE boolean_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX search_values_guid_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, field_path, guid_value)
|
||||
WHERE item_index = -1 AND guid_value IS NOT NULL;
|
||||
|
||||
CREATE INDEX search_values_array_guid_value_index
|
||||
ON [[schemaname]].search_values (pool_id, entity_type_id, entity_id, field_path, item_index, guid_value)
|
||||
WHERE item_index >= 0 AND guid_value IS NOT NULL;
|
||||
|
||||
CREATE TABLE [[schemaname]].entity_links
|
||||
(
|
||||
pool_id INTEGER NOT NULL,
|
||||
link_type_id INT NOT NULL,
|
||||
left_entity_type_id INT NOT NULL,
|
||||
left_entity_id UUID NOT NULL,
|
||||
right_entity_type_id INT NOT NULL,
|
||||
right_entity_id UUID NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (pool_id, link_type_id, left_entity_id, right_entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX entity_links_left_entity_index
|
||||
ON [[schemaname]].entity_links (pool_id, link_type_id, left_entity_id);
|
||||
|
||||
CREATE INDEX entity_links_right_entity_index
|
||||
ON [[schemaname]].entity_links (pool_id, link_type_id, right_entity_id);
|
||||
|
||||
CREATE INDEX entity_links_left_cascade_index
|
||||
ON [[schemaname]].entity_links (pool_id, left_entity_id);
|
||||
|
||||
CREATE INDEX entity_links_right_cascade_index
|
||||
ON [[schemaname]].entity_links (pool_id, right_entity_id);
|
||||
|
||||
CREATE TABLE [[schemaname]].outbox_subscriber_queue
|
||||
(
|
||||
sequence_number BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||
message_id UUID NOT NULL,
|
||||
event_id UUID NOT NULL,
|
||||
timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
event_name TEXT NOT NULL,
|
||||
subject_id UUID NOT NULL,
|
||||
entity_type_id INT NOT NULL,
|
||||
entity_type_name TEXT NOT NULL,
|
||||
pool_id INTEGER NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
subscriber_name TEXT NOT NULL,
|
||||
PRIMARY KEY (sequence_number),
|
||||
UNIQUE (message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX outbox_subscriber_queue_subscriber_index
|
||||
ON [[schemaname]].outbox_subscriber_queue (subscriber_name, sequence_number);
|
||||
|
||||
COMMENT ON SCHEMA [[schemaname]] IS '{"Version":1}';
|
||||
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
16
storage/src/Storage.PostgreSql/PostgreSqlStoreOptions.cs
Normal file
16
storage/src/Storage.PostgreSql/PostgreSqlStoreOptions.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Duende.Storage.PostgreSql;
|
||||
|
||||
public sealed class PostgreSqlStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The schema name to use for the store's tables.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression(@"^[a-zA-Z0-9_\-\#\@]+$", ErrorMessage = "Schema name must contain only alphanumeric characters, underscores, hyphens, hashes, and at signs.")]
|
||||
public string SchemaName { get; set; } = "public";
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Duende.Storage.Internal;
|
||||
using Duende.Storage.Internal.Builder;
|
||||
using Duende.Storage.PostgreSql.Internal;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace Duende.Storage.PostgreSql;
|
||||
|
||||
public static class PostgreSqlStoreServiceCollectionExtensions
|
||||
{
|
||||
extension(IStorageBuilder builder)
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a PostgreSQL store with the specified service key for multi-store scenarios.
|
||||
/// The caller must register a keyed <see cref="NpgsqlDataSource"/> with the same service key.
|
||||
/// </summary>
|
||||
internal IStorageBuilder AddPostgreSqlStore(string serviceKey, Action<PostgreSqlStoreOptions> configure)
|
||||
{
|
||||
var services = builder.Services;
|
||||
_ = services.AddStore<PostgreSqlStore>(serviceKey);
|
||||
_ = services.AddKeyedTransient<PostgreSqlStore>(serviceKey, (sp, _) =>
|
||||
{
|
||||
var dataSource = sp.GetRequiredKeyedService<NpgsqlDataSource>(serviceKey);
|
||||
var outboxSubscribers = sp.GetRequiredKeyedService<OutboxSubscribers>(serviceKey);
|
||||
return BuildStore(sp, dataSource, outboxSubscribers, configure);
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a PostgreSQL store without a service key for single-store scenarios.
|
||||
/// The caller must register an unkeyed <see cref="NpgsqlDataSource"/>.
|
||||
/// </summary>
|
||||
public IStorageBuilder AddPostgreSqlStore() => builder.AddPostgreSqlStore(_ => { });
|
||||
|
||||
/// <summary>
|
||||
/// Adds a PostgreSQL store without a service key for single-store scenarios.
|
||||
/// The caller must register an unkeyed <see cref="NpgsqlDataSource"/>.
|
||||
/// </summary>
|
||||
public IStorageBuilder AddPostgreSqlStore(Action<PostgreSqlStoreOptions> configure)
|
||||
{
|
||||
var services = builder.Services;
|
||||
_ = services.AddStore<PostgreSqlStore>();
|
||||
_ = services.AddTransient<PostgreSqlStore>(sp =>
|
||||
{
|
||||
var dataSource = sp.GetRequiredService<NpgsqlDataSource>();
|
||||
var outboxSubscribers = sp.GetRequiredService<OutboxSubscribers>();
|
||||
return BuildStore(sp, dataSource, outboxSubscribers, configure);
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
private static PostgreSqlStore BuildStore(
|
||||
IServiceProvider sp,
|
||||
NpgsqlDataSource dataSource,
|
||||
OutboxSubscribers outboxSubscribers,
|
||||
Action<PostgreSqlStoreOptions> configure)
|
||||
{
|
||||
var options = new PostgreSqlStoreOptions();
|
||||
configure(options);
|
||||
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
|
||||
return new PostgreSqlStore(
|
||||
dataSource,
|
||||
options,
|
||||
sp.GetRequiredService<DataStorageTypeRegistry>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
outboxSubscribers,
|
||||
sp.GetRequiredService<ILogger<PostgreSqlStore>>());
|
||||
}
|
||||
}
|
||||
23
storage/src/Storage.PostgreSql/Storage.PostgreSql.csproj
Normal file
23
storage/src/Storage.PostgreSql/Storage.PostgreSql.csproj
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Npgsql.DependencyInjection" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Storage\Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Duende.Storage.PostgreSql.Tests" />
|
||||
<InternalsVisibleTo Include="Duende.Platform.Storage.PostgreSql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
83
storage/src/Storage.Sqlite/Internal/Log.cs
Normal file
83
storage/src/Storage.Sqlite/Internal/Log.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.Storage.Sqlite.Internal;
|
||||
|
||||
internal static partial class Log
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Checking schema version")]
|
||||
internal static partial void CheckingSchemaVersion(ILogger logger);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Creating schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void CreatingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Migrating schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void MigratingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = $"Verifying schema {{{Parameters.SchemaName}}}")]
|
||||
internal static partial void VerifyingSchema(ILogger logger, string schemaName);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = $"Error While creating schema")]
|
||||
internal static partial void ErrorWhileCreatingSchema(ILogger logger, Exception e);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = $"Executing sql {{{Parameters.Sql}}}")]
|
||||
internal static partial void ExecutingSql(ILogger logger, string sql);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Creating DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}, {nameof(Parameters.DsoSchemaVersion)}={{{Parameters.DsoSchemaVersion}}}")]
|
||||
internal static partial void CreatingDso(ILogger logger, EntityType entityType, Guid id, uint dsoSchemaVersion);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Deleting DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}")]
|
||||
internal static partial void DeletingDso(ILogger logger, EntityType entityType, Guid id);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Reading DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}")]
|
||||
internal static partial void ReadingDso(ILogger logger, EntityType entityType, Guid id);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Reading DSOs: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Count)}={{{Parameters.Count}}}")]
|
||||
internal static partial void ReadingDsos(ILogger logger, EntityType entityType, int count);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Updating DSO: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.Id)}={{{Parameters.Id}}}, {nameof(Parameters.DsoSchemaVersion)}={{{Parameters.DsoSchemaVersion}}}, {nameof(Parameters.ExpectedEntityVersion)}={{{Parameters.ExpectedEntityVersion}}}")]
|
||||
internal static partial void UpdatingDso(ILogger logger, EntityType entityType, Guid id, uint dsoSchemaVersion, int expectedEntityVersion);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Querying DSOs: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.PageNumber)}={{{Parameters.PageNumber}}}, {nameof(Parameters.PageSize)}={{{Parameters.PageSize}}}")]
|
||||
internal static partial void QueryingDsos(ILogger logger, EntityType entityType, int pageNumber, int pageSize);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Querying DSO fields: {nameof(Parameters.EntityType)}={{{Parameters.EntityType}}}, {nameof(Parameters.FieldCount)}={{{Parameters.FieldCount}}}, {nameof(Parameters.PageNumber)}={{{Parameters.PageNumber}}}, {nameof(Parameters.PageSize)}={{{Parameters.PageSize}}}")]
|
||||
internal static partial void QueryingFieldsDsos(ILogger logger, EntityType entityType, int fieldCount, int pageNumber, int pageSize);
|
||||
|
||||
[LoggerMessage(
|
||||
Level = LogLevel.Information,
|
||||
Message = $"Executing query: {nameof(Parameters.Query)}={{{Parameters.Query}}}")]
|
||||
internal static partial void ExecutingQuery(ILogger logger, string query);
|
||||
|
||||
private static class Parameters
|
||||
{
|
||||
internal const string Count = nameof(Count);
|
||||
internal const string DsoSchemaVersion = nameof(DsoSchemaVersion);
|
||||
internal const string EntityType = nameof(EntityType);
|
||||
internal const string ExpectedEntityVersion = nameof(ExpectedEntityVersion);
|
||||
internal const string FieldCount = nameof(FieldCount);
|
||||
internal const string Id = nameof(Id);
|
||||
internal const string PageNumber = nameof(PageNumber);
|
||||
internal const string PageSize = nameof(PageSize);
|
||||
internal const string Query = nameof(Query);
|
||||
internal const string SchemaName = nameof(SchemaName);
|
||||
internal const string Sql = nameof(Sql);
|
||||
}
|
||||
}
|
||||
42
storage/src/Storage.Sqlite/Internal/MigrationScriptLoader.cs
Normal file
42
storage/src/Storage.Sqlite/Internal/MigrationScriptLoader.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Duende.Storage.Sqlite.Internal;
|
||||
|
||||
internal static class MigrationScriptLoader
|
||||
{
|
||||
private static readonly Regex VersionPattern = new(@"\.Migrations\.V(\d+)_", RegexOptions.Compiled);
|
||||
|
||||
public static IEnumerable<(int TargetVersion, string Sql)> GetScripts(
|
||||
Assembly assembly,
|
||||
DatabaseSchemaVersion fromVersion)
|
||||
{
|
||||
var assemblyName = assembly.GetName().Name;
|
||||
var prefix = $"{assemblyName}.Migrations.V";
|
||||
|
||||
return assembly.GetManifestResourceNames()
|
||||
.Where(name => name.StartsWith(prefix, StringComparison.Ordinal) && name.EndsWith(".sql", StringComparison.Ordinal))
|
||||
.Select(name => (Name: name, Version: ParseVersion(name)))
|
||||
.Where(x => x.Version > fromVersion.Value)
|
||||
.OrderBy(x => x.Version)
|
||||
.Select(x => (x.Version, ReadResource(assembly, x.Name)));
|
||||
}
|
||||
|
||||
private static int ParseVersion(string resourceName)
|
||||
{
|
||||
var match = VersionPattern.Match(resourceName);
|
||||
return match.Success ? int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture) : 0;
|
||||
}
|
||||
|
||||
private static string ReadResource(Assembly assembly, string resourceName)
|
||||
{
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource '{resourceName}' not found.");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
53
storage/src/Storage.Sqlite/Internal/SqliteDialect.cs
Normal file
53
storage/src/Storage.Sqlite/Internal/SqliteDialect.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Data.Common;
|
||||
using Duende.Storage.Internal.Querying;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Duende.Storage.Sqlite.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-specific SQL dialect implementation.
|
||||
/// </summary>
|
||||
internal sealed class SqliteDialect : ISqlDialect
|
||||
{
|
||||
public string CaseInsensitiveLikeOperator => "LIKE";
|
||||
|
||||
public string LikeEscapeClause => " ESCAPE '\\'";
|
||||
|
||||
public string TrueLiteral => "1";
|
||||
|
||||
public string FalseLiteral => "0";
|
||||
|
||||
public string EscapeLikeWildcards(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// SQLite uses backslash as escape character (like PostgreSQL)
|
||||
// Replace backslash first to avoid double-escaping
|
||||
return value
|
||||
.Replace("\\", "\\\\", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("%", "\\%", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("_", "\\_", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public object FieldPathToParameterValue(Guid fieldPathId) => fieldPathId.ToByteArray();
|
||||
|
||||
public void AddParameter(DbCommand command, string name, object value)
|
||||
{
|
||||
var sqliteCommand = (SqliteCommand)command;
|
||||
|
||||
_ = value switch
|
||||
{
|
||||
DateTimeOffset dto => sqliteCommand.Parameters.AddWithValue(name, dto.UtcDateTime.ToString("O")),
|
||||
DateTime dt => sqliteCommand.Parameters.AddWithValue(name, new DateTimeOffset(dt, TimeSpan.Zero).UtcDateTime.ToString("O")),
|
||||
Guid guid => sqliteCommand.Parameters.AddWithValue(name, guid.ToString()),
|
||||
bool b => sqliteCommand.Parameters.AddWithValue(name, b ? 1 : 0),
|
||||
_ => sqliteCommand.Parameters.AddWithValue(name, value)
|
||||
};
|
||||
}
|
||||
}
|
||||
2438
storage/src/Storage.Sqlite/Internal/SqliteStore.cs
Normal file
2438
storage/src/Storage.Sqlite/Internal/SqliteStore.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.Storage.Sqlite.Internal;
|
||||
|
||||
#pragma warning disable CA1812 // Avoid uninstantiated internal classes
|
||||
public sealed class SqliteStoreOptionsValidator(string? name) : DataAnnotationValidateOptions<SqliteStoreOptions>(name);
|
||||
152
storage/src/Storage.Sqlite/Migrations/V001_InitialCreate.sql
Normal file
152
storage/src/Storage.Sqlite/Migrations/V001_InitialCreate.sql
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
-- V0 → V1: initial schema creation
|
||||
|
||||
CREATE TABLE IF NOT EXISTS __schema_info (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Only run if current version < 1
|
||||
-- SQLite lacks procedural IF, so we use IF NOT EXISTS on all objects
|
||||
-- and the version bump at the end is guarded by a WHERE clause.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
pool_id INTEGER NOT NULL,
|
||||
entity_type_id INTEGER NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
entity_type_name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
dso_type_schema_version INTEGER NOT NULL,
|
||||
value_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
expires_at TEXT NULL,
|
||||
PRIMARY KEY (pool_id, entity_type_id, entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entities_expires_at_index
|
||||
ON entities (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entities_created_at_index
|
||||
ON entities (pool_id, entity_type_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entities_last_updated_at_index
|
||||
ON entities (pool_id, entity_type_id, last_updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entity_keys (
|
||||
pool_id INTEGER NOT NULL,
|
||||
entity_type_id INTEGER NOT NULL,
|
||||
key_type_id INTEGER NOT NULL,
|
||||
key_type_version INTEGER NOT NULL,
|
||||
key_type_name TEXT NOT NULL,
|
||||
key_value TEXT NOT NULL,
|
||||
key_json TEXT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (pool_id, entity_type_id, key_type_id, key_type_version, key_value),
|
||||
FOREIGN KEY (pool_id, entity_type_id, entity_id) REFERENCES entities (pool_id, entity_type_id, entity_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_keys_entity_type_id_entity_id_index
|
||||
ON entity_keys (entity_type_id, entity_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS search_values (
|
||||
entity_type_id INTEGER NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
field_path BLOB NOT NULL,
|
||||
field_path_text TEXT NOT NULL,
|
||||
item_index INTEGER NOT NULL,
|
||||
string_value TEXT NULL,
|
||||
number_value REAL NULL,
|
||||
datetime_value TEXT NULL,
|
||||
boolean_value INTEGER NULL,
|
||||
guid_value TEXT NULL,
|
||||
pool_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (pool_id, entity_type_id, entity_id, field_path, item_index),
|
||||
FOREIGN KEY (pool_id, entity_type_id, entity_id) REFERENCES entities (pool_id, entity_type_id, entity_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_string_value_index
|
||||
ON search_values (pool_id, entity_type_id, field_path, string_value)
|
||||
WHERE string_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_number_value_index
|
||||
ON search_values (pool_id, entity_type_id, field_path, number_value)
|
||||
WHERE number_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_datetime_value_index
|
||||
ON search_values (pool_id, entity_type_id, field_path, datetime_value)
|
||||
WHERE datetime_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_boolean_value_index
|
||||
ON search_values (pool_id, entity_type_id, field_path, boolean_value)
|
||||
WHERE boolean_value IS NOT NULL AND item_index = -1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_array_string_value_index
|
||||
ON search_values (pool_id, entity_type_id, entity_id, field_path, item_index, string_value)
|
||||
WHERE string_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_array_number_value_index
|
||||
ON search_values (pool_id, entity_type_id, entity_id, field_path, item_index, number_value)
|
||||
WHERE number_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_array_datetime_value_index
|
||||
ON search_values (pool_id, entity_type_id, entity_id, field_path, item_index, datetime_value)
|
||||
WHERE datetime_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_array_boolean_value_index
|
||||
ON search_values (pool_id, entity_type_id, entity_id, field_path, item_index, boolean_value)
|
||||
WHERE boolean_value IS NOT NULL AND item_index >= 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_guid_value_index
|
||||
ON search_values (pool_id, entity_type_id, field_path, guid_value)
|
||||
WHERE item_index = -1 AND guid_value IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS search_values_array_guid_value_index
|
||||
ON search_values (pool_id, entity_type_id, entity_id, field_path, item_index, guid_value)
|
||||
WHERE item_index >= 0 AND guid_value IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entity_links (
|
||||
pool_id INTEGER NOT NULL,
|
||||
link_type_id INTEGER NOT NULL,
|
||||
left_entity_type_id INTEGER NOT NULL,
|
||||
left_entity_id TEXT NOT NULL,
|
||||
right_entity_type_id INTEGER NOT NULL,
|
||||
right_entity_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
PRIMARY KEY (pool_id, link_type_id, left_entity_id, right_entity_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_links_left_entity_index
|
||||
ON entity_links (pool_id, link_type_id, left_entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_links_right_entity_index
|
||||
ON entity_links (pool_id, link_type_id, right_entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_links_left_cascade_index
|
||||
ON entity_links (pool_id, left_entity_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS entity_links_right_cascade_index
|
||||
ON entity_links (pool_id, right_entity_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox_subscriber_queue (
|
||||
sequence_number INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
entity_type_id INTEGER NOT NULL,
|
||||
entity_type_name TEXT NOT NULL,
|
||||
pool_id INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
subscriber_name TEXT NOT NULL,
|
||||
UNIQUE (message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS outbox_subscriber_queue_subscriber_index
|
||||
ON outbox_subscriber_queue (subscriber_name, sequence_number);
|
||||
|
||||
-- Version bump: only update if not already at version 1
|
||||
INSERT OR IGNORE INTO __schema_info (key, value) VALUES ('SchemaVersion', '{"Version":1}');
|
||||
UPDATE __schema_info SET value = '{"Version":1}' WHERE key = 'SchemaVersion' AND json_extract(value, '$.Version') < 1;
|
||||
18
storage/src/Storage.Sqlite/SqliteStoreOptions.cs
Normal file
18
storage/src/Storage.Sqlite/SqliteStoreOptions.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Duende.Storage.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the SQLite store.
|
||||
/// </summary>
|
||||
public sealed class SqliteStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The connection string for the SQLite database.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string? ConnectionString { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Duende.Storage.Internal;
|
||||
using Duende.Storage.Internal.Builder;
|
||||
using Duende.Storage.Sqlite.Internal;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Duende.Storage.Sqlite;
|
||||
|
||||
public static class SqliteStoreServiceCollectionExtensions
|
||||
{
|
||||
extension(IStorageBuilder builder)
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a SQLite store with the specified service key for multi-store scenarios.
|
||||
/// </summary>
|
||||
internal IStorageBuilder AddSqliteStore(object serviceKey, Action<SqliteStoreOptions> configure)
|
||||
{
|
||||
var services = builder.Services;
|
||||
var options = BuildOptions(configure);
|
||||
_ = services.AddStore<SqliteStore>(serviceKey);
|
||||
_ = services.AddKeyedSingleton<SqliteConnection>(serviceKey, (_, _) =>
|
||||
{
|
||||
var connection = new SqliteConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
return connection;
|
||||
});
|
||||
_ = services.AddKeyedTransient<SqliteStore>(serviceKey, (sp, _) =>
|
||||
{
|
||||
// Ensure the keep-alive connection exists (required for in-memory databases)
|
||||
_ = sp.GetRequiredKeyedService<SqliteConnection>(serviceKey);
|
||||
var outboxSubscribers = sp.GetRequiredKeyedService<OutboxSubscribers>(serviceKey);
|
||||
return BuildStore(sp, outboxSubscribers, options);
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a SQLite store without a service key for single-store scenarios.
|
||||
/// </summary>
|
||||
public IStorageBuilder AddSqliteStore(Action<SqliteStoreOptions> configure)
|
||||
{
|
||||
var services = builder.Services;
|
||||
var options = BuildOptions(configure);
|
||||
_ = services.AddStore<SqliteStore>();
|
||||
_ = services.AddSingleton<SqliteConnection>(_ =>
|
||||
{
|
||||
var connection = new SqliteConnection(options.ConnectionString);
|
||||
connection.Open();
|
||||
return connection;
|
||||
});
|
||||
_ = services.AddTransient<SqliteStore>(sp =>
|
||||
{
|
||||
// Ensure the keep-alive connection exists (required for in-memory databases)
|
||||
_ = sp.GetRequiredService<SqliteConnection>();
|
||||
var outboxSubscribers = sp.GetRequiredService<OutboxSubscribers>();
|
||||
return BuildStore(sp, outboxSubscribers, options);
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a SQLite in-memory store intended for testing only.
|
||||
/// Uses a shared in-memory database with a generated unique name.
|
||||
/// This method is NOT intended for production use.
|
||||
/// </summary>
|
||||
public IStorageBuilder AddSqliteInMemoryStore() =>
|
||||
builder.AddSqliteInMemoryStore($"InMemoryDb_{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>
|
||||
/// Adds a SQLite in-memory store intended for testing only.
|
||||
/// Uses a shared in-memory database with the specified data source name,
|
||||
/// allowing multiple connections (and tests) to share the same database.
|
||||
/// This method is NOT intended for production use.
|
||||
/// </summary>
|
||||
/// <param name="dataSourceName">
|
||||
/// The data source name for the shared in-memory database.
|
||||
/// Use the same name across tests to share state.
|
||||
/// </param>
|
||||
public IStorageBuilder AddSqliteInMemoryStore(string dataSourceName) =>
|
||||
builder.AddSqliteStore(opt => opt.ConnectionString = $"Data Source={dataSourceName};Mode=Memory;Cache=Shared");
|
||||
}
|
||||
|
||||
private static SqliteStoreOptions BuildOptions(Action<SqliteStoreOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
var options = new SqliteStoreOptions();
|
||||
configure(options);
|
||||
Validator.ValidateObject(options, new ValidationContext(options), validateAllProperties: true);
|
||||
return options;
|
||||
}
|
||||
|
||||
private static SqliteStore BuildStore(
|
||||
IServiceProvider sp,
|
||||
OutboxSubscribers outboxSubscribers,
|
||||
SqliteStoreOptions options) =>
|
||||
new(
|
||||
options,
|
||||
sp.GetRequiredService<DataStorageTypeRegistry>(),
|
||||
sp.GetRequiredService<TimeProvider>(),
|
||||
outboxSubscribers,
|
||||
sp.GetRequiredService<ILogger<SqliteStore>>());
|
||||
}
|
||||
21
storage/src/Storage.Sqlite/Storage.Sqlite.csproj
Normal file
21
storage/src/Storage.Sqlite/Storage.Sqlite.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Duende.Storage.Tests" />
|
||||
<InternalsVisibleTo Include="Duende.Storage.Sqlite.Tests" />
|
||||
<InternalsVisibleTo Include="Duende.Platform.Storage.Sqlite" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Storage\Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
17
storage/src/Storage/DatabaseSchemaVersion.cs
Normal file
17
storage/src/Storage/DatabaseSchemaVersion.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage;
|
||||
|
||||
public sealed record DatabaseSchemaVersion
|
||||
{
|
||||
public int Value { get; }
|
||||
|
||||
public DatabaseSchemaVersion(int value)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(value);
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public static readonly DatabaseSchemaVersion Zero = new(0);
|
||||
}
|
||||
58
storage/src/Storage/EntityAttributeValue/AttributeCode.cs
Normal file
58
storage/src/Storage/EntityAttributeValue/AttributeCode.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a schema attribute code (programmatic identifier). Preserves the original
|
||||
/// casing but compares case-insensitively (using ordinal ignore-case) for equality and hashing.
|
||||
/// </summary>
|
||||
[StringValue]
|
||||
public partial record AttributeCode
|
||||
{
|
||||
private const int MaxLength = 100;
|
||||
|
||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
static string Normalize(string value) => value.Trim();
|
||||
|
||||
private static bool TryValidate(string? input, out IReadOnlyList<string>? errors)
|
||||
{
|
||||
errors = null;
|
||||
|
||||
if (input is null or { Length: 0 })
|
||||
{
|
||||
errors = ["A value is required."];
|
||||
return false;
|
||||
}
|
||||
|
||||
var validationErrors = new List<string>();
|
||||
|
||||
if (!char.IsAsciiLetter(input[0]))
|
||||
{
|
||||
validationErrors.Add("Must start with an ASCII letter.");
|
||||
}
|
||||
|
||||
if (input[^1] == '_')
|
||||
{
|
||||
validationErrors.Add("Must not end with an underscore.");
|
||||
}
|
||||
|
||||
foreach (var c in input.AsSpan())
|
||||
{
|
||||
if (!char.IsAsciiLetter(c) && !char.IsAsciiDigit(c) && c != '_')
|
||||
{
|
||||
validationErrors.Add("Must only contain ASCII letters, digits, or underscores.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
errors = validationErrors;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
95
storage/src/Storage/EntityAttributeValue/AttributeCode.g.cs
Normal file
95
storage/src/Storage/EntityAttributeValue/AttributeCode.g.cs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// <auto-generated by="ValueObjectsGenerator"/>
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Duende.Storage;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<AttributeCode, string>))]
|
||||
partial record AttributeCode : IStringValue<AttributeCode>
|
||||
{
|
||||
// Constructor for controlled creation
|
||||
private AttributeCode(string value) => Value = value;
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public static AttributeCode Create(string s)
|
||||
{
|
||||
if (!TryCreate(s, out var result, out var errors))
|
||||
{
|
||||
throw new FormatException($"The value '{s}' is not a valid AttributeCode. {string.Join(" ", errors)}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeCode? result)
|
||||
=> TryCreate(s, out result, out _);
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeCode? result, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
|
||||
{
|
||||
result = null;
|
||||
errors = null;
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["A value is required."];
|
||||
return false;
|
||||
}
|
||||
|
||||
s = Normalize(s);
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["Value is empty after normalization."];
|
||||
return false;
|
||||
}
|
||||
var validationErrors = new List<string>();
|
||||
if (s.Length > MaxLength)
|
||||
{
|
||||
validationErrors.Add($"Must not exceed {MaxLength} characters.");
|
||||
}
|
||||
if (!TryValidate(s, out var tryValidateErrors))
|
||||
{
|
||||
if (tryValidateErrors is { Count: > 0 })
|
||||
{
|
||||
validationErrors.AddRange(tryValidateErrors);
|
||||
}
|
||||
else
|
||||
{
|
||||
validationErrors.Add($"The value '{s}' is not valid.");
|
||||
}
|
||||
}
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
errors = validationErrors;
|
||||
return false;
|
||||
}
|
||||
result = new AttributeCode(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static implicit operator AttributeCode(string value) => Create(value);
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static AttributeCode? CreateOrDefault(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Create(input);
|
||||
}
|
||||
|
||||
internal static AttributeCode Load(string value) => new AttributeCode(value);
|
||||
|
||||
public virtual bool Equals(AttributeCode? other) =>
|
||||
other is not null && Comparer.Equals(Value, other.Value);
|
||||
|
||||
public override int GetHashCode() =>
|
||||
Value is null ? 0 : Comparer.GetHashCode(Value);
|
||||
|
||||
}
|
||||
187
storage/src/Storage/EntityAttributeValue/AttributeDefinition.cs
Normal file
187
storage/src/Storage/EntityAttributeValue/AttributeDefinition.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
public sealed record AttributeDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a definition with a scalar data type.
|
||||
/// </summary>
|
||||
public AttributeDefinition(
|
||||
AttributeCode Code,
|
||||
ScalarDataType DataType,
|
||||
AttributeDescription? Description,
|
||||
bool IsUnique,
|
||||
IReadOnlyCollection<string>? Tags)
|
||||
: this(Code, new ScalarAttributeType(DataType), Description, IsUnique, Tags, null, 0)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a definition with a scalar data type and IsUnique flag.
|
||||
/// </summary>
|
||||
public AttributeDefinition(
|
||||
AttributeCode Code,
|
||||
ScalarDataType DataType,
|
||||
AttributeDescription? Description,
|
||||
bool IsUnique)
|
||||
: this(Code, new ScalarAttributeType(DataType), Description, IsUnique, null, null, 0)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a definition with a scalar data type (convenience overload without IsUnique/Tags).
|
||||
/// </summary>
|
||||
public AttributeDefinition(
|
||||
AttributeCode Code,
|
||||
ScalarDataType DataType,
|
||||
AttributeDescription? Description)
|
||||
: this(Code, new ScalarAttributeType(DataType), Description, false, null, null, 0)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a definition with any <see cref="AttributeType" />.
|
||||
/// </summary>
|
||||
public AttributeDefinition(
|
||||
AttributeCode Code,
|
||||
AttributeType AttributeType,
|
||||
AttributeDescription? Description,
|
||||
bool IsUnique,
|
||||
IReadOnlyCollection<string>? Tags)
|
||||
: this(Code, AttributeType, Description, IsUnique, Tags, null, 0)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a definition with any <see cref="AttributeType" /> (convenience overload without IsUnique/Tags).
|
||||
/// </summary>
|
||||
public AttributeDefinition(
|
||||
AttributeCode Code,
|
||||
AttributeType AttributeType,
|
||||
AttributeDescription? Description)
|
||||
: this(Code, AttributeType, Description, false, null, null, 0)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a definition with a scalar data type, group, and order.
|
||||
/// </summary>
|
||||
public AttributeDefinition(
|
||||
AttributeCode Code,
|
||||
ScalarDataType DataType,
|
||||
AttributeDescription? Description,
|
||||
bool IsUnique,
|
||||
IReadOnlyCollection<string>? Tags,
|
||||
AttributeGroupCode? GroupCode,
|
||||
int Order)
|
||||
: this(Code, new ScalarAttributeType(DataType), Description, IsUnique, Tags, GroupCode, Order)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a definition with any <see cref="AttributeType" />, group, and order.
|
||||
/// </summary>
|
||||
public AttributeDefinition(
|
||||
AttributeCode Code,
|
||||
AttributeType AttributeType,
|
||||
AttributeDescription? Description,
|
||||
bool IsUnique,
|
||||
IReadOnlyCollection<string>? Tags,
|
||||
AttributeGroupCode? GroupCode,
|
||||
int Order)
|
||||
{
|
||||
if (IsUnique && AttributeType is ComplexAttributeType or ListAttributeType)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"IsUnique is not supported for complex or list attribute types.",
|
||||
nameof(IsUnique));
|
||||
}
|
||||
|
||||
this.Code = Code;
|
||||
this.AttributeType = AttributeType;
|
||||
this.Description = Description;
|
||||
this.IsUnique = IsUnique;
|
||||
this.Tags = Tags ?? [];
|
||||
this.GroupCode = GroupCode;
|
||||
this.Order = Order;
|
||||
}
|
||||
|
||||
public static AttributeDefinition Load(
|
||||
AttributeCode code,
|
||||
AttributeType attributeType,
|
||||
AttributeDescription? description,
|
||||
bool isUnique,
|
||||
IReadOnlyCollection<string> tags,
|
||||
AttributeGroupCode? groupCode,
|
||||
int order) =>
|
||||
Load(code, attributeType, description, isUnique, tags, groupCode, order, null);
|
||||
|
||||
public static AttributeDefinition Load(
|
||||
AttributeCode code,
|
||||
AttributeType attributeType,
|
||||
AttributeDescription? description,
|
||||
bool isUnique,
|
||||
IReadOnlyCollection<string> tags,
|
||||
AttributeGroupCode? groupCode,
|
||||
int order,
|
||||
AttributeDisplayName? displayName) =>
|
||||
new(code, attributeType, description, isUnique, tags, groupCode, order)
|
||||
{
|
||||
DisplayName = displayName
|
||||
};
|
||||
|
||||
public AttributeCode Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The full type descriptor for this attribute.
|
||||
/// </summary>
|
||||
public AttributeType AttributeType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience accessor for scalar attribute types.
|
||||
/// Throws <see cref="InvalidOperationException" /> for non-scalar types.
|
||||
/// </summary>
|
||||
public ScalarDataType DataType =>
|
||||
AttributeType is ScalarAttributeType scalar
|
||||
? scalar.DataType
|
||||
: throw new InvalidOperationException(
|
||||
$"Attribute '{Code}' has type '{AttributeType.GetType().Name}', not a scalar type. Use AttributeType instead.");
|
||||
|
||||
public AttributeDescription? Description { get; }
|
||||
public AttributeDisplayName? DisplayName { get; init; }
|
||||
public bool IsUnique { get; }
|
||||
public IReadOnlyCollection<string> Tags { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The group this attribute belongs to, or <c>null</c> if ungrouped.
|
||||
/// </summary>
|
||||
public AttributeGroupCode? GroupCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sort weight controlling display order within the group (or among ungrouped attributes).
|
||||
/// Not required to be unique; ties are resolved by a stable secondary sort (e.g. name).
|
||||
/// </summary>
|
||||
public int Order { get; }
|
||||
|
||||
#pragma warning disable CA2225 // Operator overloads have named alternates
|
||||
public static implicit operator AttributeCode(AttributeDefinition definition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
return definition.Code;
|
||||
}
|
||||
#pragma warning restore CA2225
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the compiler-generated <c>PrintMembers</c> (private for sealed records)
|
||||
/// to avoid calling <see cref="DataType" />, which throws for non-scalar attribute types.
|
||||
/// </summary>
|
||||
private bool PrintMembers(System.Text.StringBuilder builder)
|
||||
{
|
||||
_ = builder.Append(
|
||||
System.FormattableString.Invariant(
|
||||
$"Code = {Code}, AttributeType = {AttributeType}, Description = {Description?.Value ?? "(none)"}, DisplayName = {DisplayName?.Value ?? "(none)"}, IsUnique = {IsUnique}, Tags = [{string.Join(", ", Tags)}], GroupCode = {GroupCode?.Value ?? "(none)"}, Order = {Order}"));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a schema attribute description.
|
||||
/// </summary>
|
||||
[StringValue]
|
||||
public partial record AttributeDescription
|
||||
{
|
||||
internal const int MaxLength = 200;
|
||||
|
||||
static string Normalize(string value) => value.Trim();
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// <auto-generated by="ValueObjectsGenerator"/>
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Duende.Storage;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<AttributeDescription, string>))]
|
||||
partial record AttributeDescription : IStringValue<AttributeDescription>
|
||||
{
|
||||
// Constructor for controlled creation
|
||||
private AttributeDescription(string value) => Value = value;
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public static AttributeDescription Create(string s)
|
||||
{
|
||||
if (!TryCreate(s, out var result, out var errors))
|
||||
{
|
||||
throw new FormatException($"The value '{s}' is not a valid AttributeDescription. {string.Join(" ", errors)}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeDescription? result)
|
||||
=> TryCreate(s, out result, out _);
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeDescription? result, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
|
||||
{
|
||||
result = null;
|
||||
errors = null;
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["A value is required."];
|
||||
return false;
|
||||
}
|
||||
|
||||
s = Normalize(s);
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["Value is empty after normalization."];
|
||||
return false;
|
||||
}
|
||||
var validationErrors = new List<string>();
|
||||
if (s.Length > MaxLength)
|
||||
{
|
||||
validationErrors.Add($"Must not exceed {MaxLength} characters.");
|
||||
}
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
errors = validationErrors;
|
||||
return false;
|
||||
}
|
||||
result = new AttributeDescription(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static implicit operator AttributeDescription(string value) => Create(value);
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static AttributeDescription? CreateOrDefault(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Create(input);
|
||||
}
|
||||
|
||||
internal static AttributeDescription Load(string value) => new AttributeDescription(value);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a human-readable display name for an attribute or attribute group.
|
||||
/// </summary>
|
||||
[StringValue]
|
||||
public partial record AttributeDisplayName
|
||||
{
|
||||
internal const int MaxLength = 200;
|
||||
|
||||
static string Normalize(string value) => value.Trim();
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// <auto-generated by="ValueObjectsGenerator"/>
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Duende.Storage;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<AttributeDisplayName, string>))]
|
||||
partial record AttributeDisplayName : IStringValue<AttributeDisplayName>
|
||||
{
|
||||
// Constructor for controlled creation
|
||||
private AttributeDisplayName(string value) => Value = value;
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public static AttributeDisplayName Create(string s)
|
||||
{
|
||||
if (!TryCreate(s, out var result, out var errors))
|
||||
{
|
||||
throw new FormatException($"The value '{s}' is not a valid AttributeDisplayName. {string.Join(" ", errors)}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeDisplayName? result)
|
||||
=> TryCreate(s, out result, out _);
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeDisplayName? result, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
|
||||
{
|
||||
result = null;
|
||||
errors = null;
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["A value is required."];
|
||||
return false;
|
||||
}
|
||||
|
||||
s = Normalize(s);
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["Value is empty after normalization."];
|
||||
return false;
|
||||
}
|
||||
var validationErrors = new List<string>();
|
||||
if (s.Length > MaxLength)
|
||||
{
|
||||
validationErrors.Add($"Must not exceed {MaxLength} characters.");
|
||||
}
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
errors = validationErrors;
|
||||
return false;
|
||||
}
|
||||
result = new AttributeDisplayName(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static implicit operator AttributeDisplayName(string value) => Create(value);
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static AttributeDisplayName? CreateOrDefault(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Create(input);
|
||||
}
|
||||
|
||||
internal static AttributeDisplayName Load(string value) => new AttributeDisplayName(value);
|
||||
|
||||
}
|
||||
27
storage/src/Storage/EntityAttributeValue/AttributeGroup.cs
Normal file
27
storage/src/Storage/EntityAttributeValue/AttributeGroup.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a named group of attributes with display metadata and sort ordering.
|
||||
/// </summary>
|
||||
public sealed record AttributeGroup
|
||||
{
|
||||
public AttributeGroup(
|
||||
AttributeGroupCode Code,
|
||||
AttributeDisplayName? DisplayName,
|
||||
AttributeDescription? Description,
|
||||
int Order)
|
||||
{
|
||||
this.Code = Code;
|
||||
this.DisplayName = DisplayName;
|
||||
this.Description = Description;
|
||||
this.Order = Order;
|
||||
}
|
||||
|
||||
public AttributeGroupCode Code { get; init; }
|
||||
public AttributeDisplayName? DisplayName { get; init; }
|
||||
public AttributeDescription? Description { get; init; }
|
||||
public int Order { get; init; }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an attribute group code. Preserves the original casing of the name
|
||||
/// but compares case-insensitively (using ordinal ignore-case) for equality and hashing.
|
||||
/// </summary>
|
||||
[StringValue]
|
||||
public partial record AttributeGroupCode
|
||||
{
|
||||
internal const int MaxLength = 100;
|
||||
|
||||
internal static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9_-]+$")]
|
||||
internal static partial Regex Regex();
|
||||
|
||||
static string Normalize(string value) => value.Trim();
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// <auto-generated by="ValueObjectsGenerator"/>
|
||||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Duende.Storage;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
[System.ComponentModel.TypeConverter(typeof(ValueOfTypeConverter<AttributeGroupCode, string>))]
|
||||
partial record AttributeGroupCode : IStringValue<AttributeGroupCode>
|
||||
{
|
||||
// Constructor for controlled creation
|
||||
private AttributeGroupCode(string value) => Value = value;
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public static AttributeGroupCode Create(string s)
|
||||
{
|
||||
if (!TryCreate(s, out var result, out var errors))
|
||||
{
|
||||
throw new FormatException($"The value '{s}' is not a valid AttributeGroupCode. {string.Join(" ", errors)}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeGroupCode? result)
|
||||
=> TryCreate(s, out result, out _);
|
||||
|
||||
public static bool TryCreate(string? s, [NotNullWhen(true)] out AttributeGroupCode? result, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
|
||||
{
|
||||
result = null;
|
||||
errors = null;
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["A value is required."];
|
||||
return false;
|
||||
}
|
||||
|
||||
s = Normalize(s);
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
errors = ["Value is empty after normalization."];
|
||||
return false;
|
||||
}
|
||||
var validationErrors = new List<string>();
|
||||
if (s.Length > MaxLength)
|
||||
{
|
||||
validationErrors.Add($"Must not exceed {MaxLength} characters.");
|
||||
}
|
||||
if (!Regex().IsMatch(s))
|
||||
{
|
||||
validationErrors.Add("Must match the required pattern.");
|
||||
}
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
errors = validationErrors;
|
||||
return false;
|
||||
}
|
||||
result = new AttributeGroupCode(s);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static implicit operator AttributeGroupCode(string value) => Create(value);
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static AttributeGroupCode? CreateOrDefault(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Create(input);
|
||||
}
|
||||
|
||||
internal static AttributeGroupCode Load(string value) => new AttributeGroupCode(value);
|
||||
|
||||
public virtual bool Equals(AttributeGroupCode? other) =>
|
||||
other is not null && Comparer.Equals(Value, other.Value);
|
||||
|
||||
public override int GetHashCode() =>
|
||||
Value is null ? 0 : Comparer.GetHashCode(Value);
|
||||
|
||||
}
|
||||
46
storage/src/Storage/EntityAttributeValue/AttributeType.cs
Normal file
46
storage/src/Storage/EntityAttributeValue/AttributeType.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Base type for schema attribute type descriptors.
|
||||
/// </summary>
|
||||
public abstract record AttributeType
|
||||
{
|
||||
internal AttributeType() { }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that no list is nested inside another list at any depth.
|
||||
/// </summary>
|
||||
internal void ValidateNesting() => ValidateNesting(insideList: false);
|
||||
|
||||
private void ValidateNesting(bool insideList)
|
||||
{
|
||||
switch (this)
|
||||
{
|
||||
case ScalarAttributeType:
|
||||
// leaf types — always valid
|
||||
break;
|
||||
|
||||
case ComplexAttributeType complex:
|
||||
foreach (var (_, prop) in complex.Properties)
|
||||
{
|
||||
prop.Type.ValidateNesting(insideList);
|
||||
}
|
||||
break;
|
||||
|
||||
case ListAttributeType list:
|
||||
if (insideList)
|
||||
{
|
||||
throw new ArgumentException("List types cannot be nested inside another list type.");
|
||||
}
|
||||
list.ElementType.ValidateNesting(insideList: true);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported {nameof(AttributeType)} subtype encountered in {nameof(ValidateNesting)}: {GetType().FullName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
51
storage/src/Storage/EntityAttributeValue/AttributeValue.cs
Normal file
51
storage/src/Storage/EntityAttributeValue/AttributeValue.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
|
||||
public abstract record AttributeValue
|
||||
{
|
||||
private protected AttributeValue(AttributeCode code) => Code = code;
|
||||
|
||||
public AttributeCode Code { get; }
|
||||
|
||||
public abstract object UntypedValue { get; }
|
||||
|
||||
public bool TryGetValue<T>([MaybeNullWhen(false)] out T value)
|
||||
{
|
||||
if (this is AttributeValue<T> typed)
|
||||
{
|
||||
value = typed.TypedValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public override string ToString() => UntypedValue.ToString()!;
|
||||
|
||||
public static AttributeValue<T> Load<T>(AttributeCode code, T value) => new(code, value);
|
||||
}
|
||||
|
||||
public sealed record AttributeValue<T> : AttributeValue
|
||||
{
|
||||
internal AttributeValue(AttributeCode code, T value) : base(code) =>
|
||||
TypedValue = value switch
|
||||
{
|
||||
IReadOnlyDictionary<string, object> dict => (T)(object)new ReadOnlyDictionary<string, object>(
|
||||
new Dictionary<string, object>(dict)),
|
||||
IReadOnlyList<object> list => (T)(object)list.ToList().AsReadOnly(),
|
||||
_ => value
|
||||
};
|
||||
|
||||
public T TypedValue { get; }
|
||||
|
||||
public override object UntypedValue => TypedValue!;
|
||||
|
||||
internal static AttributeValue<T> Load(AttributeCode code, T value) => new(code, value);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Collections;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
public sealed class AttributeValueCollection : IEnumerable<AttributeValue>
|
||||
{
|
||||
private readonly Dictionary<AttributeCode, AttributeValue> _dict = [];
|
||||
|
||||
public AttributeValueCollection() { }
|
||||
|
||||
public AttributeValueCollection(IEnumerable<AttributeValue> attributes)
|
||||
{
|
||||
foreach (var attribute in attributes)
|
||||
{
|
||||
if (!_dict.TryAdd(attribute.Code, attribute))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The attributes contain more than one attribute named '{attribute.Code}'", nameof(attributes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(AttributeValue attribute)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attribute);
|
||||
_dict[attribute.Code] = attribute;
|
||||
}
|
||||
|
||||
public int Count => _dict.Count;
|
||||
|
||||
public bool Remove(AttributeCode code) => _dict.Remove(code);
|
||||
|
||||
public bool Contains(AttributeCode code) => _dict.ContainsKey(code);
|
||||
|
||||
public bool TryGet(AttributeCode code, [MaybeNullWhen(false)] out AttributeValue attribute) =>
|
||||
_dict.TryGetValue(code, out attribute);
|
||||
|
||||
#pragma warning disable CA1043 // Use integral or string argument for indexers
|
||||
public AttributeValue this[AttributeCode code] => _dict[code];
|
||||
#pragma warning restore CA1043
|
||||
|
||||
public IEnumerator<AttributeValue> GetEnumerator() => _dict.Values.GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a sub-property within a <see cref="ComplexAttributeType" />,
|
||||
/// bundling the property's type with optional display metadata.
|
||||
/// </summary>
|
||||
public sealed record ComplexAttributeProperty
|
||||
{
|
||||
private ComplexAttributeProperty(AttributeType type, AttributeDisplayName? displayName, AttributeDescription? description)
|
||||
{
|
||||
Type = type;
|
||||
DisplayName = displayName;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
/// <summary>The type of this sub-property.</summary>
|
||||
public AttributeType Type { get; }
|
||||
|
||||
/// <summary>Optional human-readable display name for this sub-property.</summary>
|
||||
public AttributeDisplayName? DisplayName { get; }
|
||||
|
||||
/// <summary>Optional description for this sub-property.</summary>
|
||||
public AttributeDescription? Description { get; }
|
||||
|
||||
/// <summary>Creates a property with a scalar type and no metadata.</summary>
|
||||
public static ComplexAttributeProperty Of(ScalarDataType dataType) =>
|
||||
new(new ScalarAttributeType(dataType), null, null);
|
||||
|
||||
/// <summary>Creates a property with the given type and no metadata.</summary>
|
||||
public static ComplexAttributeProperty Of(AttributeType type) =>
|
||||
new(type, null, null);
|
||||
|
||||
/// <summary>Creates a property with the given type and display name.</summary>
|
||||
public static ComplexAttributeProperty Of(AttributeType type, AttributeDisplayName? displayName) =>
|
||||
new(type, displayName, null);
|
||||
|
||||
/// <summary>Creates a property with the given type and optional metadata.</summary>
|
||||
public static ComplexAttributeProperty Of(AttributeType type, AttributeDisplayName? displayName, AttributeDescription? description) =>
|
||||
new(type, displayName, description);
|
||||
}
|
||||
108
storage/src/Storage/EntityAttributeValue/ComplexAttributeType.cs
Normal file
108
storage/src/Storage/EntityAttributeValue/ComplexAttributeType.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a complex (nested object) attribute type with named properties.
|
||||
/// </summary>
|
||||
public sealed record ComplexAttributeType : AttributeType
|
||||
{
|
||||
public ComplexAttributeType(IReadOnlyDictionary<AttributeCode, ComplexAttributeProperty> Properties)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(Properties, nameof(Properties));
|
||||
|
||||
if (Properties.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Properties must contain at least one entry.", nameof(Properties));
|
||||
}
|
||||
|
||||
foreach (var (_, value) in Properties)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value, nameof(Properties));
|
||||
}
|
||||
|
||||
// AttributeCode already implements case-insensitive Equals/GetHashCode via the generated code.
|
||||
var dict = new Dictionary<AttributeCode, ComplexAttributeProperty>();
|
||||
foreach (var (k, v) in Properties)
|
||||
{
|
||||
dict[k] = v;
|
||||
}
|
||||
this.Properties = dict;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The named sub-properties and their types. All properties are optional — complex
|
||||
/// values may contain any subset of the defined properties. Unknown properties
|
||||
/// (not listed here) are rejected during validation.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<AttributeCode, ComplexAttributeProperty> Properties { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get a property by name (case-insensitive) and returns the schema-canonical
|
||||
/// key alongside the property. This ensures callers can normalize to the schema-defined casing.
|
||||
/// </summary>
|
||||
public bool TryGetProperty(string name, [NotNullWhen(true)] out AttributeCode? canonicalKey, out ComplexAttributeProperty property)
|
||||
{
|
||||
if (!AttributeCode.TryCreate(name, out var code))
|
||||
{
|
||||
canonicalKey = null;
|
||||
property = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The underlying dictionary uses AttributeCode.Comparer (OrdinalIgnoreCase), so TryGetValue matches case-insensitively.
|
||||
// To recover the canonical key we walk Keys — the dictionary is small (schema-defined properties).
|
||||
if (Properties.TryGetValue(code, out property!))
|
||||
{
|
||||
foreach (var key in Properties.Keys)
|
||||
{
|
||||
if (key == code)
|
||||
{
|
||||
canonicalKey = key;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canonicalKey = null;
|
||||
property = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Equals(ComplexAttributeType? other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Properties.Count != other.Properties.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in Properties)
|
||||
{
|
||||
if (!other.Properties.TryGetValue(key, out var otherValue) || !value.Equals(otherValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var (key, value) in Properties.OrderBy(p => p.Key.Value, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
hash.Add(key);
|
||||
hash.Add(value);
|
||||
}
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
public interface IReadOnlyAttributeSchema
|
||||
{
|
||||
IReadOnlyDictionary<AttributeCode, AttributeDefinition> AttributeDefinitions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The named groups defined in this schema, keyed by group name.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<AttributeGroupCode, AttributeGroup> Groups { get; }
|
||||
|
||||
AttributeValue<bool> CreateAttribute(AttributeCode code, bool value);
|
||||
|
||||
AttributeValue<DateOnly> CreateAttribute(AttributeCode code, DateOnly value);
|
||||
|
||||
AttributeValue<DateTimeOffset> CreateAttribute(AttributeCode code, DateTimeOffset value);
|
||||
|
||||
AttributeValue<decimal> CreateAttribute(AttributeCode code, decimal value);
|
||||
|
||||
AttributeValue<int> CreateAttribute(AttributeCode code, int value);
|
||||
|
||||
AttributeValue<string> CreateAttribute(AttributeCode code, string value);
|
||||
|
||||
AttributeValue<IReadOnlyDictionary<string, object>> CreateAttribute(AttributeCode code, IReadOnlyDictionary<string, object> complexValue);
|
||||
|
||||
AttributeValue<IReadOnlyList<object>> CreateAttribute(AttributeCode code, IReadOnlyList<object> listValue);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, bool value, [NotNullWhen(true)] out AttributeValue<bool>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, DateOnly value, [NotNullWhen(true)] out AttributeValue<DateOnly>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, DateTimeOffset value, [NotNullWhen(true)] out AttributeValue<DateTimeOffset>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, decimal value, [NotNullWhen(true)] out AttributeValue<decimal>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, int value, [NotNullWhen(true)] out AttributeValue<int>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, string value, [NotNullWhen(true)] out AttributeValue<string>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, IReadOnlyDictionary<string, object> complexValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyDictionary<string, object>>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, IReadOnlyList<object> listValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyList<object>>? attribute);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, bool value, [NotNullWhen(true)] out AttributeValue<bool>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, DateOnly value, [NotNullWhen(true)] out AttributeValue<DateOnly>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, DateTimeOffset value, [NotNullWhen(true)] out AttributeValue<DateTimeOffset>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, decimal value, [NotNullWhen(true)] out AttributeValue<decimal>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, int value, [NotNullWhen(true)] out AttributeValue<int>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, string value, [NotNullWhen(true)] out AttributeValue<string>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, IReadOnlyDictionary<string, object> complexValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyDictionary<string, object>>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
bool TryCreateAttribute(AttributeCode code, IReadOnlyList<object> listValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyList<object>>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors);
|
||||
|
||||
AttributeValueCollection CreateAttributes(IEnumerable<AttributeValue> attributes);
|
||||
}
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Duende.Storage.Internal.Querying;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a dynamic collection of attributes.
|
||||
/// </summary>
|
||||
public sealed class AttributeSchema : IReadOnlyAttributeSchema
|
||||
{
|
||||
private readonly Dictionary<AttributeCode, AttributeDefinition> _attributesDefinitions;
|
||||
private readonly Dictionary<AttributeGroupCode, AttributeGroup> _groups;
|
||||
|
||||
private AttributeSchema(IEnumerable<AttributeDefinition> attributeDefinitions, IEnumerable<AttributeGroup> groups)
|
||||
{
|
||||
_attributesDefinitions = new Dictionary<AttributeCode, AttributeDefinition>();
|
||||
foreach (var d in attributeDefinitions)
|
||||
{
|
||||
_attributesDefinitions[d.Code] = d; // Last-write-wins for duplicates from storage
|
||||
}
|
||||
|
||||
_groups = new Dictionary<AttributeGroupCode, AttributeGroup>();
|
||||
foreach (var g in groups)
|
||||
{
|
||||
_groups[g.Code] = g; // Last-write-wins for duplicates from storage
|
||||
}
|
||||
}
|
||||
|
||||
public AttributeSchema() : this([], [])
|
||||
{
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<AttributeCode, AttributeDefinition> AttributeDefinitions => _attributesDefinitions;
|
||||
|
||||
public IReadOnlyDictionary<AttributeGroupCode, AttributeGroup> Groups => _groups;
|
||||
|
||||
public bool AddGroup(AttributeGroup group) => _groups.TryAdd(group.Code, group);
|
||||
|
||||
public bool RemoveGroup(AttributeGroupCode name)
|
||||
{
|
||||
if (!_groups.Remove(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ungroup all attributes that referenced this group
|
||||
var toUngroup = _attributesDefinitions.Values
|
||||
.Where(d => d.GroupCode != null && d.GroupCode.Equals(name))
|
||||
.ToList();
|
||||
|
||||
foreach (var definition in toUngroup)
|
||||
{
|
||||
_attributesDefinitions[definition.Code] = AttributeDefinition.Load(
|
||||
definition.Code,
|
||||
definition.AttributeType,
|
||||
definition.Description,
|
||||
definition.IsUnique,
|
||||
definition.Tags,
|
||||
null,
|
||||
definition.Order);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool UpdateGroup(AttributeGroup group)
|
||||
{
|
||||
if (!_groups.ContainsKey(group.Code))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_groups[group.Code] = group;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool AddAttributeDefinition(AttributeDefinition definition)
|
||||
{
|
||||
if (SystemFields.IsReservedAttributeName(definition.Code.Value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.GroupCode != null && !_groups.ContainsKey(definition.GroupCode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _attributesDefinitions.TryAdd(definition.Code, definition);
|
||||
}
|
||||
|
||||
public void RemoveAttributeDefinition(AttributeCode code) => _ = _attributesDefinitions.Remove(code);
|
||||
|
||||
public AttributeValue<bool> CreateAttribute(AttributeCode code, bool value) =>
|
||||
TryCreateAttribute(code, value, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public AttributeValue<DateOnly> CreateAttribute(AttributeCode code, DateOnly value) =>
|
||||
TryCreateAttribute(code, value, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public AttributeValue<DateTimeOffset> CreateAttribute(AttributeCode code, DateTimeOffset value) =>
|
||||
TryCreateAttribute(code, value, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public AttributeValue<decimal> CreateAttribute(AttributeCode code, decimal value) =>
|
||||
TryCreateAttribute(code, value, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public AttributeValue<int> CreateAttribute(AttributeCode code, int value) =>
|
||||
TryCreateAttribute(code, value, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public AttributeValue<string> CreateAttribute(AttributeCode code, string value) =>
|
||||
TryCreateAttribute(code, value, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public AttributeValue<IReadOnlyDictionary<string, object>> CreateAttribute(AttributeCode code, IReadOnlyDictionary<string, object> complexValue) =>
|
||||
TryCreateAttribute(code, complexValue, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public AttributeValue<IReadOnlyList<object>> CreateAttribute(AttributeCode code, IReadOnlyList<object> listValue) =>
|
||||
TryCreateAttribute(code, listValue, out var attribute, out var errors)
|
||||
? attribute
|
||||
: throw new ArgumentException(string.Join("; ", errors));
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, bool value, [NotNullWhen(true)] out AttributeValue<bool>? attribute) =>
|
||||
TryCreateAttribute(code, value, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, DateOnly value, [NotNullWhen(true)] out AttributeValue<DateOnly>? attribute) =>
|
||||
TryCreateAttribute(code, value, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, DateTimeOffset value, [NotNullWhen(true)] out AttributeValue<DateTimeOffset>? attribute) =>
|
||||
TryCreateAttribute(code, value, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, decimal value, [NotNullWhen(true)] out AttributeValue<decimal>? attribute) =>
|
||||
TryCreateAttribute(code, value, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, int value, [NotNullWhen(true)] out AttributeValue<int>? attribute) =>
|
||||
TryCreateAttribute(code, value, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, string value, [NotNullWhen(true)] out AttributeValue<string>? attribute) =>
|
||||
TryCreateAttribute(code, value, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, IReadOnlyDictionary<string, object> complexValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyDictionary<string, object>>? attribute) =>
|
||||
TryCreateAttribute(code, complexValue, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, IReadOnlyList<object> listValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyList<object>>? attribute) =>
|
||||
TryCreateAttribute(code, listValue, out attribute, out _);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, bool value, [NotNullWhen(true)] out AttributeValue<bool>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors) =>
|
||||
TryCreateScalarAttribute(code, value, ScalarDataType.Boolean, out attribute, out errors);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, DateOnly value, [NotNullWhen(true)] out AttributeValue<DateOnly>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors) =>
|
||||
TryCreateScalarAttribute(code, value, ScalarDataType.Date, out attribute, out errors);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, DateTimeOffset value, [NotNullWhen(true)] out AttributeValue<DateTimeOffset>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors) =>
|
||||
TryCreateScalarAttribute(code, value, ScalarDataType.DateTime, out attribute, out errors);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, decimal value, [NotNullWhen(true)] out AttributeValue<decimal>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors) =>
|
||||
TryCreateScalarAttribute(code, value, ScalarDataType.Decimal, out attribute, out errors);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, int value, [NotNullWhen(true)] out AttributeValue<int>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors) =>
|
||||
TryCreateScalarAttribute(code, value, ScalarDataType.Integer, out attribute, out errors);
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, string value, [NotNullWhen(true)] out AttributeValue<string>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
|
||||
{
|
||||
if (!_attributesDefinitions.TryGetValue(code, out var definition))
|
||||
{
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is not defined in the schema."];
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.AttributeType is not ScalarAttributeType scalar || scalar.DataType != ScalarDataType.String)
|
||||
{
|
||||
var providedType = typeof(string).Name;
|
||||
var definedType = definition.AttributeType is ScalarAttributeType s ? s.DataType.ToString() : definition.AttributeType.GetType().Name;
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is defined as '{definedType}' but a '{providedType}' value was provided."];
|
||||
return false;
|
||||
}
|
||||
|
||||
attribute = new AttributeValue<string>(code, value);
|
||||
errors = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, IReadOnlyDictionary<string, object> complexValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyDictionary<string, object>>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
|
||||
{
|
||||
if (!_attributesDefinitions.TryGetValue(code, out var definition))
|
||||
{
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is not defined in the schema."];
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.AttributeType is not ComplexAttributeType complexType)
|
||||
{
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is not a complex type."];
|
||||
return false;
|
||||
}
|
||||
|
||||
var errorList = new List<string>();
|
||||
CollectComplexValueErrors(code, complexValue, complexType, errorList);
|
||||
|
||||
if (errorList.Count > 0)
|
||||
{
|
||||
attribute = null;
|
||||
errors = errorList;
|
||||
return false;
|
||||
}
|
||||
|
||||
attribute = new AttributeValue<IReadOnlyDictionary<string, object>>(code, complexValue);
|
||||
errors = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryCreateAttribute(AttributeCode code, IReadOnlyList<object> listValue, [NotNullWhen(true)] out AttributeValue<IReadOnlyList<object>>? attribute, [NotNullWhen(false)] out IReadOnlyList<string>? errors)
|
||||
{
|
||||
if (!_attributesDefinitions.TryGetValue(code, out var definition))
|
||||
{
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is not defined in the schema."];
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.AttributeType is not ListAttributeType listType)
|
||||
{
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is not a list type."];
|
||||
return false;
|
||||
}
|
||||
|
||||
var errorList = new List<string>();
|
||||
CollectListValueErrors(code, listValue, listType, errorList);
|
||||
|
||||
if (errorList.Count > 0)
|
||||
{
|
||||
attribute = null;
|
||||
errors = errorList;
|
||||
return false;
|
||||
}
|
||||
|
||||
attribute = new AttributeValue<IReadOnlyList<object>>(code, listValue);
|
||||
errors = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public AttributeValueCollection CreateAttributes(IEnumerable<AttributeValue> attributes) => new AttributeValueCollection(attributes);
|
||||
|
||||
private bool TryCreateScalarAttribute<T>(AttributeCode code, T value, ScalarDataType dataType, out AttributeValue<T>? attribute, out IReadOnlyList<string>? errors)
|
||||
{
|
||||
if (!_attributesDefinitions.TryGetValue(code, out var definition))
|
||||
{
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is not defined in the schema."];
|
||||
return false;
|
||||
}
|
||||
|
||||
if (definition.AttributeType is not ScalarAttributeType scalar || scalar.DataType != dataType)
|
||||
{
|
||||
var providedType = typeof(T).Name;
|
||||
var definedType = definition.AttributeType is ScalarAttributeType s ? s.DataType.ToString() : definition.AttributeType.GetType().Name;
|
||||
attribute = null;
|
||||
errors = [$"Attribute '{code}' is defined as '{definedType}' but a '{providedType}' value was provided."];
|
||||
return false;
|
||||
}
|
||||
|
||||
attribute = new AttributeValue<T>(code, value);
|
||||
errors = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void CollectComplexValueErrors(AttributeCode code, IReadOnlyDictionary<string, object> value, ComplexAttributeType complexType, List<string> errors)
|
||||
{
|
||||
foreach (var (key, propValue) in value)
|
||||
{
|
||||
if (!complexType.TryGetProperty(key, out _, out var prop))
|
||||
{
|
||||
errors.Add($"Property '{key}' is not defined in complex attribute '{code}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var expectedType = GetExpectedTypeName(prop.Type);
|
||||
|
||||
if (propValue is null)
|
||||
{
|
||||
errors.Add($"Property '{key}' in attribute '{code}' expects type '{expectedType}' but got 'null'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ValidateValueAgainstType(propValue, prop.Type))
|
||||
{
|
||||
var actualType = propValue.GetType().Name;
|
||||
errors.Add($"Property '{key}' in attribute '{code}' expects type '{expectedType}' but got '{actualType}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectListValueErrors(AttributeCode code, IReadOnlyList<object> value, ListAttributeType listType, List<string> errors)
|
||||
{
|
||||
for (var i = 0; i < value.Count; i++)
|
||||
{
|
||||
var element = value[i];
|
||||
|
||||
if (listType.ElementType is ComplexAttributeType complexElementType)
|
||||
{
|
||||
if (element is null)
|
||||
{
|
||||
errors.Add($"Element at index {i} in list attribute '{code}' expects type 'Complex' but got 'null'.");
|
||||
}
|
||||
else if (element is IReadOnlyDictionary<string, object> dict)
|
||||
{
|
||||
var before = errors.Count;
|
||||
CollectComplexValueErrors(code, dict, complexElementType, errors);
|
||||
// Prefix element-level context to any errors added
|
||||
for (var j = before; j < errors.Count; j++)
|
||||
{
|
||||
errors[j] = $"Element at index {i}: {errors[j]}";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var actualType = element.GetType().Name;
|
||||
errors.Add($"Element at index {i} in list attribute '{code}' expects type 'Complex' but got '{actualType}'.");
|
||||
}
|
||||
}
|
||||
else if (element is null)
|
||||
{
|
||||
var expectedType = GetExpectedTypeName(listType.ElementType);
|
||||
errors.Add($"Element at index {i} in list attribute '{code}' expects type '{expectedType}' but got 'null'.");
|
||||
}
|
||||
else if (!ValidateValueAgainstType(element, listType.ElementType))
|
||||
{
|
||||
var expectedType = GetExpectedTypeName(listType.ElementType);
|
||||
var actualType = element.GetType().Name;
|
||||
errors.Add($"Element at index {i} in list attribute '{code}' expects type '{expectedType}' but got '{actualType}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetExpectedTypeName(AttributeType type) =>
|
||||
type switch
|
||||
{
|
||||
ScalarAttributeType scalar => scalar.DataType.ToString(),
|
||||
ComplexAttributeType => "Complex",
|
||||
ListAttributeType => "List",
|
||||
_ => type.GetType().Name
|
||||
};
|
||||
|
||||
private static bool ValidateValueAgainstType(object value, AttributeType type) =>
|
||||
type switch
|
||||
{
|
||||
ScalarAttributeType scalar => ValidateScalarValue(value, scalar.DataType),
|
||||
ComplexAttributeType complexType => value is IReadOnlyDictionary<string, object> dict && ValidateComplexValue(dict, complexType),
|
||||
ListAttributeType listType => value is IReadOnlyList<object> list && list.All(el => ValidateValueAgainstType(el, listType.ElementType)),
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static bool ValidateComplexValue(IReadOnlyDictionary<string, object> value, ComplexAttributeType complexType)
|
||||
{
|
||||
foreach (var (key, propValue) in value)
|
||||
{
|
||||
if (!complexType.TryGetProperty(key, out _, out var prop))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ValidateValueAgainstType(propValue, prop.Type))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ValidateScalarValue(object value, ScalarDataType dataType) =>
|
||||
dataType switch
|
||||
{
|
||||
ScalarDataType.Boolean => value is bool,
|
||||
ScalarDataType.Date => value is DateOnly,
|
||||
ScalarDataType.DateTime => value is DateTimeOffset,
|
||||
ScalarDataType.Decimal => value is decimal,
|
||||
ScalarDataType.Integer => value is int,
|
||||
ScalarDataType.String => value is string,
|
||||
_ => false
|
||||
};
|
||||
|
||||
public static AttributeSchema Load(IEnumerable<AttributeDefinition> attributes) => new(attributes, []);
|
||||
|
||||
public static AttributeSchema Load(IEnumerable<AttributeDefinition> attributes, IEnumerable<AttributeGroup> groups) => new(attributes, groups);
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
public static class AttributeDefinitionDso
|
||||
{
|
||||
public sealed record V1(
|
||||
string Code,
|
||||
AttributeTypeDso Type,
|
||||
string? Description,
|
||||
bool IsUnique,
|
||||
IReadOnlyList<string> Tags,
|
||||
string? GroupCode,
|
||||
int Order,
|
||||
string? DisplayName);
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
public static class AttributeGroupDso
|
||||
{
|
||||
public sealed record V1(string Code, string? DisplayName, string? Description, int Order);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
public static class AttributeSchemaDso
|
||||
{
|
||||
public static readonly EntityType EntityType = new(1501, "UserProfileSchemaDso");
|
||||
|
||||
public sealed record V1(ICollection<AttributeDefinitionDso.V1> AttributeDefinitions, ICollection<AttributeGroupDso.V1> Groups) : IDataStorageObject
|
||||
{
|
||||
public static DataStorageObjectVersion DsoVersion { get; } = new(EntityType, 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted representation of an <see cref="AttributeType"/>.
|
||||
/// <c>EnumValues</c> and <c>ConstrainedValues</c> are reserved for future enum/constrained-string
|
||||
/// attribute types and are currently always <c>null</c>.
|
||||
/// </summary>
|
||||
public sealed record AttributeTypeDso(
|
||||
string Kind,
|
||||
string? ScalarDataType,
|
||||
IReadOnlyList<EnumValueDso>? EnumValues,
|
||||
IReadOnlyList<string>? ConstrainedValues,
|
||||
Dictionary<string, ComplexPropertyDso>? Properties,
|
||||
AttributeTypeDso? ElementType);
|
||||
|
||||
/// <summary>
|
||||
/// Persisted representation of a sub-property within a complex attribute type.
|
||||
/// </summary>
|
||||
public sealed record ComplexPropertyDso(
|
||||
AttributeTypeDso Type,
|
||||
string? DisplayName,
|
||||
string? Description);
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal;
|
||||
using Duende.Storage.Internal.Querying;
|
||||
using Duende.Storage.Internal.Querying.Fields;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves SCIM attribute paths to Faro Field types based on the dynamic user schema.
|
||||
/// Unlike other <see cref="IScimAttributeTypeResolver"/> implementations which map fixed SCIM User schema attributes,
|
||||
/// this resolver dynamically maps user-defined schema attributes to their Faro field types.
|
||||
/// Supports dotted paths (e.g., <c>address.city</c>, <c>phones.type</c>) for complex and list types.
|
||||
/// </summary>
|
||||
public sealed class AttributeTypeResolver : IScimAttributeTypeResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<AttributeCode, AttributeDefinition> _attributeDefinitions;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new resolver with the given user schema attribute definitions.
|
||||
/// </summary>
|
||||
/// <param name="attributeDefinitions">The schema attribute definitions for the user schema.</param>
|
||||
public AttributeTypeResolver(
|
||||
IReadOnlyDictionary<AttributeCode, AttributeDefinition> attributeDefinitions) =>
|
||||
_attributeDefinitions = attributeDefinitions ?? throw new ArgumentNullException(nameof(attributeDefinitions));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Field ResolveField(string attributePath)
|
||||
{
|
||||
var normalized = attributePath.Trim();
|
||||
|
||||
// Handle built-in SCIM User fields that are stored as first-class columns
|
||||
if (string.Equals(normalized, "username", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new StringField("userName");
|
||||
}
|
||||
|
||||
// Handle system timestamp fields
|
||||
if (normalized == SystemFields.CreatedAttributeName)
|
||||
{
|
||||
return SystemFields.CreatedAtField;
|
||||
}
|
||||
if (normalized == SystemFields.LastUpdatedAttributeName)
|
||||
{
|
||||
return SystemFields.LastUpdatedAtField;
|
||||
}
|
||||
|
||||
// Split on '.' to handle dotted paths (e.g., "address.city", "phones.type")
|
||||
var segments = normalized.Split('.');
|
||||
var rootSegment = segments[0];
|
||||
|
||||
// Resolve the root attribute from dynamic schema
|
||||
if (!AttributeCode.TryCreate(rootSegment, out var schemaCode) ||
|
||||
!_attributeDefinitions.TryGetValue(schemaCode, out var definition))
|
||||
{
|
||||
throw new NotSupportedException($"Unknown user attribute: {attributePath}");
|
||||
}
|
||||
|
||||
// Walk the remaining segments through the type tree
|
||||
var isArray = false;
|
||||
var currentType = definition.AttributeType;
|
||||
|
||||
for (var i = 1; i < segments.Length; i++)
|
||||
{
|
||||
var segment = segments[i];
|
||||
|
||||
// Unwrap any list wrapper before navigating into the segment
|
||||
if (currentType is ListAttributeType listWrapper)
|
||||
{
|
||||
isArray = true;
|
||||
currentType = listWrapper.ElementType;
|
||||
}
|
||||
|
||||
switch (currentType)
|
||||
{
|
||||
case ComplexAttributeType complex:
|
||||
if (!complex.TryGetProperty(segment, out _, out var prop))
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"Unknown property '{segment}' on complex attribute '{attributePath}'.");
|
||||
}
|
||||
currentType = prop.Type;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"Cannot navigate into segment '{segment}' of type '{currentType.GetType().Name}' in path '{attributePath}'.");
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap any trailing list wrapper (e.g., root type is List<scalar> with no sub-path).
|
||||
// For a root list attribute (e.g., "tags" where tags: List<string>), we resolve to a
|
||||
// multi-valued field so the evaluator checks all array-indexed entries.
|
||||
if (currentType is ListAttributeType trailingList)
|
||||
{
|
||||
isArray = true;
|
||||
currentType = trailingList.ElementType;
|
||||
}
|
||||
|
||||
return MapToField(currentType, normalized, isArray);
|
||||
}
|
||||
|
||||
private static Field MapToField(AttributeType type, string fullPath, bool isArray) =>
|
||||
type switch
|
||||
{
|
||||
ScalarAttributeType scalar => MapScalarToField(scalar.DataType, fullPath, isArray),
|
||||
|
||||
ComplexAttributeType => throw new NotSupportedException(
|
||||
$"Cannot query a complex attribute directly at path '{fullPath}'. Use a sub-property path."),
|
||||
|
||||
ListAttributeType => throw new NotSupportedException(
|
||||
$"Cannot query a list attribute directly at path '{fullPath}'. Use a sub-property path."),
|
||||
|
||||
_ => throw new NotSupportedException($"Unsupported schema attribute type: {type.GetType().Name}")
|
||||
};
|
||||
|
||||
private static Field MapScalarToField(ScalarDataType dataType, string fullPath, bool isMultiValued) =>
|
||||
dataType switch
|
||||
{
|
||||
ScalarDataType.Boolean => new BooleanField(fullPath, isMultiValued),
|
||||
ScalarDataType.Date => new DateTimeField(fullPath, isMultiValued),
|
||||
ScalarDataType.DateTime => new DateTimeField(fullPath, isMultiValued),
|
||||
ScalarDataType.Decimal => new NumberField(fullPath, isMultiValued),
|
||||
ScalarDataType.Integer => new NumberField(fullPath, isMultiValued),
|
||||
ScalarDataType.String => new StringField(fullPath, isMultiValued),
|
||||
_ => throw new NotSupportedException(
|
||||
$"Unsupported schema attribute data type: {dataType} for path {fullPath}")
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Globalization;
|
||||
using Duende.Storage.Internal;
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
public sealed record AttributeValueDskV1 : IDataStorageKey
|
||||
{
|
||||
private AttributeValueDskV1(string name, string value)
|
||||
{
|
||||
Name = name;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public static DataStorageKeyVersion DskVersion { get; } =
|
||||
new(new DataStorageKeyType(1u, "Attribute"), 1);
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public static AttributeValueDskV1 Create(AttributeValue attribute) =>
|
||||
new(attribute.Code.Value, ToInvariantString(attribute.UntypedValue));
|
||||
|
||||
public static AttributeValueDskV1 Create(AttributeCode code, object value) =>
|
||||
new(code.Value, ToInvariantString(value));
|
||||
|
||||
private static string ToInvariantString(object value) =>
|
||||
value switch
|
||||
{
|
||||
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString()!
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
public static class AttributeValueDso
|
||||
{
|
||||
public sealed record V1(string Name, object? Value);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue.Internal.Storage;
|
||||
|
||||
public sealed record EnumValueDso(string Key, string DisplayName);
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a list attribute type with a single element type.
|
||||
/// Lists cannot be nested inside other lists.
|
||||
/// </summary>
|
||||
public sealed record ListAttributeType : AttributeType
|
||||
{
|
||||
public ListAttributeType(AttributeType ElementType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ElementType);
|
||||
|
||||
this.ElementType = ElementType;
|
||||
|
||||
// Validate no list-in-list at construction time
|
||||
ValidateNesting();
|
||||
}
|
||||
|
||||
public AttributeType ElementType { get; }
|
||||
|
||||
public bool Equals(ListAttributeType? other) =>
|
||||
other is not null && ElementType.Equals(other.ElementType);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(ElementType);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a scalar (primitive) attribute type.
|
||||
/// </summary>
|
||||
public sealed record ScalarAttributeType : AttributeType
|
||||
{
|
||||
public ScalarAttributeType(ScalarDataType DataType)
|
||||
{
|
||||
if (!Enum.IsDefined(DataType))
|
||||
{
|
||||
throw new ArgumentException($"Invalid ScalarDataType value: {DataType}.", nameof(DataType));
|
||||
}
|
||||
|
||||
this.DataType = DataType;
|
||||
}
|
||||
|
||||
public ScalarDataType DataType { get; init; }
|
||||
}
|
||||
18
storage/src/Storage/EntityAttributeValue/ScalarDataType.cs
Normal file
18
storage/src/Storage/EntityAttributeValue/ScalarDataType.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.EntityAttributeValue;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a schema attribute data type.
|
||||
/// </summary>
|
||||
#pragma warning disable CA1720 // Identifiers should not contain type names
|
||||
public enum ScalarDataType
|
||||
{
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Decimal,
|
||||
Integer,
|
||||
String,
|
||||
}
|
||||
38
storage/src/Storage/IDatabaseSchema.cs
Normal file
38
storage/src/Storage/IDatabaseSchema.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal;
|
||||
|
||||
namespace Duende.Storage;
|
||||
|
||||
public interface IDatabaseSchema
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks the schema version of the database.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
Task<CheckSchemaVersionResult> CheckVersionAsync(Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Migrates the database schema to the current version. Creates the schema if it does not exist,
|
||||
/// upgrades it if behind, and is a no-op if already current. Calls <see cref="VerifySchemaAsync"/>
|
||||
/// after migration and throws if verification finds errors.
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
Task MigrateAsync(Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the actual database schema matches the expected structure.
|
||||
/// Returns a list of discrepancies (missing tables, columns, wrong types, missing indexes, etc.).
|
||||
/// </summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
Task<SchemaVerificationResult> VerifySchemaAsync(Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Builds a SQL migration script that brings the database from <paramref name="fromVersion"/> to the current version.
|
||||
/// Pass <see cref="DatabaseSchemaVersion.Zero"/> to generate the full script for a fresh database.
|
||||
/// Each migration step is gated on the schema version number, not object existence.
|
||||
/// </summary>
|
||||
/// <param name="fromVersion">The version to migrate from.</param>
|
||||
string BuildMigrationScript(DatabaseSchemaVersion fromVersion);
|
||||
}
|
||||
11
storage/src/Storage/IStorageBuilder.cs
Normal file
11
storage/src/Storage/IStorageBuilder.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Duende.Storage;
|
||||
|
||||
public interface IStorageBuilder
|
||||
{
|
||||
public IServiceCollection Services { get; }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Builder;
|
||||
|
||||
internal sealed class DataStorageTypeRegistry
|
||||
{
|
||||
private readonly Dictionary<DataStorageObjectVersion, Type> _dsoRegistrations;
|
||||
|
||||
public DataStorageTypeRegistry(IEnumerable<DsoRegistration> dsoRegistrations)
|
||||
=> _dsoRegistrations = dsoRegistrations.ToDictionary(r => r.DsoVersion, r => r.DsoType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the registered type for the specified DSO version.
|
||||
/// </summary>
|
||||
/// <param name="dsoVersion">The DSO version to look up.</param>
|
||||
/// <returns>The registered type.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the DSO type is not registered.</exception>
|
||||
public Type Get(DataStorageObjectVersion dsoVersion) =>
|
||||
!_dsoRegistrations.TryGetValue(dsoVersion, out var registeredType)
|
||||
? throw new InvalidOperationException($"DsoType {dsoVersion.EntityType.Name} with version {dsoVersion.SchemaVersion} is not registered.")
|
||||
: registeredType;
|
||||
}
|
||||
10
storage/src/Storage/Internal/Builder/DsoRegistration.cs
Normal file
10
storage/src/Storage/Internal/Builder/DsoRegistration.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Builder;
|
||||
|
||||
public sealed class DsoRegistration(Type dsoType, DataStorageObjectVersion dsoVersion)
|
||||
{
|
||||
public Type DsoType { get; } = dsoType;
|
||||
public DataStorageObjectVersion DsoVersion { get; } = dsoVersion;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
#pragma warning disable IDE0130
|
||||
namespace Duende.Storage.Internal.Builder;
|
||||
#pragma warning restore IDE0130
|
||||
|
||||
public static class DsoRegistrationServiceCollectionExtensions
|
||||
{
|
||||
extension(IServiceCollection services)
|
||||
{
|
||||
public void AddDsoRegistration<TDso>() where TDso : IDataStorageObject
|
||||
{
|
||||
var dsoRegistration = new DsoRegistration(typeof(TDso), TDso.DsoVersion);
|
||||
var dsoRegistrationServiceDescriptor = new ServiceDescriptor(typeof(DsoRegistration), dsoRegistration);
|
||||
services.Add(dsoRegistrationServiceDescriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Builder;
|
||||
|
||||
public delegate Type GetRegisteredTypeForDso(DataStorageObjectVersion version);
|
||||
19
storage/src/Storage/Internal/CheckSchemaVersionResult.cs
Normal file
19
storage/src/Storage/Internal/CheckSchemaVersionResult.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
public sealed class CheckSchemaVersionResult
|
||||
{
|
||||
internal CheckSchemaVersionResult(uint currentVersion, uint requiredVersion)
|
||||
{
|
||||
CurrentVersion = currentVersion;
|
||||
RequiredVersion = requiredVersion;
|
||||
}
|
||||
|
||||
public uint CurrentVersion { get; }
|
||||
|
||||
public uint RequiredVersion { get; }
|
||||
|
||||
public bool IsCompatible => CurrentVersion == RequiredVersion;
|
||||
}
|
||||
35
storage/src/Storage/Internal/DataStorageKey.cs
Normal file
35
storage/src/Storage/Internal/DataStorageKey.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
public sealed class DataStorageKey
|
||||
{
|
||||
private DataStorageKey(DataStorageKeyVersion version, Guid value, string? keyJsonValue)
|
||||
{
|
||||
DskVersion = version;
|
||||
Value = value;
|
||||
KeyJsonValue = keyJsonValue;
|
||||
}
|
||||
|
||||
public DataStorageKeyVersion DskVersion { get; private set; }
|
||||
|
||||
public Guid Value { get; private set; }
|
||||
|
||||
public string? KeyJsonValue { get; private set; }
|
||||
|
||||
public static DataStorageKey Create<T>(T dsk) where T : IDataStorageKey
|
||||
{
|
||||
if (dsk is IGuidDataStorageKey guidDsk)
|
||||
{
|
||||
return new DataStorageKey(T.DskVersion, guidDsk.Value, null);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(dsk);
|
||||
var guid = DeterministicGuidGenerator.Create(json);
|
||||
|
||||
return new DataStorageKey(T.DskVersion, guid, json);
|
||||
}
|
||||
}
|
||||
24
storage/src/Storage/Internal/DataStorageKeyType.cs
Normal file
24
storage/src/Storage/Internal/DataStorageKeyType.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Globalization;
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// The type of DSK that's being stored.
|
||||
/// </summary>
|
||||
/// <param name="Id">A number representation for the DSK type.</param>
|
||||
/// <param name="Name">The name of the DSK type. This name is only used for display purposes and should never change. </param>
|
||||
public readonly record struct DataStorageKeyType(uint Id, string Name)
|
||||
{
|
||||
[Obsolete("Don't use this constructor")]
|
||||
public DataStorageKeyType() : this(0!, null!) => throw new InvalidOperationException("Cannot instantiate DSKType without parameters");
|
||||
|
||||
public static DataStorageKeyType BuildFrom(Enum @enum) =>
|
||||
new((uint)Convert.ToInt32(@enum, CultureInfo.InvariantCulture), @enum.ToString());
|
||||
|
||||
public static DataStorageKeyType FromEnum(Enum value) => BuildFrom(value);
|
||||
|
||||
public static implicit operator DataStorageKeyType(Enum value) => BuildFrom(value);
|
||||
}
|
||||
10
storage/src/Storage/Internal/DataStorageKeyVersion.cs
Normal file
10
storage/src/Storage/Internal/DataStorageKeyVersion.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
public sealed record DataStorageKeyVersion(DataStorageKeyType KeyType, uint SchemaVersion)
|
||||
{
|
||||
public override string ToString() => $"{KeyType.Name}({KeyType.Id}) v{SchemaVersion}";
|
||||
|
||||
}
|
||||
12
storage/src/Storage/Internal/DataStorageObjectVersion.cs
Normal file
12
storage/src/Storage/Internal/DataStorageObjectVersion.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a versioned DSO type.
|
||||
/// </summary>
|
||||
public sealed record DataStorageObjectVersion(EntityType EntityType, uint SchemaVersion)
|
||||
{
|
||||
public override string ToString() => $"{EntityType.Name}({EntityType.Id}) v{SchemaVersion}";
|
||||
}
|
||||
31
storage/src/Storage/Internal/DeterministicGuidGenerator.cs
Normal file
31
storage/src/Storage/Internal/DeterministicGuidGenerator.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
public static class DeterministicGuidGenerator
|
||||
{
|
||||
public static Guid Create(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
throw new ArgumentException("Value cannot be null or empty.", nameof(name));
|
||||
}
|
||||
//use MD5 hash to get a 16-byte hash of the string:
|
||||
|
||||
var bytes = Encoding.Default.GetBytes(name);
|
||||
|
||||
#pragma warning disable CA5351 // MD5 is used to produce a deterministic 128-bit hash for stable GUID generation from names, not for cryptographic security
|
||||
var hashBytes = MD5.HashData(bytes);
|
||||
#pragma warning restore CA5351
|
||||
|
||||
//generate a guid from the hash:
|
||||
|
||||
var hashGuid = new Guid(hashBytes);
|
||||
|
||||
return hashGuid;
|
||||
}
|
||||
}
|
||||
16
storage/src/Storage/Internal/EntityType.cs
Normal file
16
storage/src/Storage/Internal/EntityType.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// The type of document that's being stored.
|
||||
/// Each library defines its own entity types as static fields on the containing DSO class.
|
||||
/// </summary>
|
||||
/// <param name="Id">A number representation for the DSO type. Must be unique across all entity types.</param>
|
||||
/// <param name="Name">The name of the DSO type. Used for analytics and display purposes. Should never change once entities exist.</param>
|
||||
public readonly record struct EntityType(uint Id, string Name)
|
||||
{
|
||||
[Obsolete("Don't use this constructor")]
|
||||
public EntityType() : this(0, null!) => throw new InvalidOperationException("Cannot instantiate EntityType without parameters");
|
||||
}
|
||||
99
storage/src/Storage/Internal/Expiration.cs
Normal file
99
storage/src/Storage/Internal/Expiration.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an expiration policy for a store entity.
|
||||
/// Use <see cref="AtAbsolute"/> for a fixed point in time,
|
||||
/// <see cref="InRelative"/> for a duration from now,
|
||||
/// or <see cref="NoExpiration"/> to explicitly indicate no expiration.
|
||||
/// </summary>
|
||||
public abstract record Expiration
|
||||
{
|
||||
// Prevent external subclassing.
|
||||
private Expiration() { }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves this expiration to an absolute <see cref="DateTimeOffset"/> (UTC),
|
||||
/// or <c>null</c> if the entity should never expire.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">The time provider used to determine the current time for relative expirations.</param>
|
||||
/// <returns>The absolute expiration time, or <c>null</c> if the entity should never expire.</returns>
|
||||
public abstract DateTimeOffset? Resolve(TimeProvider timeProvider);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an expiration at a specific absolute point in time.
|
||||
/// </summary>
|
||||
/// <param name="expiresAt">The absolute expiration time. Must have <see cref="DateTimeOffset.Offset"/> of <see cref="TimeSpan.Zero"/> (UTC).</param>
|
||||
/// <returns>An <see cref="AbsoluteExpiration"/> instance.</returns>
|
||||
public static Expiration AtAbsolute(DateTimeOffset expiresAt) => new AbsoluteExpiration(expiresAt);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an expiration relative to the current time.
|
||||
/// </summary>
|
||||
/// <param name="lifetime">The duration from now until expiration. Must be strictly positive.</param>
|
||||
/// <returns>A <see cref="RelativeExpiration"/> instance.</returns>
|
||||
public static Expiration InRelative(TimeSpan lifetime) => new RelativeExpiration(lifetime);
|
||||
|
||||
/// <summary>
|
||||
/// A sentinel value indicating the entity should never expire.
|
||||
/// On Create, this means the entity lives forever.
|
||||
/// On Update, this explicitly clears any existing expiration.
|
||||
/// </summary>
|
||||
public static readonly Expiration NoExpiration = new NeverExpiration();
|
||||
|
||||
/// <summary>
|
||||
/// An expiration at a fixed absolute point in time (UTC).
|
||||
/// </summary>
|
||||
internal sealed record AbsoluteExpiration : Expiration
|
||||
{
|
||||
public DateTimeOffset ExpiresAt { get; }
|
||||
|
||||
public AbsoluteExpiration(DateTimeOffset expiresAt)
|
||||
{
|
||||
if (expiresAt.Offset != TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Expiration must be in UTC (Offset must be TimeSpan.Zero).",
|
||||
nameof(expiresAt));
|
||||
}
|
||||
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public override DateTimeOffset? Resolve(TimeProvider timeProvider) => ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An expiration relative to the current time.
|
||||
/// </summary>
|
||||
internal sealed record RelativeExpiration : Expiration
|
||||
{
|
||||
public TimeSpan Lifetime { get; }
|
||||
|
||||
public RelativeExpiration(TimeSpan lifetime)
|
||||
{
|
||||
if (lifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(lifetime),
|
||||
lifetime,
|
||||
"Lifetime must be strictly positive.");
|
||||
}
|
||||
|
||||
Lifetime = lifetime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset? Resolve(TimeProvider timeProvider) =>
|
||||
timeProvider.GetUtcNow() + Lifetime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sentinel indicating no expiration. Resolves to <c>null</c>.
|
||||
/// </summary>
|
||||
internal sealed record NeverExpiration : Expiration
|
||||
{
|
||||
public override DateTimeOffset? Resolve(TimeProvider timeProvider) => null;
|
||||
}
|
||||
}
|
||||
40
storage/src/Storage/Internal/FieldType.cs
Normal file
40
storage/src/Storage/Internal/FieldType.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of a field, corresponding to the typed columns in the search_values table.
|
||||
/// This ensures QueryFields reads from the correct column instead of checking the first non-null value.
|
||||
/// </summary>
|
||||
public enum FieldType
|
||||
{
|
||||
/// <summary>
|
||||
/// String field, stored in the string_value column.
|
||||
/// </summary>
|
||||
#pragma warning disable CA1720 // Identifier 'String' contains type name
|
||||
String,
|
||||
#pragma warning restore CA1720
|
||||
|
||||
/// <summary>
|
||||
/// Number field, stored in the number_value column.
|
||||
/// </summary>
|
||||
Number,
|
||||
|
||||
/// <summary>
|
||||
/// DateTime field, stored in the datetime_value column.
|
||||
/// </summary>
|
||||
DateTime,
|
||||
|
||||
/// <summary>
|
||||
/// Boolean field, stored in the boolean_value column.
|
||||
/// </summary>
|
||||
Boolean,
|
||||
|
||||
/// <summary>
|
||||
/// Guid field, stored in the guid_value column.
|
||||
/// </summary>
|
||||
#pragma warning disable CA1720 // Identifier 'Guid' contains type name
|
||||
Guid
|
||||
#pragma warning restore CA1720
|
||||
}
|
||||
18
storage/src/Storage/Internal/Filtering/ComparisonOperator.cs
Normal file
18
storage/src/Storage/Internal/Filtering/ComparisonOperator.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
public enum ComparisonOperator
|
||||
{
|
||||
Equal,
|
||||
NotEqual,
|
||||
Contains,
|
||||
StartsWith,
|
||||
EndsWith,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Present
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering.Expressions;
|
||||
|
||||
public sealed class AttributePathExpression(string path) : FilterExpression
|
||||
{
|
||||
public string Path { get; } = path ?? throw new ArgumentNullException(nameof(path));
|
||||
|
||||
public override string ToString() => Path;
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering.Expressions;
|
||||
|
||||
public sealed class ComparisonExpression(AttributePathExpression attributePath, ComparisonOperator op, object? value)
|
||||
: FilterExpression
|
||||
{
|
||||
public AttributePathExpression AttributePath { get; } = attributePath ?? throw new ArgumentNullException(nameof(attributePath));
|
||||
|
||||
public ComparisonOperator Operator { get; } = op;
|
||||
|
||||
public object? Value { get; } = value;
|
||||
|
||||
public override string ToString() => $"{AttributePath} {Operator.ToFilterString()} {Value}";
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering.Expressions;
|
||||
|
||||
public sealed class ComplexAttributeExpression(AttributePathExpression attributePath, FilterExpression filter)
|
||||
: FilterExpression
|
||||
{
|
||||
public AttributePathExpression AttributePath { get; }
|
||||
= attributePath ?? throw new ArgumentNullException(nameof(attributePath));
|
||||
|
||||
public FilterExpression Filter { get; }
|
||||
= filter ?? throw new ArgumentNullException(nameof(filter));
|
||||
|
||||
public override string ToString() => $"{AttributePath}[{Filter}]";
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering.Expressions;
|
||||
|
||||
public abstract class FilterExpression
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering.Expressions;
|
||||
|
||||
public sealed class LogicalExpression : FilterExpression
|
||||
{
|
||||
public LogicalOperator Operator { get; }
|
||||
|
||||
public FilterExpression Left { get; }
|
||||
|
||||
public FilterExpression? Right { get; }
|
||||
|
||||
public LogicalExpression(LogicalOperator op, FilterExpression left, FilterExpression? right = null)
|
||||
{
|
||||
Operator = op;
|
||||
Left = left ?? throw new ArgumentNullException(nameof(left));
|
||||
Right = right;
|
||||
|
||||
if (op != LogicalOperator.Not && right == null)
|
||||
{
|
||||
throw new ArgumentException($"{op} operator requires a right operand", nameof(right));
|
||||
}
|
||||
if (op == LogicalOperator.Not && right != null)
|
||||
{
|
||||
throw new ArgumentException("NOT operator should not have a right operand", nameof(right));
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
Operator switch
|
||||
{
|
||||
LogicalOperator.And => $"({Left} AND {Right})",
|
||||
LogicalOperator.Or => $"({Left} OR {Right})",
|
||||
LogicalOperator.Not => $"NOT ({Left})",
|
||||
_ => $"Unknown operator: {Operator}"
|
||||
};
|
||||
}
|
||||
370
storage/src/Storage/Internal/Filtering/FilterExpressionParser.cs
Normal file
370
storage/src/Storage/Internal/Filtering/FilterExpressionParser.cs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal.Filtering.Expressions;
|
||||
using Duende.Storage.Querying;
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
public static class FilterExpressionParser
|
||||
{
|
||||
public static FilterExpression Parse(string filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
throw new ArgumentException("Filter cannot be null or whitespace.", nameof(filter));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tokens = FilterLexer.Tokenize(filter);
|
||||
var parser = new Parser(tokens, filter);
|
||||
var expression = parser.ParseFilter();
|
||||
|
||||
if (!parser.IsAtEnd)
|
||||
{
|
||||
var current = parser.Peek();
|
||||
throw new FilterParseException(
|
||||
$"Unexpected token '{current.Value}' at position {current.Position}; expected end of input");
|
||||
}
|
||||
|
||||
return expression;
|
||||
}
|
||||
catch (Exception ex) when (ex is not FilterParseException and not ArgumentException)
|
||||
{
|
||||
throw new FilterParseException($"Invalid filter syntax: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryParse(string filter, out FilterExpression? expression)
|
||||
{
|
||||
try
|
||||
{
|
||||
expression = Parse(filter);
|
||||
return true;
|
||||
}
|
||||
#pragma warning disable CA1031 // TryParse is designed to catch all exceptions
|
||||
catch (Exception)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
expression = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Parser(List<LexToken> tokens, string input)
|
||||
{
|
||||
private const int MaxDepth = 100;
|
||||
private int _position;
|
||||
private int _depth;
|
||||
|
||||
internal bool IsAtEnd => _position >= tokens.Count;
|
||||
|
||||
internal LexToken Peek()
|
||||
{
|
||||
if (IsAtEnd)
|
||||
{
|
||||
return new LexToken(FilterToken.None, string.Empty, input.Length);
|
||||
}
|
||||
return tokens[_position];
|
||||
}
|
||||
|
||||
private LexToken Advance()
|
||||
{
|
||||
if (IsAtEnd)
|
||||
{
|
||||
throw new FilterParseException("Unexpected end of input");
|
||||
}
|
||||
return tokens[_position++];
|
||||
}
|
||||
|
||||
private LexToken Expect(FilterToken type)
|
||||
{
|
||||
var token = Peek();
|
||||
if (token.Type != type)
|
||||
{
|
||||
var expected = type.ToString();
|
||||
if (IsAtEnd)
|
||||
{
|
||||
throw new FilterParseException(
|
||||
$"Unexpected end of input; expected {expected}");
|
||||
}
|
||||
throw new FilterParseException(
|
||||
$"Unexpected token '{token.Value}' at position {token.Position}; expected {expected}");
|
||||
}
|
||||
return Advance();
|
||||
}
|
||||
|
||||
private bool Check(FilterToken type) => !IsAtEnd && Peek().Type == type;
|
||||
|
||||
internal FilterExpression ParseFilter() => ParseOrExpression();
|
||||
|
||||
private FilterExpression ParseOrExpression()
|
||||
{
|
||||
var left = ParseAndExpression();
|
||||
while (Check(FilterToken.Or))
|
||||
{
|
||||
_ = Advance();
|
||||
var right = ParseAndExpression();
|
||||
left = new LogicalExpression(LogicalOperator.Or, left, right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private FilterExpression ParseAndExpression()
|
||||
{
|
||||
var left = ParseUnaryExpression();
|
||||
while (Check(FilterToken.And))
|
||||
{
|
||||
_ = Advance();
|
||||
var right = ParseUnaryExpression();
|
||||
left = new LogicalExpression(LogicalOperator.And, left, right);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private FilterExpression ParseUnaryExpression()
|
||||
{
|
||||
if (Check(FilterToken.Not))
|
||||
{
|
||||
_ = Advance();
|
||||
var expr = ParsePrimaryExpression();
|
||||
return new LogicalExpression(LogicalOperator.Not, expr);
|
||||
}
|
||||
return ParsePrimaryExpression();
|
||||
}
|
||||
|
||||
private FilterExpression ParsePrimaryExpression()
|
||||
{
|
||||
if (Check(FilterToken.LParen))
|
||||
{
|
||||
return ParseGroupedExpression();
|
||||
}
|
||||
return ParseAttributeExpression();
|
||||
}
|
||||
|
||||
private FilterExpression ParseGroupedExpression()
|
||||
{
|
||||
_ = Expect(FilterToken.LParen);
|
||||
if (++_depth > MaxDepth)
|
||||
{
|
||||
throw new FilterParseException(
|
||||
$"Filter expression exceeds maximum nesting depth of {MaxDepth}");
|
||||
}
|
||||
try
|
||||
{
|
||||
var expr = ParseFilter();
|
||||
_ = Expect(FilterToken.RParen);
|
||||
return expr;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_depth--;
|
||||
}
|
||||
}
|
||||
|
||||
private FilterExpression ParseAttributeExpression()
|
||||
{
|
||||
var attrPath = ParseAttributePath();
|
||||
|
||||
// Complex attribute filter: emails[type eq "work"]
|
||||
if (Check(FilterToken.LBracket))
|
||||
{
|
||||
return ParseComplexFilter(attrPath);
|
||||
}
|
||||
|
||||
// Present operator: title pr
|
||||
if (Check(FilterToken.Pr))
|
||||
{
|
||||
_ = Advance();
|
||||
return new ComparisonExpression(attrPath, ComparisonOperator.Present, null);
|
||||
}
|
||||
|
||||
// Comparison: attrPath op value
|
||||
if (IsComparisonOperator(Peek().Type))
|
||||
{
|
||||
var op = ParseComparisonOperator();
|
||||
var value = ParseCompValue();
|
||||
return new ComparisonExpression(attrPath, op, value);
|
||||
}
|
||||
|
||||
var current = Peek();
|
||||
if (IsAtEnd)
|
||||
{
|
||||
throw new FilterParseException("Unexpected end of input; expected operator");
|
||||
}
|
||||
throw new FilterParseException(
|
||||
$"Unexpected token '{current.Value}' at position {current.Position}; expected operator");
|
||||
}
|
||||
|
||||
private ComplexAttributeExpression ParseComplexFilter(AttributePathExpression attrPath)
|
||||
{
|
||||
_ = Expect(FilterToken.LBracket);
|
||||
if (++_depth > MaxDepth)
|
||||
{
|
||||
throw new FilterParseException(
|
||||
$"Filter expression exceeds maximum nesting depth of {MaxDepth}");
|
||||
}
|
||||
try
|
||||
{
|
||||
var filter = ParseFilter();
|
||||
_ = Expect(FilterToken.RBracket);
|
||||
return new ComplexAttributeExpression(attrPath, filter);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_depth--;
|
||||
}
|
||||
}
|
||||
|
||||
private AttributePathExpression ParseAttributePath()
|
||||
{
|
||||
var first = Expect(FilterToken.Identifier).Value;
|
||||
|
||||
while (Check(FilterToken.Dot) || Check(FilterToken.Colon))
|
||||
{
|
||||
if (Check(FilterToken.Dot))
|
||||
{
|
||||
// Standard sub-attribute: attrName.subAttr
|
||||
_ = Advance();
|
||||
first += "." + Expect(FilterToken.Identifier).Value;
|
||||
}
|
||||
else // Colon
|
||||
{
|
||||
_ = Advance();
|
||||
if (Check(FilterToken.Identifier))
|
||||
{
|
||||
first += ":" + Advance().Value;
|
||||
}
|
||||
else if (Check(FilterToken.Number))
|
||||
{
|
||||
// Version segment in URI, e.g. "2" or "2.0" in "core:2.0:User"
|
||||
first += ":" + Advance().Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
var current = Peek();
|
||||
throw new FilterParseException(
|
||||
$"Unexpected token '{current.Value}' at position {current.Position}; expected identifier or number in attribute path");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AttributePathExpression(first);
|
||||
}
|
||||
|
||||
private static bool IsComparisonOperator(FilterToken type) =>
|
||||
type is FilterToken.Eq or FilterToken.Ne or FilterToken.Co or FilterToken.Sw
|
||||
or FilterToken.Ew or FilterToken.Gt or FilterToken.Ge or FilterToken.Lt
|
||||
or FilterToken.Le;
|
||||
|
||||
private ComparisonOperator ParseComparisonOperator()
|
||||
{
|
||||
var token = Advance();
|
||||
return token.Type switch
|
||||
{
|
||||
FilterToken.Eq => ComparisonOperator.Equal,
|
||||
FilterToken.Ne => ComparisonOperator.NotEqual,
|
||||
FilterToken.Co => ComparisonOperator.Contains,
|
||||
FilterToken.Sw => ComparisonOperator.StartsWith,
|
||||
FilterToken.Ew => ComparisonOperator.EndsWith,
|
||||
FilterToken.Gt => ComparisonOperator.GreaterThan,
|
||||
FilterToken.Ge => ComparisonOperator.GreaterThanOrEqual,
|
||||
FilterToken.Lt => ComparisonOperator.LessThan,
|
||||
FilterToken.Le => ComparisonOperator.LessThanOrEqual,
|
||||
_ => throw new FilterParseException(
|
||||
$"Unexpected token '{token.Value}' at position {token.Position}; expected comparison operator")
|
||||
};
|
||||
}
|
||||
|
||||
private object? ParseCompValue()
|
||||
{
|
||||
var token = Peek();
|
||||
switch (token.Type)
|
||||
{
|
||||
case FilterToken.StringLiteral:
|
||||
_ = Advance();
|
||||
return UnescapeString(token.Value);
|
||||
|
||||
case FilterToken.Number:
|
||||
_ = Advance();
|
||||
return ParseNumber(token.Value, token.Position);
|
||||
|
||||
case FilterToken.True:
|
||||
_ = Advance();
|
||||
return true;
|
||||
|
||||
case FilterToken.False:
|
||||
_ = Advance();
|
||||
return false;
|
||||
|
||||
case FilterToken.Null:
|
||||
_ = Advance();
|
||||
return null;
|
||||
|
||||
default:
|
||||
if (IsAtEnd)
|
||||
{
|
||||
throw new FilterParseException("Unexpected end of input; expected value");
|
||||
}
|
||||
throw new FilterParseException(
|
||||
$"Unexpected token '{token.Value}' at position {token.Position}; expected value");
|
||||
}
|
||||
}
|
||||
|
||||
private static string UnescapeString(string raw)
|
||||
{
|
||||
// Strip surrounding quotes
|
||||
var inner = raw[1..^1];
|
||||
|
||||
if (!inner.Contains('\\', StringComparison.Ordinal))
|
||||
{
|
||||
return inner;
|
||||
}
|
||||
|
||||
var result = new System.Text.StringBuilder(inner.Length);
|
||||
for (var i = 0; i < inner.Length; i++)
|
||||
{
|
||||
if (inner[i] == '\\' && i + 1 < inner.Length)
|
||||
{
|
||||
i++;
|
||||
_ = result.Append(inner[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = result.Append(inner[i]);
|
||||
}
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static object ParseNumber(string text, int position)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (text.Contains('.', StringComparison.Ordinal))
|
||||
{
|
||||
return double.Parse(text, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
if (int.TryParse(text, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var i))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
if (long.TryParse(text, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var l))
|
||||
{
|
||||
return l;
|
||||
}
|
||||
|
||||
throw new FormatException($"Cannot parse number: {text}");
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException)
|
||||
{
|
||||
throw new FilterParseException(
|
||||
$"Invalid number '{text}' at position {position}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
storage/src/Storage/Internal/Filtering/FilterLexer.cs
Normal file
156
storage/src/Storage/Internal/Filtering/FilterLexer.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Querying;
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
internal static class FilterLexer
|
||||
{
|
||||
public static List<LexToken> Tokenize(string input)
|
||||
{
|
||||
var tokens = new List<LexToken>();
|
||||
var position = 0;
|
||||
|
||||
while (position < input.Length)
|
||||
{
|
||||
var ch = input[position];
|
||||
|
||||
// Skip whitespace
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
position++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single-character punctuation
|
||||
switch (ch)
|
||||
{
|
||||
case '(':
|
||||
tokens.Add(new LexToken(FilterToken.LParen, "(", position));
|
||||
position++;
|
||||
continue;
|
||||
case ')':
|
||||
tokens.Add(new LexToken(FilterToken.RParen, ")", position));
|
||||
position++;
|
||||
continue;
|
||||
case '[':
|
||||
tokens.Add(new LexToken(FilterToken.LBracket, "[", position));
|
||||
position++;
|
||||
continue;
|
||||
case ']':
|
||||
tokens.Add(new LexToken(FilterToken.RBracket, "]", position));
|
||||
position++;
|
||||
continue;
|
||||
case '.':
|
||||
tokens.Add(new LexToken(FilterToken.Dot, ".", position));
|
||||
position++;
|
||||
continue;
|
||||
case ':':
|
||||
tokens.Add(new LexToken(FilterToken.Colon, ":", position));
|
||||
position++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// String literal
|
||||
if (ch == '"')
|
||||
{
|
||||
var start = position;
|
||||
position++; // skip opening quote
|
||||
while (position < input.Length)
|
||||
{
|
||||
if (input[position] == '\\')
|
||||
{
|
||||
position += 2; // skip escape sequence
|
||||
continue;
|
||||
}
|
||||
if (input[position] == '"')
|
||||
{
|
||||
break;
|
||||
}
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position >= input.Length)
|
||||
{
|
||||
throw new FilterParseException(
|
||||
$"Unterminated string literal at position {start}");
|
||||
}
|
||||
|
||||
position++; // skip closing quote
|
||||
var value = input[start..position];
|
||||
tokens.Add(new LexToken(FilterToken.StringLiteral, value, start));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Number (digit or - followed by digit)
|
||||
if (char.IsDigit(ch) || (ch == '-' && position + 1 < input.Length && char.IsDigit(input[position + 1])))
|
||||
{
|
||||
var start = position;
|
||||
if (ch == '-')
|
||||
{
|
||||
position++;
|
||||
}
|
||||
while (position < input.Length && char.IsDigit(input[position]))
|
||||
{
|
||||
position++;
|
||||
}
|
||||
// Optional decimal part
|
||||
if (position < input.Length && input[position] == '.' && position + 1 < input.Length && char.IsDigit(input[position + 1]))
|
||||
{
|
||||
position++; // skip dot
|
||||
while (position < input.Length && char.IsDigit(input[position]))
|
||||
{
|
||||
position++;
|
||||
}
|
||||
}
|
||||
var value = input[start..position];
|
||||
tokens.Add(new LexToken(FilterToken.Number, value, start));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier or keyword
|
||||
if (char.IsLetter(ch) || ch == '_')
|
||||
{
|
||||
var start = position;
|
||||
position++;
|
||||
while (position < input.Length && (char.IsLetterOrDigit(input[position]) || input[position] == '_' || input[position] == '-'))
|
||||
{
|
||||
position++;
|
||||
}
|
||||
var word = input[start..position];
|
||||
|
||||
// Check for keywords (case-insensitive)
|
||||
var tokenType = word.ToUpperInvariant() switch
|
||||
{
|
||||
"AND" => FilterToken.And,
|
||||
"OR" => FilterToken.Or,
|
||||
"NOT" => FilterToken.Not,
|
||||
"EQ" => FilterToken.Eq,
|
||||
"NE" => FilterToken.Ne,
|
||||
"CO" => FilterToken.Co,
|
||||
"SW" => FilterToken.Sw,
|
||||
"EW" => FilterToken.Ew,
|
||||
"PR" => FilterToken.Pr,
|
||||
"GT" => FilterToken.Gt,
|
||||
"GE" => FilterToken.Ge,
|
||||
"LT" => FilterToken.Lt,
|
||||
"LE" => FilterToken.Le,
|
||||
"TRUE" => FilterToken.True,
|
||||
"FALSE" => FilterToken.False,
|
||||
"NULL" => FilterToken.Null,
|
||||
_ => FilterToken.Identifier
|
||||
};
|
||||
|
||||
tokens.Add(new LexToken(tokenType, word, start));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unexpected character
|
||||
throw new FilterParseException(
|
||||
$"Unexpected character '{ch}' at position {position}");
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
34
storage/src/Storage/Internal/Filtering/FilterToken.cs
Normal file
34
storage/src/Storage/Internal/Filtering/FilterToken.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
internal enum FilterToken
|
||||
{
|
||||
None,
|
||||
Identifier,
|
||||
StringLiteral,
|
||||
Number,
|
||||
True,
|
||||
False,
|
||||
Null,
|
||||
Eq,
|
||||
Ne,
|
||||
Co,
|
||||
Sw,
|
||||
Ew,
|
||||
Pr,
|
||||
Gt,
|
||||
Ge,
|
||||
Lt,
|
||||
Le,
|
||||
And,
|
||||
Or,
|
||||
Not,
|
||||
LParen,
|
||||
RParen,
|
||||
LBracket,
|
||||
RBracket,
|
||||
Dot,
|
||||
Colon
|
||||
}
|
||||
222
storage/src/Storage/Internal/Filtering/FilterTranslator.cs
Normal file
222
storage/src/Storage/Internal/Filtering/FilterTranslator.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal.Filtering.Expressions;
|
||||
using Duende.Storage.Internal.Querying;
|
||||
using Duende.Storage.Internal.Querying.Expressions;
|
||||
using Duende.Storage.Internal.Querying.Fields;
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
public sealed class FilterTranslator
|
||||
{
|
||||
private readonly IScimAttributeTypeResolver _resolver;
|
||||
|
||||
public FilterTranslator(IScimAttributeTypeResolver resolver) =>
|
||||
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
|
||||
|
||||
public IQueryFilterExpression? Translate(string? filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var expression = FilterExpressionParser.Parse(filter);
|
||||
return Translate(expression);
|
||||
}
|
||||
|
||||
public IQueryFilterExpression Translate(FilterExpression expression)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(expression);
|
||||
|
||||
return expression switch
|
||||
{
|
||||
ComparisonExpression comparison => TranslateComparison(comparison),
|
||||
LogicalExpression logical => TranslateLogical(logical),
|
||||
ComplexAttributeExpression complex => TranslateComplexAttribute(complex),
|
||||
_ => throw new NotSupportedException($"Unsupported expression type: {expression.GetType().Name}")
|
||||
};
|
||||
}
|
||||
|
||||
private IQueryFilterExpression TranslateComparison(ComparisonExpression comparison)
|
||||
{
|
||||
var field = _resolver.ResolveField(comparison.AttributePath.Path);
|
||||
|
||||
return comparison.Operator switch
|
||||
{
|
||||
ComparisonOperator.Equal => TranslateEqual(field, comparison.Value),
|
||||
ComparisonOperator.NotEqual => comparison.Value is null
|
||||
? new PresentExpression(field)
|
||||
: new NotExpression(TranslateEqual(field, comparison.Value)),
|
||||
ComparisonOperator.Contains => TranslateStringOp(field, comparison.Value,
|
||||
static (sf, v) => new ContainsExpression(sf, v)),
|
||||
ComparisonOperator.StartsWith => TranslateStringOp(field, comparison.Value,
|
||||
static (sf, v) => new StartsWithExpression(sf, v)),
|
||||
ComparisonOperator.EndsWith => TranslateStringOp(field, comparison.Value,
|
||||
static (sf, v) => new EndsWithExpression(sf, v)),
|
||||
ComparisonOperator.GreaterThan => new GreaterThanExpression(field, ConvertValue(field, comparison.Value)),
|
||||
ComparisonOperator.GreaterThanOrEqual => new GreaterOrEqualExpression(field, ConvertValue(field, comparison.Value)),
|
||||
ComparisonOperator.LessThan => new LessThanExpression(field, ConvertValue(field, comparison.Value)),
|
||||
ComparisonOperator.LessThanOrEqual => new LessOrEqualExpression(field, ConvertValue(field, comparison.Value)),
|
||||
ComparisonOperator.Present => new PresentExpression(field),
|
||||
_ => throw new NotSupportedException($"Unsupported comparison operator: {comparison.Operator}")
|
||||
};
|
||||
}
|
||||
|
||||
private static IQueryFilterExpression TranslateEqual(Field field, object? value)
|
||||
{
|
||||
// title eq null → not present
|
||||
if (value is null)
|
||||
{
|
||||
return new NotExpression(new PresentExpression(field));
|
||||
}
|
||||
|
||||
// For string array fields, "eq" means "any element equals the value"
|
||||
if (field is StringArrayField arrayField && value is string strValue)
|
||||
{
|
||||
return new ArrayContainsExpression(arrayField, strValue.ToUpperInvariant());
|
||||
}
|
||||
|
||||
// For multi-valued fields resolved from schema (not StringArrayField), equality
|
||||
// works via the standard EqualExpression with IsMultiValued producing item_index >= 0
|
||||
return new EqualExpression(field, ConvertValue(field, value));
|
||||
}
|
||||
|
||||
private static IQueryFilterExpression TranslateStringOp(
|
||||
Field field,
|
||||
object? value,
|
||||
Func<StringField, string, IQueryFilterExpression> factory)
|
||||
{
|
||||
if (value is not string stringValue)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"String operator requires a string value, but got {value?.GetType().Name ?? "null"}");
|
||||
}
|
||||
|
||||
// For string array fields, string operations (co, sw, ew) fall back to exact element
|
||||
// matching via ArrayContainsExpression. True substring semantics on individual array
|
||||
// elements are not yet supported by the indexing model.
|
||||
if (field is StringArrayField arrayField)
|
||||
{
|
||||
return new ArrayContainsExpression(arrayField, stringValue.ToUpperInvariant());
|
||||
}
|
||||
|
||||
if (field is not StringField stringField)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"String operator cannot be applied to field '{field.Path}' of type {field.Type}");
|
||||
}
|
||||
|
||||
return factory(stringField, stringValue.ToUpperInvariant());
|
||||
}
|
||||
|
||||
private IQueryFilterExpression TranslateLogical(LogicalExpression logical) =>
|
||||
logical.Operator switch
|
||||
{
|
||||
LogicalOperator.And => new AndExpression(Translate(logical.Left), Translate(logical.Right!)),
|
||||
LogicalOperator.Or => new OrExpression(Translate(logical.Left), Translate(logical.Right!)),
|
||||
LogicalOperator.Not => new NotExpression(Translate(logical.Left)),
|
||||
_ => throw new NotSupportedException($"Unsupported logical operator: {logical.Operator}")
|
||||
};
|
||||
|
||||
private ArrayFilterExpression TranslateComplexAttribute(ComplexAttributeExpression complex)
|
||||
{
|
||||
var prefixedFilter = PrefixAttributePaths(complex.Filter, complex.AttributePath.Path);
|
||||
var scopedTranslator = new FilterTranslator(new ArrayElementResolver(_resolver, complex.AttributePath.Path));
|
||||
var innerFilter = scopedTranslator.Translate(prefixedFilter);
|
||||
return new ArrayFilterExpression(complex.AttributePath.Path, innerFilter);
|
||||
}
|
||||
|
||||
private static FilterExpression PrefixAttributePaths(FilterExpression expression, string prefix) =>
|
||||
expression switch
|
||||
{
|
||||
ComparisonExpression comparison => new ComparisonExpression(
|
||||
new AttributePathExpression($"{prefix}.{comparison.AttributePath.Path}"),
|
||||
comparison.Operator,
|
||||
comparison.Value),
|
||||
LogicalExpression logical => logical.Operator == LogicalOperator.Not
|
||||
? new LogicalExpression(LogicalOperator.Not, PrefixAttributePaths(logical.Left, prefix))
|
||||
: new LogicalExpression(logical.Operator,
|
||||
PrefixAttributePaths(logical.Left, prefix),
|
||||
PrefixAttributePaths(logical.Right!, prefix)),
|
||||
_ => expression
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a resolver to strip the array prefix from resolved field paths.
|
||||
/// Inside an array filter, fields are per-element and the SQL builder
|
||||
/// re-adds the array prefix when building the query.
|
||||
/// </summary>
|
||||
private sealed class ArrayElementResolver(IScimAttributeTypeResolver inner, string arrayPrefix) : IScimAttributeTypeResolver
|
||||
{
|
||||
public Field ResolveField(string attributePath)
|
||||
{
|
||||
var field = inner.ResolveField(attributePath);
|
||||
var strippedPath = StripPrefix(field.Path);
|
||||
return field switch
|
||||
{
|
||||
StringField => new StringField(strippedPath),
|
||||
NumberField => new NumberField(strippedPath),
|
||||
BooleanField => new BooleanField(strippedPath),
|
||||
DateTimeField => new DateTimeField(strippedPath),
|
||||
GuidField => new GuidField(strippedPath),
|
||||
_ => field
|
||||
};
|
||||
}
|
||||
|
||||
private string StripPrefix(string path)
|
||||
{
|
||||
var prefix = arrayPrefix.ToUpperInvariant() + ".";
|
||||
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
? path[prefix.Length..]
|
||||
: path;
|
||||
}
|
||||
}
|
||||
|
||||
private static object ConvertValue(Field field, object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(value), "Cannot convert null value for comparison");
|
||||
}
|
||||
|
||||
return field.Type switch
|
||||
{
|
||||
FieldType.String => (value is string s
|
||||
? s
|
||||
: Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)!).ToUpperInvariant(),
|
||||
|
||||
FieldType.Number => value switch
|
||||
{
|
||||
decimal d => d,
|
||||
int i => (decimal)i,
|
||||
long l => (decimal)l,
|
||||
double d => decimal.Parse(d.ToString(System.Globalization.CultureInfo.InvariantCulture), System.Globalization.CultureInfo.InvariantCulture),
|
||||
float f => decimal.Parse(f.ToString(System.Globalization.CultureInfo.InvariantCulture), System.Globalization.CultureInfo.InvariantCulture),
|
||||
string s => decimal.Parse(s, System.Globalization.CultureInfo.InvariantCulture),
|
||||
_ => Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture)
|
||||
},
|
||||
|
||||
FieldType.Boolean => value switch
|
||||
{
|
||||
bool b => b,
|
||||
string s => bool.Parse(s),
|
||||
_ => Convert.ToBoolean(value, System.Globalization.CultureInfo.InvariantCulture)
|
||||
},
|
||||
|
||||
FieldType.DateTime => value switch
|
||||
{
|
||||
DateTimeOffset dto => dto,
|
||||
DateTime dt => new DateTimeOffset(dt, TimeSpan.Zero),
|
||||
// Use AssumeUniversal so date-only strings (e.g. "2024-01-01") are interpreted as UTC,
|
||||
// matching how DateOnly values are stored as DateTimeOffset at midnight UTC.
|
||||
string s => DateTimeOffset.Parse(s, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal),
|
||||
_ => throw new InvalidOperationException($"Cannot convert {value.GetType().Name} to DateTimeOffset")
|
||||
},
|
||||
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
}
|
||||
11
storage/src/Storage/Internal/Filtering/LexToken.cs
Normal file
11
storage/src/Storage/Internal/Filtering/LexToken.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
internal readonly record struct LexToken(FilterToken Type, string Value, int Position)
|
||||
{
|
||||
[Obsolete("Don't use parameterless constructor")]
|
||||
public LexToken() : this(default, string.Empty, 0) =>
|
||||
throw new InvalidOperationException("Don't use parameterless constructor");
|
||||
}
|
||||
11
storage/src/Storage/Internal/Filtering/LogicalOperator.cs
Normal file
11
storage/src/Storage/Internal/Filtering/LogicalOperator.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
public enum LogicalOperator
|
||||
{
|
||||
And,
|
||||
Or,
|
||||
Not
|
||||
}
|
||||
32
storage/src/Storage/Internal/Filtering/OperatorExtensions.cs
Normal file
32
storage/src/Storage/Internal/Filtering/OperatorExtensions.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal.Filtering;
|
||||
|
||||
internal static class OperatorExtensions
|
||||
{
|
||||
public static string ToFilterString(this ComparisonOperator op) =>
|
||||
op switch
|
||||
{
|
||||
ComparisonOperator.Equal => "eq",
|
||||
ComparisonOperator.NotEqual => "ne",
|
||||
ComparisonOperator.Contains => "co",
|
||||
ComparisonOperator.StartsWith => "sw",
|
||||
ComparisonOperator.EndsWith => "ew",
|
||||
ComparisonOperator.GreaterThan => "gt",
|
||||
ComparisonOperator.GreaterThanOrEqual => "ge",
|
||||
ComparisonOperator.LessThan => "lt",
|
||||
ComparisonOperator.LessThanOrEqual => "le",
|
||||
ComparisonOperator.Present => "pr",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(op))
|
||||
};
|
||||
|
||||
public static string ToFilterString(this LogicalOperator op) =>
|
||||
op switch
|
||||
{
|
||||
LogicalOperator.And => "and",
|
||||
LogicalOperator.Or => "or",
|
||||
LogicalOperator.Not => "not",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(op))
|
||||
};
|
||||
}
|
||||
16
storage/src/Storage/Internal/IDataStorageKey.cs
Normal file
16
storage/src/Storage/Internal/IDataStorageKey.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for DSK (Data Store Key) types.
|
||||
/// </summary>
|
||||
public interface IDataStorageKey
|
||||
{
|
||||
/// <summary>
|
||||
/// The version of the DSK. DSK's (once released) must be immutable, so we need
|
||||
/// to keep track of versions.
|
||||
/// </summary>
|
||||
static abstract DataStorageKeyVersion DskVersion { get; }
|
||||
}
|
||||
12
storage/src/Storage/Internal/IDataStorageObject.cs
Normal file
12
storage/src/Storage/Internal/IDataStorageObject.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Data Storage Object (DSO).
|
||||
/// </summary>
|
||||
public interface IDataStorageObject
|
||||
{
|
||||
static abstract DataStorageObjectVersion DsoVersion { get; }
|
||||
}
|
||||
13
storage/src/Storage/Internal/IGuidDataStorageKey.cs
Normal file
13
storage/src/Storage/Internal/IGuidDataStorageKey.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a dsk as only being a guid value. This means it won't be stored as serialized json
|
||||
/// but only uses the guid value. IE: UserSubjectId, which is already a Guid.
|
||||
/// </summary>
|
||||
public interface IGuidDataStorageKey : IDataStorageKey
|
||||
{
|
||||
Guid Value { get; }
|
||||
}
|
||||
9
storage/src/Storage/Internal/IPooledStore.cs
Normal file
9
storage/src/Storage/Internal/IPooledStore.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
public interface IPooledStore : IDatabaseSchema
|
||||
{
|
||||
IStore OpenPool(PoolId poolId);
|
||||
}
|
||||
21
storage/src/Storage/Internal/IScimAttributeTypeResolver.cs
Normal file
21
storage/src/Storage/Internal/IScimAttributeTypeResolver.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal.Querying.Fields;
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves SCIM attribute paths to Faro Field types.
|
||||
/// Implementations define the schema-specific mapping for a resource type (User, Group, etc.).
|
||||
/// </summary>
|
||||
public interface IScimAttributeTypeResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a SCIM attribute path to its corresponding Faro Field.
|
||||
/// </summary>
|
||||
/// <param name="attributePath">The SCIM attribute path (e.g., "userName", "name.familyName", "emails.value").</param>
|
||||
/// <returns>The corresponding Faro Field instance.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown when the attribute path is not recognized.</exception>
|
||||
Field ResolveField(string attributePath);
|
||||
}
|
||||
280
storage/src/Storage/Internal/IStore.cs
Normal file
280
storage/src/Storage/Internal/IStore.cs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Duende.Storage.Internal.Operations;
|
||||
using Duende.Storage.Internal.Outbox;
|
||||
using Duende.Storage.Internal.Querying;
|
||||
using Duende.Storage.Internal.Querying.Fields;
|
||||
using Duende.Storage.Internal.Querying.SearchFields;
|
||||
using Duende.Storage.Internal.Querying.Sorting;
|
||||
using Duende.Storage.Pagination;
|
||||
using Duende.Storage.Querying;
|
||||
using OutboxEventId = Duende.Storage.Internal.Outbox.OutboxEventId;
|
||||
|
||||
namespace Duende.Storage.Internal;
|
||||
|
||||
public interface IStore
|
||||
{
|
||||
internal void SetPoolId(PoolId poolId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new entity in the store, writing outbox events atomically.
|
||||
/// If the resolved expiration is already in the past, the entity is not stored (noop) and
|
||||
/// <see cref="CreateResult.Success"/> is returned.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDso">The type of the DSO to create.</typeparam>
|
||||
/// <param name="id">The unique identifier for the entity.</param>
|
||||
/// <param name="value">The DSO value to store.</param>
|
||||
/// <param name="keys">The collection of keys for alternate lookups.</param>
|
||||
/// <param name="searchFieldCollection">Optional search field values that can be used for querying.</param>
|
||||
/// <param name="expiration">The expiration policy for the entity.
|
||||
/// Use <see cref="Expiration.NoExpiration"/> for entities that should never expire.</param>
|
||||
/// <param name="outboxEvents">Outbox events to INSERT atomically within the same transaction.
|
||||
/// Pass <c>[]</c> when no events are needed.
|
||||
/// Silently ignored when the outbox is not enabled.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The result of the create operation.</returns>
|
||||
public Task<CreateResult> CreateAsync<TDso>(
|
||||
UuidV7 id,
|
||||
TDso value,
|
||||
IReadOnlyCollection<DataStorageKey> keys,
|
||||
SearchFieldCollection searchFieldCollection,
|
||||
Expiration expiration,
|
||||
IReadOnlyList<OutboxEvent> outboxEvents,
|
||||
Ct ct) where TDso : IDataStorageObject;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve an entity from the store by its unique identifier.
|
||||
/// </summary>
|
||||
/// <param name="type">The type of entity to retrieve.</param>
|
||||
/// <param name="id">The unique identifier of the entity.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A <see cref="StoreGetResult"/> indicating whether the entity was found and, if so, its value and version.</returns>
|
||||
public Task<StoreGetResult> TryReadAsync(
|
||||
EntityType type,
|
||||
UuidV7 id,
|
||||
Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve an entity from the store by an alternate key.
|
||||
/// </summary>
|
||||
/// <param name="type">The type of entity to retrieve.</param>
|
||||
/// <param name="key">The alternate key to look up.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A <see cref="StoreGetResult"/> indicating whether the entity was found and, if so, its value and version.</returns>
|
||||
public Task<StoreGetResult> TryReadAsync(
|
||||
EntityType type,
|
||||
DataStorageKey key,
|
||||
Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves multiple entities from the store by their unique identifiers.
|
||||
/// Only entities that exist in the store are included in the result;
|
||||
/// IDs that do not match any stored entity are silently omitted.
|
||||
/// </summary>
|
||||
/// <param name="entityType">The type of entities to retrieve.</param>
|
||||
/// <param name="ids">The unique identifiers of the entities to retrieve. Using a set ensures no duplicate IDs are requested.</param>
|
||||
/// <param name="maximum">The maximum number of IDs allowed in a single request.
|
||||
/// An <see cref="InvalidOperationException"/> is thrown if the count of <paramref name="ids"/> exceeds this value.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A list of <see cref="StoreGetResult"/> containing only the entities that were found.
|
||||
/// The result list may contain fewer items than <paramref name="ids"/> if some IDs
|
||||
/// do not exist in the store. Returns an empty list if no matching entities are found
|
||||
/// or if <paramref name="ids"/> is empty.
|
||||
/// </returns>
|
||||
public Task<IReadOnlyList<StoreGetResult>> TryReadManyAsync(
|
||||
EntityType entityType,
|
||||
IReadOnlySet<UuidV7> ids,
|
||||
int maximum,
|
||||
Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing entity in the store, writing outbox events atomically.
|
||||
/// The expiration value is stored as provided. Expired records remain visible on reads
|
||||
/// (TTL is best-effort) and are removed by the background purge job.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDso">The type of the DSO to update.</typeparam>
|
||||
/// <param name="id">The unique identifier for the entity.</param>
|
||||
/// <param name="dso">The new DSO value to store.</param>
|
||||
/// <param name="expectedEntityVersion">The expected version for optimistic concurrency control.</param>
|
||||
/// <param name="keys">The collection of keys for alternate lookups.</param>
|
||||
/// <param name="searchFieldCollection">Optional search field values that can be used for querying.
|
||||
/// These values replace any existing search fields for this entity.</param>
|
||||
/// <param name="expiration">The expiration policy for the entity.
|
||||
/// <c>null</c> means "don't change existing expiration".
|
||||
/// <see cref="Expiration.NoExpiration"/> explicitly clears any existing expiration.
|
||||
/// <see cref="Expiration.AtAbsolute"/> or <see cref="Expiration.InRelative"/> sets a new expiration.</param>
|
||||
/// <param name="outboxEvents">Outbox events to INSERT atomically within the same transaction.
|
||||
/// Pass <c>[]</c> when no events are needed.
|
||||
/// Silently ignored when the outbox is not enabled.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The result of the update operation.</returns>
|
||||
public Task<UpdateResult> UpdateAsync<TDso>(
|
||||
UuidV7 id,
|
||||
TDso dso,
|
||||
int expectedEntityVersion,
|
||||
IReadOnlyCollection<DataStorageKey> keys,
|
||||
SearchFieldCollection searchFieldCollection,
|
||||
Expiration? expiration,
|
||||
IReadOnlyList<OutboxEvent> outboxEvents,
|
||||
Ct ct) where TDso : IDataStorageObject;
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an entity from the store by its unique identifier, writing outbox events atomically.
|
||||
/// </summary>
|
||||
/// <param name="entityType">The type of entity to delete.</param>
|
||||
/// <param name="id">The unique identifier of the entity to delete.</param>
|
||||
/// <param name="outboxEvents">Outbox events to INSERT atomically within the same transaction.
|
||||
/// Pass <c>[]</c> when no events are needed.
|
||||
/// Silently ignored when the outbox is not enabled.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the delete operation.</returns>
|
||||
public Task<DeleteResult> DeleteAsync(EntityType entityType, UuidV7 id, IReadOnlyList<OutboxEvent> outboxEvents, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes an entity from the store by an alternate key, writing outbox events atomically.
|
||||
/// </summary>
|
||||
/// <param name="entityType">The type of entity to delete.</param>
|
||||
/// <param name="key">The alternate key identifying the entity to delete.</param>
|
||||
/// <param name="outboxEvents">Outbox events to INSERT atomically within the same transaction.
|
||||
/// Pass <c>[]</c> when no events are needed.
|
||||
/// Silently ignored when the outbox is not enabled.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The result of the delete operation.</returns>
|
||||
public Task<DeleteResult> DeleteAsync(EntityType entityType, DataStorageKey key, IReadOnlyList<OutboxEvent> outboxEvents, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a link between two entities, writing outbox events atomically.
|
||||
/// The link is unique per (LinkType, LeftId, RightId).
|
||||
/// No referential integrity check — the entities do not need to exist.
|
||||
/// </summary>
|
||||
/// <param name="definition">The link definition describing the relationship schema.</param>
|
||||
/// <param name="leftEntityId">The ID of the left-side entity.</param>
|
||||
/// <param name="rightEntityId">The ID of the right-side entity.</param>
|
||||
/// <param name="outboxEvents">Outbox events to INSERT atomically within the same transaction.
|
||||
/// Pass <c>[]</c> when no events are needed.
|
||||
/// Silently ignored when the outbox is not enabled.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns><see cref="LinkResult.Success"/> if created, <see cref="LinkResult.AlreadyLinked"/> if the exact link already exists.</returns>
|
||||
Task<LinkResult> LinkAsync(LinkDefinition definition, UuidV7 leftEntityId, UuidV7 rightEntityId, IReadOnlyList<OutboxEvent> outboxEvents, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a link between two entities, writing outbox events atomically.
|
||||
/// Returns success even if the link did not exist (idempotent).
|
||||
/// </summary>
|
||||
/// <param name="definition">The link definition describing the relationship schema.</param>
|
||||
/// <param name="leftEntityId">The ID of the left-side entity.</param>
|
||||
/// <param name="rightEntityId">The ID of the right-side entity.</param>
|
||||
/// <param name="outboxEvents">Outbox events to INSERT atomically within the same transaction.
|
||||
/// Pass <c>[]</c> when no events are needed.
|
||||
/// Silently ignored when the outbox is not enabled.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Always returns <see cref="UnlinkResult.Success"/>.</returns>
|
||||
Task<UnlinkResult> UnlinkAsync(LinkDefinition definition, UuidV7 leftEntityId, UuidV7 rightEntityId, IReadOnlyList<OutboxEvent> outboxEvents, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Purges a batch of expired entities atomically. Within a single transaction:
|
||||
/// locks expired rows, deletes associated entity links, and deletes expired entities.
|
||||
/// </summary>
|
||||
/// <param name="batchSize">Max expired entities to process (1–1000).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Number of entities deleted.</returns>
|
||||
Task<int> PurgeExpiredAsync(int batchSize, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Executes multiple operations atomically in a single transaction, writing outbox events atomically.
|
||||
/// Operations are executed in order until completion or first failure.
|
||||
/// If any operation fails, execution stops immediately and all operations are rolled back.
|
||||
/// Outbox events are INSERTed after all operations succeed but before the transaction is committed.
|
||||
/// </summary>
|
||||
/// <param name="operations">The operations to execute.</param>
|
||||
/// <param name="outboxEvents">Outbox events to INSERT atomically within the same transaction,
|
||||
/// after all operations succeed. Pass <c>[]</c> when no events are needed.
|
||||
/// Silently ignored when the outbox is not enabled.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>
|
||||
/// A BatchResult indicating overall success/failure with per-operation outcomes.
|
||||
/// When Success is false, Results contains outcomes only for operations attempted
|
||||
/// (up to and including the failed operation). No changes have been persisted.
|
||||
/// </returns>
|
||||
Task<BatchResult> ExecuteBatchAsync(IReadOnlyList<IStoreOperation> operations, IReadOnlyList<OutboxEvent> outboxEvents, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the oldest page of outbox events for a specific subscriber, ordered by sequence number.
|
||||
/// The intended usage pattern is: get oldest page → process events → delete them → repeat.
|
||||
/// </summary>
|
||||
/// <param name="subscriberName">The subscriber name to filter events for.</param>
|
||||
/// <param name="count">The maximum number of events to return.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A page of outbox events and whether more events exist beyond this page.</returns>
|
||||
Task<OutboxEventsPage> GetOutboxEventsForSubscriberAsync(SubscriberName subscriberName, int count, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes outbox events by their message IDs (the store-generated per-row identifiers).
|
||||
/// </summary>
|
||||
/// <param name="ids">The message IDs of the outbox events to delete.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task DeleteOutboxEventsAsync(IReadOnlyList<OutboxEventId> ids, Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Queries entities with the specified pagination strategy.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDso">The type of the DSO to query.</typeparam>
|
||||
/// <param name="entityType">The entity type to query.</param>
|
||||
/// <param name="filter">The filter expression to apply.</param>
|
||||
/// <param name="sort">Sort parameter. Use SortParameter.Empty if no sorting is required.</param>
|
||||
/// <param name="dataRange">The pagination strategy (page, offset, or continuation token).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A query result containing items and pagination metadata.</returns>
|
||||
Task<QueryResult<MetadataEnvelope<TDso>>> QueryAsync<TDso>(
|
||||
EntityType entityType,
|
||||
IQueryExpression filter,
|
||||
SortParameter sort,
|
||||
DataRange dataRange,
|
||||
Ct ct) where TDso : IDataStorageObject;
|
||||
|
||||
/// <summary>
|
||||
/// Queries for specific field values with the specified pagination strategy.
|
||||
/// Returns projected results instead of full entities.
|
||||
/// </summary>
|
||||
/// <param name="entityType">The entity type to query.</param>
|
||||
/// <param name="fields">The fields to project in the results.</param>
|
||||
/// <param name="filter">The filter expression to apply.</param>
|
||||
/// <param name="sort">Sort parameter. Use SortParameter.Empty if no sorting is required.</param>
|
||||
/// <param name="dataRange">The pagination strategy (page, offset, or continuation token).</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A query result containing projected field values and pagination metadata.</returns>
|
||||
Task<QueryResult<ProjectedResult>> QueryFieldsAsync(
|
||||
EntityType entityType,
|
||||
IReadOnlyCollection<Field> fields,
|
||||
IQueryExpression filter,
|
||||
SortParameter sort,
|
||||
DataRange dataRange,
|
||||
Ct ct);
|
||||
|
||||
/// <summary>
|
||||
/// Queries for entities reachable via a chain of link traversals.
|
||||
/// </summary>
|
||||
/// <typeparam name="TDso">The source entity type to return.</typeparam>
|
||||
/// <param name="query">The link query descriptor describing the traversal chain and filter.</param>
|
||||
/// <param name="dataRange">The pagination strategy.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A query result containing entities of the source type reachable via the link chain.</returns>
|
||||
Task<QueryResult<MetadataEnvelope<TDso>>> QueryLinksAsync<TDso>(
|
||||
LinkQueryDescriptor query,
|
||||
DataRange dataRange,
|
||||
Ct ct) where TDso : IDataStorageObject;
|
||||
|
||||
/// <summary>
|
||||
/// Counts entities matching the specified filter, or all entities if no filter is provided.
|
||||
/// </summary>
|
||||
/// <param name="entityType">The entity type to count.</param>
|
||||
/// <param name="filter">Optional filter expression. If null, counts all entities of the specified type.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The number of matching entities.</returns>
|
||||
Task<long> CountAsync(
|
||||
EntityType entityType,
|
||||
IQueryExpression? filter,
|
||||
Ct ct);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue