Publish - 2026-05-21 20:13:30 UTC

This commit is contained in:
Duende Bot 2026-05-21 20:14:02 +00:00
parent adae547d62
commit 1dbf96b919
316 changed files with 35735 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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))]

View 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;
}
}

View 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>

View 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;
}
}

View 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();

View 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);
}
}

View 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);
}

View 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);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);

View 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 Serveroptimized
/// 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 Serveroptimized 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 Serveroptimized 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);
}

View 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

View 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";
}

View file

@ -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>>());
}
}

View 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>

View 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);
}
}

View 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.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);
}

View 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);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);

View 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
$$;

View 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";
}

View file

@ -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>>());
}
}

View 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>

View 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);
}
}

View 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();
}
}

View 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)
};
}
}

File diff suppressed because it is too large Load diff

View file

@ -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);

View 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;

View 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; }
}

View file

@ -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>>());
}

View 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>

View 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);
}

View 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;
}
}

View 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);
}

View 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;
}
}

View file

@ -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();
}

View file

@ -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);
}

View file

@ -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();
}

View file

@ -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);
}

View 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; }
}

View file

@ -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();
}

View file

@ -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);
}

View 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}");
}
}
}

View 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);
}

View file

@ -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();
}

View file

@ -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);
}

View 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();
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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}")
};
}

View file

@ -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()!
};
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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; }
}

View 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,
}

View 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);
}

View 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; }
}

View file

@ -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;
}

View 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;
}

View file

@ -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);
}
}
}

View file

@ -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);

View 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;
}

View 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);
}
}

View 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);
}

View 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}";
}

View 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}";
}

View 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;
}
}

View 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");
}

View 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;
}
}

View 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
}

View 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
}

View 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.Expressions;
public sealed class AttributePathExpression(string path) : FilterExpression
{
public string Path { get; } = path ?? throw new ArgumentNullException(nameof(path));
public override string ToString() => Path;
}

View 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.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}";
}

View 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.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}]";
}

View file

@ -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
{
}

View file

@ -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}"
};
}

View 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);
}
}
}
}

View 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;
}
}

View 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
}

View 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
};
}
}

View 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");
}

View 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
}

View 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))
};
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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);
}

View 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);
}

View 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 (11000).</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