Skip to content
Merged
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
159 changes: 159 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json

name: Publish Preview
run-name: '[Preview] PR #${{ github.event.pull_request.number }} — ${{ github.event.pull_request.title }}'

on:
pull_request:
types: [opened, synchronize, reopened]
branches:
- main
- master
- develop

# Cancel any in-progress publish for the same PR when a new commit is pushed
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: write

env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
DOTNET_NOLOGO: true

defaults:
run:
shell: pwsh

jobs:
publish-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x

- name: Read base version from Directory.Build.props
id: base_version
shell: bash
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' Directory.Build.props)
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
echo "Base version: $VERSION"

- name: Compute preview version
id: preview_version
shell: bash
run: |
PREVIEW_VERSION="${{ steps.base_version.outputs.VERSION }}-preview.pr${{ github.event.pull_request.number }}.${{ github.run_number }}"
echo "PREVIEW_VERSION=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
echo "Preview version: $PREVIEW_VERSION"

- name: Restore dependencies
run: dotnet restore

- name: Build
run: dotnet build --configuration Release --no-restore -p:Version=${{ steps.preview_version.outputs.PREVIEW_VERSION }}

- name: Pack NuGet packages
run: dotnet pack --configuration Release --no-build -p:Version=${{ steps.preview_version.outputs.PREVIEW_VERSION }} --include-symbols -p:SymbolPackageFormat=snupkg --output ./artifacts

- name: List generated packages
run: Get-ChildItem ./artifacts | Select-Object Name | Format-Table

- name: Publish preview packages to NuGet
run: |
foreach ($file in Get-ChildItem "./artifacts" -Recurse -Include *.nupkg,*.snupkg) {
Write-Host "Processing package: $($file.Name)"

$apiKey = $null
if ($file.Name -like "Facet.Mapping.Expressions.*") {
Write-Host "Publishing Facet.Mapping.Expressions package..."
$apiKey = "${{ secrets.NUGET_API_KEY_EXPRESSIONS }}"
} elseif ($file.Name -like "Facet.Mapping.*") {
Write-Host "Publishing Facet.Mapping package..."
$apiKey = "${{ secrets.NUGET_MAP_API_KEY }}"
} elseif ($file.Name -like "Facet.Extensions.EFCore.Mapping.*") {
Write-Host "Publishing Facet.Extensions.EFCore.Mapping package..."
$apiKey = "${{ secrets.NUGET_API_KEY_EXTENSIONS_EF_MAPPING }}"
} elseif ($file.Name -like "Facet.Extensions.EFCore.*") {
Write-Host "Publishing Facet.Extensions.EFCore package..."
$apiKey = "${{ secrets.NUGET_API_KEY_EXTENSIONS_EF }}"
} elseif ($file.Name -like "Facet.Extensions.*") {
Write-Host "Publishing Facet.Extensions package..."
$apiKey = "${{ secrets.NUGET_API_KEY_EXTENSIONS }}"
} elseif ($file.Name -like "Facet.Dashboard.*") {
Write-Host "Publishing Facet.Dashboard package..."
$apiKey = "${{ secrets.NUGET_API_KEY_DASHBOARD }}"
} elseif ($file.Name -like "Facet.Attributes.*") {
Write-Host "Publishing Facet.Attributes package..."
$apiKey = "${{ secrets.NUGET_ATTRIBUTES_API_KEY }}"
} elseif ($file.Name -like "Facet.*") {
Write-Host "Publishing Facet package..."
$apiKey = "${{ secrets.NUGET_API_KEY }}"
} else {
Write-Host "Skipping unknown package: $($file.Name)"
continue
}

dotnet nuget push $file `
--api-key $apiKey `
--source https://api.nuget.org/v3/index.json `
--skip-duplicate
}

- name: Comment preview version on PR
uses: actions/github-script@v7
with:
script: |
const version = '${{ steps.preview_version.outputs.PREVIEW_VERSION }}';
const body = [
'## 📦 Preview packages published',
'',
`Version: \`${version}\``,
'',
'Install with:',
'```',
`dotnet add package Facet --version ${version}`,
'```',
'',
'_This pre-release is published automatically from this PR and will be overwritten on the next push._',
].join('\n');

// Update existing bot comment if present, otherwise create a new one
const { data: comments } = await github.rest.issues.listComments({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
});

const botComment = comments.find(
c => c.user.type === 'Bot' && c.body.includes('Preview packages published')
);

if (botComment) {
await github.rest.issues.updateComment({
comment_id: botComment.id,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
} else {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Instead of manually creating each facet, **Facet** auto-generates them from a si
- **`GenerateEquality`** - generate value-based `Equals`, `GetHashCode`, `==`, `!=` for class DTOs

### Advanced Features
- **Multi-source mapping** — a single target class can carry multiple `[Facet]` attributes, each mapping from a different source type; produces per-source constructors, projections (`ProjectionFrom{Source}`), and reverse-mapping methods (`To{Source}()`)
- **`[Flatten]`** - collapse nested object graphs into top-level properties
- **`[Wrapper]`** - reference-based delegation for facades, ViewModels, and decorators
- **`[GenerateDtos]`** - auto-generate full CRUD DTO sets (Create, Update, Response, Query, Upsert, Patch)
Expand Down
89 changes: 89 additions & 0 deletions docs/06_AdvancedScenarios.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,97 @@ public partial class UserSummaryDto { }
public partial class UserHRDto { }
```

---

## Multiple Source Types to One Target

Since Facet v6, a single target class can carry **multiple `[Facet]` attributes**, each pointing to a **different** source type. The generator emits a single partial class containing:

- A **union of all mapped properties** (deduplicated by name; first-occurrence wins).
- **Per-source constructors** and `FromSource` factory method overloads (naturally overloaded by parameter type — no naming conflict).
- **Per-source projection expressions** named `ProjectionFrom{SourceTypeName}` to avoid static-property collisions.
- **Per-source `ToSource` methods** named `To{SourceTypeName}()` to avoid method-signature conflicts; the deprecated `BackTo` alias is **not** generated for multi-source facets.
- Shared artefacts (parameterless constructor, copy constructor, equality) are generated once from the **primary** (first) attribute.

### Motivation

A common scenario in domain-driven or layered architectures is a "drop-down" or "summary" DTO that can be populated from multiple different source types — an EF Core entity **and** a domain DTO both containing the same logical data:

```csharp
// Two different source representations of the same concept
public partial class UnitEntity : ModifiedByBaseEntity { /* Id, Name, ... */ }
public partial class UnitDto : FacetsModifiedByBaseDto { /* Id, Name, ... */ }

// One unified "display" target that can accept both
[Facet(typeof(UnitEntity), Include = [nameof(UnitEntity.Name)])]
[Facet(typeof(UnitDto), Include = [nameof(UnitDto.Name)])]
public partial class UnitDropDownDto;
```

### Generated API

For the example above Facet generates a single `UnitDropDownDto` class with:

```csharp
// Constructors (overloaded by source type — no ambiguity)
var a = new UnitDropDownDto(unitEntity);
var b = new UnitDropDownDto(unitDto);

// Factory methods (overloaded)
var c = UnitDropDownDto.FromSource(unitEntity);
var d = UnitDropDownDto.FromSource(unitDto);

// Per-source LINQ projections
IQueryable<UnitDropDownDto> q1 = dbContext.Units
.Select(UnitDropDownDto.ProjectionFromUnitEntity);

IQueryable<UnitDropDownDto> q2 = unitDtos.AsQueryable()
.Select(UnitDropDownDto.ProjectionFromUnitDto);
```

### Union of Members

When source types share properties (e.g. both have `Id` and `Name`) the property is generated **once** (first-definition wins). Exclusive properties from each source type are also included:

```csharp
public class EntityA { public int Id { get; set; } public string Name { get; set; } public string EntityAOnly { get; set; } }
public class EntityB { public int Id { get; set; } public string Name { get; set; } public string EntityBOnly { get; set; } }

[Facet(typeof(EntityA))]
[Facet(typeof(EntityB))]
public partial class UnionDto;
// Generated properties: Id, Name, EntityAOnly, EntityBOnly
```

### Reverse Mapping (ToSource)

When `GenerateToSource = true` is specified on an attribute, a `To{SourceTypeName}()` method is generated for that source type:

```csharp
[Facet(typeof(UnitEntity), Include = [nameof(UnitEntity.Name)], GenerateToSource = true)]
[Facet(typeof(UnitDto), Include = [nameof(UnitDto.Name)])]
public partial class UnitDropDownDto;

// Generated:
// public UnitEntity ToUnitEntity() { ... }
// (no ToUnitDto because GenerateToSource was not set on the second attribute)
```

### Important Notes

| Behaviour | Detail |
|-----------|--------|
| **Projection names** | Always `ProjectionFrom{SourceSimpleName}` for multi-source targets |
| **ToSource names** | Always `To{SourceSimpleName}()` for multi-source targets — no `BackTo()` alias |
| **Single-source behaviour** | Unchanged: `Projection`, `ToSource()`, and `BackTo()` are still generated with the original names |
| **Member deduplication** | Properties with the same name across multiple sources are generated once; the type from the first mapping is used |
| **Configuration** | `Configuration`, `BeforeMap`, `AfterMap`, and `ToSourceConfiguration` are each read independently per attribute |

---

## Include vs Exclude Patterns


### Include Pattern - Building Focused DTOs

Use the `Include` pattern when you want facets with only specific properties:
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Welcome to the Facet documentation! This index will help you navigate all availa
- [Extension Methods](05_Extensions.md): Extension Methods (LINQ, EF Core, etc.)
- [Advanced Scenarios](06_AdvancedScenarios.md): Advanced Usage Scenarios
- Multiple facets from one source
- **Multiple source types to one target** (multi-source mapping)
- Include/Exclude patterns
- Nested Facets (single objects & collections)
- Collection support (List, Array, ICollection, IEnumerable, IReadOnlyList, IReadOnlyCollection, Immutable collections, and custom types)
Expand Down
Loading
Loading