AI agent guidance for working with the BifrostQL codebase.
BifrostQL is a .NET library that automatically publishes SQL databases as GraphQL APIs. It builds GraphQL schemas directly from database schemas—when you add a table or column, BifrostQL automatically exposes it via GraphQL with correct types and validation.
- Dynamic schema generation from SQL Server, PostgreSQL, MySQL, and SQLite databases
- Zero N+1 problem—generates one SQL query per table, not per row
- Dynamic joins via
__joinfields on every table - Directus-style filtering (
_eq,_contains,_gt,_in, etc.) - Automatic mutations for insert, update, upsert, and delete
- Module system for cross-cutting concerns (tenant isolation, soft-delete, auditing)
GraphQL Request → BifrostHttpMiddleware → BifrostDocumentExecutor
↓
DbModel + ISchema (cached per path in PathCache<Inputs>)
↓
SqlVisitor parses into GqlObjectQuery tree
↓
Filter Transformers → Mutation Transformers → Query Observers
↓
SQL Generation (GqlObjectQuery.AddSqlParameterized)
↓
SqlExecutionManager + ReaderEnum → GraphQL Response
| Component | Purpose |
|---|---|
DbModel |
In-memory database schema representation (pure data container) |
TableRelationshipOrchestrator |
Orchestrates relationship detection via strategy pattern |
GqlObjectQuery |
GraphQL query tree structure; generates parameterized SQL |
SqlVisitor |
AST visitor that parses GraphQL into GqlObjectQuery |
TableFilter |
Filter expression tree for WHERE clause generation |
DbSchemaBuilder |
GraphQL schema generation from DbModel |
DbTableResolver |
Field resolver delegating to SqlExecutionManager |
ISqlDialect |
Database-specific SQL generation abstraction |
VisualQueryBuilder |
Access-style query designer: VisualQuerySpec → parameterized SQL via ISqlDialect (model-validated, composite-FK joins). See concept doc Visual Query Builder. |
The BifrostUI desktop app ships an Access-style visual query designer. A
VisualQuerySpec (C# records in QueryModel/VisualQuery, mirrored in the
frontend lib/visual-query.ts) is assembled by the React designer and turned
into parameterized SQL server-side by VisualQueryBuilder using the active
ISqlDialect. It runs over the in-process Photino bridge (build-sql,
build-and-exec, get-builder-schema) — never HTTP/GraphQL — reusing
RawSqlExecutor. FK auto-join comes from FkAutoJoin. Full design: the
Visual Query Builder concept doc. Note: wwwroot is git-ignored; run
pnpm build in src/BifrostQL.UI/frontend to ship UI changes.
- Strategy Pattern - Relationship detection (
ITableRelationshipStrategy) - Template Method Pattern - SQL dialect base classes
- Base Class Pattern - Resolvers, transformers, collectors
- Factory Pattern - Type mapping, connection factories
- Observer Pattern - Query lifecycle hooks
BifrostQL.sln
├── src/
│ ├── BifrostQL.Core/ # Core library (net8.0, net9.0, net10.0)
│ │ ├── Forms/ # Form builder and validation
│ │ ├── Model/ # Database model classes
│ │ │ └── Relationships/ # Strategy classes for relationship detection
│ │ ├── Modules/ # Filter/mutation transformers
│ │ ├── QueryModel/ # SQL generation and dialects
│ │ ├── Resolvers/ # GraphQL field resolvers
│ │ ├── Schema/ # GraphQL schema generation
│ │ ├── Serialization/ # Data serialization helpers
│ │ └── Utils/ # Centralized utilities (StringNormalizer, etc.)
│ ├── BifrostQL.Server/ # ASP.NET Core middleware
│ ├── BifrostQL.Host/ # Example console host
│ ├── BifrostQL.Tool/ # CLI tool (dotnet tool)
│ ├── BifrostQL.UI/ # Desktop app (Photino)
│ └── data/
│ ├── BifrostQL.SqlServer/ # SQL Server dialect
│ ├── BifrostQL.Ngsql/ # PostgreSQL dialect
│ ├── BifrostQL.MySql/ # MySQL dialect
│ └── BifrostQL.Sqlite/ # SQLite dialect
├── tests/
│ ├── BifrostQL.Core.Test/ # Core library tests
│ │ ├── Unit/ # Fast, isolated unit tests
│ │ └── Integration/ # Database integration tests
│ ├── BifrostQL.Server.Test/ # Server middleware tests
│ ├── BifrostQL.Host.Test/ # Console host tests
│ ├── BifrostQL.Integration.Test/ # Full integration tests
│ ├── BifrostQL.UI.Tests/ # UI unit tests
│ └── BifrostQL.UI.E2E/ # UI end-to-end tests
StringNormalizer - Centralized string normalization:
// Use instead of ToLowerInvariant().Trim()
var normalized = StringNormalizer.NormalizeType(column.DataType);
var name = StringNormalizer.NormalizeName(tableName);MetadataKeys - Constants for all metadata keys:
// Use constants instead of magic strings
var parent = table.GetMetadataValue(MetadataKeys.Eav.Parent);
var fk = table.GetMetadataValue(MetadataKeys.Eav.ForeignKey);SqlDialectBase - Base class for all SQL dialects:
public sealed class MyDialect : SqlDialectBase
{
public MyDialect() : base('"', "||", "lastval()", " RETURNING id")
{
}
// Override only what differs from base
public override string Pagination(...) { ... }
}Available base classes:
SqlDialectBase- Full control over identifier quotes, concat operatorLimitOffsetDialectBase- For dialects using LIMIT/OFFSET paginationStandardConcatDialectBase- For dialects using||for string concat
SingleColumnFilterTransformerBase - For single-column filters:
public sealed class MyFilterTransformer : SingleColumnFilterTransformerBase
{
public MyFilterTransformer() : base("my-metadata-key", priority: 100)
{
}
public override string ModuleName => "my-filter";
protected override TableFilter BuildFilter(IDbTable table, string columnName, QueryTransformContext context)
{
return TableFilterFactory.Equals(table.DbName, columnName, value);
}
}ContextValueFilterTransformerBase - For filters needing user context:
public sealed class MyContextTransformer : ContextValueFilterTransformerBase
{
public MyContextTransformer() : base("metadata-key", "context-key", priority: 0)
{
}
public override string ModuleName => "my-context-filter";
}MetadataMutationTransformerBase - For metadata-driven mutations:
public sealed class MyMutationTransformer : MetadataMutationTransformerBase
{
public MyMutationTransformer() : base("soft-delete", priority: 100)
{
}
public override string ModuleName => "my-mutation";
protected override MutationTransformResult TransformCore(...)
{
// Transform logic here
}
}SoftDeleteMutationTransformerBase - For soft-delete implementations:
public sealed class MySoftDeleteTransformer : SoftDeleteMutationTransformerBase
{
public MySoftDeleteTransformer() : base("deleted_at", priority: 100)
{
}
public override string ModuleName => "soft-delete";
protected override MutationTransformResult TransformDelete(...)
{
// Soft-delete logic here
}
}ResolverBase - Abstract base for all resolvers:
public sealed class MyResolver : ResolverBase
{
public override ValueTask<object?> ResolveAsync(IBifrostFieldContext context)
{
// Resolver logic
}
}TableResolverBase - For table-specific resolvers:
public sealed class MyTableResolver : TableResolverBase
{
public MyTableResolver(IDbTable table) : base(table)
{
}
public override ValueTask<object?> ResolveAsync(IBifrostFieldContext context)
{
var tableRef = GetTableReference(dialect);
var whereClause = BuildWhereClause(keyValues, dialect);
// ...
}
}DatabaseResolverBase - For resolvers executing SQL:
public sealed class MyDbResolver : DatabaseResolverBase
{
public override async ValueTask<object?> ResolveAsync(IBifrostFieldContext context)
{
var result = await ExecuteScalarAsync(connFactory, sql, parameters);
return HandleDecimals(result);
}
}- Language version: C# 12+ with nullable reference types enabled
- Target frameworks: .NET 8.0, 9.0, 10.0 (Core); .NET 10.0 for the UI, Tool, and Host apps
- File-scoped namespaces: Always use
namespace X.Y.Z;(no braces) - Implicit usings: Enabled; don't duplicate common usings
| Element | Convention | Example |
|---|---|---|
| Classes | PascalCase | GqlObjectQuery |
| Interfaces | PascalCase with I prefix | IFilterTransformer |
| Methods | PascalCase | AddSqlParameterized |
| Properties | PascalCase | ScalarColumns |
| Fields | camelCase with underscore | _logger |
| Constants | PascalCase | DefaultLimit |
Prefer init properties for immutable data:
public sealed class GqlObjectQuery
{
public IDbTable DbTable { get; init; } = null!;
public List<GqlObjectColumn> ScalarColumns { get; init; } = new();
}Use required members for mandatory initialization:
public sealed class QueryTransformContext
{
public required IDbModel Model { get; init; }
public required IDictionary<string, object?> UserContext { get; init; }
}Use sealed by default for classes:
public sealed class MyService { }Prefer expression-bodied members for simple operations:
public string KeyName => Alias ?? GraphQlName;- Always use nullable reference types (
string?for nullable) - Use null-forgiving operator (
!) only when absolutely certain - Prefer
is null/is not nullover== null/!= null
- Use
StringComparison.OrdinalIgnoreCasefor case-insensitive comparisons - Use
StringComparer.OrdinalIgnoreCasefor dictionary keys
- Framework: xUnit
- Mocking: NSubstitute
- Assertions: FluentAssertions
- SQL validation:
Microsoft.SqlServer.TransactSql.ScriptDom
Unit tests follow Arrange-Act-Assert with comments:
[Fact]
public void AddSqlParameterized_WithSimpleFilter_GeneratesWhereClause()
{
// Arrange
var dbModel = StandardTestFixtures.SimpleUsers();
var usersTable = dbModel.GetTableFromDbName("Users");
var filter = TableFilter.FromObject(...);
var query = GqlObjectQueryBuilder.Create()
.WithDbTable(usersTable)
.WithColumns("Id", "Name")
.WithFilter(filter)
.Build();
var sqls = new Dictionary<string, ParameterizedSql>();
var parameters = new SqlParameterCollection();
// Act
query.AddSqlParameterized(dbModel, Dialect, sqls, parameters);
// Assert
sqls.Should().ContainSingle();
sqls["Users"].Sql.Should().Contain("WHERE");
}| Database Element | GraphQL Type |
|---|---|
Table users |
Type users, Query field users |
Column user_id |
Field userId (camelCase) |
| Primary key | Used for mutations, exposed as field |
| Foreign key | Creates single-link (parent) and multi-link (children) fields |
Standard Directus-style operators:
_eq,_neq— equality_lt,_lte,_gt,_gte— comparison_contains,_ncontains— string contains_in,_nin— set membership_between— range_null,_nnull— null checks
CSS-like selector syntax for configuration:
"dbo.users { tenant-filter: tenant_id }"
"dbo.orders { soft-delete: deleted_at; soft-delete-by: deleted_by_user_id }"
"*.__* { visibility: hidden; }" // Hide internal columnsAll SQL uses parameterized queries via SqlParameterCollection:
var cmdText = $"SELECT {columnSql} FROM {tableRef}";
var filter = GetFilterSqlParameterized(dbModel, dialect, parameters);
var baseSql = new ParameterizedSql(cmdText, Array.Empty<SqlParameterInfo>())
.Append(filter)
.Append(pagination);Implement ISqlDialect for database-specific syntax, or extend base classes:
// Simple dialect using base class
public sealed class PostgresDialect : StandardConcatDialectBase
{
public PostgresDialect() : base('"', "lastval()", " RETURNING id AS ID")
{
}
}
// Complex dialect with overrides
public sealed class SqlServerDialect : SqlDialectBase
{
public SqlServerDialect() : base("[", "]", "+", "SCOPE_IDENTITY()", " OUTPUT INSERTED.id AS ID")
{
}
public override string Pagination(...) { /* SQL Server specific */ }
}- Use
IDbConnFactoryfor creating connections - Connections are short-lived (created per request)
- Use
SqlExecutionManagerfor batch query execution
public sealed class MyFilterTransformer : SingleColumnFilterTransformerBase
{
public MyFilterTransformer() : base("my-metadata-key", priority: 100)
{
}
public override string ModuleName => "my-filter";
protected override TableFilter BuildFilter(IDbTable table, string columnName, QueryTransformContext context)
{
var value = context.UserContext.GetValueOrDefault("my_value");
return TableFilterFactory.Equals(table.DbName, columnName, value);
}
}- Create project in
src/data/BifrostQL.{Name}/ - Extend
SqlDialectBase,LimitOffsetDialectBase, orStandardConcatDialectBase - Override only methods that differ from base
- Implement
ISchemaReader,ITypeMapper,IDbConnFactory
public sealed class OracleDialect : SqlDialectBase
{
public OracleDialect() : base('"', "||", "seq_name.CURRVAL")
{
}
// Oracle uses ROWNUM for pagination
public override string Pagination(...) { ... }
}public sealed class MyResolver : TableResolverBase
{
public MyResolver(IDbTable table) : base(table)
{
}
public override async ValueTask<object?> ResolveAsync(IBifrostFieldContext context)
{
var bifrost = new BifrostContextAdapter(context);
var tableRef = GetTableReference(bifrost.ConnFactory.Dialect);
// ... resolver logic
}
}- Don't concatenate user input into SQL—always use parameters
- Don't use synchronous I/O in resolvers
- Don't cache
DbModelper-request—usePathCache<Inputs> - Don't throw generic
Exception—useBifrostExecutionErrorfor GraphQL errors - Don't mutate
DbModelafter initialization—it's cached - Don't use magic strings for metadata keys—use
MetadataKeysconstants - Don't duplicate
ToLowerInvariant().Trim()—useStringNormalizer
- Do use
ILogger<T>for all logging - Do use
StringComparison.OrdinalIgnoreCasefor case-insensitive operations - Do validate SQL syntax in tests using
ScriptDom - Do register transformers with appropriate priority values
- Do use
sealedfor classes not designed for inheritance - Do extend base classes to reduce boilerplate
- Do use the Strategy pattern for complex algorithms
# Build entire solution
dotnet build BifrostQL.sln
# Run all tests
dotnet test
# Run specific test project
dotnet test tests/BifrostQL.Core.Test/BifrostQL.Core.Test.csproj
# Run single test
dotnet test --filter "FullyQualifiedName=GqlObjectQuerySqlTest.TestJoinWithCompositeKey"
# Run the desktop UI
./bifrostui "Server=localhost;Database=mydb;User Id=sa;Password=xxx;TrustServerCertificate=True"
# Run the Host web server
dotnet run --project src/BifrostQL.Host- README.md — Project overview and quick start
- AGENTS.md — Detailed architecture and development guide (CLAUDE.md is a pointer to it)
- GitHub Repository
- NuGet Package
- Full Documentation
new ColumnDto
{
ColumnName = "MyColumn",
DataType = "nvarchar",
IsNullable = true,
MaxLength = 255
}var filter = TableFilter.FromObject(new Dictionary<string, object?>
{
{ "Status", new Dictionary<string, object?> { { "_eq", "active" } } }
}, "MyTable");builder.Services.AddBifrostQL(o => o
.BindStandardConfig(builder.Configuration)
.AddFilterTransformer<MyFilterTransformer>());// Instead of: type?.ToLowerInvariant().Trim() ?? ""
// Use:
var normalized = StringNormalizer.NormalizeType(column.DataType);// Instead of: table.GetMetadataValue("eav-parent")
// Use:
var parent = table.GetMetadataValue(MetadataKeys.Eav.Parent);