diff --git a/Directory.Packages.props b/Directory.Packages.props
index 438cccb..8ba51e8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,6 +5,7 @@
+
diff --git a/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj b/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj
index 98c6865..75f05e8 100644
--- a/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj
+++ b/src/OrchardCoreContrib.ViewCount/OrchardCoreContrib.ViewCount.csproj
@@ -37,6 +37,7 @@
+
diff --git a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs
index 644891f..4df03c5 100644
--- a/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs
+++ b/src/OrchardCoreContrib.ViewCount/Services/ViewCountService.cs
@@ -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;
@@ -17,6 +20,7 @@ namespace OrchardCoreContrib.ViewCount.Services;
///
public class ViewCountService(
IContentManager contentManager,
+ ISession session,
IEnumerable handlers,
ILogger logger) : IViewCountService
{
@@ -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();
+
+ 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);
+ }
}