Skip to content

Commit eb40270

Browse files
committed
Add PostgreSQL extension metadata helpers
1 parent d6050d6 commit eb40270

5 files changed

Lines changed: 103 additions & 54 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ The repository is intentionally split into small NuGet packages. Install only th
3737
| [EasyExtensions.Crypto](https://www.nuget.org/packages/EasyExtensions.Crypto/) | `net10.0` | Streaming AES-GCM encryption/decryption, per-chunk authentication, HKDF subkeys, secure random bytes, and hash helpers. |
3838
| [EasyExtensions.Drawing](https://www.nuget.org/packages/EasyExtensions.Drawing/) | `net10.0` | ImageSharp helpers for JPEG conversion, drawing text, blurred backgrounds, automatic brightness adjustment, and font integration. |
3939
| [EasyExtensions.EntityFrameworkCore](https://www.nuget.org/packages/EasyExtensions.EntityFrameworkCore/) | `net10.0` | Audited entities and DbContext base types, Gridify mapper registration, database health checks, and migration helpers. |
40-
| [EasyExtensions.EntityFrameworkCore.Npgsql](https://www.nuget.org/packages/EasyExtensions.EntityFrameworkCore.Npgsql/) | `net10.0` | PostgreSQL DbContext registration, connection string construction from configuration, lazy loading options, and design-time factory registration. |
40+
| [EasyExtensions.EntityFrameworkCore.Npgsql](https://www.nuget.org/packages/EasyExtensions.EntityFrameworkCore.Npgsql/) | `net10.0` | PostgreSQL DbContext registration, connection string construction, extension metadata checks, lazy loading options, and design-time factory registration. |
4141
| [EasyExtensions.Fonts](https://www.nuget.org/packages/EasyExtensions.Fonts/) | `netstandard2.1` | Embedded fonts: Arial, Consola, FreeMonospaced, RetroGaming, and UbuntuMono. |
4242
| [EasyExtensions.Mediator](https://www.nuget.org/packages/EasyExtensions.Mediator/) | `netstandard2.1` | A MediatR v12.5.0 based mediator package with request, notification, stream request, pipeline behavior, pre/post processor, and exception processor support. |
4343
| [EasyExtensions.Quartz](https://www.nuget.org/packages/EasyExtensions.Quartz/) | `netstandard2.1` | Reflection-based Quartz job registration with `JobTriggerAttribute`, hosted service setup, and optional PostgreSQL persistent store. |
@@ -74,7 +74,7 @@ The analyzer package enables the following build errors by default. Their severi
7474
| `EEX0006` | Entities rooted in `DbSet<T>`, `[Table]`, or an existing `BaseEntity<T>` graph derive from `BaseEntity<T>`, except explicit natural-key entities with `[Key]` and no conventional `Id`. |
7575
| `EEX0007` | `*Dto` types with a non-nullable value-type `Id` derive from `BaseDto<T>`. |
7676
| `EEX0008` | Concrete Quartz `IJob` implementations declare `JobTriggerAttribute`. |
77-
| `EEX0009` | EF Core raw SQL, Dapper query/execute APIs, and `DbCommand.CommandText` are not used, except constant `pg_catalog.pg_extension` existence checks through `SqlQueryRaw<bool>`. |
77+
| `EEX0009` | EF Core raw SQL, Dapper query/execute APIs, and `DbCommand.CommandText` are not used. |
7878
| `EEX0010` | EF entity properties and fields do not end with `Utc`. |
7979
| `EEX0011` | Reflection discovery, activation, and invocation APIs require an explicit diagnostic suppression; runtime type names and `Type.IsAssignableFrom` are allowed. |
8080
| `EEX0012` | EF entity properties avoid business defaults: non-nullable strings, required byte arrays, and other reference values use `null!`; non-nullable collections may use `[]`; nullable properties have no initializer. |
@@ -169,6 +169,9 @@ builder.Services.AddPostgresDbContext<AppDbContext>(postgres =>
169169
postgres.ConfigurationSection = "DatabaseSettings";
170170
postgres.UseLazyLoadingProxies = false;
171171
});
172+
173+
bool isInstalled = await dbContext.Database.IsExtensionInstalledAsync("vector", cancellationToken);
174+
bool isAvailable = await dbContext.Database.IsExtensionAvailableAsync("vector", cancellationToken);
172175
```
173176

174177
```json

‎Sources/EasyExtensions.Analyzers.Tests/RawSqlAnalyzerTests.cs‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public async Task InitializeAsync(DbContext context)
8383
}
8484

8585
[Test]
86-
public async Task ConstantExtensionExistenceQuery_DoesNotReportDiagnostic()
86+
public async Task ConstantInstalledExtensionQuery_ReportsDiagnostic()
8787
{
8888
const string source = """
8989
using Microsoft.EntityFrameworkCore;
@@ -106,7 +106,29 @@ public async Task<bool> IsVectorInstalledAsync(DbContext context)
106106
source,
107107
analyzer: new RawSqlAnalyzer());
108108

109-
Assert.That(diagnostics, Is.Empty);
109+
AssertDiagnostic(diagnostics);
110+
}
111+
112+
[Test]
113+
public async Task ConstantAvailableExtensionQuery_ReportsDiagnostic()
114+
{
115+
const string source = """
116+
using Microsoft.EntityFrameworkCore;
117+
118+
public class DatabaseInitializer
119+
{
120+
public void IsVectorAvailable(DbContext context)
121+
{
122+
context.Database.SqlQueryRaw<bool>("SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = 'vector') AS \"Value\"");
123+
}
124+
}
125+
""";
126+
127+
ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestRunner.GetDiagnosticsAsync(
128+
source,
129+
analyzer: new RawSqlAnalyzer());
130+
131+
AssertDiagnostic(diagnostics);
110132
}
111133

112134
[Test]

‎Sources/EasyExtensions.Analyzers/DiagnosticDescriptors.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ internal static class DiagnosticDescriptors
9696
"EntityFrameworkCore",
9797
DiagnosticSeverity.Error,
9898
isEnabledByDefault: true,
99-
description: "Database access must use Entity Framework Core modeled queries rather than raw SQL or command text, except constant PostgreSQL extension existence checks.",
99+
description: "Database access must use Entity Framework Core modeled queries rather than raw SQL or command text.",
100100
customTags: EnforcedSeverityTags);
101101

102102
public static readonly DiagnosticDescriptor EntityMemberUtcSuffix = new(

‎Sources/EasyExtensions.Analyzers/RawSqlAnalyzer.cs‎

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Collections.Immutable;
4-
using System.Text.RegularExpressions;
54
using Microsoft.CodeAnalysis;
65
using Microsoft.CodeAnalysis.Diagnostics;
76
using Microsoft.CodeAnalysis.Operations;
@@ -11,9 +10,6 @@ namespace EasyExtensions.Analyzers
1110
[DiagnosticAnalyzer(LanguageNames.CSharp)]
1211
public class RawSqlAnalyzer : DiagnosticAnalyzer
1312
{
14-
private static readonly Regex ExtensionExistenceQueryPattern = new(
15-
"""\A\s*SELECT\s+EXISTS\s*\(\s*SELECT\s+1\s+FROM\s+pg_catalog\.pg_extension\s+WHERE\s+extname\s*=\s*'(?:''|[^'])+'\s*\)\s+AS\s+"Value"\s*;?\s*\z""",
16-
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
1713
private static readonly HashSet<string> EfRawSqlMethodNames = new(StringComparer.Ordinal)
1814
{
1915
"ExecuteSql",
@@ -51,11 +47,6 @@ private static void AnalyzeInvocation(OperationAnalysisContext context)
5147
return;
5248
}
5349

54-
if (IsConstantExtensionExistenceQuery(invocation, method, namespaceName))
55-
{
56-
return;
57-
}
58-
5950
Report(context, invocation.Syntax.GetLocation(), invocation.TargetMethod.Name);
6051
}
6152

@@ -90,46 +81,6 @@ private static bool IsDapperMethod(IMethodSymbol method, string namespaceName)
9081
method.Name.StartsWith("Execute", StringComparison.Ordinal));
9182
}
9283

93-
private static bool IsConstantExtensionExistenceQuery(
94-
IInvocationOperation invocation,
95-
IMethodSymbol method,
96-
string namespaceName)
97-
{
98-
if (method.Name != "SqlQueryRaw" ||
99-
!namespaceName.StartsWith("Microsoft.EntityFrameworkCore", StringComparison.Ordinal) ||
100-
method.TypeArguments.Length != 1 ||
101-
method.TypeArguments[0].SpecialType != SpecialType.System_Boolean)
102-
{
103-
return false;
104-
}
105-
106-
foreach (IArgumentOperation argument in invocation.Arguments)
107-
{
108-
if (argument.Parameter?.Type.SpecialType != SpecialType.System_String)
109-
{
110-
continue;
111-
}
112-
113-
IOperation value = UnwrapConversion(argument.Value);
114-
115-
return value.ConstantValue.HasValue &&
116-
value.ConstantValue.Value is string sql &&
117-
ExtensionExistenceQueryPattern.IsMatch(sql);
118-
}
119-
120-
return false;
121-
}
122-
123-
private static IOperation UnwrapConversion(IOperation operation)
124-
{
125-
while (operation is IConversionOperation conversion)
126-
{
127-
operation = conversion.Operand;
128-
}
129-
130-
return operation;
131-
}
132-
13384
private static void Report(OperationAnalysisContext context, Location location, string apiName)
13485
{
13586
context.ReportDiagnostic(Diagnostic.Create(
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// SPDX-License-Identifier: MIT
2+
// Copyright (c) 2025–2026 Vadim Belov <https://belov.us>
3+
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.EntityFrameworkCore.Infrastructure;
6+
7+
namespace EasyExtensions.EntityFrameworkCore.Npgsql.Extensions
8+
{
9+
/// <summary>
10+
/// Provides PostgreSQL-specific extension methods for database metadata queries.
11+
/// </summary>
12+
public static class DatabaseFacadeExtensions
13+
{
14+
private const string InstalledExtensionQuery =
15+
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = {0}) AS \"Value\"";
16+
private const string AvailableExtensionQuery =
17+
"SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_available_extensions WHERE name = {0}) AS \"Value\"";
18+
19+
/// <summary>
20+
/// Determines whether a PostgreSQL extension is installed in the current database.
21+
/// </summary>
22+
/// <param name="database">The database facade used to execute the metadata query.</param>
23+
/// <param name="extensionName">The PostgreSQL extension name.</param>
24+
/// <param name="cancellationToken">The token used to cancel the asynchronous operation.</param>
25+
/// <returns><see langword="true"/> when the extension is installed; otherwise, <see langword="false"/>.</returns>
26+
public static Task<bool> IsExtensionInstalledAsync(
27+
this DatabaseFacade database,
28+
string extensionName,
29+
CancellationToken cancellationToken = default)
30+
{
31+
return ExtensionExistsAsync(
32+
database,
33+
InstalledExtensionQuery,
34+
extensionName,
35+
cancellationToken);
36+
}
37+
38+
/// <summary>
39+
/// Determines whether a PostgreSQL extension is available for installation on the server.
40+
/// </summary>
41+
/// <param name="database">The database facade used to execute the metadata query.</param>
42+
/// <param name="extensionName">The PostgreSQL extension name.</param>
43+
/// <param name="cancellationToken">The token used to cancel the asynchronous operation.</param>
44+
/// <returns><see langword="true"/> when the extension is available; otherwise, <see langword="false"/>.</returns>
45+
public static Task<bool> IsExtensionAvailableAsync(
46+
this DatabaseFacade database,
47+
string extensionName,
48+
CancellationToken cancellationToken = default)
49+
{
50+
return ExtensionExistsAsync(
51+
database,
52+
AvailableExtensionQuery,
53+
extensionName,
54+
cancellationToken);
55+
}
56+
57+
private static async Task<bool> ExtensionExistsAsync(
58+
DatabaseFacade database,
59+
string query,
60+
string extensionName,
61+
CancellationToken cancellationToken)
62+
{
63+
ArgumentNullException.ThrowIfNull(database);
64+
ArgumentException.ThrowIfNullOrWhiteSpace(extensionName);
65+
66+
string normalizedExtensionName = extensionName.Trim();
67+
68+
return await database
69+
.SqlQueryRaw<bool>(query, normalizedExtensionName)
70+
.SingleAsync(cancellationToken);
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)