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
Configurationcomprising ofQuery/Transformerhierarchical nested mappings. - Step 3: Construct the
DataProviderwith 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.CommunicationandCustomer.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.
Queryis an implementation tofetchdata for mapped sections of object graph.Transformeris an implementation tomapdata 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.
SQLqueries execute to get data from SQL database usingDapperorEntityFrameworkengines.APIquery executes web api to call anendpointusingHTTPClientsupported 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).
IDataContextis the context parameter passed to DataProvider to get aggregated results (. Aggregated Entity). This parameter is always available for both parent and child queries.IQueryResultparameter 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
IQueryResultinterface.
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
nopaths 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.IQueryResultparameter 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;
}
}

