diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml index cfb8379..2578317 100644 --- a/.github/workflows/release-ci.yml +++ b/.github/workflows/release-ci.yml @@ -7,6 +7,11 @@ on: tags: - 'v*' +# Cancel in-progress runs on the same branch when a new push arrives +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: release: name: Build, Pack & Publish @@ -15,6 +20,34 @@ jobs: contents: read packages: write + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "Pa55w0rd!" + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'Pa55w0rd!' -No -Q 'SELECT 1'" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: Pa55w0rd + POSTGRES_DB: postgres + ports: + - 5455:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout uses: actions/checkout@v4 @@ -35,16 +68,17 @@ jobs: - name: Set package version id: pkg_version run: | - if [[ "${{ github.ref }}" == refs/tags/* ]]; then - echo "version=${{ steps.gitversion.outputs.majorMinorPatch }}" >> $GITHUB_OUTPUT + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + echo "version=$(echo '${{ github.ref_name }}' | sed 's/^v//')" >> $GITHUB_OUTPUT else echo "version=${{ steps.gitversion.outputs.semVer }}" >> $GITHUB_OUTPUT fi - name: Print version run: | - echo "SemVer : ${{ steps.gitversion.outputs.semVer }}" + echo "SemVer : ${{ steps.gitversion.outputs.semVer }}" echo "MajorMinorPatch: ${{ steps.gitversion.outputs.majorMinorPatch }}" + echo "PreReleaseTag : ${{ steps.gitversion.outputs.preReleaseTag }}" echo "Package version: ${{ steps.pkg_version.outputs.version }}" - name: Setup .NET @@ -56,45 +90,67 @@ jobs: 10.0.x - name: Restore - run: dotnet restore ActiveForge/ActiveForge.sln + run: dotnet restore ActiveForge.sln - name: Build - run: dotnet build ActiveForge/ActiveForge.sln --configuration Release --no-restore + run: dotnet build ActiveForge.sln --configuration Release --no-restore + + - name: Start MongoDB replica set + run: | + docker run -d --name mongo \ + -p 27017:27017 \ + mongo:7 --replSet rs0 --bind_ip_all + # Wait for mongod to accept connections + for i in $(seq 1 30); do + docker exec mongo mongosh --quiet --eval "db.adminCommand('ping')" \ + && break || sleep 2 + done + # Initialise the single-node replica set + docker exec mongo mongosh --quiet --eval \ + "rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]})" + # Wait until the node becomes PRIMARY + for i in $(seq 1 20); do + STATUS=$(docker exec mongo mongosh --quiet --eval "rs.status().myState") + [ "$STATUS" = "1" ] && break || sleep 2 + done - name: Test - run: dotnet test ActiveForge/ActiveForge.sln --configuration Release --no-build --framework net8.0 + env: + SS_ADMIN_CONNSTR: "Server=localhost,1433;Database=master;User Id=sa;Password=Pa55w0rd!;TrustServerCertificate=True" + PG_ADMIN_CONNSTR: "Host=localhost;Port=5455;Database=postgres;Username=postgres;Password=Pa55w0rd" + run: dotnet test ActiveForge.sln --configuration Release --no-build --framework net8.0 - name: Pack — Core run: | - dotnet pack ActiveForge/src/ActiveForge/ActiveForge.csproj \ + dotnet pack src/ActiveForge/ActiveForge.csproj \ --configuration Release --no-build \ -p:PackageVersion=${{ steps.pkg_version.outputs.version }} \ --output ./artifacts - name: Pack — SqlServer run: | - dotnet pack ActiveForge/src/ActiveForge.SqlServer/ActiveForge.SqlServer.csproj \ + dotnet pack src/ActiveForge.SqlServer/ActiveForge.SqlServer.csproj \ --configuration Release --no-build \ -p:PackageVersion=${{ steps.pkg_version.outputs.version }} \ --output ./artifacts - name: Pack — PostgreSQL run: | - dotnet pack ActiveForge/src/ActiveForge.PostgreSQL/ActiveForge.PostgreSQL.csproj \ + dotnet pack src/ActiveForge.PostgreSQL/ActiveForge.PostgreSQL.csproj \ --configuration Release --no-build \ -p:PackageVersion=${{ steps.pkg_version.outputs.version }} \ --output ./artifacts - name: Pack — SQLite run: | - dotnet pack ActiveForge/src/ActiveForge.SQLite/ActiveForge.SQLite.csproj \ + dotnet pack src/ActiveForge.SQLite/ActiveForge.SQLite.csproj \ --configuration Release --no-build \ -p:PackageVersion=${{ steps.pkg_version.outputs.version }} \ --output ./artifacts - name: Pack — MongoDB run: | - dotnet pack ActiveForge/src/ActiveForge.MongoDB/ActiveForge.MongoDB.csproj \ + dotnet pack src/ActiveForge.MongoDB/ActiveForge.MongoDB.csproj \ --configuration Release --no-build \ -p:PackageVersion=${{ steps.pkg_version.outputs.version }} \ --output ./artifacts @@ -104,14 +160,9 @@ jobs: - name: Publish to GitHub Packages run: | - dotnet nuget add source \ - --username ${{ github.repository_owner }} \ - --password ${{ secrets.GITHUB_TOKEN }} \ - --store-password-in-clear-text \ - --name github \ - "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" dotnet nuget push "./artifacts/*.nupkg" \ - --source github \ + --api-key ${{ secrets.GITHUB_TOKEN }} \ + --source "https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json" \ --skip-duplicate - name: Publish to NuGet.org diff --git a/ActiveForge.sln b/ActiveForge.sln index b2c3ef2..9a413b9 100644 --- a/ActiveForge.sln +++ b/ActiveForge.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.2.11415.280 d18.0 +VisualStudioVersion = 18.2.11415.280 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ActiveForge", "src\ActiveForge\ActiveForge.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" EndProject @@ -31,11 +31,20 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{D54AC37D-D3E3-48A9-BDFE-4C19404F7C00}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "misc", "misc", "{D6383414-22E9-4C28-8500-8963CB6942DF}" + ProjectSection(SolutionItems) = preProject + .gitignore = .gitignore + GitVersion.yml = GitVersion.yml + LICENSE = LICENSE + README.md = README.md + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "github", "github", "{45231C39-1C92-4264-982D-6B40708CA0BD}" ProjectSection(SolutionItems) = preProject .gitignore = .gitignore .github\workflows\master-build.yml = .github\workflows\master-build.yml + .github\workflows\master-codeql.yml = .github\workflows\master-codeql.yml + .github\workflows\release-ci.yml = .github\workflows\release-ci.yml + .github\workflows\release-codeql.yml = .github\workflows\release-codeql.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{497629F0-306C-4BC9-99D1-B5C6EA2B2E6F}" diff --git a/README.md b/README.md index 7dc4ee8..4394080 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,19 @@ ActiveForge streamlines data-centric development with a cohesive approach to ent --- ## Requirements - -- .NET 8.0 -- One provider package: - - SQL Server → `ActiveForge.SqlServer` (wraps `Microsoft.Data.SqlClient` 5.2.1) - - PostgreSQL → `ActiveForge.PostgreSQL` (wraps `Npgsql` 8.0.3) - - MongoDB → `ActiveForge.MongoDB` (wraps `MongoDB.Driver` 2.28.0) - - SQLite → `ActiveForge.SQLite` (wraps `Microsoft.Data.Sqlite` 8.0.0) + ┌────────────┬──────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Project │ Targets │ + ├────────────┼──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ Core │ net8.0;net9.0;net10.0;net472;netstandard2.0;netstandard2.1 │ + ├────────────┼──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SqlServer │ net8.0;net9.0;net10.0;net472;netstandard2.0;netstandard2.1 │ + ├────────────┼──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ PostgreSQL │ net8.0;net9.0;net10.0 — Npgsql 8 limits this │ + ├────────────┼──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ SQLite │ net8.0;net9.0;net10.0;netstandard2.0;netstandard2.1 — net472 excluded (native binaries risk) │ + ├────────────┼──────────────────────────────────────────────────────────────────────────────────────────────┤ + │ MongoDB │ net8.0;net9.0;net10.0;net472;netstandard2.0;netstandard2.1 │ + └────────────┴──────────────────────────────────────────────────────────────────────────────────────────────┘ --- diff --git a/docs/v1.0.0 release-notes.md b/docs/v1.0.0 release-notes.md new file mode 100644 index 0000000..6172dfd --- /dev/null +++ b/docs/v1.0.0 release-notes.md @@ -0,0 +1,191 @@ +# ActiveForge ORM — v1.0.0 + +We are excited to announce the **first release of ActiveForge** — a lightweight, Active Record-style ORM for .NET ported and modernised from a proven .NET 3.5 foundation. ActiveForge brings type-safe persistence, composable querying, LINQ support, nested transactions, and DI-friendly service proxying to .NET 8 through .NET 10, with provider support for SQL Server, PostgreSQL, MongoDB, and SQLite. + +--- + +## Packages + +| Package | Description | NuGet | +|---------|-------------|-------| +| `ActiveForge.Core` | Provider-agnostic core — entities, fields, query terms, LINQ, transactions, UoW, DI | [![NuGet](https://badge.fury.io/nu/ActiveForge.Core.svg)](https://badge.fury.io/nu/ActiveForge.Core) | +| `ActiveForge.SqlServer` | SQL Server provider via `Microsoft.Data.SqlClient` | [![NuGet](https://badge.fury.io/nu/ActiveForge.SqlServer.svg)](https://badge.fury.io/nu/ActiveForge.SqlServer) | +| `ActiveForge.PostgreSQL` | PostgreSQL provider via Npgsql | [![NuGet](https://badge.fury.io/nu/ActiveForge.PostgreSQL.svg)](https://badge.fury.io/nu/ActiveForge.PostgreSQL) | +| `ActiveForge.MongoDB` | MongoDB provider with BSON mapping and aggregation pipeline join support | [![NuGet](https://badge.fury.io/nu/ActiveForge.MongoDB.svg)](https://badge.fury.io/nu/ActiveForge.MongoDB) | +| `ActiveForge.SQLite` | SQLite provider via `Microsoft.Data.Sqlite`, including in-memory database support | [![NuGet](https://badge.fury.io/nu/ActiveForge.SQLite.svg)](https://badge.fury.io/nu/ActiveForge.SQLite) | + +```shell +dotnet add package ActiveForge.Core +dotnet add package ActiveForge.SqlServer # SQL Server +dotnet add package ActiveForge.PostgreSQL # PostgreSQL +dotnet add package ActiveForge.MongoDB # MongoDB +dotnet add package ActiveForge.SQLite # SQLite / in-memory testing +``` + +--- + +## What's included + +### Entities & Mapping + +Entities follow the **Active Record pattern** — state and persistence logic live together in the same class, eliminating the need for a separate repository layer. Entities are defined once and work across all providers without modification. + +**Type-safe fields** replace plain CLR properties with wrapper types (`TString`, `TInt`, `TDecimal`, `TBool`, `TDateTime`, `TForeignKey`, and 20+ more). Each field tracks its own nullability and dirty state, handles value conversion automatically, and participates in change detection for partial updates. + +**Attributes** control the mapping between entity fields and the underlying store: + +| Attribute | Effect | +|-----------|--------| +| `[Table]` | Maps the class to a named table / collection | +| `[Column]` | Maps a field to a named column / BSON field | +| `[Identity]` | Marks the primary key; auto-populated after insert | +| `[ReadOnly]` | Included in SELECT but never written | +| `[NoPreload]` | Excluded from the default SELECT; loaded on demand via `FieldSubset` | +| `[DefaultValue]` | Pre-populates the field on construction | +| `[Encrypted]` | Transparent encrypt / decrypt at the ORM layer | +| `[Sensitive]` | Masks the value in diagnostic output | + +Additional mapping capabilities: **polymorphic mapping** (base type resolved to concrete subtype at runtime), **custom field mappers** for non-standard type conversions, and **field subsets** for loading or updating only a named subset of columns. + +--- + +### Querying + +**QueryTerm API** — build queries by composing predicate objects: `EqualTerm`, `ContainsTerm`, `LikeTerm`, `NullTerm`, `RangeTerm`, `GreaterOrEqualTerm`, `LessOrEqualTerm`, and their logical combinations. Sort with `OrderAscending` / `OrderDescending` including multi-column `CombinedSortOrder`. + +**LINQ support** — `conn.Query()` returns an `IQueryable` that translates `Where`, `OrderBy`, `ThenBy`, `Take`, and `Skip` into native ORM operations. Cross-join predicates and sort are fully supported — filter or order by a field on an embedded (joined) entity directly in the lambda: + +```csharp +conn.Query(new Product(conn)) + .Where(p => p.Category.Name == "Electronics" && p.Price < 500m) + .OrderBy(p => p.Category.Name) + .ThenBy(p => p.Price) + .Skip(0).Take(20) + .ToList(); +``` + +**Join type overrides** — class-level join types (`INNER JOIN` / `LEFT OUTER JOIN`) can be overridden per query without changing the entity definition: + +```csharp +conn.Query(new Product(conn)) + .LeftOuterJoin() + .Where(p => p.Category.Name == "Electronics") + .ToList(); +``` + +**Pagination** — `QueryPage` returns results with total-count metadata. **Lazy streaming** — `LazyQueryAll` yields rows one at a time for memory-efficient processing of large result sets. + +--- + +### Transactions & Unit of Work + +ActiveForge provides three complementary ways to manage transactions: + +**`With.Transaction`** — wrap an ad-hoc block of work: +```csharp +With.Transaction(uow, () => +{ + order.Status.SetValue("Shipped"); + order.Update(RecordLock.UpdateOption.IgnoreLock); + shipment.Insert(); +}); +``` + +**`IUnitOfWork`** — fine-grained programmatic control with `CreateTransaction`, `Commit`, and `Rollback`. Nesting is depth-tracked; only the outermost scope commits to the database. + +**`[Transaction]` attribute** — declarative demarcation via Castle DynamicProxy. Applying the attribute to a service method causes the proxy to open the connection, begin the transaction, commit on success, and roll back and close the connection on exception — with no virtual methods or base class coupling required. + +--- + +### Dependency Injection & Service Proxy + +ActiveForge integrates with any .NET DI host (ASP.NET Core, Worker Service, console) through provider-specific `IServiceCollection` extension methods. + +**One-call registration:** +```csharp +builder.Services + .AddActiveForgeSqlServer("Server=.;Database=Demo;Integrated Security=True;") + .AddServices(typeof(Program).Assembly); // auto-scan for IService implementations +``` + +**`IService` marker** — implement `IService` on any class to opt it into auto-discovery. `AddServices()` scans the supplied assemblies, registers each implementation against its non-system interfaces, and wraps it in a Castle interface proxy that activates `[Transaction]` and `[ConnectionScope]` interceptors transparently. + +**`ActiveForgeServiceFactory`** — available for standalone (non-DI) scenarios where a proxy is needed without a container. + +--- + +### Provider highlights + +#### SQL Server +- Full ADO.NET adapter layer over `Microsoft.Data.SqlClient` 5.2.1 +- `SELECT @@IDENTITY` for identity retrieval (compatible with `sp_executesql` execution scope) +- Pessimistic locking support + +#### PostgreSQL +- Full Npgsql 8.0.3 adapter layer +- `RETURNING` clause for identity retrieval after insert +- Pessimistic locking with `FOR UPDATE` semantics + +#### MongoDB +- Reflection-based BSON mapping — no serializer attributes or configuration required +- `QueryTerm` predicates translate to MongoDB `FilterDefinition` (`$eq`, `$in`, `$gte`, `$lte`, regex for `LikeTerm`) +- Automatic `$lookup` + `$unwind` aggregation pipeline for embedded Record fields (joins) +- Auto-increment integer IDs via a `__activeforge_counters` collection; `[Identity]` maps to `_id` +- Multi-document transaction support via `MongoUnitOfWork` + +#### SQLite +- Full `Microsoft.Data.Sqlite` 8.0.0 adapter layer +- In-memory database support (`Data Source=:memory:`) — ideal for fast integration tests without a server + +--- + +## Target Frameworks + +| Package | Frameworks | +|---------|-----------| +| `ActiveForge.Core` | `net8.0` · `net9.0` · `net10.0` · `net472` · `netstandard2.0` · `netstandard2.1` | +| `ActiveForge.SqlServer` | `net8.0` · `net9.0` · `net10.0` · `net472` · `netstandard2.0` · `netstandard2.1` | +| `ActiveForge.PostgreSQL` | `net8.0` · `net9.0` · `net10.0` _(limited by Npgsql 8)_ | +| `ActiveForge.SQLite` | `net8.0` · `net9.0` · `net10.0` · `netstandard2.0` · `netstandard2.1` | +| `ActiveForge.MongoDB` | `net8.0` · `net9.0` · `net10.0` · `net472` · `netstandard2.0` · `netstandard2.1` | + +--- + +## Test coverage + +**679 tests — 0 failures** across all five providers. + +| Suite | Tests | +|-------|------:| +| ActiveForge.Tests (Core) | 340 | +| ActiveForge.SqlServer.Tests | 80 | +| ActiveForge.PostgreSQL.Tests | 81 | +| ActiveForge.MongoDB.Tests | 126 | +| ActiveForge.SQLite.Tests | 52 | + +Each provider suite covers full CRUD, join queries, pagination, transactions, and Unit of Work lifecycle. + +--- + +## Documentation + +| Guide | | +|-------|-| +| [Getting Started](docs/getting-started.md) | Install, connect, define entities, run your first query | +| [Field Types](docs/field-types.md) | All `TField` types, operators, and conversion behaviour | +| [Query Builder](docs/query-builder.md) | Composing `WHERE`, `ORDER BY`, pagination, and joins | +| [Transactions & DI](docs/transactions-and-di.md) | `IUnitOfWork`, `With.Transaction`, `[Transaction]`, service proxies | +| [LINQ Querying](docs/linq-querying.md) | `conn.Query()`, cross-join predicates, join overrides | +| [Field Subsets](docs/field-subsets.md) | Partial fetches and partial updates | +| [Advanced](docs/advanced.md) | Encryption, custom mappers, polymorphism | +| [Wiki](https://github.com/CodeShayk/ActiveForge/wiki) | Comprehensive reference with examples | + +--- + +## Contributing + +Bug reports, feature requests, and pull requests are welcome — please see [CONTRIBUTING.md](Contribution_Guidelines.md) for guidelines. + +--- + +**Full commit history:** https://github.com/CodeShayk/ActiveForge/commits/v1.0.0