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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<ItemGroup>
<PackageVersion Include="Azure.Communication.Sms" Version="1.1.0-beta.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper.StrongName" Version="2.1.66" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.Elm" Version="0.2.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.0-preview-25107-01" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.1" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageReference Include="OrchardCore.DisplayManagement" />
<PackageReference Include="OrchardCoreContrib.Abstractions" />
<PackageReference Include="OrchardCoreContrib.Infrastructure.Abstractions" />
<PackageReference Include="Dapper.StrongName" />
</ItemGroup>

</Project>
89 changes: 87 additions & 2 deletions src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
using OrchardCoreContrib.Infrastructure;
using OrchardCoreContrib.ViewCount.Handlers;
using OrchardCoreContrib.ViewCount.Models;
using System.Data.Common;
using YesSql;
using Dapper;

namespace OrchardCoreContrib.ViewCount.Services;

Expand All @@ -17,6 +20,7 @@ namespace OrchardCoreContrib.ViewCount.Services;
/// </remarks>
public class ViewCountService(
IContentManager contentManager,
ISession session,
IEnumerable<IViewCountContentHandler> handlers,
ILogger<ViewCountService> logger) : IViewCountService
{
Expand All @@ -42,12 +46,93 @@ public async Task ViewAsync(ContentItem contentItem)

await handlers.InvokeAsync((handler, context) => handler.ViewingAsync(context), context, logger);

contentItem.Content.ViewCountPart.Count = ++count;
// Perform atomic increment directly in the database to avoid race conditions
await IncrementViewCountAtomicallyAsync(contentItem.ContentItemId);

await contentManager.UpdateAsync(contentItem);
// Reload the content item to get the updated count
contentItem = await contentManager.GetAsync(contentItem.ContentItemId);
viewCountPart = contentItem?.As<ViewCountPart>();

if (viewCountPart != null)
{
count = viewCountPart.Count;
}

context = new ViewCountContentContext(contentItem, count);

await handlers.InvokeAsync((handler, context) => handler.ViewedAsync(context), context, logger);
}

private async Task IncrementViewCountAtomicallyAsync(string contentItemId)
{
var dialect = session.Store.Configuration.SqlDialect;
var tablePrefix = session.Store.Configuration.TablePrefix;
var schema = session.Store.Configuration.Schema;
var documentTable = $"{tablePrefix}{nameof(ContentItem)}Document";

string sql;

// Use database-specific JSON update functions for atomic increment
if (dialect.Name == "SqlServer")
{
sql = $@"
UPDATE {dialect.QuoteForTableName(documentTable, schema)}
SET {dialect.QuoteForColumnName("Content")} = JSON_MODIFY(
{dialect.QuoteForColumnName("Content")},
'$.ViewCountPart.Count',
CAST(ISNULL(JSON_VALUE({dialect.QuoteForColumnName("Content")}, '$.ViewCountPart.Count'), '0') AS INT) + 1
)
WHERE JSON_VALUE({dialect.QuoteForColumnName("Content")}, '$.ContentItemId') = @ContentItemId";
}
else if (dialect.Name == "Sqlite")
{
sql = $@"
UPDATE {dialect.QuoteForTableName(documentTable, schema)}
SET {dialect.QuoteForColumnName("Content")} = json_set(
{dialect.QuoteForColumnName("Content")},
'$.ViewCountPart.Count',
CAST(COALESCE(json_extract({dialect.QuoteForColumnName("Content")}, '$.ViewCountPart.Count'), 0) AS INTEGER) + 1
)
WHERE json_extract({dialect.QuoteForColumnName("Content")}, '$.ContentItemId') = @ContentItemId";
}
else if (dialect.Name == "MySql")
{
sql = $@"
UPDATE {dialect.QuoteForTableName(documentTable, schema)}
SET {dialect.QuoteForColumnName("Content")} = JSON_SET(
{dialect.QuoteForColumnName("Content")},
'$.ViewCountPart.Count',
CAST(COALESCE(JSON_EXTRACT({dialect.QuoteForColumnName("Content")}, '$.ViewCountPart.Count'), 0) AS UNSIGNED) + 1
)
WHERE JSON_EXTRACT({dialect.QuoteForColumnName("Content")}, '$.ContentItemId') = @ContentItemId";
}
else if (dialect.Name == "PostgreSql")
{
sql = $@"
UPDATE {dialect.QuoteForTableName(documentTable, schema)}
SET {dialect.QuoteForColumnName("Content")} = jsonb_set(
{dialect.QuoteForColumnName("Content")},
'{{ViewCountPart,Count}}',
to_jsonb(COALESCE(({dialect.QuoteForColumnName("Content")}#>>'{{ViewCountPart,Count}}')::int, 0) + 1)
)
WHERE {dialect.QuoteForColumnName("Content")}->>'ContentItemId' = @ContentItemId";
}
else
{
logger.LogError("Unsupported SQL dialect '{DialectName}' for atomic view count updates. Supported dialects are: SqlServer, Sqlite, MySql, PostgreSql.", dialect.Name);
throw new NotSupportedException($"The SQL dialect '{dialect.Name}' is not supported for atomic view count updates.");
}

// Use existing session connection managed by YesSql.
// Do not dispose the connection as YesSql manages its lifecycle within the session's transaction scope.
var connection = await session.CreateConnectionAsync();

var commandDefinition = new CommandDefinition(
commandText: sql,
parameters: new { ContentItemId = contentItemId },
transaction: session.CurrentTransaction
);

await connection.ExecuteAsync(commandDefinition);
}
}
Loading