Schemio
allows you to aggregate data from heterogeneous data stores offering SQL
& API
packages out of the box below. SQL queries are supported by Dapper
and EntityFramework
engines. You could also extend
Schemio to provide your own implementation(s) of Query
and supporting Query Engine
to retrieve data from custom
data store(s).
Below are the Nuget packages available.
Scemio.Core
- Install to extend schemio to implement custom
querying engine.
NuGet\Install-Package Schemio.Core
Schemio.SQL
- Install when you would like to include SQL Dapper
queries to access SQL database.
NuGet\Install-Package Schemio.SQL
Schemio.EntityFramework
- Install when you would like to include SQL EntityFramework
queries to access SQL database.
NuGet\Install-Package Schemio.EntityFramework
Schemio.Api
- Install when you would like to include web api queries with HttpClient
query engine.
NuGet\Install-Package Schemio.Api
To use Schemio you need to do the below steps
- Step 1: Define the aggregated
Entity
. - Step 2: Setup the aggregate
Configuration
comprising ofQuery
/Transformer
hierarchical nested mappings. - Step 3: Construct the
DataProvider
with required dependencies.
To create an aggregate Entity
, implement the class from IEntity
interface. This is the entity that will be returned as aggregated result from multiple queries assembled to execute against homogeneous or heterogeneous data storage's.
Below is an example Customer
entity.
public class Customer : IEntity
{
public int CustomerId { get; set; }
public string CustomerCode { get; set; }
public string CustomerName { get; set; }
public Communication Communication { get; set; }
public Order[] Orders { get; set; }
}
For the customer class, we can see there are three levels of nesting in the object graph.
- Level 1 with paths:
Customer
- Level 2 with paths:
Customer.Communication
andCustomer.Orders
- Level 3 with paths:
Customer.Orders.Items
If we choose XML Schema Definition (XSD) for the Customer entity then XPaths for nesting levels should be.
- Level 1 with XPath: Customer
- Level 2 with XPaths: Customer/Communication and Customer/Orders
- Level 3 with XPath: Customer/Orders/Order/Items/Item
To define Entity Configuration
, derive from EntityConfiguration<TEntity>
class where TEntity
is aggregate entity in context (ie. IEntity
).
The Entity Configuration
is basically hierarchies
of Query
& Transformer
pairs mapped to the schema paths
pointing to various nesting
levels in the entity's object graph.
Query
is an implementation tofetch
data for mapped sections of object graph.Transformer
is an implementation tomap
data fetched by the associated query to the relevant sections of the entity's object graph.
Below is an example Entity Configuration for the Customer Entity.
internal class CustomerConfiguration : EntityConfiguration<Customer>
{
public override IEnumerable<Mapping<Customer, IQueryResult>> GetSchema()
{
return CreateSchema.For<Customer>()
.Map<CustomerQuery, CustomerTransform>(For.Paths("customer"),
customer => customer.Dependents
.Map<CommunicationQuery, CommunicationTransform>(For.Paths("customer/communication"))
.Map<OrdersQuery, OrdersTransform>(For.Paths("customer/orders"),
customerOrders => customerOrders.Dependents
.Map<OrderItemsQuery, OrderItemsTransform>(For.Paths("customer/orders/order/items"))))
.End();
}
}
CustomerConfiguration
shows query/transformer
pairs mapped at three levels of nesting as per the Customer
entity object graph.
XPaths
are used to identify the schema paths in the object graph. Alternately, you could use your own representation to name the pairs or map the object graph. However, you would need to provide the ISchemaPathmatcher
implementation to managing path matching.
Every Query
type in the Entity Configuration
definition should have a complementing Transformer
type.
You could map multiple schema paths
to a given query/transformer pair. Currently, XPath
and JSONPath
schema languages are supported.
Below is the snippet from CustomerConfiguration
definition shows that CustomerQuery
has associated CustomerTransform
and the pair is mapped to the root Customer
object.
.Map<CustomerQuery, CustomerTransform>(For.Paths("customer"))
You could nest query/transformer pairs in a parent/child
hierarchy. In which case the output of the parent query will serve as the input to the child query to resolve its query context.
The query/transformer mappings can be nested
to 5
levels down.
Below is snippet to show nesting of CommunicationQuery
as child to CustomerQuery
.
.Map<CustomerQuery, CustomerTransform>(For.Paths("customer"), -- Parent
customer => customer.Dependents
.Map<CommunicationQuery, CommunicationTransform>(For.Paths("customer/communication")) -- Child
Execution Flow
- In parent/child hierarchy, the first parent query executes first, followed by its immediate children. The execution flows in sequence to the last child query in order of its nesting.
- While executing the output of the parent is passed in to the child query to resolve query context and get it ready for execution.
- Transformers are also executed in the same sequence to map data to the Aggregate Entity.
- When a query path for nested query is included for execution, all the parent queries involved in that object graph get included for execution in order of its nesting.
Please see the execution sequence below for queries and transformers nested in CustomerConfiguration
implemented above.
Query
- The purpose of a query class is to execute with supported QueryEngine to fetch data from data storage.
QueryEngine
is an implementation of IQueryEngine
to execute queries with supported data storage to return query result (ie. Result instance of IQueryResult
).
Depending on the Nuget package(s) installed, you could implement SQL
and API
queries.
SQL
queries execute to get data from SQL database usingDapper
orEntityFramework
engines.API
query executes web api to call anendpoint
usingHTTPClient
supported engine to get data.
Important: You can combine heterogeneous queries in the Entity configuration to target different data stores.
Example of SQL & API queries are below.
You need to override the GetQuery(IDataContext context, IQueryResult parentQueryResult)
method to return query delegate (package specific implementation).
IDataContext
is the context parameter passed to DataProvider to get aggregated results (. Aggregated Entity). This parameter is always available for both parent and child queries.IQueryResult
parameter is only available when query is configured in child mode, else will be null.
To create a SQL query you need to derive from SQLQuery<TQueryResult>
where TQueryResult is IQueryResult
implementation.
- Example Parent Query - CustomerQuery
public class CustomerQuery : SQLQuery<CustomerResult>
{
protected override Func<IDbConnection, Task<CustomerResult>> GetQuery(IDataContext context, IQueryResult parentQueryResult)
{
// Executes as root or level 1 query.
var customer = (CustomerRequest)context.Request;
return connection => connection.QueryFirstOrDefaultAsync<CustomerResult>(new CommandDefinition
(
"select CustomerId as Id, " +
"Customer_Name as Name," +
"Customer_Code as Code " +
$"from TCustomer where customerId={customer.CustomerId}"
));
}
}
- Example Child Query - OrdersQuery
internal class OrdersQuery : SQLQuery<CollectionResult<OrderResult>>
{
protected override Func<IDbConnection, Task<CollectionResult<OrderResult>>> GetQuery(IDataContext context, IQueryResult parentQueryResult)
{
// Execute as child to customer query.
var customer = (CustomerResult)parentQueryResult;
return async connection =>
{
var items = await connection.QueryAsync<OrderResult>(new CommandDefinition
(
"select OrderId, " +
"OrderNo, " +
"OrderDate " +
"from TOrder " +
$"where customerId={customer.Id}"
));
return new CollectionResult<OrderResult>(items);
};
}
}
To create a SQL query you need to derive from SQLQuery<TQueryResult>
where TQueryResult is IQueryResult
implementation.
- Example Parent Query - CustomerQuery
public class CustomerQuery : SQLQuery<CustomerResult>
{
protected override Func<DbContext, Task<CustomerResult>> GetQuery(IDataContext context, IQueryResult parentQueryResult)
{
// Executes as root or level 1 query. parentQueryResult will be null.
var customer = (CustomerRequest)context.Request;
return async dbContext =>
{
var result = await dbContext.Set<Customer>()
.Where(c => c.Id == customer.CustomerId)
.Select(c => new CustomerResult
{
Id = c.Id,
Name = c.Name,
Code = c.Code
})
.FirstOrDefaultAsync();
return result;
};
}
}
- Example Child Query - OrdersQuery
internal class OrdersQuery : SQLQuery<CollectionResult<OrderResult>>
{
protected override Func<DbContext, Task<CollectionResult<OrderResult>>> GetQuery(IDataContext context, IQueryResult parentQueryResult)
{
// Execute as child to customer query.
var customer = (CustomerResult)parentQueryResult;
return async dbContext =>
{
var items = await dbContext.Set<Order>()
.Where(p => p.Customer.Id == customer.Id)
.Select(c => new OrderResult
{
CustomerId = c.CustomerId,
OrderId = c.OrderId,
Date = c.Date,
OrderNo = c.OrderNo
})
.ToListAsync();
return new CollectionResult<OrderResult>(items);
};
}
}
To create a Web API query you need to derive from WebQuery<TQueryResult>
where TQueryResult is IQueryResult
implementation.
Important
: If you need to get response headers in the result then TQueryResult should derive from WebHeaderResult
class.
- Example Parent Query - CustomerQuery
public class CustomerWebQuery : WebQuery<CustomerResult>
{
public CustomerWebQuery() : base(Endpoints.BaseAddress)
{
}
protected override Func<Uri> GetQuery(IDataContext context, IQueryResult parentApiResult)
{
// Executes as root or level 1 api.
var customerRequest = (CustomerRequest)context.Request;
return () => new Uri(string.Format(Endpoints.BaseAddress + Endpoints.Customer, customerRequest.CustomerId), UriKind.Absolute);
}
/// <summary>
/// Override to pass outgoing request headers.
/// </summary>
/// <returns></returns>
protected override IDictionary<string, string> GetRequestHeaders()
{
return new Dictionary<string, string>
{
{ "x-meta-branch-code", "London" }
};
}
/// <summary>
/// Override to subscribe for given Response headers to be added to TQueryResult.
/// For receiving response headers, You need to implement the TQueryResult type from `WebHeaderResult` class instead of IQueryResult.
/// </summary>
/// <returns></returns>
protected override IEnumerable<string> GetResponseHeaders()
{
return new[] { "x-meta-branch-code" };
}
}
Note: CustomerResult is above query implements from WebHeaderResult
class to support response headers.
public class CustomerResult : WebHeaderResult
{
public int Id { get; set; }
public string Code { get; set; }
public string Name { get; set; }
}
- Example Child Query - OrdersQuery
internal class OrdersQuery : WebQuery<CollectionResult<OrderResult>>
{
public OrdersQuery() : base(Endpoints.BaseAddress)
{
}
protected override Func<Uri> GetQuery(IDataContext context, IQueryResult parentQueryResult)
{
// Execute as child to customer api.
var customer = (CustomerResult)parentQueryResult;
return ()=> new Uri(string.Format($"v2/customers/{customer.Id}/orders);
}
}
The purpose of the transformer class is to transform the data fetched by the linked query class and map to the configured object graph of the entity.
To define a transformer class, you need to implement BaseTransformer<TQueryResult, TEntity>
- where TEntity is Aggregate Entity implementing
IEntity
. eg. Customer. - where TQueryResult is Query Result from associated Query. It is an implementation of
IQueryResult
interface.
Example transformer - Customer Transformer
internal class CustomerTransform : BaseTransformer<CustomerResult, Customer>
{
public override Customer Transform(CustomerResult queryResult, Customer entity)
{
var customer = entity ?? new Customer();
customer.CustomerId = queryResult.Id;
customer.CustomerName = queryResult.CustomerName;
customer.CustomerCode = queryResult.CustomerCode;
if (queryResult is WebHeaderResult webHeaderResult)
if (webHeaderResult.Headers.TryGetValue("x-meta-branch-code", out var branch))
customer.Branch = branch;
return customer;
}
}
Note: It is important
that the transformer should map data only to the schema path(s)
pointing section(s)
of the object graph.
For the example query/transformer mapping
.Map<CommunicationQuery, CommunicationTransform>(For.Paths("customer/communication"))
The Communication transformer should map data only to the customer/communication
xpath mapped object graph of customer class.
Data provider needs to be setup with required dependencies. Provide implementations of below dependencies to construct the data provider.
With ServiceCollection, you need to register the below dependencies.
// Register core services
services.AddTransient(typeof(IQueryBuilder<>), typeof(QueryBuilder<>));
services.AddTransient(typeof(IEntityBuilder<>), typeof(EntityBuilder<>));
services.AddTransient(typeof(IDataProvider<>), typeof(DataProvider<>));
services.AddTransient<IQueryExecutor, QueryExecutor>();
// Register instance of ISchemaPathMatcher - Json, XPath or Custom.
services.AddTransient(c => new XPathMatcher());
// Enable logging
services.AddLogging();
//For Dapper SQL engine - Schemio.SQL
services.AddTransient<IQueryEngine>(c => new QueryEngine(new SQLConfiguration { ConnectionSettings = new ConnectionSettings {
Providername = "System.Data.SqlClient",
ConnectionString ="Data Source=Powerstation; Initial Catalog=Customer; Integrated Security=SSPI;"
}});
// For entity framework engine - Schemio.EntityFramework
services.AddDbContextFactory<CustomerDbContext>(options => options.UseSqlServer(YourSqlConnection), ServiceLifetime.Scoped);
services.AddTransient<IQueryEngine>(c => new QueryEngine<CustomerDbContext>(c.GetService<IDbContextFactory<CustomerDbContext>>());
// For HTTPClient Engine for web APIs - Schemio.API
// Enable HttpClient
services.AddHttpClient();
services.AddTransient<IQueryEngine, QueryEngine>();
// Register each entity configuration. eg CustomerConfiguration
services.AddTransient<IEntityConfiguration<Customer>, CustomerConfiguration>();
Please Note:
You can combine multiple query engines and implement supporting types of queries to execute on target data platforms.
i. Example registration: Schemio.SQL
// Enable DbProviderFactory.
DbProviderFactories.RegisterFactory(DbProviderName, SqliteFactory.Instance);
var connectionString = $"DataSource={Environment.CurrentDirectory}//Customer.db;mode=readonly;cache=shared";
var configuration = new SQLConfiguration { ConnectionSettings = new ConnectionSettings { ConnectionString = connectionString, ProviderName = DbProviderName } };
// Enable logging
services.AddLogging();
services.UseSchemio()
.WithEngine(c => new QueryEngine(configuration))
.WithPathMatcher(c => new XPathMatcher())
.WithEntityConfiguration<Customer>(c => new CustomerConfiguration());
ii. Example registration: Schemio.EntityFramework
var connectionString = $"DataSource={Environment.CurrentDirectory}//Customer.db;mode=readonly;cache=shared";
// Enable DBContext Factory
services.AddDbContextFactory<CustomerDbContext>(options =>
options.UseSqlite(connectionString));
// Enable logging
services.AddLogging();
services.UseSchemio()
.WithEngine(c => new QueryEngine<CustomerDbContext>(c.GetService<IDbContextFactory<CustomerDbContext>>()))
.WithPathMatcher(c => new XPathMatcher())
.WithEntityConfiguration<Customer>(c => new CustomerConfiguration());
iii. Example registration: Schemio.API
// Enable logging
services.AddLogging();
// Enable HttpClient
services.AddHttpClient();
services.UseSchemio()
.WithEngine<QueryEngine>()
.WithPathMatcher(c => new XPathMatcher())
.WithEntityConfiguration<Customer>(c => new CustomerConfiguration());
iv. Example registration: Multiple Engines
// Enable logging
services.AddLogging();
// Enable HttpClient
services.AddHttpClient();
var connectionString = $"DataSource={Environment.CurrentDirectory}//Customer.db;mode=readonly;cache=shared";
// Enable DBContext Factory
services.AddDbContextFactory<CustomerDbContext>(options =>
options.UseSqlite(connectionString));
services.UseSchemio()
.WithEngine<QueryEngine>()
.WithEngine(c => new QueryEngine<CustomerDbContext>(c.GetService<IDbContextFactory<CustomerDbContext>>()))
.WithPathMatcher(c => new XPathMatcher())
.WithEntityConfiguration<Customer>(c => new CustomerConfiguration());
To use Data provider, Inject IDataProvider<T>
where T is IEntity, using constructor & property injection method or explicitly Resolve using service provider ie. IServiceProvider.GetService(typeof(IDataProvider<Customer>))
You need to call the GetData()
method with an instance of parameter class derived from IEntityRequest
interface.
The IEntityRequest
provides a SchemaPaths
property, which is a list of schema paths to include for the given request to fetch aggregated data.
- When
no
paths are passed in the parameter then entire aggregated entity for all configured queries is returned. - When list of schema paths are included in the request then the returned aggregated data entity only includes query results from included queries.
When nested path for a nested query is included (eg. customer/orders/order/items) then all parent queries in the respective parent paths also get included for execution.
Example - Control Flow
You could extend Schemio by providing your own custom implementation of the query engine (IQueryEngine
) and query (IQuery
) to execute queries on custom target data platform.
To do this, you need to extend the base interfaces as depicted below.
Implement IQueryEngine
interface to provide the custom query engine to be used with schemio.
public interface IQueryEngine
{
/// <summary>
/// Detrmines whether an instance of query can be executed with this engine.
/// </summary>
/// <param name="query">instance of IQuery.</param>
/// <returns>Boolean; True when supported.</returns>
bool CanExecute(IQuery query);
/// <summary>
/// Executes a given query returning query result.
/// </summary>
/// <param name="queries">Custom instance of IQuery.</param>
/// <returns>Task of IQueryResult.</returns>
Task<IQueryResult> Execute(IQuery> query);
}
Example Entity Framework implementation is below
public class QueryEngine<T> : IQueryEngine where T : DbContext
{
private readonly IDbContextFactory<T> _dbContextFactory;
public QueryEngine(IDbContextFactory<T> _dbContextFactory)
{
this._dbContextFactory = _dbContextFactory;
}
public bool CanExecute(IQuery query) => query != null && query is ISQLQuery;
public Task<IQueryResult> Execute(IQuery query)
{
using (var dbcontext = _dbContextFactory.CreateDbContext())
{
var result = ((ISQLQuery)query).Run(dbcontext);
return result;
}
}
}
With the Query Engine implementation, you also need to provide custom implementation of IQuery
for executing the query with custom query engine.
To do this, you need to extend BaseQuery<TResult>
where TQueryResult is IQueryResult
. And, provide overrides for below methods.
bool IsContextResolved()
Engine calls this method to confirm whether the query is ready for execution. Return true when query context is resolved.void ResolveQuery(IDataContext context, IQueryResult parentQueryResult)
This method is invoked by schemio to resolve the query context required for execution ith supporting query engine.IQueryResult
parameter is only available when the custom query is configured in nested or child mode.
Example - EntityFramework Supported query implementation is shown below.
public abstract class SQLQuery<TQueryResult>
: BaseQuery<TQueryResult>, ISQLQuery
where TQueryResult : IQueryResult
{
private Func<DbContext, Task<TQueryResult>> QueryDelegate = null;
public override bool IsContextResolved() => QueryDelegate != null;
public override void ResolveQuery(IDataContext context, IQueryResult parentQueryResult)
{
QueryDelegate = GetQuery(context, parentQueryResult);
}
async Task<IQueryResult> ISQLQuery.Run(DbContext dbContext)
{
return await QueryDelegate(dbContext);
}
/// <summary>
/// Get query delegate to return query result.
/// </summary>
/// <param name="context"></param>
/// <param name="parentQueryResult"></param>
/// <returns></returns>
protected abstract Func<DbContext, Task<TQueryResult>> GetQuery(IDataContext context, IQueryResult parentQueryResult);
}
Additionally, You can use your own schema language instead of XPath/JSONPath to map aggregated entity's object graph, and register with schemio.
To do this you need to follow the below steps:
Provide entity schema definition with query/transformer pairs using custom schema language paths.
Example - with Dummy schema mapping
.Map<OrderQuery, OrderTransform>(For.Paths("customer$orders"))
Provide implementation of ISchemaPathMatcher
interface and implement IsMatch()
method to provide logic for matching custom paths.
Important
: This matcher is used by query builder to filter queries based matched paths, to include only required queries for execution to optimize performance.
public interface ISchemaPathMatcher
{
bool IsMatch(string inputPath, ISchemaPaths configuredPaths);
}
Example implementation of XPathMatcher is below.
public class XPathMatcher : ISchemaPathMatcher
{
private static readonly Regex ancestorRegex = new Regex(@"=ancestor::(?'path'.*?)(/@|\[.*\]/@)", RegexOptions.Compiled);
public bool IsMatch(string inputXPath, ISchemaPaths configuredXPaths)
{
if (inputXPath == null)
return false;
if (configuredXPaths.Paths.Any(x => inputXPath.ToLower().Contains(x.ToLower())))
return true;
if (configuredXPaths.Paths.Any(x => inputXPath.Contains("ancestor::")
&& ancestorRegex.Matches(inputXPath).Select(match => match.Groups["path"].Value).Distinct().Any(match => x.EndsWith(match))))
return true;
return false;
}
}