PostgreSql üzerinde çalışması için çalışma

This commit is contained in:
Sedat Öztürk 2026-05-25 17:31:54 +03:00
parent 0b5eb3d978
commit 01e19ef26f
47 changed files with 10764 additions and 8303 deletions

View file

@ -15,7 +15,7 @@ public static class SettingsConsts
public const string FormNamePrefix = "Setting_";
public const string DefaultDatabaseProvider = DatabaseProvider.SqlServer;
public const string DefaultDatabaseProvider = DatabaseProvider.PostgreSql;
public static class DatabaseProvider
{

View file

@ -4,7 +4,10 @@ public class DatabaseTableDto
{
public string SchemaName { get; set; }
public string TableName { get; set; }
public string FullName => $"{SchemaName}.{TableName}";
public string DataSourceType { get; set; }
public string FullName => DataSourceType == "Postgresql"
? $"\"{SchemaName}\".\"{TableName}\""
: $"[{SchemaName}].[{TableName}]";
}
public class DatabaseColumnDto

View file

@ -22,6 +22,7 @@ public interface ISqlObjectManagerAppService : IApplicationService
// Database Metadata Operations
Task<List<DatabaseColumnDto>> GetTableColumnsAsync(string dataSourceCode, string schemaName, string tableName);
Task<string> GetTableCreateScriptAsync(string dataSourceCode, string schemaName, string tableName);
/// <summary>
/// Gets the SQL definition/body of a native SQL Server object (Stored Procedure, View, or Function)

View file

@ -9,7 +9,10 @@ public class SqlNativeObjectDto
{
public string SchemaName { get; set; } = "dbo";
public string ObjectName { get; set; } = "";
public string FullName => $"[{SchemaName}].[{ObjectName}]";
public string DataSourceType { get; set; } = "";
public string FullName => DataSourceType == "Postgresql"
? $"\"{SchemaName}\".\"{ObjectName}\""
: $"[{SchemaName}].[{ObjectName}]";
}
/// <summary>

View file

@ -13,6 +13,8 @@ using Microsoft.Extensions.Logging;
using Volo.Abp.Application.Services;
using Volo.Abp.MultiTenancy;
using System.Text.RegularExpressions;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.Queries;
namespace Sozsoft.SqlQueryManager.Application;
@ -43,6 +45,7 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
private readonly ISqlExecutorService _sqlExecutorService;
private readonly ISqlTemplateProvider _templateProvider;
private readonly IDataSourceManager _dataSourceManager;
private readonly ICurrentTenant _currentTenant;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHostEnvironment _hostEnvironment;
@ -51,6 +54,7 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
public SqlObjectManagerAppService(
ISqlExecutorService sqlExecutorService,
ISqlTemplateProvider templateProvider,
IDataSourceManager dataSourceManager,
ICurrentTenant currentTenant,
IHttpContextAccessor httpContextAccessor,
IHostEnvironment hostEnvironment,
@ -58,6 +62,7 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
{
_sqlExecutorService = sqlExecutorService;
_templateProvider = templateProvider;
_dataSourceManager = dataSourceManager;
_currentTenant = currentTenant;
_httpContextAccessor = httpContextAccessor;
_hostEnvironment = hostEnvironment;
@ -90,11 +95,12 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
{
ValidateTenantAccess();
var result = new SqlObjectExplorerDto();
var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode);
result.Tables = await GetTablesAsync(dataSourceCode);
result.Views = await GetNativeObjectsAsync(dataSourceCode, "V");
result.StoredProcedures = await GetNativeObjectsAsync(dataSourceCode, "P");
result.Functions = await GetNativeObjectsAsync(dataSourceCode, "FN", "IF", "TF");
result.Tables = await GetTablesAsync(dataSourceCode, dataSourceType);
result.Views = await GetNativeObjectsAsync(dataSourceCode, dataSourceType, "V");
result.StoredProcedures = await GetNativeObjectsAsync(dataSourceCode, dataSourceType, "P");
result.Functions = await GetNativeObjectsAsync(dataSourceCode, dataSourceType, "FN", "IF", "TF");
result.Templates = _templateProvider.GetAvailableQueryTemplates()
.Select(t => new SqlTemplateDto
@ -109,20 +115,25 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
return result;
}
private async Task<List<SqlNativeObjectDto>> GetNativeObjectsAsync(string dataSourceCode, params string[] objectTypes)
private async Task<DataSourceTypeEnum> GetDataSourceTypeAsync(string dataSourceCode)
{
var typeList = string.Join(",", objectTypes.Select(t => $"'{t}'"));
var query = $@"
SELECT
SCHEMA_NAME(o.schema_id) AS SchemaName,
o.name AS ObjectName
FROM
sys.objects o
WHERE
o.type IN ({typeList})
AND o.is_ms_shipped = 0
ORDER BY
SCHEMA_NAME(o.schema_id), o.name";
var dataSource = await _dataSourceManager.GetDataSourceAsync(_currentTenant.IsAvailable, dataSourceCode);
if (dataSource == null)
{
throw new Volo.Abp.UserFriendlyException($"Data source '{dataSourceCode}' was not found.");
}
return dataSource.DataSourceType;
}
private async Task<List<SqlNativeObjectDto>> GetNativeObjectsAsync(
string dataSourceCode,
DataSourceTypeEnum dataSourceType,
params string[] objectTypes)
{
var query = dataSourceType == DataSourceTypeEnum.Postgresql
? BuildPostgreSqlNativeObjectsQuery(objectTypes)
: BuildSqlServerNativeObjectsQuery(objectTypes);
var result = await _sqlExecutorService.ExecuteQueryAsync(query, dataSourceCode);
@ -136,8 +147,9 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
{
objects.Add(new SqlNativeObjectDto
{
SchemaName = dict["SchemaName"]?.ToString() ?? "dbo",
ObjectName = dict["ObjectName"]?.ToString() ?? ""
SchemaName = GetDictionaryValue(dict, "SchemaName")?.ToString() ?? GetDefaultSchemaName(dataSourceType),
ObjectName = GetDictionaryValue(dict, "ObjectName")?.ToString() ?? "",
DataSourceType = dataSourceType.ToString()
});
}
}
@ -146,9 +158,73 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
return objects;
}
private async Task<List<DatabaseTableDto>> GetTablesAsync(string dataSourceCode)
private static string BuildSqlServerNativeObjectsQuery(params string[] objectTypes)
{
var query = @"
var typeList = string.Join(",", objectTypes.Select(t => $"'{t}'"));
return $@"
SELECT
SCHEMA_NAME(o.schema_id) AS SchemaName,
o.name AS ObjectName
FROM
sys.objects o
WHERE
o.type IN ({typeList})
AND o.is_ms_shipped = 0
ORDER BY
SCHEMA_NAME(o.schema_id), o.name";
}
private static string BuildPostgreSqlNativeObjectsQuery(params string[] objectTypes)
{
var wantsViews = objectTypes.Contains("V");
var wantsProcedures = objectTypes.Contains("P");
var wantsFunctions = objectTypes.Any(t => t is "FN" or "IF" or "TF");
if (wantsViews)
{
return @"
SELECT
table_schema AS ""SchemaName"",
table_name AS ""ObjectName""
FROM information_schema.views
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name";
}
var proKinds = new List<string>();
if (wantsProcedures)
{
proKinds.Add("'p'");
}
if (wantsFunctions)
{
proKinds.Add("'f'");
}
return $@"
SELECT
n.nspname AS ""SchemaName"",
p.proname AS ""ObjectName""
FROM pg_proc p
INNER JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND p.prokind IN ({string.Join(",", proKinds)})
ORDER BY n.nspname, p.proname";
}
private async Task<List<DatabaseTableDto>> GetTablesAsync(string dataSourceCode, DataSourceTypeEnum dataSourceType)
{
var query = dataSourceType == DataSourceTypeEnum.Postgresql
? @"
SELECT
table_schema AS ""SchemaName"",
table_name AS ""TableName""
FROM information_schema.tables
WHERE table_type = 'BASE TABLE'
AND table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY table_schema, table_name"
: @"
SELECT
SCHEMA_NAME(t.schema_id) AS SchemaName,
t.name AS TableName
@ -171,8 +247,9 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
{
tables.Add(new DatabaseTableDto
{
SchemaName = dict["SchemaName"]?.ToString() ?? "dbo",
TableName = dict["TableName"]?.ToString() ?? ""
SchemaName = GetDictionaryValue(dict, "SchemaName")?.ToString() ?? GetDefaultSchemaName(dataSourceType),
TableName = GetDictionaryValue(dict, "TableName")?.ToString() ?? "",
DataSourceType = dataSourceType.ToString()
});
}
}
@ -229,12 +306,9 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
public async Task<string> GetNativeObjectDefinitionAsync(string dataSourceCode, string schemaName, string objectName)
{
ValidateTenantAccess();
var query = @"
SELECT OBJECT_DEFINITION(OBJECT_ID(@ObjectName)) AS Definition";
var fullObjectName = $"[{schemaName}].[{objectName}]";
var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode);
var result = await _sqlExecutorService.ExecuteQueryAsync(
query.Replace("@ObjectName", $"'{fullObjectName}'"),
BuildNativeObjectDefinitionQuery(dataSourceType, schemaName, objectName),
dataSourceCode);
if (result.Success && result.Data != null)
@ -243,15 +317,18 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
if (dataList.Count > 0)
{
var row = dataList[0] as IDictionary<string, object>;
if (row != null && row.ContainsKey("Definition"))
if (row != null)
{
var definition = row["Definition"]?.ToString() ?? string.Empty;
var definition = GetDictionaryValue(row, "Definition")?.ToString() ?? string.Empty;
// Always open object script as CREATE OR ALTER in editor.
definition = Regex.Replace(
definition,
@"^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?",
"CREATE OR ALTER ",
RegexOptions.IgnoreCase);
if (dataSourceType == DataSourceTypeEnum.Mssql)
{
definition = Regex.Replace(
definition,
@"^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?",
"CREATE OR ALTER ",
RegexOptions.IgnoreCase);
}
return definition;
}
}
@ -260,10 +337,59 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
return string.Empty;
}
private static string BuildNativeObjectDefinitionQuery(
DataSourceTypeEnum dataSourceType,
string schemaName,
string objectName)
{
if (dataSourceType == DataSourceTypeEnum.Postgresql)
{
var schema = ToSqlLiteral(schemaName);
var name = ToSqlLiteral(objectName);
return $@"
SELECT ""Definition""
FROM (
SELECT pg_get_viewdef(c.oid, true) AS ""Definition""
FROM pg_class c
INNER JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = {schema}
AND c.relname = {name}
AND c.relkind IN ('v', 'm')
UNION ALL
SELECT pg_get_functiondef(p.oid) AS ""Definition""
FROM pg_proc p
INNER JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = {schema}
AND p.proname = {name}
AND p.prokind IN ('f', 'p')
) d
LIMIT 1";
}
var fullObjectName = $"[{schemaName.Replace("]", "]]")}].[{objectName.Replace("]", "]]")}]";
return $@"
SELECT OBJECT_DEFINITION(OBJECT_ID({ToSqlLiteral(fullObjectName)})) AS Definition";
}
public async Task<List<DatabaseColumnDto>> GetTableColumnsAsync(string dataSourceCode, string schemaName, string tableName)
{
ValidateTenantAccess();
var query = $@"
var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode);
var query = dataSourceType == DataSourceTypeEnum.Postgresql
? $@"
SELECT
column_name AS ""ColumnName"",
COALESCE(NULLIF(udt_name, ''), data_type) AS ""DataType"",
CASE WHEN is_nullable = 'YES' THEN TRUE ELSE FALSE END AS ""IsNullable"",
character_maximum_length AS ""MaxLength""
FROM information_schema.columns
WHERE table_schema = {ToSqlLiteral(schemaName)}
AND table_name = {ToSqlLiteral(tableName)}
ORDER BY ordinal_position"
: $@"
SELECT
c.name AS ColumnName,
TYPE_NAME(c.user_type_id) AS DataType,
@ -274,8 +400,8 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
INNER JOIN sys.tables t ON c.object_id = t.object_id
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
WHERE
s.name = '{schemaName}'
AND t.name = '{tableName}'
s.name = {ToSqlLiteral(schemaName)}
AND t.name = {ToSqlLiteral(tableName)}
ORDER BY
c.column_id";
@ -291,10 +417,10 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
{
columns.Add(new DatabaseColumnDto
{
ColumnName = dict["ColumnName"]?.ToString() ?? "",
DataType = dict["DataType"]?.ToString() ?? "",
IsNullable = dict["IsNullable"] is bool b && b,
MaxLength = dict["MaxLength"] != null ? int.Parse(dict["MaxLength"].ToString()) : null
ColumnName = GetDictionaryValue(dict, "ColumnName")?.ToString() ?? "",
DataType = GetDictionaryValue(dict, "DataType")?.ToString() ?? "",
IsNullable = ToBoolean(GetDictionaryValue(dict, "IsNullable")),
MaxLength = ToNullableInt(GetDictionaryValue(dict, "MaxLength"))
});
}
}
@ -303,6 +429,218 @@ public class SqlObjectManagerAppService : ApplicationService, ISqlObjectManagerA
return columns;
}
[HttpGet("api/app/sql-object-manager/table-create-script")]
public async Task<string> GetTableCreateScriptAsync(string dataSourceCode, string schemaName, string tableName)
{
ValidateTenantAccess();
var dataSourceType = await GetDataSourceTypeAsync(dataSourceCode);
var result = await _sqlExecutorService.ExecuteQueryAsync(
BuildTableCreateScriptQuery(dataSourceType, schemaName, tableName),
dataSourceCode);
if (!result.Success || result.Data == null)
{
return string.Empty;
}
var row = result.Data.FirstOrDefault() as IDictionary<string, object>;
return GetDictionaryValue(row, "Script")?.ToString() ?? string.Empty;
}
private static string BuildTableCreateScriptQuery(
DataSourceTypeEnum dataSourceType,
string schemaName,
string tableName)
{
if (dataSourceType == DataSourceTypeEnum.Postgresql)
{
return BuildPostgreSqlTableCreateScriptQuery(schemaName, tableName);
}
return BuildSqlServerTableCreateScriptQuery(schemaName, tableName);
}
private static string BuildSqlServerTableCreateScriptQuery(string schemaName, string tableName)
{
var fullName = $"[{schemaName.Replace("]", "]]")}].[{tableName.Replace("]", "]]")}]";
var escapedFullName = ToSqlLiteral(fullName);
return $@"
DECLARE @ObjectId INT = OBJECT_ID({escapedFullName});
IF @ObjectId IS NULL
BEGIN
SELECT CAST('' AS NVARCHAR(MAX)) AS Script;
RETURN;
END;
;WITH cols AS
(
SELECT
c.column_id,
' ' + QUOTENAME(c.name) + ' ' +
CASE
WHEN t.name IN ('varchar', 'char', 'varbinary', 'binary') THEN
t.name + '(' + CASE WHEN c.max_length = -1 THEN 'MAX' ELSE CAST(c.max_length AS VARCHAR(10)) END + ')'
WHEN t.name IN ('nvarchar', 'nchar') THEN
t.name + '(' + CASE WHEN c.max_length = -1 THEN 'MAX' ELSE CAST(c.max_length / 2 AS VARCHAR(10)) END + ')'
WHEN t.name IN ('decimal', 'numeric') THEN
t.name + '(' + CAST(c.precision AS VARCHAR(10)) + ',' + CAST(c.scale AS VARCHAR(10)) + ')'
WHEN t.name IN ('datetime2', 'datetimeoffset', 'time') THEN
t.name + '(' + CAST(c.scale AS VARCHAR(10)) + ')'
ELSE t.name
END +
CASE
WHEN ic.object_id IS NOT NULL
THEN ' IDENTITY(' + CAST(ic.seed_value AS VARCHAR(30)) + ',' + CAST(ic.increment_value AS VARCHAR(30)) + ')'
ELSE ''
END +
CASE WHEN c.is_nullable = 1 THEN ' NULL' ELSE ' NOT NULL' END +
ISNULL(' DEFAULT ' + dc.definition, '') AS line
FROM sys.columns c
INNER JOIN sys.types t ON c.user_type_id = t.user_type_id
LEFT JOIN sys.identity_columns ic ON c.object_id = ic.object_id AND c.column_id = ic.column_id
LEFT JOIN sys.default_constraints dc ON c.default_object_id = dc.object_id
WHERE c.object_id = @ObjectId
),
pk AS
(
SELECT
' CONSTRAINT ' + QUOTENAME(k.name) + ' PRIMARY KEY ' +
CASE WHEN i.type = 1 THEN 'CLUSTERED' ELSE 'NONCLUSTERED' END +
CHAR(13) + CHAR(10) + ' (' + CHAR(13) + CHAR(10) +
(
SELECT ' ' + QUOTENAME(c.name) + CASE WHEN ic.is_descending_key = 1 THEN ' DESC' ELSE ' ASC' END + CHAR(13) + CHAR(10)
FROM sys.index_columns ic
INNER JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id
WHERE ic.object_id = i.object_id
AND ic.index_id = i.index_id
AND ic.is_included_column = 0
ORDER BY ic.key_ordinal
FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)') +
' )' AS line
FROM sys.key_constraints k
INNER JOIN sys.indexes i ON k.parent_object_id = i.object_id AND k.unique_index_id = i.index_id
WHERE k.parent_object_id = @ObjectId
AND k.type = 'PK'
)
SELECT
'IF OBJECT_ID(N''{fullName.Replace("'", "''")}'', ''U'') IS NULL' + CHAR(13) + CHAR(10) +
'BEGIN' + CHAR(13) + CHAR(10) +
' CREATE TABLE {fullName}' + CHAR(13) + CHAR(10) +
' (' + CHAR(13) + CHAR(10) +
STUFF(
(
SELECT ',' + CHAR(13) + CHAR(10) + line
FROM cols
ORDER BY column_id
FOR XML PATH(''), TYPE
).value('.', 'NVARCHAR(MAX)')
,1,3,'') +
ISNULL(
(
SELECT ',' + CHAR(13) + CHAR(10) + line
FROM pk
),
''
) + CHAR(13) + CHAR(10) + ' )' + CHAR(13) + CHAR(10) + 'END' + CHAR(13) + CHAR(10) + 'GO' AS Script;";
}
private static string BuildPostgreSqlTableCreateScriptQuery(string schemaName, string tableName)
{
return $@"
WITH cols AS (
SELECT
c.ordinal_position,
' ' || quote_ident(c.column_name) || ' ' ||
CASE
WHEN c.data_type = 'character varying' THEN 'varchar(' || c.character_maximum_length || ')'
WHEN c.data_type = 'character' THEN 'char(' || c.character_maximum_length || ')'
WHEN c.data_type = 'numeric' AND c.numeric_precision IS NOT NULL THEN 'numeric(' || c.numeric_precision || ',' || c.numeric_scale || ')'
WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name
ELSE c.data_type
END ||
CASE WHEN c.is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END ||
COALESCE(' DEFAULT ' || c.column_default, '') AS line
FROM information_schema.columns c
WHERE c.table_schema = {ToSqlLiteral(schemaName)}
AND c.table_name = {ToSqlLiteral(tableName)}
),
pk AS (
SELECT
' CONSTRAINT ' || quote_ident(tc.constraint_name) || ' PRIMARY KEY (' ||
string_agg(quote_ident(kcu.column_name), ', ' ORDER BY kcu.ordinal_position) || ')' AS line
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
AND tc.table_name = kcu.table_name
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = {ToSqlLiteral(schemaName)}
AND tc.table_name = {ToSqlLiteral(tableName)}
GROUP BY tc.constraint_name
)
SELECT
'CREATE TABLE IF NOT EXISTS ' || quote_ident({ToSqlLiteral(schemaName)}) || '.' || quote_ident({ToSqlLiteral(tableName)}) || E'\n(\n' ||
string_agg(line, E',\n' ORDER BY sort_order) ||
E'\n);' AS ""Script""
FROM (
SELECT ordinal_position AS sort_order, line FROM cols
UNION ALL
SELECT 100000 AS sort_order, line FROM pk
) s;";
}
private static object GetDictionaryValue(IDictionary<string, object> row, string key)
{
if (row == null)
{
return null;
}
if (row.TryGetValue(key, out var value))
{
return value;
}
var match = row.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.OrdinalIgnoreCase));
return string.IsNullOrEmpty(match.Key) ? null : match.Value;
}
private static string GetDefaultSchemaName(DataSourceTypeEnum dataSourceType)
{
return dataSourceType == DataSourceTypeEnum.Postgresql ? "public" : "dbo";
}
private static string ToSqlLiteral(string value)
{
return $"'{(value ?? string.Empty).Replace("'", "''")}'";
}
private static bool ToBoolean(object value)
{
return value switch
{
bool boolValue => boolValue,
short shortValue => shortValue != 0,
int intValue => intValue != 0,
long longValue => longValue != 0,
_ => bool.TryParse(value?.ToString(), out var parsed) && parsed
};
}
private static int? ToNullableInt(object value)
{
if (value == null)
{
return null;
}
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
}
private SqlQueryExecutionResultDto MapExecutionResult(SqlExecutionResult result, bool isDeployed = false)
{
return new SqlQueryExecutionResultDto

View file

@ -5,6 +5,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Sozsoft.Platform.DynamicData;
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.Queries;
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp;
@ -38,14 +39,25 @@ public class SqlExecutorService : DomainService, ISqlExecutorService
.WithData("DataSourceCode", dataSourceCode);
}
// Get appropriate repository based on database type
// For now, using MS SQL Server repository
var repository = _serviceProvider.GetKeyedService<IDynamicDataRepository>("Ms");
var repositoryKey = dataSource.DataSourceType switch
{
DataSourceTypeEnum.Mssql => "Ms",
DataSourceTypeEnum.Postgresql => "Pg",
_ => null
};
if (repositoryKey == null)
{
throw new BusinessException("SqlQueryManager:DataSourceTypeNotSupported")
.WithData("DatabaseType", dataSource.DataSourceType);
}
var repository = _serviceProvider.GetKeyedService<IDynamicDataRepository>(repositoryKey);
if (repository == null)
{
throw new BusinessException("SqlQueryManager:RepositoryNotFound")
.WithData("DatabaseType", "Ms");
.WithData("DatabaseType", repositoryKey);
}
return repository;

View file

@ -8,16 +8,16 @@ public class AnnouncementDto : FullAuditedEntityDto<Guid>
{
public Guid? TenantId { get; set; }
public string Title { get; set; }
public string Excerpt { get; set; }
public string Content { get; set; }
public string ImageUrl { get; set; }
public string Category { get; set; }
public string Title { get; set; } = string.Empty;
public string Excerpt { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string ImageUrl { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public Guid? UserId { get; set; }
public UserInfoViewModel User { get; set; }
public UserInfoViewModel? User { get; set; }
public DateTime PublishDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public bool IsPinned { get; set; }
public int ViewCount { get; set; }
public string Attachments { get; set; }
public string Attachments { get; set; } = string.Empty;
}

View file

@ -9,7 +9,7 @@ public class SocialPostDto : FullAuditedEntityDto<Guid>
{
public Guid? UserId { get; set; }
public UserInfoViewModel? User { get; set; }
public string Content { get; set; }
public string Content { get; set; } = string.Empty;
public int LikeCount { get; set; }
public bool IsLiked { get; set; }
@ -17,14 +17,14 @@ public class SocialPostDto : FullAuditedEntityDto<Guid>
public SocialLocationDto? Location { get; set; }
public SocialMediaDto? Media { get; set; }
public List<SocialCommentDto> Comments { get; set; }
public List<SocialLikeDto> Likes { get; set; }
public List<SocialCommentDto> Comments { get; set; } = [];
public List<SocialLikeDto> Likes { get; set; } = [];
}
public class SocialLocationDto : FullAuditedEntityDto<Guid>
{
public Guid SocialPostId { get; set; }
public string Name { get; set; }
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public double? Lat { get; set; }
public double? Lng { get; set; }
@ -34,8 +34,8 @@ public class SocialLocationDto : FullAuditedEntityDto<Guid>
public class SocialMediaDto : FullAuditedEntityDto<Guid>
{
public Guid SocialPostId { get; set; }
public string Type { get; set; } // image | video | poll
public string[] Urls { get; set; }
public string Type { get; set; } = string.Empty; // image | video | poll
public string[] Urls { get; set; } = [];
// Poll Fields
public string? PollQuestion { get; set; }
@ -43,13 +43,13 @@ public class SocialMediaDto : FullAuditedEntityDto<Guid>
public DateTime? PollEndsAt { get; set; }
public string? PollUserVoteId { get; set; }
public List<SocialPollOptionDto> PollOptions { get; set; }
public List<SocialPollOptionDto> PollOptions { get; set; } = [];
}
public class SocialPollOptionDto : FullAuditedEntityDto<Guid>
{
public Guid SocialMediaId { get; set; }
public string Text { get; set; }
public string Text { get; set; } = string.Empty;
public int Votes { get; set; }
}
@ -58,7 +58,7 @@ public class SocialCommentDto : FullAuditedEntityDto<Guid>
public Guid SocialPostId { get; set; }
public Guid? UserId { get; set; }
public UserInfoViewModel? User { get; set; }
public string Content { get; set; }
public string Content { get; set; } = string.Empty;
}
public class SocialLikeDto : FullAuditedEntityDto<Guid>

View file

@ -123,6 +123,22 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
.MapDepartmentAndJobPositionAssignments(departmentDict, jobPositionDict);
}
private async Task<UserInfoViewModel> GetDashboardFallbackUserAsync(
IReadOnlyDictionary<Guid, string> departmentDict,
IReadOnlyDictionary<Guid, JobPosition> jobPositionDict)
{
var normalizedAdmin = PlatformConsts.AbpIdentity.User.AdminEmailDefaultValue;
var user = await _identityUserRepository.FindByNormalizedUserNameAsync(normalizedAdmin)
?? await _identityUserRepository.FindByNormalizedEmailAsync(normalizedAdmin);
if (user == null && CurrentUser.Id.HasValue)
{
user = await _identityUserRepository.FindAsync(CurrentUser.Id.Value);
}
return user != null ? MapUserInfoViewModel(user, departmentDict, jobPositionDict) : null;
}
private async Task<List<EventDto>> GetUpcomingEventsAsync()
{
var queryable = await _eventRepository
@ -166,6 +182,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
}
var (departmentDict, jobPositionDict) = await GetUserLookupDictionariesAsync();
var fallbackUser = await GetDashboardFallbackUserAsync(departmentDict, jobPositionDict);
var users = await _identityUserRepository.GetListAsync();
var userDict = users
@ -178,7 +195,11 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
var result = new List<EventDto>();
foreach (var evt in events)
{
if (!evt.UserId.HasValue || !userDict.TryGetValue(evt.UserId.Value, out var user))
var user = evt.UserId.HasValue && userDict.TryGetValue(evt.UserId.Value, out var eventUser)
? eventUser
: fallbackUser;
if (user == null)
continue;
var commentDtos = new List<EventCommentDto>();
@ -346,17 +367,22 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
var announcementDtos = new List<AnnouncementDto>();
var (departmentDict, jobPositionDict) = await GetUserLookupDictionariesAsync();
var fallbackUser = await GetDashboardFallbackUserAsync(departmentDict, jobPositionDict);
foreach (var announcement in announcements)
{
var dto = ObjectMapper.Map<Announcement, AnnouncementDto>(announcement);
var user = await _identityUserRepository.FindAsync(announcement.UserId ?? Guid.Empty);
if (user != null)
if (announcement.UserId.HasValue)
{
dto.User = MapUserInfoViewModel(user, departmentDict, jobPositionDict);
var user = await _identityUserRepository.FindAsync(announcement.UserId.Value);
if (user != null)
{
dto.User = MapUserInfoViewModel(user, departmentDict, jobPositionDict);
}
}
dto.User ??= fallbackUser;
announcementDtos.Add(dto);
}
@ -433,8 +459,8 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
// Collect all unique user IDs to resolve in a single query
var userIds = dtos
.Select(p => p.UserId)
.Union(dtos.SelectMany(p => p.Comments.Select(c => c.UserId)))
.Union(dtos.SelectMany(p => p.Likes.Select(l => l.UserId)))
.Union(dtos.SelectMany(p => (p.Comments ?? []).Select(c => c.UserId)))
.Union(dtos.SelectMany(p => (p.Likes ?? []).Select(l => l.UserId)))
.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct()
@ -454,11 +480,11 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
if (dto.UserId.HasValue && userMap.TryGetValue(dto.UserId.Value, out var postUser))
dto.User = postUser;
foreach (var comment in dto.Comments)
foreach (var comment in dto.Comments ?? [])
if (comment.UserId.HasValue && userMap.TryGetValue(comment.UserId.Value, out var commentUser))
comment.User = commentUser;
foreach (var like in dto.Likes)
foreach (var like in dto.Likes ?? [])
if (like.UserId.HasValue && userMap.TryGetValue(like.UserId.Value, out var likeUser))
like.User = likeUser;
}
@ -467,7 +493,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
foreach (var dto in dtos)
{
dto.IsOwnPost = dto.UserId == CurrentUser.Id;
dto.IsLiked = dto.Likes.Any(l => l.UserId == CurrentUser.Id);
dto.IsLiked = dto.Likes?.Any(l => l.UserId == CurrentUser.Id) == true;
}
await EnrichPollOptionsAsync(dtos);

View file

@ -6,6 +6,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Events;
using static Sozsoft.Settings.SettingsConsts;
namespace Sozsoft.Platform.DbMigrator;
@ -13,6 +14,8 @@ class Program
{
static async Task Main(string[] args)
{
ConfigurePostgreSqlCompatibility();
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
@ -30,6 +33,14 @@ class Program
await CreateHostBuilder(args).RunConsoleAsync();
}
private static void ConfigurePostgreSqlCompatibility()
{
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
{
System.AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.AddAppSettingsSecretsJson()

View file

@ -34,6 +34,12 @@ public class HangfireDbSchemaMigrator : IPlatformDbSchemaMigrator, ITransientDep
public async Task MigrateAsync()
{
if (DefaultDatabaseProvider != DatabaseProvider.SqlServer)
{
_logger.LogInformation("HangFire SQL Server schema migration skipped for database provider '{Provider}'.", DefaultDatabaseProvider);
return;
}
var connectionString = _configuration.GetConnectionString(DefaultDatabaseProvider);
if (string.IsNullOrWhiteSpace(connectionString))
{

View file

@ -258,7 +258,7 @@
"code": "App.SiteManagement.General.NewMemberNotificationEmails",
"nameKey": "App.SiteManagement.General.NewMemberNotificationEmails",
"descriptionKey": "App.SiteManagement.General.NewMemberNotificationEmails.Description",
"defaultValue": "system@sozsoft.com",
"defaultValue": "SYSTEM@SOZSOFT.COM",
"isVisibleToClients": false,
"providers": "G|D",
"isInherited": false,
@ -274,7 +274,7 @@
"code": "App.SiteManagement.General.TimedLoginEmails",
"nameKey": "App.SiteManagement.General.TimedLoginEmails",
"descriptionKey": "App.SiteManagement.General.TimedLoginEmails.Description",
"defaultValue": "system@sozsoft.com",
"defaultValue": "SYSTEM@SOZSOFT.COM",
"isVisibleToClients": false,
"providers": "G|D",
"isInherited": false,
@ -466,7 +466,7 @@
"code": "Abp.Mailing.DefaultFromAddress",
"nameKey": "Abp.Mailing.DefaultFromAddress",
"descriptionKey": "Abp.Mailing.DefaultFromAddress.Description",
"defaultValue": "system@sozsoft.com",
"defaultValue": "SYSTEM@SOZSOFT.COM",
"isVisibleToClients": false,
"providers": "T|G|D",
"isInherited": false,
@ -482,7 +482,7 @@
"code": "Abp.Mailing.Smtp.UserName",
"nameKey": "Abp.Mailing.Smtp.UserName",
"descriptionKey": "Abp.Mailing.Smtp.UserName.Description",
"defaultValue": "system@sozsoft.com",
"defaultValue": "SYSTEM@SOZSOFT.COM",
"isVisibleToClients": false,
"providers": "T|G|D",
"isInherited": false,

View file

@ -212,6 +212,11 @@ public class HostDataSeeder : IDataSeedContributor, ITransientDependency
.ToListAsync())
.ToHashSet();
var countryGroupNames = (await dbCtx.Set<CountryGroup>()
.Select(c => c.Name)
.ToListAsync())
.ToHashSet();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
using var fs = File.OpenRead(Path.Combine("Seeds", "Countries.json"));
@ -228,10 +233,19 @@ public class HostDataSeeder : IDataSeedContributor, ITransientDependency
if (!seenCodes.Add(item.Name) || existingCodes.Contains(item.Name))
continue;
var groupName = string.IsNullOrWhiteSpace(item.GroupName)
? null
: item.GroupName.Trim();
if (!string.IsNullOrWhiteSpace(groupName) && !countryGroupNames.Contains(groupName))
{
groupName = null;
}
buffer.Add(new Country(
item.Name,
item.Name,
item.GroupName,
groupName,
item.Currency,
item.PhoneCode,
item.TaxLabel

View file

@ -18440,6 +18440,12 @@
"en": "Failed",
"tr": "Başarısız"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKit.DynamicServices.TotalDescription",
"en": "Number of failed services",
"tr": "Başarısız servislerin sayısı"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKit.DynamicServices.FailedDescription",

View file

@ -4164,11 +4164,11 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
DeleteCommand = DefaultDeleteCommand(nameof(TableNameEnum.Event)),
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
PagerOptionJson = DefaultPagerOptionJson,
EditingOptionJson = DefaultEditingOptionJson(listFormName, 750, 500, true, true, true, true, false, true),
EditingOptionJson = DefaultEditingOptionJson(listFormName, 900, 500, true, true, true, true, false, true),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>()
{
new() {
Order = 1, ColCount = 4, ColSpan = 1, ItemType = "group", Items =
Order = 1, ColCount = 3, ColSpan = 1, ItemType = "group", Items =
[
new EditingFormItemDto { Order = 1, DataField = "CategoryId", ColSpan = 1, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 2, DataField = "TypeId", ColSpan = 1, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox, EditorOptions=EditorOptionValues.ShowClearButton },
@ -4178,7 +4178,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep
new EditingFormItemDto { Order = 6, DataField = "UserId", ColSpan = 1, IsRequired = true, EditorType2 = EditorTypes.dxSelectBox, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 7, DataField = "Status", ColSpan = 1, EditorType2 = EditorTypes.dxSelectBox, EditorOptions=EditorOptionValues.ShowClearButton },
new EditingFormItemDto { Order = 8, DataField = "ParticipantsCount", ColSpan = 1, EditorType2 = EditorTypes.dxNumberBox },
new EditingFormItemDto { Order = 9, DataField = "Description", ColSpan = 2, EditorType2 = EditorTypes.dxTextArea },
new EditingFormItemDto { Order = 9, DataField = "Description", ColSpan = 2, EditorType2 = EditorTypes.dxTextBox },
new EditingFormItemDto { Order = 10, DataField = "Photos", ColSpan = 1, EditorType2 = EditorTypes.dxImageUpload, EditorOptions = EditorOptionValues.ImageUploadOptions },
]}
}),

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE public."Adm_T_DatabaseBackupAll"()
LANGUAGE plpgsql
AS $$
BEGIN
RAISE NOTICE 'PostgreSQL backup must be performed with pg_dump/pg_dumpall or an external backup service.';
END;
$$;

View file

@ -0,0 +1,7 @@
CREATE OR REPLACE PROCEDURE public."Adm_T_DatabaseBackupFilesDeleteAll"()
LANGUAGE plpgsql
AS $$
BEGIN
RAISE NOTICE 'PostgreSQL backup file cleanup must be performed by the host/container scheduler.';
END;
$$;

View file

@ -0,0 +1,103 @@
DO $$
BEGIN
IF to_regclass('hangfire.job') IS NULL
OR to_regclass('hangfire.jobparameter') IS NULL
OR to_regclass('hangfire.state') IS NULL THEN
RAISE NOTICE 'Hangfire PostgreSQL tables were not found. Sas_H_BackgroundWorker_JobFlow view creation skipped.';
RETURN;
END IF;
EXECUTE $view$
CREATE OR REPLACE VIEW public."Sas_H_BackgroundWorker_JobFlow" AS
SELECT
j.id AS "Id",
COALESCE(p."RecurringJobId", 'OneTimeJob') AS "JobName",
COALESCE(prc."ProcessingAt", enq."EnqueuedAt", j.createdat) AS "ExecutionDate",
CASE
WHEN suc."DurationMs" IS NOT NULL THEN suc."DurationMs"
WHEN prc."ProcessingAt" IS NOT NULL AND suc."SucceededAt" IS NOT NULL
THEN (EXTRACT(EPOCH FROM (suc."SucceededAt" - prc."ProcessingAt")) * 1000)::bigint
WHEN prc."ProcessingAt" IS NOT NULL AND fld."FailedAt" IS NOT NULL
THEN (EXTRACT(EPOCH FROM (fld."FailedAt" - prc."ProcessingAt")) * 1000)::bigint
ELSE NULL
END AS "Duration",
j.statename AS "Status",
CASE
WHEN j.statename = 'Failed' THEN COALESCE(
fld."ExceptionMessage",
fld."FailedReason",
fld."ExceptionType",
'Unknown error'
)
ELSE NULL
END AS "FailureReason",
fld."ExceptionType",
fld."ExceptionDetails"
FROM hangfire.job j
LEFT JOIN hangfire.jobqueue jq
ON jq.jobid = j.id
LEFT JOIN LATERAL
(
SELECT
MAX(CASE
WHEN jp.name = 'RecurringJobId'
THEN REPLACE(jp.value, '"', '')
END) AS "RecurringJobId"
FROM hangfire.jobparameter jp
WHERE jp.jobid = j.id
) p ON TRUE
LEFT JOIN LATERAL
(
SELECT
s.createdat AS "EnqueuedAt",
s.reason AS "EnqueuedReason",
s.data::jsonb ->> 'Queue' AS "QueueName"
FROM hangfire.state s
WHERE s.jobid = j.id
AND s.name = 'Enqueued'
ORDER BY s.createdat DESC
LIMIT 1
) enq ON TRUE
LEFT JOIN LATERAL
(
SELECT
s.createdat AS "ProcessingAt",
s.reason AS "ProcessingReason",
s.data::jsonb ->> 'ServerId' AS "ServerName",
s.data::jsonb ->> 'WorkerId' AS "WorkerId"
FROM hangfire.state s
WHERE s.jobid = j.id
AND s.name = 'Processing'
ORDER BY s.createdat DESC
LIMIT 1
) prc ON TRUE
LEFT JOIN LATERAL
(
SELECT
s.createdat AS "SucceededAt",
s.reason AS "SucceededReason",
NULLIF(s.data::jsonb ->> 'PerformanceDuration', '')::bigint AS "DurationMs"
FROM hangfire.state s
WHERE s.jobid = j.id
AND s.name = 'Succeeded'
ORDER BY s.createdat DESC
LIMIT 1
) suc ON TRUE
LEFT JOIN LATERAL
(
SELECT
s.createdat AS "FailedAt",
s.reason AS "FailedReason",
s.data::jsonb ->> 'ExceptionType' AS "ExceptionType",
s.data::jsonb ->> 'ExceptionMessage' AS "ExceptionMessage",
s.data::jsonb ->> 'ExceptionDetails' AS "ExceptionDetails"
FROM hangfire.state s
WHERE s.jobid = j.id
AND s.name = 'Failed'
ORDER BY s.createdat DESC
LIMIT 1
) fld ON TRUE
WHERE COALESCE(j.invocationdata::jsonb ->> 'Method', '') <> 'DoWorkAsync'
$view$;
END;
$$;

View file

@ -9,6 +9,7 @@ using Sozsoft.Platform.EntityFrameworkCore;
using Volo.Abp.Data;
using Volo.Abp.DependencyInjection;
using Volo.Abp.EntityFrameworkCore;
using static Sozsoft.Settings.SettingsConsts;
namespace Sozsoft.Platform.Data.Seeds;
@ -32,10 +33,11 @@ public class SqlDataSeeder : IDataSeedContributor, ITransientDependency
public async Task SeedAsync(DataSeedContext context)
{
var sqlDataPath = Path.Combine(Directory.GetCurrentDirectory(), "Seeds", "SqlData");
var dataDirectoryName = GetDataDirectoryName();
var sqlDataPath = Path.Combine(Directory.GetCurrentDirectory(), "Seeds", dataDirectoryName);
if (!Directory.Exists(sqlDataPath))
{
_logger.LogInformation("Seeds/SqlData directory not found, skipping SqlDataSeeder.");
_logger.LogInformation("Seeds/{DirectoryName} directory not found, skipping SqlDataSeeder.", dataDirectoryName);
return;
}
@ -45,11 +47,15 @@ public class SqlDataSeeder : IDataSeedContributor, ITransientDependency
if (sqlFiles.Length == 0)
{
_logger.LogInformation("No .sql files found in Seeds/SqlData directory, skipping SqlDataSeeder.");
_logger.LogInformation("No .sql files found in Seeds/{DirectoryName} directory, skipping SqlDataSeeder.", dataDirectoryName);
return;
}
_logger.LogInformation("SqlDataSeeder started. {Count} file(s) to be processed.", sqlFiles.Length);
_logger.LogInformation(
"SqlDataSeeder started for provider '{Provider}' from Seeds/{DirectoryName}. {Count} file(s) to be processed.",
DefaultDatabaseProvider,
dataDirectoryName,
sqlFiles.Length);
var dbContext = await _dbContextProvider.GetDbContextAsync();
@ -98,6 +104,13 @@ public class SqlDataSeeder : IDataSeedContributor, ITransientDependency
_logger.LogInformation("SqlDataSeeder completed. {Count} file(s) processed.", sqlFiles.Length);
}
private static string GetDataDirectoryName()
{
return DefaultDatabaseProvider == DatabaseProvider.PostgreSql
? "PostgresData"
: "SqlData";
}
private static (string Action, string? ObjectName, string? ObjectType) ExtractSqlInfo(string sql)
{
var patterns = new[]

View file

@ -85,6 +85,10 @@
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
<Content Include="Seeds\PostgresData\*.sql">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>

View file

@ -162,7 +162,7 @@ public static class PlatformConsts
public const string AdminRoleName = "admin";
public const string AdminNameDefaultValue = "Sedat";
public const string AdminSurNameDefaultValue = "ÖZTÜRK";
public const string AdminEmailDefaultValue = "system@sozsoft.com";
public const string AdminEmailDefaultValue = "SYSTEM@SOZSOFT.COM";
public const string AdminPasswordDefaultValue = "1q2w3E*";
public const string AdminPhoneNumberDefaultValue = "05449476346";
public const string AdminRocketUsernameDefaultValue = "sedat.ozturk";

View file

@ -21,47 +21,57 @@ public class DynamicEntityManager : IDynamicEntityManager
public async Task<List<object>?> GetEntityListAsync(string entityName)
{
var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext);
var tableName = QuoteIdentifier(entityName, isPostgreSql);
var idDeletedColumn = QuoteIdentifier("IsDeleted", isPostgreSql);
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
var query = hasIsDeleted
? $"SELECT * FROM [{entityName}] WHERE IsDeleted = 0 OR IsDeleted IS NULL"
: $"SELECT * FROM [{entityName}]";
? $"SELECT * FROM {tableName} WHERE {idDeletedColumn} = {FalseLiteral(isPostgreSql)} OR {idDeletedColumn} IS NULL"
: $"SELECT * FROM {tableName}";
return await ExecuteRawQueryAsync(query);
}
public async Task<object?> GetEntityByIdAsync(string entityName, Guid id)
{
var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext);
var tableName = QuoteIdentifier(entityName, isPostgreSql);
var idColumn = QuoteIdentifier("Id", isPostgreSql);
var isDeletedColumn = QuoteIdentifier("IsDeleted", isPostgreSql);
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
var query = hasIsDeleted
? $"SELECT * FROM [{entityName}] WHERE Id = '{id}' AND (IsDeleted = 0 OR IsDeleted IS NULL)"
: $"SELECT * FROM [{entityName}] WHERE Id = '{id}'";
? $"SELECT * FROM {tableName} WHERE {idColumn} = '{id}' AND ({isDeletedColumn} = {FalseLiteral(isPostgreSql)} OR {isDeletedColumn} IS NULL)"
: $"SELECT * FROM {tableName} WHERE {idColumn} = '{id}'";
var result = await ExecuteRawQueryAsync(query);
return result?.FirstOrDefault();
}
public async Task<object?> CreateEntityAsync(string entityName, JsonElement data)
{
var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext);
var newId = Guid.NewGuid();
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
var hasCreationTime = await ColumnExistsAsync(entityName, "CreationTime");
var columns = new List<string> { "[Id]" };
var columns = new List<string> { QuoteIdentifier("Id", isPostgreSql) };
var values = new List<string> { $"'{newId}'" };
if (hasCreationTime) { columns.Add("[CreationTime]"); values.Add("SYSUTCDATETIME()"); }
if (hasIsDeleted) { columns.Add("[IsDeleted]"); values.Add("0"); }
if (hasCreationTime) { columns.Add(QuoteIdentifier("CreationTime", isPostgreSql)); values.Add(UtcNowExpression(isPostgreSql)); }
if (hasIsDeleted) { columns.Add(QuoteIdentifier("IsDeleted", isPostgreSql)); values.Add(FalseLiteral(isPostgreSql)); }
foreach (var prop in data.EnumerateObject())
{
if (prop.NameEquals("id") || prop.NameEquals("Id"))
continue;
columns.Add($"[{prop.Name}]");
values.Add(FormatValueForSql(prop.Value));
columns.Add(QuoteIdentifier(prop.Name, isPostgreSql));
values.Add(FormatValueForSql(prop.Value, isPostgreSql));
}
var insertQuery = $"INSERT INTO [{entityName}] ({string.Join(", ", columns)}) VALUES ({string.Join(", ", values)})";
var insertQuery = $"INSERT INTO {QuoteIdentifier(entityName, isPostgreSql)} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", values)})";
var dbContext = await _dbContextProvider.GetDbContextAsync();
await dbContext.Database.ExecuteSqlRawAsync(insertQuery);
return await GetEntityByIdAsync(entityName, newId);
@ -69,6 +79,8 @@ public class DynamicEntityManager : IDynamicEntityManager
public async Task<object?> UpdateEntityAsync(string entityName, Guid id, JsonElement data)
{
var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext);
var existing = await GetEntityByIdAsync(entityName, id);
if (existing == null)
return null;
@ -77,19 +89,18 @@ public class DynamicEntityManager : IDynamicEntityManager
var hasLastModification = await ColumnExistsAsync(entityName, "LastModificationTime");
if (hasLastModification)
setParts.Add("[LastModificationTime] = SYSUTCDATETIME()");
setParts.Add($"{QuoteIdentifier("LastModificationTime", isPostgreSql)} = {UtcNowExpression(isPostgreSql)}");
foreach (var prop in data.EnumerateObject())
{
if (prop.NameEquals("id") || prop.NameEquals("Id"))
continue;
setParts.Add($"[{prop.Name}] = {FormatValueForSql(prop.Value)}");
setParts.Add($"{QuoteIdentifier(prop.Name, isPostgreSql)} = {FormatValueForSql(prop.Value, isPostgreSql)}");
}
var updateQuery = $"UPDATE [{entityName}] SET {string.Join(", ", setParts)} WHERE Id = '{id}'";
var updateQuery = $"UPDATE {QuoteIdentifier(entityName, isPostgreSql)} SET {string.Join(", ", setParts)} WHERE {QuoteIdentifier("Id", isPostgreSql)} = '{id}'";
var dbContext = await _dbContextProvider.GetDbContextAsync();
await dbContext.Database.ExecuteSqlRawAsync(updateQuery);
return await GetEntityByIdAsync(entityName, id);
@ -102,6 +113,7 @@ public class DynamicEntityManager : IDynamicEntityManager
return false;
var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext);
var hasIsDeleted = await ColumnExistsAsync(entityName, "IsDeleted");
try
@ -110,21 +122,21 @@ public class DynamicEntityManager : IDynamicEntityManager
{
var hasDeletionTime = await ColumnExistsAsync(entityName, "DeletionTime");
var softDeleteQuery = hasDeletionTime
? $"UPDATE [{entityName}] SET [IsDeleted] = 1, [DeletionTime] = SYSUTCDATETIME() WHERE Id = '{id}'"
: $"UPDATE [{entityName}] SET [IsDeleted] = 1 WHERE Id = '{id}'";
? $"UPDATE {QuoteIdentifier(entityName, isPostgreSql)} SET {QuoteIdentifier("IsDeleted", isPostgreSql)} = {TrueLiteral(isPostgreSql)}, {QuoteIdentifier("DeletionTime", isPostgreSql)} = {UtcNowExpression(isPostgreSql)} WHERE {QuoteIdentifier("Id", isPostgreSql)} = '{id}'"
: $"UPDATE {QuoteIdentifier(entityName, isPostgreSql)} SET {QuoteIdentifier("IsDeleted", isPostgreSql)} = {TrueLiteral(isPostgreSql)} WHERE {QuoteIdentifier("Id", isPostgreSql)} = '{id}'";
var affected = await dbContext.Database.ExecuteSqlRawAsync(softDeleteQuery);
return affected > 0;
}
else
{
var hardDeleteQuery = $"DELETE FROM [{entityName}] WHERE Id = '{id}'";
var hardDeleteQuery = $"DELETE FROM {QuoteIdentifier(entityName, isPostgreSql)} WHERE {QuoteIdentifier("Id", isPostgreSql)} = '{id}'";
var affected = await dbContext.Database.ExecuteSqlRawAsync(hardDeleteQuery);
return affected > 0;
}
}
catch
{
var hardDeleteQuery = $"DELETE FROM [{entityName}] WHERE Id = '{id}'";
var hardDeleteQuery = $"DELETE FROM {QuoteIdentifier(entityName, isPostgreSql)} WHERE {QuoteIdentifier("Id", isPostgreSql)} = '{id}'";
var affected = await dbContext.Database.ExecuteSqlRawAsync(hardDeleteQuery);
return affected > 0;
}
@ -132,8 +144,11 @@ public class DynamicEntityManager : IDynamicEntityManager
private async Task<bool> ColumnExistsAsync(string tableName, string columnName)
{
var query = $"SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '{tableName}' AND COLUMN_NAME = '{columnName}'";
var dbContext = await _dbContextProvider.GetDbContextAsync();
var isPostgreSql = IsPostgreSql(dbContext);
var query = isPostgreSql
? $"SELECT COUNT(1) FROM information_schema.columns WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_name = '{tableName.Replace("'", "''")}' AND column_name = '{columnName.Replace("'", "''")}'"
: $"SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = '{tableName.Replace("'", "''")}' AND COLUMN_NAME = '{columnName.Replace("'", "''")}'";
var connection = dbContext.Database.GetDbConnection();
await dbContext.Database.OpenConnectionAsync();
try
@ -181,14 +196,14 @@ public class DynamicEntityManager : IDynamicEntityManager
}
}
private static string FormatValueForSql(JsonElement value)
private static string FormatValueForSql(JsonElement value, bool isPostgreSql)
{
return value.ValueKind switch
{
JsonValueKind.Number when value.TryGetInt64(out var l) => l.ToString(),
JsonValueKind.Number when value.TryGetDecimal(out var d) => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
JsonValueKind.True => "1",
JsonValueKind.False => "0",
JsonValueKind.True => TrueLiteral(isPostgreSql),
JsonValueKind.False => FalseLiteral(isPostgreSql),
JsonValueKind.Null => "NULL",
JsonValueKind.String when value.TryGetGuid(out var g) => $"'{g}'",
JsonValueKind.String when value.TryGetDateTime(out var dt) => $"'{dt:yyyy-MM-dd HH:mm:ss}'",
@ -196,5 +211,32 @@ public class DynamicEntityManager : IDynamicEntityManager
_ => "NULL",
};
}
private static bool IsPostgreSql(PlatformDbContext dbContext)
{
return dbContext.Database.ProviderName?.Contains("Npgsql", StringComparison.OrdinalIgnoreCase) == true;
}
private static string QuoteIdentifier(string identifier, bool isPostgreSql)
{
return isPostgreSql
? $"\"{identifier.Replace("\"", "\"\"")}\""
: $"[{identifier.Replace("]", "]]")}]";
}
private static string TrueLiteral(bool isPostgreSql)
{
return isPostgreSql ? "TRUE" : "1";
}
private static string FalseLiteral(bool isPostgreSql)
{
return isPostgreSql ? "FALSE" : "0";
}
private static string UtcNowExpression(bool isPostgreSql)
{
return isPostgreSql ? "NOW() AT TIME ZONE 'UTC'" : "SYSUTCDATETIME()";
}
}

View file

@ -2,9 +2,13 @@
using System.Collections.Generic;
using System.Data;
using System.Data.Common;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Dapper;
using Sozsoft.Platform.DynamicData;
using Sozsoft.Platform;
using Npgsql;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Threading;
@ -20,6 +24,10 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
private readonly Dictionary<string, DbTransaction> _transactions;
private readonly Dictionary<string, NpgsqlConnection> _connections;
private readonly HashSet<string> _registeredTransactions;
private readonly HashSet<string> _registeredConnections;
private readonly object _lock = new object();
public bool IsDisposed { get; private set; }
public PgDynamicDataRepository(
@ -30,6 +38,8 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
_cancellationTokenProvider = cancellationTokenProvider;
_transactions = [];
_connections = [];
_registeredTransactions = [];
_registeredConnections = [];
}
private string BuildKey(string cs)
@ -41,44 +51,72 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
private async Task<NpgsqlConnection> GetOrCreateConnectionAsync(string cs)
{
var key = BuildKey(cs);
if (!_connections.TryGetValue(key, out var connection))
NpgsqlConnection connection;
lock (_lock)
{
connection = new NpgsqlConnection(cs);
_connections[key] = connection;
if (_connections.TryGetValue(key, out connection))
{
// varsa aynı connection'ı kullan
}
else
{
connection = new NpgsqlConnection(cs);
_connections[key] = connection;
}
}
// Lock dışında state yönetimi
if (connection.State == ConnectionState.Broken)
{
connection.Close();
}
if (connection.State != ConnectionState.Open)
if (connection.State == ConnectionState.Closed)
{
await connection.OpenAsync(_cancellationTokenProvider.FallbackToProvider(default));
}
// UoW tamamlandığında connection'ı kapatmak için tek seferlik kayıt
if (_unitOfWorkManager.Current != null)
{
_unitOfWorkManager.Current.OnCompleted(async () =>
lock (_lock)
{
if (_connections.TryGetValue(key, out var conn))
if (!_registeredConnections.Contains(key))
{
_connections.Remove(key);
_registeredConnections.Add(key);
try
_unitOfWorkManager.Current.OnCompleted(async () =>
{
if (conn.State != ConnectionState.Closed)
NpgsqlConnection conn = null;
lock (_lock)
{
await conn.CloseAsync();
if (_connections.TryGetValue(key, out conn))
{
_connections.Remove(key);
}
_registeredConnections.Remove(key);
}
conn.Dispose();
}
catch
{
// ignore
}
if (conn != null)
{
try
{
if (conn.State != ConnectionState.Closed)
{
await conn.CloseAsync();
}
conn.Dispose();
}
catch
{
// ignore
}
}
});
}
});
}
}
return connection;
@ -88,44 +126,62 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
{
var key = BuildKey(cs);
if (_transactions.TryGetValue(key, out var tx))
lock (_lock)
{
if (tx?.Connection != null &&
tx.Connection == con &&
tx.Connection.State == ConnectionState.Open)
if (_transactions.TryGetValue(key, out var existing))
{
return tx;
}
if (existing?.Connection != null &&
existing.Connection == con &&
existing.Connection.State == ConnectionState.Open)
{
return existing;
}
try { tx?.Dispose(); } catch { }
_transactions.Remove(key);
try { existing?.Dispose(); } catch { }
_transactions.Remove(key);
}
}
var newTx = await con.BeginTransactionAsync(_cancellationTokenProvider.FallbackToProvider(default));
_transactions[key] = newTx;
var newTransaction = await con.BeginTransactionAsync(_cancellationTokenProvider.FallbackToProvider(default));
bool shouldRegister = false;
if (_unitOfWorkManager.Current != null)
lock (_lock)
{
_transactions[key] = newTransaction;
if (!_registeredTransactions.Contains(key))
{
_registeredTransactions.Add(key);
shouldRegister = true;
}
}
if (shouldRegister && _unitOfWorkManager.Current != null)
{
_unitOfWorkManager.Current.AddTransactionApi(
key,
new DapperTransactionApi(newTx, _cancellationTokenProvider)
new DapperTransactionApi(newTransaction, _cancellationTokenProvider)
);
_unitOfWorkManager.Current.OnCompleted(() =>
{
_transactions.Remove(key);
lock (_lock)
{
_transactions.Remove(key);
_registeredTransactions.Remove(key);
}
return Task.CompletedTask;
});
}
return newTx;
return newTransaction;
}
// ------------------ Dapper metotları ------------------
public virtual async Task<List<T>> QueryAsync<T>(string sql, string cs, Dictionary<string, object> parameters = null)
{
var param = new DynamicParameters(parameters);
var param = CreateDynamicParameters(parameters);
var dbConnection = await GetOrCreateConnectionAsync(cs);
var transaction = await GetOrCreateTransactionAsync(dbConnection, cs);
@ -135,7 +191,7 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
public virtual async Task<IEnumerable<dynamic>> QueryAsync(string sql, string cs, Dictionary<string, object> parameters = null)
{
var param = new DynamicParameters(parameters);
var param = CreateDynamicParameters(parameters);
var dbConnection = await GetOrCreateConnectionAsync(cs);
var transaction = await GetOrCreateTransactionAsync(dbConnection, cs);
@ -144,7 +200,7 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
public virtual async Task<T> QuerySingleAsync<T>(string sql, string cs, Dictionary<string, object> parameters = null)
{
var param = new DynamicParameters(parameters);
var param = CreateDynamicParameters(parameters);
var dbConnection = await GetOrCreateConnectionAsync(cs);
var transaction = await GetOrCreateTransactionAsync(dbConnection, cs);
@ -153,7 +209,7 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
public virtual async Task<T> ExecuteScalarAsync<T>(string sql, string cs, Dictionary<string, object> parameters = null)
{
var param = new DynamicParameters(parameters);
var param = CreateDynamicParameters(parameters);
var dbConnection = await GetOrCreateConnectionAsync(cs);
var transaction = await GetOrCreateTransactionAsync(dbConnection, cs);
@ -184,13 +240,257 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
public virtual async Task<int> ExecuteAsync(string sql, string cs, Dictionary<string, object> parameters = null)
{
var param = new DynamicParameters(parameters);
var param = CreateDynamicParameters(parameters);
var dbConnection = await GetOrCreateConnectionAsync(cs);
var transaction = await GetOrCreateTransactionAsync(dbConnection, cs);
return await dbConnection.ExecuteAsync(sql, param, transaction);
}
private static DynamicParameters CreateDynamicParameters(Dictionary<string, object> parameters)
{
var dynamicParameters = new DynamicParameters();
if (parameters == null)
{
return dynamicParameters;
}
foreach (var parameter in parameters)
{
dynamicParameters.Add(parameter.Key, NormalizeParameterValue(parameter.Value));
}
return dynamicParameters;
}
private static object NormalizeParameterValue(object value)
{
if (value == null || value == DBNull.Value)
{
return value;
}
if (value is JsonElement jsonElement)
{
return NormalizeJsonElement(jsonElement);
}
if (value is Array array && value is not byte[])
{
return NormalizeArrayParameter(array);
}
return value;
}
private static object NormalizeArrayParameter(Array values)
{
var normalizedValues = values
.Cast<object>()
.Select(NormalizeParameterValue)
.Where(value => value != null && value != DBNull.Value)
.ToArray();
if (normalizedValues.Length == 0)
{
return Array.Empty<string>();
}
if (TryBuildGuidArray(normalizedValues, out var guidValues))
{
return guidValues;
}
if (TryBuildIntArray(normalizedValues, out var intValues))
{
return intValues;
}
if (TryBuildLongArray(normalizedValues, out var longValues))
{
return longValues;
}
if (TryBuildDecimalArray(normalizedValues, out var decimalValues))
{
return decimalValues;
}
if (TryBuildBoolArray(normalizedValues, out var boolValues))
{
return boolValues;
}
if (TryBuildDateTimeOffsetArray(normalizedValues, out var dateTimeOffsetValues))
{
return dateTimeOffsetValues;
}
var stringValues = normalizedValues.Select(value => value.ToString()).ToArray();
if (stringValues.Length == 1 && stringValues[0]?.Contains(PlatformConsts.MultiValueDelimiter) == true)
{
return stringValues[0].Split(PlatformConsts.MultiValueDelimiter, StringSplitOptions.RemoveEmptyEntries);
}
return stringValues;
}
private static object NormalizeJsonElement(JsonElement value)
{
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number when value.TryGetInt32(out var intValue) => intValue,
JsonValueKind.Number when value.TryGetInt64(out var longValue) => longValue,
JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Undefined => null,
_ => value.ToString()
};
}
private static bool TryBuildGuidArray(object[] values, out Guid[] result)
{
result = new Guid[values.Length];
for (var i = 0; i < values.Length; i++)
{
if (values[i] is Guid guidValue)
{
result[i] = guidValue;
continue;
}
if (!Guid.TryParse(values[i]?.ToString(), out result[i]))
{
result = null;
return false;
}
}
return true;
}
private static bool TryBuildIntArray(object[] values, out int[] result)
{
result = new int[values.Length];
for (var i = 0; i < values.Length; i++)
{
if (values[i] is int intValue)
{
result[i] = intValue;
continue;
}
if (!int.TryParse(values[i]?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out result[i]))
{
result = null;
return false;
}
}
return true;
}
private static bool TryBuildLongArray(object[] values, out long[] result)
{
result = new long[values.Length];
for (var i = 0; i < values.Length; i++)
{
if (values[i] is long longValue)
{
result[i] = longValue;
continue;
}
if (!long.TryParse(values[i]?.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out result[i]))
{
result = null;
return false;
}
}
return true;
}
private static bool TryBuildDecimalArray(object[] values, out decimal[] result)
{
result = new decimal[values.Length];
for (var i = 0; i < values.Length; i++)
{
if (values[i] is decimal decimalValue)
{
result[i] = decimalValue;
continue;
}
if (!decimal.TryParse(values[i]?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out result[i]))
{
result = null;
return false;
}
}
return true;
}
private static bool TryBuildBoolArray(object[] values, out bool[] result)
{
result = new bool[values.Length];
for (var i = 0; i < values.Length; i++)
{
if (values[i] is bool boolValue)
{
result[i] = boolValue;
continue;
}
if (!bool.TryParse(values[i]?.ToString(), out result[i]))
{
result = null;
return false;
}
}
return true;
}
private static bool TryBuildDateTimeOffsetArray(object[] values, out DateTimeOffset[] result)
{
result = new DateTimeOffset[values.Length];
for (var i = 0; i < values.Length; i++)
{
if (values[i] is DateTimeOffset dateTimeOffsetValue)
{
result[i] = dateTimeOffsetValue;
continue;
}
if (values[i] is DateTime dateTimeValue)
{
result[i] = dateTimeValue;
continue;
}
if (!DateTimeOffset.TryParse(values[i]?.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result[i]))
{
result = null;
return false;
}
}
return true;
}
// ------------------ Dispose ------------------
public void Dispose()
@ -208,31 +508,36 @@ public class PgDynamicDataRepository : IDynamicDataRepository, IScopedDependency
if (disposing)
{
foreach (var tx in _transactions.Values)
lock (_lock)
{
try { tx?.Dispose(); } catch { }
}
_transactions.Clear();
foreach (var connection in _connections.Values)
{
try
foreach (var tx in _transactions.Values)
{
if (connection != null)
try { tx?.Dispose(); } catch { }
}
_transactions.Clear();
_registeredTransactions.Clear();
foreach (var connection in _connections.Values)
{
try
{
if (connection.State != ConnectionState.Closed)
if (connection != null)
{
connection.Close();
if (connection.State != ConnectionState.Closed)
{
connection.Close();
}
connection.Dispose();
}
connection.Dispose();
}
catch
{
// ignore
}
}
catch
{
// ignore
}
_connections.Clear();
_registeredConnections.Clear();
}
_connections.Clear();
}
IsDisposed = true;

View file

@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Sozsoft.Platform.Data;
using Volo.Abp.DependencyInjection;
using static Sozsoft.Settings.SettingsConsts;
namespace Sozsoft.Platform.EntityFrameworkCore;
@ -26,10 +27,17 @@ public class EntityFrameworkCorePlatformDbSchemaMigrator
* current scope.
*/
await _serviceProvider
var database = _serviceProvider
.GetRequiredService<PlatformDbContext>()
.Database
.MigrateAsync();
.Database;
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
{
await database.EnsureCreatedAsync();
return;
}
await database.MigrateAsync();
}
}

View file

@ -38,6 +38,8 @@ public class PlatformDbContext :
IIdentityDbContext,
ITenantManagementDbContext
{
private readonly bool _isPostgreSql;
#region Saas
public DbSet<LogEntry> LogEntries { get; set; }
public DbSet<Tenant> Tenants { get; set; }
@ -144,14 +146,15 @@ public class PlatformDbContext :
public PlatformDbContext(DbContextOptions<PlatformDbContext> options)
: base(options)
{
_isPostgreSql = options.Extensions.Any(extension =>
extension.GetType().Namespace?.StartsWith("Npgsql.EntityFrameworkCore.PostgreSQL", StringComparison.Ordinal) == true);
}
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
if (_isPostgreSql)
{
configurationBuilder.Properties<string>().UseCollation("tr-x-icu");
}
@ -1447,5 +1450,23 @@ public class PlatformDbContext :
b.HasIndex(x => new { x.TenantId, x.Name }).IsUnique().HasFilter("[IsDeleted] = 0");
});
ConfigureProviderSpecificModel(builder);
}
private void ConfigureProviderSpecificModel(ModelBuilder builder)
{
if (!_isPostgreSql)
{
return;
}
foreach (var index in builder.Model.GetEntityTypes().SelectMany(entityType => entityType.GetIndexes()))
{
if (index.GetFilter() == "[IsDeleted] = 0")
{
index.SetFilter("\"IsDeleted\" = FALSE");
}
}
}
}

View file

@ -16,27 +16,45 @@ public class PlatformDbContextFactory : IDesignTimeDbContextFactory<PlatformDbCo
PlatformEfCoreEntityExtensionMappings.Configure();
var configuration = BuildConfiguration();
var databaseProvider = GetDatabaseProvider(args);
var builder = new DbContextOptionsBuilder<PlatformDbContext>();
switch (DefaultDatabaseProvider)
switch (databaseProvider)
{
case DatabaseProvider.PostgreSql:
//AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); // PGSQL
builder.UseNpgsql(configuration.GetConnectionString(DefaultDatabaseProvider));
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
builder.UseNpgsql(configuration.GetConnectionString(DatabaseProvider.PostgreSql));
break;
case DatabaseProvider.SqlServer:
builder.UseSqlServer(configuration.GetConnectionString(DefaultDatabaseProvider));
builder.UseSqlServer(configuration.GetConnectionString(DatabaseProvider.SqlServer));
break;
default:
throw new InvalidOperationException("Unsupported database provider configured.");
throw new InvalidOperationException($"Unsupported database provider configured: {databaseProvider}");
}
return new PlatformDbContext(builder.Options);
}
private static string GetDatabaseProvider(string[] args)
{
var provider = Environment.GetEnvironmentVariable("EF_DATABASE_PROVIDER");
for (var i = 0; i < args.Length; i++)
{
if (args[i].Equals("--provider", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
provider = args[i + 1];
break;
}
}
return string.IsNullOrWhiteSpace(provider)
? DefaultDatabaseProvider
: provider;
}
private static IConfigurationRoot BuildConfiguration()
{

File diff suppressed because it is too large Load diff

View file

@ -202,7 +202,7 @@
"Summary": "blog.posts.ai.excerpt",
"CoverImage": "https://images.pexels.com/photos/8386434/pexels-photo-8386434.jpeg?auto=compress&cs=tinysrgb&w=1920",
"CategoryName": "blog.categories.technology",
"Author": "system@sozsoft.com"
"Author": "SYSTEM@SOZSOFT.COM"
},
{
"Title": "blog.posts.web.title",
@ -213,7 +213,7 @@
"Summary": "blog.posts.web.excerpt",
"CoverImage": "https://images.pexels.com/photos/11035471/pexels-photo-11035471.jpeg?auto=compress&cs=tinysrgb&w=1920",
"CategoryName": "blog.categories.webdev",
"Author": "system@sozsoft.com"
"Author": "SYSTEM@SOZSOFT.COM"
},
{
"Title": "blog.posts.security.title",
@ -224,7 +224,7 @@
"Summary": "blog.posts.security.excerpt",
"CoverImage": "https://images.pexels.com/photos/5380642/pexels-photo-5380642.jpeg?auto=compress&cs=tinysrgb&w=1920",
"CategoryName": "blog.categories.security",
"Author": "system@sozsoft.com"
"Author": "SYSTEM@SOZSOFT.COM"
},
{
"Title": "blog.posts.mobile.title",
@ -235,7 +235,7 @@
"ReadTime": "4 dk",
"CoverImage": "https://images.pexels.com/photos/13017583/pexels-photo-13017583.jpeg?auto=compress&cs=tinysrgb&w=1920",
"CategoryName": "blog.categories.mobile",
"Author": "system@sozsoft.com"
"Author": "SYSTEM@SOZSOFT.COM"
},
{
"Title": "blog.posts.database.title",
@ -246,7 +246,7 @@
"ReadTime": "8 dk",
"CoverImage": "https://images.pexels.com/photos/325229/pexels-photo-325229.jpeg?auto=compress&cs=tinysrgb&w=1920",
"CategoryName": "blog.categories.database",
"Author": "system@sozsoft.com"
"Author": "SYSTEM@SOZSOFT.COM"
},
{
"Title": "blog.posts.digital.title",
@ -257,7 +257,7 @@
"ReadTime": "6 dk",
"CoverImage": "https://images.pexels.com/photos/7681091/pexels-photo-7681091.jpeg?auto=compress&cs=tinysrgb&w=1920",
"CategoryName": "blog.categories.digital",
"Author": "system@sozsoft.com"
"Author": "SYSTEM@SOZSOFT.COM"
}
],
"GlobalSearch": [
@ -341,7 +341,7 @@
"PermissionsJson": [
{
"ResourceType": "User",
"ResourceId": "system@sozsoft.com"
"ResourceId": "SYSTEM@SOZSOFT.COM"
}
]
}
@ -1298,7 +1298,7 @@
"content": "Ankara ofisimiz 1 Kasım tarihinde hizmete başlıyor! Tüm çalışanlarımızıılış törenimize davet ediyoruz.",
"excerpt": "Ankara ofisimiz 1 Kasım tarihinde hizmete başlıyor!",
"category": "general",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"publishDate": "12-10-2024",
"isPinned": true,
"viewCount": 0,
@ -1309,7 +1309,7 @@
"content": "Yıl sonu performans değerlendirmelerimiz 20 Ekim - 5 Kasım tarihleri arasında gerçekleştirilecektir. Lütfen formları zamanında doldurunuz.",
"excerpt": "Yıl sonu performans değerlendirmeleri başlıyor.",
"category": "event",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"publishDate": "08-10-2024",
"expiryDate": "05-11-2024",
"isPinned": true,
@ -1320,7 +1320,7 @@
"content": "Bu Cumartesi saat 02: 00 - 06: 00 arası sistemlerimizde bakım çalışması yapılacaktır. Bu süre içinde sistemlere erişim sağlanamayacaktır.",
"excerpt": "Cumartesi gecesi planlı bakım çalışması",
"category": "urgent",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"publishDate": "08-10-2024",
"isPinned": false,
"viewCount": 0
@ -1330,7 +1330,7 @@
"content": "Yazılım Geliştirme ekibimiz için React İleri Seviye eğitimi 25-26 Ekim tarihlerinde düzenlenecektir. Katılım için IK birimine başvurunuz.",
"excerpt": "React İleri Seviye eğitimi kayıtları başladı",
"category": "event",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"publishDate": "09-10-2024",
"isPinned": false,
"viewCount": 0
@ -1340,7 +1340,7 @@
"content": "Bilgi güvenliği politikamız güncellenmiştir. Tüm çalışanlarımızın yeni politikayı okuması ve onaylaması gerekmektedir.",
"excerpt": "Güvenlik politikası güncellendi - Onay gerekli",
"category": "urgent",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"publishDate": "04-10-2024",
"isPinned": true,
"viewCount": 0
@ -1514,42 +1514,42 @@
"SocialPosts": [
{
"content": "Yeni proje üzerinde çalışıyoruz! React ve TypeScript ile harika bir deneyim oluşturuyoruz. Ekip çalışması harika gidiyor! 🚀",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"likeCount": 0,
"isLiked": false,
"isOwnPost": true
},
{
"content": "Bu hafta sprint planlamasını yaptık. Ekibimizle birlikte yeni özellikleri değerlendirdik. Heyecan verici bir hafta olacak!",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"likeCount": 0,
"isLiked": false,
"isOwnPost": true
},
{
"content": "Yeni tasarım sistemimizin ilk prototipini hazırladık! Kullanıcı deneyimini iyileştirmek için çok çalıştık. Geri bildirimlerinizi bekliyorum! 🎨",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"likeCount": 0,
"isLiked": false,
"isOwnPost": true
},
{
"content": "CI/CD pipeline güncellememiz tamamlandı! Deployment süremiz %40 azaldı. Otomasyonun gücü 💪",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"likeCount": 0,
"isLiked": false,
"isOwnPost": true
},
{
"content": "Ekip üyelerimize yeni eğitim programımızı duyurmak istiyorum! 🎓 React, TypeScript ve Modern Web Geliştirme konularında kapsamlı bir program hazırladık.",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"likeCount": 0,
"isLiked": false,
"isOwnPost": true
},
{
"content": "Bugün müşteri ile harika bir toplantı yaptık! Yeni projenin detaylarını konuştuk. 🎯",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"likeCount": 0,
"isLiked": false,
"isOwnPost": true
@ -1635,53 +1635,53 @@
"SocialComments": [
{
"postContent": "Yeni proje üzerinde çalışıyoruz! React ve TypeScript ile harika bir deneyim oluşturuyoruz. Ekip çalışması harika gidiyor! 🚀",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "Harika görünüyor! Başarılar 👏"
},
{
"postContent": "Yeni proje üzerinde çalışıyoruz! React ve TypeScript ile harika bir deneyim oluşturuyoruz. Ekip çalışması harika gidiyor! 🚀",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "TypeScript gerçekten fark yaratıyor!"
},
{
"postContent": "Bu hafta sprint planlamasını yaptık. Ekibimizle birlikte yeni özellikleri değerlendirdik. Heyecan verici bir hafta olacak!",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "Mesajlaşma özelliğine kesinlikle ihtiyacımız var!"
},
{
"postContent": "Yeni tasarım sistemimizin ilk prototipini hazırladık! Kullanıcı deneyimini iyileştirmek için çok çalıştık. Geri bildirimlerinizi bekliyorum! 🎨",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "Tasarımlar çok şık! Renk paleti özellikle güzel 😍"
},
{
"postContent": "Yeni tasarım sistemimizin ilk prototipini hazırladık! Kullanıcı deneyimini iyileştirmek için çok çalıştık. Geri bildirimlerinizi bekliyorum! 🎨",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "Dark mode opsiyonu da olacak mı?"
},
{
"postContent": "CI/CD pipeline güncellememiz tamamlandı! Deployment süremiz %40 azaldı. Otomasyonun gücü 💪",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "Harika iş! Detayları paylaşabilir misin?"
},
{
"postContent": "Ekip üyelerimize yeni eğitim programımızı duyurmak istiyorum! 🎓 React, TypeScript ve Modern Web Geliştirme konularında kapsamlı bir program hazırladık.",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "Ne zaman başlıyor?"
},
{
"postContent": "Ekip üyelerimize yeni eğitim programımızı duyurmak istiyorum! 🎓 React, TypeScript ve Modern Web Geliştirme konularında kapsamlı bir program hazırladık.",
"userName": "system@sozsoft.com",
"userName": "SYSTEM@SOZSOFT.COM",
"content": "Gelecek hafta başlıyoruz! Kayıt linki mail ile paylaşılacak."
}
],
"SocialLikes": [
{
"postContent": "Yeni proje üzerinde çalışıyoruz! React ve TypeScript ile harika bir deneyim oluşturuyoruz. Ekip çalışması harika gidiyor! 🚀",
"userName": "system@sozsoft.com"
"userName": "SYSTEM@SOZSOFT.COM"
},
{
"postContent": "Yeni tasarım sistemimizin ilk prototipini hazırladık! Kullanıcı deneyimini iyileştirmek için çok çalıştık. Geri bildirimlerinizi bekliyorum! 🎨",
"userName": "system@sozsoft.com"
"userName": "SYSTEM@SOZSOFT.COM"
}
],
"EventTypes": [
@ -1862,7 +1862,7 @@
"Description": "Tüm departmanların katılımıyla düzenlenen geleneksel yaz futbol turnuvası.",
"Place": "Şirket Kampüsü Spor Alanı",
"Status": "published",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"ParticipantsCount": 64,
"IsPublished": true,
"Likes": 0,
@ -1877,7 +1877,7 @@
"Description": "Çalışanlarımıza özel, rehber eşliğinde 2 günlük kültürel gezi.",
"Place": "Kapadokya, Nevşehir",
"Status": "published",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"ParticipantsCount": 25,
"IsPublished": true,
"Likes": 0,
@ -1892,7 +1892,7 @@
"Description": "Caz müziğinin en güzel örneklerinin canlı performanslarla sunulacağı özel akşam.",
"Place": "Şirket Konferans Salonu",
"Status": "published",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"ParticipantsCount": 40,
"IsPublished": true,
"Likes": 0,
@ -1903,55 +1903,55 @@
"EventComments": [
{
"EventName": "Yaz Futbol Turnuvası 2025",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "Muhteşem bir gündü! Yılın en güzel etkinliği 🎉",
"Likes": 12
},
{
"EventName": "Yaz Futbol Turnuvası 2025",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "Voleybol turnuvası harikaydı, gelecek yıl yine yapalım!",
"Likes": 8
},
{
"EventName": "Kültür Gezisi: Kapadokya",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "Ekibimiz 2. oldu! Çok gurur duydum herkesle 💪",
"Likes": 15
},
{
"EventName": "Kültür Gezisi: Kapadokya",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "Gece boyunca kod yazmak ve pizza yemek priceless! 🍕",
"Likes": 10
},
{
"EventName": "Müzik Dinletisi: Jazz Akşamı",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "İT departmanı şampiyon oldu! Gelecek sene kupayı koruyacağız 🏆",
"Likes": 18
},
{
"EventName": "Müzik Dinletisi: Jazz Akşamı",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "Yılın en şık gecesi! Organizasyon mükemmeldi 👏",
"Likes": 25
},
{
"EventName": "Müzik Dinletisi: Jazz Akşamı",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "Tombala hediyelerim harika, çok teşekkürler! 🎁",
"Likes": 14
},
{
"EventName": "Müzik Dinletisi: Jazz Akşamı",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "Müzik grubunuz süperdi, dans pistinden ayrılamadık! 🎵",
"Likes": 19
},
{
"EventName": "Müzik Dinletisi: Jazz Akşamı",
"UserName": "system@sozsoft.com",
"UserName": "SYSTEM@SOZSOFT.COM",
"Content": "İlk defa ebru yaptım, çok huzurlu bir deneyimdi 🎨",
"Likes": 11
}

View file

@ -10,6 +10,7 @@ using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
using Serilog;
using static Sozsoft.Settings.SettingsConsts;
@ -38,9 +39,10 @@ internal static class SetupAppRunner
if (DefaultDatabaseProvider == DatabaseProvider.SqlServer)
return SqlServerIsReady(connectionString);
#pragma warning disable CS0162
return true; // Diğer sağlayıcılar için geçici — ileride PostgreSQL desteği eklenecek
#pragma warning restore CS0162
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
return PostgreSqlIsReady(connectionString);
return false;
}
catch (Exception ex)
{
@ -83,6 +85,46 @@ internal static class SetupAppRunner
return (int)tableCheck.ExecuteScalar() > 0;
}
private static bool PostgreSqlIsReady(string connectionString)
{
var csb = new NpgsqlConnectionStringBuilder(connectionString);
var dbName = csb.Database;
if (string.IsNullOrEmpty(dbName))
return false;
var maintenanceCsb = new NpgsqlConnectionStringBuilder(connectionString)
{
Database = "postgres",
Timeout = 8,
CommandTimeout = 8
};
using var maintenanceConn = new NpgsqlConnection(maintenanceCsb.ConnectionString);
maintenanceConn.Open();
using var dbCheck = new NpgsqlCommand(
"SELECT COUNT(1) FROM pg_database WHERE datname = @n",
maintenanceConn);
dbCheck.Parameters.AddWithValue("n", dbName);
if (Convert.ToInt32(dbCheck.ExecuteScalar()) == 0)
return false;
csb.Timeout = 8;
csb.CommandTimeout = 8;
using var dbConn = new NpgsqlConnection(csb.ConnectionString);
dbConn.Open();
using var tableCheck = new NpgsqlCommand(
"""
SELECT COUNT(1)
FROM information_schema.tables
WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
AND lower(table_name) = lower('AbpRoles')
""",
dbConn);
return Convert.ToInt32(tableCheck.ExecuteScalar()) > 0;
}
// Minimal Kurulum Uygulaması
public static async Task<int> RunAsync(string[] args, IConfiguration configuration)

View file

@ -337,6 +337,12 @@ public class PlatformHttpApiHostModule : AbpModule
private void ConfigureHangfire(ServiceConfigurationContext context, IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString(DefaultDatabaseProvider);
if (connectionString.IsNullOrWhiteSpace() || !SetupAppRunner.DatabaseIsReady(configuration))
{
return;
}
Configure<AbpHangfireOptions>(options =>
{
options.ServerOptions = new BackgroundJobServerOptions
@ -345,31 +351,25 @@ public class PlatformHttpApiHostModule : AbpModule
};
});
// Configure Hangfire storage based on database provider
// Note: Currently DefaultDatabaseProvider is set to SqlServer in SettingsConsts.cs
// PostgreSQL configuration is preserved for potential future use
#pragma warning disable CS0162 // Unreachable code detected
#pragma warning disable CS0618 // Type or member is obsolete
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
{
context.Services.AddHangfire(options =>
{
options.UsePostgreSqlStorage(
configuration.GetConnectionString(DefaultDatabaseProvider),
storageOptions => storageOptions.UseNpgsqlConnection(connectionString),
new PostgreSqlStorageOptions
{
PrepareSchemaIfNecessary = true
});
});
});
}
#pragma warning restore CS0618
#pragma warning restore CS0162
else if (DefaultDatabaseProvider == DatabaseProvider.SqlServer)
{
context.Services.AddHangfire(options =>
{
options.UseSqlServerStorage(
configuration.GetConnectionString(DefaultDatabaseProvider),
connectionString,
new SqlServerStorageOptions
{
PrepareSchemaIfNecessary = true,
@ -379,9 +379,10 @@ public class PlatformHttpApiHostModule : AbpModule
QueuePollInterval = TimeSpan.Zero,
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true
});
});
});
}
#pragma warning restore CS0162
}

View file

@ -19,6 +19,8 @@ public class Program
{
public async static Task<int> Main(string[] args)
{
ConfigurePostgreSqlCompatibility();
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
@ -191,6 +193,14 @@ public class Program
Log.CloseAndFlush();
}
}
private static void ConfigurePostgreSqlCompatibility()
{
if (DefaultDatabaseProvider == DatabaseProvider.PostgreSql)
{
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
}
}

View file

@ -0,0 +1 @@
docker compose -f .\docker-compose-data.yml --profile postgres up -d

View file

@ -0,0 +1 @@
docker compose -f .\docker-compose-data.yml --profile postgres down -v

View file

@ -0,0 +1 @@
docker compose -f .\docker-compose-data.yml --profile sql down -v

View file

@ -1,6 +1,17 @@
{
"commit": "8f3932b",
"commit": "0b5eb3d",
"releases": [
{
"version": "1.1.01",
"buildDate": "2026-05-24",
"commit": "6262baa6f12d695a25d83304af985092715d439a",
"changeLog": [
"- Workflow tanımlaması yapılabilir.",
"- Dark mod için uygulama güncellemesi",
"- Form Field kısmında düzenleme",
"- Hangfire Recurring Job düzenlemesi."
]
},
{
"version": "1.0.10",
"buildDate": "2026-05-11",

View file

@ -27,12 +27,14 @@ export interface SqlTemplateDto {
export interface DatabaseTableDto {
schemaName: string
tableName: string
dataSourceType?: string
fullName: string
}
export interface SqlNativeObjectDto {
schemaName: string
objectName: string
dataSourceType?: string
fullName: string
}

View file

@ -39,6 +39,16 @@ export class SqlObjectManagerService {
{ apiName: this.apiName, ...config },
)
getTableCreateScript = (dataSourceCode: string, schemaName: string, tableName: string, config?: Partial<Config>) =>
apiService.fetchData<string, void>(
{
method: 'GET',
url: '/api/app/sql-object-manager/table-create-script',
params: { dataSourceCode, schemaName, tableName },
},
{ apiName: this.apiName, ...config },
)
getNativeObjectDefinition = (dataSourceCode: string, schemaName: string, objectName: string, config?: Partial<Config>) =>
apiService.fetchData<string, void>(
{

View file

@ -13,6 +13,7 @@ import {
FaTrash,
} from 'react-icons/fa'
import type { DatabaseTableDto, SqlNativeObjectDto } from '@/proxy/sql-query-manager/models'
import { DataSourceTypeEnum } from '@/proxy/form/models'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
@ -29,6 +30,7 @@ interface TreeNode {
interface SqlObjectExplorerProps {
dataSource: string | null
dataSourceType?: DataSourceTypeEnum
onTemplateSelect?: (template: string, templateType: string) => void
onViewDefinition?: (schemaName: string, objectName: string) => void
onGenerateTableScript?: (schemaName: string, tableName: string) => void
@ -55,6 +57,7 @@ const FOLDER_META: Record<FolderKey, { label: string; color: string }> = {
const SqlObjectExplorer = ({
dataSource,
dataSourceType,
onTemplateSelect,
onViewDefinition,
onGenerateTableScript,
@ -64,6 +67,7 @@ const SqlObjectExplorer = ({
refreshTrigger,
}: SqlObjectExplorerProps) => {
const { translate } = useLocalization()
const isPostgreSql = dataSourceType === DataSourceTypeEnum.Postgresql
const [treeData, setTreeData] = useState<TreeNode[]>([])
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set(['root']))
const [loading, setLoading] = useState(false)
@ -182,7 +186,13 @@ const SqlObjectExplorer = ({
if (node.folder === 'tables') {
// Generate SELECT template for tables
const t = node.data as DatabaseTableDto
onTemplateSelect?.(`SELECT TOP 10 *\nFROM ${t.fullName ?? `[${t.schemaName}].[${t.tableName}]`};`, 'table-select')
const fullName = t.fullName ?? (isPostgreSql ? `"${t.schemaName}"."${t.tableName}"` : `[${t.schemaName}].[${t.tableName}]`)
onTemplateSelect?.(
isPostgreSql
? `SELECT *\nFROM ${fullName}\nLIMIT 10;`
: `SELECT TOP 10 *\nFROM ${fullName};`,
'table-select',
)
} else {
// Load native object definition into editor
const obj = node.data as SqlNativeObjectDto
@ -250,14 +260,16 @@ const SqlObjectExplorer = ({
const buildDropSql = (node: TreeNode): string => {
if (node.folder === 'tables') {
const t = node.data as DatabaseTableDto
return `DROP TABLE ${t.fullName ?? `[${t.schemaName}].[${t.tableName}]`};`
const fullName = t.fullName ?? (isPostgreSql ? `"${t.schemaName}"."${t.tableName}"` : `[${t.schemaName}].[${t.tableName}]`)
return isPostgreSql ? `DROP TABLE IF EXISTS ${fullName};` : `DROP TABLE ${fullName};`
}
const obj = node.data as SqlNativeObjectDto
const fullName = obj.fullName ?? (isPostgreSql ? `"${obj.schemaName}"."${obj.objectName}"` : `[${obj.schemaName}].[${obj.objectName}]`)
const keyword =
node.folder === 'views' ? 'VIEW' :
node.folder === 'procedures' ? 'PROCEDURE' :
'FUNCTION'
return `DROP ${keyword} ${obj.fullName ?? `[${obj.schemaName}].[${obj. objectName}]`};`
return isPostgreSql ? `DROP ${keyword} IF EXISTS ${fullName};` : `DROP ${keyword} ${fullName};`
}
const getSqlDataFileCandidates = (node: TreeNode): string[] => {

View file

@ -4,6 +4,7 @@ import Container from '@/components/shared/Container'
import ConfirmDialog from '@/components/shared/ConfirmDialog'
import { getDataSources } from '@/services/data-source.service'
import type { DataSourceDto } from '@/proxy/data-source'
import { DataSourceTypeEnum } from '@/proxy/form/models'
import type { SqlQueryExecutionResultDto } from '@/proxy/sql-query-manager/models'
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
import { FaDatabase, FaPlay, FaFileAlt, FaCopy, FaExclamationTriangle } from 'react-icons/fa'
@ -139,9 +140,16 @@ const SqlQueryManager = () => {
const escapeSqlLiteral = (value: string) => value.replace(/'/g, "''")
const escapeSqlIdentifier = (value: string) => value.replace(/]/g, ']]')
const escapePgIdentifier = (value: string) => value.replace(/"/g, '""')
const getSafeFullName = (schemaName: string, objectName: string) =>
`[${escapeSqlIdentifier(schemaName)}].[${escapeSqlIdentifier(objectName)}]`
const getSafePgFullName = (schemaName: string, objectName: string) =>
`"${escapePgIdentifier(schemaName)}"."${escapePgIdentifier(objectName)}"`
const selectedDataSourceType = state.dataSources.find(
(item) => item.code === state.selectedDataSource,
)?.dataSourceType
const isPostgreSql = selectedDataSourceType === DataSourceTypeEnum.Postgresql
const buildTableScriptQuery = (schemaName: string, tableName: string) => {
const fullName = getSafeFullName(schemaName, tableName)
@ -231,23 +239,40 @@ SELECT
const getTableCreateScript = async (schemaName: string, tableName: string): Promise<string> => {
if (!state.selectedDataSource) return ''
const result = await sqlObjectManagerService.executeQuery({
queryText: buildTableScriptQuery(schemaName, tableName),
dataSourceCode: state.selectedDataSource,
})
const result = await sqlObjectManagerService.getTableCreateScript(
state.selectedDataSource,
schemaName,
tableName,
)
const firstRow = result.data?.data?.[0]
if (!firstRow) return ''
return firstRow.Script || firstRow.script || ''
return result.data || ''
}
const normalizeNativeDefinitionToCreate = (definition: string) => {
if (!definition?.trim()) return ''
if (isPostgreSql) return definition
return definition.replace(/^\s*(?:CREATE|ALTER)\s+(?:OR\s+ALTER\s+)?/i, 'CREATE OR ALTER ')
}
const buildDropIfExistsScript = (obj: SqlExplorerSelectedObject) => {
if (isPostgreSql) {
const fullName = getSafePgFullName(obj.schemaName, obj.objectName)
if (obj.objectType === 'table') {
return `DROP TABLE IF EXISTS ${fullName};`
}
if (obj.objectType === 'view') {
return `DROP VIEW IF EXISTS ${fullName};`
}
if (obj.objectType === 'procedure') {
return `DROP PROCEDURE IF EXISTS ${fullName};`
}
return `DROP FUNCTION IF EXISTS ${fullName};`
}
const fullName = getSafeFullName(obj.schemaName, obj.objectName)
if (obj.objectType === 'table') {
@ -266,6 +291,33 @@ SELECT
}
const buildObjectExistsCheckQuery = (obj: SqlExplorerSelectedObject) => {
if (isPostgreSql) {
const schema = escapeSqlLiteral(obj.schemaName)
const name = escapeSqlLiteral(obj.objectName)
if (obj.objectType === 'table') {
return `SELECT CASE WHEN EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = '${schema}' AND table_name = '${name}' AND table_type = 'BASE TABLE'
) THEN 1 ELSE 0 END AS "ExistsFlag";`
}
if (obj.objectType === 'view') {
return `SELECT CASE WHEN EXISTS (
SELECT 1 FROM information_schema.views
WHERE table_schema = '${schema}' AND table_name = '${name}'
) THEN 1 ELSE 0 END AS "ExistsFlag";`
}
const proKind = obj.objectType === 'procedure' ? 'p' : 'f'
return `SELECT CASE WHEN EXISTS (
SELECT 1
FROM pg_proc p
INNER JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = '${schema}' AND p.proname = '${name}' AND p.prokind = '${proKind}'
) THEN 1 ELSE 0 END AS "ExistsFlag";`
}
const fullName = getSafeFullName(obj.schemaName, obj.objectName)
const escapedFullName = escapeSqlLiteral(fullName)
@ -299,6 +351,88 @@ SELECT
}
const getTemplateContent = (templateType: string): string => {
if (isPostgreSql) {
const pgTemplates: Record<string, string> = {
select: `-- Basic SELECT query
SELECT
"Column1",
"Column2",
"Column3"
FROM
"public"."TableName"
WHERE
"Column1" = 'value'
ORDER BY
"Column1" ASC
LIMIT 100;`,
insert: `-- Basic INSERT query
INSERT INTO "public"."TableName" ("Column1", "Column2", "Column3")
VALUES
('Value1', 'Value2', 'Value3');`,
update: `-- Basic UPDATE query
UPDATE "public"."TableName"
SET
"Column1" = 'NewValue1',
"Column2" = 'NewValue2'
WHERE
"Id" = '00000000-0000-0000-0000-000000000000';`,
delete: `-- Basic DELETE query
DELETE FROM "public"."TableName"
WHERE
"Id" = '00000000-0000-0000-0000-000000000000';`,
'create-procedure': `-- Create Stored Procedure
CREATE OR REPLACE PROCEDURE "public"."ProcedureName"(
"Parameter1" integer,
"Parameter2" varchar
)
LANGUAGE plpgsql
AS $$
BEGIN
-- Add your logic here
END;
$$;`,
'create-view': `-- Create View
CREATE OR REPLACE VIEW "public"."ViewName" AS
SELECT
t1."Column1",
t1."Column2"
FROM "public"."TableName1" t1
WHERE t1."IsActive" = TRUE;`,
'create-scalar-function': `-- Create Scalar Function
CREATE OR REPLACE FUNCTION "public"."ScalarFunctionName"(
"Parameter1" integer,
"Parameter2" varchar
)
RETURNS varchar
LANGUAGE plpgsql
AS $$
BEGIN
RETURN "Parameter2";
END;
$$;`,
'create-table-function': `-- Create Table-Valued Function
CREATE OR REPLACE FUNCTION "public"."TableFunctionName"(
"Parameter1" integer
)
RETURNS TABLE("Column1" integer, "Column2" varchar)
LANGUAGE sql
AS $$
SELECT t."Column1", t."Column2"
FROM "public"."TableName" t
WHERE t."Id" = "Parameter1";
$$;`,
}
return pgTemplates[templateType] || pgTemplates.select
}
const templates: Record<string, string> = {
select: `-- Basic SELECT query
SELECT
@ -981,6 +1115,7 @@ GO`,
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<SqlObjectExplorer
dataSource={state.selectedDataSource}
dataSourceType={selectedDataSourceType}
onTemplateSelect={handleTemplateSelect}
onViewDefinition={handleViewDefinition}
onGenerateTableScript={handleGenerateTableScript}

View file

@ -35,6 +35,14 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
const [hoveredCommentAuthor, setHoveredCommentAuthor] = useState<string | null>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const { user } = useStoreState((state) => state.auth)
const postUser = post.user ?? user ?? {}
const postComments = post.comments ?? []
const postLikeCount = post.likeCount ?? 0
const postUserId = postUser.id ?? ''
const postUserTenantId = postUser.tenantId ?? ''
const postUserFullName =
postUser.fullName || [postUser.name, postUser.surname].filter(Boolean).join(' ') || '-'
const postUserTitle = postUser.jobPositions?.[0]?.name || ''
// Intersection Observer for video autoplay/pause
useEffect(() => {
@ -177,7 +185,8 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
case 'poll':
if (post.media.pollQuestion && post.media.pollOptions) {
const isExpired = post.media.pollEndsAt ? new Date() > post.media.pollEndsAt : false
const pollEndsAt = post.media.pollEndsAt ? new Date(post.media.pollEndsAt) : null
const isExpired = pollEndsAt ? new Date() > pollEndsAt : false
const hasVoted = !!post.media.pollUserVoteId
const totalVotes = post.media.pollTotalVotes || 0
const pollUserVoteId = post.media.pollUserVoteId
@ -189,13 +198,13 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
</h4>
<div className="space-y-2">
{post.media.pollOptions.map((option) => {
const percentage = totalVotes > 0 ? (option.votes / totalVotes) * 100 : 0
const percentage = totalVotes > 0 ? ((option.votes ?? 0) / totalVotes) * 100 : 0
const isSelected = pollUserVoteId === option.id
return (
<button
key={option.id}
onClick={() => !hasVoted && !isExpired && onVote(post.id, option.id)}
onClick={() => option.id && !hasVoted && !isExpired && onVote(post.id, option.id)}
disabled={hasVoted || isExpired}
className={classNames(
'w-full text-left p-3 rounded-lg relative overflow-hidden transition-all',
@ -231,8 +240,8 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
{totalVotes} oy {' '}
{isExpired
? 'Sona erdi'
: post.media.pollEndsAt
? dayjs(post.media.pollEndsAt).fromNow() + ' bitiyor'
: pollEndsAt
? dayjs(pollEndsAt).fromNow() + ' bitiyor'
: ''}
</div>
</div>
@ -259,18 +268,18 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
onMouseEnter={() => setShowUserCard(true)}
onMouseLeave={() => setShowUserCard(false)}
>
<Avatar size={32} shape="circle" src={AVATAR_URL(post.user.id, post.user.tenantId)} />
<Avatar size={32} shape="circle" src={AVATAR_URL(postUserId, postUserTenantId)} />
<AnimatePresence>
{showUserCard && (
<UserProfileCard
user={{
id: post.user.id || '',
name: post.user.fullName || '',
title: post.user.jobPositions?.[0]?.name || '',
email: post.user.email,
phoneNumber: post.user.phoneNumber,
department: post.user.departments?.[0]?.name,
tenantId: post.user.tenantId || '',
id: postUserId,
name: postUserFullName,
title: postUserTitle,
email: postUser.email,
phoneNumber: postUser.phoneNumber,
department: postUser.departments?.[0]?.name,
tenantId: postUserTenantId,
}}
position="bottom"
/>
@ -278,9 +287,9 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
</AnimatePresence>
</div>
<div>
<h3 className="text-sm text-gray-900 dark:text-gray-100">{post.user.fullName}</h3>
<h3 className="text-sm text-gray-900 dark:text-gray-100">{postUserFullName}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{post.user.jobPositions?.[0]?.name || ''} {dayjs(post.creationTime).fromNow()}
{postUserTitle} {dayjs(post.creationTime).fromNow()}
</p>
</div>
</div>
@ -313,7 +322,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
)}
>
{post.isLiked ? <FaHeart className="w-5 h-5" /> : <FaRegHeart className="w-5 h-5" />}
<span className="text-sm font-medium">{post.likeCount}</span>
<span className="text-sm font-medium">{postLikeCount}</span>
</button>
<button
@ -321,7 +330,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 transition-colors"
>
<FaRegCommentAlt className="w-5 h-5" />
<span className="text-sm font-medium">{post.comments.length}</span>
<span className="text-sm font-medium">{postComments.length}</span>
</button>
</div>
@ -358,7 +367,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
{/* Comments List */}
<div className="space-y-3">
{post.comments.map((comment) => (
{postComments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div
className="relative"
@ -368,16 +377,16 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
<Avatar
size={32}
shape="circle"
src={AVATAR_URL(comment.user.id, comment.user.tenantId)}
src={AVATAR_URL(comment.user?.id ?? '', comment.user?.tenantId ?? '')}
/>
<AnimatePresence>
{hoveredCommentAuthor === comment.id && (
<UserProfileCard
user={{
id: comment.user.id || '',
name: comment.user.fullName || '',
title: comment.user.jobPositions?.[0]?.name || '',
tenantId: comment.user.tenantId || '',
id: comment.user?.id || '',
name: comment.user?.fullName || '',
title: comment.user?.jobPositions?.[0]?.name || '',
tenantId: comment.user?.tenantId || '',
}}
position="bottom"
/>
@ -387,7 +396,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
<div className="flex-1">
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg px-4 py-2">
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100">
{comment.user.fullName}
{comment.user?.fullName || '-'}
</h4>
<p className="text-sm text-gray-800 dark:text-gray-200">{comment.content}</p>
</div>

View file

@ -38,7 +38,14 @@ const AnnouncementModal: React.FC<AnnouncementModalProps> = ({ announcement, onC
intranetService.incrementAnnouncementViewCount(announcement.id)
}, [announcement.id])
const announcementUser = announcement.user ?? {}
const announcementUserName =
announcementUser.fullName ||
[announcementUser.name, announcementUser.surname].filter(Boolean).join(' ') ||
'-'
const images = announcement.imageUrl ? announcement.imageUrl.split('|').filter(Boolean) : []
const attachments = Array.isArray(announcement.attachments) ? announcement.attachments : []
const category = announcement.category || 'general'
const imgSrc = (img: string) =>
img.startsWith('data:') || img.startsWith('http://') || img.startsWith('https://') || img.startsWith('/')
@ -79,17 +86,17 @@ const AnnouncementModal: React.FC<AnnouncementModalProps> = ({ announcement, onC
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<span
className={`px-3 py-1 text-xs font-medium rounded-full ${getCategoryColor(announcement.category)}`}
className={`px-3 py-1 text-xs font-medium rounded-full ${getCategoryColor(category)}`}
>
{announcement.category === 'general' &&
{category === 'general' &&
`📢 ${translate('::App.Platform.Intranet.AnnouncementDetailModal.Category.General')}`}
{announcement.category === 'hr' &&
{category === 'hr' &&
`👥 ${translate('::App.Platform.Intranet.AnnouncementDetailModal.Category.HR')}`}
{announcement.category === 'it' &&
{category === 'it' &&
`💻 ${translate('::App.Platform.Intranet.AnnouncementDetailModal.Category.IT')}`}
{announcement.category === 'event' &&
{category === 'event' &&
`🎉 ${translate('::App.Platform.Intranet.AnnouncementDetailModal.Category.Event')}`}
{announcement.category === 'urgent' &&
{category === 'urgent' &&
`🚨 ${translate('::App.Platform.Intranet.AnnouncementDetailModal.Category.Urgent')}`}
</span>
{announcement.isPinned && (
@ -115,11 +122,11 @@ const AnnouncementModal: React.FC<AnnouncementModalProps> = ({ announcement, onC
<Avatar
size={32}
shape="circle"
src={AVATAR_URL(announcement.user.id, announcement.user.tenantId)}
src={AVATAR_URL(announcementUser.id ?? '', announcementUser.tenantId ?? '')}
/>
<div>
<p className="font-semibold text-gray-900 dark:text-white">
{announcement.user.fullName}
{announcementUserName}
</p>
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<span>{currentLocalDate(announcement.publishDate, currentLocale || 'tr')}</span>
@ -174,15 +181,15 @@ const AnnouncementModal: React.FC<AnnouncementModalProps> = ({ announcement, onC
</div>
{/* Attachments */}
{announcement.attachments && announcement.attachments.length > 0 && (
{attachments.length > 0 && (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<FaClipboard className="w-5 h-5" />
{translate('::App.Platform.Intranet.AnnouncementDetailModal.Attachments')} (
{announcement.attachments.length})
{attachments.length})
</h3>
<div className="space-y-2">
{announcement.attachments.map((attachment, idx) => (
{attachments.map((attachment, idx) => (
<a
key={idx}
href={attachment.url}

View file

@ -12,7 +12,8 @@ interface AnnouncementsProps {
}
const Announcements: React.FC<AnnouncementsProps> = ({ announcements, onAnnouncementClick }) => {
const pinnedAnnouncements = announcements.filter((a) => a.isPinned).slice(0, 3)
const safeAnnouncements = announcements ?? []
const pinnedAnnouncements = safeAnnouncements.filter((a) => a?.isPinned).slice(0, 3)
const { translate } = useLocalization()
const getCategoryColor = (category: string) => {
@ -26,6 +27,15 @@ const Announcements: React.FC<AnnouncementsProps> = ({ announcements, onAnnounce
return colors[category] || colors.general
}
const getUserName = (announcement: AnnouncementDto) => {
const announcementUser = announcement.user
return (
announcementUser?.fullName ||
[announcementUser?.name, announcementUser?.surname].filter(Boolean).join(' ') ||
'-'
)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
@ -47,11 +57,11 @@ const Announcements: React.FC<AnnouncementsProps> = ({ announcements, onAnnounce
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{announcement.title}
{announcement.title || '-'}
</h3>
{announcement.category && (
<span
className={`px-2 py-1 text-center text-xs rounded-full ${getCategoryColor(announcement.category)}`}
className={`px-2 py-1 text-center text-xs rounded-full ${getCategoryColor(announcement.category || 'general')}`}
>
{(() => {
const key = `::App.Platform.Intranet.Widgets.Announcements.Category.${announcement.category.charAt(0).toUpperCase() + announcement.category.slice(1)}`
@ -62,21 +72,21 @@ const Announcements: React.FC<AnnouncementsProps> = ({ announcements, onAnnounce
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{announcement.excerpt}
{announcement.excerpt || ''}
</p>
<div className="flex items-center gap-2 mt-3 text-xs text-gray-500 dark:text-gray-400">
<Avatar
size={24}
shape="circle"
src={AVATAR_URL(announcement.user.id, announcement.user.tenantId)}
src={AVATAR_URL(announcement.user?.id ?? '', announcement.user?.tenantId ?? '')}
/>
<span>{announcement.user.fullName}</span>
<span>{getUserName(announcement)}</span>
<span></span>
<span>{dayjs(announcement.publishDate).fromNow()}</span>
<span></span>
<span className="flex items-center gap-1">
<FaEye className="w-3 h-3" />
{announcement.viewCount}
{announcement.viewCount ?? 0}
</span>
</div>
</div>

View file

@ -35,10 +35,16 @@ const UpcomingEvents: React.FC<UpcomingEventsProps> = ({ events, onEventClick })
const { translate } = useLocalization()
const now = dayjs()
const upcomingEvents = events
.filter((event) => event.isPublished && !dayjs(event.date).isBefore(now, 'day'))
const upcomingEvents = (events ?? [])
.filter((event) => event?.isPublished && !dayjs(event.date).isBefore(now, 'day'))
.sort((left, right) => dayjs(left.date).valueOf() - dayjs(right.date).valueOf())
const getUserName = (event: EventDto) => (
event.user?.fullName ||
[event.user?.name, event.user?.surname].filter(Boolean).join(' ') ||
'-'
)
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
@ -72,9 +78,9 @@ const UpcomingEvents: React.FC<UpcomingEventsProps> = ({ events, onEventClick })
<Avatar
size={24}
shape="circle"
src={AVATAR_URL(event.user.id, event.user.tenantId)}
src={AVATAR_URL(event.user?.id ?? '', event.user?.tenantId ?? '')}
/>
<span>{event.user.fullName}</span>
<span>{getUserName(event)}</span>
<span></span>
<span>{dayjs(event.date).fromNow()}</span>
{event.likes > 0 && (