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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,121 @@ public class CrmCustomerResolver : IExternalResolver
}
```

Example: resolve an address from an external source by its id:

```json
{
"entityType": "customer",
"pipeline": [
{
"project": {
"id": 1,
"name": 1,
"attributes.addressId": 1
}
},
{
"resolve": {
"source": "crm.address",
"localPath": "attributes.addressId",
"as": "address"
}
},
{
"page": {
"limit": 25
}
}
]
}
```

If a customer document contains `attributes.addressId = "addr-42"`, the resolver registered for `crm.address` receives `"addr-42"` and the resolved address object is added to the result as `address`.

Example: complete OxQL query with one cross-service resolve stage against `contact-api/v1`:

```json
{
"entityType": "invoice",
"pipeline": [
{
"match": {
"id": { "eq": "invoice-1001" }
}
},
{
"project": {
"id": 1,
"entityType": 1,
"attributes.contactId": 1,
"attributes.totalAmount": 1
}
},
{
"resolve": {
"source": "contact-api/v1",
"localPath": "attributes.contactId",
"as": "contact"
}
},
{
"page": {
"limit": 1
}
}
]
}
```

When this query is executed with `services=contact-api/v1=https://contact-api.internal/`, and the invoice contains `attributes.contactId = "contact-42"`, the resolver calls `contact-api/v1` to load `contact-42` by id and adds the returned contact object to the result as `contact`.

Example: resolve with a parameterized subquery sent to `contact-api/v1`:

```json
{
"entityType": "invoice",
"pipeline": [
{
"match": {
"id": { "eq": "invoice-1001" }
}
},
{
"resolve": {
"source": "contact-api/v1",
"localPath": "attributes.contactId",
"parameters": {
"contactId": "attributes.contactId"
},
"subquery": {
"entityType": "contact",
"pipeline": [
{
"match": {
"id": { "eq": { "$var": "contactId" } }
}
},
{
"page": {
"limit": 1
}
}
]
},
"as": "contact"
}
},
{
"page": {
"limit": 1
}
}
]
}
```

The resolver uses `parameters` to map local document values into subquery variables, then forwards the `subquery` to the remote OxQL endpoint.

### IQueryAdapter<T>
Implement for non-MongoDB backends:

Expand Down
4 changes: 4 additions & 0 deletions src/.github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copilot Instructions

## Project Guidelines
- When the user asks for a resolver example, 'dynamic resolver system' refers to a cross-service resolver setup, not Microsoft Dynamics.
45 changes: 45 additions & 0 deletions src/OxQL.Core/Interfaces/IExternalResolver.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
using OxQL.Core.Models;

namespace OxQL.Core.Interfaces;

/// <summary>
/// Describes a resolve request sent to an <see cref="IExternalResolver"/>.
/// </summary>
public sealed record ExternalResolveRequest
{
/// <summary>
/// The keys to resolve in key-based mode.
/// </summary>
public IReadOnlyList<string>? Keys { get; init; }

/// <summary>
/// The query request to execute in subquery mode.
/// </summary>
public QueryRequest? Query { get; init; }
}

/// <summary>
/// Resolves data from external sources (e.g., CRM, external APIs).
/// </summary>
Expand All @@ -19,4 +37,31 @@ public interface IExternalResolver
Task<IReadOnlyDictionary<string, object?>> ResolveAsync(
IReadOnlyList<string> keys,
CancellationToken cancellationToken = default);

/// <summary>
/// Resolves one object using either key-based or query-based request context.
/// </summary>
/// <remarks>
/// Default behavior maps key-based requests to <see cref="ResolveAsync(IReadOnlyList{string}, CancellationToken)"/>.
/// Override this in resolvers that support subquery forwarding.
/// </remarks>
async Task<object?> ResolveOneAsync(
ExternalResolveRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);

if (request.Query is not null)
{
throw new NotSupportedException(
$"Resolver '{Source}' does not support query-based resolve requests.");
}

if (request.Keys is null || request.Keys.Count == 0)
return null;

var resolved = await ResolveAsync(request.Keys, cancellationToken);
var firstKey = request.Keys[0];
return resolved.TryGetValue(firstKey, out var value) ? value : null;
}
}
12 changes: 12 additions & 0 deletions src/OxQL.Core/Models/LookupStage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ public sealed record ResolveStage
[JsonPropertyName("localPath")]
public required string LocalPath { get; init; }

/// <summary>
/// Optional parameter mappings for subquery mode (variable name -> local path).
/// </summary>
[JsonPropertyName("parameters")]
public IReadOnlyDictionary<string, string>? Parameters { get; init; }

/// <summary>
/// Optional subquery sent to the external source.
/// </summary>
[JsonPropertyName("subquery")]
public QueryRequest? Subquery { get; init; }

/// <summary>
/// The alias for the resolved result.
/// </summary>
Expand Down
39 changes: 39 additions & 0 deletions src/OxQL.Core/Validation/QueryValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,45 @@ private void ValidateResolve(ResolveStage resolve, List<QueryValidationError> er

ValidatePath(resolve.LocalPath, "resolve.localPath", errors);

if (resolve.Parameters is not null)
{
foreach (var (name, path) in resolve.Parameters)
{
if (string.IsNullOrWhiteSpace(name))
{
errors.Add(new QueryValidationError
{
Code = "INVALID_RESOLVE_PARAMETER_NAME",
Message = "Resolve parameter name cannot be empty."
});
continue;
}

ValidatePath(path, $"resolve.parameters.{name}", errors);
}
}

if (resolve.Subquery is not null)
{
if (string.IsNullOrWhiteSpace(resolve.Subquery.EntityType))
{
errors.Add(new QueryValidationError
{
Code = "INVALID_RESOLVE_SUBQUERY_ENTITY",
Message = "Resolve subquery entityType is required."
});
}

if (resolve.Subquery.Pipeline is null || resolve.Subquery.Pipeline.Count == 0)
{
errors.Add(new QueryValidationError
{
Code = "INVALID_RESOLVE_SUBQUERY_PIPELINE",
Message = "Resolve subquery pipeline must contain at least one stage."
});
}
}

if (string.IsNullOrWhiteSpace(resolve.As))
{
errors.Add(new QueryValidationError
Expand Down
Loading