diff --git a/.agents/skills/git-workflow/SKILL.md b/.agents/skills/git-workflow/SKILL.md new file mode 100644 index 0000000..8c1db88 --- /dev/null +++ b/.agents/skills/git-workflow/SKILL.md @@ -0,0 +1,53 @@ +--- +name: git-workflow +description: >- + Git workflow automation for committing, branching, and opening pull requests. + Use this whenever the user asks to commit their work, create a branch, or + create/open/draft a PR. +--- + +# Git Workflow + +Use this skill whenever the user asks to: + +- commit these changes +- create a commit +- save my work +- stage and commit +- commit current work +- create a branch +- start a feature branch +- make a branch for this work +- start working on a change +- create a PR +- open a PR +- draft a PR +- prepare a pull request +- commit and open a PR +- create a branch and PR +- submit the current work + +______________________________________________________________________ + +## Shared references + +Before executing any workflow, load all four shared references: + +- [Scope Detection](shared/scope-detection.md) +- [File Inclusion Policy](shared/file-inclusion-policy.md) +- [Safety Rules](shared/safety-rules.md) +- [Conventional Types](shared/conventional-types.md) + +______________________________________________________________________ + +## Intent routing + +Based on the user's request, load exactly one workflow doc: + +| User intent | Load | +| ----------------------------------------------- | -------------------------------- | +| Commit work, save changes, stage and commit | [docs/commit.md](docs/commit.md) | +| Create a branch, start a feature branch | [docs/branch.md](docs/branch.md) | +| Create/open/draft a PR, submit the current work | [docs/pr.md](docs/pr.md) | + +When intent is ambiguous, prefer the more complete workflow. If the user says "commit and open a PR", load `docs/pr.md` — it covers the full lifecycle including commit and branch. diff --git a/.agents/skills/git-workflow/docs/branch.md b/.agents/skills/git-workflow/docs/branch.md new file mode 100644 index 0000000..77641a6 --- /dev/null +++ b/.agents/skills/git-workflow/docs/branch.md @@ -0,0 +1,63 @@ +# Branch Workflow + +## Shared references + +Load before executing: + +- [Scope Detection](../shared/scope-detection.md) +- [Conventional Types](../shared/conventional-types.md) +- [Safety Rules](../shared/safety-rules.md) + +______________________________________________________________________ + +## Goal + +Create a properly named branch for the current work based on inferred change intent, then switch to it. + +## Branch naming format + +``` +/- +``` + +If no scope applies: + +``` +/ +``` + +Rules: + +- lowercase only +- hyphen-separated +- concise and descriptive +- remove punctuation + +Examples: + +- `feat/core-add-pr-automation` +- `fix/github-handle-detached-head` +- `docs/update-readme` +- `ci/github-improve-release-workflow` + +## Workflow + +1. Inspect repository status and changed files +2. Infer change type (see [Conventional Types](../shared/conventional-types.md)) +3. Infer optional scope (see [Scope Detection](../shared/scope-detection.md)) +4. Generate branch name +5. Create the branch +6. Switch to the branch + +## Branch-specific safety + +If a branch with the same name already exists, append a short numeric suffix (e.g. `-2`) rather than overwriting it. + +See also [Safety Rules](../shared/safety-rules.md) for general constraints. + +## Output + +Report: + +- branch name created +- branch switched to diff --git a/.agents/skills/git-workflow/docs/commit.md b/.agents/skills/git-workflow/docs/commit.md new file mode 100644 index 0000000..07785f7 --- /dev/null +++ b/.agents/skills/git-workflow/docs/commit.md @@ -0,0 +1,47 @@ +# Commit Workflow + +## Shared references + +Load before executing: + +- [Scope Detection](../shared/scope-detection.md) +- [File Inclusion Policy](../shared/file-inclusion-policy.md) +- [Safety Rules](../shared/safety-rules.md) +- [Conventional Types](../shared/conventional-types.md) + +______________________________________________________________________ + +## Goal + +Create a commit representing the user's current working changes using a conventional commit format. + +## Commit format + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +The description must immediately follow the colon and space. Scope is wrapped in parentheses when present: `feat(parser): add CSV support`. + +For breaking changes, append `!` after the type/scope and/or include a `BREAKING CHANGE:` footer. See [Conventional Types](../shared/conventional-types.md) for details. + +## Workflow + +1. Inspect repository status +2. Identify all modified files +3. Stage all user-modified files (see [File Inclusion Policy](../shared/file-inclusion-policy.md)) +4. Exclude only obvious junk artifacts +5. Infer `` and `` (see [Conventional Types](../shared/conventional-types.md) and [Scope Detection](../shared/scope-detection.md)) +6. Generate and create the commit + +## Output + +Report: + +- commit message used +- files committed +- any files excluded and why diff --git a/.agents/skills/git-workflow/docs/pr.md b/.agents/skills/git-workflow/docs/pr.md new file mode 100644 index 0000000..a88e341 --- /dev/null +++ b/.agents/skills/git-workflow/docs/pr.md @@ -0,0 +1,123 @@ +# PR Workflow + +## Shared references + +Load before executing: + +- [Scope Detection](../shared/scope-detection.md) +- [File Inclusion Policy](../shared/file-inclusion-policy.md) +- [Safety Rules](../shared/safety-rules.md) +- [Conventional Types](../shared/conventional-types.md) + +______________________________________________________________________ + +## Goal + +Prepare the current work for review and create a pull request that includes: + +- a correctly named branch +- a conventional commit message +- a PR title following the required format +- a reviewable PR body that explains what changed, why, validation, and risk + +Template location: `../templates/pull-request-template.md` + +______________________________________________________________________ + +## PR title format + +``` +[optional scope]: +``` + +For breaking changes, append `!` after the type/scope: `feat(api)!: remove deprecated endpoint` + +Example: `feat(core): add automated PR workflow` + +______________________________________________________________________ + +## Branch rules + +Create a new branch if: + +- the current branch is `main` +- the repository is in detached `HEAD` + +If already on a feature branch, use the current branch. + +Branch naming follows `/-` (or `/` when no scope applies). See [Branch Workflow](branch.md) for full naming rules. + +______________________________________________________________________ + +## Execution flow + +### 1 — Inspect repository + +Determine: current branch, whether HEAD is detached, git status, modified files, diff summary, and commit history against the base branch. + +### 2 — Infer metadata + +Determine: PR type, optional scope, short description, PR title, branch name. + +### 3 — Prepare branch + +If on `main` or detached `HEAD`, create a new branch and switch to it. Otherwise stay on the current branch. + +### 4 — Commit work + +Stage all user-modified files per [File Inclusion Policy](../shared/file-inclusion-policy.md). Exclude only obvious junk. Create commit. Skip if nothing to commit. + +### 5 — Push branch + +Push to origin. Set upstream if necessary. + +### 6 — Generate PR body + +Load `../templates/pull-request-template.md` and adapt it to the actual change. + +Treat the template as a default outline, not a rigid contract. Prioritize reviewer scanability and signal quality over filling every heading. + +Required information: + +- what changed +- why it changed +- how it was validated + +Default outline (adapt as needed): + +- Summary - 2-4 sentences covering what changed and why +- Changes - grouped in the way that makes the diff easiest to review (for example by concern, subsystem, workflow, or user impact) +- Validation - concrete tests, manual verification, and confidence signals +- Breaking Changes - include only when applicable +- Related Issues - include only when applicable; do not invent issue numbers +- Release Notes - include only for user-visible or package-relevant changes +- Notes for Reviewers - include when review guidance, risks, tradeoffs, follow-up context, or requested feedback focus would help; for UI changes, include screenshots/video links when useful + +Review mode: + +- open as draft when implementation is incomplete, checks are pending, or early feedback is requested +- when draft, state what is incomplete and what feedback is being requested + +Rules: + +- omit empty sections entirely (do not include `N/A`, `None`, or `No related issues`) +- prefer fewer, high-signal sections over boilerplate +- use backticks for identifiers, commands, files, and code terms +- keep the Summary concise and focused on intent, not file-by-file trivia + +### 7 — Create PR + +Create the pull request using the generated title and body, as draft or ready-for-review based on the review mode rules above. + +______________________________________________________________________ + +## Output + +Report: + +- branch name and whether it was created +- commit message and whether a commit was created +- PR title +- PR body +- any files excluded and why +- any assumptions or blockers diff --git a/.agents/skills/git-workflow/examples/ci-example.md b/.agents/skills/git-workflow/examples/ci-example.md new file mode 100644 index 0000000..cf03352 --- /dev/null +++ b/.agents/skills/git-workflow/examples/ci-example.md @@ -0,0 +1,17 @@ +# Example: CI PR + +## Scenario + +Current work updates GitHub Actions and release automation for NuGet publishing. + +## Expected branch + +`ci/github-improve-nuget-release-workflow` + +## Expected commit + +`ci(github): improve NuGet release workflow` + +## Expected PR title + +`ci(github): improve NuGet release workflow` diff --git a/.agents/skills/git-workflow/examples/feature-example.md b/.agents/skills/git-workflow/examples/feature-example.md new file mode 100644 index 0000000..1cd8a99 --- /dev/null +++ b/.agents/skills/git-workflow/examples/feature-example.md @@ -0,0 +1,52 @@ +# Example: Feature PR + +## Scenario + +Current work adds automatic PR template loading and branch creation when running from `main`. + +## Expected branch + +`feat/core-automate-pr-workflow` + +## Expected commit + +`feat(core): automate PR workflow from main` + +## Expected PR title + +`feat(core): automate PR workflow from main` + +## Example PR body + +# 🚀 Pull Request + +## 📋 Summary + +> Adds automation for branch preparation and PR generation when opening a pull request from the current repository state. This removes manual branch setup when starting from `main` and keeps PR metadata generation consistent with inferred change intent. + +______________________________________________________________________ + +## 📝 Changes + +- Branch preparation flow + - Detects `main` and detached `HEAD` before PR creation + - Creates and switches to a generated branch only when needed +- Metadata and PR drafting + - Infers PR metadata (`type`, optional `scope`, short description) + - Loads the local PR template and builds the PR body from current repository state +- Workflow consistency + - Reuses shared scope and inclusion policy logic so commit and PR behavior stay aligned + +______________________________________________________________________ + +## 🧪 Validation + +- Build/test status: Not explicitly verified by the agent +- Manual verification performed: Reviewed repository status, branch behavior, and generated PR content paths +- Edge cases checked: Existing feature branch path and detached `HEAD` path + +______________________________________________________________________ + +## 💬 Notes for Reviewers + +> Please focus on branch creation guardrails and metadata inference fallbacks, especially when repository state is ambiguous. diff --git a/.agents/skills/git-workflow/examples/fix-example.md b/.agents/skills/git-workflow/examples/fix-example.md new file mode 100644 index 0000000..1586ac1 --- /dev/null +++ b/.agents/skills/git-workflow/examples/fix-example.md @@ -0,0 +1,17 @@ +# Example: Fix PR + +## Scenario + +Current work fixes a bug in GitHub workflow handling for detached HEAD repositories. + +## Expected branch + +`fix/github-handle-detached-head` + +## Expected commit + +`fix(github): handle detached HEAD when opening PRs` + +## Expected PR title + +`fix(github): handle detached HEAD when opening PRs` diff --git a/.agents/skills/git-workflow/shared/conventional-types.md b/.agents/skills/git-workflow/shared/conventional-types.md new file mode 100644 index 0000000..e1d4003 --- /dev/null +++ b/.agents/skills/git-workflow/shared/conventional-types.md @@ -0,0 +1,36 @@ +# Conventional Types + +Valid `` values: + +| Type | Description | SemVer impact | +| ---------- | ----------------------------------------------------------------- | ------------- | +| `feat` | Introduces a new feature | MINOR | +| `fix` | Patches a bug | PATCH | +| `build` | Changes to the build system or external dependencies | — | +| `chore` | Maintenance tasks not modifying src or test files | — | +| `ci` | Changes to CI/CD configuration or scripts | — | +| `docs` | Documentation changes only | — | +| `perf` | A code change that improves performance | — | +| `refactor` | A code change that neither fixes a bug nor adds a feature | — | +| `style` | Changes that do not affect meaning (whitespace, formatting, etc.) | — | +| `test` | Adding or updating tests | — | + +## Breaking changes + +A breaking change correlates with MAJOR in SemVer. Mark it in one of two ways: + +**Append `!` after the type/scope:** + +``` +feat(api)!: remove deprecated endpoint +``` + +**Or include a `BREAKING CHANGE:` footer:** + +``` +feat(api): remove deprecated endpoint + +BREAKING CHANGE: The /v1/users endpoint has been removed. Use /v2/users instead. +``` + +Both forms may be combined. diff --git a/.agents/skills/git-workflow/shared/file-inclusion-policy.md b/.agents/skills/git-workflow/shared/file-inclusion-policy.md new file mode 100644 index 0000000..dd60bfe --- /dev/null +++ b/.agents/skills/git-workflow/shared/file-inclusion-policy.md @@ -0,0 +1,56 @@ +# File Inclusion Policy + +Treat the working tree as the user's intent. + +## Default behavior + +Include **all user-modified files** in the commit: + +- modified files +- staged files +- unstaged files +- untracked files +- deleted files + +If the user changed a file, assume the change is intentional. + +Do **not** exclude files merely because they appear unrelated to the inferred task. + +## Allowed automatic exclusions + +Files may only be excluded if they are clearly not intended for source control: + +- `.DS_Store` +- editor swap files +- temporary files +- build output folders +- cache folders +- machine-local configuration files +- secret files that should never be committed + +Example patterns: + +``` +.DS_Store +*.swp +*.tmp +bin/ +obj/ +node_modules/ +.vscode/* +``` + +## Ambiguity rule + +If there is **any uncertainty** about whether a file should be committed: + +**Include the file.** + +Never silently omit a user-modified file. + +## Transparency rule + +If any files are excluded automatically, explicitly report: + +- which files were excluded +- the reason they were excluded diff --git a/.agents/skills/git-workflow/shared/safety-rules.md b/.agents/skills/git-workflow/shared/safety-rules.md new file mode 100644 index 0000000..7ba692f --- /dev/null +++ b/.agents/skills/git-workflow/shared/safety-rules.md @@ -0,0 +1,13 @@ +# Safety Rules + +Never: + +- rewrite history +- force push +- silently omit user-modified files +- invent issue numbers +- fabricate test results +- mark checklist items complete without evidence +- overwrite existing branches without confirmation + +If the repository state is ambiguous, choose the safest non-destructive option. diff --git a/.agents/skills/git-workflow/shared/scope-detection.md b/.agents/skills/git-workflow/shared/scope-detection.md new file mode 100644 index 0000000..95df6d0 --- /dev/null +++ b/.agents/skills/git-workflow/shared/scope-detection.md @@ -0,0 +1,20 @@ +# Scope Detection + +Infer scope from the folder containing the majority of the changes. + +| Folder | Scope | +| ----------------------------- | ------------------- | +| `.github/workflows` | `github` | +| `src/Core` | `core` | +| `src/Abstractions` | `abstractions` | +| `src/SourceGenerators` | `source-generators` | +| `src/OpenTelemetry` | `opentelemetry` | +| `tests` | `tests` | +| `test` | `testing` | +| `docs` | `docs` | +| `build` | `build` | +| dependency or package updates | `deps` | + +If multiple folders are involved, prioritize the **primary concern of the change**. + +If no mapping clearly applies, omit the scope. diff --git a/.agents/skills/git-workflow/templates/pull-request-template.md b/.agents/skills/git-workflow/templates/pull-request-template.md new file mode 100644 index 0000000..dbbb7cf --- /dev/null +++ b/.agents/skills/git-workflow/templates/pull-request-template.md @@ -0,0 +1,54 @@ +# 🚀 Pull Request + +## 📋 Summary + +> 2-4 sentences: what changed, why it changed, and the expected outcome. + +______________________________________________________________________ + +## 📝 Changes + + + + + +______________________________________________________________________ + +## 🧪 Validation + + + +- Build/test status: +- Manual verification performed: +- Edge cases checked: + +______________________________________________________________________ + +## ⚠️ Breaking Changes (Optional) + + + +- What changed: +- Previous behavior: +- New behavior: +- Migration/action needed: + +______________________________________________________________________ + +## 🧩 Related Issues (Optional) + + + + + +______________________________________________________________________ + +## 📦 Release Notes (Optional) + + + +______________________________________________________________________ + +## 💬 Notes for Reviewers (Optional) + + diff --git a/.agents/skills/git-workflow/templates/release-notes-template.md b/.agents/skills/git-workflow/templates/release-notes-template.md new file mode 100644 index 0000000..c37c9c2 --- /dev/null +++ b/.agents/skills/git-workflow/templates/release-notes-template.md @@ -0,0 +1,17 @@ +## Release Notes + +### Added + +- + +### Changed + +- + +### Fixed + +- + +### Internal + +- diff --git a/.claude/hooks/format.py b/.claude/hooks/format.py new file mode 100644 index 0000000..f4c278a --- /dev/null +++ b/.claude/hooks/format.py @@ -0,0 +1,102 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.14" +# /// + +import json +import os +import sys +import subprocess + +DOTSETTINGS_FILE_NAME = "LayeredCraft.DynamoMapper.sln.DotSettings" + + +def main(): + try: + # Read JSON input from stdin + input_data = json.load(sys.stdin) + + cwd = input_data["cwd"] + eddited_input = input_data["tool_input"]["file_path"] + _, ext = os.path.splitext(eddited_input) + + print(f"Running code cleanup on: '{eddited_input}' in directory: '{cwd}'") + + match ext.lower(): + case ".cs" | ".csx" | ".csproj" | ".props": + csharp(cwd, eddited_input) + case ".md": + markdown(cwd, eddited_input) + case _: + print(f"Skipping unsupported file type: '{ext}'") + + sys.exit(0) + + except json.JSONDecodeError: + # Handle JSON decode errors gracefully + sys.exit(0) + except Exception: + # Exit cleanly on any other error + sys.exit(0) + + +def csharp(cwd: str, eddited_input: str) -> None: + print("======================================") + + print("Running C# code cleanup...") + + result = subprocess.run( + [ + "dotnet", + "tool", + "run", + "jb", + "cleanupcode", + "--profile=Built-in: Reformat Code", + f"--include={eddited_input}", + f"--settings={os.getenv('DOTSETTINGS_FILE')}", + ], + cwd=cwd, + capture_output=True, + text=True, + ) + + print(result.stdout) + + print("======================================") + + +def markdown(cwd: str, eddited_input: str) -> None: + print("======================================") + + print("Running Markdown code cleanup...") + + result = subprocess.run( + [ + "uvx", + "--with", + "mdformat-mkdocs", + "--with", + "mdformat-frontmatter", + "mdformat", + eddited_input, + "--exclude", + ".agents/**", + "--exclude", + ".claude/**", + "--exclude", + ".opencode/**" + ], + cwd=cwd, + capture_output=True, + text=True, + ) + + print(result.stdout) + + print("======================================") + + +if __name__ == "__main__": + print("Running format_cs.py hook...") + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..836a1fd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "respectGitignore": false, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "uv run $CLAUDE_PROJECT_DIR/.claude/hooks/format.py" + } + ] + } + ] + }, + "env": { + "DOTSETTINGS_FILE": "LayeredCraft.DynamoMapper.sln.DotSettings" + } +} diff --git a/.claude/skills/dynamo-mapper b/.claude/skills/dynamo-mapper new file mode 120000 index 0000000..8b80185 --- /dev/null +++ b/.claude/skills/dynamo-mapper @@ -0,0 +1 @@ +../../skills/dynamo-mapper \ No newline at end of file diff --git a/.claude/skills/git-workflow b/.claude/skills/git-workflow new file mode 120000 index 0000000..0ef95d2 --- /dev/null +++ b/.claude/skills/git-workflow @@ -0,0 +1 @@ +../../.agents/skills/git-workflow \ No newline at end of file diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 0000000..8ec3ee6 --- /dev/null +++ b/.opencode/opencode.jsonc @@ -0,0 +1,45 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [ + "CLAUDE.local.md" + ], + "formatter": { + "cs-jb-formatter": { + "command": [ + "dotnet", + "tool", + "run", + "jb", + "cleanupcode", + "--profile=Built-in: Reformat Code", + "--include=$FILE", + "--settings=LayeredCraft.DynamoMapper.sln.DotSettings" + ], + "extensions": [ + ".cs", + ".props", + ".csproj" + ] + }, + "mdformat": { + "command": [ + "uvx", + "--with", + "mdformat-mkdocs", + "--with", + "mdformat-frontmatter", + "mdformat", + "$FILE", + "--exclude", + ".agents/**", + "--exclude", + ".claude/**", + "--exclude", + ".opencode/**" + ], + "extensions": [ + ".md" + ] + } + } +} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index dd0b289..57a6779 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,29 +5,33 @@ $(NoWarn);NU1507 - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/LayeredCraft.DynamoMapper.sln.DotSettings b/LayeredCraft.DynamoMapper.sln.DotSettings index 6f48e3e..ca99829 100644 --- a/LayeredCraft.DynamoMapper.sln.DotSettings +++ b/LayeredCraft.DynamoMapper.sln.DotSettings @@ -1,143 +1,62 @@ - - DO_NOT_SHOW - <?xml version="1.0" encoding="utf-16"?><Profile name="Full Custom Cleanup"><CppReformatCode>True</CppReformatCode><FSharpReformatCode>True</FSharpReformatCode><ShaderLabReformatCode>True</ShaderLabReformatCode><XMLReformatCode>True</XMLReformatCode><VBReformatCode>True</VBReformatCode><CSReformatCode>True</CSReformatCode><CSharpReformatComments>True</CSharpReformatComments><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" ArrangeArgumentsStyle="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeCodeBodyStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CppCodeStyleCleanupDescriptor ArrangeBraces="True" ArrangeAuto="True" ArrangeFunctionDeclarations="True" ArrangeNestedNamespaces="True" ArrangeTypeAliases="True" ArrangeCVQualifiers="True" ArrangeSlashesInIncludeDirectives="True" ArrangeOverridingFunctions="True" SortDefinitions="True" SortIncludeDirectives="True" SortMemberInitializers="True" /><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><VBOptimizeImports>True</VBOptimizeImports><VBShortenReferences>True</VBShortenReferences><Xaml.RemoveRedundantNamespaceAlias>True</Xaml.RemoveRedundantNamespaceAlias><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CppAddTypenameTemplateKeywords>True</CppAddTypenameTemplateKeywords><CppCStyleToStaticCastDescriptor>True</CppCStyleToStaticCastDescriptor><CppRedundantDereferences>True</CppRedundantDereferences><CppDeleteRedundantAccessSpecifier>True</CppDeleteRedundantAccessSpecifier><CppRemoveCastDescriptor>True</CppRemoveCastDescriptor><CppRemoveElseKeyword>True</CppRemoveElseKeyword><CppShortenQualifiedName>True</CppShortenQualifiedName><CppDeleteRedundantSpecifier>True</CppDeleteRedundantSpecifier><CppRemoveStatement>True</CppRemoveStatement><CppDeleteRedundantTypenameTemplateKeywords>True</CppDeleteRedundantTypenameTemplateKeywords><CppReplaceExpressionWithBooleanConst>True</CppReplaceExpressionWithBooleanConst><CppMakeIfConstexpr>True</CppMakeIfConstexpr><CppMakePostfixOperatorPrefix>True</CppMakePostfixOperatorPrefix><CppMakeVariableConstexpr>True</CppMakeVariableConstexpr><CppChangeSmartPointerToMakeFunction>True</CppChangeSmartPointerToMakeFunction><CppReplaceThrowWithRethrowFix>True</CppReplaceThrowWithRethrowFix><CppTypeTraitAliasDescriptor>True</CppTypeTraitAliasDescriptor><CppRemoveRedundantConditionalExpressionDescriptor>True</CppRemoveRedundantConditionalExpressionDescriptor><CppSimplifyConditionalExpressionDescriptor>True</CppSimplifyConditionalExpressionDescriptor><CppReplaceExpressionWithNullptr>True</CppReplaceExpressionWithNullptr><CppReplaceTieWithStructuredBindingDescriptor>True</CppReplaceTieWithStructuredBindingDescriptor><CppUseAssociativeContainsDescriptor>True</CppUseAssociativeContainsDescriptor><CppUseEraseAlgorithmDescriptor>True</CppUseEraseAlgorithmDescriptor><CppJoinDeclarationAndAssignmentDescriptor>True</CppJoinDeclarationAndAssignmentDescriptor><CppMakeClassFinal>True</CppMakeClassFinal><CppMakeLocalVarConstDescriptor>True</CppMakeLocalVarConstDescriptor><CppMakeMethodConst>True</CppMakeMethodConst><CppMakeMethodStatic>True</CppMakeMethodStatic><CppMakePtrOrRefParameterConst>True</CppMakePtrOrRefParameterConst><CppMakeParameterConst>True</CppMakeParameterConst><CppPassValueParameterByConstReference>True</CppPassValueParameterByConstReference><CppRemoveElaboratedTypeSpecifierDescriptor>True</CppRemoveElaboratedTypeSpecifierDescriptor><CppRemoveRedundantLambdaParameterListDescriptor>True</CppRemoveRedundantLambdaParameterListDescriptor><CppRemoveRedundantMemberInitializerDescriptor>True</CppRemoveRedundantMemberInitializerDescriptor><CppRemoveRedundantParentheses>True</CppRemoveRedundantParentheses><CppRemoveTemplateArgumentsDescriptor>True</CppRemoveTemplateArgumentsDescriptor><CppRemoveUnreachableCode>True</CppRemoveUnreachableCode><CppRemoveUnusedIncludes>True</CppRemoveUnusedIncludes><CppRemoveUnusedLambdaCaptures>True</CppRemoveUnusedLambdaCaptures><CppReplaceIfWithIfConsteval>True</CppReplaceIfWithIfConsteval><RemoveCodeRedundanciesVB>True</RemoveCodeRedundanciesVB><VBMakeFieldReadonly>True</VBMakeFieldReadonly><Xaml.RedundantFreezeAttribute>True</Xaml.RedundantFreezeAttribute><Xaml.RemoveRedundantModifiersAttribute>True</Xaml.RemoveRedundantModifiersAttribute><Xaml.RemoveRedundantNameAttribute>True</Xaml.RemoveRedundantNameAttribute><Xaml.RemoveRedundantResource>True</Xaml.RemoveRedundantResource><Xaml.RemoveRedundantCollectionProperty>True</Xaml.RemoveRedundantCollectionProperty><Xaml.RemoveRedundantAttachedPropertySetter>True</Xaml.RemoveRedundantAttachedPropertySetter><Xaml.RemoveRedundantStyledValue>True</Xaml.RemoveRedundantStyledValue><Xaml.RemoveForbiddenResourceName>True</Xaml.RemoveForbiddenResourceName><Xaml.RemoveRedundantGridDefinitionsAttribute>True</Xaml.RemoveRedundantGridDefinitionsAttribute><Xaml.RemoveRedundantUpdateSourceTriggerAttribute>True</Xaml.RemoveRedundantUpdateSourceTriggerAttribute><Xaml.RemoveRedundantBindingModeAttribute>True</Xaml.RemoveRedundantBindingModeAttribute><Xaml.RemoveRedundantGridSpanAttribut>True</Xaml.RemoveRedundantGridSpanAttribut><IDEA_SETTINGS>&lt;profile version="1.0"&gt; - &lt;option name="myName" value="Full Custom Cleanup" /&gt; - &lt;inspection_tool class="ConditionalExpressionWithIdenticalBranchesJS" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="ES6ShorthandObjectProperty" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="JSRemoveUnnecessaryParentheses" enabled="true" level="WARNING" enabled_by_default="true" /&gt; - &lt;inspection_tool class="UnterminatedStatementJS" enabled="true" level="WARNING" enabled_by_default="true" /&gt; -&lt;/profile&gt;</IDEA_SETTINGS><RIDER_SETTINGS>&lt;profile&gt; - &lt;Language id="CSS"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="EditorConfig"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="HCL"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="HTML"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="HTTP Request"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Handlebars"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Ini"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="JSON"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Jade"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="JavaScript"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="Markdown"&gt; - &lt;Reformat&gt;false&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Mermaid"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="PowerShell"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Properties"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="RELAX-NG"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="Razor"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="SQL"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="TOML"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="VueExpr"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="XML"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;OptimizeImports&gt;true&lt;/OptimizeImports&gt; - &lt;Rearrange&gt;false&lt;/Rearrange&gt; - &lt;/Language&gt; - &lt;Language id="liquid"&gt; - &lt;Reformat&gt;false&lt;/Reformat&gt; - &lt;/Language&gt; - &lt;Language id="yaml"&gt; - &lt;Reformat&gt;true&lt;/Reformat&gt; - &lt;/Language&gt; -&lt;/profile&gt;</RIDER_SETTINGS><CSharpFormatDocComments>True</CSharpFormatDocComments></Profile> - Built-in: Full Cleanup - NotRequired - NotRequired - NotRequired - NotRequired - ExpressionBody - ExpressionBody - ExpressionBody - True - False - False + + NotRequiredForBoth + True + False + False + False + TOGETHER_SAME_LINE + True + True + True + True + INSIDE + 1 + 1 + False + False False False False False + False + False False False - True - 1 - ALWAYS - ALWAYS - ALWAYS + 0 + True + NEVER + NEVER + ALWAYS False NEVER - False - False - ALWAYS_IF_MULTILINE + STRONGLY + False True True + True CHOP_IF_LONG - False - True + CHOP_IF_LONG + True + True True + True True - True - True + True + CHOP_IF_LONG + CHOP_IF_LONG CHOP_IF_LONG True CHOP_IF_LONG CHOP_IF_LONG - 2 - False - False - 2 - ByFirstAttr - True - False - True - False - False - True - False - False - True + CHOP_IF_LONG + 100 True True True - True - True \ No newline at end of file + True \ No newline at end of file diff --git a/LayeredCraft.DynamoMapper.slnx b/LayeredCraft.DynamoMapper.slnx index 7fecd2d..88876aa 100644 --- a/LayeredCraft.DynamoMapper.slnx +++ b/LayeredCraft.DynamoMapper.slnx @@ -76,11 +76,14 @@ + + diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..9d98ce1 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "git-workflow": { + "source": "LayeredCraft/skills", + "sourceType": "github", + "computedHash": "fbd78cda46da7d8753beca0d68ce6532589daa4da0a457aa709fe1e11da468fe" + } + } +} diff --git a/skills/dynamo-mapper/SKILL.md b/skills/dynamo-mapper/SKILL.md index 2ca5b77..814178e 100644 --- a/skills/dynamo-mapper/SKILL.md +++ b/skills/dynamo-mapper/SKILL.md @@ -10,7 +10,8 @@ Use this skill when generating or explaining DynamoMapper code. ## Core truths - DynamoMapper is a C# incremental source generator for `T <-> Dictionary`. -- Configure mapping on a `static partial` mapper class marked with `[DynamoMapper]`. +- Configure mapping on a partial mapper class marked with `[DynamoMapper]`. +- Mapper classes can be instance-based or `static`; both shapes are supported. - The generator recognizes unimplemented partial methods whose names start with `To` or `From` and use the expected model/dictionary signatures. - One-way mappers are valid: `To*` only or `From*` only. @@ -41,6 +42,7 @@ Use this skill when generating or explaining DynamoMapper code. - Do not tell the user to decorate every POCO property; configuration belongs on the mapper class. - Do not assume methods must be named exactly `ToItem` and `FromItem`; the `To`/`From` prefix matters, but the generator also expects the recognized model/dictionary signatures. +- Do not assume mappers must be `static`; non-static members are valid too. - Check `references/gotchas.md` before teaching hooks or custom converter signatures. - Do not assume every unsupported converter setup becomes a DynamoMapper diagnostic; some become normal C# compile errors. diff --git a/skills/dynamo-mapper/references/core-usage.md b/skills/dynamo-mapper/references/core-usage.md index 5d88877..2459c73 100644 --- a/skills/dynamo-mapper/references/core-usage.md +++ b/skills/dynamo-mapper/references/core-usage.md @@ -13,16 +13,19 @@ public sealed class Order } [DynamoMapper] -public static partial class OrderMapper +public partial class OrderMapper { - public static partial Dictionary ToItem(Order source); - public static partial Order FromItem(Dictionary item); + public partial Dictionary ToItem(Order source); + public partial Order FromItem(Dictionary item); } ``` +`static` mapper classes are also supported if you prefer static access. + ## Mapper rules -- The mapper is a `static partial class` marked with `[DynamoMapper]`. +- The mapper is a `partial class` marked with `[DynamoMapper]`. +- Mapper classes and methods may be instance-based or `static`. - `To*` methods take one model parameter and return `Dictionary`. - `From*` methods take one `Dictionary` and return the model type. - One-way mappers are valid. diff --git a/skills/dynamo-mapper/references/gotchas.md b/skills/dynamo-mapper/references/gotchas.md index 2bb7621..1a00725 100644 --- a/skills/dynamo-mapper/references/gotchas.md +++ b/skills/dynamo-mapper/references/gotchas.md @@ -4,6 +4,7 @@ - Do not tell users to decorate every domain-model property. - Do not require methods to be named exactly `ToItem` and `FromItem`. +- Do not require mapper classes or mapper methods to be `static`. - Do not teach lifecycle hooks as currently implemented behavior. - Do not use the old property-level converter signatures from stale docs. - Do not assume every converter mistake becomes a DynamoMapper diagnostic. diff --git a/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs b/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs new file mode 100644 index 0000000..4cc6544 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DependencyInjection/DynamoClientServiceCollectionExtensions.cs @@ -0,0 +1,44 @@ +using Amazon.DynamoDBv2; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LayeredCraft.DynamoMapper.Client.DependencyInjection; + +/// Provides dependency injection registration helpers for . +public static class DynamoClientServiceCollectionExtensions +{ + /// + /// Registers as a singleton and applies mapper configuration + /// through the returned builder. + /// + /// The service collection to update. + /// Applies mapper registrations to the builder. + /// The service collection for further chaining. + public static IServiceCollection AddDynamoClient( + this IServiceCollection services, + Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + var registrations = new List<(Type DtoType, Type MapperType)>(); + var builder = new DynamoClientServiceBuilder(services, registrations); + configure(builder); + + services.TryAddSingleton(serviceProvider => + { + var dynamoDbClient = + builder.AmazonDynamoDb ?? serviceProvider.GetRequiredService(); + var clientBuilder = new DynamoClientBuilder().WithAmazonDynamoDb(dynamoDbClient); + + foreach (var registration in registrations) + { + var mapper = serviceProvider.GetRequiredService(registration.MapperType); + clientBuilder.WithMapper(registration.DtoType, mapper); + } + + return clientBuilder.Build(); + }); + return services; + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs new file mode 100644 index 0000000..2324529 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClient.cs @@ -0,0 +1,193 @@ +using System.Collections.Immutable; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Client.Models; + +// ReSharper disable MemberCanBePrivate.Global + +namespace LayeredCraft.DynamoMapper.Client; + +/// +/// Provides typed convenience methods for reading and writing DynamoDB items through +/// registered mappers. +/// +public class DynamoClient +{ + private readonly ImmutableDictionary _mappers; + + /// Gets the underlying DynamoDB client used for all requests. + public IAmazonDynamoDB AmazonDynamoDb { get; } + + internal DynamoClient(ImmutableDictionary mappers, IAmazonDynamoDB dynamoDbClient) + { + ArgumentNullException.ThrowIfNull(dynamoDbClient); + + _mappers = mappers; + AmazonDynamoDb = dynamoDbClient; + } + + /// Gets the mapper registered for the specified DTO type. + /// The DTO type to retrieve a mapper for. + /// The mapper registered for . + /// + /// Thrown when no mapper has been registered for + /// . + /// + public IDynamoMapper GetMapper() + { + if (_mappers.TryGetValue(typeof(T), out var mapper)) + return (IDynamoMapper)mapper; + + throw new InvalidOperationException($"No mapper found for type {typeof(T)}"); + } + + /// Retrieves a single item by key and maps it to the specified DTO type. + /// The DTO type to map the item to. + /// The DynamoDB table name. + /// The primary key of the item to retrieve. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the mapped DTO when an item is found; otherwise, + /// . + /// + public async Task> GetItemAsync( + string tableName, + Dictionary key, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.GetItemAsync(tableName, key, cancellationToken); + var mappedItem = result.Item is null || result.Item.Count == 0 + ? default + : GetMapper().FromItem(result.Item); + + return new GetItemResponse(result, mappedItem); + } + + /// Saves a mapped DTO to the specified table. + /// The DTO type to write. + /// The DynamoDB table name. + /// The DTO instance to map and save. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the DynamoDB response together with a mapped item when attributes are + /// returned. + /// + public async Task PutItemAsync( + string tableName, + T item, + CancellationToken cancellationToken = default) + { + var mappedItem = GetMapper().ToItem(item); + return await AmazonDynamoDb.PutItemAsync(tableName, mappedItem, cancellationToken); + } + + /// Executes a put request and maps returned attributes to the specified DTO type. + /// The DTO type to map the returned attributes to. + /// The put request to execute. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the DynamoDB response together with a mapped item when the request + /// returns attributes; otherwise, . + /// + public async Task> PutItemAsync( + PutItemRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.PutItemAsync(request, cancellationToken); + var mappedItem = + result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + return new PutItemResponse(result, mappedItem); + } + + /// Deletes a single item by key from the specified table. + /// The DynamoDB table name. + /// The primary key of the item to delete. + /// The cancellation token for the asynchronous operation. + /// A task that completes when the delete request has finished. + public Task DeleteItemAsync( + string tableName, + Dictionary key, + CancellationToken cancellationToken = default) + => AmazonDynamoDb.DeleteItemAsync(tableName, key, cancellationToken); + + /// Executes a delete request and maps returned attributes to the specified DTO type. + /// The DTO type to map the returned attributes to. + /// The delete request to execute. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the DynamoDB response together with a mapped item when the request + /// returns attributes; otherwise, . + /// + public async Task> DeleteItemAsync( + DeleteItemRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.DeleteItemAsync(request, cancellationToken); + var mappedItem = + result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + return new DeleteItemResponse(result, mappedItem); + } + + /// Executes an update request and maps returned attributes to the specified DTO type. + /// The DTO type to map the returned attributes to. + /// The update request to execute. + /// The cancellation token for the asynchronous operation. + /// + /// A task that returns the DynamoDB response together with a mapped item when the request + /// returns attributes; otherwise, . + /// + public async Task> UpdateItemAsync( + UpdateItemRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.UpdateItemAsync(request, cancellationToken); + var mappedItem = + result.Attributes.Count == 0 ? default : GetMapper().FromItem(result.Attributes); + return new UpdateItemResponse(result, mappedItem); + } + + /// Executes a query request and maps each returned item to the specified DTO type. + /// The DTO type to map the query results to. + /// The query request to execute. + /// The cancellation token for the asynchronous operation. + /// A task that returns the mapped query results. + public async Task> QueryAsync( + QueryRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.QueryAsync(request, cancellationToken); + var mapper = GetMapper(); + var mappedItems = result.Items.Select(mapper.FromItem).ToList(); + return new QueryResponse(result, mappedItems); + } + + /// Executes a scan request and maps each returned item to the specified DTO type. + /// The DTO type to map the scan results to. + /// The scan request to execute. + /// The cancellation token for the asynchronous operation. + /// A task that returns the mapped scan results. + public async Task> ScanAsync( + ScanRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.ScanAsync(request, cancellationToken); + var mapper = GetMapper(); + var mappedItems = result.Items.Select(mapper.FromItem).ToList(); + return new ScanResponse(result, mappedItems); + } + + /// Executes a PartiQL statement and maps each returned item to the specified DTO type. + /// The DTO type to map the returned items to. + /// The PartiQL request to execute. + /// The cancellation token for the asynchronous operation. + /// A task that returns the mapped statement results. + public async Task> ExecuteStatementAsync( + ExecuteStatementRequest request, + CancellationToken cancellationToken = default) + { + var result = await AmazonDynamoDb.ExecuteStatementAsync(request, cancellationToken); + var mapper = GetMapper(); + var mappedItems = result.Items.Select(mapper.FromItem).ToList(); + return new ExecuteStatementResponse(result, mappedItems); + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs new file mode 100644 index 0000000..31bbfac --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientBuilder.cs @@ -0,0 +1,58 @@ +using System.Collections.Immutable; +using Amazon.DynamoDBv2; + +namespace LayeredCraft.DynamoMapper.Client; + +/// Builds a with registered mappers and a DynamoDB client. +public class DynamoClientBuilder +{ + private readonly Dictionary _mappers = new(); + private IAmazonDynamoDB? _dynamoDbClient; + + /// Registers the specified mapper instance for the DTO type. + /// The DTO type handled by the mapper. + /// The mapper instance to register. + /// The current builder instance. + public DynamoClientBuilder WithMapper(IDynamoMapper mapper) + { + ArgumentNullException.ThrowIfNull(mapper); + + _mappers[typeof(TDto)] = mapper; + return this; + } + + /// Registers a mapper for the specified DTO type. + /// The DTO type handled by the mapper. + /// The mapper type to instantiate and register. + /// The current builder instance. + public DynamoClientBuilder WithMapper() + where TMapper : class, IDynamoMapper, new() + => WithMapper(new TMapper()); + + /// Uses the specified DynamoDB client when building the . + /// The DynamoDB client instance to use. + /// The current builder instance. + public DynamoClientBuilder WithAmazonDynamoDb(IAmazonDynamoDB dynamoDbClient) + { + _dynamoDbClient = dynamoDbClient; + return this; + } + + /// Builds a from the configured mappers and DynamoDB client. + /// A configured instance. + public DynamoClient Build() + { + _dynamoDbClient ??= new AmazonDynamoDBClient(); + + return new DynamoClient(_mappers.ToImmutableDictionary(), _dynamoDbClient); + } + + internal DynamoClientBuilder WithMapper(Type dtoType, object mapper) + { + ArgumentNullException.ThrowIfNull(dtoType); + ArgumentNullException.ThrowIfNull(mapper); + + _mappers[dtoType] = mapper; + return this; + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs b/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs new file mode 100644 index 0000000..cdf6152 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/DynamoClientServiceBuilder.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using Amazon.DynamoDBv2; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LayeredCraft.DynamoMapper.Client; + +/// +/// Provides a fluent registration surface for configuring in a +/// dependency injection container. +/// +public sealed class DynamoClientServiceBuilder +{ + private readonly IList<(Type DtoType, Type MapperType)> _registrations; + + internal DynamoClientServiceBuilder( + IServiceCollection services, + IList<(Type DtoType, Type MapperType)> registrations) + { + ArgumentNullException.ThrowIfNull(services); + + Services = services; + _registrations = registrations; + } + + /// Gets the service collection being configured. + public IServiceCollection Services { get; } + + internal IAmazonDynamoDB? AmazonDynamoDb { get; private set; } + + /// Uses the specified DynamoDB client when building the . + /// The DynamoDB client instance to use. + /// The current registration builder. + public DynamoClientServiceBuilder WithAmazonDynamoDb(IAmazonDynamoDB dynamoDbClient) + { + ArgumentNullException.ThrowIfNull(dynamoDbClient); + + AmazonDynamoDb = dynamoDbClient; + return this; + } + + /// Registers a mapper that should be included in the built . + /// The DTO type handled by the mapper. + /// The mapper implementation type. + /// The current registration builder. + public DynamoClientServiceBuilder AddMapper() + where TMapper : class, IDynamoMapper + { + Services.TryAddSingleton(); + _registrations.Add((typeof(TDto), typeof(TMapper))); + return this; + } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs new file mode 100644 index 0000000..db046b7 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/IDynamoMapper.cs @@ -0,0 +1,18 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client; + +/// Maps a DTO to and from a DynamoDB item representation. +/// The DTO type handled by the mapper. +public interface IDynamoMapper +{ + /// Converts a DTO instance into a DynamoDB item. + /// The DTO instance to convert. + /// A DynamoDB item keyed by attribute name. + Dictionary ToItem(TDto source); + + /// Creates a DTO instance from a DynamoDB item. + /// The DynamoDB item to convert. + /// The DTO created from the item. + TDto FromItem(Dictionary item); +} diff --git a/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj new file mode 100644 index 0000000..b37a550 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/LayeredCraft.DynamoMapper.Client.csproj @@ -0,0 +1,16 @@ + + + + net10.0 + 14 + enable + enable + true + + + + + + + + diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs new file mode 100644 index 0000000..7d84170 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/DeleteItemResponse.cs @@ -0,0 +1,27 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB DeleteItem operation together with optional +/// mapped returned attributes. +/// +/// The DTO type produced from returned DynamoDB attributes. +/// +/// DynamoDB only returns attributes for DeleteItem when +/// requests old values. In all other cases +/// is . +/// +public class DeleteItemResponse : DeleteItemResponse +{ + internal DeleteItemResponse(DeleteItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Attributes = response.Attributes; + ConsumedCapacity = response.ConsumedCapacity; + ItemCollectionMetrics = response.ItemCollectionMetrics; + } + + /// Gets the returned attributes mapped to when present. + public T? MappedItem { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs new file mode 100644 index 0000000..00a59fb --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/ExecuteStatementResponse.cs @@ -0,0 +1,34 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB PartiQL ExecuteStatement operation together +/// with mapped DTO items. +/// +/// The DTO type produced from the returned DynamoDB items. +/// +/// This type provides the same general response context as +/// , including the raw statement +/// results, pagination state, and consumed capacity details, while also exposing +/// for typed access through a registered mapper. +/// +public class ExecuteStatementResponse : ExecuteStatementResponse +{ + internal ExecuteStatementResponse(ExecuteStatementResponse response, List mappedItems) + { + MappedItems = mappedItems; + Items = response.Items; + LastEvaluatedKey = response.LastEvaluatedKey; + NextToken = response.NextToken; + ConsumedCapacity = response.ConsumedCapacity; + } + + /// Gets the items returned by DynamoDB mapped to . + /// + /// This list corresponds to the raw items exposed by + /// , but each entry has been + /// projected into the typed DTO using the registered mapper. + /// + public List MappedItems { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs new file mode 100644 index 0000000..72fae05 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/GetItemResponse.cs @@ -0,0 +1,32 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB GetItem operation together with an optional +/// mapped DTO instance. +/// +/// The DTO type produced from the returned DynamoDB item. +/// +/// This type provides the same general response context as +/// , including consumed capacity details and +/// the raw DynamoDB item, while also exposing for typed access through a +/// registered mapper. +/// +public class GetItemResponse : GetItemResponse +{ + internal GetItemResponse(GetItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Item = response.Item; + ConsumedCapacity = response.ConsumedCapacity; + IsItemSet = response.IsItemSet; + } + + /// Gets the item returned by DynamoDB mapped to . + /// + /// This value is when the response does not contain an item or when + /// the mapped type is nullable and the mapper produces a null value. + /// + public T? MappedItem { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs new file mode 100644 index 0000000..d74c43e --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/PutItemResponse.cs @@ -0,0 +1,27 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB PutItem operation together with optional mapped +/// returned attributes. +/// +/// The DTO type produced from returned DynamoDB attributes. +/// +/// DynamoDB only returns attributes for PutItem when +/// requests ALL_OLD. In all other cases +/// is . +/// +public class PutItemResponse : PutItemResponse +{ + internal PutItemResponse(PutItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Attributes = response.Attributes; + ConsumedCapacity = response.ConsumedCapacity; + ItemCollectionMetrics = response.ItemCollectionMetrics; + } + + /// Gets the returned attributes mapped to when present. + public T? MappedItem { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs new file mode 100644 index 0000000..af2c336 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/QueryResponse.cs @@ -0,0 +1,32 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// Represents the result of a DynamoDB Query operation together with mapped DTO items. +/// The DTO type produced from the returned DynamoDB items. +/// +/// This type provides the same general response context as +/// , including the raw query items, result +/// counts, pagination state, and consumed capacity details, while also exposing +/// for typed access through a registered mapper. +/// +public class QueryResponse : QueryResponse +{ + internal QueryResponse(QueryResponse response, List mappedItems) + { + MappedItems = mappedItems; + Items = response.Items; + Count = response.Count; + LastEvaluatedKey = response.LastEvaluatedKey; + ScannedCount = response.ScannedCount; + ConsumedCapacity = response.ConsumedCapacity; + } + + /// Gets the items returned by DynamoDB mapped to . + /// + /// This list corresponds to the raw items exposed by + /// , but each entry has been projected + /// into the typed DTO using the registered mapper. + /// + public List MappedItems { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs new file mode 100644 index 0000000..ef2b972 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/ScanResponse.cs @@ -0,0 +1,32 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// Represents the result of a DynamoDB Scan operation together with mapped DTO items. +/// The DTO type produced from the returned DynamoDB items. +/// +/// This type provides the same general response context as +/// , including the raw scan items, result +/// counts, pagination state, and consumed capacity details, while also exposing +/// for typed access through a registered mapper. +/// +public class ScanResponse : ScanResponse +{ + internal ScanResponse(ScanResponse response, List mappedItems) + { + MappedItems = mappedItems; + Items = response.Items; + Count = response.Count; + LastEvaluatedKey = response.LastEvaluatedKey; + ScannedCount = response.ScannedCount; + ConsumedCapacity = response.ConsumedCapacity; + } + + /// Gets the items returned by DynamoDB mapped to . + /// + /// This list corresponds to the raw items exposed by + /// , but each entry has been projected + /// into the typed DTO using the registered mapper. + /// + public List MappedItems { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs b/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs new file mode 100644 index 0000000..ef12703 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/Models/UpdateItemResponse.cs @@ -0,0 +1,22 @@ +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Models; + +/// +/// Represents the result of a DynamoDB UpdateItem operation together with optional +/// mapped returned attributes. +/// +/// The DTO type produced from returned DynamoDB attributes. +public class UpdateItemResponse : UpdateItemResponse +{ + internal UpdateItemResponse(UpdateItemResponse response, T? mappedItem) + { + MappedItem = mappedItem; + Attributes = response.Attributes; + ConsumedCapacity = response.ConsumedCapacity; + ItemCollectionMetrics = response.ItemCollectionMetrics; + } + + /// Gets the returned attributes mapped to when present. + public T? MappedItem { get; } +} diff --git a/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs new file mode 100644 index 0000000..24681a7 --- /dev/null +++ b/src/LayeredCraft.DynamoMapper.Client/extensions/AttributeValueConverterExtensions.cs @@ -0,0 +1,229 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Amazon.DynamoDBv2.Model; + +namespace System; + +/// +/// Convenience extensions for converting common scalar values to +/// . +/// +public static class AttributeValueConverterExtensions +{ + extension(string? str) + { + /// Converts the string to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue() + => str is null ? new AttributeValue { NULL = true } : new AttributeValue { S = str }; + } + + extension(bool value) + { + /// Converts the boolean to a DynamoDB BOOL attribute. + public AttributeValue ToAttributeValue() => new() { BOOL = value }; + } + + extension(bool? value) + { + /// Converts the nullable boolean to a DynamoDB BOOL attribute or NULL attribute. + public AttributeValue ToAttributeValue() + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue { BOOL = value.Value }; + } + + extension(int num) + { + /// Converts the integer to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(int? num) + { + /// Converts the nullable integer to a DynamoDB number attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(long num) + { + /// Converts the long integer to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(long? num) + { + /// Converts the nullable long integer to a DynamoDB number attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(float num) + { + /// Converts the single-precision number to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(float? num) + { + /// + /// Converts the nullable single-precision number to a DynamoDB number attribute or NULL + /// attribute. + /// + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(double num) + { + /// Converts the double-precision number to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(double? num) + { + /// + /// Converts the nullable double-precision number to a DynamoDB number attribute or NULL + /// attribute. + /// + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(decimal num) + { + /// Converts the decimal number to a DynamoDB number attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => new() { N = num.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(decimal? num) + { + /// Converts the nullable decimal number to a DynamoDB number attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax("NumericFormat")] string? format = null) + => num is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + N = num.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(Guid value) + { + /// Converts the GUID to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.GuidFormat)] string format = "D") + => new() { S = value.ToString(format) }; + } + + extension(Guid? value) + { + /// Converts the nullable GUID to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.GuidFormat)] string format = "D") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue { S = value.Value.ToString(format) }; + } + + extension(DateTime value) + { + /// Converts the date and time to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => new() { S = value.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(DateTime? value) + { + /// Converts the nullable date and time to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + S = value.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(DateTimeOffset value) + { + /// Converts the date and time offset to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => new() { S = value.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(DateTimeOffset? value) + { + /// + /// Converts the nullable date and time offset to a DynamoDB string attribute or NULL + /// attribute. + /// + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.DateTimeFormat)] string format = "o") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + S = value.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } + + extension(TimeSpan value) + { + /// Converts the time span to a DynamoDB string attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.TimeSpanFormat)] string format = "c") + => new() { S = value.ToString(format, CultureInfo.InvariantCulture) }; + } + + extension(TimeSpan? value) + { + /// Converts the nullable time span to a DynamoDB string attribute or NULL attribute. + public AttributeValue ToAttributeValue( + [StringSyntax(StringSyntaxAttribute.TimeSpanFormat)] string format = "c") + => value is null + ? new AttributeValue { NULL = true } + : new AttributeValue + { + S = value.Value.ToString(format, CultureInfo.InvariantCulture), + }; + } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs new file mode 100644 index 0000000..013b5b9 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/AttributeValueConverterExtensionsTests.cs @@ -0,0 +1,290 @@ +using System.Globalization; +using Amazon.DynamoDBv2.Model; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class AttributeValueConverterExtensionsTests +{ + [Fact] + public void String_ToAttributeValue_ReturnsStringAttribute() + { + var attribute = "hello".ToAttributeValue(); + + attribute.Should().BeEquivalentTo(StringAttribute("hello")); + } + + [Fact] + public void NullableString_ToAttributeValue_ReturnsNullAttribute_WhenValueIsNull() + { + string? value = null; + + var attribute = value.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void Bool_ToAttributeValue_ReturnsBoolAttribute() + { + var attribute = true.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(BoolAttribute(true)); + } + + [Fact] + public void NullableBool_ToAttributeValue_ReturnsBoolOrNullAttribute() + { + bool? value = false; + bool? nullValue = null; + + var attribute = value.ToAttributeValue(); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(BoolAttribute(false)); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void Int_ToAttributeValue_UsesInvariantCultureAndFormatString() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 12345.ToAttributeValue("N0"); + + attribute.Should().BeEquivalentTo(NumberAttribute("12,345")); + } + + [Fact] + public void NullableInt_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + int? value = 255; + int? nullValue = null; + + var attribute = value.ToAttributeValue("X"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("FF")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void Long_ToAttributeValue_UsesInvariantCultureAndFormatString() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 123456789L.ToAttributeValue("N0"); + + attribute.Should().BeEquivalentTo(NumberAttribute("123,456,789")); + } + + [Fact] + public void NullableLong_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + long? value = 4095; + long? nullValue = null; + + var attribute = value.ToAttributeValue("X"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("FFF")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void Float_ToAttributeValue_UsesInvariantCulture() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 12.5f.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("12.5")); + } + + [Fact] + public void NullableFloat_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + float? value = 12.5f; + float? nullValue = null; + + var attribute = value.ToAttributeValue("0.00"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("12.50")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void Double_ToAttributeValue_UsesInvariantCulture() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 1234.5d.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("1234.5")); + } + + [Fact] + public void NullableDouble_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + double? value = 1234.5d; + double? nullValue = null; + + var attribute = value.ToAttributeValue("0.000"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("1234.500")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void Decimal_ToAttributeValue_UsesInvariantCulture() + { + using var _ = new CultureScope("de-DE"); + + var attribute = 1234.5m.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("1234.5")); + } + + [Fact] + public void NullableDecimal_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + decimal? value = 1234.5m; + decimal? nullValue = null; + + var attribute = value.ToAttributeValue("0.000"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(NumberAttribute("1234.500")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void Guid_ToAttributeValue_UsesDefaultDFormat() + { + var value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + + var attribute = value.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(StringAttribute("00112233-4455-6677-8899-aabbccddeeff")); + } + + [Fact] + public void NullableGuid_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + Guid? value = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff"); + Guid? nullValue = null; + + var attribute = value.ToAttributeValue("N"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(StringAttribute("00112233445566778899aabbccddeeff")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void DateTime_ToAttributeValue_UsesDefaultRoundTripFormat() + { + var value = new DateTime(2025, 4, 7, 12, 34, 56, DateTimeKind.Utc); + + var attribute = value.ToAttributeValue(); + + attribute + .Should() + .BeEquivalentTo(StringAttribute(value.ToString("o", CultureInfo.InvariantCulture))); + } + + [Fact] + public void NullableDateTime_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + DateTime? value = new DateTime(2025, 4, 7, 12, 34, 56, DateTimeKind.Utc); + DateTime? nullValue = null; + + var attribute = value.ToAttributeValue("yyyyMMdd"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(StringAttribute("20250407")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void DateTimeOffset_ToAttributeValue_UsesDefaultRoundTripFormat() + { + var value = new DateTimeOffset(2025, 4, 7, 12, 34, 56, TimeSpan.FromHours(2)); + + var attribute = value.ToAttributeValue(); + + attribute + .Should() + .BeEquivalentTo(StringAttribute(value.ToString("o", CultureInfo.InvariantCulture))); + } + + [Fact] + public void NullableDateTimeOffset_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + DateTimeOffset? value = new DateTimeOffset(2025, 4, 7, 12, 34, 56, TimeSpan.Zero); + DateTimeOffset? nullValue = null; + + var attribute = value.ToAttributeValue("yyyy-MM-dd zzz"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute.Should().BeEquivalentTo(StringAttribute("2025-04-07 +00:00")); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + [Fact] + public void TimeSpan_ToAttributeValue_UsesDefaultConstantFormat() + { + var value = TimeSpan.FromHours(1) + TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(3); + + var attribute = value.ToAttributeValue(); + + attribute + .Should() + .BeEquivalentTo(StringAttribute(value.ToString("c", CultureInfo.InvariantCulture))); + } + + [Fact] + public void NullableTimeSpan_ToAttributeValue_UsesFormatStringOrNullAttribute() + { + TimeSpan? value = + TimeSpan.FromHours(26) + TimeSpan.FromMinutes(3) + TimeSpan.FromSeconds(4); + TimeSpan? nullValue = null; + + var attribute = value.ToAttributeValue("g"); + var nullAttribute = nullValue.ToAttributeValue(); + + attribute + .Should() + .BeEquivalentTo( + StringAttribute(value.Value.ToString("g", CultureInfo.InvariantCulture))); + nullAttribute.Should().BeEquivalentTo(NullAttribute()); + } + + private static AttributeValue StringAttribute(string value) => new() { S = value }; + + private static AttributeValue NumberAttribute(string value) => new() { N = value }; + + private static AttributeValue BoolAttribute(bool value) => new() { BOOL = value }; + + private static AttributeValue NullAttribute() => new() { NULL = true }; + + private sealed class CultureScope : IDisposable + { + private readonly CultureInfo _originalCulture = CultureInfo.CurrentCulture; + private readonly CultureInfo _originalUiCulture = CultureInfo.CurrentUICulture; + + public CultureScope(string name) + { + var culture = CultureInfo.GetCultureInfo(name); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } + + public void Dispose() + { + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUiCulture; + } + } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs new file mode 100644 index 0000000..439e073 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoClientTests.cs @@ -0,0 +1,368 @@ +using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Client.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class DynamoClientTests(DynamoDbFixture fixture) : IClassFixture +{ + private readonly DynamoClient _client = new DynamoClientBuilder() + .WithAmazonDynamoDb(fixture.Client) + .WithMapper() + .WithMapper() + .WithMapper() + .Build(); + + [Fact] + public async Task GetItemAsync_UserProfile_ReturnsSeededItem() + { + var expected = TestDataSamples.UserProfiles[0]; + + var item = await _client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(expected.Pk, expected.Sk), + TestContext.Current.CancellationToken); + + item.MappedItem.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task QueryAsync_ProjectRecord_ReturnsSeededProjectsForOwner() + { + var expected = TestDataSamples.ProjectRecords[0]; + + var response = await _client.QueryAsync( + new QueryRequest + { + TableName = DynamoDbFixture.TableName, + KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = expected.Pk.ToAttributeValue(), + [":skPrefix"] = "PROJECT#".ToAttributeValue(), + }, + }, + TestContext.Current.CancellationToken); + + response.MappedItems.Should().ContainEquivalentOf(expected); + } + + [Fact] + public async Task ScanAsync_UserProfile_ReturnsSeededProfiles() + { + var response = await _client.ScanAsync( + new ScanRequest + { + TableName = DynamoDbFixture.TableName, + FilterExpression = "entityType = :entityType", + ExpressionAttributeValues = + new Dictionary + { + [":entityType"] = "UserProfile".ToAttributeValue(), + }, + }, + TestContext.Current.CancellationToken); + + response.MappedItems.Should().BeEquivalentTo(TestDataSamples.UserProfiles); + } + + [Fact] + public async Task ExecuteStatementAsync_UserProfile_ReturnsSeededProfiles() + { + var response = await _client.ExecuteStatementAsync( + new ExecuteStatementRequest + { + Statement = $""" + SELECT * FROM "{DynamoDbFixture.TableName}" + WHERE entityType = ? + """, + Parameters = ["UserProfile".ToAttributeValue()], + }, + TestContext.Current.CancellationToken); + + response.MappedItems.Should().BeEquivalentTo(TestDataSamples.UserProfiles); + } + + [Fact] + public async Task ExecuteStatementAsync_UserProfile_WithKeyFilter_ReturnsMappedItem() + { + var expected = TestDataSamples.UserProfiles[0]; + + var response = await _client.ExecuteStatementAsync( + new ExecuteStatementRequest + { + Statement = $""" + SELECT * FROM "{DynamoDbFixture.TableName}" + WHERE pk = ? AND sk = ? + """, + Parameters = [expected.Pk.ToAttributeValue(), expected.Sk.ToAttributeValue()], + }, + TestContext.Current.CancellationToken); + + response.MappedItems.Should().BeEquivalentTo([expected]); + } + + [Fact] + public async Task PutItemAsync_ThenDeleteItemAsync_PersistsAndRemovesItem() + { + var item = new TaskRecord + { + Pk = "PROJECT#p-9999", + Sk = "TASK#t-9999", + EntityType = "TaskRecord", + TaSkId = "t-9999", + ProjectId = "p-9999", + AssignedUserId = "u-1001", + Title = "Verify client put", + Notes = "Inserted by integration test.", + EstimateHours = 2.5m, + Completed = false, + Order = 99, + CreatedAt = "2025-04-06T10:00:00Z", + DueAt = "2025-04-07T10:00:00Z", + Checklist = + [ + new TaSkChecklistItem { Text = "Write item", Done = true }, + new TaSkChecklistItem { Text = "Read item", Done = false }, + ], + Metadata = new TaSkMetadata { Color = "blue", BlockedBy = null }, + }; + + await _client.PutItemAsync( + DynamoDbFixture.TableName, + item, + TestContext.Current.CancellationToken); + + var persisted = await _client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(item.Pk, item.Sk), + TestContext.Current.CancellationToken); + + persisted.MappedItem.Should().BeEquivalentTo(item); + + await _client.DeleteItemAsync( + DynamoDbFixture.TableName, + CreateKey(item.Pk, item.Sk), + TestContext.Current.CancellationToken); + + var deleted = await _client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(item.Pk, item.Sk), + TestContext.Current.CancellationToken); + + deleted.MappedItem.Should().BeNull(); + } + + [Fact] + public async Task PutItemAsync_UserProfile_WithAllOld_ReturnsMappedOldItem() + { + var original = new UserProfile + { + Pk = "USER#u-9998", + Sk = "PROFILE#u-9998", + EntityType = "UserProfile", + UserId = "u-9998", + Email = "original@example.com", + DisplayName = "Original User", + Age = 41, + IsActive = true, + AccountBalance = 10.25m, + CreatedAt = "2025-04-01T00:00:00Z", + LastLoginEpoch = 1743465600, + Tags = ["temp", "original"], + Preferences = + new UserPreferences + { + Theme = "dark", NotificationsEnabled = true, Language = "en-US", + }, + LoginHistory = + [ + new LoginHistoryEntry + { + At = "2025-04-01T00:00:00Z", IpAddress = "203.0.113.99", + }, + ], + ProfilePhoto = [9, 9, 9], + }; + var replacement = new UserProfile + { + Pk = original.Pk, + Sk = original.Sk, + EntityType = original.EntityType, + UserId = original.UserId, + Email = "updated@example.com", + DisplayName = "Updated User", + Age = original.Age, + IsActive = original.IsActive, + AccountBalance = original.AccountBalance, + CreatedAt = original.CreatedAt, + LastLoginEpoch = original.LastLoginEpoch, + Tags = original.Tags, + Preferences = original.Preferences, + LoginHistory = original.LoginHistory, + ProfilePhoto = original.ProfilePhoto, + }; + + await _client.PutItemAsync( + DynamoDbFixture.TableName, + original, + TestContext.Current.CancellationToken); + + var response = await _client.PutItemAsync( + new PutItemRequest + { + TableName = DynamoDbFixture.TableName, + Item = _client.GetMapper().ToItem(replacement), + ReturnValues = "ALL_OLD", + }, + TestContext.Current.CancellationToken); + + response.MappedItem.Should().BeEquivalentTo(original); + + await _client.DeleteItemAsync( + DynamoDbFixture.TableName, + CreateKey(original.Pk, original.Sk), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task UpdateItemAsync_TaskRecord_ReturnsMappedUpdatedItem() + { + var existing = TestDataSamples.TaskRecords[0]; + var expected = new TaskRecord + { + Pk = existing.Pk, + Sk = existing.Sk, + EntityType = existing.EntityType, + TaSkId = existing.TaSkId, + ProjectId = existing.ProjectId, + AssignedUserId = existing.AssignedUserId, + Title = existing.Title, + Notes = "Updated by integration test.", + EstimateHours = existing.EstimateHours, + Completed = false, + Order = existing.Order, + CreatedAt = existing.CreatedAt, + DueAt = existing.DueAt, + Checklist = existing.Checklist, + Metadata = existing.Metadata, + }; + + var updated = await _client.UpdateItemAsync( + new UpdateItemRequest + { + TableName = DynamoDbFixture.TableName, + Key = CreateKey(existing.Pk, existing.Sk), + UpdateExpression = "SET notes = :notes, completed = :completed", + ExpressionAttributeValues = new Dictionary + { + [":notes"] = "Updated by integration test.".ToAttributeValue(), + [":completed"] = false.ToAttributeValue(), + }, + ReturnValues = "ALL_NEW", + }, + TestContext.Current.CancellationToken); + + updated.MappedItem.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task DeleteItemAsync_TaskRecord_WithAllOld_ReturnsMappedDeletedItem() + { + var existing = new TaskRecord + { + Pk = "PROJECT#p-9998", + Sk = "TASK#t-9998", + EntityType = "TaskRecord", + TaSkId = "t-9998", + ProjectId = "p-9998", + AssignedUserId = "u-1001", + Title = "Temporary task", + Notes = "Delete me", + EstimateHours = 1.5m, + Completed = false, + Order = 1, + CreatedAt = "2025-04-01T00:00:00Z", + DueAt = "2025-04-02T00:00:00Z", + Checklist = [new TaSkChecklistItem { Text = "One", Done = false }], + Metadata = new TaSkMetadata { Color = "green", BlockedBy = null }, + }; + + await _client.PutItemAsync( + DynamoDbFixture.TableName, + existing, + TestContext.Current.CancellationToken); + + var deleted = await _client.DeleteItemAsync( + new DeleteItemRequest + { + TableName = DynamoDbFixture.TableName, + Key = CreateKey(existing.Pk, existing.Sk), + ReturnValues = "ALL_OLD", + }, + TestContext.Current.CancellationToken); + + deleted.MappedItem.Should().BeEquivalentTo(existing); + } + + [Fact] + public async Task AddDynamoClient_ResolvesWorkingClient() + { + var services = new ServiceCollection(); + services.AddSingleton(fixture.Client); + + services.AddDynamoClient(builder => + { + builder.AddMapper(); + builder.AddMapper(); + builder.AddMapper(); + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService(); + var expected = TestDataSamples.UserProfiles[1]; + + var item = await client.GetItemAsync( + DynamoDbFixture.TableName, + CreateKey(expected.Pk, expected.Sk), + TestContext.Current.CancellationToken); + + item.MappedItem.Should().BeEquivalentTo(expected); + } + + [Fact] + public async Task AddDynamoClient_WithAmazonDynamoDbOverride_ResolvesWorkingClient() + { + var services = new ServiceCollection(); + + services.AddDynamoClient(builder => + { + builder.WithAmazonDynamoDb(fixture.Client); + builder.AddMapper(); + builder.AddMapper(); + builder.AddMapper(); + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService(); + var expected = TestDataSamples.ProjectRecords[1]; + + var response = await client.QueryAsync( + new QueryRequest + { + TableName = DynamoDbFixture.TableName, + KeyConditionExpression = "pk = :pk AND begins_with(sk, :skPrefix)", + ExpressionAttributeValues = new Dictionary + { + [":pk"] = expected.Pk.ToAttributeValue(), + [":skPrefix"] = "PROJECT#".ToAttributeValue(), + }, + }, + TestContext.Current.CancellationToken); + + response.MappedItems.Should().ContainEquivalentOf(expected); + } + + private static Dictionary CreateKey(string pk, string sk) + => new() { ["pk"] = pk.ToAttributeValue(), ["sk"] = sk.ToAttributeValue() }; +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs new file mode 100644 index 0000000..a23145a --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/DynamoDbFixture.cs @@ -0,0 +1,89 @@ +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using Testcontainers.DynamoDb; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class DynamoDbFixture : IAsyncLifetime +{ + private static readonly UserProfileMapper UserProfiles = new(); + private static readonly ProjectRecordMapper ProjectRecords = new(); + private static readonly TaskRecordMapper TaskRecords = new(); + + public const string TableName = "test-data"; + + public readonly DynamoDbContainer Container = + new DynamoDbBuilder("amazon/dynamodb-local:latest").Build(); + + public static CancellationToken CancellationToken => TestContext.Current.CancellationToken; + + public IAmazonDynamoDB Client + { + get + { + field ??= new AmazonDynamoDBClient( + new AmazonDynamoDBConfig { ServiceURL = Container.GetConnectionString() }); + return field; + } + } + + public async ValueTask DisposeAsync() => await Container.StopAsync(CancellationToken); + + public async ValueTask InitializeAsync() + { + await Container.StartAsync(CancellationToken); + + await Client.CreateTableAsync( + new CreateTableRequest + { + TableName = TableName, + BillingMode = BillingMode.PAY_PER_REQUEST, + AttributeDefinitions = + [ + new AttributeDefinition("pk", ScalarAttributeType.S), + new AttributeDefinition("sk", ScalarAttributeType.S), + ], + KeySchema = + [ + new KeySchemaElement("pk", KeyType.HASH), + new KeySchemaElement("sk", KeyType.RANGE), + ], + }, + CancellationToken); + + var writeRequests = + TestDataSamples + .UserProfiles + .Select(UserProfiles.ToItem) + .Concat(TestDataSamples.ProjectRecords.Select(ProjectRecords.ToItem)) + .Concat(TestDataSamples.TaskRecords.Select(TaskRecords.ToItem)) + .Select(item => new WriteRequest { PutRequest = new PutRequest { Item = item } }) + .ToArray(); + + foreach (var batch in writeRequests.Chunk(25)) + await WriteBatchUntilCompleteAsync(batch); + } + + private async Task WriteBatchUntilCompleteAsync(IReadOnlyCollection batch) + { + var pending = batch.ToArray(); + + while (pending.Length > 0) + { + var response = await Client.BatchWriteItemAsync( + new BatchWriteItemRequest + { + RequestItems = + new Dictionary> + { + [TableName] = pending.ToList(), + }, + }, + CancellationToken); + + pending = response.UnprocessedItems.TryGetValue(TableName, out var unprocessed) + ? unprocessed.ToArray() + : []; + } + } +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj new file mode 100644 index 0000000..dfecfd2 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/LayeredCraft.DynamoMapper.Client.Tests.csproj @@ -0,0 +1,39 @@ + + + + net10.0 + enable + Exe + enable + false + true + + + + + + + + + + + PreserveNewest + + + + + + + + + + + diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs new file mode 100644 index 0000000..90da8a7 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataMappers.cs @@ -0,0 +1,28 @@ +using Amazon.DynamoDBv2.Model; +using LayeredCraft.DynamoMapper.Runtime; + +namespace LayeredCraft.DynamoMapper.Client.Tests; + +[DynamoMapper] +public partial class UserProfileMapper : IDynamoMapper +{ + public partial Dictionary ToItem(UserProfile source); + + public partial UserProfile FromItem(Dictionary item); +} + +[DynamoMapper] +public partial class ProjectRecordMapper : IDynamoMapper +{ + public partial Dictionary ToItem(ProjectRecord source); + + public partial ProjectRecord FromItem(Dictionary item); +} + +[DynamoMapper] +public partial class TaskRecordMapper : IDynamoMapper +{ + public partial Dictionary ToItem(TaskRecord source); + + public partial TaskRecord FromItem(Dictionary item); +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs new file mode 100644 index 0000000..0c2193e --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/TestDataModels.cs @@ -0,0 +1,308 @@ +namespace LayeredCraft.DynamoMapper.Client.Tests; + +public sealed class UserProfile +{ + public required string Pk { get; init; } + + public required string Sk { get; init; } + + public required string EntityType { get; init; } + + public required string UserId { get; init; } + + public required string Email { get; init; } + + public required string DisplayName { get; init; } + + public int Age { get; init; } + + public bool IsActive { get; init; } + + public decimal AccountBalance { get; init; } + + public required string CreatedAt { get; init; } + + public long LastLoginEpoch { get; init; } + + public required List Tags { get; init; } + + public required UserPreferences Preferences { get; init; } + + public required List LoginHistory { get; init; } + + public required byte[] ProfilePhoto { get; init; } +} + +public sealed class ProjectRecord +{ + public required string Pk { get; init; } + + public required string Sk { get; init; } + + public required string EntityType { get; init; } + + public required string ProjectId { get; init; } + + public required string OwnerUserId { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public decimal Budget { get; init; } + + public bool IsArchived { get; init; } + + public int Priority { get; init; } + + public required string StartDate { get; init; } + + public required string DueDate { get; init; } + + public required List Labels { get; init; } + + public required ProjectSettings Settings { get; init; } + + public required ProjectMetrics Metrics { get; init; } +} + +public sealed class TaskRecord +{ + public required string Pk { get; init; } + + public required string Sk { get; init; } + + public required string EntityType { get; init; } + + public required string TaSkId { get; init; } + + public required string ProjectId { get; init; } + + public required string AssignedUserId { get; init; } + + public required string Title { get; init; } + + public required string Notes { get; init; } + + public decimal EstimateHours { get; init; } + + public bool Completed { get; init; } + + public int Order { get; init; } + + public required string CreatedAt { get; init; } + + public required string DueAt { get; init; } + + public required List Checklist { get; init; } + + public required TaSkMetadata Metadata { get; init; } +} + +public sealed class UserPreferences +{ + public required string Theme { get; init; } + + public bool NotificationsEnabled { get; init; } + + public required string Language { get; init; } +} + +public sealed class LoginHistoryEntry +{ + public required string At { get; init; } + + public required string IpAddress { get; init; } +} + +public sealed class ProjectSettings +{ + public required string Visibility { get; init; } + + public bool AllowGuestComments { get; init; } +} + +public sealed class ProjectMetrics +{ + public int TaSkCount { get; init; } + + public int CompletedTaSkCount { get; init; } +} + +public sealed class TaSkChecklistItem +{ + public required string Text { get; init; } + + public bool Done { get; init; } +} + +public sealed class TaSkMetadata +{ + public required string Color { get; init; } + + public string? BlockedBy { get; init; } +} + +public static class TestDataSamples +{ + public static IReadOnlyList UserProfiles { get; } = + [ + new() + { + Pk = "USER#u-1001", + Sk = "PROFILE#u-1001", + EntityType = "UserProfile", + UserId = "u-1001", + Email = "alex.carter@example.com", + DisplayName = "Alex Carter", + Age = 34, + IsActive = true, + AccountBalance = 1520.75m, + CreatedAt = "2025-01-10T09:15:00Z", + LastLoginEpoch = 1739529600, + Tags = ["admin", "beta", "us-east-1"], + Preferences = + new UserPreferences + { + Theme = "dark", NotificationsEnabled = true, Language = "en-US", + }, + LoginHistory = + [ + new LoginHistoryEntry + { + At = "2025-02-11T08:00:00Z", IpAddress = "203.0.113.10", + }, + new LoginHistoryEntry + { + At = "2025-02-12T18:45:00Z", IpAddress = "203.0.113.11", + }, + ], + ProfilePhoto = [1, 2, 3, 4, 5], + }, + new() + { + Pk = "USER#u-1002", + Sk = "PROFILE#u-1002", + EntityType = "UserProfile", + UserId = "u-1002", + Email = "maya.chen@example.com", + DisplayName = "Maya Chen", + Age = 29, + IsActive = false, + AccountBalance = 87.40m, + CreatedAt = "2024-11-03T14:20:00Z", + LastLoginEpoch = 1738771200, + Tags = ["designer", "trial"], + Preferences = + new UserPreferences + { + Theme = "light", NotificationsEnabled = false, Language = "en-GB", + }, + LoginHistory = + [ + new LoginHistoryEntry + { + At = "2025-01-28T12:15:00Z", IpAddress = "198.51.100.25", + }, + new LoginHistoryEntry + { + At = "2025-02-05T07:32:00Z", IpAddress = "198.51.100.44", + }, + ], + ProfilePhoto = [10, 20, 30, 40], + }, + ]; + + public static IReadOnlyList ProjectRecords { get; } = + [ + new() + { + Pk = "USER#u-1001", + Sk = "PROJECT#p-2001", + EntityType = "ProjectRecord", + ProjectId = "p-2001", + OwnerUserId = "u-1001", + Name = "Apollo Migration", + Description = "Move customer workflows to the new platform.", + Budget = 125000.00m, + IsArchived = false, + Priority = 1, + StartDate = "2025-02-01", + DueDate = "2025-06-30", + Labels = ["migration", "high-priority", "enterprise"], + Settings = + new ProjectSettings { Visibility = "private", AllowGuestComments = false }, + Metrics = new ProjectMetrics { TaSkCount = 18, CompletedTaSkCount = 7 }, + }, + new() + { + Pk = "USER#u-1002", + Sk = "PROJECT#p-2002", + EntityType = "ProjectRecord", + ProjectId = "p-2002", + OwnerUserId = "u-1002", + Name = "Website Refresh", + Description = "Update marketing pages and design tokens.", + Budget = 18000.50m, + IsArchived = false, + Priority = 2, + StartDate = "2025-03-15", + DueDate = "2025-05-01", + Labels = ["design", "marketing"], + Settings = + new ProjectSettings { Visibility = "team", AllowGuestComments = true }, + Metrics = new ProjectMetrics { TaSkCount = 9, CompletedTaSkCount = 3 }, + }, + ]; + + public static IReadOnlyList TaskRecords { get; } = + [ + new() + { + Pk = "PROJECT#p-2001", + Sk = "TASK#t-3001", + EntityType = "TaskRecord", + TaSkId = "t-3001", + ProjectId = "p-2001", + AssignedUserId = "u-1001", + Title = "Audit existing integrations", + Notes = "Document external dependencies and rate limits.", + EstimateHours = 6.5m, + Completed = true, + Order = 1, + CreatedAt = "2025-02-02T10:00:00Z", + DueAt = "2025-02-05T17:00:00Z", + Checklist = + [ + new TaSkChecklistItem { Text = "List current providers", Done = true }, + new TaSkChecklistItem { Text = "Capture auth mechanisms", Done = true }, + ], + Metadata = new TaSkMetadata { Color = "green", BlockedBy = null }, + }, + new() + { + Pk = "PROJECT#p-2002", + Sk = "TASK#t-3002", + EntityType = "TaskRecord", + TaSkId = "t-3002", + ProjectId = "p-2002", + AssignedUserId = "u-1002", + Title = "Create homepage mockups", + Notes = "Deliver desktop and mobile variants for review.", + EstimateHours = 12.0m, + Completed = false, + Order = 2, + CreatedAt = "2025-03-16T09:30:00Z", + DueAt = "2025-03-20T16:00:00Z", + Checklist = + [ + new TaSkChecklistItem { Text = "Collect brand assets", Done = true }, + new TaSkChecklistItem { Text = "Draft hero section", Done = false }, + ], + Metadata = new TaSkMetadata + { + Color = "orange", BlockedBy = "Awaiting stakeholder feedback", + }, + }, + ]; +} diff --git a/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json b/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json new file mode 100644 index 0000000..c2f8426 --- /dev/null +++ b/test/LayeredCraft.DynamoMapper.Client.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +}