├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── actions │ └── dotnet │ │ └── action.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── build.yml │ ├── changelog.config │ ├── changelog.yml │ ├── combine-prs.yml │ ├── dotnet-file.yml │ ├── includes.yml │ ├── pages.yml │ ├── publish.yml │ └── triage.yml ├── .gitignore ├── .netconfig ├── Directory.Build.rsp ├── Gemfile ├── TableStorage.sln ├── _config.yml ├── assets ├── css │ └── style.scss └── img │ ├── document.png │ ├── entity.png │ ├── icon-32.png │ ├── icon.svg │ └── tablestorage.png ├── changelog.md ├── license.txt ├── readme.md └── src ├── Directory.Build.props ├── Directory.Build.targets ├── Directory.props ├── Directory.targets ├── SponsorLink ├── Analyzer │ ├── Analyzer.csproj │ ├── GraceApiAnalyzer.cs │ ├── Properties │ │ └── launchSettings.json │ ├── StatusReportingAnalyzer.cs │ ├── StatusReportingGenerator.cs │ └── buildTransitive │ │ └── SponsorableLib.targets ├── Directory.Build.props ├── Directory.Build.targets ├── Library │ ├── Library.csproj │ ├── MyClass.cs │ ├── Resources.resx │ └── readme.md ├── SponsorLink.Analyzer.Tests.targets ├── SponsorLink.Analyzer.targets ├── SponsorLink │ ├── AnalyzerOptionsExtensions.cs │ ├── AppDomainDictionary.cs │ ├── DiagnosticsManager.cs │ ├── Resources.es-AR.resx │ ├── Resources.es.resx │ ├── Resources.resx │ ├── SponsorLink.cs │ ├── SponsorLink.csproj │ ├── SponsorLinkAnalyzer.cs │ ├── SponsorManifest.cs │ ├── SponsorStatus.cs │ ├── SponsorableLib.targets │ ├── Tracing.cs │ ├── buildTransitive │ │ └── Devlooped.Sponsors.targets │ └── sponsorable.md ├── SponsorLinkAnalyzer.sln ├── Tests │ ├── .netconfig │ ├── AnalyzerTests.cs │ ├── Attributes.cs │ ├── Extensions.cs │ ├── JsonOptions.cs │ ├── Resources.Designer.cs │ ├── Resources.resx │ ├── Sample.cs │ ├── SponsorManifestTests.cs │ ├── SponsorableManifest.cs │ ├── Tests.csproj │ └── keys │ │ ├── kzu.key │ │ ├── kzu.key.jwk │ │ ├── kzu.key.txt │ │ ├── kzu.pub │ │ ├── kzu.pub.jwk │ │ ├── kzu.pub.txt │ │ └── sponsorlink.jwt ├── jwk.ps1 └── readme.md ├── TableStorage.Bson.Source ├── Devlooped.TableStorage.Bson.Source.targets ├── TableStorage.Bson.Source.csproj └── readme.md ├── TableStorage.Bson ├── BsonDocumentSerializer.cs ├── TableStorage.Bson.csproj ├── Visibility.cs └── readme.md ├── TableStorage.CodeAnalysis ├── Devlooped.TableStorage.targets ├── Properties │ └── launchSettings.json └── TableStorage.CodeAnalysis.csproj ├── TableStorage.Memory ├── ConstantReducer.cs ├── MemoryPartition.cs ├── MemoryPartition`1.cs ├── MemoryRepository.cs ├── MemoryRepository`1.cs └── TableStorage.Memory.csproj ├── TableStorage.MessagePack.Source ├── Devlooped.TableStorage.MessagePack.Source.targets ├── TableStorage.MessagePack.Source.csproj └── readme.md ├── TableStorage.MessagePack ├── MessagePackDocumentSerializer.cs ├── TableStorage.MessagePack.csproj ├── Visibility.cs └── readme.md ├── TableStorage.Newtonsoft.Source ├── Devlooped.TableStorage.Newtonsoft.Source.targets ├── TableStorage.Newtonsoft.Source.csproj └── readme.md ├── TableStorage.Newtonsoft ├── JsonDocumentSerializer.cs ├── TableStorage.Newtonsoft.csproj ├── Visibility.cs └── readme.md ├── TableStorage.Protobuf.Source ├── Devlooped.TableStorage.Protobuf.Source.targets ├── TableStorage.Protobuf.Source.csproj └── readme.md ├── TableStorage.Protobuf ├── DateTimeOffsetSurrogate.cs ├── ProtobufDocumentSerializer.cs ├── TableStorage.Protobuf.csproj ├── Visibility.cs ├── message.proto └── readme.md ├── TableStorage.Source ├── Devlooped.TableStorage.Source.targets ├── TableStorage.Source.csproj └── readme.md ├── TableStorage ├── AttributedDocumentRepository`1.cs ├── AttributedTableRepository`1.cs ├── DocumentPartition.cs ├── DocumentPartition`1.cs ├── DocumentRepository.cs ├── DocumentRepository`1.cs ├── DocumentSerializer.cs ├── EntityPropertiesMapper.cs ├── ExpressionExtensions.cs ├── FilterExpressionFixer.cs ├── Http.cs ├── IBinaryDocumentSerializer.cs ├── IDocumentEntity.cs ├── IDocumentPartition`1.cs ├── IDocumentRepository`1.cs ├── IDocumentSerializer.cs ├── IDocumentTimestamp.cs ├── IQueryableExtensions.cs ├── IStringDocumentSerializer.cs ├── ITablePartition`1.cs ├── ITableRepository`1.cs ├── ITableStoragePartition`1.cs ├── ITableStorage`1.cs ├── NonMutatingAttribute.cs ├── PartitionKeyAttribute.cs ├── RowKeyAttribute.cs ├── System │ └── Chunk.cs ├── TableAttribute.cs ├── TableConnection.cs ├── TableEntityPartition.cs ├── TableEntityRepository.cs ├── TablePartition.cs ├── TablePartition`1.cs ├── TableRepository.cs ├── TableRepositoryQuery`1.cs ├── TableRepository`1.cs ├── TableStorage.Source.csproj ├── TableStorage.csproj ├── TableStorage.sln ├── TableStorageAttribute.cs ├── TableStorageExtensions.cs ├── UpdateStrategy.cs ├── Visibility.cs └── readme.md ├── Tests ├── .editorconfig ├── .netconfig ├── Books.csv ├── DocumentRepositoryTests.cs ├── MemoryTests.cs ├── QueryTests.cs ├── QueryableExtensions.cs ├── RepositoryTests.cs ├── Sample.cs ├── System │ └── Collections │ │ └── Generic │ │ └── IAsyncEnumerableExtensions.cs ├── Tests.csproj └── xunit.runner.json ├── icon.png ├── kzu.snk └── nuget.config /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome:http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Don't use tabs for indentation. 7 | [*] 8 | indent_style = space 9 | # (Please don't specify an indent_size here; that has too many unintended consequences.) 10 | 11 | # Code files 12 | [*.{cs,csx,vb,vbx}] 13 | indent_size = 4 14 | 15 | # Xml project files 16 | [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj,msbuildproj,props,targets}] 17 | indent_size = 2 18 | 19 | # Xml config files 20 | [*.{ruleset,config,nuspec,resx,vsixmanifest,vsct}] 21 | indent_size = 2 22 | 23 | # YAML files 24 | [*.{yaml,yml}] 25 | indent_size = 2 26 | 27 | # JSON files 28 | [*.json] 29 | indent_size = 2 30 | 31 | # Dotnet code style settings: 32 | [*.{cs,vb}] 33 | # Sort using and Import directives with System.* appearing first 34 | dotnet_sort_system_directives_first = true 35 | # Avoid "this." and "Me." if not necessary 36 | dotnet_style_qualification_for_field = false:suggestion 37 | dotnet_style_qualification_for_property = false:suggestion 38 | dotnet_style_qualification_for_method = false:suggestion 39 | dotnet_style_qualification_for_event = false:suggestion 40 | 41 | # Use language keywords instead of framework type names for type references 42 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 43 | dotnet_style_predefined_type_for_member_access = true:suggestion 44 | 45 | # Suggest more modern language features when available 46 | dotnet_style_object_initializer = true:suggestion 47 | dotnet_style_collection_initializer = true:suggestion 48 | dotnet_style_coalesce_expression = true:suggestion 49 | dotnet_style_null_propagation = true:suggestion 50 | dotnet_style_explicit_tuple_names = true:suggestion 51 | 52 | # CSharp code style settings: 53 | 54 | # IDE0040: Add accessibility modifiers 55 | dotnet_style_require_accessibility_modifiers = omit_if_default:error 56 | 57 | # IDE0040: Add accessibility modifiers 58 | dotnet_diagnostic.IDE0040.severity = error 59 | 60 | [*.cs] 61 | # Top-level files are definitely OK 62 | csharp_using_directive_placement = outside_namespace:silent 63 | csharp_style_namespace_declarations = block_scoped:silent 64 | csharp_prefer_simple_using_statement = true:suggestion 65 | csharp_prefer_braces = true:silent 66 | 67 | # Prefer "var" everywhere 68 | csharp_style_var_for_built_in_types = true:suggestion 69 | csharp_style_var_when_type_is_apparent = true:suggestion 70 | csharp_style_var_elsewhere = true:suggestion 71 | 72 | # Prefer method-like constructs to have an expression-body 73 | csharp_style_expression_bodied_methods = true:none 74 | csharp_style_expression_bodied_constructors = true:none 75 | csharp_style_expression_bodied_operators = true:none 76 | 77 | # Prefer property-like constructs to have an expression-body 78 | csharp_style_expression_bodied_properties = true:none 79 | csharp_style_expression_bodied_indexers = true:none 80 | csharp_style_expression_bodied_accessors = true:none 81 | 82 | # Suggest more modern language features when available 83 | csharp_style_pattern_matching_over_is_with_cast_check = true:error 84 | csharp_style_pattern_matching_over_as_with_null_check = true:error 85 | csharp_style_inlined_variable_declaration = true:suggestion 86 | csharp_style_throw_expression = true:suggestion 87 | csharp_style_conditional_delegate_call = true:suggestion 88 | 89 | # Newline settings 90 | csharp_new_line_before_open_brace = all 91 | csharp_new_line_before_else = true 92 | csharp_new_line_before_catch = true 93 | csharp_new_line_before_finally = true 94 | csharp_new_line_before_members_in_object_initializers = true 95 | csharp_new_line_before_members_in_anonymous_types = true 96 | 97 | # Test settings 98 | [**/*Tests*/**{.cs,.vb}] 99 | # xUnit1013: Public method should be marked as test. Allows using records as test classes 100 | dotnet_diagnostic.xUnit1013.severity = none 101 | 102 | # CS9113: Parameter is unread (usually, ITestOutputHelper) 103 | dotnet_diagnostic.CS9113.severity = none 104 | 105 | # Default severity for analyzer diagnostics with category 'Style' 106 | dotnet_analyzer_diagnostic.category-Style.severity = none 107 | 108 | # VSTHRD200: Use "Async" suffix for async methods 109 | dotnet_diagnostic.VSTHRD200.severity = none 110 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # normalize by default 2 | * text=auto encoding=UTF-8 3 | *.sh text eol=lf 4 | *.sbn eol=lf 5 | 6 | # These are windows specific files which we may as well ensure are 7 | # always crlf on checkout 8 | *.bat text eol=crlf 9 | *.cmd text eol=crlf 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: devlooped 2 | -------------------------------------------------------------------------------- /.github/actions/dotnet/action.yml: -------------------------------------------------------------------------------- 1 | name: ⚙ dotnet 2 | description: Configures dotnet if the repo/org defines the DOTNET custom property 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: 🔎 dotnet 8 | id: dotnet 9 | shell: bash 10 | run: | 11 | VERSIONS=$(gh api repos/${{ github.repository }}/properties/values | jq -r '.[] | select(.property_name == "DOTNET") | .value') 12 | # Remove extra whitespace from VERSIONS 13 | VERSIONS=$(echo "$VERSIONS" | tr -s ' ' | tr -d ' ') 14 | # Convert comma-separated to newline-separated 15 | NEWLINE_VERSIONS=$(echo "$VERSIONS" | tr ',' '\n') 16 | # Validate versions 17 | while IFS= read -r version; do 18 | if ! [[ $version =~ ^[0-9]+(\.[0-9]+(\.[0-9]+)?)?(\.x)?$ ]]; then 19 | echo "Error: Invalid version format: $version" 20 | exit 1 21 | fi 22 | done <<< "$NEWLINE_VERSIONS" 23 | # Write multiline output to $GITHUB_OUTPUT 24 | { 25 | echo 'versions<> $GITHUB_OUTPUT 29 | 30 | - name: ⚙ dotnet 31 | if: steps.dotnet.outputs.versions != '' 32 | uses: actions/setup-dotnet@v4 33 | with: 34 | dotnet-version: | 35 | ${{ steps.dotnet.outputs.versions }} 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: nuget 7 | directory: / 8 | schedule: 9 | interval: daily 10 | groups: 11 | Azure: 12 | patterns: 13 | - "Azure*" 14 | - "Microsoft.Azure*" 15 | Identity: 16 | patterns: 17 | - "System.IdentityModel*" 18 | - "Microsoft.IdentityModel*" 19 | System: 20 | patterns: 21 | - "System*" 22 | exclude-patterns: 23 | - "System.IdentityModel*" 24 | Extensions: 25 | patterns: 26 | - "Microsoft.Extensions*" 27 | Web: 28 | patterns: 29 | - "Microsoft.AspNetCore*" 30 | Tests: 31 | patterns: 32 | - "Microsoft.NET.Test*" 33 | - "xunit*" 34 | - "coverlet*" 35 | ThisAssembly: 36 | patterns: 37 | - "ThisAssembly*" 38 | ProtoBuf: 39 | patterns: 40 | - "protobuf-*" 41 | Spectre: 42 | patterns: 43 | - "Spectre.Console*" 44 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - bydesign 5 | - dependencies 6 | - duplicate 7 | - question 8 | - invalid 9 | - wontfix 10 | - need info 11 | - techdebt 12 | authors: 13 | - devlooped-bot 14 | - dependabot 15 | - github-actions 16 | categories: 17 | - title: ✨ Implemented enhancements 18 | labels: 19 | - enhancement 20 | - title: 🐛 Fixed bugs 21 | labels: 22 | - bug 23 | - title: 📝 Documentation updates 24 | labels: 25 | - docs 26 | - documentation 27 | - title: 🔨 Other 28 | labels: 29 | - '*' 30 | exclude: 31 | labels: 32 | - dependencies 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # Builds and runs tests in all three supported OSes 2 | # Pushes CI feed if secrets.SLEET_CONNECTION is provided 3 | 4 | name: build 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | configuration: 9 | type: choice 10 | description: Configuration 11 | options: 12 | - Release 13 | - Debug 14 | push: 15 | branches: [ main, dev, 'dev/*', 'feature/*', 'rel/*' ] 16 | paths-ignore: 17 | - changelog.md 18 | - readme.md 19 | pull_request: 20 | types: [opened, synchronize, reopened] 21 | 22 | env: 23 | DOTNET_NOLOGO: true 24 | PackOnBuild: true 25 | GeneratePackageOnBuild: true 26 | VersionPrefix: 42.42.${{ github.run_number }} 27 | VersionLabel: ${{ github.ref }} 28 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 29 | MSBUILDTERMINALLOGGER: auto 30 | Configuration: ${{ github.event.inputs.configuration || 'Release' }} 31 | 32 | defaults: 33 | run: 34 | shell: bash 35 | 36 | jobs: 37 | os-matrix: 38 | runs-on: ubuntu-latest 39 | outputs: 40 | matrix: ${{ steps.lookup.outputs.matrix }} 41 | steps: 42 | - name: 🤘 checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: 🔎 lookup 46 | id: lookup 47 | shell: pwsh 48 | run: | 49 | $path = './.github/workflows/os-matrix.json' 50 | $os = if (test-path $path) { cat $path } else { '["ubuntu-latest"]' } 51 | echo "matrix=$os" >> $env:GITHUB_OUTPUT 52 | 53 | build: 54 | needs: os-matrix 55 | name: build-${{ matrix.os }} 56 | runs-on: ${{ matrix.os }} 57 | strategy: 58 | matrix: 59 | os: ${{ fromJSON(needs.os-matrix.outputs.matrix) }} 60 | steps: 61 | - name: 🤘 checkout 62 | uses: actions/checkout@v4 63 | 64 | - name: ⚙ dotnet 65 | uses: actions/setup-dotnet@v4 66 | with: 67 | dotnet-version: | 68 | 6.x 69 | 8.x 70 | 9.x 71 | 72 | - name: 🙏 build 73 | run: dotnet build -m:1 -bl:build.binlog 74 | 75 | - name: ⚙ azurite 76 | run: | 77 | npm install azurite 78 | npx azurite & 79 | 80 | - name: 🧪 test 81 | run: | 82 | dotnet tool update -g dotnet-retest 83 | dotnet retest -- --no-build 84 | 85 | - name: 🐛 logs 86 | uses: actions/upload-artifact@v4 87 | if: runner.debug && always() 88 | with: 89 | name: logs 90 | path: '*.binlog' 91 | 92 | - name: 🚀 sleet 93 | env: 94 | SLEET_CONNECTION: ${{ secrets.SLEET_CONNECTION }} 95 | if: env.SLEET_CONNECTION != '' 96 | run: | 97 | dotnet tool install -g --version 4.0.18 sleet 98 | sleet push bin --config none -f --verbose -p "SLEET_FEED_CONTAINER=nuget" -p "SLEET_FEED_CONNECTIONSTRING=${{ secrets.SLEET_CONNECTION }}" -p "SLEET_FEED_TYPE=azure" || echo "No packages found" 99 | 100 | dotnet-format: 101 | runs-on: ubuntu-latest 102 | steps: 103 | - name: 🤘 checkout 104 | uses: actions/checkout@v4 105 | 106 | - name: ✓ ensure format 107 | run: | 108 | dotnet format whitespace --verify-no-changes -v:diag --exclude ~/.nuget 109 | dotnet format style --verify-no-changes -v:diag --exclude ~/.nuget 110 | -------------------------------------------------------------------------------- /.github/workflows/changelog.config: -------------------------------------------------------------------------------- 1 | usernames-as-github-logins=true 2 | issues_wo_labels=true 3 | pr_wo_labels=true 4 | exclude-labels=bydesign,dependencies,duplicate,discussion,question,invalid,wontfix,need info,docs 5 | enhancement-label=:sparkles: Implemented enhancements: 6 | bugs-label=:bug: Fixed bugs: 7 | issues-label=:hammer: Other: 8 | pr-label=:twisted_rightwards_arrows: Merged: 9 | unreleased=false 10 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: changelog 2 | on: 3 | workflow_dispatch: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | changelog: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 🤖 defaults 12 | uses: devlooped/actions-bot@v1 13 | with: 14 | name: ${{ secrets.BOT_NAME }} 15 | email: ${{ secrets.BOT_EMAIL }} 16 | gh_token: ${{ secrets.GH_TOKEN }} 17 | github_token: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: 🤘 checkout 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | ref: main 24 | token: ${{ env.GH_TOKEN }} 25 | 26 | - name: ⚙ ruby 27 | uses: ruby/setup-ruby@v1 28 | with: 29 | ruby-version: 3.0.3 30 | 31 | - name: ⚙ changelog 32 | run: | 33 | gem install github_changelog_generator 34 | github_changelog_generator --user ${GITHUB_REPOSITORY%/*} --project ${GITHUB_REPOSITORY##*/} --token $GH_TOKEN --o changelog.md --config-file .github/workflows/changelog.config 35 | 36 | - name: 🚀 changelog 37 | run: | 38 | git add changelog.md 39 | (git commit -m "🖉 Update changelog with ${GITHUB_REF#refs/*/}" && git push) || echo "Done" -------------------------------------------------------------------------------- /.github/workflows/dotnet-file.yml: -------------------------------------------------------------------------------- 1 | # Synchronizes .netconfig-configured files with dotnet-file 2 | name: dotnet-file 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | push: 8 | branches: [ 'dotnet-file' ] 9 | 10 | env: 11 | DOTNET_NOLOGO: true 12 | 13 | jobs: 14 | run: 15 | permissions: 16 | contents: write 17 | uses: devlooped/oss/.github/workflows/dotnet-file-core.yml@main 18 | secrets: 19 | BOT_NAME: ${{ secrets.BOT_NAME }} 20 | BOT_EMAIL: ${{ secrets.BOT_EMAIL }} 21 | GH_TOKEN: ${{ secrets.GH_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/includes.yml: -------------------------------------------------------------------------------- 1 | name: +Mᐁ includes 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - 'main' 7 | paths: 8 | - '**.md' 9 | - '!changelog.md' 10 | 11 | jobs: 12 | includes: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | steps: 18 | - name: 🤖 defaults 19 | uses: devlooped/actions-bot@v1 20 | with: 21 | name: ${{ secrets.BOT_NAME }} 22 | email: ${{ secrets.BOT_EMAIL }} 23 | gh_token: ${{ secrets.GH_TOKEN }} 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | 26 | - name: 🤘 checkout 27 | uses: actions/checkout@v4 28 | with: 29 | token: ${{ env.GH_TOKEN }} 30 | 31 | - name: +Mᐁ includes 32 | uses: devlooped/actions-includes@v1 33 | 34 | - name: ✍ pull request 35 | uses: peter-evans/create-pull-request@v6 36 | with: 37 | add-paths: '**.md' 38 | base: main 39 | branch: markdown-includes 40 | delete-branch: true 41 | labels: docs 42 | author: ${{ env.BOT_AUTHOR }} 43 | committer: ${{ env.BOT_AUTHOR }} 44 | commit-message: +Mᐁ includes 45 | title: +Mᐁ includes 46 | body: +Mᐁ includes 47 | token: ${{ env.GH_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | # Workflow to cross-post a jekyll site (or GitHub Pages) 2 | # to another org/repo. 3 | # Required secrets in repository consuming this workflow: 4 | # - PAGES_ORGANIZATION: the target organization to publish 5 | # pages to. 6 | # - PAGES_ACCESS_TOKEN: a token that is valid in the target 7 | # org/repo for pushing the resulting site 8 | # - PAGES_REPOSITORY: optional repository name under the 9 | # target organization. Defaults to source repo name. 10 | 11 | name: pages 12 | on: 13 | workflow_dispatch: 14 | push: 15 | branches: 16 | - main 17 | - pages 18 | - docs 19 | 20 | env: 21 | PAGES_ORGANIZATION: ${{ secrets.PAGES_ORGANIZATION }} 22 | PAGES_REPOSITORY: ${{ secrets.PAGES_REPOSITORY }} 23 | 24 | jobs: 25 | gh-pages: 26 | runs-on: ubuntu-latest 27 | env: 28 | PAGES_ORGANIZATION: ${{ secrets.PAGES_ORGANIZATION }} 29 | PAGES_REPOSITORY: ${{ secrets.PAGES_REPOSITORY }} 30 | PAGES_ACCESS_TOKEN: ${{ secrets.PAGES_ACCESS_TOKEN }} 31 | steps: 32 | - name: ✅ organization 33 | if: env.PAGES_ORGANIZATION == '' 34 | run: | 35 | echo "::error title=PAGES_ORGANIZATION secret is required." 36 | exit 1 37 | 38 | - name: ✅ token 39 | if: env.PAGES_ACCESS_TOKEN == '' 40 | run: | 41 | echo "::error title=PAGES_ACCESS_TOKEN secret is required." 42 | exit 1 43 | 44 | - name: 🤘 checkout 45 | uses: actions/checkout@v2 46 | 47 | - name: ⚙ jekyll 48 | run: | 49 | sudo gem install bundler 50 | sudo bundle install 51 | 52 | - name: 🖉 default repo 53 | if: env.PAGES_REPOSITORY == '' 54 | run: echo "PAGES_REPOSITORY=${GITHUB_REPOSITORY#*/}" >> $GITHUB_ENV 55 | 56 | - name: 🙏 build 57 | run: bundle exec jekyll build -b ${{ env.PAGES_REPOSITORY }} 58 | env: 59 | JEKYLL_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - name: ✓ commit 62 | run: | 63 | cd _site 64 | git init 65 | git add -A 66 | git config --local user.email "bot@clarius.org" 67 | git config --local user.name "bot@clarius.org" 68 | git commit -m "Publish pages from ${GITHUB_REPOSITORY}@${GITHUB_SHA:0:9}" 69 | 70 | - name: 🚀 push 71 | uses: ad-m/github-push-action@v0.6.0 72 | with: 73 | github_token: ${{ env.PAGES_ACCESS_TOKEN }} 74 | repository: ${{ env.PAGES_ORGANIZATION }}/${{ env.PAGES_REPOSITORY }} 75 | branch: gh-pages 76 | force: true 77 | directory: ./_site -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # Builds a final release version and pushes to nuget.org 2 | # whenever a release is published. 3 | # Requires: secrets.NUGET_API_KEY 4 | 5 | name: publish 6 | on: 7 | release: 8 | types: [prereleased, released] 9 | 10 | env: 11 | DOTNET_NOLOGO: true 12 | Configuration: Release 13 | PackOnBuild: true 14 | GeneratePackageOnBuild: true 15 | VersionLabel: ${{ github.ref }} 16 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 17 | MSBUILDTERMINALLOGGER: auto 18 | 19 | jobs: 20 | publish: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: 🤘 checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: ⚙ dotnet 27 | uses: actions/setup-dotnet@v4 28 | with: 29 | dotnet-version: | 30 | 6.x 31 | 8.x 32 | 9.x 33 | 34 | - name: 🙏 build 35 | run: dotnet build -m:1 -bl:build.binlog 36 | 37 | - name: ⚙ azurite 38 | run: | 39 | npm install azurite 40 | npx azurite & 41 | 42 | - name: 🧪 test 43 | run: | 44 | dotnet tool update -g dotnet-retest 45 | dotnet retest -- --no-build 46 | 47 | - name: 🐛 logs 48 | uses: actions/upload-artifact@v4 49 | if: runner.debug && always() 50 | with: 51 | name: logs 52 | path: '*.binlog' 53 | 54 | - name: 🚀 nuget 55 | env: 56 | NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} 57 | if: env.NUGET_API_KEY != '' 58 | run: dotnet nuget push ./bin/**/*.nupkg -s https://api.nuget.org/v3/index.json -k ${{secrets.NUGET_API_KEY}} --skip-duplicate 59 | 60 | - name: 🚀 sleet 61 | env: 62 | SLEET_CONNECTION: ${{ secrets.SLEET_CONNECTION }} 63 | if: env.SLEET_CONNECTION != '' 64 | run: | 65 | dotnet tool install -g --version 4.0.18 sleet 66 | sleet push bin --config none -f --verbose -p "SLEET_FEED_CONTAINER=nuget" -p "SLEET_FEED_CONNECTIONSTRING=${{ secrets.SLEET_CONNECTION }}" -p "SLEET_FEED_TYPE=azure" || echo "No packages found" 67 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | name: 'triage' 2 | on: 3 | schedule: 4 | - cron: '42 0 * * *' 5 | 6 | workflow_dispatch: 7 | # Manual triggering through the GitHub UI, API, or CLI 8 | inputs: 9 | daysBeforeClose: 10 | description: "Days before closing stale or need info issues" 11 | required: true 12 | default: "30" 13 | daysBeforeStale: 14 | description: "Days before labeling stale" 15 | required: true 16 | default: "180" 17 | daysSinceClose: 18 | description: "Days since close to lock" 19 | required: true 20 | default: "30" 21 | daysSinceUpdate: 22 | description: "Days since update to lock" 23 | required: true 24 | default: "30" 25 | 26 | permissions: 27 | actions: write # For managing the operation state cache 28 | issues: write 29 | contents: read 30 | 31 | jobs: 32 | stale: 33 | # Do not run on forks 34 | if: github.repository_owner == 'devlooped' 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: ⌛ rate 38 | shell: pwsh 39 | if: github.event_name != 'workflow_dispatch' 40 | env: 41 | GH_TOKEN: ${{ secrets.DEVLOOPED_TOKEN }} 42 | run: | 43 | # add random sleep since we run on fixed schedule 44 | $wait = get-random -max 180 45 | echo "Waiting random $wait seconds to start" 46 | sleep $wait 47 | # get currently authenticated user rate limit info 48 | $rate = gh api rate_limit | convertfrom-json | select -expandproperty rate 49 | # if we don't have at least 100 requests left, wait until reset 50 | if ($rate.remaining -lt 100) { 51 | $wait = ($rate.reset - (Get-Date (Get-Date).ToUniversalTime() -UFormat %s)) 52 | echo "Rate limit remaining is $($rate.remaining), waiting for $($wait / 1000) seconds to reset" 53 | sleep $wait 54 | $rate = gh api rate_limit | convertfrom-json | select -expandproperty rate 55 | echo "Rate limit has reset to $($rate.remaining) requests" 56 | } 57 | 58 | - name: ✏️ stale labeler 59 | # pending merge: https://github.com/actions/stale/pull/1176 60 | uses: kzu/stale@c8450312ba97b204bf37545cb249742144d6ca69 61 | with: 62 | ascending: true # Process the oldest issues first 63 | stale-issue-label: 'stale' 64 | stale-issue-message: | 65 | Due to lack of recent activity, this issue has been labeled as 'stale'. 66 | It will be closed if no further activity occurs within ${{ fromJson(inputs.daysBeforeClose || 30 ) }} more days. 67 | Any new comment will remove the label. 68 | close-issue-message: | 69 | This issue will now be closed since it has been labeled 'stale' without activity for ${{ fromJson(inputs.daysBeforeClose || 30 ) }} days. 70 | days-before-stale: ${{ fromJson(inputs.daysBeforeStale || 180) }} 71 | days-before-close: ${{ fromJson(inputs.daysBeforeClose || 30 ) }} 72 | days-before-pr-close: -1 # Do not close PRs labeled as 'stale' 73 | exempt-all-milestones: true 74 | exempt-all-assignees: true 75 | exempt-issue-labels: priority,sponsor,backed 76 | exempt-authors: kzu 77 | 78 | - name: 🤘 checkout actions 79 | uses: actions/checkout@v4 80 | with: 81 | repository: 'microsoft/vscode-github-triage-actions' 82 | ref: v42 83 | 84 | - name: ⚙ install actions 85 | run: npm install --production 86 | 87 | - name: 🔒 issues locker 88 | uses: ./locker 89 | with: 90 | token: ${{ secrets.DEVLOOPED_TOKEN }} 91 | ignoredLabel: priority 92 | daysSinceClose: ${{ fromJson(inputs.daysSinceClose || 30) }} 93 | daysSinceUpdate: ${{ fromJson(inputs.daysSinceUpdate || 30) }} 94 | 95 | - name: 🔒 need info closer 96 | uses: ./needs-more-info-closer 97 | with: 98 | token: ${{ secrets.DEVLOOPED_TOKEN }} 99 | label: 'need info' 100 | closeDays: ${{ fromJson(inputs.daysBeforeClose || 30) }} 101 | closeComment: "This issue has been closed automatically because it needs more information and has not had recent activity.\n\nHappy Coding!" 102 | pingDays: 80 103 | pingComment: "Hey @${assignee}, this issue might need further attention.\n\n@${author}, you can help us out by closing this issue if the problem no longer exists, or adding more information." -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | artifacts 4 | pack 5 | TestResults 6 | results 7 | BenchmarkDotNet.Artifacts 8 | /app 9 | .vs 10 | .vscode 11 | .genaiscript 12 | .idea 13 | local.settings.json 14 | 15 | *.suo 16 | *.sdf 17 | *.userprefs 18 | *.user 19 | *.nupkg 20 | *.metaproj 21 | *.tmp 22 | *.log 23 | *.cache 24 | *.binlog 25 | *.zip 26 | __azurite*.* 27 | __*__ 28 | 29 | .nuget 30 | *.lock.json 31 | *.nuget.props 32 | *.nuget.targets 33 | 34 | node_modules 35 | _site 36 | .jekyll-metadata 37 | .jekyll-cache 38 | .sass-cache 39 | Gemfile.lock 40 | package-lock.json 41 | -------------------------------------------------------------------------------- /Directory.Build.rsp: -------------------------------------------------------------------------------- 1 | # See https://docs.microsoft.com/en-us/visualstudio/msbuild/msbuild-response-files 2 | -nr:false 3 | -m:1 4 | -v:m 5 | -clp:Summary;ForceNoAlign 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'github-pages', '~> 231', group: :jekyll_plugins 4 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | 3 | exclude: [ 'src/', '*.sln', 'Gemfile*', '*.rsp' ] -------------------------------------------------------------------------------- /assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "jekyll-theme-slate"; 5 | 6 | .inner { 7 | max-width: 960px; 8 | } 9 | 10 | pre, code { 11 | background-color: unset; 12 | font-size: unset; 13 | } 14 | 15 | code { 16 | font-size: 0.80em; 17 | } 18 | 19 | h1 > img { 20 | border: unset; 21 | box-shadow: unset; 22 | vertical-align: middle; 23 | -moz-box-shadow: unset; 24 | -o-box-shadow: unset; 25 | -ms-box-shadow: unset; 26 | } 27 | -------------------------------------------------------------------------------- /assets/img/document.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/assets/img/document.png -------------------------------------------------------------------------------- /assets/img/entity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/assets/img/entity.png -------------------------------------------------------------------------------- /assets/img/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/assets/img/icon-32.png -------------------------------------------------------------------------------- /assets/img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /assets/img/tablestorage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/assets/img/tablestorage.png -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Daniel Cazzulino and Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/Directory.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped 5 | enable 6 | latest 7 | https://api.nuget.org/v3/index.json;https://pkg.kzu.app/index.json;$(RestoreSources) 8 | 9 | https://clarius.org/TableStorage 10 | Devlooped.TableStorage 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Directory.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $(Description) 6 | 7 | > This project uses SponsorLink and may issue IDE-only warnings if no active sponsorship is detected. 8 | > Learn more at https://github.com/devlooped#sponsorlink. 9 | 10 | $(PackFolder.StartsWith('analyzers/')) 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/SponsorLink/Analyzer/Analyzer.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | SponsorableLib.Analyzers 5 | netstandard2.0 6 | true 7 | analyzers/dotnet/roslyn4.0 8 | true 9 | false 10 | true 11 | $(MSBuildThisFileDirectory)..\SponsorLink.Analyzer.targets 12 | disable 13 | SponsorableLib 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\Tests\keys\kzu.pub.jwk')) 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/SponsorLink/Analyzer/GraceApiAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Immutable; 2 | using System.Linq; 3 | using Devlooped.Sponsors; 4 | using Microsoft.CodeAnalysis; 5 | using Microsoft.CodeAnalysis.CSharp; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | using static Devlooped.Sponsors.SponsorLink; 8 | 9 | namespace Analyzer; 10 | 11 | /// 12 | /// Links the sponsor status for the current compilation. 13 | /// 14 | [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] 15 | public class GraceApiAnalyzer : DiagnosticAnalyzer 16 | { 17 | public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( 18 | new DiagnosticDescriptor( 19 | "SL010", "Grace API usage", "Reports info for APIs that are in grace period", "Sponsors", 20 | DiagnosticSeverity.Info, true, helpLinkUri: Funding.HelpUrl), 21 | new DiagnosticDescriptor( 22 | "SL011", "Report Sponsoring Status", "Fake to get it to call us", "Sponsors", 23 | DiagnosticSeverity.Warning, true) 24 | ); 25 | 26 | #pragma warning disable RS1026 // Enable concurrent execution 27 | public override void Initialize(AnalysisContext context) 28 | #pragma warning restore RS1026 // Enable concurrent execution 29 | { 30 | #if !DEBUG 31 | // Only enable concurrent execution in release builds, otherwise debugging is quite annoying. 32 | context.EnableConcurrentExecution(); 33 | #endif 34 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); 35 | // Report info grace and expiring diagnostics. 36 | context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.InvocationExpression); 37 | context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.SimpleMemberAccessExpression); 38 | } 39 | 40 | void AnalyzeNode(SyntaxNodeAnalysisContext context) 41 | { 42 | var status = Diagnostics.GetOrSetStatus(() => context.Options); 43 | if (status != SponsorStatus.Grace) 44 | return; 45 | 46 | ReportGraceSymbol(context, context.Node.GetLocation(), context.SemanticModel.GetSymbolInfo(context.Node).Symbol); 47 | } 48 | 49 | void ReportGraceSymbol(SyntaxNodeAnalysisContext context, Location location, ISymbol? symbol) 50 | { 51 | if (symbol != null && 52 | symbol.GetAttributes().Any(attr => 53 | attr.AttributeClass?.ToDisplayString() == "System.ComponentModel.CategoryAttribute" && 54 | attr.ConstructorArguments.Any(arg => arg.Value as string == "Sponsored"))) 55 | { 56 | context.ReportDiagnostic(Diagnostic.Create( 57 | SupportedDiagnostics[0], 58 | location)); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/SponsorLink/Analyzer/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "SponsorableLib": { 4 | "commandName": "DebugRoslynComponent", 5 | "targetProject": "..\\Tests\\Tests.csproj", 6 | "environmentVariables": { 7 | "SPONSORLINK_TRACE": "true" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Immutable; 3 | using System.IO; 4 | using System.Linq; 5 | using Devlooped.Sponsors; 6 | using Humanizer; 7 | using Microsoft.CodeAnalysis; 8 | using Microsoft.CodeAnalysis.Diagnostics; 9 | using Microsoft.CodeAnalysis.Text; 10 | using static Devlooped.Sponsors.SponsorLink; 11 | 12 | namespace Analyzer; 13 | 14 | [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] 15 | public class StatusReportingAnalyzer : DiagnosticAnalyzer 16 | { 17 | public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( 18 | new DiagnosticDescriptor( 19 | "SL001", "Report Sponsoring Status", "Reports sponsoring status determined by SponsorLink", "Sponsors", 20 | DiagnosticSeverity.Info, true), 21 | new DiagnosticDescriptor( 22 | "SL002", "Report Sponsoring Status", "Fake to get it to call us", "Sponsors", 23 | DiagnosticSeverity.Warning, true) 24 | ); 25 | 26 | public override void Initialize(AnalysisContext context) 27 | { 28 | context.EnableConcurrentExecution(); 29 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); 30 | 31 | context.RegisterCompilationAction(c => 32 | { 33 | var installed = c.Options.AdditionalFiles.Where(x => 34 | { 35 | var options = c.Options.AnalyzerConfigOptionsProvider.GetOptions(x); 36 | // In release builds, we'll have a single such item, since we IL-merge the analyzer. 37 | return options.TryGetValue("build_metadata.Analyzer.ItemType", out var itemType) && 38 | options.TryGetValue("build_metadata.Analyzer.NuGetPackageId", out var packageId) && 39 | itemType == "Analyzer" && 40 | packageId == "SponsorableLib"; 41 | }).Select(x => File.GetLastWriteTime(x.Path)).OrderByDescending(x => x).FirstOrDefault(); 42 | 43 | var status = Diagnostics.GetOrSetStatus(() => c.Options); 44 | 45 | var location = Location.None; 46 | if (c.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.MSBuildProjectFullPath", out var value)) 47 | location = Location.Create(value, new TextSpan(), new LinePositionSpan()); 48 | 49 | c.ReportDiagnostic(Diagnostic.Create(SupportedDiagnostics[0], location, status.ToString())); 50 | 51 | if (installed != default) 52 | Tracing.Trace($"Status: {status}, Installed: {(DateTime.Now - installed).Humanize()} ago"); 53 | else 54 | Tracing.Trace($"Status: {status}, unknown install time"); 55 | }); 56 | } 57 | } -------------------------------------------------------------------------------- /src/SponsorLink/Analyzer/StatusReportingGenerator.cs: -------------------------------------------------------------------------------- 1 | using Devlooped.Sponsors; 2 | using Microsoft.CodeAnalysis; 3 | using static Devlooped.Sponsors.SponsorLink; 4 | 5 | namespace Analyzer; 6 | 7 | [Generator] 8 | public class StatusReportingGenerator : IIncrementalGenerator 9 | { 10 | public void Initialize(IncrementalGeneratorInitializationContext context) 11 | { 12 | context.RegisterSourceOutput( 13 | // this is required to ensure status is registered properly independently 14 | // of analyzer runs. 15 | context.GetStatusOptions(), 16 | (spc, source) => 17 | { 18 | var status = Diagnostics.GetOrSetStatus(source); 19 | spc.AddSource("StatusReporting.cs", 20 | $""" 21 | // Status: {status} 22 | // DesignTimeBuild: {source.GlobalOptions.IsDesignTimeBuild()} 23 | """); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/SponsorLink/Analyzer/buildTransitive/SponsorableLib.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/SponsorLink/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | latest 6 | true 7 | annotations 8 | true 9 | 10 | false 11 | $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)bin')) 12 | 13 | https://pkg.kzu.app/index.json;https://api.nuget.org/v3/index.json 14 | $(PackageOutputPath);$(RestoreSources) 15 | 16 | 18 | $([System.DateTime]::Parse("2024-03-15")) 19 | $([System.DateTime]::UtcNow.Subtract($(Epoc)).TotalDays) 20 | $([System.Math]::Truncate($(TotalDays))) 21 | $([System.Math]::Floor($([MSBuild]::Divide($([System.DateTime]::UtcNow.TimeOfDay.TotalSeconds), 10)))) 22 | 42.$(Days).$(Seconds) 23 | 24 | SponsorableLib 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/SponsorLink/Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/SponsorLink/Library/Library.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | SponsorableLib 5 | netstandard2.0 6 | true 7 | SponsorableLib 8 | Sample library incorporating SponsorLink checks 9 | true 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/SponsorLink/Library/MyClass.cs: -------------------------------------------------------------------------------- 1 | namespace SponsorableLib; 2 | 3 | public class MyClass 4 | { 5 | } 6 | -------------------------------------------------------------------------------- /src/SponsorLink/Library/readme.md: -------------------------------------------------------------------------------- 1 | # Sponsorable Library 2 | 3 | Example of a library that is available for sponsorship and leverages 4 | [SponsorLink](https://github.com/devlooped/SponsorLink) to remind users 5 | in an IDE (VS/Rider). 6 | -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink.Analyzer.Tests.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | true 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | $([MSBuild]::ValueOrDefault('%(FullPath)', '').Replace('net6.0', 'netstandard2.0').Replace('net8.0', 'netstandard2.0').Replace('netcoreapp3.1', 'netstandard2.0')) 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink/AnalyzerOptionsExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.CodeAnalysis.Diagnostics; 2 | 3 | static class AnalyzerOptionsExtensions 4 | { 5 | /// 6 | /// Gets whether the current build is a design-time build. 7 | /// 8 | public static bool IsDesignTimeBuild(this AnalyzerConfigOptionsProvider options) => 9 | options.GlobalOptions.TryGetValue("build_property.DesignTimeBuild", out var value) && 10 | bool.TryParse(value, out var isDesignTime) && isDesignTime; 11 | 12 | /// 13 | /// Gets whether the current build is a design-time build. 14 | /// 15 | public static bool IsDesignTimeBuild(this AnalyzerConfigOptions options) => 16 | options.TryGetValue("build_property.DesignTimeBuild", out var value) && 17 | bool.TryParse(value, out var isDesignTime) && isDesignTime; 18 | } -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink/AppDomainDictionary.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | 5 | namespace Devlooped.Sponsors; 6 | 7 | /// 8 | /// A helper class to store and retrieve values from the current 9 | /// as typed named values. 10 | /// 11 | /// 12 | /// This allows tools that run within the same app domain to share state, such as 13 | /// MSBuild tasks or Roslyn analyzers. 14 | /// 15 | static class AppDomainDictionary 16 | { 17 | /// 18 | /// Gets the value associated with the specified name, or creates a new one if it doesn't exist. 19 | /// 20 | public static TValue Get(string name) where TValue : notnull, new() 21 | { 22 | var data = AppDomain.CurrentDomain.GetData(name); 23 | if (data is TValue firstTry) 24 | return firstTry; 25 | 26 | lock (AppDomain.CurrentDomain) 27 | { 28 | if (AppDomain.CurrentDomain.GetData(name) is TValue secondTry) 29 | return secondTry; 30 | 31 | var newValue = new TValue(); 32 | AppDomain.CurrentDomain.SetData(name, newValue); 33 | return newValue; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System.Collections.Immutable; 4 | using System.Linq; 5 | using Microsoft.CodeAnalysis; 6 | using Microsoft.CodeAnalysis.Diagnostics; 7 | using static Devlooped.Sponsors.SponsorLink; 8 | 9 | namespace Devlooped.Sponsors; 10 | 11 | /// 12 | /// Links the sponsor status for the current compilation. 13 | /// 14 | [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] 15 | public class SponsorLinkAnalyzer : DiagnosticAnalyzer 16 | { 17 | public override ImmutableArray SupportedDiagnostics { get; } = DiagnosticsManager.KnownDescriptors.Values.ToImmutableArray(); 18 | 19 | #pragma warning disable RS1026 // Enable concurrent execution 20 | public override void Initialize(AnalysisContext context) 21 | #pragma warning restore RS1026 // Enable concurrent execution 22 | { 23 | #if !DEBUG 24 | // Only enable concurrent execution in release builds, otherwise debugging is quite annoying. 25 | context.EnableConcurrentExecution(); 26 | #endif 27 | context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); 28 | 29 | #pragma warning disable RS1013 // Start action has no registered non-end actions 30 | // We do this so that the status is set at compilation start so we can use it 31 | // across all other analyzers. We report only on finish because multiple 32 | // analyzers can report the same diagnostic and we want to avoid duplicates. 33 | context.RegisterCompilationStartAction(ctx => 34 | { 35 | // Setting the status early allows other analyzers to potentially check for it. 36 | var status = Diagnostics.GetOrSetStatus(() => ctx.Options); 37 | 38 | // Never report any diagnostic unless we're in an editor. 39 | if (IsEditor) 40 | { 41 | // NOTE: for multiple projects with the same product name, we only report one diagnostic, 42 | // so it's expected to NOT get a diagnostic back. Also, we don't want to report 43 | // multiple diagnostics for each project in a solution that uses the same product. 44 | ctx.RegisterCompilationEndAction(ctx => 45 | { 46 | // We'd never report Info/hero link if users opted out of it. 47 | if (status.IsSponsor() && 48 | ctx.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.SponsorLinkHero", out var slHero) && 49 | bool.TryParse(slHero, out var isHero) && isHero) 50 | return; 51 | 52 | // Only report if the package is directly referenced in the project for 53 | // any of the funding packages we monitor (i.e. we could have one or more 54 | // metapackages we also consider "direct references). 55 | // See SL_CollectDependencies in buildTransitive\Devlooped.Sponsors.targets 56 | foreach (var prop in Funding.PackageIds.Select(id => id.Replace('.', '_'))) 57 | { 58 | if (ctx.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property." + prop, out var package) && 59 | package?.Length > 0 && 60 | Diagnostics.TryGet() is { } diagnostic) 61 | { 62 | ctx.ReportDiagnostic(diagnostic); 63 | break; 64 | } 65 | } 66 | }); 67 | } 68 | }); 69 | #pragma warning restore RS1013 // Start action has no registered non-end actions 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink/SponsorStatus.cs: -------------------------------------------------------------------------------- 1 | // 2 | namespace Devlooped.Sponsors; 3 | 4 | public static class SponsorStatusExtensions 5 | { 6 | /// 7 | /// Whether represents a sponsor (directly or indirectly). 8 | /// 9 | public static bool IsSponsor(this SponsorStatus status) 10 | => status == SponsorStatus.User || 11 | status == SponsorStatus.Team || 12 | status == SponsorStatus.Contributor || 13 | status == SponsorStatus.Organization; 14 | } 15 | 16 | /// 17 | /// The determined sponsoring status. 18 | /// 19 | public enum SponsorStatus 20 | { 21 | /// 22 | /// Sponsorship status is unknown. 23 | /// 24 | Unknown, 25 | /// 26 | /// Sponsorship status is unknown, but within the grace period. 27 | /// 28 | Grace, 29 | /// 30 | /// The sponsors manifest is expired but within the grace period. 31 | /// 32 | Expiring, 33 | /// 34 | /// The sponsors manifest is expired and outside the grace period. 35 | /// 36 | Expired, 37 | /// 38 | /// The user is personally sponsoring. 39 | /// 40 | User, 41 | /// 42 | /// The user is a team member. 43 | /// 44 | Team, 45 | /// 46 | /// The user is a contributor. 47 | /// 48 | Contributor, 49 | /// 50 | /// The user is a member of a contributing organization. 51 | /// 52 | Organization, 53 | /// 54 | /// The user is a OSS author. 55 | /// 56 | OpenSource, 57 | } 58 | -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink/SponsorableLib.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $([System.IO.Path]::GetFullPath($(MSBuildThisFileDirectory)sponsorable.md)) 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | $(WarningsNotAsErrors);LIB001;LIB002;LIB003;LIB004;LIB005 16 | 17 | $(BaseIntermediateOutputPath)autosync.stamp 18 | 19 | $(HOME) 20 | $(USERPROFILE) 21 | 22 | true 23 | $([System.IO.Path]::GetFullPath('$(UserProfileHome)/.sponsorlink')) 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 48 | 49 | 50 | 51 | 52 | %(GitRoot.FullPath) 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink/Tracing.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Diagnostics; 5 | using System.IO; 6 | using System.Runtime.CompilerServices; 7 | using System.Text; 8 | 9 | namespace Devlooped.Sponsors; 10 | 11 | static class Tracing 12 | { 13 | public static void Trace([CallerMemberName] string? message = null, [CallerFilePath] string? filePath = null, [CallerLineNumber] int lineNumber = 0) 14 | { 15 | var trace = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SPONSORLINK_TRACE")); 16 | #if DEBUG 17 | trace = true; 18 | #endif 19 | 20 | if (!trace) 21 | return; 22 | 23 | var line = new StringBuilder() 24 | .Append($"[{DateTime.Now:O}]") 25 | .Append($"[{Process.GetCurrentProcess().ProcessName}:{Process.GetCurrentProcess().Id}]") 26 | .Append($" {message} ") 27 | .AppendLine($" -> {filePath}({lineNumber})") 28 | .ToString(); 29 | 30 | var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".sponsorlink"); 31 | Directory.CreateDirectory(dir); 32 | 33 | var tries = 0; 34 | // Best-effort only 35 | while (tries < 10) 36 | { 37 | try 38 | { 39 | File.AppendAllText(Path.Combine(dir, "trace.log"), line); 40 | Debugger.Log(0, "SponsorLink", line); 41 | return; 42 | } 43 | catch (IOException) 44 | { 45 | tries++; 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLink/sponsorable.md: -------------------------------------------------------------------------------- 1 | # Why Sponsor 2 | 3 | Well, why not? It's super cheap :) 4 | 5 | This could even be partially auto-generated from FUNDING.yml and what-not. -------------------------------------------------------------------------------- /src/SponsorLink/SponsorLinkAnalyzer.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.10.34928.147 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzer", "Analyzer\Analyzer.csproj", "{584984D6-926B-423D-9416-519613423BAE}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Library", "Library\Library.csproj", "{598CD398-A172-492C-8367-827D43276029}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{EA02494C-6ED4-47A0-8D43-20F50BE8554F}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SponsorLink", "SponsorLink\SponsorLink.csproj", "{B91C7E99-3D2E-4FDF-B017-9123E810197F}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Release|Any CPU = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {584984D6-926B-423D-9416-519613423BAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {584984D6-926B-423D-9416-519613423BAE}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {584984D6-926B-423D-9416-519613423BAE}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {584984D6-926B-423D-9416-519613423BAE}.Release|Any CPU.Build.0 = Release|Any CPU 24 | {598CD398-A172-492C-8367-827D43276029}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {598CD398-A172-492C-8367-827D43276029}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {598CD398-A172-492C-8367-827D43276029}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {598CD398-A172-492C-8367-827D43276029}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {EA02494C-6ED4-47A0-8D43-20F50BE8554F}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {B91C7E99-3D2E-4FDF-B017-9123E810197F}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {1DDA0EFF-BEF6-49BB-8AA8-D71FE1CD3E6F} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /src/SponsorLink/Tests/.netconfig: -------------------------------------------------------------------------------- 1 | [config] 2 | root = true 3 | [file "SponsorableManifest.cs"] 4 | url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/SponsorableManifest.cs 5 | sha = 5a4cad3a084f53afe34a6b75e4f3a084a0f1bf9e 6 | etag = 9a07c856d06e0cde629fce3ec014f64f9adfd5ae5805a35acf623eba0ee045c1 7 | weak 8 | [file "JsonOptions.cs"] 9 | url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/JsonOptions.cs 10 | sha = 80ea1bfe47049ef6c6ed4f424dcf7febb729cbba 11 | etag = 17799725ad9b24eb5998365962c30b9a487bddadca37c616e35b76b8c9eb161a 12 | weak 13 | [file "Extensions.cs"] 14 | url = https://github.com/devlooped/SponsorLink/blob/main/src/Core/Extensions.cs 15 | sha = c455f6fa1a4d404181d076d7f3362345c8ed7df2 16 | etag = 9e51b7e6540fae140490a5283b1e67ce071bd18a267bc2ae0b35c7248261aed1 17 | weak -------------------------------------------------------------------------------- /src/SponsorLink/Tests/Attributes.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Xunit; 3 | 4 | public class SecretsFactAttribute : FactAttribute 5 | { 6 | public SecretsFactAttribute(params string[] secrets) 7 | { 8 | var configuration = new ConfigurationBuilder() 9 | .AddUserSecrets() 10 | .Build(); 11 | 12 | var missing = new HashSet(); 13 | 14 | foreach (var secret in secrets) 15 | { 16 | if (string.IsNullOrEmpty(configuration[secret])) 17 | missing.Add(secret); 18 | } 19 | 20 | if (missing.Count > 0) 21 | Skip = "Missing user secrets: " + string.Join(',', missing); 22 | } 23 | } 24 | 25 | public class LocalFactAttribute : SecretsFactAttribute 26 | { 27 | public LocalFactAttribute(params string[] secrets) : base(secrets) 28 | { 29 | if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) 30 | Skip = "Non-CI test"; 31 | } 32 | } 33 | 34 | public class CIFactAttribute : FactAttribute 35 | { 36 | public CIFactAttribute() 37 | { 38 | if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) 39 | Skip = "CI-only test"; 40 | } 41 | } 42 | 43 | public class LocalTheoryAttribute : TheoryAttribute 44 | { 45 | public LocalTheoryAttribute() 46 | { 47 | if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) 48 | Skip = "Non-CI test"; 49 | } 50 | } 51 | 52 | public class CITheoryAttribute : TheoryAttribute 53 | { 54 | public CITheoryAttribute() 55 | { 56 | if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI"))) 57 | Skip = "CI-only test"; 58 | } 59 | } -------------------------------------------------------------------------------- /src/SponsorLink/Tests/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.CompilerServices; 3 | using System.Security.Cryptography; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.IdentityModel.Tokens; 6 | 7 | namespace Devlooped.Sponsors; 8 | 9 | static class Extensions 10 | { 11 | public static HashCode Add(this HashCode hash, params object[] items) 12 | { 13 | foreach (var item in items) 14 | hash.Add(item); 15 | 16 | return hash; 17 | } 18 | 19 | 20 | public static HashCode AddRange(this HashCode hash, IEnumerable items) 21 | { 22 | foreach (var item in items) 23 | hash.Add(item); 24 | 25 | return hash; 26 | } 27 | 28 | public static bool ThumbprintEquals(this SecurityKey key, RSA rsa) => key.ThumbprintEquals(new RsaSecurityKey(rsa)); 29 | 30 | public static bool ThumbprintEquals(this RSA rsa, SecurityKey key) => key.ThumbprintEquals(rsa); 31 | 32 | public static bool ThumbprintEquals(this SecurityKey first, SecurityKey second) 33 | { 34 | var expectedKey = JsonWebKeyConverter.ConvertFromSecurityKey(second); 35 | var actualKey = JsonWebKeyConverter.ConvertFromSecurityKey(first); 36 | return expectedKey.ComputeJwkThumbprint().AsSpan().SequenceEqual(actualKey.ComputeJwkThumbprint()); 37 | } 38 | 39 | public static Array Cast(this Array array, Type elementType) 40 | { 41 | //Convert the object list to the destination array type. 42 | var result = Array.CreateInstance(elementType, array.Length); 43 | Array.Copy(array, result, array.Length); 44 | return result; 45 | } 46 | 47 | public static void Assert(this ILogger logger, [DoesNotReturnIf(false)] bool condition, [CallerArgumentExpression(nameof(condition))] string? message = default, params object?[] args) 48 | { 49 | if (!condition) 50 | { 51 | //Debug.Assert(condition, message); 52 | logger.LogError(message, args); 53 | throw new InvalidOperationException(message); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/SponsorLink/Tests/JsonOptions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text.Json; 3 | using System.Text.Json.Serialization; 4 | using System.Text.Json.Serialization.Metadata; 5 | using Microsoft.IdentityModel.Tokens; 6 | 7 | namespace Devlooped.Sponsors; 8 | 9 | static partial class JsonOptions 10 | { 11 | public static JsonSerializerOptions Default { get; } = 12 | #if NET6_0_OR_GREATER 13 | new(JsonSerializerDefaults.Web) 14 | #else 15 | new() 16 | #endif 17 | { 18 | AllowTrailingCommas = true, 19 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase, 20 | ReadCommentHandling = JsonCommentHandling.Skip, 21 | #if NET6_0_OR_GREATER 22 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, 23 | #endif 24 | WriteIndented = true, 25 | Converters = 26 | { 27 | new JsonStringEnumConverter(allowIntegerValues: false), 28 | #if NET6_0_OR_GREATER 29 | new DateOnlyJsonConverter() 30 | #endif 31 | } 32 | }; 33 | 34 | public static JsonSerializerOptions JsonWebKey { get; } = new(JsonSerializerOptions.Default) 35 | { 36 | WriteIndented = true, 37 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault | JsonIgnoreCondition.WhenWritingNull, 38 | TypeInfoResolver = new DefaultJsonTypeInfoResolver 39 | { 40 | Modifiers = 41 | { 42 | info => 43 | { 44 | if (info.Type != typeof(JsonWebKey)) 45 | return; 46 | 47 | foreach (var prop in info.Properties) 48 | { 49 | // Don't serialize empty lists, makes for more concise JWKs 50 | prop.ShouldSerialize = (obj, value) => 51 | value is not null && 52 | (value is not IList list || list.Count > 0); 53 | } 54 | } 55 | } 56 | } 57 | }; 58 | 59 | 60 | #if NET6_0_OR_GREATER 61 | public class DateOnlyJsonConverter : JsonConverter 62 | { 63 | public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 64 | => DateOnly.Parse(reader.GetString()?[..10] ?? "", CultureInfo.InvariantCulture); 65 | 66 | public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) 67 | => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture)); 68 | } 69 | #endif 70 | } 71 | -------------------------------------------------------------------------------- /src/SponsorLink/Tests/Resources.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Tests { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class Resources { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal Resources() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Tests.Resources", typeof(Resources).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/SponsorLink/Tests/Resources.resx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | text/microsoft-resx 91 | 92 | 93 | 1.3 94 | 95 | 96 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 97 | 98 | 99 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 100 | 101 | -------------------------------------------------------------------------------- /src/SponsorLink/Tests/Sample.cs: -------------------------------------------------------------------------------- 1 | extern alias Analyzer; 2 | using System; 3 | using System.Globalization; 4 | using System.Runtime.CompilerServices; 5 | using System.Security.Cryptography; 6 | using Analyzer::Devlooped.Sponsors; 7 | using Microsoft.CodeAnalysis; 8 | using Xunit; 9 | using Xunit.Abstractions; 10 | 11 | namespace Tests; 12 | 13 | public class Sample(ITestOutputHelper output) 14 | { 15 | [Theory] 16 | [InlineData("es-AR", SponsorStatus.Unknown)] 17 | [InlineData("es-AR", SponsorStatus.Expiring)] 18 | [InlineData("es-AR", SponsorStatus.Expired)] 19 | [InlineData("es-AR", SponsorStatus.User)] 20 | [InlineData("es-AR", SponsorStatus.Contributor)] 21 | [InlineData("es", SponsorStatus.Unknown)] 22 | [InlineData("es", SponsorStatus.Expiring)] 23 | [InlineData("es", SponsorStatus.Expired)] 24 | [InlineData("es", SponsorStatus.User)] 25 | [InlineData("es", SponsorStatus.Contributor)] 26 | [InlineData("en", SponsorStatus.Unknown)] 27 | [InlineData("en", SponsorStatus.Expiring)] 28 | [InlineData("en", SponsorStatus.Expired)] 29 | [InlineData("en", SponsorStatus.User)] 30 | [InlineData("en", SponsorStatus.Contributor)] 31 | [InlineData("", SponsorStatus.Unknown)] 32 | [InlineData("", SponsorStatus.Expiring)] 33 | [InlineData("", SponsorStatus.Expired)] 34 | [InlineData("", SponsorStatus.User)] 35 | [InlineData("", SponsorStatus.Contributor)] 36 | public void Test(string culture, SponsorStatus kind) 37 | { 38 | Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = 39 | culture == "" ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(culture); 40 | 41 | var diag = GetDescriptor(["foo"], "bar", "FB", kind); 42 | 43 | output.WriteLine(diag.Title.ToString()); 44 | output.WriteLine(diag.MessageFormat.ToString()); 45 | output.WriteLine(diag.Description.ToString()); 46 | } 47 | 48 | [Fact] 49 | public void RenderSponsorables() 50 | { 51 | Assert.NotEmpty(SponsorLink.Sponsorables); 52 | 53 | foreach (var pair in SponsorLink.Sponsorables) 54 | { 55 | output.WriteLine($"{pair.Key} = {pair.Value}"); 56 | // Read the JWK 57 | var jsonWebKey = Microsoft.IdentityModel.Tokens.JsonWebKey.Create(pair.Value); 58 | 59 | Assert.NotNull(jsonWebKey); 60 | 61 | using var key = RSA.Create(new RSAParameters 62 | { 63 | Modulus = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(jsonWebKey.N), 64 | Exponent = Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(jsonWebKey.E), 65 | }); 66 | } 67 | } 68 | 69 | DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch 70 | { 71 | SponsorStatus.Unknown => DiagnosticsManager.CreateUnknown(sponsorable, product, prefix), 72 | SponsorStatus.Expiring => DiagnosticsManager.CreateExpiring(sponsorable, prefix), 73 | SponsorStatus.Expired => DiagnosticsManager.CreateExpired(sponsorable, prefix), 74 | SponsorStatus.User => DiagnosticsManager.CreateSponsor(sponsorable, prefix), 75 | SponsorStatus.Contributor => DiagnosticsManager.CreateContributor(sponsorable, prefix), 76 | _ => throw new NotImplementedException(), 77 | }; 78 | } -------------------------------------------------------------------------------- /src/SponsorLink/Tests/Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | true 6 | CS8981;$(NoWarn) 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | %(GitRoot.FullPath) 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | true 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/SponsorLink/Tests/keys/kzu.key: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/src/SponsorLink/Tests/keys/kzu.key -------------------------------------------------------------------------------- /src/SponsorLink/Tests/keys/kzu.key.jwk: -------------------------------------------------------------------------------- 1 | { 2 | "d": "OmDrKyd0ap7Az2V09C8E6aASK0nnXHGUdTIymmU1tGoxaWpkZ4gLGaYDp4L5fKc-AaqqD3PjvfJvEWfXLqJtEWfUl4gahWrgUkmuzPyAVFFioIzeGIvTGELsR6lTRke0IB2kvfvS7hRgX8Py8ohCAGHiidmoec2SyKkEg0aPdxrIKV8hx5ybC_D_4zRKn0GuVwATIeVZzPpTcyJX_sn4NHDOqut0Xg02iHhMKpF850BSoC97xGMlcSjLocFSTwI63msz6jWQ-6LRVXsfRr2mqakAvsPpqEQ3Ytk9Ud9xW0ctuAWyo6UXev5w2XEL8cSXm33-57fi3ekC_jGCqW0KfAU4Cr2UbuTC0cv8Vv0F4Xm5FizolmuSBFOvf55-eqsjmpwf9hftYAiIlFF-49-P0DpeJejSeoL06BE3e3_IVu3g3HNnSWVUOLJ5Uk5FQ-ieHhf-r2Tq5qZ8_-losHekQbCxCMY2isc-r6V6BMnVL_9kWPxpXwhjKrYxNFZEXUJ1", 3 | "dp": "HjCs_QF1Hn1SGS2OqZzYhGhNk4PTw9Hs97E7g3pb4liY0uECYOYp1RNoMyzvNBZVwlxhpeTTS299yPoXeYmseXfLtcjfIVi6mSWS_u27Hd0zfSdaPDOXyyK-mZfIV7Q76RTost0QY3LA0ciJbj3gJqpl38dhuNQ8h9Yqt-TFyb3CUaM3A_JUNKOTce8qnkLrasytPEuSroOBT8bgCWJIjw_mXWMGcoqRFWHw9Nyp9mIyvtPjUQ9ig3bGSP_-3LZf", 4 | "dq": "IP6EsAZ_6psFdlQrvnugYFs91fEP5QfBzNHmbfmsPRVX4QM5B3L6klQyJsLqvPfF1Xu17ZffLFkNBKuiphIcLPo0yZTJG9Y7S8gLuPAmrH-ndfxG-bQ8Yt0ZB1pA77ILIS8bUTKrMqAWS-VcaxcSCIyhSusLEWYYDi3PEzB375OUw4aXIk3ob8bePG7UqFSL6qmDPgkGLTxkY9m5dEiOshHygtVY-H_jjOIawliEPgmgAr2M-zlXiphovDyAT0PV", 5 | "e": "AQAB", 6 | "kty": "RSA", 7 | "n": "yP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9-oBw5zIVZekgMP55XxmKvkJd1k-bYWSv-QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y_7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe_bXDkR74yG3mmq_Ne0qhNk6wXuX-NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb-YKry-h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr-OPrxEdZzL9BcI4fMJMz7YdiIu-qbIp_vqatbalfNasumf8RgtPOkR2vgc59", 8 | "p": "6JTf8Qb0iHRL6MIIs7MlkEKBNpnAu_Nie4HTqhxy2wfE4cBr6QZ98iJniXffDIjq_GxVpw9K-Bv2gTcNrlzOiBaLf3X2Itfice_Qd-luhNbnXVfiA5sg6dZ2wbBuue5ann5iJ_TIbxO4CLUiqQp0PCReUPzTQhzesHxM2-dBC9AYDl7P6p1FF53Hh_Knx9UywhoPvNtoCJy35-5rj0ghgPYz289dbOBccZnvabRueOr_wpHGMKaznqiDMrcFSZ07", 9 | "q": "3TvrN8R9imw6E6JkVQ4PtveE0vkvkSWHUpn9KwKFIJJiwL_HSS4z_8IYR1_0Q1OgK5-z-QcXhq9P7jTjz02I2uwWhP3RZQf99RZACfMaeIs8O2V-I89WdlJYOerzAelW4nYw7zyeVoT5c5osicGWfSmWslLRjA1yx7x1KA_MCU_KIEBlpe1RgEUYPET3OtvPKFIVQYoJfQC5PFlmrC-kgHZMSpdHjWgWi5gPn0fIBCKFsXcPrt2n_lKKGc4lFOen", 10 | "qi": "m-tgdFqO1Ax3C00oe7kdkYLHMD56wkGARdqPCqS5IGhFVKCOA8U6O_s5bSL4r0TzPE0KrJ4A5QJEwjbH4bXssPaaAlv1ZdWjn8YMQCYFolg_pgUWYYI5vNxG1gIsLGXPTfE8a6SObkJ2Q9VC5ZZp14r4lPvJhwFICIGSRBKcvS-gO_gqB3LKuG9TQBi-CE4DHDLJwsCbEBR8Ber45oTqvG7hphpOhBHsFZ8_6f3Reg_sK1BCz9HFCx8hhi8rBfUp" 11 | } -------------------------------------------------------------------------------- /src/SponsorLink/Tests/keys/kzu.key.txt: -------------------------------------------------------------------------------- 1 | MIIG4wIBAAKCAYEAyP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9+oBw5zIVZekgMP55XxmKvkJd1k+bYWSv+QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y/7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe/bXDkR74yG3mmq/Ne0qhNk6wXuX+NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb+YKry+h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr+OPrxEdZzL9BcI4fMJMz7YdiIu+qbIp/vqatbalfNasumf8RgtPOkR2vgc59AgMBAAECggGAOmDrKyd0ap7Az2V09C8E6aASK0nnXHGUdTIymmU1tGoxaWpkZ4gLGaYDp4L5fKc+AaqqD3PjvfJvEWfXLqJtEWfUl4gahWrgUkmuzPyAVFFioIzeGIvTGELsR6lTRke0IB2kvfvS7hRgX8Py8ohCAGHiidmoec2SyKkEg0aPdxrIKV8hx5ybC/D/4zRKn0GuVwATIeVZzPpTcyJX/sn4NHDOqut0Xg02iHhMKpF850BSoC97xGMlcSjLocFSTwI63msz6jWQ+6LRVXsfRr2mqakAvsPpqEQ3Ytk9Ud9xW0ctuAWyo6UXev5w2XEL8cSXm33+57fi3ekC/jGCqW0KfAU4Cr2UbuTC0cv8Vv0F4Xm5FizolmuSBFOvf55+eqsjmpwf9hftYAiIlFF+49+P0DpeJejSeoL06BE3e3/IVu3g3HNnSWVUOLJ5Uk5FQ+ieHhf+r2Tq5qZ8/+losHekQbCxCMY2isc+r6V6BMnVL/9kWPxpXwhjKrYxNFZEXUJ1AoHBAOiU3/EG9Ih0S+jCCLOzJZBCgTaZwLvzYnuB06occtsHxOHAa+kGffIiZ4l33wyI6vxsVacPSvgb9oE3Da5czogWi3919iLX4nHv0HfpboTW511X4gObIOnWdsGwbrnuWp5+Yif0yG8TuAi1IqkKdDwkXlD800Ic3rB8TNvnQQvQGA5ez+qdRRedx4fyp8fVMsIaD7zbaAict+fua49IIYD2M9vPXWzgXHGZ72m0bnjq/8KRxjCms56ogzK3BUmdOwKBwQDdO+s3xH2KbDoTomRVDg+294TS+S+RJYdSmf0rAoUgkmLAv8dJLjP/whhHX/RDU6Arn7P5BxeGr0/uNOPPTYja7BaE/dFlB/31FkAJ8xp4izw7ZX4jz1Z2Ulg56vMB6VbidjDvPJ5WhPlzmiyJwZZ9KZayUtGMDXLHvHUoD8wJT8ogQGWl7VGARRg8RPc6288oUhVBigl9ALk8WWasL6SAdkxKl0eNaBaLmA+fR8gEIoWxdw+u3af+UooZziUU56cCgcAeMKz9AXUefVIZLY6pnNiEaE2Tg9PD0ez3sTuDelviWJjS4QJg5inVE2gzLO80FlXCXGGl5NNLb33I+hd5iax5d8u1yN8hWLqZJZL+7bsd3TN9J1o8M5fLIr6Zl8hXtDvpFOiy3RBjcsDRyIluPeAmqmXfx2G41DyH1iq35MXJvcJRozcD8lQ0o5Nx7yqeQutqzK08S5Kug4FPxuAJYkiPD+ZdYwZyipEVYfD03Kn2YjK+0+NRD2KDdsZI//7ctl8CgcAg/oSwBn/qmwV2VCu+e6BgWz3V8Q/lB8HM0eZt+aw9FVfhAzkHcvqSVDImwuq898XVe7Xtl98sWQ0Eq6KmEhws+jTJlMkb1jtLyAu48Casf6d1/Eb5tDxi3RkHWkDvsgshLxtRMqsyoBZL5VxrFxIIjKFK6wsRZhgOLc8TMHfvk5TDhpciTehvxt48btSoVIvqqYM+CQYtPGRj2bl0SI6yEfKC1Vj4f+OM4hrCWIQ+CaACvYz7OVeKmGi8PIBPQ9UCgcEAm+tgdFqO1Ax3C00oe7kdkYLHMD56wkGARdqPCqS5IGhFVKCOA8U6O/s5bSL4r0TzPE0KrJ4A5QJEwjbH4bXssPaaAlv1ZdWjn8YMQCYFolg/pgUWYYI5vNxG1gIsLGXPTfE8a6SObkJ2Q9VC5ZZp14r4lPvJhwFICIGSRBKcvS+gO/gqB3LKuG9TQBi+CE4DHDLJwsCbEBR8Ber45oTqvG7hphpOhBHsFZ8/6f3Reg/sK1BCz9HFCx8hhi8rBfUp -------------------------------------------------------------------------------- /src/SponsorLink/Tests/keys/kzu.pub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/src/SponsorLink/Tests/keys/kzu.pub -------------------------------------------------------------------------------- /src/SponsorLink/Tests/keys/kzu.pub.jwk: -------------------------------------------------------------------------------- 1 | { 2 | "e": "AQAB", 3 | "kty": "RSA", 4 | "n": "yP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9-oBw5zIVZekgMP55XxmKvkJd1k-bYWSv-QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y_7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe_bXDkR74yG3mmq_Ne0qhNk6wXuX-NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb-YKry-h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr-OPrxEdZzL9BcI4fMJMz7YdiIu-qbIp_vqatbalfNasumf8RgtPOkR2vgc59" 5 | } -------------------------------------------------------------------------------- /src/SponsorLink/Tests/keys/kzu.pub.txt: -------------------------------------------------------------------------------- 1 | MIIBigKCAYEAyP71VgOgHDtGbxdyN31mIFFITmGYEk2cwepKbyqKTbTYXF1OXaMoP5n3mfwqwzUQmEAsrclAigPcK4GIy5WWlc5YujIxKauJjsKe0FBxMnFp9o1UcBUHfgDJjaAKieQxb44717b1MwCcflEGnCGTXkntdr45y9Gi1D9+oBw5zIVZekgMP55XxmKvkJd1k+bYWSv+QFG2JJwRIGwr29Jr62juCsLB7Tg83ZGKCa22Y/7lQcezxRRD5OrGWhf3gTYArbrEzbYy653zbHfbOCJeVBe/bXDkR74yG3mmq/Ne0qhNk6wXuX+NrKEvdPxRSRBF7C465fcVY9PM6eTqEPQwKDiarHpU1NTwUetzb+YKry+h678RJWMhC7I9lzzWVobbC0YVKG7XpeVqBB4u7Q6cGo5Xkf19VldkIxQMu9sFeuHGDSoiCLqmRmwNn9GsMV77oZWr+OPrxEdZzL9BcI4fMJMz7YdiIu+qbIp/vqatbalfNasumf8RgtPOkR2vgc59AgMBAAE= -------------------------------------------------------------------------------- /src/SponsorLink/Tests/keys/sponsorlink.jwt: -------------------------------------------------------------------------------- 1 | eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTk4NjgyMzAsImlzcyI6Imh0dHBzOi8vc3BvbnNvcmxpbmsuZGV2bG9vcGVkLmNvbS8iLCJhdWQiOlsiaHR0cHM6Ly9naXRodWIuY29tL3Nwb25zb3JzL2t6dSIsImh0dHBzOi8vZ2l0aHViLmNvbS9zcG9uc29ycy9kZXZsb29wZWQiXSwiY2xpZW50X2lkIjoiYTgyMzUwZmIyYmFlNDA3YjMwMjEiLCJzdWJfandrIjp7ImUiOiJBUUFCIiwia3R5IjoiUlNBIiwibiI6InlQNzFWZ09nSER0R2J4ZHlOMzFtSUZGSVRtR1lFazJjd2VwS2J5cUtUYlRZWEYxT1hhTW9QNW4zbWZ3cXd6VVFtRUFzcmNsQWlnUGNLNEdJeTVXV2xjNVl1akl4S2F1SmpzS2UwRkJ4TW5GcDlvMVVjQlVIZmdESmphQUtpZVF4YjQ0NzE3YjFNd0NjZmxFR25DR1RYa250ZHI0NXk5R2kxRDktb0J3NXpJVlpla2dNUDU1WHhtS3ZrSmQxay1iWVdTdi1RRkcySkp3UklHd3IyOUpyNjJqdUNzTEI3VGc4M1pHS0NhMjJZXzdsUWNlenhSUkQ1T3JHV2hmM2dUWUFyYnJFemJZeTY1M3piSGZiT0NKZVZCZV9iWERrUjc0eUczbW1xX05lMHFoTms2d1h1WC1OcktFdmRQeFJTUkJGN0M0NjVmY1ZZOVBNNmVUcUVQUXdLRGlhckhwVTFOVHdVZXR6Yi1ZS3J5LWg2NzhSSldNaEM3STlsenpXVm9iYkMwWVZLRzdYcGVWcUJCNHU3UTZjR281WGtmMTlWbGRrSXhRTXU5c0ZldUhHRFNvaUNMcW1SbXdObjlHc01WNzdvWldyLU9QcnhFZFp6TDlCY0k0Zk1KTXo3WWRpSXUtcWJJcF92cWF0YmFsZk5hc3VtZjhSZ3RQT2tSMnZnYzU5In19.er4apYbEjHVKlQ_aMXoRhHYeR8N-3uIrCk3HX8UuZO7mb0CaS94-422EI3z5O9vRvckcGkNVoiSIX0ykZqUMHTZxBae-QZc1u_rhdBOChoaxWqpUiPXLZ5-yi7mcRwqg2DOUb2eHTNfRjwJ-0tjL1R1TqZw9d8Bgku1zw2ZTuJl_WsBRHKHTD_s5KyCP5yhSOUumrsf3nXYrc20fJ7ql0FsL0MP66utJk7TFYHGhQV3cfcXYqFEpv-k6tqB9k3Syc0UnepmQT0Y3dtcBzQzCOzfKQ8bdaAXVHjfp4VvXBluHmh9lP6TeZmpvlmQDFvyk0kp1diTbo9pqmX_llNDWNxBdvaSZGa7RZMG_dE2WJGtQNu0C_sbEZDPZsKncxdtm-j-6Y7GRqx7uxe4Py8tAZ7SxjiPgD64jf9KF2OT6f6drVtzohVzYCs6-vhcXzC2sQvd_gQ-SoFNTa1MEcMgGbL-fFWUC7-7bQV1DlSg2YFwrxEIwbM-gHpLZHyyJLvYD -------------------------------------------------------------------------------- /src/SponsorLink/jwk.ps1: -------------------------------------------------------------------------------- 1 | curl https://raw.githubusercontent.com/devlooped/.github/main/sponsorlink.jwt --silent | jq -R 'split(".") | .[1] | @base64d | fromjson' | jq '.sub_jwk' -------------------------------------------------------------------------------- /src/SponsorLink/readme.md: -------------------------------------------------------------------------------- 1 | # SponsorLink .NET Analyzer Sample 2 | 3 | This is one opinionated implementation of [SponsorLink](https://devlooped.com/SponsorLink) 4 | for .NET projects leveraging Roslyn analyzers. 5 | 6 | It is intended for use by [devlooped](https://github.com/devlooped) projects, but can be 7 | used as a template for other sponsorables as well. Supporting arbitrary sponsoring scenarios 8 | is out of scope though, since we just use GitHub sponsors for now. 9 | 10 | ## Usage 11 | 12 | A project can include all the necessary files by using the [dotnet-file](https://github.com/devlooped/dotnet-file) 13 | tool and sync all files to a folder, such as: 14 | 15 | ```shell 16 | dotnet file add https://github.com/devlooped/SponsorLink/tree/main/samples/dotnet/ src/SponsorLink/ 17 | ``` 18 | 19 | Including the analyzer and targets in a project involves two steps. 20 | 21 | 1. Create an analyzer project and add the following property: 22 | 23 | ```xml 24 | 25 | ... 26 | $(MSBuildThisFileDirectory)..\SponsorLink\SponsorLink.Analyzer.targets 27 | 28 | ``` 29 | 30 | 2. Add a `buildTransitive\[PackageId].targets` file with the following import: 31 | 32 | ```xml 33 | 34 | 35 | 36 | ``` 37 | 38 | 3. Set the package id(s) that will be checked for funding in the analyzer, such as: 39 | 40 | ```xml 41 | 42 | SponsorableLib;SponsorableLib.Core 43 | 44 | ``` 45 | 46 | The default analyzer will report a diagnostic for sponsorship status only 47 | if the project being compiled as a direct package reference to one of the 48 | specified package ids. 49 | 50 | This property defaults to `$(PackageId)` if present. Otherwise, it defaults 51 | to `$(FundingProduct)`, which in turn defaults to `$(Product)` if not provided. 52 | 53 | As long as NuGetizer is used, the right packaging will be done automatically. -------------------------------------------------------------------------------- /src/TableStorage.Bson.Source/Devlooped.TableStorage.Bson.Source.targets: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | false 6 | Devlooped\TableStorage.Bson\%(Filename)%(Extension) 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/TableStorage.Bson.Source/TableStorage.Bson.Source.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.Bson.Source 5 | netstandard2.0 6 | true 7 | false 8 | true 9 | A source-only BSON binary serializer for use with document-based repositories. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/TableStorage.Bson.Source/readme.md: -------------------------------------------------------------------------------- 1 | A source-only BSON binary serializer for use with document-based repositories. 2 | 3 | Usage: 4 | 5 | ```csharp 6 | var repo = DocumentRepository.Create(..., serializer: BsonDocumentSerializer.Default); 7 | ``` 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/TableStorage.Bson/BsonDocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Globalization; 5 | using System.IO; 6 | using Newtonsoft.Json; 7 | 8 | namespace Devlooped 9 | { 10 | /// 11 | /// Implementation of which 12 | /// uses Newtonsoft.Json implementation of BSON for serialization. 13 | /// 14 | partial class BsonDocumentSerializer : IBinaryDocumentSerializer 15 | { 16 | static readonly JsonSerializer serializer = 17 | #if NET6_0_OR_GREATER 18 | JsonSerializer.Create(new JsonSerializerSettings 19 | { 20 | Converters = 21 | { 22 | new DateOnlyJsonConverter(), 23 | } 24 | }); 25 | #else 26 | new JsonSerializer(); 27 | #endif 28 | 29 | /// 30 | /// Default instance of the serializer. 31 | /// 32 | public static IDocumentSerializer Default { get; } = new BsonDocumentSerializer(); 33 | 34 | /// 35 | public T? Deserialize(byte[] data) 36 | { 37 | if (data.Length == 0) 38 | return default; 39 | 40 | using var mem = new MemoryStream(data); 41 | using var reader = new Newtonsoft.Json.Bson.BsonDataReader(mem); 42 | return (T?)serializer.Deserialize(reader); 43 | } 44 | 45 | /// 46 | public byte[] Serialize(T value) 47 | { 48 | if (value == null) 49 | return new byte[0]; 50 | 51 | using var mem = new MemoryStream(); 52 | using var writer = new Newtonsoft.Json.Bson.BsonDataWriter(mem); 53 | serializer.Serialize(writer, value); 54 | return mem.ToArray(); 55 | } 56 | 57 | #if NET6_0_OR_GREATER 58 | class DateOnlyJsonConverter : JsonConverter 59 | { 60 | public override DateOnly ReadJson(JsonReader reader, Type objectType, DateOnly existingValue, bool hasExistingValue, JsonSerializer serializer) 61 | => DateOnly.Parse((string)reader.Value!, CultureInfo.InvariantCulture); 62 | 63 | public override void WriteJson(JsonWriter writer, DateOnly value, JsonSerializer serializer) 64 | => writer.WriteValue(value.ToString("O", CultureInfo.InvariantCulture)); 65 | } 66 | #endif 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/TableStorage.Bson/TableStorage.Bson.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.Bson 5 | netstandard2.0;net6.0 6 | true 7 | A BSON binary serializer for use with document-based repositories. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/TableStorage.Bson/Visibility.cs: -------------------------------------------------------------------------------- 1 | // 2 | namespace Devlooped 3 | { 4 | // Sets default visibility when using compiled version, where everything is public 5 | public partial class BsonDocumentSerializer { } 6 | } 7 | -------------------------------------------------------------------------------- /src/TableStorage.Bson/readme.md: -------------------------------------------------------------------------------- 1 | 2 | A BSON binary serializer for use with document-based repositories. 3 | 4 | Usage: 5 | 6 | ```csharp 7 | var repo = DocumentRepository.Create(..., serializer: BsonDocumentSerializer.Default); 8 | ``` 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/TableStorage.CodeAnalysis/Devlooped.TableStorage.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/TableStorage.CodeAnalysis/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "TableStorage.CodeAnalysis": { 4 | "commandName": "DebugRoslynComponent", 5 | "targetProject": "..\\Tests\\Tests.csproj", 6 | "environmentVariables": { 7 | "SPONSORLINK_TRACE": "true" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/TableStorage.CodeAnalysis/TableStorage.CodeAnalysis.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.CodeAnalysis 5 | netstandard2.0 6 | analyzers/dotnet/roslyn4.0 7 | false 8 | $(MSBuildThisFileDirectory)..\SponsorLink\SponsorLink.Analyzer.targets 9 | Devlooped.TableStorage 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/TableStorage.Memory/ConstantReducer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using System.Reflection; 3 | 4 | class ConstantReducer : ExpressionVisitor 5 | { 6 | protected override Expression VisitMember(MemberExpression node) 7 | { 8 | // If the expression is a constant or can be reduced to a constant, evaluate it 9 | if (node.Expression is ConstantExpression constantExpr) 10 | { 11 | object? container = constantExpr.Value; 12 | object? value = null; 13 | 14 | if (node.Member is FieldInfo field) 15 | value = field.GetValue(container); 16 | else if (node.Member is PropertyInfo prop) 17 | value = prop.GetValue(container); 18 | 19 | return Expression.Constant(value, node.Type); 20 | } 21 | 22 | // Try to evaluate more complex expressions 23 | try 24 | { 25 | var lambda = Expression.Lambda(node); 26 | var compiled = lambda.Compile(); 27 | var value = compiled.DynamicInvoke(); 28 | return Expression.Constant(value, node.Type); 29 | } 30 | catch 31 | { 32 | // If evaluation fails, fallback to default behavior 33 | return base.VisitMember(node); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/TableStorage.Memory/MemoryPartition.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Concurrent; 5 | using System.Linq; 6 | using System.Linq.Expressions; 7 | using System.Reflection; 8 | using Azure.Data.Tables; 9 | 10 | namespace Devlooped; 11 | 12 | /// 13 | /// Factory methods to create instances 14 | /// that store entities using individual columns for entity properties. 15 | /// 16 | public static partial class MemoryPartition 17 | { 18 | /// 19 | /// Default table name to use when a value is not not provided 20 | /// (or overriden via ), which is Entities. 21 | /// 22 | public const string DefaultTableName = "Entities"; 23 | 24 | /// 25 | /// Creates an . 26 | /// 27 | /// Table name to use. 28 | /// Fixed partition key to scope entity persistence. 29 | /// The new . 30 | public static MemoryPartition Create(string tableName, string partitionKey) 31 | => new MemoryPartition(tableName, partitionKey, x => x.RowKey); 32 | 33 | /// 34 | /// Creates an for the given entity type 35 | /// , using as the table name and the 36 | /// Name as the partition key. 37 | /// 38 | /// The type of entity that the repository will manage. 39 | /// Table name to use. 40 | /// Function to retrieve the row key for a given entity. 41 | /// The new . 42 | public static MemoryPartition Create( 43 | string tableName, 44 | Expression> rowKey) where T : class 45 | => Create(DefaultTableName, default, rowKey); 46 | 47 | /// 48 | /// Creates an for the given entity type 49 | /// . 50 | /// 51 | /// The type of entity that the repository will manage. 52 | /// Optional table name to use. If not provided, 53 | /// will be used, unless a on the type overrides it. 54 | /// Optional fixed partition key to scope entity persistence. 55 | /// If not provided, the Name will be used. 56 | /// Optional function to retrieve the row key for a given entity. 57 | /// If not provided, the class will need a property annotated with . 58 | /// The new . 59 | public static MemoryPartition Create( 60 | string? tableName = default, 61 | string? partitionKey = null, 62 | Expression>? rowKey = null) where T : class 63 | { 64 | partitionKey ??= TablePartition.GetDefaultPartitionKey(); 65 | rowKey ??= RowKeyAttribute.CreateAccessor(); 66 | 67 | return new MemoryPartition(tableName ?? TablePartition.GetDefaultTableName(), partitionKey, rowKey); 68 | } 69 | } -------------------------------------------------------------------------------- /src/TableStorage.Memory/MemoryPartition`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.ComponentModel; 6 | using System.Linq; 7 | using System.Linq.Expressions; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Azure.Data.Tables; 11 | using Mono.Linq.Expressions; 12 | 13 | namespace Devlooped; 14 | 15 | /// 16 | public partial class MemoryPartition : ITablePartition, IDocumentPartition where T : class 17 | { 18 | readonly MemoryRepository repository; 19 | 20 | /// 21 | /// Initializes the repository with the given storage account and optional table name. 22 | /// 23 | /// The to use to connect to the table. 24 | public MemoryPartition() 25 | : this(TablePartition.GetDefaultTableName(), 26 | TablePartition.GetDefaultPartitionKey(), 27 | RowKeyAttribute.CreateAccessor()) 28 | { } 29 | 30 | /// 31 | /// Initializes the repository with the given storage account and optional table name. 32 | /// 33 | /// The table that backs this table partition. 34 | public MemoryPartition(string tableName) 35 | : this(tableName ?? TablePartition.GetDefaultTableName(), 36 | TablePartition.GetDefaultPartitionKey(), 37 | RowKeyAttribute.CreateAccessor()) 38 | { } 39 | 40 | /// 41 | /// Initializes the repository with the given storage account and optional table name. 42 | /// 43 | /// The table that backs this table partition. 44 | /// The fixed partition key that backs this table partition. 45 | public MemoryPartition(string tableName, string partitionKey) 46 | : this(tableName ?? TablePartition.GetDefaultTableName(), 47 | partitionKey, 48 | RowKeyAttribute.CreateAccessor()) 49 | { } 50 | 51 | /// 52 | /// Initializes the repository with the given storage account and optional table name. 53 | /// 54 | /// The table that backs this table partition. 55 | /// The fixed partition key that backs this table partition. 56 | /// A function to determine the row key for an entity of type within the partition. 57 | public MemoryPartition(string tableName, string partitionKey, Expression> rowKey) 58 | { 59 | partitionKey ??= TablePartition.GetDefaultPartitionKey(); 60 | PartitionKey = partitionKey; 61 | 62 | repository = new MemoryRepository(tableName, _ => partitionKey, 63 | rowKey ?? RowKeyAttribute.CreateAccessor()); 64 | } 65 | 66 | /// 67 | public string TableName => repository.TableName; 68 | 69 | /// 70 | public string PartitionKey { get; } 71 | 72 | /// 73 | public IQueryable CreateQuery() => repository.CreateQuery(PartitionKey); 74 | 75 | /// 76 | public Task DeleteAsync(T entity, CancellationToken cancellation = default) 77 | { 78 | if (entity is TableEntity te && !PartitionKey.Equals(te.PartitionKey, StringComparison.Ordinal)) 79 | throw new ArgumentException("Entity does not belong to the partition."); 80 | 81 | return repository.DeleteAsync(entity, cancellation); 82 | } 83 | 84 | /// 85 | public Task DeleteAsync(string rowKey, CancellationToken cancellation = default) 86 | => repository.DeleteAsync(PartitionKey, rowKey, cancellation); 87 | 88 | /// 89 | public IAsyncEnumerable EnumerateAsync(CancellationToken cancellation = default) 90 | => repository.EnumerateAsync(PartitionKey, cancellation); 91 | 92 | /// 93 | public IAsyncEnumerable EnumerateAsync(Expression> predicate, CancellationToken cancellation = default) 94 | => repository.EnumerateAsync(predicate.AndAlso(x => x.PartitionKey == PartitionKey), cancellation); 95 | 96 | /// 97 | public Task GetAsync(string rowKey, CancellationToken cancellation = default) 98 | => repository.GetAsync(PartitionKey, rowKey, cancellation); 99 | 100 | /// 101 | public Task PutAsync(T entity, CancellationToken cancellation = default) 102 | { 103 | if (entity is TableEntity te && !PartitionKey.Equals(te.PartitionKey, StringComparison.Ordinal)) 104 | throw new ArgumentException("Entity does not belong to the partition."); 105 | 106 | return repository.PutAsync(entity, cancellation); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/TableStorage.Memory/MemoryRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using Azure.Data.Tables; 4 | 5 | namespace Devlooped; 6 | 7 | /// 8 | /// Factory methods to create in-memory and 9 | /// instances (since 10 | /// implements both. 11 | /// 12 | public static class MemoryRepository 13 | { 14 | /// 15 | /// Creates an repository. 16 | /// 17 | /// Table name to use. 18 | /// The new . 19 | public static MemoryRepository Create(string tableName) 20 | => new(tableName, x => x.PartitionKey, x => x.RowKey); 21 | 22 | /// 23 | /// Creates an repository. 24 | /// 25 | /// The new . 26 | public static MemoryRepository Create() 27 | => new("Entities", x => x.PartitionKey, x => x.RowKey); 28 | 29 | /// 30 | /// Creates an for the given entity type 31 | /// , using the Name as 32 | /// the table name. 33 | /// 34 | /// The type of entity that the repository will manage. 35 | /// Function to retrieve the partition key for a given entity. 36 | /// Function to retrieve the row key for a given entity. 37 | /// The new . 38 | public static MemoryRepository Create( 39 | Expression> partitionKey, 40 | Expression> rowKey) where T : class 41 | => Create(typeof(T).Name, partitionKey, rowKey); 42 | 43 | /// 44 | /// Creates an for the given entity type 45 | /// . 46 | /// 47 | /// The type of entity that the repository will manage. 48 | /// Optional table name to use. If not provided, the 49 | /// Optional function to retrieve the partition key for a given entity. 50 | /// If not provided, the class will need a property annotated with . 51 | /// Optional function to retrieve the row key for a given entity. 52 | /// If not provided, the class will need a property annotated with . 53 | /// The new . 54 | public static MemoryRepository Create( 55 | string? tableName = default, 56 | Expression>? partitionKey = null, 57 | Expression>? rowKey = null) where T : class 58 | { 59 | partitionKey ??= PartitionKeyAttribute.CreateAccessor(); 60 | rowKey ??= RowKeyAttribute.CreateAccessor(); 61 | 62 | return new MemoryRepository(tableName ?? TableRepository.GetDefaultTableName(), partitionKey, rowKey); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/TableStorage.Memory/TableStorage.Memory.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Devlooped.TableStorage.Memory 5 | netstandard2.0 6 | true 7 | In-memory implementations for easy testing of Azure/CosmosDB Table Storage 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/TableStorage.MessagePack.Source/Devlooped.TableStorage.MessagePack.Source.targets: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | false 6 | Devlooped\TableStorage.MessagePack\%(Filename)%(Extension) 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/TableStorage.MessagePack.Source/TableStorage.MessagePack.Source.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.MessagePack.Source 5 | netstandard2.0 6 | true 7 | false 8 | true 9 | A source-only MessagePack binary serializer for use with document-based repositories. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/TableStorage.MessagePack.Source/readme.md: -------------------------------------------------------------------------------- 1 | A source-only MessagePack binary serializer for use with document-based repositories. 2 | 3 | Usage: 4 | 5 | ```csharp 6 | var repo = DocumentRepository.Create(..., serializer: MessagePackDocumentSerializer.Default); 7 | ``` 8 | 9 | > NOTE: MessagePack attributes must be used as usual in order for the serialization to work. 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/TableStorage.MessagePack/MessagePackDocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using MessagePack; 6 | using MessagePack.Formatters; 7 | using MessagePack.Resolvers; 8 | 9 | namespace Devlooped 10 | { 11 | /// 12 | /// Default implementation of which 13 | /// uses Newtonsoft.Json implementation of BSON for serialization. 14 | /// 15 | partial class MessagePackDocumentSerializer : IBinaryDocumentSerializer 16 | { 17 | /// 18 | /// Default instance of the serializer using default serialization options. 19 | /// 20 | public static IDocumentSerializer Default { get; } = new MessagePackDocumentSerializer(); 21 | 22 | readonly MessagePackSerializerOptions? options; 23 | 24 | /// 25 | /// Initializes the document serializer with the given optional serializer options. 26 | /// 27 | public MessagePackDocumentSerializer(MessagePackSerializerOptions? options = default) 28 | { 29 | this.options = options; 30 | #if NET6_0_OR_GREATER 31 | this.options = (options ?? MessagePackSerializerOptions.Standard) 32 | .WithResolver(CompositeResolver.Create( 33 | StandardResolver.Instance, 34 | DateOnlyFormatterResolver.Instance)); 35 | #endif 36 | } 37 | 38 | /// 39 | public T? Deserialize(byte[] data) => data.Length == 0 ? default : MessagePackSerializer.Deserialize(data, options); 40 | 41 | /// 42 | public byte[] Serialize(T value) => value == null ? new byte[0] : MessagePackSerializer.Serialize(value.GetType(), value, options); 43 | 44 | #if NET6_0_OR_GREATER 45 | internal class DateOnlyFormatterResolver : IFormatterResolver 46 | { 47 | public static IFormatterResolver Instance = new DateOnlyFormatterResolver(); 48 | 49 | public IMessagePackFormatter GetFormatter() 50 | { 51 | if (typeof(T) == typeof(DateOnly)) 52 | return (IMessagePackFormatter)DateOnlyFormatter.Instance; 53 | 54 | return null!; 55 | } 56 | 57 | internal class DateOnlyFormatter : IMessagePackFormatter 58 | { 59 | public static readonly IMessagePackFormatter Instance = new DateOnlyFormatter(); 60 | 61 | public void Serialize(ref MessagePackWriter writer, DateOnly value, MessagePackSerializerOptions options) 62 | => writer.Write(value.DayNumber); 63 | 64 | public DateOnly Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) 65 | => DateOnly.FromDayNumber(reader.ReadInt32()); 66 | } 67 | } 68 | #endif 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/TableStorage.MessagePack/TableStorage.MessagePack.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.MessagePack 5 | netstandard2.0;net6.0 6 | true 7 | A MessagePack binary serializer for use with document-based repositories. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/TableStorage.MessagePack/Visibility.cs: -------------------------------------------------------------------------------- 1 | // 2 | namespace Devlooped 3 | { 4 | // Sets default visibility when using compiled version, where everything is public 5 | public partial class MessagePackDocumentSerializer { } 6 | } 7 | -------------------------------------------------------------------------------- /src/TableStorage.MessagePack/readme.md: -------------------------------------------------------------------------------- 1 | 2 | A MessagePack binary serializer for use with document-based repositories. 3 | 4 | Usage: 5 | 6 | ```csharp 7 | var repo = DocumentRepository.Create(..., serializer: MessagePackDocumentSerializer.Default); 8 | ``` 9 | 10 | > NOTE: MessagePack attributes must be used as usual in order for the serialization to work. 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/TableStorage.Newtonsoft.Source/Devlooped.TableStorage.Newtonsoft.Source.targets: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | false 6 | Devlooped\TableStorage.Newtonsoft\%(Filename)%(Extension) 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/TableStorage.Newtonsoft.Source/TableStorage.Newtonsoft.Source.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.Newtonsoft.Source 5 | netstandard2.0 6 | true 7 | false 8 | true 9 | A source-only Newtonsoft.Json-based serializer for use with document-based repositories. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/TableStorage.Newtonsoft.Source/readme.md: -------------------------------------------------------------------------------- 1 | A source-only Json.NET serializer for use with document-based repositories. 2 | 3 | Usage: 4 | 5 | ```csharp 6 | var repo = DocumentRepository.Create(..., serializer: JsonDocumentSerializer.Default); 7 | ``` 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/TableStorage.Newtonsoft/JsonDocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Globalization; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Converters; 7 | 8 | namespace Devlooped 9 | { 10 | /// 11 | /// Implementation of which 12 | /// uses Newtonsoft.Json implementation for serialization. 13 | /// 14 | partial class JsonDocumentSerializer : IStringDocumentSerializer 15 | { 16 | /// 17 | /// Default serializer settings to use for the singleton 18 | /// document serialier. 19 | /// 20 | /// 21 | /// Default settings are: 22 | /// 23 | /// - with configured with 24 | /// = and 25 | /// = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffK" 26 | /// 27 | /// 28 | /// - = 29 | /// 30 | /// - = 31 | /// 32 | /// 33 | public static JsonSerializerSettings DefaultSettings { get; } = new JsonSerializerSettings 34 | { 35 | Converters = 36 | { 37 | new IsoDateTimeConverter 38 | { 39 | DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffK", 40 | DateTimeStyles = DateTimeStyles.AdjustToUniversal 41 | }, 42 | #if NET6_0_OR_GREATER 43 | new DateOnlyJsonConverter(), 44 | #endif 45 | }, 46 | DateFormatHandling = DateFormatHandling.IsoDateFormat, 47 | DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffK", 48 | Formatting = Formatting.Indented, 49 | NullValueHandling = NullValueHandling.Ignore, 50 | }; 51 | 52 | /// 53 | /// Default instance of the serializer, using the . 54 | /// 55 | public static IDocumentSerializer Default { get; } = new JsonDocumentSerializer(); 56 | 57 | readonly JsonSerializerSettings settings; 58 | 59 | /// 60 | /// Initializes the document serializer using the . 61 | /// 62 | public JsonDocumentSerializer() 63 | : this(DefaultSettings) { } 64 | 65 | /// 66 | /// Initializes the document serializer using the given . 67 | /// 68 | public JsonDocumentSerializer(JsonSerializerSettings settings) => this.settings = settings; 69 | 70 | /// 71 | public T? Deserialize(string data) => JsonConvert.DeserializeObject(data, settings); 72 | 73 | /// 74 | public string Serialize(T value) => JsonConvert.SerializeObject(value, settings); 75 | 76 | #if NET6_0_OR_GREATER 77 | class DateOnlyJsonConverter : JsonConverter 78 | { 79 | public override DateOnly ReadJson(JsonReader reader, Type objectType, DateOnly existingValue, bool hasExistingValue, JsonSerializer serializer) 80 | => DateOnly.Parse((string)reader.Value!, CultureInfo.InvariantCulture); 81 | 82 | public override void WriteJson(JsonWriter writer, DateOnly value, JsonSerializer serializer) 83 | => writer.WriteValue(value.ToString("O", CultureInfo.InvariantCulture)); 84 | } 85 | #endif 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/TableStorage.Newtonsoft/TableStorage.Newtonsoft.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.Newtonsoft 5 | netstandard2.0;net6.0 6 | true 7 | A Json.NET serializer for use with document-based repositories. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/TableStorage.Newtonsoft/Visibility.cs: -------------------------------------------------------------------------------- 1 | // 2 | namespace Devlooped 3 | { 4 | // Sets default visibility when using compiled version, where everything is public 5 | public partial class JsonDocumentSerializer { } 6 | } 7 | -------------------------------------------------------------------------------- /src/TableStorage.Newtonsoft/readme.md: -------------------------------------------------------------------------------- 1 | 2 | A Json.NET serializer for use with document-based repositories. 3 | 4 | Usage: 5 | 6 | ```csharp 7 | var repo = DocumentRepository.Create(..., serializer: JsonDocumentSerializer.Default); 8 | ``` 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf.Source/Devlooped.TableStorage.Protobuf.Source.targets: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | false 6 | Devlooped\TableStorage.Protobuf\%(Filename)%(Extension) 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf.Source/TableStorage.Protobuf.Source.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.Protobuf.Source 5 | netstandard2.0 6 | true 7 | false 8 | true 9 | A source-only Protocol Buffers binary serializer for use with document-based repositories. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf.Source/readme.md: -------------------------------------------------------------------------------- 1 | A source-only Protocol Buffers binary serializer for use with document-based repositories. 2 | 3 | Usage: 4 | 5 | ```csharp 6 | var repo = DocumentRepository.Create(..., serializer: ProtobufDocumentSerializer.Default); 7 | ``` 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf/DateTimeOffsetSurrogate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.Serialization; 3 | 4 | namespace ProtobufNetTest 5 | { 6 | [DataContract(Name = nameof(DateTimeOffset))] 7 | public class DateTimeOffsetSurrogate 8 | { 9 | [DataMember(Order = 1)] 10 | public long? Value { get; set; } 11 | 12 | public static implicit operator DateTimeOffset(DateTimeOffsetSurrogate surrogate) 13 | { 14 | return DateTimeOffset.FromUnixTimeMilliseconds(surrogate.Value.GetValueOrDefault()); 15 | } 16 | 17 | public static implicit operator DateTimeOffset?(DateTimeOffsetSurrogate surrogate) 18 | { 19 | return surrogate != null ? DateTimeOffset.FromUnixTimeMilliseconds(surrogate.Value.GetValueOrDefault()) : null; 20 | } 21 | 22 | public static implicit operator DateTimeOffsetSurrogate(DateTimeOffset source) 23 | { 24 | return new DateTimeOffsetSurrogate 25 | { 26 | Value = source.ToUnixTimeMilliseconds() 27 | }; 28 | } 29 | 30 | public static implicit operator DateTimeOffsetSurrogate(DateTimeOffset? source) 31 | { 32 | return new DateTimeOffsetSurrogate 33 | { 34 | Value = source?.ToUnixTimeMilliseconds() 35 | }; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/TableStorage.Protobuf/ProtobufDocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.IO; 5 | using ProtoBuf; 6 | using ProtoBuf.Meta; 7 | using ProtobufNetTest; 8 | 9 | namespace Devlooped 10 | { 11 | /// 12 | /// Default implementation of which 13 | /// uses Newtonsoft.Json implementation of BSON for serialization. 14 | /// 15 | partial class ProtobufDocumentSerializer : IBinaryDocumentSerializer 16 | { 17 | /// 18 | /// Default instance of the serializer. 19 | /// 20 | public static IDocumentSerializer Default { get; } = new ProtobufDocumentSerializer(); 21 | 22 | static ProtobufDocumentSerializer() 23 | { 24 | RuntimeTypeModel.Default.Add(typeof(DateTimeOffset), false).SetSurrogate(typeof(DateTimeOffsetSurrogate)); 25 | RuntimeTypeModel.Default.Add(typeof(DateTimeOffset?), false).SetSurrogate(typeof(DateTimeOffsetSurrogate)); 26 | } 27 | 28 | /// 29 | public T? Deserialize(byte[] data) 30 | { 31 | if (data.Length == 0) 32 | return default; 33 | 34 | using var mem = new MemoryStream(data); 35 | return (T?)Serializer.Deserialize(typeof(T), mem); 36 | } 37 | 38 | /// 39 | public byte[] Serialize(T value) 40 | { 41 | if (value == null) 42 | return new byte[0]; 43 | 44 | using var mem = new MemoryStream(); 45 | Serializer.Serialize(mem, value); 46 | return mem.ToArray(); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf/TableStorage.Protobuf.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.Protobuf 5 | netstandard2.0;net6.0 6 | true 7 | A Protocol Buffers binary serializer for use with document-based repositories. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf/Visibility.cs: -------------------------------------------------------------------------------- 1 | // 2 | namespace Devlooped 3 | { 4 | // Sets default visibility when using compiled version, where everything is public 5 | public partial class ProtobufDocumentSerializer { } 6 | } 7 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf/message.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | -------------------------------------------------------------------------------- /src/TableStorage.Protobuf/readme.md: -------------------------------------------------------------------------------- 1 | 2 | A Protocol Buffers binary serializer for use with document-based repositories. 3 | 4 | Usage: 5 | 6 | ```csharp 7 | var repo = DocumentRepository.Create(..., serializer: ProtobufDocumentSerializer.Default); 8 | ``` 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/TableStorage.Source/Devlooped.TableStorage.Source.targets: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | false 6 | Devlooped\TableStorage\%(Filename)%(Extension) 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/TableStorage.Source/TableStorage.Source.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Devlooped.TableStorage.Source 5 | netstandard2.0;netstandard2.1 6 | true 7 | false 8 | true 9 | Source-only repository pattern with POCO object support for storing to Azure/CosmosDB Table Storage 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/TableStorage.Source/readme.md: -------------------------------------------------------------------------------- 1 | Source-only version of [TableStorage](https://www.nuget.org/packages/Devlooped.TableStorage). 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/TableStorage/AttributedDocumentRepository`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | 4 | namespace Devlooped 5 | { 6 | /// 7 | /// An implementation which relies on the entity type 8 | /// being annotated with and , and 9 | /// optionally (defaults to type name). 10 | /// 11 | /// 12 | /// When attributed entities are used, this is a convenient generic implementation for use with 13 | /// a dependency injection container, such as in ASP.NET Core: 14 | /// 15 | /// services.AddScoped(typeof(IDocumentRepository<>), typeof(AttributedDocumentRepository<>)); 16 | /// 17 | /// 18 | partial class AttributedDocumentRepository : DocumentRepository where T : class 19 | { 20 | /// 21 | /// Initializes the repository using the given storage account. 22 | /// 23 | /// Storage account to connect to. 24 | /// Optional serializer to use instead of the default . 25 | public AttributedDocumentRepository(CloudStorageAccount storageAccount, IDocumentSerializer? serializer = default) 26 | : base(storageAccount, 27 | TableRepository.GetDefaultTableName(), 28 | PartitionKeyAttribute.CreateCompiledAccessor(), 29 | RowKeyAttribute.CreateCompiledAccessor(), 30 | serializer ?? DocumentSerializer.Default, 31 | true) 32 | { 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/TableStorage/AttributedTableRepository`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | 4 | namespace Devlooped 5 | { 6 | /// 7 | /// An implementation which relies on the entity type 8 | /// being annotated with and , and 9 | /// optionally (defaults to type name). 10 | /// 11 | /// 12 | /// When attributed entities are used, this is a convenient generic implementation for use with 13 | /// a dependency injection container, such as in ASP.NET Core: 14 | /// 15 | /// services.AddScoped(typeof(ITableRepository<>), typeof(AttributedTableRepository<>)); 16 | /// 17 | /// 18 | partial class AttributedTableRepository : TableRepository where T : class 19 | { 20 | /// 21 | /// Initializes the repository using the given storage account. 22 | /// 23 | /// Storage account to connect to. 24 | public AttributedTableRepository(CloudStorageAccount storageAccount) 25 | : base(storageAccount, 26 | TableRepository.GetDefaultTableName(), 27 | PartitionKeyAttribute.CreateAccessor(), 28 | RowKeyAttribute.CreateAccessor()) 29 | { 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/TableStorage/DocumentPartition`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq.Expressions; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Mono.Linq.Expressions; 9 | 10 | namespace Devlooped 11 | { 12 | /// 13 | partial class DocumentPartition : IDocumentPartition where T : class 14 | { 15 | readonly DocumentRepository repository; 16 | 17 | /// 18 | /// Initializes the repository with the given storage account and optional table name. 19 | /// 20 | /// The to use to connect to the table. 21 | /// The table that backs this table partition. 22 | /// The fixed partition key that backs this table partition. 23 | /// A function to determine the row key for an entity of type within the partition. 24 | /// The serializer to use. 25 | /// Whether to serialize properties as columns too, like table repositories, for easier querying. 26 | protected internal DocumentPartition(CloudStorageAccount storageAccount, string tableName, string partitionKey, Func rowKey, IDocumentSerializer serializer, bool includeProperties = false) 27 | : this(new TableConnection(storageAccount, tableName ?? DocumentPartition.GetDefaultTableName()), partitionKey, rowKey, serializer, includeProperties) 28 | { 29 | } 30 | 31 | /// 32 | /// Initializes the repository with the given storage account and optional table name. 33 | /// 34 | /// The table to connect to. 35 | /// The fixed partition key that backs this table partition. 36 | /// A function to determine the row key for an entity of type within the partition. 37 | /// The serializer to use. 38 | /// Whether to serialize properties as columns too, like table repositories, for easier querying. 39 | protected internal DocumentPartition(TableConnection tableConnection, string partitionKey, Func rowKey, IDocumentSerializer serializer, bool includeProperties = false) 40 | { 41 | PartitionKey = partitionKey ?? TablePartition.GetDefaultPartitionKey(); 42 | repository = new DocumentRepository( 43 | tableConnection, 44 | _ => PartitionKey, 45 | rowKey ?? RowKeyAttribute.CreateCompiledAccessor(), 46 | serializer, 47 | includeProperties); 48 | } 49 | 50 | /// 51 | public string TableName => repository.TableName; 52 | 53 | /// 54 | public string PartitionKey { get; } 55 | 56 | /// 57 | public Task DeleteAsync(T entity, CancellationToken cancellation = default) 58 | => repository.DeleteAsync(entity, cancellation); 59 | 60 | /// 61 | public Task DeleteAsync(string rowKey, CancellationToken cancellation = default) 62 | => repository.DeleteAsync(PartitionKey, rowKey, cancellation); 63 | 64 | /// 65 | public IAsyncEnumerable EnumerateAsync(CancellationToken cancellation = default) 66 | => repository.EnumerateAsync(PartitionKey, cancellation); 67 | 68 | /// 69 | public IAsyncEnumerable EnumerateAsync(Expression> predicate, CancellationToken cancellation = default) 70 | => repository.EnumerateAsync(predicate.AndAlso(x => x.PartitionKey == PartitionKey), cancellation); 71 | 72 | /// 73 | public Task GetAsync(string rowKey, CancellationToken cancellation = default) 74 | => repository.GetAsync(PartitionKey, rowKey, cancellation); 75 | 76 | /// 77 | public Task PutAsync(T entity, CancellationToken cancellation = default) 78 | => repository.PutAsync(entity, cancellation); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/TableStorage/DocumentRepository.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using Azure; 5 | using Azure.Data.Tables; 6 | 7 | namespace Devlooped 8 | { 9 | /// 10 | /// Factory methods to create instances 11 | /// that store entities as a serialized document. 12 | /// 13 | static partial class DocumentRepository 14 | { 15 | /// 16 | /// Creates an for the given entity type 17 | /// . 18 | /// 19 | /// The type of entity that the repository will manage. 20 | /// The storage account to use. 21 | /// Optional table name to use. If not provided, the 22 | /// Name will be used, unless a on the type overrides it. 23 | /// Optional function to retrieve the partition key for a given entity. 24 | /// If not provided, the class will need a property annotated with . 25 | /// Optional function to retrieve the row key for a given entity. 26 | /// If not provided, the class will need a property annotated with . 27 | /// Optional serializer to use instead of the default . 28 | /// Whether to serialize properties as columns too, like table repositories, for easier querying. 29 | /// The new . 30 | public static IDocumentRepository Create( 31 | CloudStorageAccount storageAccount, 32 | string? tableName = default, 33 | Func? partitionKey = default, 34 | Func? rowKey = default, 35 | IDocumentSerializer? serializer = default, 36 | bool includeProperties = false) where T : class 37 | => Create( 38 | new TableConnection(storageAccount, tableName ??= TableRepository.GetDefaultTableName()), 39 | partitionKey, rowKey, serializer, includeProperties); 40 | 41 | /// 42 | /// Creates an for the given entity type 43 | /// . 44 | /// 45 | /// The type of entity that the repository will manage. 46 | /// The storage account and table to use. 47 | /// Optional function to retrieve the partition key for a given entity. 48 | /// If not provided, the class will need a property annotated with . 49 | /// Optional function to retrieve the row key for a given entity. 50 | /// If not provided, the class will need a property annotated with . 51 | /// Optional serializer to use instead of the default . 52 | /// Whether to serialize properties as columns too, like table repositories, for easier querying. 53 | /// The new . 54 | public static IDocumentRepository Create( 55 | TableConnection tableConnection, 56 | Func? partitionKey = default, 57 | Func? rowKey = default, 58 | IDocumentSerializer? serializer = default, 59 | bool includeProperties = false) where T : class 60 | { 61 | partitionKey ??= PartitionKeyAttribute.CreateCompiledAccessor(); 62 | rowKey ??= RowKeyAttribute.CreateCompiledAccessor(); 63 | serializer ??= DocumentSerializer.Default; 64 | 65 | return new DocumentRepository(tableConnection, partitionKey, rowKey, serializer, includeProperties); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/TableStorage/DocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Globalization; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | 8 | namespace Devlooped 9 | { 10 | /// 11 | /// Default implementation of which 12 | /// uses System.Text.Json for serialization. 13 | /// 14 | partial class DocumentSerializer : IStringDocumentSerializer 15 | { 16 | /// 17 | /// Default serializer options to use for the singleton document serialier. 18 | /// 19 | /// 20 | /// Default settings are: = true, 21 | /// = and 22 | /// = 23 | /// 24 | public static JsonSerializerOptions DefaultOptions = new JsonSerializerOptions 25 | { 26 | AllowTrailingCommas = true, 27 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, 28 | WriteIndented = true, 29 | Converters = 30 | { 31 | // Enums should persist/parse with their string values instead 32 | new JsonStringEnumConverter(allowIntegerValues: false), 33 | #if NET6_0_OR_GREATER 34 | new DateOnlyJsonConverter() 35 | #endif 36 | } 37 | }; 38 | 39 | #if NET6_0_OR_GREATER 40 | public class DateOnlyJsonConverter : JsonConverter 41 | { 42 | public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) 43 | => DateOnly.Parse(reader.GetString()?.Substring(0, 10) ?? "", CultureInfo.InvariantCulture); 44 | 45 | public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) 46 | => writer.WriteStringValue(value.ToString("O", CultureInfo.InvariantCulture)); 47 | } 48 | #endif 49 | 50 | /// 51 | /// Default instance of the serializer. 52 | /// 53 | public static IStringDocumentSerializer Default { get; } = new DocumentSerializer(); 54 | 55 | readonly JsonSerializerOptions options; 56 | 57 | /// 58 | /// Initializes the document serializer using the . 59 | /// 60 | public DocumentSerializer() 61 | : this(DefaultOptions) { } 62 | 63 | /// 64 | /// Initializes the document serializer using the given . 65 | /// 66 | public DocumentSerializer(JsonSerializerOptions options) => this.options = options; 67 | 68 | /// 69 | public T? Deserialize(string data) => JsonSerializer.Deserialize(data, options); 70 | 71 | /// 72 | public string Serialize(T value) => JsonSerializer.Serialize(value, options); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/TableStorage/ExpressionExtensions.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Linq.Expressions; 7 | using System.Reflection; 8 | using System.Threading; 9 | using System.Xml.Linq; 10 | 11 | namespace Devlooped 12 | { 13 | static class ExpressionExtensions 14 | { 15 | public static string? GetPropertyName(this Expression>? expression) 16 | { 17 | if (expression == null) 18 | return null; 19 | 20 | var visitor = new PropertyVisitor(); 21 | visitor.Visit(expression); 22 | 23 | // Support single-property accessor expressions only. 24 | // The attributed-property/ctor involves a more complex expression. 25 | if (visitor.Properties.Count == 1) 26 | return visitor.Properties[0].Name; 27 | 28 | return null; 29 | } 30 | 31 | class PropertyVisitor : ExpressionVisitor 32 | { 33 | bool skipProps = false; 34 | 35 | public List Properties { get; } = new(); 36 | 37 | protected override Expression VisitMember(MemberExpression node) 38 | { 39 | if (!skipProps && node.Member is PropertyInfo property) 40 | Properties.Add(property); 41 | 42 | return base.VisitMember(node); 43 | } 44 | 45 | protected override Expression VisitMethodCall(MethodCallExpression node) 46 | { 47 | if (node.Method.GetCustomAttribute() != null) 48 | return base.VisitMethodCall(node); 49 | 50 | return SkipProps(node, base.VisitMethodCall); 51 | } 52 | 53 | protected override Expression VisitBinary(BinaryExpression node) 54 | => SkipProps(node, base.VisitBinary); 55 | 56 | TResult SkipProps(T arg, Func func) 57 | { 58 | skipProps = true; 59 | try 60 | { 61 | return func(arg); 62 | } 63 | finally 64 | { 65 | skipProps = false; 66 | } 67 | } 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/TableStorage/Http.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Globalization; 5 | using System.Linq; 6 | using System.Net.Http; 7 | using System.Net.Http.Headers; 8 | using System.Runtime.InteropServices; 9 | using System.Security.Cryptography; 10 | using System.Text; 11 | 12 | namespace Devlooped 13 | { 14 | static class Http 15 | { 16 | public static HttpClient Client { get; } 17 | 18 | static Http() 19 | { 20 | Client = new HttpClient(); 21 | Client.DefaultRequestHeaders.Add("Accept", "application/json; odata=nometadata"); 22 | Client.DefaultRequestHeaders.Add("Accept-Charset", "UTF-8"); 23 | Client.DefaultRequestHeaders.Add("User-Agent", $"Azure-Cosmos-Table/1.0.8 ({RuntimeInformation.FrameworkDescription}; {Environment.OSVersion.Platform} {Environment.OSVersion.Version})"); 24 | Client.DefaultRequestHeaders.Add("DataServiceVersion", "3.0;NetFx"); 25 | Client.DefaultRequestHeaders.Add("MaxDataServiceVersion", "3.0;NetFx"); 26 | Client.DefaultRequestHeaders.Add("x-ms-version", "2017-07-29"); 27 | } 28 | 29 | public static HttpRequestMessage AddAuthorizationHeader(this HttpRequestMessage request, CloudStorageAccount account) 30 | { 31 | if (!request.Headers.TryGetValues("x-ms-date", out var values) || 32 | values.FirstOrDefault() is not string date || 33 | string.IsNullOrEmpty(date)) 34 | { 35 | date = DateTime.UtcNow.ToString("R", CultureInfo.InvariantCulture); 36 | request.Headers.Add("x-ms-date", date); 37 | } 38 | 39 | var resource = request.RequestUri?.GetComponents(UriComponents.Path, UriFormat.Unescaped); 40 | var toSign = string.Format("{0}\n/{1}/{2}", 41 | request.Headers.GetValues("x-ms-date").First(), 42 | account.Credentials.AccountName, 43 | resource?.TrimStart('/')); 44 | 45 | var hasher = new HMACSHA256(Convert.FromBase64String(account.Credentials.Key ?? "")); 46 | var signature = hasher.ComputeHash(Encoding.UTF8.GetBytes(toSign)); 47 | var authentication = new AuthenticationHeaderValue("SharedKeyLite", account.Credentials.AccountName + ":" + Convert.ToBase64String(signature)); 48 | 49 | request.Headers.Authorization = authentication; 50 | 51 | return request; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/TableStorage/IBinaryDocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | 4 | namespace Devlooped 5 | { 6 | /// 7 | /// Serializes an object to/from a byte array. 8 | /// 9 | partial interface IBinaryDocumentSerializer : IDocumentSerializer 10 | { 11 | /// 12 | /// Serializes the value to a byte array. 13 | /// 14 | byte[] Serialize(T value); 15 | 16 | /// 17 | /// Deserializes an object from its serialized data. 18 | /// 19 | T? Deserialize(byte[] data); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/TableStorage/IDocumentEntity.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using Azure; 5 | using Azure.Data.Tables; 6 | 7 | namespace Devlooped 8 | { 9 | /// 10 | /// Document metadata for querying purposes. 11 | /// 12 | partial interface IDocumentEntity : ITableEntity 13 | { 14 | /// 15 | /// The type of the document, its . 16 | /// 17 | /// 18 | /// For nested types, the '+' will be replaced with '.'. 19 | /// 20 | string? Type { get; } 21 | 22 | /// 23 | /// The major.minor version of the assembly the document type belongs to. 24 | /// 25 | string? Version { get; } 26 | 27 | /// 28 | /// The major component of the . 29 | /// 30 | int? MajorVersion { get; } 31 | 32 | /// 33 | /// The minor component of the . 34 | /// 35 | int? MinorVersion { get; } 36 | } 37 | 38 | internal class BinaryDocumentEntity : ITableEntity, IDocumentEntity 39 | { 40 | public BinaryDocumentEntity() { } 41 | public BinaryDocumentEntity(string partitionKey, string rowKey) 42 | => (PartitionKey, RowKey) 43 | = (partitionKey, rowKey); 44 | 45 | public byte[]? Document { get; set; } 46 | public string? Type { get; set; } 47 | public string? Version { get; set; } 48 | public int? MajorVersion { get; set; } 49 | public int? MinorVersion { get; set; } 50 | 51 | public string PartitionKey { get; set; } = ""; 52 | public string RowKey { get; set; } = ""; 53 | public DateTimeOffset? Timestamp { get; set; } 54 | public ETag ETag { get; set; } = ETag.All; 55 | } 56 | 57 | internal class StringDocumentEntity : ITableEntity, IDocumentEntity 58 | { 59 | public StringDocumentEntity() { } 60 | public StringDocumentEntity(string partitionKey, string rowKey) 61 | => (PartitionKey, RowKey) 62 | = (partitionKey, rowKey); 63 | 64 | public string? Document { get; set; } 65 | public string? Type { get; set; } 66 | public string? Version { get; set; } 67 | public int? MajorVersion { get; set; } 68 | public int? MinorVersion { get; set; } 69 | 70 | public string PartitionKey { get; set; } = ""; 71 | public string RowKey { get; set; } = ""; 72 | public DateTimeOffset? Timestamp { get; set; } 73 | public ETag ETag { get; set; } = ETag.All; 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/TableStorage/IDocumentPartition`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq.Expressions; 6 | using System.Threading; 7 | 8 | namespace Devlooped 9 | { 10 | /// 11 | /// A specific partition within an . 12 | /// 13 | /// The type of entity being persisted. 14 | partial interface IDocumentPartition : ITableStoragePartition where T : class 15 | { 16 | /// 17 | /// Queries the document repository for items that match the given . 18 | /// 19 | /// 20 | /// var books = DocumentPartition.Create<Book>(); 21 | /// await foreach (var book in books.EnumerateAsync(x => 22 | /// x.PartitionKey == "Rick Riordan" && 23 | /// x.RowKey.CompareTo("Percy Jackson") >= 0 && 24 | /// x.Version == "1.0")) 25 | /// { 26 | /// Console.WriteLine(book.ISBN); 27 | /// } 28 | /// 29 | public IAsyncEnumerable EnumerateAsync(Expression> predicate, CancellationToken cancellation = default); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/TableStorage/IDocumentRepository`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq.Expressions; 6 | using System.Threading; 7 | 8 | namespace Devlooped 9 | { 10 | /// 11 | /// A generic repository that stores entities in table storage as serialized 12 | /// documents. 13 | /// 14 | /// The type of entity being persisted. 15 | partial interface IDocumentRepository : ITableStorage where T : class 16 | { 17 | ///// 18 | ///// Creates a query for use with LINQ expressions. See 19 | ///// supported operators. 20 | ///// 21 | ///// 22 | ///// var books = DocumentRepository.Create<Book>(); 23 | ///// await foreach (var book in books.CreateQuery().Where(x => x.PartitionKey == "Rick Riordan" && x.Version == "1.2")) 24 | ///// { 25 | ///// Console.WriteLine(book.ISBN); 26 | ///// } 27 | ///// 28 | ///// 29 | ///// var books = TableRepository.Create<Book>(); 30 | ///// await foreach (var published in from book in books.CreateQuery() 31 | ///// where book.IsPublished && book.Pages > 1000 32 | ///// select book) 33 | ///// { 34 | ///// Console.WriteLine(published.ISBN); 35 | ///// } 36 | ///// 37 | //IQueryable CreateQuery(); 38 | 39 | /// 40 | /// Queries the document repository for items that match the given . 41 | /// 42 | /// 43 | /// var books = DocumentRepository.Create<Book>(); 44 | /// await foreach (var book in books.EnumerateAsync(x => x.PartitionKey == "Rick Riordan" && x.DocumentType )) 45 | /// { 46 | /// Console.WriteLine(book.ISBN); 47 | /// } 48 | /// 49 | public IAsyncEnumerable EnumerateAsync(Expression> predicate, CancellationToken cancellation = default); 50 | } 51 | } -------------------------------------------------------------------------------- /src/TableStorage/IDocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | 4 | namespace Devlooped 5 | { 6 | /// 7 | /// Marker interface for both 8 | /// and interfaces. 9 | /// 10 | partial interface IDocumentSerializer 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/TableStorage/IDocumentTimestamp.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Devlooped; 4 | 5 | /// 6 | /// Opts-in to receiving the document timestamp from the 7 | /// when retrieving the document from the table storage. 8 | /// 9 | public interface IDocumentTimestamp 10 | { 11 | /// 12 | /// The timestamp of the document. 13 | /// 14 | DateTimeOffset? Timestamp { get; set; } 15 | } 16 | -------------------------------------------------------------------------------- /src/TableStorage/IQueryableExtensions.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.ComponentModel; 6 | using System.Linq; 7 | using System.Threading; 8 | 9 | namespace Devlooped 10 | { 11 | /// 12 | /// Extension method to 13 | /// allow native await foreach support for queries created from 14 | /// the or , which 15 | /// implement . 16 | /// 17 | [EditorBrowsable(EditorBrowsableState.Never)] 18 | static partial class IQueryableExtensions 19 | { 20 | /// 21 | /// Gets the for an that 22 | /// implements , for use with await foreach. 23 | /// 24 | /// The does not implement 25 | /// . 26 | public static IAsyncEnumerator GetAsyncEnumerator(this IQueryable source, CancellationToken cancellation = default) 27 | { 28 | if (source is not IAsyncEnumerable enumerable) 29 | throw new ArgumentException("The source it not an async enumerable.", nameof(source)); 30 | 31 | return enumerable.GetAsyncEnumerator(cancellation); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/TableStorage/IStringDocumentSerializer.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | 4 | namespace Devlooped 5 | { 6 | /// 7 | /// Serializes an object to/from a string. 8 | /// 9 | partial interface IStringDocumentSerializer : IDocumentSerializer 10 | { 11 | /// 12 | /// Serializes the value to a string. 13 | /// 14 | string Serialize(T value); 15 | 16 | /// 17 | /// Deserializes an object from its serialized data. 18 | /// 19 | T? Deserialize(string data); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/TableStorage/ITablePartition`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System.Linq; 4 | 5 | namespace Devlooped 6 | { 7 | /// 8 | /// A specific partition within an , which allows querying 9 | /// by the entity properties, since they are stored in individual columns. 10 | /// 11 | /// The type of entity being persisted. 12 | partial interface ITablePartition : ITableStoragePartition where T : class 13 | { 14 | /// 15 | /// Creates a query for use with LINQ expressions. See 16 | /// supported operators. 17 | /// 18 | /// 19 | /// The query is scoped to the current partition already. 20 | /// 21 | /// 22 | /// var books = TablePartition.Create<Book>("Bestsellers"); 23 | /// await foreach (var book in books.CreateQuery().Where(x => x.IsPublished)) 24 | /// { 25 | /// Console.WriteLine(book.ISBN); 26 | /// } 27 | /// 28 | /// 29 | /// var books = TablePartition.Create<Book>("Bestsellers"); 30 | /// await foreach (var published in from book in books.CreateQuery() 31 | /// where book.IsPublished && book.Pages > 1000 32 | /// select book) 33 | /// { 34 | /// Console.WriteLine(published.ISBN); 35 | /// } 36 | /// 37 | IQueryable CreateQuery(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/TableStorage/ITableRepository`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System.Linq; 4 | 5 | namespace Devlooped 6 | { 7 | /// 8 | /// A specialized which allows querying 9 | /// the repository by the entity properties, since they are stored in individual 10 | /// columns. 11 | /// 12 | /// The type of entity being persisted. 13 | partial interface ITableRepository : ITableStorage where T : class 14 | { 15 | /// 16 | /// Creates a query for use with LINQ expressions. See 17 | /// supported operators. 18 | /// 19 | /// 20 | /// var books = TableRepository.Create<Book>(); 21 | /// await foreach (var book in books.CreateQuery().Where(x => x.IsPublished)) 22 | /// { 23 | /// Console.WriteLine(book.ISBN); 24 | /// } 25 | /// 26 | /// 27 | /// var books = TableRepository.Create<Book>(); 28 | /// await foreach (var published in from book in books.CreateQuery() 29 | /// where book.IsPublished && book.Pages > 1000 30 | /// select book) 31 | /// { 32 | /// Console.WriteLine(published.ISBN); 33 | /// } 34 | /// 35 | IQueryable CreateQuery(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/TableStorage/ITableStoragePartition`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System.Collections.Generic; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Devlooped 8 | { 9 | /// 10 | /// A specific partition within an . 11 | /// 12 | /// The type of entity being persisted. 13 | partial interface ITableStoragePartition where T : class 14 | { 15 | /// 16 | /// Gets the table name being used. 17 | /// 18 | string TableName { get; } 19 | 20 | /// 21 | /// Gets the partition key being used. 22 | /// 23 | string PartitionKey { get; } 24 | 25 | /// 26 | /// Enumerates asynchronously all entities in the partition.. 27 | /// 28 | /// Optional . 29 | IAsyncEnumerable EnumerateAsync(CancellationToken cancellation = default); 30 | 31 | /// 32 | /// Retrieves an entity from the partition given its . 33 | /// 34 | /// The entity row key. 35 | /// Optional . 36 | /// The retrieved entity, or if not found. 37 | Task GetAsync(string rowKey, CancellationToken cancellation = default); 38 | 39 | /// 40 | /// Writes an entity to the partition, overwriting an existing value, if any. 41 | /// 42 | /// The entity to persist. 43 | /// Optional . 44 | /// The saved entity. 45 | Task PutAsync(T entity, CancellationToken cancellation = default); 46 | 47 | /// 48 | /// Deletes an entity from the partition. 49 | /// 50 | /// The entity to delete. 51 | /// Optional . 52 | Task DeleteAsync(T entity, CancellationToken cancellation = default); 53 | 54 | /// 55 | /// Deletes an entity from the partition given its . 56 | /// 57 | /// The entity row key. 58 | /// Optional . 59 | Task DeleteAsync(string rowKey, CancellationToken cancellation = default); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/TableStorage/ITableStorage`1.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System.Collections.Generic; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Devlooped 8 | { 9 | /// 10 | /// A generic repository that stores entities in table storage. 11 | /// 12 | /// The type of entity being persisted. 13 | partial interface ITableStorage where T : class 14 | { 15 | /// 16 | /// Gets the table name being used. 17 | /// 18 | string TableName { get; } 19 | 20 | /// 21 | /// Deletes an entity from the repository. 22 | /// 23 | /// The entity to delete. 24 | /// Optional . 25 | /// if an existing record was deleted, otherwise. 26 | Task DeleteAsync(T entity, CancellationToken cancellation = default); 27 | 28 | /// 29 | /// Deletes an entity from the repository given its and . 30 | /// 31 | /// The entity partition key. 32 | /// The entity row key. 33 | /// Optional . 34 | /// if an existing record was deleted, otherwise. 35 | Task DeleteAsync(string partitionKey, string rowKey, CancellationToken cancellation = default); 36 | 37 | /// 38 | /// Enumerates asynchronously all entities, optionally within the given . 39 | /// 40 | /// The optional partition key to scope the enumeration to. 41 | /// Optional . 42 | /// 43 | IAsyncEnumerable EnumerateAsync(string? partitionKey = default, CancellationToken cancellation = default); 44 | 45 | /// 46 | /// Retrieves an entity from the repository. 47 | /// 48 | /// The entity partition key. 49 | /// The entity row key. 50 | /// Optional . 51 | /// The retrieved entity, or if not found. 52 | Task GetAsync(string partitionKey, string rowKey, CancellationToken cancellation = default); 53 | 54 | /// 55 | /// Retrieves an entity from the repository. 56 | /// 57 | /// The entity to use to lookup partition and row key values. 58 | /// Optional . 59 | /// The retrieved entity, or if not found. 60 | Task GetAsync(T entity, CancellationToken cancellation = default); 61 | 62 | /// 63 | /// Writes an entity to the table, overwriting an existing value, if any. 64 | /// 65 | /// The entity to persist. 66 | /// Optional . 67 | /// The saved entity. 68 | Task PutAsync(T entity, CancellationToken cancellation = default); 69 | 70 | /// 71 | /// Writes a set of entities to the table, overwriting a existing values, if any. 72 | /// 73 | /// The entities to persist. 74 | /// Optional . 75 | /// 76 | /// Automatically batches operations for better performance. 77 | /// 78 | Task PutAsync(IEnumerable entities, CancellationToken cancellation = default); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/TableStorage/NonMutatingAttribute.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.ComponentModel; 5 | 6 | namespace Devlooped 7 | { 8 | /// 9 | /// Attribute that marks a method as non-mutating, meaning it doesn't change the value of 10 | /// the received arguments if they are in turn returned to the caller. 11 | /// 12 | [EditorBrowsable(EditorBrowsableState.Never)] 13 | [AttributeUsage(AttributeTargets.Method)] 14 | class NonMutatingAttribute : Attribute { } 15 | } -------------------------------------------------------------------------------- /src/TableStorage/PartitionKeyAttribute.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Linq.Expressions; 5 | 6 | namespace Devlooped 7 | { 8 | /// 9 | /// Flags the property to use as the table storage partition key 10 | /// when storing the annotated type using the . 11 | /// Can be applied at the class level instead with a fixed value to persist 12 | /// entities with a fixed partition key value. 13 | /// 14 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Parameter)] 15 | partial class PartitionKeyAttribute : TableStorageAttribute 16 | { 17 | /// 18 | /// Used to annotate a property that will be used as the partition key. 19 | /// 20 | public PartitionKeyAttribute() { } 21 | 22 | /// 23 | /// Used to annotate the class with a fixed partition key value to be 24 | /// used for all entities. 25 | /// 26 | public PartitionKeyAttribute(string partitionKey) => PartitionKey = partitionKey; 27 | 28 | /// 29 | /// If used at the class level, a non-null value to use as the shared 30 | /// partition key for all entities. 31 | /// 32 | public string? PartitionKey { get; } 33 | 34 | /// 35 | /// Creates a strong-typed accessor expression for the property annotated 36 | /// with for instances of the given type 37 | /// . 38 | /// 39 | public static Expression> CreateAccessor() => CreateGetter(); 40 | 41 | /// 42 | /// Creates a strong-typed fast compiled accessor for the property annotated 43 | /// with for instances of the given type 44 | /// . 45 | /// 46 | public static Func CreateCompiledAccessor() => CreateCompiledGetter(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/TableStorage/RowKeyAttribute.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Linq.Expressions; 5 | 6 | namespace Devlooped 7 | { 8 | /// 9 | /// Flags the property to use as the table storage row key 10 | /// when storing the annotated type using or 11 | /// . 12 | /// 13 | [AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)] 14 | partial class RowKeyAttribute : TableStorageAttribute 15 | { 16 | /// 17 | /// Creates a strong-typed accessor expression for the property annotated 18 | /// with for instances of the given type 19 | /// . 20 | /// 21 | public static Expression> CreateAccessor() => CreateGetter(); 22 | 23 | /// 24 | /// Creates a strong-typed fast compiled accessor for the property annotated 25 | /// with for instances of the given type 26 | /// . 27 | /// 28 | public static Func CreateCompiledAccessor() => CreateCompiledGetter(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/TableStorage/TableAttribute.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Text.RegularExpressions; 5 | 6 | namespace Devlooped 7 | { 8 | /// 9 | /// Overrides the default table name to use when persisting an entity to 10 | /// table storage without providing an explicit table name. 11 | /// 12 | [AttributeUsage(AttributeTargets.Class)] 13 | partial class TableAttribute : Attribute 14 | { 15 | /// 16 | /// Initializes the attribute with the table name to use by default. 17 | /// 18 | public TableAttribute(string name) 19 | { 20 | if (!ValidatorExpr().IsMatch(name)) 21 | throw new ArgumentException($"Table name '{name}' contains invalid characters.", nameof(name)); 22 | 23 | Name = name; 24 | } 25 | 26 | /// 27 | /// The default table name to use. 28 | /// 29 | public string Name { get; } 30 | 31 | #if NET8_0_OR_GREATER 32 | [GeneratedRegex("^[A-Za-z][A-Za-z0-9]{2,62}$", RegexOptions.Compiled)] 33 | private static partial Regex ValidatorExpr(); 34 | #else 35 | static Regex ValidatorExpr() => validator; 36 | static readonly Regex validator = new Regex("^[A-Za-z][A-Za-z0-9]{2,62}$", RegexOptions.Compiled); 37 | #endif 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/TableStorage/TableConnection.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Azure.Data.Tables; 3 | 4 | namespace Devlooped 5 | { 6 | /// 7 | /// Represents a connection to a given 8 | /// over a given . 9 | /// 10 | partial class TableConnection 11 | { 12 | readonly CloudStorageAccount storageAccount; 13 | TableClient? table; 14 | 15 | /// 16 | /// Creates the affinitized table connection for the given table name. 17 | /// 18 | /// The storage account to use. 19 | /// The table to connect to. 20 | public TableConnection(CloudStorageAccount storageAccount, string tableName) 21 | { 22 | this.storageAccount = storageAccount; 23 | TableName = tableName; 24 | } 25 | 26 | /// 27 | /// Gets the storage account used to connect to the table. 28 | /// 29 | public CloudStorageAccount StorageAccount => storageAccount; 30 | 31 | /// 32 | /// Gets the name of the table to use. 33 | /// 34 | public string TableName { get; } 35 | 36 | /// 37 | /// Gets table client for this connection, creating the table if it doesn't exist. 38 | /// 39 | public async Task GetTableAsync() => table ??= await CreateTableClientAsync(); 40 | 41 | async Task CreateTableClientAsync() 42 | { 43 | var tableClient = storageAccount.CreateTableServiceClient(); 44 | var table = tableClient.GetTableClient(TableName); 45 | await table.CreateIfNotExistsAsync(); 46 | return table; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/TableStorage/TableEntityPartition.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.ComponentModel; 6 | using System.Linq; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Azure.Data.Tables; 10 | 11 | namespace Devlooped 12 | { 13 | /// 14 | /// A specific partition within a , which 15 | /// allows querying by the entity properties, since they are stored in individual columns. 16 | /// 17 | partial class TableEntityPartition : ITablePartition 18 | { 19 | readonly TableEntityRepository repository; 20 | 21 | /// 22 | /// Initializes the repository with the given storage account and optional table name. 23 | /// 24 | /// The to use to connect to the table. 25 | /// The table that backs this table partition. 26 | /// The fixed partition key that backs this table partition. 27 | protected internal TableEntityPartition(CloudStorageAccount storageAccount, string tableName, string partitionKey) 28 | : this(new TableConnection(storageAccount, tableName), partitionKey) 29 | { 30 | } 31 | 32 | /// 33 | /// Initializes the repository with the given storage account and optional table name. 34 | /// 35 | /// The to use to connect to the table. 36 | /// The fixed partition key that backs this table partition. 37 | protected internal TableEntityPartition(TableConnection tableConnection, string partitionKey) 38 | { 39 | PartitionKey = partitionKey; 40 | repository = new TableEntityRepository(tableConnection); 41 | } 42 | 43 | /// 44 | public string TableName => repository.TableName; 45 | 46 | /// 47 | public string PartitionKey { get; } 48 | 49 | /// 50 | /// The to use when updating an existing entity. 51 | /// 52 | public TableUpdateMode UpdateMode { get; set; } 53 | 54 | /// 55 | /// The strategy to use when updating an existing entity. 56 | /// 57 | [EditorBrowsable(EditorBrowsableState.Never)] 58 | public UpdateStrategy UpdateStrategy 59 | { 60 | // Backs-compatible implementation 61 | get => UpdateMode == TableUpdateMode.Replace ? UpdateStrategy.Replace : UpdateStrategy.Merge; 62 | set => UpdateMode = value.UpdateMode; 63 | } 64 | 65 | /// 66 | public IQueryable CreateQuery() => repository.CreateQuery().Where(x => x.PartitionKey == PartitionKey); 67 | 68 | /// 69 | public Task DeleteAsync(TableEntity entity, CancellationToken cancellation = default) 70 | { 71 | if (!PartitionKey.Equals(entity.PartitionKey, StringComparison.Ordinal)) 72 | throw new ArgumentException("Entity does not belong to the partition."); 73 | 74 | return repository.DeleteAsync(entity, cancellation); 75 | } 76 | 77 | /// 78 | public Task DeleteAsync(string rowKey, CancellationToken cancellation = default) 79 | => repository.DeleteAsync(PartitionKey, rowKey, cancellation); 80 | 81 | /// 82 | public IAsyncEnumerable EnumerateAsync(CancellationToken cancellation = default) 83 | => repository.EnumerateAsync(PartitionKey, cancellation); 84 | 85 | /// 86 | public Task GetAsync(string rowKey, CancellationToken cancellation = default) 87 | => repository.GetAsync(PartitionKey, rowKey, cancellation); 88 | 89 | /// 90 | public Task PutAsync(TableEntity entity, CancellationToken cancellation = default) 91 | { 92 | if (!PartitionKey.Equals(entity.PartitionKey, StringComparison.Ordinal)) 93 | throw new ArgumentException("Entity does not belong to the partition."); 94 | 95 | return repository.PutAsync(entity, cancellation); 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/TableStorage/TableStorage.Source.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Devlooped.TableStorage.Source 5 | netstandard2.0;netstandard2.1 6 | true 7 | false 8 | true 9 | false 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/TableStorage/TableStorage.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Devlooped.TableStorage 5 | netstandard2.0;netstandard2.1;net6.0;net8.0 6 | true 7 | Repository pattern with POCO object support for storing to Azure/CosmosDB Table Storage 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/TableStorage/TableStorage.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.5.2.0 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TableStorage", "TableStorage.csproj", "{8CAA456D-466E-787F-F20A-B609C2ECC3CC}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TableStorage.Source", "TableStorage.Source.csproj", "{C5AF0FDF-87D5-8732-01C1-3EB98E9DD4A0}" 8 | EndProject 9 | Global 10 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 11 | Debug|Any CPU = Debug|Any CPU 12 | Release|Any CPU = Release|Any CPU 13 | EndGlobalSection 14 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 15 | {8CAA456D-466E-787F-F20A-B609C2ECC3CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 16 | {8CAA456D-466E-787F-F20A-B609C2ECC3CC}.Debug|Any CPU.Build.0 = Debug|Any CPU 17 | {8CAA456D-466E-787F-F20A-B609C2ECC3CC}.Release|Any CPU.ActiveCfg = Release|Any CPU 18 | {8CAA456D-466E-787F-F20A-B609C2ECC3CC}.Release|Any CPU.Build.0 = Release|Any CPU 19 | {C5AF0FDF-87D5-8732-01C1-3EB98E9DD4A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {C5AF0FDF-87D5-8732-01C1-3EB98E9DD4A0}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {C5AF0FDF-87D5-8732-01C1-3EB98E9DD4A0}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {C5AF0FDF-87D5-8732-01C1-3EB98E9DD4A0}.Release|Any CPU.Build.0 = Release|Any CPU 23 | EndGlobalSection 24 | GlobalSection(SolutionProperties) = preSolution 25 | HideSolutionNode = FALSE 26 | EndGlobalSection 27 | GlobalSection(ExtensibilityGlobals) = postSolution 28 | SolutionGuid = {BCD5559A-E612-4756-B028-272A2775BCC2} 29 | EndGlobalSection 30 | EndGlobal 31 | -------------------------------------------------------------------------------- /src/TableStorage/TableStorageExtensions.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | using System; 4 | using System.Collections.Generic; 5 | using System.ComponentModel; 6 | using System.Linq; 7 | using System.Linq.Expressions; 8 | using System.Threading; 9 | 10 | namespace Devlooped 11 | { 12 | /// 13 | /// Various usability overloads. 14 | /// 15 | [EditorBrowsable(EditorBrowsableState.Never)] 16 | static partial class TableStorageExtensions 17 | { 18 | /// 19 | /// Queries the repository for items that match the given . 20 | /// 21 | /// 22 | /// Shortcut for CreateQuery().Where(predicate) and returning as 23 | /// for use with await foreach directly. 24 | /// 25 | /// 26 | /// var books = TableRepository.Create<Book>(); 27 | /// await foreach (var book in books.QueryAsync(x => x.IsPublished)) 28 | /// { 29 | /// Console.WriteLine(book.ISBN); 30 | /// } 31 | /// 32 | public static IAsyncEnumerable EnumerateAsync(this ITableRepository repository, Expression> predicate, CancellationToken cancellation = default) where T : class 33 | => (IAsyncEnumerable)repository.CreateQuery().Where(predicate); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/TableStorage/UpdateStrategy.cs: -------------------------------------------------------------------------------- 1 | // 2 | #nullable enable 3 | 4 | using System.ComponentModel; 5 | using Azure.Data.Tables; 6 | 7 | namespace Devlooped 8 | { 9 | /// 10 | /// Provides the strategy used when saving entities (using `PutAsync`) that 11 | /// already exist in the table. Either or . 12 | /// When not provided, is used by default. 13 | /// 14 | [EditorBrowsable(EditorBrowsableState.Never)] 15 | abstract partial class UpdateStrategy 16 | { 17 | /// 18 | /// When storing an entity that already exists in the table, merge with the 19 | /// existing data. 20 | /// 21 | public static UpdateStrategy Merge { get; } = new MergeStrategy(); 22 | 23 | /// 24 | /// When storing an entity that already exists in the table, replace the 25 | /// existing data. 26 | /// 27 | public static UpdateStrategy Replace { get; } = new ReplaceStrategy(); 28 | 29 | /// 30 | /// Gets the mode in which updates should be performed for the given strategy. 31 | /// 32 | protected abstract internal TableUpdateMode UpdateMode { get; } 33 | 34 | /// 35 | /// Provides automatic conversion from to the 36 | /// new enumeration. 37 | /// 38 | public static implicit operator TableUpdateMode(UpdateStrategy strategy) => strategy.UpdateMode; 39 | 40 | class MergeStrategy : UpdateStrategy 41 | { 42 | protected internal override TableUpdateMode UpdateMode => TableUpdateMode.Merge; 43 | } 44 | 45 | class ReplaceStrategy : UpdateStrategy 46 | { 47 | protected internal override TableUpdateMode UpdateMode => TableUpdateMode.Replace; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/TableStorage/Visibility.cs: -------------------------------------------------------------------------------- 1 | // 2 | namespace Devlooped; 3 | 4 | // Sets default visibility when using compiled version, where everything is public 5 | public partial interface ITableStorage { } 6 | public partial interface ITableStoragePartition { } 7 | public partial interface ITableRepository { } 8 | public partial interface ITableRepository { } 9 | public partial interface ITablePartition { } 10 | public partial interface IDocumentRepository { } 11 | public partial interface IDocumentPartition { } 12 | public partial interface IDocumentSerializer { } 13 | public partial interface IBinaryDocumentSerializer { } 14 | public partial interface IStringDocumentSerializer { } 15 | public partial interface IDocumentEntity { } 16 | 17 | public partial class TableConnection { } 18 | public partial class TableRepository { } 19 | public partial class TableRepository { } 20 | public partial class AttributedTableRepository { } 21 | public partial class DocumentRepository { } 22 | public partial class DocumentRepository { } 23 | public partial class DocumentSerializer { } 24 | public partial class AttributedDocumentRepository { } 25 | public partial class TablePartition { } 26 | public partial class TablePartition { } 27 | public partial class DocumentPartition { } 28 | public partial class UpdateStrategy { } 29 | 30 | public partial class TableStorageExtensions { } 31 | public partial class IQueryableExtensions { } 32 | 33 | public partial class PartitionKeyAttribute { } 34 | public partial class RowKeyAttribute { } 35 | public partial class TableAttribute { } 36 | public partial class TableStorageAttribute { } -------------------------------------------------------------------------------- /src/TableStorage/readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/Tests/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # xUnit1013: Public method should be marked as test 4 | dotnet_diagnostic.xUnit1013.severity = none 5 | -------------------------------------------------------------------------------- /src/Tests/.netconfig: -------------------------------------------------------------------------------- 1 | [file "System/Collections/Generic/IAsyncEnumerableExtensions.cs"] 2 | url = https://github.com/devlooped/catbag/blob/main/System/Collections/Generic/IAsyncEnumerableExtensions.cs 3 | sha = fd4229d4b2ebcad93768ddb6afee652d4a476fe2 4 | etag = 8710a896af867e690062b025b3f95de9f2e3f219555942ec91e1364ad8d9bfac 5 | weak 6 | -------------------------------------------------------------------------------- /src/Tests/Books.csv: -------------------------------------------------------------------------------- 1 | PartitionKey,RowKey,Title,Author,Format,Pages,IsPublished 2 | Book,9780141339245,The Blood of Olympus,Rick Riordan,Paperback,560,FALSE 3 | Book,9780141346809,Percy Jackson and the Lightning Thief,Rick Riordan,Paperback,400,TRUE 4 | Book,9781368013611,The Kane Chronicles Box Set,Rick Riordan,Hardback,1472,TRUE 5 | Book,9781423140597,The Son of Neptune,Rick Riordan,Hardback,521,FALSE 6 | Book,9781423140603,The Mark of Athena,Rick Riordan,Hardback,608,FALSE 7 | Book,9781423141891,Percy Jackson & the Olympians Boxed Set,Rick Riordan,Hardback,1744,TRUE 8 | Book,9781423146735,The Blood of Olympus,Rick Riordan,Hardback,516,TRUE 9 | Book,9781484707234,Percy Jackson and the Olympians,Rick Riordan,Paperback,1840,TRUE 10 | Book,9781484720721,The Heroes of Olympus Hardcover Boxed Set,Rick Riordan,Hardback,2404,FALSE 11 | Book,9781484732748,Trials of Apollo,Rick Riordan,Hardback,384,TRUE 12 | Book,9781484746455,The Tower of Nero,Rick Riordan,Hardback,416,TRUE 13 | -------------------------------------------------------------------------------- /src/Tests/MemoryTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using Azure.Data.Tables; 4 | using Xunit.Abstractions; 5 | 6 | namespace Devlooped; 7 | 8 | public class MemoryDocTests : DocumentRepositoryTests 9 | { 10 | protected override IDocumentRepository CreateRepository(IDocumentSerializer serializer) 11 | => MemoryRepository.Create(); 12 | 13 | protected override IDocumentPartition CreatePartition(IDocumentSerializer serializer) 14 | => MemoryPartition.Create(); 15 | 16 | protected override bool VerifyTableStorage => false; 17 | } 18 | 19 | public class MemoryRepoTests(ITestOutputHelper output) : RepositoryTests(output) 20 | { 21 | protected override ITablePartition CreatePartition(string partitionKey) 22 | => MemoryPartition.Create("Entities", partitionKey); 23 | 24 | protected override ITablePartition CreatePartition(string? partitionKey = null, Expression>? rowKey = null) 25 | => MemoryPartition.Create(partitionKey: partitionKey, rowKey: rowKey); 26 | 27 | protected override ITableRepository CreateRepository() 28 | => MemoryRepository.Create(); 29 | 30 | protected override ITableRepository CreateRepository(Expression>? partitionKey = null, Expression>? rowKey = null) 31 | => MemoryRepository.Create(partitionKey: partitionKey!, rowKey: rowKey!); 32 | } 33 | -------------------------------------------------------------------------------- /src/Tests/QueryableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | 5 | namespace Devlooped 6 | { 7 | public static class QueryableExtensions 8 | { 9 | public static async IAsyncEnumerable AsAsyncEnumerable(this IQueryable queryable) 10 | { 11 | if (queryable is IAsyncEnumerable asyncEnumerable) 12 | { 13 | await foreach (var item in asyncEnumerable) 14 | { 15 | yield return item; 16 | await Task.Yield(); 17 | } 18 | yield break; 19 | } 20 | 21 | foreach (var item in queryable) 22 | { 23 | yield return item; 24 | await Task.Yield(); 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Tests/Sample.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Devlooped 7 | { 8 | public class Sample 9 | { 10 | public async Task RunAsync() 11 | { 12 | var repo = TableRepository.Create( 13 | CloudStorageAccount.DevelopmentStorageAccount, 14 | tableName: "Products", 15 | partitionKey: p => p.Category, 16 | rowKey: p => p.Id); 17 | 18 | await repo.PutAsync(new Product("book", "9781473217386") 19 | { 20 | Title = "Neuromancer", 21 | Price = 7.32 22 | }); 23 | 24 | var docs = DocumentRepository.Create( 25 | CloudStorageAccount.DevelopmentStorageAccount, 26 | tableName: "Documents", 27 | partitionKey: p => p.Category, 28 | rowKey: p => p.Id); 29 | 30 | await docs.PutAsync(new Product("book", "9781473217386") 31 | { 32 | Title = "Neuromancer", 33 | Price = 7.32 34 | }); 35 | 36 | var bin = DocumentRepository.Create( 37 | CloudStorageAccount.DevelopmentStorageAccount, 38 | tableName: "Documents", 39 | partitionKey: p => p.Category, 40 | rowKey: p => p.Id, 41 | serializer: BsonDocumentSerializer.Default); 42 | 43 | await docs.PutAsync(new Product("book", "9781473217386") 44 | { 45 | Title = "Neuromancer", 46 | Price = 7.32 47 | }); 48 | 49 | } 50 | 51 | public async Task BooksAsync() 52 | { 53 | var storageAccount = CloudStorageAccount.DevelopmentStorageAccount; 54 | 55 | // We lay out the parameter names for clarity only. 56 | var repo = TableRepository.Create(storageAccount, 57 | tableName: "Books", 58 | partitionKey: p => p.Author, 59 | rowKey: p => p.ISBN); 60 | 61 | //await LoadBooksAsync(repo); 62 | 63 | // Create a new Book and save it 64 | await repo.PutAsync( 65 | new Book("9781473217386", "Neuromancer", "William Gibson", BookFormat.Paperback, 320)); 66 | 67 | await foreach (var book in from b in repo.CreateQuery() 68 | where b.Author == "William Gibson" && b.Format == BookFormat.Paperback 69 | select new { b.Title }) 70 | { 71 | Console.WriteLine(book.Title); 72 | } 73 | } 74 | 75 | async Task LoadBooksAsync(ITableRepository books) 76 | { 77 | foreach (var book in File.ReadAllLines("Books.csv").Skip(1) 78 | .Select(line => line.Split(',')) 79 | .Select(values => new Book(values[1], values[2], values[3], Enum.Parse(values[4]), int.Parse(values[5]), bool.Parse(values[6])))) 80 | { 81 | await books.PutAsync(book); 82 | } 83 | } 84 | 85 | public enum BookFormat { Paperback, Hardback } 86 | 87 | public record Book(string ISBN, string Title, string Author, BookFormat Format, int Pages, bool IsPublished = true); 88 | 89 | public record Product(string Category, string Id) 90 | { 91 | public string? Title { get; init; } 92 | public double Price { get; init; } 93 | public DateOnly CreatedAt { get; init; } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Tests/Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | false 6 | Preview 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "methodDisplay": "method", 3 | "preEnumerateTheories": true 4 | } -------------------------------------------------------------------------------- /src/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/src/icon.png -------------------------------------------------------------------------------- /src/kzu.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devlooped/TableStorage/6367598be92204b4faa0e746537c49118c52b079/src/kzu.snk -------------------------------------------------------------------------------- /src/nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | --------------------------------------------------------------------------------