From b1b6fb885cba6b05c9ea548bd01ae6839bb1c91f Mon Sep 17 00:00:00 2001 From: Graham Carr Date: Sun, 11 Jan 2026 20:17:20 +0000 Subject: [PATCH] Added query library functionality --- .claude/settings.local.json | 3 +- Sample/SampleCms/.config/dotnet-tools.json | 13 + .../AdministrationController.cs | 12 + .../IOptiGraphExtensionsDataContext.cs | 2 + .../OptiGraphExtensionsDataContext.cs | 2 + src/OptiGraphExtensions/Entities/QueryType.cs | 18 + .../Entities/SavedQuery.cs | 100 ++ .../OptiGraphExtensionsServiceExtensions.cs | 18 + .../Models/QueryExecutionModels.cs | 133 +++ .../QueryLibrary/Models/QueryFilter.cs | 23 + .../QueryLibrary/Models/SavedQueryModel.cs | 88 ++ .../QueryLibrary/Models/SchemaField.cs | 38 + .../QueryLibraryManagementComponent.razor | 833 +++++++++++++++++ .../QueryLibraryManagementComponentBase.cs | 855 ++++++++++++++++++ .../CachedSavedQueryRepository.cs | 138 +++ .../Repositories/ISavedQueryRepository.cs | 16 + .../Repositories/SavedQueryRepository.cs | 81 ++ .../Abstractions/ICsvExportService.cs | 28 + .../Abstractions/IQueryBuilderService.cs | 30 + .../Abstractions/IQueryExecutionService.cs | 34 + .../Services/Abstractions/IRawQueryService.cs | 34 + .../Abstractions/ISavedQueryService.cs | 50 + .../Abstractions/ISchemaDiscoveryService.cs | 25 + .../QueryLibrary/Services/CsvExportService.cs | 157 ++++ .../Services/QueryBuilderService.cs | 328 +++++++ .../Services/QueryExecutionService.cs | 347 +++++++ .../QueryLibrary/Services/RawQueryService.cs | 231 +++++ .../Services/SavedQueryService.cs | 223 +++++ .../Services/SchemaDiscoveryService.cs | 354 ++++++++ .../Features/QueryLibrary/_Imports.razor | 10 + .../Menus/OptiGraphExtensionsMenuProvider.cs | 1 + ...111000000_AddSavedQueriesTable.Designer.cs | 210 +++++ .../20260111000000_AddSavedQueriesTable.cs | 50 + ...GraphExtensionsDataContextModelSnapshot.cs | 70 ++ .../Administration/QueryLibrary/Index.cshtml | 8 + .../Layouts/_LayoutBlazorAdminPage.cshtml | 11 + 36 files changed, 4573 insertions(+), 1 deletion(-) create mode 100644 Sample/SampleCms/.config/dotnet-tools.json create mode 100644 src/OptiGraphExtensions/Entities/QueryType.cs create mode 100644 src/OptiGraphExtensions/Entities/SavedQuery.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryExecutionModels.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryFilter.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Models/SavedQueryModel.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Models/SchemaField.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponent.razor create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponentBase.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Repositories/CachedSavedQueryRepository.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Repositories/ISavedQueryRepository.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Repositories/SavedQueryRepository.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ICsvExportService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryBuilderService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryExecutionService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IRawQueryService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISavedQueryService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISchemaDiscoveryService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/CsvExportService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryBuilderService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryExecutionService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/RawQueryService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/SavedQueryService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/Services/SchemaDiscoveryService.cs create mode 100644 src/OptiGraphExtensions/Features/QueryLibrary/_Imports.razor create mode 100644 src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.Designer.cs create mode 100644 src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.cs create mode 100644 src/OptiGraphExtensions/Views/OptiGraphExtensions/Administration/QueryLibrary/Index.cshtml diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b3d5bdc..e9d0273 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "WebSearch", "Bash(Remove-Item -Recurse -Force \"D:\\Projects\\Optimizely\\OptiGraphExtensions\\src\\OptiGraphExtensions\\Features\\StopWords\")", "Bash(Remove-Item -Force \"D:\\Projects\\Optimizely\\OptiGraphExtensions\\src\\OptiGraphExtensions\\Entities\\StopWord.cs\")", - "Bash(Remove-Item -Recurse -Force \"D:\\Projects\\Optimizely\\OptiGraphExtensions\\src\\OptiGraphExtensions\\Views\\OptiGraphExtensions\\Administration\\StopWords\")" + "Bash(Remove-Item -Recurse -Force \"D:\\Projects\\Optimizely\\OptiGraphExtensions\\src\\OptiGraphExtensions\\Views\\OptiGraphExtensions\\Administration\\StopWords\")", + "Bash(dotnet restore:*)" ], "deny": [], "ask": [] diff --git a/Sample/SampleCms/.config/dotnet-tools.json b/Sample/SampleCms/.config/dotnet-tools.json new file mode 100644 index 0000000..fbb7b76 --- /dev/null +++ b/Sample/SampleCms/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.1", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/src/OptiGraphExtensions/Administration/AdministrationController.cs b/src/OptiGraphExtensions/Administration/AdministrationController.cs index da6aaf3..e28ccdf 100644 --- a/src/OptiGraphExtensions/Administration/AdministrationController.cs +++ b/src/OptiGraphExtensions/Administration/AdministrationController.cs @@ -57,6 +57,18 @@ public IActionResult Webhooks() return View("~/Views/OptiGraphExtensions/Administration/Webhooks/Index.cshtml", model); } + [HttpGet] + [Route("~/optimizely-graphextensions/administration/query-library")] + public IActionResult QueryLibrary() + { + var model = new AdministrationViewModel + { + Title = "Opti Graph Extensions - Query Library", + }; + + return View("~/Views/OptiGraphExtensions/Administration/QueryLibrary/Index.cshtml", model); + } + [HttpGet] [Route("~/optimizely-addon/administration/public/list")] [AllowAnonymous] diff --git a/src/OptiGraphExtensions/Entities/IOptiGraphExtensionsDataContext.cs b/src/OptiGraphExtensions/Entities/IOptiGraphExtensionsDataContext.cs index eb655f4..acc3836 100644 --- a/src/OptiGraphExtensions/Entities/IOptiGraphExtensionsDataContext.cs +++ b/src/OptiGraphExtensions/Entities/IOptiGraphExtensionsDataContext.cs @@ -10,6 +10,8 @@ public interface IOptiGraphExtensionsDataContext DbSet PinnedResults { get; set; } + DbSet SavedQueries { get; set; } + Task SaveChangesAsync(CancellationToken cancellationToken = default); } } diff --git a/src/OptiGraphExtensions/Entities/OptiGraphExtensionsDataContext.cs b/src/OptiGraphExtensions/Entities/OptiGraphExtensionsDataContext.cs index 980e3d2..0696b94 100644 --- a/src/OptiGraphExtensions/Entities/OptiGraphExtensionsDataContext.cs +++ b/src/OptiGraphExtensions/Entities/OptiGraphExtensionsDataContext.cs @@ -23,5 +23,7 @@ public OptiGraphExtensionsDataContext( public DbSet PinnedResultsCollections { get; set; } public DbSet PinnedResults { get; set; } + + public DbSet SavedQueries { get; set; } } } diff --git a/src/OptiGraphExtensions/Entities/QueryType.cs b/src/OptiGraphExtensions/Entities/QueryType.cs new file mode 100644 index 0000000..2c6e731 --- /dev/null +++ b/src/OptiGraphExtensions/Entities/QueryType.cs @@ -0,0 +1,18 @@ +namespace OptiGraphExtensions.Entities +{ + /// + /// Represents the type of saved query for the Query Library. + /// + public enum QueryType + { + /// + /// Visual query built using the query builder UI. + /// + Visual = 0, + + /// + /// Raw GraphQL query entered directly by the user. + /// + Raw = 1 + } +} diff --git a/src/OptiGraphExtensions/Entities/SavedQuery.cs b/src/OptiGraphExtensions/Entities/SavedQuery.cs new file mode 100644 index 0000000..a3fc66e --- /dev/null +++ b/src/OptiGraphExtensions/Entities/SavedQuery.cs @@ -0,0 +1,100 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OptiGraphExtensions.Entities +{ + /// + /// Represents a saved query configuration for the Query Library. + /// Supports both visual query builder and raw GraphQL queries. + /// + [Table("tbl_OptiGraphExtensions_SavedQueries")] + public class SavedQuery + { + public Guid Id { get; set; } + + [Required] + [StringLength(255)] + public string Name { get; set; } = string.Empty; + + [StringLength(1000)] + public string? Description { get; set; } + + /// + /// The type of query: Visual (0) or Raw (1). + /// + public QueryType QueryType { get; set; } = QueryType.Visual; + + // === Visual Query Fields === + + /// + /// The content type to query (for Visual queries). + /// + [StringLength(255)] + public string? ContentType { get; set; } + + /// + /// JSON-serialized List of selected field paths (for Visual queries). + /// Example: ["Name", "ContentLink.GuidValue", "Changed"] + /// + public string? SelectedFieldsJson { get; set; } + + /// + /// JSON-serialized List of QueryFilter objects (for Visual queries). + /// + public string? FiltersJson { get; set; } + + /// + /// Language filter for the query. + /// + [StringLength(10)] + public string? Language { get; set; } + + /// + /// Field to sort results by (for Visual queries). + /// + [StringLength(100)] + public string? SortField { get; set; } + + /// + /// Whether to sort in descending order. + /// + public bool SortDescending { get; set; } + + // === Raw Query Fields === + + /// + /// The raw GraphQL query string (for Raw queries). + /// + public string? RawGraphQuery { get; set; } + + /// + /// JSON-serialized Dictionary of query variables (for Raw queries). + /// Example: { "limit": 100, "locale": ["en"] } + /// + public string? QueryVariablesJson { get; set; } + + // === Common Fields === + + /// + /// Number of items per page for pagination. + /// + public int PageSize { get; set; } = 100; + + /// + /// Whether the query is active and visible to users. + /// + public bool IsActive { get; set; } = true; + + // === Audit Fields === + + public DateTime CreatedAt { get; set; } + + [StringLength(255)] + public string? CreatedBy { get; set; } + + public DateTime? UpdatedAt { get; set; } + + [StringLength(255)] + public string? UpdatedBy { get; set; } + } +} diff --git a/src/OptiGraphExtensions/Features/Configuration/OptiGraphExtensionsServiceExtensions.cs b/src/OptiGraphExtensions/Features/Configuration/OptiGraphExtensionsServiceExtensions.cs index df57d10..69c05b4 100644 --- a/src/OptiGraphExtensions/Features/Configuration/OptiGraphExtensionsServiceExtensions.cs +++ b/src/OptiGraphExtensions/Features/Configuration/OptiGraphExtensionsServiceExtensions.cs @@ -24,6 +24,9 @@ using OptiGraphExtensions.Features.PinnedResults.Models; using OptiGraphExtensions.Features.Webhooks.Services; using OptiGraphExtensions.Features.Webhooks.Services.Abstractions; +using OptiGraphExtensions.Features.QueryLibrary.Repositories; +using OptiGraphExtensions.Features.QueryLibrary.Services; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; namespace OptiGraphExtensions.Features.Configuration; @@ -75,6 +78,8 @@ public static IServiceCollection AddOptiGraphExtensions( services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); + services.AddHttpClient(); + services.AddHttpClient(); // Database var connectionStringName = string.IsNullOrWhiteSpace(concreteOptions.ConnectionStringName) ? "EPiServerDB" : concreteOptions.ConnectionStringName; @@ -177,5 +182,18 @@ internal static void SetUpOptiGraphExtensionsDatabase(this IServiceCollection se // Register webhook services services.AddScoped(); + + // Register Query Library services + services.AddScoped(); + services.AddScoped(provider => + { + var baseRepository = provider.GetRequiredService(); + var cacheService = provider.GetRequiredService(); + return new CachedSavedQueryRepository(baseRepository, cacheService); + }); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryExecutionModels.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryExecutionModels.cs new file mode 100644 index 0000000..db81335 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryExecutionModels.cs @@ -0,0 +1,133 @@ +using OptiGraphExtensions.Entities; + +namespace OptiGraphExtensions.Features.QueryLibrary.Models +{ + /// + /// Request model for executing a query. + /// + public class QueryExecutionRequest + { + /// + /// The type of query (Visual or Raw). + /// + public QueryType QueryType { get; set; } = QueryType.Visual; + + // === Visual Query Fields === + + /// + /// The content type to query (for Visual queries). + /// + public string? ContentType { get; set; } + + /// + /// List of field paths to select (for Visual queries). + /// + public List SelectedFields { get; set; } = new(); + + /// + /// List of filter conditions (for Visual queries). + /// + public List Filters { get; set; } = new(); + + /// + /// Language filter. + /// + public string? Language { get; set; } + + /// + /// Field to sort results by (for Visual queries). + /// + public string? SortField { get; set; } + + /// + /// Whether to sort in descending order. + /// + public bool SortDescending { get; set; } + + // === Raw Query Fields === + + /// + /// The raw GraphQL query string (for Raw queries). + /// + public string? RawGraphQuery { get; set; } + + /// + /// Query variables as a dictionary (for Raw queries). + /// + public Dictionary? QueryVariables { get; set; } + + // === Common Fields === + + /// + /// Number of items per page. + /// + public int PageSize { get; set; } = 100; + + /// + /// Cursor for pagination (null for first page). + /// + public string? Cursor { get; set; } + } + + /// + /// Result model for query execution. + /// + public class QueryExecutionResult + { + /// + /// The data rows as a list of dictionaries (column name -> value). + /// + public List> Rows { get; set; } = new(); + + /// + /// The column names/headers in order. + /// + public List Columns { get; set; } = new(); + + /// + /// Total count of items matching the query. + /// + public int TotalCount { get; set; } + + /// + /// Cursor for the next page (null if no more pages). + /// + public string? NextCursor { get; set; } + + /// + /// Whether there are more pages available. + /// + public bool HasMore { get; set; } + + /// + /// Error message if the query failed. + /// + public string? ErrorMessage { get; set; } + + /// + /// Whether the query execution was successful. + /// + public bool IsSuccess => string.IsNullOrEmpty(ErrorMessage); + } + + /// + /// Result model for query validation. + /// + public class QueryValidationResult + { + /// + /// Whether the query is valid. + /// + public bool IsValid { get; set; } + + /// + /// List of validation errors or warnings. + /// + public List Messages { get; set; } = new(); + + /// + /// Whether the query supports pagination (has cursor and total fields). + /// + public bool SupportsPagination { get; set; } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryFilter.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryFilter.cs new file mode 100644 index 0000000..310beed --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Models/QueryFilter.cs @@ -0,0 +1,23 @@ +namespace OptiGraphExtensions.Features.QueryLibrary.Models +{ + /// + /// Represents a filter condition for a visual query. + /// + public class QueryFilter + { + /// + /// The field name to filter on. + /// + public string Field { get; set; } = string.Empty; + + /// + /// The filter operator (eq, neq, contains, startsWith, gt, lt, gte, lte). + /// + public string Operator { get; set; } = "eq"; + + /// + /// The value to filter by. + /// + public string Value { get; set; } = string.Empty; + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Models/SavedQueryModel.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Models/SavedQueryModel.cs new file mode 100644 index 0000000..d85ecbe --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Models/SavedQueryModel.cs @@ -0,0 +1,88 @@ +using System.ComponentModel.DataAnnotations; +using OptiGraphExtensions.Entities; + +namespace OptiGraphExtensions.Features.QueryLibrary.Models +{ + /// + /// DTO model for saved queries used in API and UI. + /// + public class SavedQueryModel + { + public Guid Id { get; set; } + + [Required(ErrorMessage = "Name is required")] + [StringLength(255, ErrorMessage = "Name must be less than 255 characters")] + public string Name { get; set; } = string.Empty; + + [StringLength(1000, ErrorMessage = "Description must be less than 1000 characters")] + public string? Description { get; set; } + + /// + /// The type of query (Visual or Raw). + /// + public QueryType QueryType { get; set; } = QueryType.Visual; + + // === Visual Query Fields === + + /// + /// The content type to query (required for Visual queries). + /// + public string? ContentType { get; set; } + + /// + /// List of field paths to select. + /// + public List SelectedFields { get; set; } = new(); + + /// + /// List of filter conditions. + /// + public List Filters { get; set; } = new(); + + /// + /// Language filter. + /// + public string? Language { get; set; } + + /// + /// Field to sort results by. + /// + public string? SortField { get; set; } + + /// + /// Whether to sort in descending order. + /// + public bool SortDescending { get; set; } + + // === Raw Query Fields === + + /// + /// The raw GraphQL query string (required for Raw queries). + /// + public string? RawGraphQuery { get; set; } + + /// + /// Query variables as JSON string. + /// + public string? QueryVariablesJson { get; set; } + + // === Common Fields === + + /// + /// Number of items per page. + /// + public int PageSize { get; set; } = 100; + + /// + /// Whether the query is active. + /// + public bool IsActive { get; set; } = true; + + // === Audit Fields (read-only) === + + public DateTime? CreatedAt { get; set; } + public string? CreatedBy { get; set; } + public DateTime? UpdatedAt { get; set; } + public string? UpdatedBy { get; set; } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Models/SchemaField.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Models/SchemaField.cs new file mode 100644 index 0000000..689180f --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Models/SchemaField.cs @@ -0,0 +1,38 @@ +namespace OptiGraphExtensions.Features.QueryLibrary.Models +{ + /// + /// Represents a field from the Optimizely Graph schema for use in the visual query builder. + /// + public class SchemaField + { + /// + /// The display name of the field. + /// + public string Name { get; set; } = string.Empty; + + /// + /// The full path to the field (e.g., "ContentLink.GuidValue"). + /// + public string Path { get; set; } = string.Empty; + + /// + /// The GraphQL type of the field (String, Int, DateTime, Boolean, Object, etc.). + /// + public string Type { get; set; } = string.Empty; + + /// + /// Whether this field is a nested object type. + /// + public bool IsNested { get; set; } + + /// + /// Whether this field is a list/array type. + /// + public bool IsList { get; set; } + + /// + /// Child fields for nested object types. + /// + public List Children { get; set; } = new(); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponent.razor b/src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponent.razor new file mode 100644 index 0000000..6384eaf --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponent.razor @@ -0,0 +1,833 @@ +@inherits QueryLibraryManagementComponentBase + +@if (IsLoading) +{ +
+
+

Loading...

+
+} +else +{ +
+ @if (!string.IsNullOrEmpty(ErrorMessage)) + { +
+
@ErrorMessage
+ +
+ } + + @if (!string.IsNullOrEmpty(SuccessMessage)) + { +
+
@SuccessMessage
+ +
+ } + + @if (CurrentMode == "list") + { + @* List Mode - Show saved queries *@ +
+
+

Saved Queries

+
+ + +
+
+ + @if (!Queries.Any()) + { +
+

No saved queries yet. Create a new query to get started.

+
+ } + else + { +
+ + + + + + + + + + + + @foreach (var query in Queries) + { + + + + + + + + } + +
NameTypeContent TypeCreatedActions
+ @query.Name + @if (!string.IsNullOrEmpty(query.Description)) + { +
@query.Description + } +
+ + @GetQueryTypeText(query.QueryType) + + @(query.ContentType ?? "-") + @FormatDate(query.CreatedAt) + @if (!string.IsNullOrEmpty(query.CreatedBy)) + { +
by @query.CreatedBy + } +
+ + + +
+
+ + @if (TotalPages > 1) + { +
+ + Showing @((CurrentPage - 1) * PageSize + 1) to @Math.Min(CurrentPage * PageSize, TotalItems) of @TotalItems queries + +
+ + @for (int i = 1; i <= TotalPages; i++) + { + var pageNum = i; + + } + +
+
+ } + } +
+ } + else if (CurrentMode == "build") + { + @* Build Mode - Query builder *@ +
+
+

@(CurrentQuery.Id == Guid.Empty ? "Create Query" : "Edit Query")

+ +
+ + @* Tab Toggle *@ +
+ + +
+ + @* Sync indicator *@ + @if (QueryInputMode == "raw" && !string.IsNullOrEmpty(CurrentQuery.ContentType)) + { +
+ Query generated from visual settings. Edit the query directly or switch to Visual Builder to modify settings. +
+ } + + @* Query Name & Description *@ +
+ + +
+ +
+ + +
+ + @if (QueryInputMode == "visual") + { + @* Visual Query Builder *@ +
+ + @if (IsLoadingContentTypes) + { + + } + else + { + + } +
+ + @if (!string.IsNullOrEmpty(CurrentQuery.ContentType)) + { +
+ + @if (IsLoadingFields) + { + + } + else if (AvailableFields.Any()) + { +
+ @foreach (var field in AvailableFields) + { +
+ +
+ } +
+ } + else + { +

No fields available for this content type.

+ } +
+ +
+ + @foreach (var filter in CurrentQuery.Filters) + { + var currentFilter = filter; +
+ + + + +
+ } + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ } + } + else + { + @* Raw GraphQL Editor *@ +
+ + +
+ +
+ + +
+ +
+ Tip: For auto-pagination during export, include $limit: Int! and $cursor: String + variables, and request cursor and total fields in your response. +
+ + @if (RawQueryValidation != null && RawQueryValidation.Messages.Any()) + { +
+ @foreach (var message in RawQueryValidation.Messages) + { +
+ @message +
+ } +
+ } + } + +
+ + +
+
+ } + else if (CurrentMode == "preview") + { + @* Preview Mode - Show results *@ +
+
+

Query Results: @CurrentQuery.Name

+
+ + + +
+
+ + @if (PreviewResult != null) + { +
+ Total: @PreviewResult.TotalCount rows + @if (PreviewResult.HasMore) + { + (showing first @PreviewResult.Rows.Count) + } +
+ + @if (PreviewResult.Columns.Any() && PreviewResult.Rows.Any()) + { +
+ + + + @foreach (var column in PreviewResult.Columns) + { + + } + + + + @foreach (var row in PreviewResult.Rows) + { + + @foreach (var column in PreviewResult.Columns) + { + + } + + } + +
@column
@(row.TryGetValue(column, out var value) ? value?.ToString() ?? "" : "")
+
+ } + else + { +
+

No results found for this query.

+
+ } + } +
+ } +
+} + + diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponentBase.cs b/src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponentBase.cs new file mode 100644 index 0000000..10b5c23 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/QueryLibraryManagementComponentBase.cs @@ -0,0 +1,855 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using OptiGraphExtensions.Entities; +using OptiGraphExtensions.Features.Common.Components; +using OptiGraphExtensions.Features.QueryLibrary.Models; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; +using OptiGraphExtensions.Features.Synonyms.Services.Abstractions; + +namespace OptiGraphExtensions.Features.QueryLibrary +{ + public class QueryLibraryManagementComponentBase : ManagementComponentBase + { + [Inject] + protected ISavedQueryService SavedQueryService { get; set; } = null!; + + [Inject] + protected ISchemaDiscoveryService SchemaService { get; set; } = null!; + + [Inject] + protected IQueryExecutionService ExecutionService { get; set; } = null!; + + [Inject] + protected ICsvExportService CsvExportService { get; set; } = null!; + + [Inject] + protected IRawQueryService RawQueryService { get; set; } = null!; + + [Inject] + protected IQueryBuilderService QueryBuilderService { get; set; } = null!; + + [Inject] + protected IPaginationService PaginationService { get; set; } = null!; + + [Inject] + protected IJSRuntime JSRuntime { get; set; } = null!; + + // UI State + protected string CurrentMode { get; set; } = "list"; // list, build, preview + protected string QueryInputMode { get; set; } = "visual"; // visual, raw + + // Saved Queries + protected IList AllQueries { get; set; } = new List(); + protected PaginationResult? PaginationResult { get; set; } + protected int CurrentPage { get; set; } = 1; + protected int PageSize { get; set; } = 10; + protected int TotalPages => PaginationResult?.TotalPages ?? 0; + protected int TotalItems => PaginationResult?.TotalItems ?? 0; + protected List Queries => PaginationResult?.Items?.ToList() ?? new List(); + + // Query Builder State + protected SavedQueryModel CurrentQuery { get; set; } = new(); + protected IList AvailableContentTypes { get; set; } = new List(); + protected IList AvailableFields { get; set; } = new List(); + protected bool IsLoadingContentTypes { get; set; } + protected bool IsLoadingFields { get; set; } + + // Preview State + protected QueryExecutionResult? PreviewResult { get; set; } + protected bool IsExecuting { get; set; } + protected bool IsExporting { get; set; } + protected int ExportProgress { get; set; } + + // Validation + protected QueryValidationResult? RawQueryValidation { get; set; } + + protected override async Task LoadDataAsync() + { + await LoadQueries(); + await LoadContentTypes(); + } + + protected async Task LoadQueries() + { + await ExecuteWithLoadingAndErrorHandlingAsync(async () => + { + AllQueries = (await SavedQueryService.GetAllQueriesAsync()).ToList(); + UpdatePaginatedQueries(); + }, "loading saved queries"); + } + + protected async Task LoadContentTypes() + { + IsLoadingContentTypes = true; + StateHasChanged(); + + try + { + AvailableContentTypes = await SchemaService.GetContentTypesAsync(); + } + catch (Exception ex) + { + SetErrorMessage($"Failed to load content types: {ex.Message}"); + } + finally + { + IsLoadingContentTypes = false; + StateHasChanged(); + } + } + + protected async Task OnContentTypeChanged(ChangeEventArgs e) + { + CurrentQuery.ContentType = e.Value?.ToString(); + CurrentQuery.SelectedFields = new List(); + + if (!string.IsNullOrEmpty(CurrentQuery.ContentType)) + { + await LoadFieldsForContentType(CurrentQuery.ContentType); + } + else + { + AvailableFields = new List(); + } + + OnVisualSettingsChanged(); + StateHasChanged(); + } + + protected async Task LoadFieldsForContentType(string contentType) + { + IsLoadingFields = true; + StateHasChanged(); + + try + { + AvailableFields = await SchemaService.GetFieldsForContentTypeAsync(contentType); + } + catch (Exception ex) + { + SetErrorMessage($"Failed to load fields: {ex.Message}"); + } + finally + { + IsLoadingFields = false; + StateHasChanged(); + } + } + + protected void ToggleField(string fieldPath, bool isChecked) + { + if (isChecked) + { + if (!CurrentQuery.SelectedFields.Contains(fieldPath)) + { + CurrentQuery.SelectedFields.Add(fieldPath); + } + } + else + { + CurrentQuery.SelectedFields.Remove(fieldPath); + } + OnVisualSettingsChanged(); + StateHasChanged(); + } + + protected bool IsFieldSelected(string fieldPath) + { + return CurrentQuery.SelectedFields.Contains(fieldPath); + } + + protected void AddFilter() + { + CurrentQuery.Filters.Add(new QueryFilter + { + Field = "", + Operator = "eq", + Value = "" + }); + OnVisualSettingsChanged(); + StateHasChanged(); + } + + protected void RemoveFilter(QueryFilter filter) + { + CurrentQuery.Filters.Remove(filter); + OnVisualSettingsChanged(); + StateHasChanged(); + } + + protected void OnFilterChanged() + { + OnVisualSettingsChanged(); + StateHasChanged(); + } + + protected void OnSortChanged() + { + OnVisualSettingsChanged(); + StateHasChanged(); + } + + protected void OnLanguageChanged() + { + OnVisualSettingsChanged(); + StateHasChanged(); + } + + protected void ShowCreateForm() + { + CurrentMode = "build"; + QueryInputMode = "visual"; + CurrentQuery = new SavedQueryModel + { + QueryType = QueryType.Visual, + SelectedFields = new List(), + Filters = new List(), + PageSize = 100 + }; + PreviewResult = null; + RawQueryValidation = null; + ClearMessages(); + } + + protected void ShowRawQueryForm() + { + CurrentMode = "build"; + QueryInputMode = "raw"; + CurrentQuery = new SavedQueryModel + { + QueryType = QueryType.Raw, + RawGraphQuery = GetDefaultRawQuery(), + QueryVariablesJson = "{\n \"limit\": 100\n}", + PageSize = 100 + }; + PreviewResult = null; + RawQueryValidation = null; + ClearMessages(); + } + + protected async Task SwitchToVisualMode() + { + // Try to parse raw query into visual settings + if (!string.IsNullOrEmpty(CurrentQuery.RawGraphQuery)) + { + await ParseRawQueryToVisualAsync(); + } + + QueryInputMode = "visual"; + CurrentQuery.QueryType = QueryType.Visual; + StateHasChanged(); + } + + protected void SwitchToRawMode() + { + // Generate raw query from visual settings + SyncVisualToRaw(); + + QueryInputMode = "raw"; + CurrentQuery.QueryType = QueryType.Raw; + StateHasChanged(); + } + + /// + /// Generates the raw GraphQL query from the current visual builder settings. + /// + protected void SyncVisualToRaw() + { + try + { + // Only generate if we have a content type selected + if (string.IsNullOrEmpty(CurrentQuery.ContentType)) + { + if (string.IsNullOrEmpty(CurrentQuery.RawGraphQuery)) + { + CurrentQuery.RawGraphQuery = GetDefaultRawQuery(); + CurrentQuery.QueryVariablesJson = "{\n \"limit\": 100\n}"; + } + return; + } + + // Create execution request from current visual settings + var request = new QueryExecutionRequest + { + QueryType = QueryType.Visual, + ContentType = CurrentQuery.ContentType, + SelectedFields = CurrentQuery.SelectedFields ?? new List(), + Filters = CurrentQuery.Filters ?? new List(), + Language = CurrentQuery.Language, + SortField = CurrentQuery.SortField, + SortDescending = CurrentQuery.SortDescending, + PageSize = CurrentQuery.PageSize > 0 ? CurrentQuery.PageSize : 100 + }; + + // Generate the GraphQL query + CurrentQuery.RawGraphQuery = QueryBuilderService.BuildGraphQLQuery(request); + + // Generate variables + var variables = QueryBuilderService.BuildVariables(request); + CurrentQuery.QueryVariablesJson = System.Text.Json.JsonSerializer.Serialize( + variables, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } + catch (Exception) + { + // If generation fails, use default query + if (string.IsNullOrEmpty(CurrentQuery.RawGraphQuery)) + { + CurrentQuery.RawGraphQuery = GetDefaultRawQuery(); + CurrentQuery.QueryVariablesJson = "{\n \"limit\": 100\n}"; + } + } + } + + /// + /// Parses a raw GraphQL query and updates the visual builder settings. + /// + protected async Task ParseRawQueryToVisualAsync() + { + if (string.IsNullOrWhiteSpace(CurrentQuery.RawGraphQuery)) + { + return; + } + + try + { + var query = CurrentQuery.RawGraphQuery; + + // Extract content type from query (e.g., "ArticlePage(" or "Content(") + var contentTypeMatch = Regex.Match(query, @"^\s*(?:query\s+\w+\s*\([^)]*\)\s*\{\s*)?(\w+)\s*\(", RegexOptions.Multiline); + if (contentTypeMatch.Success) + { + var parsedContentType = contentTypeMatch.Groups[1].Value; + + // Check if content type exists in available types + if (AvailableContentTypes.Contains(parsedContentType)) + { + if (CurrentQuery.ContentType != parsedContentType) + { + CurrentQuery.ContentType = parsedContentType; + await LoadFieldsForContentType(parsedContentType); + } + } + } + + // Extract fields from items { ... } block + var itemsMatch = Regex.Match(query, @"items\s*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}", RegexOptions.Singleline); + if (itemsMatch.Success) + { + var fieldsBlock = itemsMatch.Groups[1].Value; + var parsedFields = ParseFieldsFromBlock(fieldsBlock); + + if (parsedFields.Any()) + { + CurrentQuery.SelectedFields = parsedFields; + } + } + + // Extract language from locale parameter + var localeMatch = Regex.Match(query, @"locale:\s*\$locale"); + if (localeMatch.Success) + { + // Try to get locale from variables + if (!string.IsNullOrEmpty(CurrentQuery.QueryVariablesJson)) + { + try + { + var variables = System.Text.Json.JsonSerializer.Deserialize>( + CurrentQuery.QueryVariablesJson, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (variables?.TryGetValue("locale", out var localeValue) == true) + { + if (localeValue is System.Text.Json.JsonElement jsonElement) + { + if (jsonElement.ValueKind == System.Text.Json.JsonValueKind.Array && jsonElement.GetArrayLength() > 0) + { + CurrentQuery.Language = jsonElement[0].GetString(); + } + else if (jsonElement.ValueKind == System.Text.Json.JsonValueKind.String) + { + CurrentQuery.Language = jsonElement.GetString(); + } + } + } + } + catch { } + } + } + + // Extract orderBy + var orderByMatch = Regex.Match(query, @"orderBy:\s*\{\s*(\w+):\s*(ASC|DESC)\s*\}", RegexOptions.IgnoreCase); + if (orderByMatch.Success) + { + CurrentQuery.SortField = orderByMatch.Groups[1].Value; + CurrentQuery.SortDescending = orderByMatch.Groups[2].Value.Equals("DESC", StringComparison.OrdinalIgnoreCase); + } + + // Note: Parsing where clause filters is complex and may not cover all cases + // We'll skip filter parsing for now as it requires a full GraphQL parser + } + catch (Exception) + { + // Parsing failed - keep existing visual settings + } + } + + /// + /// Parses field names from a GraphQL fields block. + /// + private List ParseFieldsFromBlock(string fieldsBlock, string prefix = "") + { + var fields = new List(); + + // Simple regex-based parsing of field names + // This handles basic cases like "Name", "ContentLink { GuidValue }", etc. + var lines = fieldsBlock.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed == "{" || trimmed == "}") + { + continue; + } + + // Check for nested field: FieldName { ... } + var nestedMatch = Regex.Match(trimmed, @"^(\w+)\s*\{"); + if (nestedMatch.Success) + { + var fieldName = nestedMatch.Groups[1].Value; + var fullPath = string.IsNullOrEmpty(prefix) ? fieldName : $"{prefix}.{fieldName}"; + + // For nested fields, we add the parent field (visual builder will add default subfields) + fields.Add(fullPath); + } + else + { + // Simple field name + var fieldMatch = Regex.Match(trimmed, @"^(\w+)\s*$"); + if (fieldMatch.Success) + { + var fieldName = fieldMatch.Groups[1].Value; + // Skip cursor and total as they're pagination fields + if (fieldName != "cursor" && fieldName != "total") + { + var fullPath = string.IsNullOrEmpty(prefix) ? fieldName : $"{prefix}.{fieldName}"; + fields.Add(fullPath); + } + } + } + } + + return fields; + } + + /// + /// Called when visual builder settings change to update the raw query. + /// + protected void OnVisualSettingsChanged() + { + // Update raw query if we're in build mode + if (CurrentMode == "build") + { + SyncVisualToRaw(); + } + } + + protected void BackToList() + { + CurrentMode = "list"; + CurrentQuery = new SavedQueryModel(); + PreviewResult = null; + RawQueryValidation = null; + ClearMessages(); + } + + protected async Task EditQuery(SavedQueryModel query) + { + CurrentMode = "build"; + CurrentQuery = new SavedQueryModel + { + Id = query.Id, + Name = query.Name, + Description = query.Description, + QueryType = query.QueryType, + ContentType = query.ContentType, + SelectedFields = query.SelectedFields?.ToList() ?? new List(), + Filters = query.Filters?.Select(f => new QueryFilter + { + Field = f.Field, + Operator = f.Operator, + Value = f.Value + }).ToList() ?? new List(), + Language = query.Language, + SortField = query.SortField, + SortDescending = query.SortDescending, + RawGraphQuery = query.RawGraphQuery, + QueryVariablesJson = query.QueryVariablesJson, + PageSize = query.PageSize, + IsActive = query.IsActive + }; + + QueryInputMode = query.QueryType == QueryType.Raw ? "raw" : "visual"; + + if (!string.IsNullOrEmpty(CurrentQuery.ContentType)) + { + await LoadFieldsForContentType(CurrentQuery.ContentType); + } + + PreviewResult = null; + RawQueryValidation = null; + ClearMessages(); + } + + protected async Task RunQuery(SavedQueryModel query) + { + await EditQuery(query); + await ExecutePreview(); + } + + protected async Task DeleteQuery(Guid id) + { + await ExecuteWithLoadingAndErrorHandlingAsync(async () => + { + await SavedQueryService.DeleteQueryAsync(id); + SetSuccessMessage("Query deleted successfully."); + await LoadQueries(); + }, "deleting query", showLoading: false); + } + + protected async Task SaveQuery() + { + if (string.IsNullOrWhiteSpace(CurrentQuery.Name)) + { + SetErrorMessage("Query name is required."); + return; + } + + await ExecuteWithLoadingAndErrorHandlingAsync(async () => + { + if (CurrentQuery.Id == Guid.Empty) + { + await SavedQueryService.CreateQueryAsync(CurrentQuery); + SetSuccessMessage("Query saved successfully."); + } + else + { + await SavedQueryService.UpdateQueryAsync(CurrentQuery.Id, CurrentQuery); + SetSuccessMessage("Query updated successfully."); + } + + await LoadQueries(); + }, "saving query", showLoading: false); + } + + protected async Task ExecutePreview() + { + IsExecuting = true; + PreviewResult = null; + ClearMessages(); + StateHasChanged(); + + try + { + // Validate raw query if in raw mode + if (CurrentQuery.QueryType == QueryType.Raw) + { + if (string.IsNullOrWhiteSpace(CurrentQuery.RawGraphQuery)) + { + SetErrorMessage("GraphQL query is required."); + return; + } + + RawQueryValidation = RawQueryService.ValidateQuery(CurrentQuery.RawGraphQuery); + if (!RawQueryValidation.IsValid) + { + SetErrorMessage(string.Join("; ", RawQueryValidation.Messages)); + return; + } + } + else + { + // Validate visual query + if (string.IsNullOrEmpty(CurrentQuery.ContentType)) + { + SetErrorMessage("Content type is required."); + return; + } + } + + var request = SavedQueryService.ToExecutionRequest(CurrentQuery); + PreviewResult = await ExecutionService.ExecuteQueryAsync(request); + + if (!PreviewResult.IsSuccess) + { + SetErrorMessage(PreviewResult.ErrorMessage ?? "Query execution failed."); + } + else + { + CurrentMode = "preview"; + } + } + catch (Exception ex) + { + SetErrorMessage($"Query execution failed: {ex.Message}"); + } + finally + { + IsExecuting = false; + StateHasChanged(); + } + } + + protected async Task ExportToCsv() + { + IsExporting = true; + ExportProgress = 0; + ClearMessages(); + + try + { + await InvokeAsync(StateHasChanged); + + // If we have preview results, export those first (single page) + // For full export with pagination, we'll fetch all pages + if (PreviewResult != null && PreviewResult.IsSuccess && PreviewResult.Rows.Any()) + { + // Quick export of current preview + await ExportPreviewDataAsync(); + } + else + { + // Full export with pagination + await ExportAllPagesAsync(); + } + } + catch (Exception ex) + { + SetErrorMessage($"Export failed: {ex.Message}"); + } + finally + { + IsExporting = false; + ExportProgress = 0; + await InvokeAsync(StateHasChanged); + } + } + + private async Task ExportPreviewDataAsync() + { + if (PreviewResult == null || !PreviewResult.Rows.Any()) + { + SetErrorMessage("No preview data to export."); + return; + } + + ExportProgress = 50; + await InvokeAsync(StateHasChanged); + + var csvContent = CsvExportService.GenerateCsvPreview(PreviewResult, int.MaxValue); + var filename = CsvExportService.GenerateFilename(CurrentQuery.Name ?? "export"); + + ExportProgress = 90; + await InvokeAsync(StateHasChanged); + + // Trigger download via JavaScript + await DownloadCsvAsync(filename, csvContent); + + var rowCount = PreviewResult.Rows.Count; + var hasMore = PreviewResult.HasMore; + SetSuccessMessage($"Exported {rowCount} rows{(hasMore ? " (first page only - use full export for all data)" : "")}."); + } + + private async Task ExportAllPagesAsync() + { + var request = SavedQueryService.ToExecutionRequest(CurrentQuery); + + var allRows = new List>(); + var columns = new List(); + string? cursor = null; + var hasMore = true; + var pageCount = 0; + var totalCount = 0; + + while (hasMore) + { + pageCount++; + request.Cursor = cursor; + + QueryExecutionResult result; + try + { + result = await ExecutionService.ExecuteQueryAsync(request); + } + catch (Exception ex) + { + SetErrorMessage($"Query execution failed on page {pageCount}: {ex.Message}"); + return; + } + + if (!result.IsSuccess) + { + SetErrorMessage(result.ErrorMessage ?? $"Export failed on page {pageCount}."); + return; + } + + if (!columns.Any() && result.Columns.Any()) + { + columns = result.Columns; + } + + if (totalCount == 0 && result.TotalCount > 0) + { + totalCount = result.TotalCount; + } + + allRows.AddRange(result.Rows); + + if (totalCount > 0) + { + ExportProgress = Math.Min(99, (int)((double)allRows.Count / totalCount * 100)); + } + else + { + ExportProgress = Math.Min(99, pageCount * 10); + } + await InvokeAsync(StateHasChanged); + + cursor = result.NextCursor; + hasMore = result.HasMore && !string.IsNullOrEmpty(cursor); + + if (pageCount > 1000) + { + SetErrorMessage("Export exceeded maximum page limit (1000 pages)."); + return; + } + } + + if (!allRows.Any()) + { + SetErrorMessage("No data to export."); + return; + } + + var csvResult = new QueryExecutionResult + { + Rows = allRows, + Columns = columns, + TotalCount = allRows.Count + }; + + var csvContent = CsvExportService.GenerateCsvPreview(csvResult, int.MaxValue); + var filename = CsvExportService.GenerateFilename(CurrentQuery.Name ?? "export"); + + await DownloadCsvAsync(filename, csvContent); + + SetSuccessMessage($"Exported {allRows.Count} rows successfully."); + } + + protected async Task DownloadCsvAsync(string filename, string content) + { + // Use JavaScript to trigger file download + var bytes = System.Text.Encoding.UTF8.GetBytes(content); + var base64 = Convert.ToBase64String(bytes); + await JSRuntime.InvokeVoidAsync("downloadCsv", filename, base64); + } + + protected void BackToBuilder() + { + CurrentMode = "build"; + StateHasChanged(); + } + + protected void UpdatePaginatedQueries() + { + PaginationResult = PaginationService.GetPage(AllQueries, CurrentPage, PageSize); + StateHasChanged(); + } + + protected void GoToPage(int page) + { + NavigateToPage(page, CurrentPage, TotalPages, (p) => CurrentPage = p, UpdatePaginatedQueriesAsync); + } + + protected void GoToPreviousPage() + { + NavigateToPreviousPage(CurrentPage, (p) => CurrentPage = p, UpdatePaginatedQueriesAsync); + } + + protected void GoToNextPage() + { + NavigateToNextPage(CurrentPage, TotalPages, (p) => CurrentPage = p, UpdatePaginatedQueriesAsync); + } + + private Task UpdatePaginatedQueriesAsync() + { + UpdatePaginatedQueries(); + return Task.CompletedTask; + } + + protected static string GetQueryTypeBadgeClass(QueryType queryType) + { + return queryType == QueryType.Raw ? "epi-badge--secondary" : "epi-badge--primary"; + } + + protected static string GetQueryTypeText(QueryType queryType) + { + return queryType == QueryType.Raw ? "Raw GraphQL" : "Visual"; + } + + protected static string FormatDate(DateTime? date) + { + return date?.ToString("yyyy-MM-dd HH:mm") ?? ""; + } + + private static string GetDefaultRawQuery() + { + return @"query ExportContent($limit: Int!, $cursor: String) { + Content(limit: $limit, cursor: $cursor) { + items { + Name + ContentLink { + GuidValue + } + ContentType + Language { + Name + } + } + cursor + total + } +}"; + } + + protected static IEnumerable AvailableOperators => new[] + { + "eq", + "neq", + "contains", + "startsWith", + "gt", + "lt", + "gte", + "lte" + }; + + protected static string GetOperatorDisplayName(string op) + { + return op switch + { + "eq" => "equals", + "neq" => "not equals", + "contains" => "contains", + "startsWith" => "starts with", + "gt" => "greater than", + "lt" => "less than", + "gte" => "greater or equal", + "lte" => "less or equal", + _ => op + }; + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/CachedSavedQueryRepository.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/CachedSavedQueryRepository.cs new file mode 100644 index 0000000..e394e7e --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/CachedSavedQueryRepository.cs @@ -0,0 +1,138 @@ +using OptiGraphExtensions.Entities; +using OptiGraphExtensions.Features.Common.Caching; + +namespace OptiGraphExtensions.Features.QueryLibrary.Repositories +{ + public class CachedSavedQueryRepository : ISavedQueryRepository + { + private readonly ISavedQueryRepository _repository; + private readonly ICacheService _cacheService; + private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(15); + + public CachedSavedQueryRepository(ISavedQueryRepository repository, ICacheService cacheService) + { + _repository = repository; + _cacheService = cacheService; + } + + public async Task> GetAllAsync() + { + var cacheKey = CacheKeyBuilder.BuildEntityListKey(); + var cachedResult = await _cacheService.GetAsync>(cacheKey); + + if (cachedResult != null) + { + return cachedResult; + } + + var result = await _repository.GetAllAsync(); + await _cacheService.SetAsync(cacheKey, result, _cacheExpiration); + return result; + } + + public async Task> GetActiveAsync() + { + var cacheKey = $"{CacheKeyBuilder.BuildEntityListKey()}:active"; + var cachedResult = await _cacheService.GetAsync>(cacheKey); + + if (cachedResult != null) + { + return cachedResult; + } + + var result = await _repository.GetActiveAsync(); + await _cacheService.SetAsync(cacheKey, result, _cacheExpiration); + return result; + } + + public async Task GetByIdAsync(Guid id) + { + var cacheKey = CacheKeyBuilder.BuildEntityKey(id); + var cachedResult = await _cacheService.GetAsync(cacheKey); + + if (cachedResult != null) + { + return cachedResult; + } + + var result = await _repository.GetByIdAsync(id); + if (result != null) + { + await _cacheService.SetAsync(cacheKey, result, _cacheExpiration); + } + return result; + } + + public async Task CreateAsync(SavedQuery query) + { + var result = await _repository.CreateAsync(query); + + // Invalidate related cache entries + await InvalidateSavedQueryCacheAsync(); + + // Cache the new entity + var cacheKey = CacheKeyBuilder.BuildEntityKey(result.Id); + await _cacheService.SetAsync(cacheKey, result, _cacheExpiration); + + return result; + } + + public async Task UpdateAsync(SavedQuery query) + { + var result = await _repository.UpdateAsync(query); + + // Invalidate related cache entries + await InvalidateSavedQueryCacheAsync(); + + // Update cache with new data + var cacheKey = CacheKeyBuilder.BuildEntityKey(result.Id); + await _cacheService.SetAsync(cacheKey, result, _cacheExpiration); + + return result; + } + + public async Task DeleteAsync(Guid id) + { + var result = await _repository.DeleteAsync(id); + + if (result) + { + // Invalidate related cache entries + await InvalidateSavedQueryCacheAsync(); + + // Remove the specific entity from cache + var cacheKey = CacheKeyBuilder.BuildEntityKey(id); + await _cacheService.RemoveAsync(cacheKey); + } + + return result; + } + + public async Task ExistsAsync(Guid id) + { + var cacheKey = CacheKeyBuilder.BuildExistsKey(id); + + if (_cacheService.Exists(cacheKey)) + { + var cachedResult = await _cacheService.GetAsync(cacheKey); + return cachedResult; + } + + var result = await _repository.ExistsAsync(id); + await _cacheService.SetAsync(cacheKey, result, _cacheExpiration); + return result; + } + + public async Task NameExistsAsync(string name, Guid? excludeId = null) + { + // Don't cache name existence checks as they depend on excludeId + return await _repository.NameExistsAsync(name, excludeId); + } + + private async Task InvalidateSavedQueryCacheAsync() + { + var pattern = CacheKeyBuilder.BuildEntityPattern(); + await _cacheService.RemoveByPatternAsync(pattern); + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/ISavedQueryRepository.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/ISavedQueryRepository.cs new file mode 100644 index 0000000..432215b --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/ISavedQueryRepository.cs @@ -0,0 +1,16 @@ +using OptiGraphExtensions.Entities; + +namespace OptiGraphExtensions.Features.QueryLibrary.Repositories +{ + public interface ISavedQueryRepository + { + Task> GetAllAsync(); + Task> GetActiveAsync(); + Task GetByIdAsync(Guid id); + Task CreateAsync(SavedQuery query); + Task UpdateAsync(SavedQuery query); + Task DeleteAsync(Guid id); + Task ExistsAsync(Guid id); + Task NameExistsAsync(string name, Guid? excludeId = null); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/SavedQueryRepository.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/SavedQueryRepository.cs new file mode 100644 index 0000000..343d6ee --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Repositories/SavedQueryRepository.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore; +using OptiGraphExtensions.Entities; + +namespace OptiGraphExtensions.Features.QueryLibrary.Repositories +{ + public class SavedQueryRepository : ISavedQueryRepository + { + private readonly IOptiGraphExtensionsDataContext _dataContext; + + public SavedQueryRepository(IOptiGraphExtensionsDataContext dataContext) + { + _dataContext = dataContext ?? throw new ArgumentNullException(nameof(dataContext)); + } + + public async Task> GetAllAsync() + { + return await _dataContext.SavedQueries + .OrderByDescending(q => q.CreatedAt) + .ToListAsync(); + } + + public async Task> GetActiveAsync() + { + return await _dataContext.SavedQueries + .Where(q => q.IsActive) + .OrderByDescending(q => q.CreatedAt) + .ToListAsync(); + } + + public async Task GetByIdAsync(Guid id) + { + return await _dataContext.SavedQueries.FindAsync(id); + } + + public async Task CreateAsync(SavedQuery query) + { + ArgumentNullException.ThrowIfNull(query); + _dataContext.SavedQueries.Add(query); + await _dataContext.SaveChangesAsync(); + return query; + } + + public async Task UpdateAsync(SavedQuery query) + { + ArgumentNullException.ThrowIfNull(query); + _dataContext.SavedQueries.Update(query); + await _dataContext.SaveChangesAsync(); + return query; + } + + public async Task DeleteAsync(Guid id) + { + var query = await _dataContext.SavedQueries.FindAsync(id); + if (query == null) + { + return false; + } + + _dataContext.SavedQueries.Remove(query); + await _dataContext.SaveChangesAsync(); + return true; + } + + public async Task ExistsAsync(Guid id) + { + return await _dataContext.SavedQueries.AnyAsync(q => q.Id == id); + } + + public async Task NameExistsAsync(string name, Guid? excludeId = null) + { + var query = _dataContext.SavedQueries.Where(q => q.Name == name); + + if (excludeId.HasValue) + { + query = query.Where(q => q.Id != excludeId.Value); + } + + return await query.AnyAsync(); + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ICsvExportService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ICsvExportService.cs new file mode 100644 index 0000000..a0b7d3f --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ICsvExportService.cs @@ -0,0 +1,28 @@ +using OptiGraphExtensions.Features.QueryLibrary.Models; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions +{ + /// + /// Service for exporting query results to CSV format. + /// + public interface ICsvExportService + { + /// + /// Streams CSV export to the output stream, handling large datasets efficiently. + /// + Task ExportToCsvAsync( + IAsyncEnumerable results, + Stream outputStream, + CancellationToken cancellationToken = default); + + /// + /// Generates CSV content from a single result set (for preview). + /// + string GenerateCsvPreview(QueryExecutionResult result, int maxRows = 100); + + /// + /// Generates the filename for a CSV export. + /// + string GenerateFilename(string queryName); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryBuilderService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryBuilderService.cs new file mode 100644 index 0000000..7e911cb --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryBuilderService.cs @@ -0,0 +1,30 @@ +using OptiGraphExtensions.Features.QueryLibrary.Models; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions +{ + /// + /// Service for building GraphQL queries from visual query configurations. + /// + public interface IQueryBuilderService + { + /// + /// Builds a GraphQL query string from a query execution request. + /// + string BuildGraphQLQuery(QueryExecutionRequest request); + + /// + /// Builds GraphQL variables for the query. + /// + Dictionary BuildVariables(QueryExecutionRequest request); + + /// + /// Converts selected field paths to GraphQL field selection syntax. + /// + string BuildFieldSelection(IEnumerable fieldPaths); + + /// + /// Builds the WHERE clause from filter conditions. + /// + string BuildWhereClause(IEnumerable filters); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryExecutionService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryExecutionService.cs new file mode 100644 index 0000000..49deeeb --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IQueryExecutionService.cs @@ -0,0 +1,34 @@ +using OptiGraphExtensions.Features.QueryLibrary.Models; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions +{ + /// + /// Service for executing queries against Optimizely Graph. + /// + public interface IQueryExecutionService + { + /// + /// Executes a query and returns a single page of results. + /// + Task ExecuteQueryAsync(QueryExecutionRequest request); + + /// + /// Executes a query with auto-pagination to fetch all results. + /// + IAsyncEnumerable ExecuteQueryWithPaginationAsync( + QueryExecutionRequest request, + CancellationToken cancellationToken = default); + + /// + /// Gets the total count for a query without fetching data. + /// + Task GetQueryCountAsync(QueryExecutionRequest request); + + /// + /// Executes a raw GraphQL query directly. + /// + Task ExecuteRawQueryAsync( + string query, + Dictionary? variables = null); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IRawQueryService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IRawQueryService.cs new file mode 100644 index 0000000..3d6564c --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/IRawQueryService.cs @@ -0,0 +1,34 @@ +using OptiGraphExtensions.Features.QueryLibrary.Models; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions +{ + /// + /// Service for handling raw GraphQL queries. + /// + public interface IRawQueryService + { + /// + /// Validates a raw GraphQL query. + /// + QueryValidationResult ValidateQuery(string rawQuery); + + /// + /// Parses query variables from JSON string. + /// + Dictionary? ParseVariables(string? variablesJson); + + /// + /// Checks if the query supports cursor-based pagination. + /// + bool SupportsPagination(string rawQuery); + + /// + /// Injects pagination variables into the query if missing. + /// + (string query, Dictionary variables) InjectPaginationSupport( + string rawQuery, + Dictionary? existingVariables, + int pageSize, + string? cursor); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISavedQueryService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISavedQueryService.cs new file mode 100644 index 0000000..32afcb2 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISavedQueryService.cs @@ -0,0 +1,50 @@ +using OptiGraphExtensions.Features.QueryLibrary.Models; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions +{ + /// + /// Service for managing saved queries. + /// + public interface ISavedQueryService + { + /// + /// Gets all saved queries. + /// + Task> GetAllQueriesAsync(); + + /// + /// Gets all active saved queries. + /// + Task> GetActiveQueriesAsync(); + + /// + /// Gets a saved query by ID. + /// + Task GetQueryByIdAsync(Guid id); + + /// + /// Creates a new saved query. + /// + Task CreateQueryAsync(SavedQueryModel model, string? createdBy = null); + + /// + /// Updates an existing saved query. + /// + Task UpdateQueryAsync(Guid id, SavedQueryModel model, string? updatedBy = null); + + /// + /// Deletes a saved query. + /// + Task DeleteQueryAsync(Guid id); + + /// + /// Checks if a query with the given ID exists. + /// + Task QueryExistsAsync(Guid id); + + /// + /// Converts a saved query to a query execution request. + /// + QueryExecutionRequest ToExecutionRequest(SavedQueryModel model); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISchemaDiscoveryService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISchemaDiscoveryService.cs new file mode 100644 index 0000000..ce2fb51 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/Abstractions/ISchemaDiscoveryService.cs @@ -0,0 +1,25 @@ +using OptiGraphExtensions.Features.QueryLibrary.Models; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions +{ + /// + /// Service for discovering the Optimizely Graph schema (content types and fields). + /// + public interface ISchemaDiscoveryService + { + /// + /// Gets all available content types from Optimizely Graph. + /// + Task> GetContentTypesAsync(); + + /// + /// Gets all queryable fields for a specific content type. + /// + Task> GetFieldsForContentTypeAsync(string contentType); + + /// + /// Clears the schema cache (used after Graph schema changes). + /// + Task ClearCacheAsync(); + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/CsvExportService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/CsvExportService.cs new file mode 100644 index 0000000..e496bec --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/CsvExportService.cs @@ -0,0 +1,157 @@ +using System.Globalization; +using System.Text; +using OptiGraphExtensions.Features.QueryLibrary.Models; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services +{ + /// + /// Service for exporting query results to CSV format. + /// + public class CsvExportService : ICsvExportService + { + // UTF-8 BOM for Excel compatibility + private static readonly byte[] Utf8Bom = { 0xEF, 0xBB, 0xBF }; + + public async Task ExportToCsvAsync( + IAsyncEnumerable results, + Stream outputStream, + CancellationToken cancellationToken = default) + { + using var writer = new StreamWriter(outputStream, Encoding.UTF8, leaveOpen: true); + + // Write UTF-8 BOM for Excel compatibility + await outputStream.WriteAsync(Utf8Bom, cancellationToken); + + var headersWritten = false; + var columns = new List(); + + await foreach (var result in results.WithCancellation(cancellationToken)) + { + if (!result.IsSuccess) + { + // Write error message if query failed + await writer.WriteLineAsync($"Error: {result.ErrorMessage}"); + break; + } + + // Write headers on first result + if (!headersWritten && result.Columns.Any()) + { + columns = result.Columns; + await writer.WriteLineAsync(FormatCsvRow(columns)); + headersWritten = true; + } + + // Write data rows + foreach (var row in result.Rows) + { + var values = columns.Select(col => + row.TryGetValue(col, out var value) ? FormatCsvValue(value) : ""); + await writer.WriteLineAsync(FormatCsvRow(values)); + } + + await writer.FlushAsync(); + } + } + + public string GenerateCsvPreview(QueryExecutionResult result, int maxRows = 100) + { + var sb = new StringBuilder(); + + if (!result.IsSuccess) + { + sb.AppendLine($"Error: {result.ErrorMessage}"); + return sb.ToString(); + } + + if (!result.Columns.Any()) + { + sb.AppendLine("No data to display"); + return sb.ToString(); + } + + // Write headers + sb.AppendLine(FormatCsvRow(result.Columns)); + + // Write data rows (limited) + var rowCount = 0; + foreach (var row in result.Rows) + { + if (rowCount >= maxRows) break; + + var values = result.Columns.Select(col => + row.TryGetValue(col, out var value) ? FormatCsvValue(value) : ""); + sb.AppendLine(FormatCsvRow(values)); + rowCount++; + } + + if (result.Rows.Count > maxRows) + { + sb.AppendLine($"... and {result.Rows.Count - maxRows} more rows"); + } + + return sb.ToString(); + } + + public string GenerateFilename(string queryName) + { + // Sanitize query name for filename + var sanitized = string.Join("_", queryName.Split(Path.GetInvalidFileNameChars())); + if (string.IsNullOrWhiteSpace(sanitized)) + { + sanitized = "export"; + } + + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture); + return $"{sanitized}_{timestamp}.csv"; + } + + private string FormatCsvRow(IEnumerable values) + { + return string.Join(",", values.Select(v => EscapeCsvValue(v))); + } + + private string FormatCsvValue(object? value) + { + if (value == null) + { + return ""; + } + + return value switch + { + bool b => b ? "true" : "false", + DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + DateTimeOffset dto => dto.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture) ?? "", + _ => value.ToString() ?? "" + }; + } + + private string EscapeCsvValue(string value) + { + if (string.IsNullOrEmpty(value)) + { + return ""; + } + + // Check if value needs quoting (RFC 4180) + var needsQuoting = value.Contains(',') || + value.Contains('"') || + value.Contains('\n') || + value.Contains('\r') || + value.StartsWith(' ') || + value.EndsWith(' '); + + if (!needsQuoting) + { + return value; + } + + // Escape double quotes by doubling them + var escaped = value.Replace("\"", "\"\""); + return $"\"{escaped}\""; + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryBuilderService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryBuilderService.cs new file mode 100644 index 0000000..6c3bb98 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryBuilderService.cs @@ -0,0 +1,328 @@ +using System.Text; +using System.Text.RegularExpressions; +using OptiGraphExtensions.Entities; +using OptiGraphExtensions.Features.QueryLibrary.Models; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services +{ + /// + /// Service for building GraphQL queries from visual query configurations. + /// + public class QueryBuilderService : IQueryBuilderService + { + private static readonly Dictionary OperatorMapping = new() + { + ["eq"] = "eq", + ["neq"] = "neq", + ["contains"] = "contains", + ["startsWith"] = "startsWith", + ["gt"] = "gt", + ["lt"] = "lt", + ["gte"] = "gte", + ["lte"] = "lte", + ["exists"] = "exists" + }; + + // Default subfields for known complex field names in Optimizely Graph + // These fields require subfield selection and cannot be queried as scalar values + private static readonly Dictionary DefaultSubfieldsForFieldNames = new(StringComparer.OrdinalIgnoreCase) + { + // Content references + ["ContentLink"] = "GuidValue Url", + ["ParentLink"] = "GuidValue Url", + + // Language fields + ["Language"] = "Name", + ["MasterLanguage"] = "Name", + ["ExistingLanguages"] = "Name", + + // Content area fields - use pattern matching for any *ContentArea field + ["MainContentArea"] = "DisplayOption ContentLink { GuidValue Url }", + ["RelatedContentArea"] = "DisplayOption ContentLink { GuidValue Url }", + + // Media/Image fields + ["Thumbnail"] = "Url", + ["PageImage"] = "Url", + ["TeaserImage"] = "Url", + + // Link fields + ["Links"] = "Href Text Title Target", + + // Metadata + ["Ancestors"] = "GuidValue", + ["Categories"] = "GuidValue", + + // Site + ["SiteId"] = "GuidValue" + }; + + // Patterns for field names that are typically complex types + private static readonly string[] ComplexFieldPatterns = new[] + { + "ContentArea$", // Any field ending with ContentArea + "^Image$", // Image field + "Image$", // Any field ending with Image + "Link$", // Any field ending with Link (but not ContentLink which is handled above) + "Reference$", // Any field ending with Reference + "Block$" // Any field ending with Block + }; + + // Default subfields for pattern matches + private const string DefaultContentAreaSubfields = "DisplayOption ContentLink { GuidValue Url }"; + private const string DefaultLinkSubfields = "GuidValue Url"; + private const string DefaultImageSubfields = "Url"; + + public string BuildGraphQLQuery(QueryExecutionRequest request) + { + if (request.QueryType == QueryType.Raw && !string.IsNullOrEmpty(request.RawGraphQuery)) + { + return request.RawGraphQuery; + } + + if (string.IsNullOrEmpty(request.ContentType)) + { + throw new ArgumentException("ContentType is required for visual queries"); + } + + var fieldSelection = BuildFieldSelection(request.SelectedFields); + var whereClause = BuildWhereClause(request.Filters); + var localeParam = !string.IsNullOrEmpty(request.Language) + ? ", $locale: [Locales]" + : ""; + var localeFilter = !string.IsNullOrEmpty(request.Language) + ? "locale: $locale," + : ""; + var orderByClause = BuildOrderByClause(request.SortField, request.SortDescending); + + var query = $@" +query ExportQuery($limit: Int!, $cursor: String{localeParam}) {{ + {request.ContentType}( + {whereClause} + limit: $limit, + cursor: $cursor, + {localeFilter} + {orderByClause} + ) {{ + items {{ + {fieldSelection} + }} + cursor + total + }} +}}"; + + return query; + } + + public Dictionary BuildVariables(QueryExecutionRequest request) + { + var variables = new Dictionary + { + ["limit"] = request.PageSize > 0 ? request.PageSize : 100 + }; + + if (!string.IsNullOrEmpty(request.Cursor)) + { + variables["cursor"] = request.Cursor; + } + + if (!string.IsNullOrEmpty(request.Language)) + { + variables["locale"] = new[] { request.Language.ToLowerInvariant() }; + } + + return variables; + } + + public string BuildFieldSelection(IEnumerable fieldPaths) + { + if (fieldPaths == null || !fieldPaths.Any()) + { + // Default fields if none selected + return @"Name + ContentLink { GuidValue } + ContentType + Language { Name }"; + } + + // Group nested fields by their parent path + var fieldTree = BuildFieldTree(fieldPaths); + return RenderFieldTree(fieldTree, 0); + } + + public string BuildWhereClause(IEnumerable filters) + { + if (filters == null || !filters.Any()) + { + return ""; + } + + var conditions = new List(); + + foreach (var filter in filters.Where(f => + !string.IsNullOrEmpty(f.Field) && + !string.IsNullOrEmpty(f.Value))) + { + var graphOperator = OperatorMapping.GetValueOrDefault(filter.Operator, "eq"); + var value = FormatFilterValue(filter.Value, graphOperator); + conditions.Add($"{filter.Field}: {{ {graphOperator}: {value} }}"); + } + + if (!conditions.Any()) + { + return ""; + } + + return $"where: {{ {string.Join(", ", conditions)} }},"; + } + + private string BuildOrderByClause(string? sortField, bool sortDescending) + { + if (string.IsNullOrEmpty(sortField)) + { + return ""; + } + + var direction = sortDescending ? "DESC" : "ASC"; + return $"orderBy: {{ {sortField}: {direction} }}"; + } + + private Dictionary BuildFieldTree(IEnumerable fieldPaths) + { + var tree = new Dictionary(); + + foreach (var path in fieldPaths) + { + var parts = path.Split('.'); + var current = tree; + + for (int i = 0; i < parts.Length; i++) + { + var part = parts[i]; + + if (i == parts.Length - 1) + { + // Leaf node + if (!current.ContainsKey(part)) + { + current[part] = new Dictionary(); + } + } + else + { + // Branch node + if (!current.ContainsKey(part)) + { + current[part] = new Dictionary(); + } + current = (Dictionary)current[part]; + } + } + } + + return tree; + } + + private string RenderFieldTree(Dictionary tree, int indent) + { + var sb = new StringBuilder(); + var indentStr = new string(' ', indent * 4); + + foreach (var (fieldName, children) in tree) + { + var childDict = (Dictionary)children; + + if (childDict.Count == 0) + { + // Leaf field - check if it needs default subfields + var subfields = GetDefaultSubfieldsForField(fieldName); + if (!string.IsNullOrEmpty(subfields)) + { + // Complex field - add default subfields + sb.AppendLine($"{indentStr}{fieldName} {{ {subfields} }}"); + } + else + { + // Simple scalar field + sb.AppendLine($"{indentStr}{fieldName}"); + } + } + else + { + // Nested field with explicit children + sb.AppendLine($"{indentStr}{fieldName} {{"); + sb.Append(RenderFieldTree(childDict, indent + 1)); + sb.AppendLine($"{indentStr}}}"); + } + } + + return sb.ToString(); + } + + /// + /// Gets the default subfields for a complex field name, if any. + /// Returns null for scalar fields that don't need subfields. + /// + private string? GetDefaultSubfieldsForField(string fieldName) + { + // First check exact matches + if (DefaultSubfieldsForFieldNames.TryGetValue(fieldName, out var subfields)) + { + return subfields; + } + + // Then check patterns + if (Regex.IsMatch(fieldName, "ContentArea$", RegexOptions.IgnoreCase)) + { + return DefaultContentAreaSubfields; + } + + if (Regex.IsMatch(fieldName, "Image$", RegexOptions.IgnoreCase) || + Regex.IsMatch(fieldName, "^Image$", RegexOptions.IgnoreCase)) + { + return DefaultImageSubfields; + } + + if (Regex.IsMatch(fieldName, "Link$", RegexOptions.IgnoreCase) && + !fieldName.Equals("ContentLink", StringComparison.OrdinalIgnoreCase) && + !fieldName.Equals("ParentLink", StringComparison.OrdinalIgnoreCase)) + { + return DefaultLinkSubfields; + } + + if (Regex.IsMatch(fieldName, "Reference$", RegexOptions.IgnoreCase)) + { + return DefaultLinkSubfields; + } + + // No default subfields - treat as scalar + return null; + } + + private string FormatFilterValue(string value, string graphOperator) + { + // For boolean operators + if (graphOperator == "exists") + { + return value.ToLowerInvariant() == "true" ? "true" : "false"; + } + + // Try to parse as number + if (int.TryParse(value, out _) || double.TryParse(value, out _)) + { + return value; + } + + // Try to parse as boolean + if (bool.TryParse(value, out var boolValue)) + { + return boolValue ? "true" : "false"; + } + + // String value - needs quotes + var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\""); + return $"\"{escaped}\""; + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryExecutionService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryExecutionService.cs new file mode 100644 index 0000000..22dfe25 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/QueryExecutionService.cs @@ -0,0 +1,347 @@ +using System.Net.Http.Headers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using OptiGraphExtensions.Common; +using OptiGraphExtensions.Entities; +using OptiGraphExtensions.Features.Common.Services; +using OptiGraphExtensions.Features.QueryLibrary.Models; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; +using OptiGraphExtensions.Features.Synonyms.Services.Abstractions; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services +{ + /// + /// Service for executing queries against Optimizely Graph. + /// + public class QueryExecutionService : IQueryExecutionService + { + private readonly HttpClient _httpClient; + private readonly IOptiGraphConfigurationService _configurationService; + private readonly IGraphConfigurationValidator _graphConfigurationValidator; + private readonly IQueryBuilderService _queryBuilderService; + private readonly IRawQueryService _rawQueryService; + private readonly JsonSerializerOptions _jsonOptions; + + public QueryExecutionService( + HttpClient httpClient, + IOptiGraphConfigurationService configurationService, + IGraphConfigurationValidator graphConfigurationValidator, + IQueryBuilderService queryBuilderService, + IRawQueryService rawQueryService) + { + _httpClient = httpClient; + _configurationService = configurationService; + _graphConfigurationValidator = graphConfigurationValidator; + _queryBuilderService = queryBuilderService; + _rawQueryService = rawQueryService; + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + public async Task ExecuteQueryAsync(QueryExecutionRequest request) + { + try + { + string query; + Dictionary variables; + + if (request.QueryType == QueryType.Raw) + { + query = request.RawGraphQuery ?? throw new ArgumentException("RawGraphQuery is required"); + variables = request.QueryVariables ?? new Dictionary(); + + // Inject pagination support if needed + (query, variables) = _rawQueryService.InjectPaginationSupport( + query, variables, request.PageSize, request.Cursor); + } + else + { + query = _queryBuilderService.BuildGraphQLQuery(request); + variables = _queryBuilderService.BuildVariables(request); + } + + return await ExecuteRawQueryAsync(query, variables); + } + catch (Exception ex) + { + return new QueryExecutionResult + { + ErrorMessage = $"Query execution failed: {ex.Message}" + }; + } + } + + public async IAsyncEnumerable ExecuteQueryWithPaginationAsync( + QueryExecutionRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? cursor = null; + var hasMore = true; + + while (hasMore && !cancellationToken.IsCancellationRequested) + { + request.Cursor = cursor; + var result = await ExecuteQueryAsync(request); + + if (!result.IsSuccess) + { + yield return result; + yield break; + } + + yield return result; + + cursor = result.NextCursor; + hasMore = result.HasMore && !string.IsNullOrEmpty(cursor); + } + } + + public async Task GetQueryCountAsync(QueryExecutionRequest request) + { + // Execute query with limit 0 to just get count + var countRequest = new QueryExecutionRequest + { + QueryType = request.QueryType, + ContentType = request.ContentType, + SelectedFields = new List { "Name" }, // Minimal field selection + Filters = request.Filters, + Language = request.Language, + RawGraphQuery = request.RawGraphQuery, + QueryVariables = request.QueryVariables, + PageSize = 1 // Minimal page size + }; + + var result = await ExecuteQueryAsync(countRequest); + return result.TotalCount; + } + + public async Task ExecuteRawQueryAsync( + string query, + Dictionary? variables = null) + { + try + { + var (gatewayUrl, appKey, secret) = GetAndValidateGraphConfiguration(); + + var request = new + { + query, + variables = variables ?? new Dictionary() + }; + + var authHeader = $"{appKey}:{secret}".Base64Encode(); + var graphqlEndpoint = $"{gatewayUrl}/content/v2"; + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, graphqlEndpoint); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", authHeader); + httpRequest.Content = new StringContent( + JsonSerializer.Serialize(request, _jsonOptions), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.SendAsync(httpRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + return new QueryExecutionResult + { + ErrorMessage = $"HTTP {response.StatusCode}: {responseContent}" + }; + } + + return ParseGraphQLResponse(responseContent); + } + catch (Exception ex) + { + return new QueryExecutionResult + { + ErrorMessage = $"Query execution failed: {ex.Message}" + }; + } + } + + private QueryExecutionResult ParseGraphQLResponse(string responseContent) + { + try + { + using var doc = JsonDocument.Parse(responseContent); + var root = doc.RootElement; + + // Check for GraphQL errors + if (root.TryGetProperty("errors", out var errors) && errors.GetArrayLength() > 0) + { + var errorMessages = new List(); + foreach (var error in errors.EnumerateArray()) + { + if (error.TryGetProperty("message", out var msg)) + { + errorMessages.Add(msg.GetString() ?? "Unknown error"); + } + } + + return new QueryExecutionResult + { + ErrorMessage = string.Join("; ", errorMessages) + }; + } + + // Parse data + if (!root.TryGetProperty("data", out var data)) + { + return new QueryExecutionResult + { + ErrorMessage = "No data in response" + }; + } + + // Find the first property in data (the content type query result) + JsonElement? contentResult = null; + foreach (var prop in data.EnumerateObject()) + { + contentResult = prop.Value; + break; + } + + if (!contentResult.HasValue) + { + return new QueryExecutionResult + { + ErrorMessage = "No content type result in response" + }; + } + + var result = new QueryExecutionResult(); + + // Parse items + if (contentResult.Value.TryGetProperty("items", out var items)) + { + result.Rows = ParseItems(items, out var columns); + result.Columns = columns; + } + + // Parse pagination info + if (contentResult.Value.TryGetProperty("cursor", out var cursor) && + cursor.ValueKind == JsonValueKind.String) + { + result.NextCursor = cursor.GetString(); + result.HasMore = !string.IsNullOrEmpty(result.NextCursor); + } + + if (contentResult.Value.TryGetProperty("total", out var total) && + total.ValueKind == JsonValueKind.Number) + { + result.TotalCount = total.GetInt32(); + } + + return result; + } + catch (JsonException ex) + { + return new QueryExecutionResult + { + ErrorMessage = $"Failed to parse response: {ex.Message}" + }; + } + } + + private List> ParseItems(JsonElement items, out List columns) + { + var rows = new List>(); + var columnSet = new HashSet(); + + foreach (var item in items.EnumerateArray()) + { + var row = FlattenJsonElement(item, ""); + rows.Add(row); + + foreach (var key in row.Keys) + { + columnSet.Add(key); + } + } + + // Sort columns for consistent ordering + columns = columnSet.OrderBy(c => c).ToList(); + + return rows; + } + + private Dictionary FlattenJsonElement(JsonElement element, string prefix) + { + var result = new Dictionary(); + + if (element.ValueKind != JsonValueKind.Object) + { + result[prefix] = GetJsonValue(element); + return result; + } + + foreach (var property in element.EnumerateObject()) + { + var key = string.IsNullOrEmpty(prefix) + ? property.Name + : $"{prefix}.{property.Name}"; + + if (property.Value.ValueKind == JsonValueKind.Object) + { + // Recursively flatten nested objects + var nested = FlattenJsonElement(property.Value, key); + foreach (var (nestedKey, nestedValue) in nested) + { + result[nestedKey] = nestedValue; + } + } + else if (property.Value.ValueKind == JsonValueKind.Array) + { + // Convert arrays to comma-separated strings + var values = new List(); + foreach (var arrayItem in property.Value.EnumerateArray()) + { + var val = GetJsonValue(arrayItem); + if (val != null) + { + values.Add(val.ToString() ?? ""); + } + } + result[key] = string.Join(", ", values); + } + else + { + result[key] = GetJsonValue(property.Value); + } + } + + return result; + } + + private object? GetJsonValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => element.GetRawText() + }; + } + + private (string gatewayUrl, string appKey, string secret) GetAndValidateGraphConfiguration() + { + var gatewayUrl = _configurationService.GetGatewayUrl(); + var appKey = _configurationService.GetAppKey(); + var secret = _configurationService.GetSecret(); + + _graphConfigurationValidator.ValidateConfiguration(gatewayUrl, appKey, secret); + + return (gatewayUrl, appKey, secret); + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/RawQueryService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/RawQueryService.cs new file mode 100644 index 0000000..2eeada4 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/RawQueryService.cs @@ -0,0 +1,231 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using OptiGraphExtensions.Features.QueryLibrary.Models; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services +{ + /// + /// Service for handling raw GraphQL queries. + /// + public class RawQueryService : IRawQueryService + { + private readonly JsonSerializerOptions _jsonOptions; + + // Regex patterns for query analysis + private static readonly Regex CursorVariablePattern = new( + @"\$cursor\s*:\s*String", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex LimitVariablePattern = new( + @"\$limit\s*:\s*Int", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex CursorFieldPattern = new( + @"\bcursor\b(?!\s*:)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex TotalFieldPattern = new( + @"\btotal\b(?!\s*:)", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static readonly Regex QueryOperationPattern = new( + @"^\s*(query|mutation|subscription)\s+(\w+)?\s*(\([^)]*\))?\s*{", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + public RawQueryService() + { + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + public QueryValidationResult ValidateQuery(string rawQuery) + { + var result = new QueryValidationResult + { + IsValid = true, + Messages = new List() + }; + + if (string.IsNullOrWhiteSpace(rawQuery)) + { + result.IsValid = false; + result.Messages.Add("Query cannot be empty"); + return result; + } + + // Basic syntax check - ensure it looks like a GraphQL query + if (!rawQuery.Contains("{") || !rawQuery.Contains("}")) + { + result.IsValid = false; + result.Messages.Add("Query must contain braces { }"); + return result; + } + + // Check for balanced braces + var openBraces = rawQuery.Count(c => c == '{'); + var closeBraces = rawQuery.Count(c => c == '}'); + if (openBraces != closeBraces) + { + result.IsValid = false; + result.Messages.Add("Unbalanced braces in query"); + return result; + } + + // Check for pagination support + result.SupportsPagination = SupportsPagination(rawQuery); + + if (!result.SupportsPagination) + { + result.Messages.Add( + "Warning: Query may not support pagination. " + + "For full export, include $limit and $cursor variables, " + + "and request 'cursor' and 'total' fields in response."); + } + + return result; + } + + public Dictionary? ParseVariables(string? variablesJson) + { + if (string.IsNullOrWhiteSpace(variablesJson)) + { + return null; + } + + try + { + var variables = JsonSerializer.Deserialize>( + variablesJson, _jsonOptions); + + if (variables == null) + { + return null; + } + + // Convert JsonElement values to appropriate types + var result = new Dictionary(); + foreach (var (key, value) in variables) + { + result[key] = ConvertJsonElement(value); + } + + return result; + } + catch (JsonException) + { + return null; + } + } + + public bool SupportsPagination(string rawQuery) + { + // Check if query has $limit and $cursor variables + var hasLimitVar = LimitVariablePattern.IsMatch(rawQuery); + var hasCursorVar = CursorVariablePattern.IsMatch(rawQuery); + + // Check if query requests cursor and total in response + var hasCursorField = CursorFieldPattern.IsMatch(rawQuery); + var hasTotalField = TotalFieldPattern.IsMatch(rawQuery); + + return hasLimitVar && hasCursorVar && hasCursorField && hasTotalField; + } + + public (string query, Dictionary variables) InjectPaginationSupport( + string rawQuery, + Dictionary? existingVariables, + int pageSize, + string? cursor) + { + var variables = existingVariables ?? new Dictionary(); + var modifiedQuery = rawQuery; + + // Add limit to variables + variables["limit"] = pageSize; + + // Add cursor to variables if provided + if (!string.IsNullOrEmpty(cursor)) + { + variables["cursor"] = cursor; + } + + // If query already supports pagination, just return with updated variables + if (SupportsPagination(rawQuery)) + { + return (modifiedQuery, variables); + } + + // If query has a named operation with parameters, try to inject $limit and $cursor + var match = QueryOperationPattern.Match(rawQuery); + if (match.Success) + { + var existingParams = match.Groups[3].Value; + var newParams = BuildPaginationParams(existingParams); + + // Replace the operation signature + var operationType = match.Groups[1].Value; + var operationName = match.Groups[2].Success ? match.Groups[2].Value : "Query"; + + var oldSignature = match.Value; + var newSignature = $"{operationType} {operationName}{newParams} {{"; + + modifiedQuery = rawQuery.Replace(oldSignature, newSignature); + } + + return (modifiedQuery, variables); + } + + private string BuildPaginationParams(string existingParams) + { + var hasExistingParams = !string.IsNullOrEmpty(existingParams) && + existingParams != "()" && + existingParams.Length > 2; + + var paramList = new List(); + + if (hasExistingParams) + { + // Extract existing params without parentheses + var inner = existingParams.Trim('(', ')'); + if (!string.IsNullOrWhiteSpace(inner)) + { + paramList.Add(inner); + } + } + + // Add limit if not present + if (!LimitVariablePattern.IsMatch(existingParams ?? "")) + { + paramList.Add("$limit: Int!"); + } + + // Add cursor if not present + if (!CursorVariablePattern.IsMatch(existingParams ?? "")) + { + paramList.Add("$cursor: String"); + } + + return $"({string.Join(", ", paramList)})"; + } + + private object ConvertJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Array => element.EnumerateArray() + .Select(e => ConvertJsonElement(e)) + .ToList(), + JsonValueKind.Object => element.EnumerateObject() + .ToDictionary(p => p.Name, p => ConvertJsonElement(p.Value)), + _ => element.GetRawText() + }; + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/SavedQueryService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/SavedQueryService.cs new file mode 100644 index 0000000..366c69d --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/SavedQueryService.cs @@ -0,0 +1,223 @@ +using System.Text.Json; +using OptiGraphExtensions.Entities; +using OptiGraphExtensions.Features.QueryLibrary.Models; +using OptiGraphExtensions.Features.QueryLibrary.Repositories; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services +{ + /// + /// Service for managing saved queries with mapping between entities and models. + /// + public class SavedQueryService : ISavedQueryService + { + private readonly ISavedQueryRepository _repository; + private readonly IRawQueryService _rawQueryService; + private readonly JsonSerializerOptions _jsonOptions; + + public SavedQueryService( + ISavedQueryRepository repository, + IRawQueryService rawQueryService) + { + _repository = repository; + _rawQueryService = rawQueryService; + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + public async Task> GetAllQueriesAsync() + { + var entities = await _repository.GetAllAsync(); + return entities.Select(MapToModel); + } + + public async Task> GetActiveQueriesAsync() + { + var entities = await _repository.GetActiveAsync(); + return entities.Select(MapToModel); + } + + public async Task GetQueryByIdAsync(Guid id) + { + var entity = await _repository.GetByIdAsync(id); + return entity != null ? MapToModel(entity) : null; + } + + public async Task CreateQueryAsync(SavedQueryModel model, string? createdBy = null) + { + var entity = MapToEntity(model); + entity.Id = Guid.NewGuid(); + entity.CreatedAt = DateTime.UtcNow; + entity.CreatedBy = createdBy; + + var created = await _repository.CreateAsync(entity); + return MapToModel(created); + } + + public async Task UpdateQueryAsync(Guid id, SavedQueryModel model, string? updatedBy = null) + { + var existing = await _repository.GetByIdAsync(id); + if (existing == null) + { + return null; + } + + // Update fields + existing.Name = model.Name; + existing.Description = model.Description; + existing.QueryType = model.QueryType; + existing.ContentType = model.ContentType; + existing.SelectedFieldsJson = SerializeFields(model.SelectedFields); + existing.FiltersJson = SerializeFilters(model.Filters); + existing.Language = model.Language; + existing.SortField = model.SortField; + existing.SortDescending = model.SortDescending; + existing.RawGraphQuery = model.RawGraphQuery; + existing.QueryVariablesJson = model.QueryVariablesJson; + existing.PageSize = model.PageSize; + existing.IsActive = model.IsActive; + existing.UpdatedAt = DateTime.UtcNow; + existing.UpdatedBy = updatedBy; + + var updated = await _repository.UpdateAsync(existing); + return MapToModel(updated); + } + + public async Task DeleteQueryAsync(Guid id) + { + return await _repository.DeleteAsync(id); + } + + public async Task QueryExistsAsync(Guid id) + { + return await _repository.ExistsAsync(id); + } + + public QueryExecutionRequest ToExecutionRequest(SavedQueryModel model) + { + var request = new QueryExecutionRequest + { + QueryType = model.QueryType, + ContentType = model.ContentType, + SelectedFields = model.SelectedFields ?? new List(), + Filters = model.Filters ?? new List(), + Language = model.Language, + SortField = model.SortField, + SortDescending = model.SortDescending, + RawGraphQuery = model.RawGraphQuery, + PageSize = model.PageSize > 0 ? model.PageSize : 100 + }; + + // Parse query variables for raw queries + if (model.QueryType == QueryType.Raw && !string.IsNullOrEmpty(model.QueryVariablesJson)) + { + request.QueryVariables = _rawQueryService.ParseVariables(model.QueryVariablesJson); + } + + return request; + } + + private SavedQueryModel MapToModel(SavedQuery entity) + { + return new SavedQueryModel + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description, + QueryType = entity.QueryType, + ContentType = entity.ContentType, + SelectedFields = DeserializeFields(entity.SelectedFieldsJson), + Filters = DeserializeFilters(entity.FiltersJson), + Language = entity.Language, + SortField = entity.SortField, + SortDescending = entity.SortDescending, + RawGraphQuery = entity.RawGraphQuery, + QueryVariablesJson = entity.QueryVariablesJson, + PageSize = entity.PageSize, + IsActive = entity.IsActive, + CreatedAt = entity.CreatedAt, + CreatedBy = entity.CreatedBy, + UpdatedAt = entity.UpdatedAt, + UpdatedBy = entity.UpdatedBy + }; + } + + private SavedQuery MapToEntity(SavedQueryModel model) + { + return new SavedQuery + { + Id = model.Id, + Name = model.Name, + Description = model.Description, + QueryType = model.QueryType, + ContentType = model.ContentType, + SelectedFieldsJson = SerializeFields(model.SelectedFields), + FiltersJson = SerializeFilters(model.Filters), + Language = model.Language, + SortField = model.SortField, + SortDescending = model.SortDescending, + RawGraphQuery = model.RawGraphQuery, + QueryVariablesJson = model.QueryVariablesJson, + PageSize = model.PageSize, + IsActive = model.IsActive + }; + } + + private List DeserializeFields(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new List(); + } + + try + { + return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } + + private string SerializeFields(List? fields) + { + if (fields == null || !fields.Any()) + { + return "[]"; + } + + return JsonSerializer.Serialize(fields, _jsonOptions); + } + + private List DeserializeFilters(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new List(); + } + + try + { + return JsonSerializer.Deserialize>(json, _jsonOptions) ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } + + private string SerializeFilters(List? filters) + { + if (filters == null || !filters.Any()) + { + return "[]"; + } + + return JsonSerializer.Serialize(filters, _jsonOptions); + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/Services/SchemaDiscoveryService.cs b/src/OptiGraphExtensions/Features/QueryLibrary/Services/SchemaDiscoveryService.cs new file mode 100644 index 0000000..a73bd50 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/Services/SchemaDiscoveryService.cs @@ -0,0 +1,354 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using OptiGraphExtensions.Common; +using OptiGraphExtensions.Features.Common.Caching; +using OptiGraphExtensions.Features.Common.Services; +using OptiGraphExtensions.Features.QueryLibrary.Models; +using OptiGraphExtensions.Features.QueryLibrary.Services.Abstractions; +using OptiGraphExtensions.Features.Synonyms.Services.Abstractions; + +namespace OptiGraphExtensions.Features.QueryLibrary.Services +{ + /// + /// Service for discovering the Optimizely Graph schema via GraphQL introspection. + /// + public class SchemaDiscoveryService : ISchemaDiscoveryService + { + private readonly HttpClient _httpClient; + private readonly IOptiGraphConfigurationService _configurationService; + private readonly IGraphConfigurationValidator _graphConfigurationValidator; + private readonly ICacheService _cacheService; + private readonly JsonSerializerOptions _jsonOptions; + private readonly TimeSpan _cacheExpiration = TimeSpan.FromMinutes(15); + + private const string ContentTypesCacheKey = "QueryLibrary:ContentTypes"; + private const string FieldsCacheKeyPrefix = "QueryLibrary:Fields:"; + + public SchemaDiscoveryService( + HttpClient httpClient, + IOptiGraphConfigurationService configurationService, + IGraphConfigurationValidator graphConfigurationValidator, + ICacheService cacheService) + { + _httpClient = httpClient; + _configurationService = configurationService; + _graphConfigurationValidator = graphConfigurationValidator; + _cacheService = cacheService; + _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + } + + public async Task> GetContentTypesAsync() + { + // Check cache first + var cached = await _cacheService.GetAsync>(ContentTypesCacheKey); + if (cached != null) + { + return cached; + } + + var (gatewayUrl, appKey, secret) = GetAndValidateGraphConfiguration(); + + // Use introspection to get all types that are likely content types + var query = @" + query IntrospectSchema { + __schema { + types { + name + kind + interfaces { + name + } + } + } + }"; + + var response = await ExecuteGraphQueryAsync(gatewayUrl, appKey, secret, query); + + var contentTypes = ParseContentTypes(response); + + // Cache the result + await _cacheService.SetAsync(ContentTypesCacheKey, contentTypes, _cacheExpiration); + + return contentTypes; + } + + public async Task> GetFieldsForContentTypeAsync(string contentType) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return new List(); + } + + // Check cache first + var cacheKey = $"{FieldsCacheKeyPrefix}{contentType}"; + var cached = await _cacheService.GetAsync>(cacheKey); + if (cached != null) + { + return cached; + } + + var (gatewayUrl, appKey, secret) = GetAndValidateGraphConfiguration(); + + var query = $@" + query IntrospectType {{ + __type(name: ""{contentType}"") {{ + name + fields {{ + name + type {{ + name + kind + ofType {{ + name + kind + ofType {{ + name + kind + }} + }} + }} + }} + }} + }}"; + + var response = await ExecuteGraphQueryAsync(gatewayUrl, appKey, secret, query); + + var fields = ParseFields(response, contentType); + + // Cache the result + await _cacheService.SetAsync(cacheKey, fields, _cacheExpiration); + + return fields; + } + + public async Task ClearCacheAsync() + { + await _cacheService.RemoveByPatternAsync("^QueryLibrary:"); + } + + private IList ParseContentTypes(string response) + { + try + { + using var doc = JsonDocument.Parse(response); + var root = doc.RootElement; + + if (!root.TryGetProperty("data", out var data) || + !data.TryGetProperty("__schema", out var schema) || + !schema.TryGetProperty("types", out var types)) + { + return new List(); + } + + var contentTypes = new List(); + + foreach (var type in types.EnumerateArray()) + { + if (!type.TryGetProperty("name", out var nameProp) || + !type.TryGetProperty("kind", out var kindProp)) + { + continue; + } + + var typeName = nameProp.GetString(); + var kind = kindProp.GetString(); + + // Skip internal types (start with __) and non-OBJECT types + if (string.IsNullOrEmpty(typeName) || + typeName.StartsWith("__") || + kind != "OBJECT") + { + continue; + } + + // Check if the type implements IContent or similar interface + // or has a name that suggests it's a content type + if (type.TryGetProperty("interfaces", out var interfaces)) + { + var implementsContent = false; + foreach (var iface in interfaces.EnumerateArray()) + { + if (iface.TryGetProperty("name", out var ifaceName)) + { + var ifaceNameStr = ifaceName.GetString(); + if (ifaceNameStr == "IContent" || + ifaceNameStr == "IContentData" || + ifaceNameStr == "ILocale") + { + implementsContent = true; + break; + } + } + } + + if (implementsContent) + { + contentTypes.Add(typeName); + } + } + + // Also include types ending with "Page", "Block", "Media" as they're likely content types + if (!contentTypes.Contains(typeName) && + (typeName.EndsWith("Page") || + typeName.EndsWith("Block") || + typeName.EndsWith("Media") || + typeName.EndsWith("Folder") || + typeName.EndsWith("File") || + typeName.EndsWith("Image") || + typeName.EndsWith("Video"))) + { + contentTypes.Add(typeName); + } + } + + return contentTypes.Distinct().OrderBy(n => n).ToList(); + } + catch (JsonException) + { + return new List(); + } + } + + private IList ParseFields(string response, string contentType) + { + try + { + using var doc = JsonDocument.Parse(response); + var root = doc.RootElement; + + if (!root.TryGetProperty("data", out var data) || + !data.TryGetProperty("__type", out var typeInfo)) + { + return new List(); + } + + if (typeInfo.ValueKind == JsonValueKind.Null || + !typeInfo.TryGetProperty("fields", out var fields)) + { + return new List(); + } + + var result = new List(); + + foreach (var field in fields.EnumerateArray()) + { + var schemaField = ParseFieldInfo(field, ""); + if (schemaField != null) + { + result.Add(schemaField); + } + } + + return result.OrderBy(f => f.Name).ToList(); + } + catch (JsonException) + { + return new List(); + } + } + + private SchemaField? ParseFieldInfo(JsonElement field, string parentPath) + { + if (!field.TryGetProperty("name", out var nameProp) || + !field.TryGetProperty("type", out var typeProp)) + { + return null; + } + + var fieldName = nameProp.GetString(); + if (string.IsNullOrEmpty(fieldName) || fieldName.StartsWith("_")) + { + // Skip internal fields (except we might want to keep some like _score) + if (fieldName != "_score") + { + return null; + } + } + + var (typeName, typeKind, isList) = ResolveType(typeProp); + + var path = string.IsNullOrEmpty(parentPath) + ? fieldName + : $"{parentPath}.{fieldName}"; + + var schemaField = new SchemaField + { + Name = fieldName!, + Path = path!, + Type = typeName ?? "Unknown", + IsNested = typeKind == "OBJECT", + IsList = isList + }; + + return schemaField; + } + + private (string? typeName, string? typeKind, bool isList) ResolveType(JsonElement typeElement) + { + var kind = typeElement.TryGetProperty("kind", out var kindProp) + ? kindProp.GetString() + : null; + + var name = typeElement.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() + : null; + + // Handle NON_NULL and LIST wrappers + if ((kind == "NON_NULL" || kind == "LIST") && + typeElement.TryGetProperty("ofType", out var ofType)) + { + var isList = kind == "LIST"; + var (innerName, innerKind, innerIsList) = ResolveType(ofType); + return (innerName, innerKind, isList || innerIsList); + } + + return (name, kind, false); + } + + private async Task ExecuteGraphQueryAsync( + string gatewayUrl, + string appKey, + string secret, + string query) + { + var authHeader = $"{appKey}:{secret}".Base64Encode(); + var graphqlEndpoint = $"{gatewayUrl}/content/v2"; + + var request = new { query }; + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, graphqlEndpoint); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", authHeader); + httpRequest.Content = new StringContent( + JsonSerializer.Serialize(request, _jsonOptions), + Encoding.UTF8, + "application/json"); + + var response = await _httpClient.SendAsync(httpRequest); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException( + $"GraphQL introspection failed: {response.StatusCode} - {errorContent}"); + } + + return await response.Content.ReadAsStringAsync(); + } + + private (string gatewayUrl, string appKey, string secret) GetAndValidateGraphConfiguration() + { + var gatewayUrl = _configurationService.GetGatewayUrl(); + var appKey = _configurationService.GetAppKey(); + var secret = _configurationService.GetSecret(); + + _graphConfigurationValidator.ValidateConfiguration(gatewayUrl, appKey, secret); + + return (gatewayUrl, appKey, secret); + } + } +} diff --git a/src/OptiGraphExtensions/Features/QueryLibrary/_Imports.razor b/src/OptiGraphExtensions/Features/QueryLibrary/_Imports.razor new file mode 100644 index 0000000..c4af1c7 --- /dev/null +++ b/src/OptiGraphExtensions/Features/QueryLibrary/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using OptiGraphExtensions.Features.QueryLibrary.Models +@using OptiGraphExtensions.Entities diff --git a/src/OptiGraphExtensions/Menus/OptiGraphExtensionsMenuProvider.cs b/src/OptiGraphExtensions/Menus/OptiGraphExtensionsMenuProvider.cs index d2208db..097dcd0 100644 --- a/src/OptiGraphExtensions/Menus/OptiGraphExtensionsMenuProvider.cs +++ b/src/OptiGraphExtensions/Menus/OptiGraphExtensionsMenuProvider.cs @@ -15,6 +15,7 @@ public IEnumerable GetMenuItems() yield return CreateMenuItem("Synonyms", "/global/cms/optigraphextensions/synonyms", "/optimizely-graphextensions/administration/synonyms/", SortIndex.Last + 32); yield return CreateMenuItem("Pinned Results", "/global/cms/optigraphextensions/pinned.results", "/optimizely-graphextensions/administration/pinned-results/", SortIndex.Last + 33); yield return CreateMenuItem("Webhooks", "/global/cms/optigraphextensions/webhooks", "/optimizely-graphextensions/administration/webhooks/", SortIndex.Last + 34); + yield return CreateMenuItem("Query Library", "/global/cms/optigraphextensions/querylibrary", "/optimizely-graphextensions/administration/query-library/", SortIndex.Last + 35); } private static UrlMenuItem CreateMenuItem(string name, string path, string url, int index) diff --git a/src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.Designer.cs b/src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.Designer.cs new file mode 100644 index 0000000..5965b15 --- /dev/null +++ b/src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.Designer.cs @@ -0,0 +1,210 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OptiGraphExtensions.Entities; + +#nullable disable + +namespace OptiGraphExtensions.Migrations +{ + [DbContext(typeof(OptiGraphExtensionsDataContext))] + [Migration("20260111000000_AddSavedQueriesTable")] + partial class AddSavedQueriesTable + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("OptiGraphExtensions.Entities.PinnedResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CollectionId") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("GraphId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Language") + .HasColumnType("nvarchar(max)"); + + b.Property("Phrases") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("TargetKey") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetName") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CollectionId"); + + b.ToTable("tbl_OptiGraphExtensions_PinnedResults"); + }); + + modelBuilder.Entity("OptiGraphExtensions.Entities.PinnedResultsCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("GraphCollectionId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("tbl_OptiGraphExtensions_PinnedResultsCollections"); + }); + + modelBuilder.Entity("OptiGraphExtensions.Entities.SavedQuery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("FiltersJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Language") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PageSize") + .HasColumnType("int"); + + b.Property("QueryType") + .HasColumnType("int"); + + b.Property("QueryVariablesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("RawGraphQuery") + .HasColumnType("nvarchar(max)"); + + b.Property("SelectedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("SortDescending") + .HasColumnType("bit"); + + b.Property("SortField") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("Id"); + + b.ToTable("tbl_OptiGraphExtensions_SavedQueries"); + }); + + modelBuilder.Entity("OptiGraphExtensions.Entities.Synonym", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Language") + .HasColumnType("nvarchar(max)"); + + b.Property("Slot") + .HasColumnType("int"); + + b.Property("SynonymItem") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("tbl_OptiGraphExtensions_Synonyms"); + }); + + modelBuilder.Entity("OptiGraphExtensions.Entities.PinnedResult", b => + { + b.HasOne("OptiGraphExtensions.Entities.PinnedResultsCollection", "Collection") + .WithMany("PinnedResults") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("OptiGraphExtensions.Entities.PinnedResultsCollection", b => + { + b.Navigation("PinnedResults"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.cs b/src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.cs new file mode 100644 index 0000000..afe1c5c --- /dev/null +++ b/src/OptiGraphExtensions/Migrations/20260111000000_AddSavedQueriesTable.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OptiGraphExtensions.Migrations +{ + /// + public partial class AddSavedQueriesTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "tbl_OptiGraphExtensions_SavedQueries", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + Description = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: true), + QueryType = table.Column(type: "int", nullable: false), + ContentType = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + SelectedFieldsJson = table.Column(type: "nvarchar(max)", nullable: true), + FiltersJson = table.Column(type: "nvarchar(max)", nullable: true), + Language = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + SortField = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: true), + SortDescending = table.Column(type: "bit", nullable: false), + RawGraphQuery = table.Column(type: "nvarchar(max)", nullable: true), + QueryVariablesJson = table.Column(type: "nvarchar(max)", nullable: true), + PageSize = table.Column(type: "int", nullable: false), + IsActive = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + CreatedBy = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true), + UpdatedAt = table.Column(type: "datetime2", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_tbl_OptiGraphExtensions_SavedQueries", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "tbl_OptiGraphExtensions_SavedQueries"); + } + } +} diff --git a/src/OptiGraphExtensions/Migrations/OptiGraphExtensionsDataContextModelSnapshot.cs b/src/OptiGraphExtensions/Migrations/OptiGraphExtensionsDataContextModelSnapshot.cs index d8fb982..f94a671 100644 --- a/src/OptiGraphExtensions/Migrations/OptiGraphExtensionsDataContextModelSnapshot.cs +++ b/src/OptiGraphExtensions/Migrations/OptiGraphExtensionsDataContextModelSnapshot.cs @@ -91,6 +91,76 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("tbl_OptiGraphExtensions_PinnedResultsCollections"); }); + modelBuilder.Entity("OptiGraphExtensions.Entities.SavedQuery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ContentType") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("FiltersJson") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Language") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("PageSize") + .HasColumnType("int"); + + b.Property("QueryType") + .HasColumnType("int"); + + b.Property("QueryVariablesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("RawGraphQuery") + .HasColumnType("nvarchar(max)"); + + b.Property("SelectedFieldsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("SortDescending") + .HasColumnType("bit"); + + b.Property("SortField") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedBy") + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("Id"); + + b.ToTable("tbl_OptiGraphExtensions_SavedQueries"); + }); + modelBuilder.Entity("OptiGraphExtensions.Entities.Synonym", b => { b.Property("Id") diff --git a/src/OptiGraphExtensions/Views/OptiGraphExtensions/Administration/QueryLibrary/Index.cshtml b/src/OptiGraphExtensions/Views/OptiGraphExtensions/Administration/QueryLibrary/Index.cshtml new file mode 100644 index 0000000..8e5c7c5 --- /dev/null +++ b/src/OptiGraphExtensions/Views/OptiGraphExtensions/Administration/QueryLibrary/Index.cshtml @@ -0,0 +1,8 @@ +@using EPiServer.Shell.Navigation +@model OptiGraphExtensions.Administration.AdministrationViewModel + +@{ + Layout = "/Views/Shared/Layouts/_LayoutBlazorAdminPage.cshtml"; +} + +@(await Html.RenderComponentAsync(Microsoft.AspNetCore.Mvc.Rendering.RenderMode.Server)) diff --git a/src/OptiGraphExtensions/Views/Shared/Layouts/_LayoutBlazorAdminPage.cshtml b/src/OptiGraphExtensions/Views/Shared/Layouts/_LayoutBlazorAdminPage.cshtml index dd6c2dd..859214e 100644 --- a/src/OptiGraphExtensions/Views/Shared/Layouts/_LayoutBlazorAdminPage.cshtml +++ b/src/OptiGraphExtensions/Views/Shared/Layouts/_LayoutBlazorAdminPage.cshtml @@ -39,5 +39,16 @@ + \ No newline at end of file