├── .config └── dotnet-tools.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.yml │ └── FEATURE_REQUEST.yml ├── release-drafter.yml └── workflows │ ├── build.yml │ ├── docs.yml │ └── release-drafter.yml ├── .gitignore ├── Directory.Build.props ├── Directory.Build.targets ├── Key.snk ├── LICENSE ├── README.md ├── Sable.sln ├── _docs ├── .gitignore ├── .npmrc ├── .vitepress │ └── config.mts ├── README.md ├── guide │ ├── existing-project-integration.md │ ├── multi-tenancy-setup.md │ └── multiple-database-setup.md ├── index.md ├── introduction │ ├── getting-started.md │ └── why-sable.md ├── package-lock.json ├── package.json ├── public │ └── logo.svg └── reference │ ├── cli.md │ └── how-sable-works.md ├── build.cake ├── docs ├── 404.html ├── README.html ├── assets │ ├── README.md.85f54f8a.js │ ├── README.md.85f54f8a.lean.js │ ├── app.7749c2c3.js │ ├── chunks │ │ ├── @localSearchIndexroot.b15700c6.js │ │ ├── VPLocalSearchBox.f83fd2dc.js │ │ ├── framework.b8722102.js │ │ └── theme.6a9fdd37.js │ ├── guide_existing-project-integration.md.3810c307.js │ ├── guide_existing-project-integration.md.3810c307.lean.js │ ├── guide_multi-tenancy-setup.md.ce8c0d82.js │ ├── guide_multi-tenancy-setup.md.ce8c0d82.lean.js │ ├── guide_multiple-database-setup.md.e2e4ff14.js │ ├── guide_multiple-database-setup.md.e2e4ff14.lean.js │ ├── index.md.3716e34c.js │ ├── index.md.3716e34c.lean.js │ ├── inter-italic-cyrillic-ext.33bd5a8e.woff2 │ ├── inter-italic-cyrillic.ea42a392.woff2 │ ├── inter-italic-greek-ext.4fbe9427.woff2 │ ├── inter-italic-greek.8f4463c4.woff2 │ ├── inter-italic-latin-ext.bd8920cc.woff2 │ ├── inter-italic-latin.bd3b6f56.woff2 │ ├── inter-italic-vietnamese.6ce511fb.woff2 │ ├── inter-roman-cyrillic-ext.e75737ce.woff2 │ ├── inter-roman-cyrillic.5f2c6c8c.woff2 │ ├── inter-roman-greek-ext.ab0619bc.woff2 │ ├── inter-roman-greek.d5a6d92a.woff2 │ ├── inter-roman-latin-ext.0030eebd.woff2 │ ├── inter-roman-latin.2ed14f66.woff2 │ ├── inter-roman-vietnamese.14ce25a6.woff2 │ ├── introduction_getting-started.md.b6a14f95.js │ ├── introduction_getting-started.md.b6a14f95.lean.js │ ├── introduction_why-sable.md.21ebe741.js │ ├── introduction_why-sable.md.21ebe741.lean.js │ ├── reference_cli.md.93f35ef2.js │ ├── reference_cli.md.93f35ef2.lean.js │ ├── reference_how-sable-works.md.46d9baa9.js │ ├── reference_how-sable-works.md.46d9baa9.lean.js │ └── style.d664fc75.css ├── guide │ ├── existing-project-integration.html │ ├── multi-tenancy-setup.html │ └── multiple-database-setup.html ├── hashmap.json ├── index.html ├── introduction │ ├── getting-started.html │ └── why-sable.html ├── logo.svg └── reference │ ├── cli.html │ └── how-sable-works.html ├── global.json ├── images ├── Banner.png ├── Hero.png └── Icon.png ├── samples ├── README.md ├── Sable.Samples.Core │ ├── Book.cs │ ├── IOtherDocumentStore.cs │ ├── Order.cs │ └── Sable.Samples.Core.csproj ├── Sable.Samples.GettingStarted │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Sable.Samples.GettingStarted.csproj │ ├── appsettings.Development.json │ ├── appsettings.json │ └── sable │ │ └── Marten │ │ ├── 20240130030104_backfill.sql │ │ ├── migrations │ │ ├── 20240130030018_InfrastructureSetup.sql │ │ ├── 20240130030021_Initial.sql │ │ ├── 20240130030245_M1.sql │ │ └── 20240130031301_M2.sql │ │ ├── schema.txt │ │ └── scripts │ │ └── 20240130031331_script.sql ├── Sable.Samples.MultiTenancy │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Sable.Samples.MultiTenancy.csproj │ ├── appsettings.Development.json │ ├── appsettings.json │ └── sable │ │ └── Marten │ │ ├── migrations │ │ ├── 20231013223111_InfrastructureSetup.sql │ │ └── 20231013223114_Initial.sql │ │ └── schema.txt └── Sable.Samples.MultipleDatabases │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Sable.Samples.MultipleDatabases.csproj │ ├── appsettings.Development.json │ ├── appsettings.json │ └── sable │ ├── IOtherDocumentStore │ ├── 20231018224532_backfill.sql │ ├── migrations │ │ ├── 20231018224343_InfrastructureSetup.sql │ │ ├── 20231018224348_Initial.sql │ │ ├── 20231018224546_RemoveIndex.sql │ │ └── 20231018224612_Custom.sql │ ├── schema.txt │ └── scripts │ │ └── 20231018224817_script.sql │ └── Marten │ ├── migrations │ ├── 20231013224417_InfrastructureSetup.sql │ ├── 20231013224420_Initial.sql │ └── 20231013224536_AddIndexOnName.sql │ └── schema.txt ├── src ├── Directory.Build.props ├── Sable.Cli │ ├── AnsiConsoleLogger.cs │ ├── Commands │ │ ├── AddMigrationCommand.cs │ │ ├── BackfillMigrationsCommand.cs │ │ ├── CreateMigrationScriptCommand.cs │ │ ├── InitializeInfrastructureCommand.cs │ │ └── UpdateDatabaseCommand.cs │ ├── Extensions │ │ └── EnumerableExtensions.cs │ ├── IConsoleLogger.cs │ ├── IMartenMigrationManager.cs │ ├── MartenMigrationManager.cs │ ├── Migration.cs │ ├── Options │ │ └── PostgresContainerOptions.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── ReadinessProbeWaitStrategy.cs │ ├── Sable.Cli.csproj │ ├── SableCliConstants.cs │ ├── Settings │ │ └── ProjectSettings.cs │ ├── Templates.cs │ ├── TypeRegistrar.cs │ ├── Utilities │ │ ├── FileSystemUtilities.cs │ │ └── ValidationUtilities.cs │ └── containerOptions.upstream.json └── Sable │ ├── Extensions │ └── ServiceCollectionExtensions.cs │ ├── Sable.csproj │ └── SableConstants.cs └── tests ├── Directory.Build.props ├── Sable.Cli.Tests ├── FileSystemUtilitiesTests.cs └── Sable.Cli.Tests.csproj └── Sable.Tests ├── IOtherDatabase.cs ├── Sable.Tests.csproj └── ServiceRegistrationTests.cs /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-format": { 6 | "version": "5.1.250801", 7 | "commands": [ 8 | "dotnet-format" 9 | ] 10 | }, 11 | "cake.tool": { 12 | "version": "5.0.0", 13 | "commands": [ 14 | "dotnet-cake" 15 | ] 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################### 2 | # Git Line Endings # 3 | ############################### 4 | 5 | # Set default behavior to automatically normalize line endings. 6 | * text=auto 7 | 8 | # Force batch scripts to always use CRLF line endings so that if a repo is accessed 9 | # in Windows via a file share from Linux, the scripts will work. 10 | *.{cmd,[cC][mM][dD]} text eol=crlf 11 | *.{bat,[bB][aA][tT]} text eol=crlf 12 | 13 | # Force bash scripts to always use LF line endings so that if a repo is accessed 14 | # in Unix via a file share from Windows, the scripts will work. 15 | *.sh text eol=lf 16 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jabellard -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.yml: -------------------------------------------------------------------------------- 1 | name: 🐞 Bug Report 2 | description: If something isn't working as expected 3 | labels: [bug] 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Is there an existing issue for this? 8 | description: Please search to see if an issue already exists for the bug you encountered. 9 | options: 10 | - label: I have searched the existing issues 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: Current Behavior 15 | description: A concise description of what you're experiencing. 16 | validations: 17 | required: false 18 | - type: textarea 19 | attributes: 20 | label: Expected Behavior 21 | description: A concise description of what you expected to happen. 22 | validations: 23 | required: false 24 | - type: textarea 25 | attributes: 26 | label: Steps To Reproduce 27 | description: Steps to reproduce the behavior. 28 | placeholder: | 29 | 1. In this environment... 30 | 2. With this config... 31 | 3. Run '...' 32 | 4. See error... 33 | validations: 34 | required: True 35 | - type: input 36 | id: sable_version 37 | attributes: 38 | label: Sable Version 39 | description: What version of Sable are you seeing the problem on? 40 | placeholder: 1.0.0 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Anything else? 46 | description: | 47 | Links? References? Anything that will give us more context about the issue you are encountering! 48 | 49 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 50 | validations: 51 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea for this project 3 | labels: [enhancement] 4 | 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing proposal for this? 9 | description: Please search to see if a proposal already exists for this feature. 10 | options: 11 | - label: I have searched the existing proposals 12 | required: true 13 | - type: textarea 14 | attributes: 15 | label: Is your feature request related to a problem? 16 | description: A clear and concise description of what the problem is 17 | placeholder: | 18 | I have an issue when [...] 19 | validations: 20 | required: True 21 | - type: textarea 22 | attributes: 23 | label: Describe the solution you'd like 24 | description: A clear and concise description of what you want to happen. Add any considered drawbacks. 25 | placeholder: | 26 | I would love if Sable could [...] 27 | validations: 28 | required: True 29 | - type: textarea 30 | attributes: 31 | label: Alternatives you considered 32 | description: A clear and concise description of any alternative solutions or features you've considered. 33 | validations: 34 | required: False -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | # release-drafter automatically creates a draft release for you each time you complete a PR in the main branch. 2 | # It uses GitHub labels to categorize changes (See categories) and draft the release. 3 | # release-drafter also generates a version for your release based on GitHub labels. You can add a label of 'major', 4 | # 'minor' or 'patch' to determine which number in the version to increment. 5 | # You may need to add these labels yourself. 6 | # See https://github.com/release-drafter/release-drafter 7 | name-template: "$RESOLVED_VERSION" 8 | tag-template: "$RESOLVED_VERSION" 9 | change-template: "- $TITLE by @$AUTHOR (#$NUMBER)" 10 | no-changes-template: "- No changes" 11 | categories: 12 | - title: "📚 Documentation" 13 | labels: 14 | - "documentation" 15 | - title: "🚀 New Features" 16 | labels: 17 | - "enhancement" 18 | - title: "🐛 Bug Fixes" 19 | labels: 20 | - "bug" 21 | - title: "🧰 Maintenance" 22 | labels: 23 | - "maintenance" 24 | version-resolver: 25 | major: 26 | labels: 27 | - "major" 28 | minor: 29 | labels: 30 | - "minor" 31 | patch: 32 | labels: 33 | - "patch" 34 | default: patch 35 | template: | 36 | $CHANGES 37 | 38 | ## 👨🏼‍💻 Contributors 39 | 40 | $CONTRIBUTORS 41 | autolabeler: 42 | - label: "documentation" 43 | files: 44 | - "**/*.md" 45 | - "docs/**/*" 46 | - "_docs/**/*" 47 | - label: "enhancement" 48 | files: 49 | - "src/**/*" 50 | - label: "maintenance" 51 | files: 52 | - ".github/**/*" 53 | - "images/**/*" 54 | - "tests/**/*" 55 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | release: 9 | types: 10 | - published 11 | workflow_dispatch: 12 | 13 | env: 14 | # Disable the .NET logo in the console output. 15 | DOTNET_NOLOGO: true 16 | # Disable the .NET first time experience to skip caching NuGet packages and speed up the build. 17 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true 18 | # Disable sending .NET CLI telemetry to Microsoft. 19 | DOTNET_CLI_TELEMETRY_OPTOUT: true 20 | # Set the build number in MinVer. 21 | MINVERBUILDMETADATA: build.${{github.run_number}} 22 | 23 | jobs: 24 | build: 25 | name: Build 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: "Checkout" 29 | uses: actions/checkout@v3.0.2 30 | with: 31 | lfs: true 32 | fetch-depth: 0 33 | - name: "Install .NET Core SDK" 34 | uses: actions/setup-dotnet@v4 35 | with: 36 | dotnet-version: '8.0.x' 37 | - name: "Dotnet Tool Restore" 38 | run: dotnet tool restore 39 | shell: bash 40 | - name: "Dotnet Cake Build" 41 | run: dotnet cake --target=Build 42 | shell: bash 43 | - name: "Dotnet Cake Test" 44 | run: dotnet cake --target=Test 45 | shell: bash 46 | - name: "Dotnet Cake Pack" 47 | run: dotnet cake --target=Pack 48 | shell: bash 49 | - name: "Publish Artifacts" 50 | uses: actions/upload-artifact@v4 51 | with: 52 | name: "BuildArtifacts" 53 | path: "./Artifacts" 54 | 55 | push-nuget: 56 | name: "Push NuGet Packages" 57 | needs: build 58 | if: github.event_name == 'release' 59 | environment: 60 | name: "NuGet" 61 | url: https://www.nuget.org/packages/sable 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: "Download Artifact" 65 | uses: actions/download-artifact@v4.1.7 66 | with: 67 | name: "BuildArtifacts" 68 | - name: "Dotnet NuGet Push" 69 | run: | 70 | for package in *.nupkg; do 71 | if [[ "$package" != *preview* ]]; then 72 | dotnet nuget push "$package" \ 73 | --source https://api.nuget.org/v3/index.json \ 74 | --skip-duplicate \ 75 | --api-key ${{ secrets.NUGET_API_KEY }}; 76 | fi; 77 | done 78 | shell: bash 79 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Single deploy job since we're just deploying 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | # Upload docs artifacts 40 | path: './docs' 41 | - name: Deploy to GitHub Pages 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request_target: 8 | types: 9 | - edited 10 | - opened 11 | - reopened 12 | - synchronize 13 | workflow_dispatch: 14 | 15 | jobs: 16 | update_release_draft: 17 | permissions: 18 | contents: write 19 | pull-requests: write 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: "Draft Release" 23 | uses: release-drafter/release-drafter@v5.20.0 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUnit 46 | *.VisualState.xml 47 | TestResult.xml 48 | nunit-*.xml 49 | 50 | # Build Results of an ATL Project 51 | [Dd]ebugPS/ 52 | [Rr]eleasePS/ 53 | dlldata.c 54 | 55 | # Benchmark Results 56 | BenchmarkDotNet.Artifacts/ 57 | 58 | # .NET Core 59 | project.lock.json 60 | project.fragment.lock.json 61 | artifacts/ 62 | 63 | # StyleCop 64 | StyleCopReport.xml 65 | 66 | # Files built by Visual Studio 67 | *_i.c 68 | *_p.c 69 | *_h.h 70 | *.ilk 71 | *.meta 72 | *.obj 73 | *.iobj 74 | *.pch 75 | *.pdb 76 | *.ipdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *_wpftmp.csproj 87 | *.log 88 | *.vspscc 89 | *.vssscc 90 | .builds 91 | *.pidb 92 | *.svclog 93 | *.scc 94 | 95 | # Chutzpah Test files 96 | _Chutzpah* 97 | 98 | # Visual C++ cache files 99 | ipch/ 100 | *.aps 101 | *.ncb 102 | *.opendb 103 | *.opensdf 104 | *.sdf 105 | *.cachefile 106 | *.VC.db 107 | *.VC.VC.opendb 108 | 109 | # Visual Studio profiler 110 | *.psess 111 | *.vsp 112 | *.vspx 113 | *.sap 114 | 115 | # Visual Studio Trace Files 116 | *.e2e 117 | 118 | # TFS 2012 Local Workspace 119 | $tf/ 120 | 121 | # Guidance Automation Toolkit 122 | *.gpState 123 | 124 | # ReSharper is a .NET coding add-in 125 | _ReSharper*/ 126 | *.[Rr]e[Ss]harper 127 | *.DotSettings.user 128 | 129 | # JustCode is a .NET coding add-in 130 | .JustCode 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | 351 | # Ionide (cross platform F# VS Code tools) working folder 352 | .ionide/ 353 | 354 | Artifacts/ 355 | 356 | .idea/ 357 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | enable 5 | $(NoWarn);1591 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | preview 6 | 10 | 11 | normal 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Key.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/Key.snk -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Bloomberg Finance L.P. 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Sable logo 4 | 5 |

6 | 7 | # Sable ⚡ 8 | 9 | Database Migration Management Tool for [Marten](https://github.com/JasperFx/marten) 10 | 11 | [Sable (*Martes zibellina*)](https://en.wikipedia.org/wiki/Sable) is a species of marten. 12 | 13 | ## Menu 14 | 15 | - [Quick Start](#quick-start) 16 | - [Documentation](#documentation) 17 | - [Contributions](#contributions) 18 | - [License](#license) 19 | - [Code of Conduct](#code-of-conduct) 20 | - [Security Policy](#security-policy) 21 | 22 | ## Quick Start 23 | 24 | ### Prerequisites 25 | 26 | Before starting, ensure the following prerequisites are met: 27 | - [Docker](https://docs.docker.com/engine/install/) is installed. 28 | - The **Sable** .NET tool is installed by running the following command: 29 | 30 | ```bash 31 | dotnet tool install -g Sable.Cli 32 | ``` 33 | 34 | See [.NET tools](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools) to learn more about how .NET tools work. 35 | 36 | ### Application Configuration 37 | 38 | This guide assumes you have experience with configuring Marten together with its command line tooling support in .NET projects. If that is not the case, please take a look at the following guides before proceeding: 39 | - [Getting Started](https://martendb.io/getting-started.html) with Marten 40 | - [Command Line Tooling](https://martendb.io/configuration/cli.html#command-line-tooling) in Marten 41 | 42 | Now we're going to integrate **Sable** into a new project. 43 | 44 | - Create a new project: 45 | ```bash 46 | dotnet new webapi 47 | ``` 48 | - Configure Marten along with its command line tooling support. 49 | - Add **Sable** integration support to the project: 50 | ```bash 51 | dotnet add package Sable 52 | ``` 53 | 54 | Now for the fun part. Replace whatever overload of `AddMarten` you're using with `AddMartenWithSableSupport`. That's all it takes to complete the integration. 55 | 56 | At this point, you should have a configuration that looks something like this: 57 | ```c# 58 | using Marten; 59 | using Oakton; 60 | using Sable.Extensions; 61 | using Sable.Samples.Core; 62 | using Weasel.Core; 63 | 64 | var builder = WebApplication.CreateBuilder(args); 65 | builder.Host.ApplyOaktonExtensions(); 66 | builder.Services.AddMartenWithSableSupport(_ => 67 | { 68 | var options = new StoreOptions(); 69 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 70 | options.DatabaseSchemaName = "books"; 71 | options.AutoCreateSchemaObjects = AutoCreate.None; 72 | options.Schema.For() 73 | .Index(x => x.Contents); 74 | return options; 75 | }); 76 | 77 | var app = builder.Build(); 78 | app.MapGet("/", () => "💪🏾"); 79 | 80 | return await app.RunOaktonCommands(args); 81 | ``` 82 | 83 | ### Initialize Migration Infrastructure 84 | 85 | Okay. Now that your project is properly configured, what's next? 86 | In your project directory, run the following command: 87 | 88 | ```bash 89 | sable init --database --schema 90 | ``` 91 | 92 | The default values for the database and schema names are `Marten` and `public`, respectively. 93 | `Marten` is the name associated with the database for the default configuration. This is important, especially when multiple databases are used in the same project. 94 | 95 | Running the command above should also have created some migration files in the `./sable//migrations` directory. 96 | 97 | ### Update Database 98 | 99 | Now, to update the database, follow either one of the following stategies: 100 | - Create a migration script that you can apply manually: 101 | 102 | ```bash 103 | sable migrations script --database 104 | ``` 105 | 106 | Running the command above should have created a migration script in the `./sable//scripts` directory. 107 | You can now take that script and apply it manually to your database. 108 | 109 | OR 110 | 111 | - Point **Sable** to the database and have it run the migration it for you: 112 | 113 | ```bash 114 | sable database update --database 115 | ``` 116 | 117 | Running the command above should have applied the pending migrations to your database. 118 | 119 | ### Add Migration 120 | Okay. Everything is good so far, but you just added a new index to a document and want to update the database. What do you do? 121 | That's pretty simple. Just add a new migration: 122 | 123 | ```bash 124 | sable migrations add AddIndexOnName --database 125 | ``` 126 | 127 | Running the command above should have created a new migration file in the `./sable//migrations` directory. 128 | 129 | To apply that migration, just follow one of the database update strategies outlined above one more time. 130 | 131 | ## Documentation 132 | 133 | To learn more, check out the [documentation](https://bloomberg.github.io/sable/). 134 | 135 | ## Contributions 136 | 137 | We :heart: contributions. 138 | 139 | Have you had a good experience with this project? Why not share some love and contribute code, or just let us know about any issues you had with it? 140 | 141 | We welcome issue reports [here](../../issues); be sure to choose the proper issue template for your issue, so that we can be sure you're providing the necessary information. 142 | 143 | Before sending a [Pull Request](../../pulls), please make sure you read our [Contribution Guidelines](https://github.com/bloomberg/.github/blob/master/CONTRIBUTING.md). 144 | 145 | ## License 146 | 147 | Please read the [LICENSE](LICENSE) file. 148 | 149 | ## Code of Conduct 150 | 151 | This project has adopted a [Code of Conduct](https://github.com/bloomberg/.github/blob/master/CODE_OF_CONDUCT.md). 152 | If you have any concerns about the Code, or behavior which you have experienced in the project, please 153 | contact us at opensource@bloomberg.net. 154 | 155 | ## Security Policy 156 | 157 | - [Security Policy](https://github.com/bloomberg/sable/security/policy) 158 | 159 | If you believe you have identified a security vulnerability in this project, you may submit a private vulnerability disclosure. 160 | 161 | Please do NOT open an issue in the GitHub repository, as we'd prefer to keep vulnerability reports private until we've had an opportunity to review and address them. 162 | 163 | If you have any questions or concerns, please send an email to the Bloomberg OSPO at opensource@bloomberg.net. 164 | 165 | --- 166 | -------------------------------------------------------------------------------- /Sable.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 17 3 | VisualStudioVersion = 17.0.31423.177 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{719809C2-A551-4C4A-9EFD-B10FB5E35BC0}" 6 | ProjectSection(SolutionItems) = preProject 7 | src\Directory.Build.props = src\Directory.Build.props 8 | EndProjectSection 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "items", "items", "{F20E2797-D1E3-4321-91BB-FAE54954D2A0}" 11 | ProjectSection(SolutionItems) = preProject 12 | .gitignore = .gitignore 13 | build.cake = build.cake 14 | Directory.Build.props = Directory.Build.props 15 | Directory.Build.targets = Directory.Build.targets 16 | global.json = global.json 17 | .editorconfig = .editorconfig 18 | .config\dotnet-tools.json = .config\dotnet-tools.json 19 | EndProjectSection 20 | EndProject 21 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "documentation", "documentation", "{7EDFA103-DB69-4C88-9DE4-97ADBF8253A1}" 22 | ProjectSection(SolutionItems) = preProject 23 | LICENSE = LICENSE 24 | README.md = README.md 25 | EndProjectSection 26 | EndProject 27 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{E1B24F25-B8A4-46EE-B7EB-7803DCFC543F}" 28 | ProjectSection(SolutionItems) = preProject 29 | tests\Directory.Build.props = tests\Directory.Build.props 30 | EndProjectSection 31 | EndProject 32 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "images", "images", "{26F71F5B-2940-4FB0-9681-A76060CBCEF9}" 33 | ProjectSection(SolutionItems) = preProject 34 | images\Banner.png = images\Banner.png 35 | images\Hero.png = images\Hero.png 36 | images\Icon.png = images\Icon.png 37 | EndProjectSection 38 | EndProject 39 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{566DF0E2-1288-4083-9B55-4C8B69BB1432}" 40 | ProjectSection(SolutionItems) = preProject 41 | .github\ISSUE_TEMPLATE\BUG_REPORT.yml = .github\ISSUE_TEMPLATE\BUG_REPORT.yml 42 | .github\ISSUE_TEMPLATE\FEATURE_REQUEST.yml = .github\ISSUE_TEMPLATE\FEATURE_REQUEST.yml 43 | EndProjectSection 44 | EndProject 45 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{0555C737-CE4B-4C78-87AB-6296E1E32D01}" 46 | ProjectSection(SolutionItems) = preProject 47 | .github\CODEOWNERS = .github\CODEOWNERS 48 | .github\release-drafter.yml = .github\release-drafter.yml 49 | EndProjectSection 50 | EndProject 51 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sable", "src\Sable\Sable.csproj", "{FF1F5A44-8B89-4316-AD77-E1C56CF0655E}" 52 | EndProject 53 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{EFE1E5ED-D337-4874-82EC-D9FA0BC7D3AB}" 54 | ProjectSection(SolutionItems) = preProject 55 | .github\release-drafter.yml = .github\release-drafter.yml 56 | EndProjectSection 57 | EndProject 58 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{841C67EF-BBB2-4730-8E29-22FF3FD54306}" 59 | ProjectSection(SolutionItems) = preProject 60 | .github\workflows\build.yml = .github\workflows\build.yml 61 | .github\workflows\release-drafter.yml = .github\workflows\release-drafter.yml 62 | EndProjectSection 63 | EndProject 64 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sable.Cli", "src\Sable.Cli\Sable.Cli.csproj", "{5AFD0604-B5BD-4E96-98E1-9ADC13069BCC}" 65 | EndProject 66 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5934E500-E691-4C28-A8A5-119E9786F2AE}" 67 | EndProject 68 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sable.Samples.Core", "samples\Sable.Samples.Core\Sable.Samples.Core.csproj", "{0E11EECD-333F-4FB5-A9C1-D4D71223C96D}" 69 | EndProject 70 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sable.Samples.GettingStarted", "samples\Sable.Samples.GettingStarted\Sable.Samples.GettingStarted.csproj", "{35170F5E-7773-4C94-A297-D0DC4E92A9CA}" 71 | EndProject 72 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sable.Samples.MultipleDatabases", "samples\Sable.Samples.MultipleDatabases\Sable.Samples.MultipleDatabases.csproj", "{0741B165-9DFA-49EB-B91C-7DECD6584B7A}" 73 | EndProject 74 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sable.Samples.MultiTenancy", "samples\Sable.Samples.MultiTenancy\Sable.Samples.MultiTenancy.csproj", "{1DB49A91-FA54-4534-9B63-312DC30F98B7}" 75 | EndProject 76 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sable.Cli.Tests", "tests\Sable.Cli.Tests\Sable.Cli.Tests.csproj", "{03FA826C-6DF3-465F-A1BE-83BA55156F74}" 77 | EndProject 78 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sable.Tests", "tests\Sable.Tests\Sable.Tests.csproj", "{65E71BD0-E5CD-42A2-959D-B5D74A655AF8}" 79 | EndProject 80 | Global 81 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 82 | Debug|Any CPU = Debug|Any CPU 83 | Release|Any CPU = Release|Any CPU 84 | EndGlobalSection 85 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 86 | {FF1F5A44-8B89-4316-AD77-E1C56CF0655E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 87 | {FF1F5A44-8B89-4316-AD77-E1C56CF0655E}.Debug|Any CPU.Build.0 = Debug|Any CPU 88 | {FF1F5A44-8B89-4316-AD77-E1C56CF0655E}.Release|Any CPU.ActiveCfg = Release|Any CPU 89 | {FF1F5A44-8B89-4316-AD77-E1C56CF0655E}.Release|Any CPU.Build.0 = Release|Any CPU 90 | {5AFD0604-B5BD-4E96-98E1-9ADC13069BCC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 91 | {5AFD0604-B5BD-4E96-98E1-9ADC13069BCC}.Debug|Any CPU.Build.0 = Debug|Any CPU 92 | {5AFD0604-B5BD-4E96-98E1-9ADC13069BCC}.Release|Any CPU.ActiveCfg = Release|Any CPU 93 | {5AFD0604-B5BD-4E96-98E1-9ADC13069BCC}.Release|Any CPU.Build.0 = Release|Any CPU 94 | {0E11EECD-333F-4FB5-A9C1-D4D71223C96D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 95 | {0E11EECD-333F-4FB5-A9C1-D4D71223C96D}.Debug|Any CPU.Build.0 = Debug|Any CPU 96 | {0E11EECD-333F-4FB5-A9C1-D4D71223C96D}.Release|Any CPU.ActiveCfg = Release|Any CPU 97 | {0E11EECD-333F-4FB5-A9C1-D4D71223C96D}.Release|Any CPU.Build.0 = Release|Any CPU 98 | {35170F5E-7773-4C94-A297-D0DC4E92A9CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 99 | {35170F5E-7773-4C94-A297-D0DC4E92A9CA}.Debug|Any CPU.Build.0 = Debug|Any CPU 100 | {35170F5E-7773-4C94-A297-D0DC4E92A9CA}.Release|Any CPU.ActiveCfg = Release|Any CPU 101 | {35170F5E-7773-4C94-A297-D0DC4E92A9CA}.Release|Any CPU.Build.0 = Release|Any CPU 102 | {0741B165-9DFA-49EB-B91C-7DECD6584B7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 103 | {0741B165-9DFA-49EB-B91C-7DECD6584B7A}.Debug|Any CPU.Build.0 = Debug|Any CPU 104 | {0741B165-9DFA-49EB-B91C-7DECD6584B7A}.Release|Any CPU.ActiveCfg = Release|Any CPU 105 | {0741B165-9DFA-49EB-B91C-7DECD6584B7A}.Release|Any CPU.Build.0 = Release|Any CPU 106 | {1DB49A91-FA54-4534-9B63-312DC30F98B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 107 | {1DB49A91-FA54-4534-9B63-312DC30F98B7}.Debug|Any CPU.Build.0 = Debug|Any CPU 108 | {1DB49A91-FA54-4534-9B63-312DC30F98B7}.Release|Any CPU.ActiveCfg = Release|Any CPU 109 | {1DB49A91-FA54-4534-9B63-312DC30F98B7}.Release|Any CPU.Build.0 = Release|Any CPU 110 | {03FA826C-6DF3-465F-A1BE-83BA55156F74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 111 | {03FA826C-6DF3-465F-A1BE-83BA55156F74}.Debug|Any CPU.Build.0 = Debug|Any CPU 112 | {03FA826C-6DF3-465F-A1BE-83BA55156F74}.Release|Any CPU.ActiveCfg = Release|Any CPU 113 | {03FA826C-6DF3-465F-A1BE-83BA55156F74}.Release|Any CPU.Build.0 = Release|Any CPU 114 | {65E71BD0-E5CD-42A2-959D-B5D74A655AF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 115 | {65E71BD0-E5CD-42A2-959D-B5D74A655AF8}.Debug|Any CPU.Build.0 = Debug|Any CPU 116 | {65E71BD0-E5CD-42A2-959D-B5D74A655AF8}.Release|Any CPU.ActiveCfg = Release|Any CPU 117 | {65E71BD0-E5CD-42A2-959D-B5D74A655AF8}.Release|Any CPU.Build.0 = Release|Any CPU 118 | EndGlobalSection 119 | GlobalSection(SolutionProperties) = preSolution 120 | HideSolutionNode = FALSE 121 | EndGlobalSection 122 | GlobalSection(NestedProjects) = preSolution 123 | {566DF0E2-1288-4083-9B55-4C8B69BB1432} = {0555C737-CE4B-4C78-87AB-6296E1E32D01} 124 | {0555C737-CE4B-4C78-87AB-6296E1E32D01} = {7EDFA103-DB69-4C88-9DE4-97ADBF8253A1} 125 | {FF1F5A44-8B89-4316-AD77-E1C56CF0655E} = {719809C2-A551-4C4A-9EFD-B10FB5E35BC0} 126 | {EFE1E5ED-D337-4874-82EC-D9FA0BC7D3AB} = {F20E2797-D1E3-4321-91BB-FAE54954D2A0} 127 | {841C67EF-BBB2-4730-8E29-22FF3FD54306} = {EFE1E5ED-D337-4874-82EC-D9FA0BC7D3AB} 128 | {5AFD0604-B5BD-4E96-98E1-9ADC13069BCC} = {719809C2-A551-4C4A-9EFD-B10FB5E35BC0} 129 | {0E11EECD-333F-4FB5-A9C1-D4D71223C96D} = {5934E500-E691-4C28-A8A5-119E9786F2AE} 130 | {35170F5E-7773-4C94-A297-D0DC4E92A9CA} = {5934E500-E691-4C28-A8A5-119E9786F2AE} 131 | {0741B165-9DFA-49EB-B91C-7DECD6584B7A} = {5934E500-E691-4C28-A8A5-119E9786F2AE} 132 | {1DB49A91-FA54-4534-9B63-312DC30F98B7} = {5934E500-E691-4C28-A8A5-119E9786F2AE} 133 | {03FA826C-6DF3-465F-A1BE-83BA55156F74} = {E1B24F25-B8A4-46EE-B7EB-7803DCFC543F} 134 | {65E71BD0-E5CD-42A2-959D-B5D74A655AF8} = {E1B24F25-B8A4-46EE-B7EB-7803DCFC543F} 135 | EndGlobalSection 136 | GlobalSection(ExtensibilityGlobals) = postSolution 137 | SolutionGuid = {73F36209-F8D6-4066-8951-D97729F773CF} 138 | EndGlobalSection 139 | EndGlobal 140 | -------------------------------------------------------------------------------- /_docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | .idea/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # vitepress 54 | .vitepress/cache 55 | .vitepress/dist 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # vuepress v2.x temp and cache directory 110 | .temp 111 | .cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* -------------------------------------------------------------------------------- /_docs/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /_docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | base: "/sable/", 6 | title: "Sable", 7 | description: "Database Migration Management for Marten", 8 | themeConfig: { 9 | // https://vitepress.dev/reference/default-theme-config 10 | nav: [ 11 | { text: 'Introduction', link: '/introduction/why-sable' } 12 | ], 13 | logo: '/logo.svg', 14 | sidebar: [ 15 | { 16 | text: 'Introduction', 17 | items: [ 18 | { text: 'Why Sable?', link: '/introduction/why-sable' }, 19 | { text: 'Getting Started', link: '/introduction/getting-started' } 20 | ] 21 | }, 22 | { 23 | text: 'Guide', 24 | items: [ 25 | { text: 'Multi-Tenancy Setup', link: '/guide/multi-tenancy-setup' }, 26 | { text: 'Multiple Database Setup', link: '/guide/multiple-database-setup' }, 27 | { text: 'Existing Project Integration', link: '/guide/existing-project-integration' } 28 | ] 29 | }, 30 | { 31 | text: 'Reference', 32 | items: [ 33 | { text: 'Command Line Interface', link: '/reference/cli' }, 34 | { text: 'How Sable Works', link: '/reference/how-sable-works' } 35 | ] 36 | } 37 | ], 38 | search: { 39 | provider: 'local' 40 | }, 41 | socialLinks: [ 42 | { icon: 'github', link: 'https://github.com/bloomberg/sable' } 43 | ] 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /_docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation -------------------------------------------------------------------------------- /_docs/guide/existing-project-integration.md: -------------------------------------------------------------------------------- 1 | # Existing Project Integration 2 | 3 | So, you've read the [Getting Started](../introduction/getting-started) guide, and may be wondering how to integrate **Sable** into an existing project. It's very simple. 4 | 5 | ## Prerequisites 6 | - If you have yet to do so, make sure to read the [Getting Started](../introduction/getting-started) guide. The process for integrating Sable into an existing project is very 7 | similar to what is described there, but with a slight twist, so everything learned there will be applicable for existing projects. 8 | - Make sure all of your Postgres databases across every environment where your code is running have already converged to the same latest state. 9 | 10 | Once those prerequisites are met, you're all set to go. 11 | 12 | ## Application Configuration 13 | 14 | - Ensure support for sable is configured. Your configuration should look something like the following: 15 | ```c# 16 | using Marten; 17 | using Oakton; 18 | using Sable.Extensions; 19 | using Sable.Samples.Core; 20 | using Weasel.Core; 21 | 22 | var builder = WebApplication.CreateBuilder(args); 23 | builder.Host.ApplyOaktonExtensions(); 24 | builder.Services.AddMartenWithSableSupport(_ => 25 | { 26 | var options = new StoreOptions(); 27 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 28 | options.DatabaseSchemaName = "books"; 29 | options.AutoCreateSchemaObjects = AutoCreate.None; 30 | options.Schema.For() 31 | .Index(x => x.Contents); 32 | return options; 33 | }); 34 | 35 | var app = builder.Build(); 36 | app.MapGet("/", () => "💪🏾"); 37 | 38 | return await app.RunOaktonCommands(args); 39 | ``` 40 | 41 | ## Initialize Migration Infrastructure 42 | 43 | Ok. Now that your project is properly configured, what's next? 44 | In your project directory, run the following command: 45 | 46 | ```bash 47 | sable init --database --schema 48 | ``` 49 | 50 | The default values for the database and schema are `Marten` and `public`, respectively. 51 | `Marten` is the name associated with the database for the default configuration. This is important when multiple databases are referenced in the same project. 52 | 53 | Running the command above should have created some migration file in the `./sable//migrations` directory. 54 | 55 | ## Backfill Initial Migrations 56 | 57 | Once the migration infrastructure has been initialized for the Marten database, there's one more thing to do before proceeding. 58 | The Postgres databases are already up to date, so we must not apply the newly generated migrations. Instead, we'll just backfill them. 59 | **Sable** maintains a table to keep track of applied migrations in the database. 60 | Whenever a new migration is applied, **Sable** inserts a new record in that table for that migration to ensure it is applied only once. 61 | In our case, since the Postgres databases are already up to date, the newly generated migrations have already been applied without **Sable**, so we'll just backfill them: 62 | 63 | ```bash 64 | sable migrations backfill --database 65 | ``` 66 | 67 | Running the command above should have created a new migration file in the `./sable/` directory called `_backfill.sql`. 68 | Apply it to your database. That's all it takes to integrate **Sable** into an existing project. From this point on, treat the project as if it had been integrated with **Sable** from the very beginning. 69 | 70 | To learn more about how **Sable** works, see [How Sable Works](../reference/how-sable-works). -------------------------------------------------------------------------------- /_docs/guide/multi-tenancy-setup.md: -------------------------------------------------------------------------------- 1 | # Multi-tenancy Setup 2 | 3 | So, you already went through the [Getting Started](../introduction/getting-started) guide, and may be wondering if there's 4 | any additional configuration needed for multi-tenancy setups. Given that all Postgres databases for the configured tenants must have identical structures, there's none. Everything you learned in the getting started guide 5 | still applies for any single database setup with multi-tenancy configured. As such, a multi-tenancy setup will look something like this: 6 | 7 | ```c# 8 | using Marten; 9 | using Oakton; 10 | using Sable.Extensions; 11 | using Sable.Samples.Core; 12 | using Weasel.Core; 13 | 14 | var builder = WebApplication.CreateBuilder(args); 15 | builder.Host.ApplyOaktonExtensions(); 16 | 17 | builder.Services.AddMartenWithSableSupport(_ => 18 | { 19 | var options = new StoreOptions 20 | { 21 | DatabaseSchemaName = "books" 22 | }; 23 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 24 | options.MultiTenantedDatabases(x => 25 | { 26 | x.AddMultipleTenantDatabase(builder.Configuration["Databases:Books:GoldTier"], "books_gold") 27 | .ForTenants("gold1", "gold2"); 28 | x.AddSingleTenantDatabase(builder.Configuration["Databases:Books:SilverTier"], "books_silver"); 29 | }); 30 | options.AutoCreateSchemaObjects = AutoCreate.None; 31 | options.Schema.For(); 32 | return options; 33 | }); 34 | 35 | var app = builder.Build(); 36 | app.MapGet("/", () => "💪🏾"); 37 | 38 | return await app.RunOaktonCommands(args); 39 | ``` 40 | 41 | Again, given that all Postgres databases for the configured tenants must have identical structures, there's no need to generate a different set of migrations for every single database identifier. So, for the example shown above, 42 | you just need to specify the database name as `Marten`, not `books_basic` nor `books_basic`, when running **Sable** commands. That's it. 43 | 44 | See [Multi-tencancy Sample](https://martendb.io/configuration/multitenancy.html) for a sample application with a multi-tenancy setup. 45 | 46 | To learn more about multi-tenancy in Marten, see [Marten Multi-tencancy](https://martendb.io/configuration/multitenancy.html). -------------------------------------------------------------------------------- /_docs/guide/multiple-database-setup.md: -------------------------------------------------------------------------------- 1 | # Multiple Database Setup 2 | 3 | After going through the [Getting Started](../introduction/getting-started) guide, you may be wondering how to manage migrations for a project where multiple databases are configured with **Sable**. 4 | It's pretty simple. Sable will just maintain migrations in separate directories for the configured databases. Let's say your configuration looks something like the following: 5 | ```c# 6 | using Marten; 7 | using Oakton; 8 | using Sable.Extensions; 9 | using Sable.Samples.Core; 10 | using Weasel.Core; 11 | 12 | var builder = WebApplication.CreateBuilder(args); 13 | builder.Host.ApplyOaktonExtensions(); 14 | 15 | builder.Services.AddMartenWithSableSupport(_ => 16 | { 17 | var options = new StoreOptions 18 | { 19 | DatabaseSchemaName = "books" 20 | }; 21 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 22 | options.MultiTenantedDatabases(x => 23 | { 24 | x.AddMultipleTenantDatabase(builder.Configuration["Databases:Books:GoldTier"], "books_gold") 25 | .ForTenants("gold1", "gold2"); 26 | x.AddSingleTenantDatabase(builder.Configuration["Databases:Books:SilverTier"], "books_silver"); 27 | }); 28 | options.AutoCreateSchemaObjects = AutoCreate.None; 29 | options.Schema.For(); 30 | return options; 31 | }); 32 | 33 | builder.Services.AddMartenStoreWithSableSupport(_ => 34 | { 35 | var options = new StoreOptions 36 | { 37 | DatabaseSchemaName = "orders" 38 | }; 39 | options.Connection(builder.Configuration["Databases:Orders:BasicTier"]); 40 | options.MultiTenantedDatabases(x => 41 | { 42 | x.AddMultipleTenantDatabase(builder.Configuration["Databases:Books:GoldTier"], "orders_gold") 43 | .ForTenants("gold1", "gold2"); 44 | x.AddSingleTenantDatabase(builder.Configuration["Databases:Books:SilverTier"], "orders_silver"); 45 | }); 46 | options.AutoCreateSchemaObjects = AutoCreate.None; 47 | options.Schema.For(); 48 | return options; 49 | }); 50 | 51 | var app = builder.Build(); 52 | app.MapGet("/", () => "💪🏾"); 53 | 54 | return await app.RunOaktonCommands(args); 55 | ``` 56 | 57 | When running a **Sable** command, just specify for which database you intend to use it for. For instance, in the example above, you would specify the name of the database as either `Marten` (the default database name) or `IOtherDocumentStore`. 58 | Sable will take care of the rest, and manage migrations for those databases in two separate directories called `Marten` and `IOtherDocumentStore`, respectively. 59 | 60 | To learn more about multi-database setups in Marten, see [Marten Multi-Database Setups](https://jeremydmiller.com/2022/03/29/working-with-multiple-marten-databases-in-one-application/). 61 | -------------------------------------------------------------------------------- /_docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | title: Sable 6 | titleTemplate: Database Migrations for Marten 7 | 8 | hero: 9 | name: Sable 10 | text: Database Migrations for Marten 11 | tagline: Simple, easy to use database migration management tool for Marten. 12 | image: 13 | src: /logo.svg 14 | alt: Sable logo 15 | actions: 16 | - theme: brand 17 | text: Get Started 18 | link: /introduction/getting-started 19 | - theme: alt 20 | text: Why Sable? 21 | link: /introduction/why-sable 22 | - theme: alt 23 | text: View on GitHub 24 | link: https://github.com/bloomberg/sable 25 | 26 | features: 27 | - icon: 💡 28 | title: Intuitive 29 | details: Sable comes with an intuitive, easy to use CLI that is heavily inspired by other popular migration tools like dotnet-ef. 30 | - icon: 🚀 31 | title: Seamless Integration 32 | details: Sable can be seamlessly integrated into new, or existing projects. Integration is also minimally invasive. Opt-in or opt-out anytime with absolutely no headaches. 33 | - icon: 💪🏾 34 | title: Comprehensive Support 35 | details: Well-thought out to support any Marten configuration, including those that have multiple database references or multi-tenancy setups. 36 | --- 37 | 58 | -------------------------------------------------------------------------------- /_docs/introduction/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Prerequisites 4 | 5 | Before starting, ensure the following prerequisites are met: 6 | - [Docker](https://docs.docker.com/engine/install/) is installed. To learn why Docker is needed, see [How Sable Works](../reference/how-sable-works). 7 | - The **Sable** dotnet tool is installed by running the following command: 8 | 9 | ```bash 10 | dotnet tool install -g Sable.Cli 11 | ``` 12 | 13 | See [.NET Tools](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools) to learn more about how .NET tools work. 14 | 15 | ## Application Configuration 16 | 17 | This guide assumes you have experience with configuring Marten along with its command line tooling support in .NET projects. If that is not the case, please take a look at the following guides before proceeding: 18 | - [Getting Started With Marten](https://martendb.io/getting-started.html) 19 | - [Marten Command Line Tooling](https://martendb.io/configuration/cli.html#command-line-tooling) 20 | 21 | Ok. Let's move on. We're going to integrate **Sable** into a new project. To learn how to integrate **Sable** into an exiting project, see [Existing Project Integration](../guide/existing-project-integration) after going through this guide. 22 | The process is similar, so everything learned here will be applicable for existing projects as well. 23 | 24 | - Create a new project: 25 | ```bash 26 | dotnet new webapi 27 | ``` 28 | - Configure marten along with its command line tooling support. 29 | - Add **Sable** integration support to the project: 30 | ```bash 31 | dotnet add package Sable 32 | ``` 33 | 34 | Now the fun part. Replace whatever overload of `AddMarten` you're using with `AddMartenWithSableSupport`. That's all there is to it for the integration. 35 | 36 | At this point, you should have a configuration that looks something like this: 37 | ```c# 38 | using Marten; 39 | using Oakton; 40 | using Sable.Extensions; 41 | using Sable.Samples.Core; 42 | using Weasel.Core; 43 | 44 | var builder = WebApplication.CreateBuilder(args); 45 | builder.Host.ApplyOaktonExtensions(); 46 | builder.Services.AddMartenWithSableSupport(_ => 47 | { 48 | var options = new StoreOptions(); 49 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 50 | options.DatabaseSchemaName = "books"; 51 | options.AutoCreateSchemaObjects = AutoCreate.None; 52 | options.Schema.For() 53 | .Index(x => x.Contents); 54 | return options; 55 | }); 56 | 57 | var app = builder.Build(); 58 | app.MapGet("/", () => "💪🏾"); 59 | 60 | return await app.RunOaktonCommands(args); 61 | ``` 62 | 63 | ## Initialize Migration Infrastructure 64 | 65 | Ok. Now that your project is properly configured, what's next? 66 | In your project directory, run the following command: 67 | 68 | ```bash 69 | sable init --database --schema 70 | ``` 71 | 72 | The default values for the database and schema names are `Marten` and `public`, respectively. 73 | `Marten` is the name associated with the database for the default configuration. This is important when multiple databases are used in the same project. 74 | 75 | Running the command above should have created some migration files in the `./sable//migrations` directory. 76 | 77 | 78 | 79 | ## Update Database 80 | 81 | Now, to update the database, follow either one of the following strategies: 82 | - Create a migration script that you can then apply manually: 83 | 84 | ```bash 85 | sable migrations script --database 86 | ``` 87 | 88 | Running the command above should have created a migration script in the `./sable//scripts` directory. 89 | You can now take that script and apply it manually to your database. 90 | 91 | - Point **Sable** to the database and have it run the migration it for you: 92 | 93 | ```bash 94 | sable database update --database 95 | ``` 96 | 97 | Running the command above should have applied the pending migrations to your database. 98 | 99 | ## Add Migration 100 | Ok. All good so far, but you just added a new index to a document and want to update the database. What do you do? 101 | Pretty simple. Just add a new migration: 102 | 103 | ```bash 104 | sable migrations add --database 105 | ``` 106 | 107 | Running the command above should have created a new migration file in the `./sable//migrations` directory. 108 | 109 | To apply that migration, just follow one of the database update strategies outlined above one more time. 110 | 111 | 112 | ## What's Next? 113 | 114 | - Want do see some more sample configurations? See [Sample Configurations](https://github.com/bloomberg/sable/tree/main/samples). 115 | 116 | - You have a more complicated configuration with a multi-tenancy setup? See [Multi-Tenancy Setup](../guide/multi-tenancy-setup) 117 | 118 | - You have a more complicated configuration with multiple database references? See [Multiple Database Setup](../guide/multiple-database-setup) 119 | 120 | - Curious to know how Sable works? See [How Sable Works](../reference/how-sable-works) -------------------------------------------------------------------------------- /_docs/introduction/why-sable.md: -------------------------------------------------------------------------------- 1 | # Why **Sable**? 2 | 3 | The **Marten** team has done a phenomenal job with providing the foundational infrastructure required for managing database migrations. The command line tooling for that is made available via the `Marten.CommandLine` package, and works just fine. 4 | With a connection string that is sufficiently privileged to execute migration scripts, the `marten-patch` and `marten-apply` commands can easily be used to carry out the process. However, in a corporate environment like Bloomberg, this approach is not feasible. But why not? 5 | Well, for local development, it's not an issue, but for other environments like dev, alpha, beta, and prod, we've encountered some limitations because of the following reasons: 6 | - There's a standard process for executing database migrations scripts. An engineer can't just point to a database to run migrations, but needs to submit a ticket that must be approved by a manager/team lead before the script can be executed. 7 | - An application will often be deployed to multiple environments in a sequential deployment pipeline (e.g., dev -> alpha -> beta -> prod). Furthermore, these deployments won't happen in a compressed time frame. You want to test things in one environment before proceeding to the next, so it might take at least a week before moving from one environment the next. As a result, a lot of questions/concerns will surface: 8 | - How to know which scripts have already been applied to which environments? 9 | - How to make sure scripts are applied in the same order for each environment in the deployment pipeline? 10 | - How to guard against human errors like applying a script in an environment more than once? Errors like this can lead to costly outcomes like an accidentally dropped table. 11 | 12 | **Sable** solves all of these problem by taking a simple, intuitive, and minimally-invasive approach. Curious to know how it works? See [How Sable Works](/reference/how-sable-works) to learn more. -------------------------------------------------------------------------------- /_docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "vitepress": "^1.0.0-rc.21" 4 | }, 5 | "scripts": { 6 | "docs:dev": "vitepress dev", 7 | "docs:build": "vitepress build --outDir ../docs", 8 | "docs:preview": "vitepress preview" 9 | } 10 | } -------------------------------------------------------------------------------- /_docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /_docs/reference/how-sable-works.md: -------------------------------------------------------------------------------- 1 | # How Sable Works 2 | 3 | ## Overview 4 | 5 | To set the stage for talking about how **Sable** works, let's first talk about how things works without **Sable**. 6 | Without **Sable**, you'd just use the [Marten Command Line Tooling](https://martendb.io/configuration/cli.html#command-line-tooling) support to run commands like `marten-patch` and `marten-apply` on your project. 7 | **Sable** does not add any additional functionality to that toolset. In fact, it is built on top of it, and will literally just run those same commands that you'd run manually. 8 | 9 | When manually using the command line tooling support, you need to connect to an actual database, which introduces the problems outlined in [Why Sable](../introduction/why-sable). 10 | **Sable** solves those problems by using a temporary shadow database instead of an actual one. For instance, when running the **Sable** command to create a new migration, **Sable** will: 11 | - Dynamically create a Postgres docker container to be used as the shadow database. 12 | - Create a script from the existing migrations. 13 | - Apply the script to build the shadow database. 14 | - Run the `marten-patch` command on your project. That command is executed in a context where an environment variable is set by Sable. That environment variable is 15 | then used to override the connection string for Marten in the project so that it points to the Docker container instead of an actual database. The script generated by Marten 16 | is then saved in a sable `migrations` directory. If no changes were detected, the file fill be empty. 17 | - Delete the Docker container. 18 | 19 | ## Custom Shadow Database 20 | 21 | By default, **Sable** uses a Docker container built from a version of the official [Postgres](https://hub.docker.com/_/postgres) image from the DockerHub registry. 22 | However, in a corporate environment, maybe you have to use an image from an internal private registry. Or maybe you just want to use a different image from DockerHub. 23 | That's possible. Any **Sable** command that needs to use a shadow database has a `-c|--container-options` option. That option can be used to point to a JSON file that contains 24 | the configuration for how build a custom container for the shadow database. An example looks like this: 25 | ```json 26 | { 27 | "Image": "postgres:15.1", 28 | "PortBindings": [ 29 | { 30 | "HostPort": 5470, 31 | "ContainerPort": 5432 32 | } 33 | ], 34 | "EnvironmentVariables": { 35 | "PGPORT": "5432", 36 | "POSTGRES_DB": "postgres", 37 | "POSTGRES_USER": "postgres", 38 | "POSTGRES_PASSWORD": "postgres" 39 | 40 | }, 41 | "ConnectionString": "Host=localhost;Port=5470;Username=postgres;Password=postgres;Database=postgres" 42 | } 43 | ``` 44 | 45 | `ConnectionString` is the connection string that should be used to connect to the container once it is running. 46 | 47 | ## Migration Tracking and Idempotency 48 | 49 | To ensure applying a migration is executed as an idempotent operation, Sable maintains a table in the database called `.__sable_migrations` to keep track of already applied migrations. A migration script generated by **Sable** will look something like this: 50 | ```sql 51 | ---Generated by Sable on 10/14/2023 11:32:48 PM 52 | 53 | 54 | BEGIN; 55 | 56 | DO $$ 57 | BEGIN 58 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231013224735_AddIndexOnCustomerId') THEN 59 | 60 | RAISE NOTICE 'Running migration with Id = 20231013224735_AddIndexOnCustomerId'; 61 | 62 | CREATE INDEX mt_doc_order_idx_customer_id ON orders.mt_doc_order USING btree ((CAST(data ->> 'CustomerId' as uuid))); 63 | 64 | 65 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 66 | VALUES ('20231013224735_AddIndexOnCustomerId', '0'); 67 | END IF; 68 | END $$; 69 | 70 | COMMIT; 71 | 72 | 73 | BEGIN; 74 | 75 | DO $$ 76 | BEGIN 77 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231014233240_AddIndexOnDatePurchased') THEN 78 | 79 | RAISE NOTICE 'Running migration with Id = 20231014233240_AddIndexOnDatePurchased'; 80 | 81 | CREATE INDEX mt_doc_order_idx_date_purchased_utc ON orders.mt_doc_order USING btree ((orders.mt_immutable_timestamp(data ->> 'DatePurchasedUtc'))); 82 | 83 | 84 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 85 | VALUES ('20231014233240_AddIndexOnDatePurchased', '0'); 86 | END IF; 87 | END $$; 88 | 89 | COMMIT; 90 | ``` 91 | 92 | If a record already exists in the table for a migration, It won't be applied. Otherwise, applying the migration as well as recording that it has been applied in the migration table will execute in the same database transaction. 93 | 94 | -------------------------------------------------------------------------------- /build.cake: -------------------------------------------------------------------------------- 1 | var target = Argument("Target", "Default"); 2 | var configuration = 3 | HasArgument("Configuration") ? Argument("Configuration") : 4 | EnvironmentVariable("Configuration", "Release"); 5 | 6 | var artifactsDirectory = Directory("./Artifacts"); 7 | 8 | Task("Clean") 9 | .Description("Cleans the artifacts, bin and obj directories.") 10 | .Does(() => 11 | { 12 | CleanDirectory(artifactsDirectory); 13 | DeleteDirectories(GetDirectories("**/bin"), new DeleteDirectorySettings() { Force = true, Recursive = true }); 14 | DeleteDirectories(GetDirectories("**/obj"), new DeleteDirectorySettings() { Force = true, Recursive = true }); 15 | }); 16 | 17 | Task("Restore") 18 | .Description("Restores NuGet packages.") 19 | .IsDependentOn("Clean") 20 | .Does(() => 21 | { 22 | DotNetRestore(); 23 | }); 24 | 25 | Task("Build") 26 | .Description("Builds the solution.") 27 | .IsDependentOn("Restore") 28 | .Does(() => 29 | { 30 | DotNetBuild( 31 | ".", 32 | new DotNetBuildSettings() 33 | { 34 | Configuration = configuration, 35 | NoRestore = true, 36 | }); 37 | }); 38 | 39 | Task("Test") 40 | .Description("Runs unit tests and outputs test results to the artifacts directory.") 41 | .DoesForEach( 42 | GetFiles("./tests/**/*.csproj"), 43 | project => 44 | { 45 | DotNetTest( 46 | project.ToString(), 47 | new DotNetTestSettings() 48 | { 49 | Blame = true, 50 | Collectors = new string[] { "Code Coverage", "XPlat Code Coverage" }, 51 | Configuration = configuration, 52 | Loggers = new string[] 53 | { 54 | $"trx;LogFileName={project.GetFilenameWithoutExtension()}.trx", 55 | $"html;LogFileName={project.GetFilenameWithoutExtension()}.html", 56 | }, 57 | NoBuild = true, 58 | NoRestore = true, 59 | ResultsDirectory = artifactsDirectory, 60 | }); 61 | }); 62 | 63 | Task("Pack") 64 | .Description("Creates NuGet packages and outputs them to the artifacts directory.") 65 | .Does(() => 66 | { 67 | DotNetPack( 68 | ".", 69 | new DotNetPackSettings() 70 | { 71 | Configuration = configuration, 72 | IncludeSymbols = true, 73 | MSBuildSettings = new DotNetMSBuildSettings() 74 | { 75 | ContinuousIntegrationBuild = !BuildSystem.IsLocalBuild, 76 | }, 77 | NoBuild = true, 78 | NoRestore = true, 79 | OutputDirectory = artifactsDirectory, 80 | }); 81 | }); 82 | 83 | Task("Default") 84 | .Description("Cleans, restores NuGet packages, builds the solution, runs unit tests and then creates NuGet packages.") 85 | .IsDependentOn("Build") 86 | .IsDependentOn("Test") 87 | .IsDependentOn("Pack"); 88 | 89 | RunTarget(target); 90 | -------------------------------------------------------------------------------- /docs/assets/README.md.85f54f8a.js: -------------------------------------------------------------------------------- 1 | import{_ as t,o as a,c as o,k as e,a as n}from"./chunks/framework.b8722102.js";const f=JSON.parse('{"title":"Documentation","description":"","frontmatter":{},"headers":[],"relativePath":"README.md","filePath":"README.md"}'),c={name:"README.md"},r=e("h1",{id:"documentation",tabindex:"-1"},[n("Documentation "),e("a",{class:"header-anchor",href:"#documentation","aria-label":'Permalink to "Documentation"'},"​")],-1),s=[r];function i(d,m,_,l,p,h){return a(),o("div",null,s)}const E=t(c,[["render",i]]);export{f as __pageData,E as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/README.md.85f54f8a.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as t,o as a,c as o,k as e,a as n}from"./chunks/framework.b8722102.js";const f=JSON.parse('{"title":"Documentation","description":"","frontmatter":{},"headers":[],"relativePath":"README.md","filePath":"README.md"}'),c={name:"README.md"},r=e("h1",{id:"documentation",tabindex:"-1"},[n("Documentation "),e("a",{class:"header-anchor",href:"#documentation","aria-label":'Permalink to "Documentation"'},"​")],-1),s=[r];function i(d,m,_,l,p,h){return a(),o("div",null,s)}const E=t(c,[["render",i]]);export{f as __pageData,E as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/app.7749c2c3.js: -------------------------------------------------------------------------------- 1 | import{s,a1 as i,a2 as u,a3 as c,a4 as l,a5 as d,a6 as f,a7 as m,a8 as h,a9 as A,aa as g,V as P,d as v,u as y,j as C,y as w,ab as _,ac as b,ad as E,ae as R}from"./chunks/framework.b8722102.js";import{t as D}from"./chunks/theme.6a9fdd37.js";function p(e){if(e.extends){const a=p(e.extends);return{...a,...e,async enhanceApp(t){a.enhanceApp&&await a.enhanceApp(t),e.enhanceApp&&await e.enhanceApp(t)}}}return e}const o=p(D),j=v({name:"VitePressApp",setup(){const{site:e}=y();return C(()=>{w(()=>{document.documentElement.lang=e.value.lang,document.documentElement.dir=e.value.dir})}),_(),b(),E(),o.setup&&o.setup(),()=>R(o.Layout)}});async function O(){const e=T(),a=S();a.provide(u,e);const t=c(e.route);return a.provide(l,t),a.component("Content",d),a.component("ClientOnly",f),Object.defineProperties(a.config.globalProperties,{$frontmatter:{get(){return t.frontmatter.value}},$params:{get(){return t.page.value.params}}}),o.enhanceApp&&await o.enhanceApp({app:a,router:e,siteData:m}),{app:a,router:e,data:t}}function S(){return h(j)}function T(){let e=s,a;return A(t=>{let n=g(t),r=null;return n&&(e&&(a=n),(e||a===n)&&(n=n.replace(/\.js$/,".lean.js")),r=P(()=>import(n),[])),s&&(e=!1),r},o.NotFound)}s&&O().then(({app:e,router:a,data:t})=>{a.go().then(()=>{i(a.route,t.site),e.mount("#app")})});export{O as createApp}; 2 | -------------------------------------------------------------------------------- /docs/assets/guide_existing-project-integration.md.3810c307.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as s,o as a,c as n,Q as o}from"./chunks/framework.b8722102.js";const u=JSON.parse('{"title":"Existing Project Integration","description":"","frontmatter":{},"headers":[],"relativePath":"guide/existing-project-integration.md","filePath":"guide/existing-project-integration.md"}'),l={name:"guide/existing-project-integration.md"},e=o("",18),p=[e];function t(r,c,i,y,E,d){return a(),n("div",null,p)}const h=s(l,[["render",t]]);export{u as __pageData,h as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/guide_multi-tenancy-setup.md.ce8c0d82.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as s,o as n,c as a,Q as o}from"./chunks/framework.b8722102.js";const F=JSON.parse('{"title":"Multi-tenancy Setup","description":"","frontmatter":{},"headers":[],"relativePath":"guide/multi-tenancy-setup.md","filePath":"guide/multi-tenancy-setup.md"}'),l={name:"guide/multi-tenancy-setup.md"},p=o("",6),e=[p];function t(r,c,E,y,i,u){return n(),a("div",null,e)}const g=s(l,[["render",t]]);export{F as __pageData,g as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/guide_multiple-database-setup.md.e2e4ff14.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as s,o as a,c as n,Q as o}from"./chunks/framework.b8722102.js";const d=JSON.parse('{"title":"Multiple Database Setup","description":"","frontmatter":{},"headers":[],"relativePath":"guide/multiple-database-setup.md","filePath":"guide/multiple-database-setup.md"}'),l={name:"guide/multiple-database-setup.md"},p=o("",5),e=[p];function t(r,c,E,y,i,u){return a(),n("div",null,e)}const b=s(l,[["render",t]]);export{d as __pageData,b as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/index.md.3716e34c.js: -------------------------------------------------------------------------------- 1 | import{_ as t,o as e,c as a}from"./chunks/framework.b8722102.js";const p=JSON.parse('{"title":"Sable","titleTemplate":"Database Migrations for Marten","description":"","frontmatter":{"layout":"home","title":"Sable","titleTemplate":"Database Migrations for Marten","hero":{"name":"Sable","text":"Database Migrations for Marten","tagline":"Simple, easy to use database migration management tool for Marten.","image":{"src":"/logo.svg","alt":"Sable logo"},"actions":[{"theme":"brand","text":"Get Started","link":"/introduction/getting-started"},{"theme":"alt","text":"Why Sable?","link":"/introduction/why-sable"},{"theme":"alt","text":"View on GitHub","link":"https://github.com/bloomberg/sable"}]},"features":[{"icon":"💡","title":"Intuitive","details":"Sable comes with an intuitive, easy to use CLI that is heavily inspired by other popular migration tools like dotnet-ef."},{"icon":"🚀","title":"Seamless Integration","details":"Sable can be seamlessly integrated into new, or existing projects. Integration is also minimally invasive. Opt-in or opt-out anytime with absolutely no headaches."},{"icon":"💪🏾","title":"Comprehensive Support","details":"Well-thought out to support any Marten configuration, including those that have multiple database references or multi-tenancy setups."}]},"headers":[],"relativePath":"index.md","filePath":"index.md"}'),i={name:"index.md"};function n(o,s,l,r,m,c){return e(),a("div")}const u=t(i,[["render",n]]);export{p as __pageData,u as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/index.md.3716e34c.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as t,o as e,c as a}from"./chunks/framework.b8722102.js";const p=JSON.parse('{"title":"Sable","titleTemplate":"Database Migrations for Marten","description":"","frontmatter":{"layout":"home","title":"Sable","titleTemplate":"Database Migrations for Marten","hero":{"name":"Sable","text":"Database Migrations for Marten","tagline":"Simple, easy to use database migration management tool for Marten.","image":{"src":"/logo.svg","alt":"Sable logo"},"actions":[{"theme":"brand","text":"Get Started","link":"/introduction/getting-started"},{"theme":"alt","text":"Why Sable?","link":"/introduction/why-sable"},{"theme":"alt","text":"View on GitHub","link":"https://github.com/bloomberg/sable"}]},"features":[{"icon":"💡","title":"Intuitive","details":"Sable comes with an intuitive, easy to use CLI that is heavily inspired by other popular migration tools like dotnet-ef."},{"icon":"🚀","title":"Seamless Integration","details":"Sable can be seamlessly integrated into new, or existing projects. Integration is also minimally invasive. Opt-in or opt-out anytime with absolutely no headaches."},{"icon":"💪🏾","title":"Comprehensive Support","details":"Well-thought out to support any Marten configuration, including those that have multiple database references or multi-tenancy setups."}]},"headers":[],"relativePath":"index.md","filePath":"index.md"}'),i={name:"index.md"};function n(o,s,l,r,m,c){return e(),a("div")}const u=t(i,[["render",n]]);export{p as __pageData,u as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/inter-italic-cyrillic-ext.33bd5a8e.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-italic-cyrillic-ext.33bd5a8e.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-italic-cyrillic.ea42a392.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-italic-cyrillic.ea42a392.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-italic-greek-ext.4fbe9427.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-italic-greek-ext.4fbe9427.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-italic-greek.8f4463c4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-italic-greek.8f4463c4.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-italic-latin-ext.bd8920cc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-italic-latin-ext.bd8920cc.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-italic-latin.bd3b6f56.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-italic-latin.bd3b6f56.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-italic-vietnamese.6ce511fb.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-italic-vietnamese.6ce511fb.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-roman-cyrillic-ext.e75737ce.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-roman-cyrillic-ext.e75737ce.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-roman-cyrillic.5f2c6c8c.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-roman-cyrillic.5f2c6c8c.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-roman-greek-ext.ab0619bc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-roman-greek-ext.ab0619bc.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-roman-greek.d5a6d92a.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-roman-greek.d5a6d92a.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-roman-latin-ext.0030eebd.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-roman-latin-ext.0030eebd.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-roman-latin.2ed14f66.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-roman-latin.2ed14f66.woff2 -------------------------------------------------------------------------------- /docs/assets/inter-roman-vietnamese.14ce25a6.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/docs/assets/inter-roman-vietnamese.14ce25a6.woff2 -------------------------------------------------------------------------------- /docs/assets/introduction_getting-started.md.b6a14f95.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as s,o as a,c as n,Q as o}from"./chunks/framework.b8722102.js";const h=JSON.parse('{"title":"Getting Started","description":"","frontmatter":{},"headers":[],"relativePath":"introduction/getting-started.md","filePath":"introduction/getting-started.md"}'),l={name:"introduction/getting-started.md"},e=o("",37),p=[e];function t(r,c,i,y,E,d){return a(),n("div",null,p)}const g=s(l,[["render",t]]);export{h as __pageData,g as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/introduction_why-sable.md.21ebe741.js: -------------------------------------------------------------------------------- 1 | import{_ as e,o as t,c as a,Q as o}from"./chunks/framework.b8722102.js";const b=JSON.parse('{"title":"Why Sable?","description":"","frontmatter":{},"headers":[],"relativePath":"introduction/why-sable.md","filePath":"introduction/why-sable.md"}'),n={name:"introduction/why-sable.md"},i=o('

Why Sable?

The Marten team has done a phenomenal job with providing the foundational infrastructure required for managing database migrations. The command line tooling for that is made available via the Marten.CommandLine package, and works just fine. With a connection string that is sufficiently privileged to execute migration scripts, the marten-patch and marten-apply commands can easily be used to carry out the process. However, in a corporate environment like Bloomberg, this approach is not feasible. But why not? Well, for local development, it's not an issue, but for other environments like dev, alpha, beta, and prod, we've encountered some limitations because of the following reasons:

  • There's a standard process for executing database migrations scripts. An engineer can't just point to a database to run migrations, but needs to submit a ticket that must be approved by a manager/team lead before the script can be executed.
  • An application will often be deployed to multiple environments in a sequential deployment pipeline (e.g., dev -> alpha -> beta -> prod). Furthermore, these deployments won't happen in a compressed time frame. You want to test things in one environment before proceeding to the next, so it might take at least a week before moving from one environment the next. As a result, a lot of questions/concerns will surface:
    • How to know which scripts have already been applied to which environments?
    • How to make sure scripts are applied in the same order for each environment in the deployment pipeline?
    • How to guard against human errors like applying a script in an environment more than once? Errors like this can lead to costly outcomes like an accidentally dropped table.

Sable solves all of these problem by taking a simple, intuitive, and minimally-invasive approach. Curious to know how it works? See How Sable Works to learn more.

',4),r=[i];function s(l,c,d,p,h,m){return t(),a("div",null,r)}const g=e(n,[["render",s]]);export{b as __pageData,g as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/introduction_why-sable.md.21ebe741.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as e,o as t,c as a,Q as o}from"./chunks/framework.b8722102.js";const b=JSON.parse('{"title":"Why Sable?","description":"","frontmatter":{},"headers":[],"relativePath":"introduction/why-sable.md","filePath":"introduction/why-sable.md"}'),n={name:"introduction/why-sable.md"},i=o("",4),r=[i];function s(l,c,d,p,h,m){return t(),a("div",null,r)}const g=e(n,[["render",s]]);export{b as __pageData,g as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/reference_cli.md.93f35ef2.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as t,o as a,c as e,Q as s}from"./chunks/framework.b8722102.js";const g=JSON.parse('{"title":"Command Line Interface","description":"","frontmatter":{},"headers":[],"relativePath":"reference/cli.md","filePath":"reference/cli.md"}'),o={name:"reference/cli.md"},n=s("",31),i=[n];function r(l,d,c,p,h,b){return a(),e("div",null,i)}const m=t(o,[["render",r]]);export{g as __pageData,m as default}; 2 | -------------------------------------------------------------------------------- /docs/assets/reference_how-sable-works.md.46d9baa9.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as s,o as n,c as a,Q as o}from"./chunks/framework.b8722102.js";const F=JSON.parse('{"title":"How Sable Works","description":"","frontmatter":{},"headers":[],"relativePath":"reference/how-sable-works.md","filePath":"reference/how-sable-works.md"}'),l={name:"reference/how-sable-works.md"},p=o("",13),e=[p];function t(r,c,E,y,i,d){return n(),a("div",null,e)}const h=s(l,[["render",t]]);export{F as __pageData,h as default}; 2 | -------------------------------------------------------------------------------- /docs/hashmap.json: -------------------------------------------------------------------------------- 1 | {"readme.md":"85f54f8a","guide_multi-tenancy-setup.md":"ce8c0d82","guide_multiple-database-setup.md":"e2e4ff14","reference_how-sable-works.md":"46d9baa9","introduction_why-sable.md":"21ebe741","reference_cli.md":"93f35ef2","introduction_getting-started.md":"b6a14f95","index.md":"3716e34c","guide_existing-project-integration.md":"3810c307"} 2 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "rollForward": "latestMajor", 4 | "version": "6.0.300" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /images/Banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/images/Banner.png -------------------------------------------------------------------------------- /images/Hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/images/Hero.png -------------------------------------------------------------------------------- /images/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloomberg/sable/d20bb9a803e19fc64cd0640a0394ebe22a05c1c9/images/Icon.png -------------------------------------------------------------------------------- /samples/README.md: -------------------------------------------------------------------------------- 1 | # Samples 2 | Sample **Sable** configurations. -------------------------------------------------------------------------------- /samples/Sable.Samples.Core/Book.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable.Samples.Core; 5 | 6 | public class Book 7 | { 8 | public Guid Id { get; set; } 9 | public string Name { get; set; } 10 | public string Contents { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /samples/Sable.Samples.Core/IOtherDocumentStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Marten; 5 | 6 | namespace Sable.Samples.Core; 7 | 8 | public interface IOtherDocumentStore : IDocumentStore 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /samples/Sable.Samples.Core/Order.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable.Samples.Core; 5 | 6 | public class Order 7 | { 8 | public Guid Id { get; set; } 9 | public Guid CustomerId { get; set; } 10 | public DateTime DatePurchasedUtc { get; set; } 11 | } 12 | -------------------------------------------------------------------------------- /samples/Sable.Samples.Core/Sable.Samples.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Sable.Extensions; 5 | using Sable.Samples.Core; 6 | using Marten; 7 | using Oakton; 8 | using Weasel.Core; 9 | 10 | var builder = WebApplication.CreateBuilder(args); 11 | builder.Host.ApplyOaktonExtensions(); 12 | builder.Services.AddMartenWithSableSupport(_ => 13 | { 14 | var options = new StoreOptions(); 15 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 16 | options.DatabaseSchemaName = "books"; 17 | options.AutoCreateSchemaObjects = AutoCreate.None; 18 | options.Schema.For() 19 | .Index(x => x.Name, i => 20 | { 21 | i.IsConcurrent = true; 22 | }) 23 | .Index(x => x.Contents); 24 | return options; 25 | }); 26 | 27 | var app = builder.Build(); 28 | app.MapGet("/", () => "💪🏾"); 29 | 30 | return await app.RunOaktonCommands(args); 31 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:5163", 7 | "sslPort": 44360 8 | } 9 | }, 10 | "profiles": { 11 | "Samples.GettingStarted": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7291;http://localhost:5055", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/Sable.Samples.GettingStarted.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "Databases": { 10 | "Books": { 11 | "BasicTier": "Host=localhost;Port=5430;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 12 | "GoldTier": "Host=localhost;Port=5431;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 13 | "SilverTier": "Host=localhost;Port=5432;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20" 14 | }, 15 | "Orders": { 16 | "BasicTier": "Host=localhost;Port=5450;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 17 | "GoldTier": "Host=localhost;Port=5451;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 18 | "SilverTier": "Host=localhost;Port=5452;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/sable/Marten/20240130030104_backfill.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Generated by Sable on 1/30/2024 3:01:03 AM 3 | 4 | BEGIN; 5 | CREATE TABLE IF NOT EXISTS books.__sable_migrations ( 6 | migration_id character varying(150) NOT NULL, 7 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 8 | backfilled boolean NOT NULL DEFAULT false, 9 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 10 | ); 11 | 12 | 13 | 14 | DO $$ 15 | BEGIN 16 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20240130030018_InfrastructureSetup') THEN 17 | 18 | RAISE NOTICE 'Inserting record for migration with Id = 20240130030018_InfrastructureSetup'; 19 | 20 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 21 | VALUES ('20240130030018_InfrastructureSetup', '1'); 22 | END IF; 23 | END $$; 24 | 25 | 26 | 27 | 28 | DO $$ 29 | BEGIN 30 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20240130030021_Initial') THEN 31 | 32 | RAISE NOTICE 'Inserting record for migration with Id = 20240130030021_Initial'; 33 | 34 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 35 | VALUES ('20240130030021_Initial', '1'); 36 | END IF; 37 | END $$; 38 | 39 | 40 | COMMIT; 41 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/sable/Marten/migrations/20240130030018_InfrastructureSetup.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Generated by Sable on 1/30/2024 3:00:18 AM 3 | -- Sable NoIdempotenceWrapper 4 | 5 | CREATE SCHEMA IF NOT EXISTS books; 6 | 7 | CREATE TABLE IF NOT EXISTS books.__sable_migrations ( 8 | migration_id character varying(150) NOT NULL, 9 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 10 | backfilled boolean NOT NULL DEFAULT false, 11 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 12 | ); 13 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/sable/Marten/migrations/20240130030021_Initial.sql: -------------------------------------------------------------------------------- 1 | -- Generated by Sable on 1/30/2024 3:00:21 AM 2 | 3 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamp(value text) RETURNS timestamp without time zone LANGUAGE sql IMMUTABLE AS 4 | $function$ 5 | select value::timestamp 6 | 7 | $function$; 8 | 9 | 10 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamptz(value text) RETURNS timestamp with time zone LANGUAGE sql IMMUTABLE AS 11 | $function$ 12 | select value::timestamptz 13 | 14 | $function$; 15 | 16 | 17 | CREATE OR REPLACE FUNCTION books.mt_grams_vector(text) 18 | RETURNS tsvector 19 | LANGUAGE plpgsql 20 | IMMUTABLE STRICT 21 | AS $function$ 22 | BEGIN 23 | RETURN (SELECT array_to_string(mt_grams_array($1), ' ')::tsvector); 24 | END 25 | $function$; 26 | 27 | 28 | CREATE OR REPLACE FUNCTION books.mt_grams_query(text) 29 | RETURNS tsquery 30 | LANGUAGE plpgsql 31 | IMMUTABLE STRICT 32 | AS $function$ 33 | BEGIN 34 | RETURN (SELECT array_to_string(mt_grams_array($1), ' & ')::tsquery); 35 | END 36 | $function$; 37 | 38 | 39 | CREATE OR REPLACE FUNCTION books.mt_grams_array(words text) 40 | RETURNS text[] 41 | LANGUAGE plpgsql 42 | IMMUTABLE STRICT 43 | AS $function$ 44 | DECLARE result text[]; 45 | DECLARE word text; 46 | DECLARE clean_word text; 47 | BEGIN 48 | FOREACH word IN ARRAY string_to_array(words, ' ') 49 | LOOP 50 | clean_word = regexp_replace(word, '[^a-zA-Z0-9]+', '','g'); 51 | FOR i IN 1 .. length(clean_word) 52 | LOOP 53 | result := result || quote_literal(substr(lower(clean_word), i, 1)); 54 | result := result || quote_literal(substr(lower(clean_word), i, 2)); 55 | result := result || quote_literal(substr(lower(clean_word), i, 3)); 56 | END LOOP; 57 | END LOOP; 58 | 59 | RETURN ARRAY(SELECT DISTINCT e FROM unnest(result) AS a(e) ORDER BY e); 60 | END; 61 | $function$; 62 | 63 | 64 | CREATE TABLE IF NOT EXISTS books.mt_doc_book ( 65 | id uuid NOT NULL, 66 | data jsonb NOT NULL, 67 | mt_last_modified timestamp with time zone NULL DEFAULT (transaction_timestamp()), 68 | mt_version uuid NOT NULL DEFAULT (md5(random()::text || clock_timestamp()::text)::uuid), 69 | mt_dotnet_type varchar NULL, 70 | CONSTRAINT pkey_mt_doc_book_id PRIMARY KEY (id) 71 | ); 72 | 73 | CREATE OR REPLACE FUNCTION books.mt_upsert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 74 | DECLARE 75 | final_version uuid; 76 | BEGIN 77 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()) 78 | ON CONFLICT ON CONSTRAINT pkey_mt_doc_book_id 79 | DO UPDATE SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp(); 80 | 81 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 82 | RETURN final_version; 83 | END; 84 | $function$; 85 | 86 | 87 | CREATE OR REPLACE FUNCTION books.mt_insert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 88 | BEGIN 89 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()); 90 | 91 | RETURN docVersion; 92 | END; 93 | $function$; 94 | 95 | 96 | CREATE OR REPLACE FUNCTION books.mt_update_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 97 | DECLARE 98 | final_version uuid; 99 | BEGIN 100 | UPDATE books.mt_doc_book SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp() where id = docId; 101 | 102 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 103 | RETURN final_version; 104 | END; 105 | $function$; 106 | 107 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/sable/Marten/migrations/20240130030245_M1.sql: -------------------------------------------------------------------------------- 1 | -- Generated by Sable on 1/30/2024 3:02:45 AM 2 | 3 | 4 | -- MANUALLY EDITED TO BE MADE INDEMPOTENT SINCE THE 'NoIdempotenceWrapper' DIRECTIVE IS SET. THIS IS AN ADVANCED USE CASE 5 | -- ORIGINALLY GENERATED SCRIPT: CREATE INDEX CONCURRENTLY mt_doc_book_idx_name ON books.mt_doc_book USING btree ((data ->> 'Name')); 6 | 7 | -- Sable NoIdempotenceWrapper 8 | -- Sable NoTransactionWrapper 9 | 10 | CREATE INDEX CONCURRENTLY IF NOT EXISTS mt_doc_book_idx_name ON books.mt_doc_book USING btree ((data ->> 'Name')); 11 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/sable/Marten/migrations/20240130031301_M2.sql: -------------------------------------------------------------------------------- 1 | -- Generated by Sable on 1/30/2024 3:13:01 AM 2 | 3 | CREATE INDEX mt_doc_book_idx_contents ON books.mt_doc_book USING btree ((data ->> 'Contents')); 4 | -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/sable/Marten/schema.txt: -------------------------------------------------------------------------------- 1 | books -------------------------------------------------------------------------------- /samples/Sable.Samples.GettingStarted/sable/Marten/scripts/20240130031331_script.sql: -------------------------------------------------------------------------------- 1 | -- Generated by Sable on 1/30/2024 3:13:31 AM 2 | 3 | 4 | BEGIN; 5 | 6 | 7 | -- Generated by Sable on 1/30/2024 3:00:18 AM 8 | -- Sable NoIdempotenceWrapper 9 | 10 | CREATE SCHEMA IF NOT EXISTS books; 11 | 12 | CREATE TABLE IF NOT EXISTS books.__sable_migrations ( 13 | migration_id character varying(150) NOT NULL, 14 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 15 | backfilled boolean NOT NULL DEFAULT false, 16 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 17 | ); 18 | 19 | 20 | DO $$ 21 | BEGIN 22 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20240130030018_InfrastructureSetup') THEN 23 | 24 | RAISE NOTICE 'Inserting record for migration with Id = 20240130030018_InfrastructureSetup'; 25 | 26 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 27 | VALUES ('20240130030018_InfrastructureSetup', '0'); 28 | END IF; 29 | END $$; 30 | 31 | COMMIT; 32 | 33 | 34 | 35 | BEGIN; 36 | 37 | DO $$ 38 | BEGIN 39 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20240130030021_Initial') THEN 40 | 41 | RAISE NOTICE 'Running migration with Id = 20240130030021_Initial'; 42 | 43 | -- Generated by Sable on 1/30/2024 3:00:21 AM 44 | 45 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamp(value text) RETURNS timestamp without time zone LANGUAGE sql IMMUTABLE AS 46 | $function$ 47 | select value::timestamp 48 | 49 | $function$; 50 | 51 | 52 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamptz(value text) RETURNS timestamp with time zone LANGUAGE sql IMMUTABLE AS 53 | $function$ 54 | select value::timestamptz 55 | 56 | $function$; 57 | 58 | 59 | CREATE OR REPLACE FUNCTION books.mt_grams_vector(text) 60 | RETURNS tsvector 61 | LANGUAGE plpgsql 62 | IMMUTABLE STRICT 63 | AS $function$ 64 | BEGIN 65 | RETURN (SELECT array_to_string(mt_grams_array($1), ' ')::tsvector); 66 | END 67 | $function$; 68 | 69 | 70 | CREATE OR REPLACE FUNCTION books.mt_grams_query(text) 71 | RETURNS tsquery 72 | LANGUAGE plpgsql 73 | IMMUTABLE STRICT 74 | AS $function$ 75 | BEGIN 76 | RETURN (SELECT array_to_string(mt_grams_array($1), ' & ')::tsquery); 77 | END 78 | $function$; 79 | 80 | 81 | CREATE OR REPLACE FUNCTION books.mt_grams_array(words text) 82 | RETURNS text[] 83 | LANGUAGE plpgsql 84 | IMMUTABLE STRICT 85 | AS $function$ 86 | DECLARE result text[]; 87 | DECLARE word text; 88 | DECLARE clean_word text; 89 | BEGIN 90 | FOREACH word IN ARRAY string_to_array(words, ' ') 91 | LOOP 92 | clean_word = regexp_replace(word, '[^a-zA-Z0-9]+', '','g'); 93 | FOR i IN 1 .. length(clean_word) 94 | LOOP 95 | result := result || quote_literal(substr(lower(clean_word), i, 1)); 96 | result := result || quote_literal(substr(lower(clean_word), i, 2)); 97 | result := result || quote_literal(substr(lower(clean_word), i, 3)); 98 | END LOOP; 99 | END LOOP; 100 | 101 | RETURN ARRAY(SELECT DISTINCT e FROM unnest(result) AS a(e) ORDER BY e); 102 | END; 103 | $function$; 104 | 105 | 106 | CREATE TABLE IF NOT EXISTS books.mt_doc_book ( 107 | id uuid NOT NULL, 108 | data jsonb NOT NULL, 109 | mt_last_modified timestamp with time zone NULL DEFAULT (transaction_timestamp()), 110 | mt_version uuid NOT NULL DEFAULT (md5(random()::text || clock_timestamp()::text)::uuid), 111 | mt_dotnet_type varchar NULL, 112 | CONSTRAINT pkey_mt_doc_book_id PRIMARY KEY (id) 113 | ); 114 | 115 | CREATE OR REPLACE FUNCTION books.mt_upsert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 116 | DECLARE 117 | final_version uuid; 118 | BEGIN 119 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()) 120 | ON CONFLICT ON CONSTRAINT pkey_mt_doc_book_id 121 | DO UPDATE SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp(); 122 | 123 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 124 | RETURN final_version; 125 | END; 126 | $function$; 127 | 128 | 129 | CREATE OR REPLACE FUNCTION books.mt_insert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 130 | BEGIN 131 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()); 132 | 133 | RETURN docVersion; 134 | END; 135 | $function$; 136 | 137 | 138 | CREATE OR REPLACE FUNCTION books.mt_update_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 139 | DECLARE 140 | final_version uuid; 141 | BEGIN 142 | UPDATE books.mt_doc_book SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp() where id = docId; 143 | 144 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 145 | RETURN final_version; 146 | END; 147 | $function$; 148 | 149 | 150 | 151 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 152 | VALUES ('20240130030021_Initial', '0'); 153 | END IF; 154 | END $$; 155 | 156 | COMMIT; 157 | 158 | 159 | 160 | -- Generated by Sable on 1/30/2024 3:02:45 AM 161 | 162 | 163 | -- MANUALLY EDITED TO BE MADE INDEMPOTENT SINCE THE 'NoIdempotenceWrapper' DIRECTIVE IS SET. THIS IS AN ADVANCED USE CASE 164 | -- ORIGINALLY GENERATED SCRIPT: CREATE INDEX CONCURRENTLY mt_doc_book_idx_name ON books.mt_doc_book USING btree ((data ->> 'Name')); 165 | 166 | -- Sable NoIdempotenceWrapper 167 | -- Sable NoTransactionWrapper 168 | 169 | CREATE INDEX CONCURRENTLY IF NOT EXISTS mt_doc_book_idx_name ON books.mt_doc_book USING btree ((data ->> 'Name')); 170 | 171 | 172 | DO $$ 173 | BEGIN 174 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20240130030245_M1') THEN 175 | 176 | RAISE NOTICE 'Inserting record for migration with Id = 20240130030245_M1'; 177 | 178 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 179 | VALUES ('20240130030245_M1', '0'); 180 | END IF; 181 | END $$; 182 | 183 | 184 | 185 | BEGIN; 186 | 187 | DO $$ 188 | BEGIN 189 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20240130031301_M2') THEN 190 | 191 | RAISE NOTICE 'Running migration with Id = 20240130031301_M2'; 192 | 193 | -- Generated by Sable on 1/30/2024 3:13:01 AM 194 | 195 | CREATE INDEX mt_doc_book_idx_contents ON books.mt_doc_book USING btree ((data ->> 'Contents')); 196 | 197 | 198 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 199 | VALUES ('20240130031301_M2', '0'); 200 | END IF; 201 | END $$; 202 | 203 | COMMIT; 204 | 205 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Sable.Extensions; 5 | using Sable.Samples.Core; 6 | using Marten; 7 | using Oakton; 8 | using Weasel.Core; 9 | 10 | var builder = WebApplication.CreateBuilder(args); 11 | builder.Host.ApplyOaktonExtensions(); 12 | 13 | builder.Services.AddMartenWithSableSupport(_ => 14 | { 15 | var options = new StoreOptions 16 | { 17 | DatabaseSchemaName = "books" 18 | }; 19 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 20 | options.MultiTenantedDatabases(x => 21 | { 22 | x.AddMultipleTenantDatabase(builder.Configuration["Databases:Books:GoldTier"], "books_gold") 23 | .ForTenants("gold1", "gold2"); 24 | x.AddSingleTenantDatabase(builder.Configuration["Databases:Books:SilverTier"], "books_silver"); 25 | }); 26 | options.AutoCreateSchemaObjects = AutoCreate.None; 27 | options.Schema.For(); 28 | return options; 29 | }); 30 | 31 | var app = builder.Build(); 32 | app.MapGet("/", () => "💪🏾"); 33 | 34 | return await app.RunOaktonCommands(args); 35 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:20304", 7 | "sslPort": 44376 8 | } 9 | }, 10 | "profiles": { 11 | "Samples.SingleDatabase": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7244;http://localhost:5288", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/Sable.Samples.MultiTenancy.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "Databases": { 10 | "Books": { 11 | "BasicTier": "Host=localhost;Port=5430;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 12 | "GoldTier": "Host=localhost;Port=5431;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 13 | "SilverTier": "Host=localhost;Port=5432;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20" 14 | }, 15 | "Orders": { 16 | "BasicTier": "Host=localhost;Port=5450;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 17 | "GoldTier": "Host=localhost;Port=5451;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 18 | "SilverTier": "Host=localhost;Port=5452;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/sable/Marten/migrations/20231013223111_InfrastructureSetup.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Sable NoIdempotenceWrapper 3 | 4 | CREATE SCHEMA IF NOT EXISTS books; 5 | 6 | CREATE TABLE IF NOT EXISTS books.__sable_migrations ( 7 | migration_id character varying(150) NOT NULL, 8 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 9 | backfilled boolean NOT NULL DEFAULT false, 10 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 11 | ); 12 | 13 | DO $$ 14 | BEGIN 15 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20231013223111_InfrastructureSetup') THEN 16 | 17 | RAISE NOTICE 'Inserting record for migration with Id = 20231013223111_InfrastructureSetup'; 18 | 19 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 20 | VALUES ('20231013223111_InfrastructureSetup', '0'); 21 | END IF; 22 | END $$; 23 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/sable/Marten/migrations/20231013223114_Initial.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamp(value text) RETURNS timestamp without time zone LANGUAGE sql IMMUTABLE AS 2 | $function$ 3 | select value::timestamp 4 | 5 | $function$; 6 | 7 | 8 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamptz(value text) RETURNS timestamp with time zone LANGUAGE sql IMMUTABLE AS 9 | $function$ 10 | select value::timestamptz 11 | 12 | $function$; 13 | 14 | 15 | CREATE OR REPLACE FUNCTION books.mt_grams_vector(text) 16 | RETURNS tsvector 17 | LANGUAGE plpgsql 18 | IMMUTABLE STRICT 19 | AS $function$ 20 | BEGIN 21 | RETURN (SELECT array_to_string(mt_grams_array($1), ' ')::tsvector); 22 | END 23 | $function$; 24 | 25 | 26 | CREATE OR REPLACE FUNCTION books.mt_grams_query(text) 27 | RETURNS tsquery 28 | LANGUAGE plpgsql 29 | IMMUTABLE STRICT 30 | AS $function$ 31 | BEGIN 32 | RETURN (SELECT array_to_string(mt_grams_array($1), ' & ')::tsquery); 33 | END 34 | $function$; 35 | 36 | 37 | CREATE OR REPLACE FUNCTION books.mt_grams_array(words text) 38 | RETURNS text[] 39 | LANGUAGE plpgsql 40 | IMMUTABLE STRICT 41 | AS $function$ 42 | DECLARE result text[]; 43 | DECLARE word text; 44 | DECLARE clean_word text; 45 | BEGIN 46 | FOREACH word IN ARRAY string_to_array(words, ' ') 47 | LOOP 48 | clean_word = regexp_replace(word, '[^a-zA-Z0-9]+', '','g'); 49 | FOR i IN 1 .. length(clean_word) 50 | LOOP 51 | result := result || quote_literal(substr(lower(clean_word), i, 1)); 52 | result := result || quote_literal(substr(lower(clean_word), i, 2)); 53 | result := result || quote_literal(substr(lower(clean_word), i, 3)); 54 | END LOOP; 55 | END LOOP; 56 | 57 | RETURN ARRAY(SELECT DISTINCT e FROM unnest(result) AS a(e) ORDER BY e); 58 | END; 59 | $function$; 60 | 61 | 62 | CREATE TABLE IF NOT EXISTS books.mt_doc_book ( 63 | id uuid NOT NULL, 64 | data jsonb NOT NULL, 65 | mt_last_modified timestamp with time zone NULL DEFAULT (transaction_timestamp()), 66 | mt_version uuid NOT NULL DEFAULT (md5(random()::text || clock_timestamp()::text)::uuid), 67 | mt_dotnet_type varchar NULL, 68 | CONSTRAINT pkey_mt_doc_book_id PRIMARY KEY (id) 69 | ); 70 | 71 | CREATE OR REPLACE FUNCTION books.mt_upsert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 72 | DECLARE 73 | final_version uuid; 74 | BEGIN 75 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()) 76 | ON CONFLICT ON CONSTRAINT pkey_mt_doc_book_id 77 | DO UPDATE SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp(); 78 | 79 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 80 | RETURN final_version; 81 | END; 82 | $function$; 83 | 84 | 85 | CREATE OR REPLACE FUNCTION books.mt_insert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 86 | BEGIN 87 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()); 88 | 89 | RETURN docVersion; 90 | END; 91 | $function$; 92 | 93 | 94 | CREATE OR REPLACE FUNCTION books.mt_update_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 95 | DECLARE 96 | final_version uuid; 97 | BEGIN 98 | UPDATE books.mt_doc_book SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp() where id = docId; 99 | 100 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 101 | RETURN final_version; 102 | END; 103 | $function$; 104 | 105 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultiTenancy/sable/Marten/schema.txt: -------------------------------------------------------------------------------- 1 | books -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Sable.Extensions; 5 | using Sable.Samples.Core; 6 | using Marten; 7 | using Oakton; 8 | using Weasel.Core; 9 | 10 | var builder = WebApplication.CreateBuilder(args); 11 | builder.Host.ApplyOaktonExtensions(); 12 | 13 | builder.Services.AddMartenWithSableSupport(_ => 14 | { 15 | var options = new StoreOptions 16 | { 17 | DatabaseSchemaName = "books" 18 | }; 19 | options.Connection(builder.Configuration["Databases:Books:BasicTier"]); 20 | options.MultiTenantedDatabases(x => 21 | { 22 | x.AddMultipleTenantDatabase(builder.Configuration["Databases:Books:GoldTier"], "books_gold") 23 | .ForTenants("gold1", "gold2"); 24 | x.AddSingleTenantDatabase(builder.Configuration["Databases:Books:SilverTier"], "books_silver"); 25 | }); 26 | options.AutoCreateSchemaObjects = AutoCreate.None; 27 | options.Schema.For(); 28 | return options; 29 | }); 30 | 31 | builder.Services.AddMartenStoreWithSableSupport(_ => 32 | { 33 | var options = new StoreOptions 34 | { 35 | DatabaseSchemaName = "orders" 36 | }; 37 | options.Connection(builder.Configuration["Databases:Orders:BasicTier"]); 38 | options.MultiTenantedDatabases(x => 39 | { 40 | x.AddMultipleTenantDatabase(builder.Configuration["Databases:Books:GoldTier"], "orders_gold") 41 | .ForTenants("gold1", "gold2"); 42 | x.AddSingleTenantDatabase(builder.Configuration["Databases:Books:SilverTier"], "orders_silver"); 43 | }); 44 | options.AutoCreateSchemaObjects = AutoCreate.None; 45 | options.Schema.For(); 46 | return options; 47 | }); 48 | 49 | var app = builder.Build(); 50 | app.MapGet("/", () => "💪🏾"); 51 | 52 | return await app.RunOaktonCommands(args); 53 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:45845", 7 | "sslPort": 44311 8 | } 9 | }, 10 | "profiles": { 11 | "Samples.MultipleDatabases": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "https://localhost:7176;http://localhost:5291", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "environmentVariables": { 24 | "ASPNETCORE_ENVIRONMENT": "Development" 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/Sable.Samples.MultipleDatabases.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "Databases": { 10 | "Books": { 11 | "BasicTier": "Host=localhost;Port=5430;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 12 | "GoldTier": "Host=localhost;Port=5431;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 13 | "SilverTier": "Host=localhost;Port=5432;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20" 14 | }, 15 | "Orders": { 16 | "BasicTier": "Host=localhost;Port=5450;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 17 | "GoldTier": "Host=localhost;Port=5451;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20", 18 | "SilverTier": "Host=localhost;Port=5452;Username=postgres;Password=P0stG&e$;Database=postgres;Minimum Pool Size=0;Maximum Pool Size=20" 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/IOtherDocumentStore/20231018224532_backfill.sql: -------------------------------------------------------------------------------- 1 | 2 | ---Generated by Sable on 10/18/2023 10:45:32 PM 3 | 4 | BEGIN; 5 | CREATE TABLE IF NOT EXISTS orders.__sable_migrations ( 6 | migration_id character varying(150) NOT NULL, 7 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 8 | backfilled boolean NOT NULL DEFAULT false, 9 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 10 | ); 11 | 12 | 13 | 14 | DO $$ 15 | BEGIN 16 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231018224343_InfrastructureSetup') THEN 17 | 18 | RAISE NOTICE 'Inserting record for migration with Id = 20231018224343_InfrastructureSetup'; 19 | 20 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 21 | VALUES ('20231018224343_InfrastructureSetup', '1'); 22 | END IF; 23 | END $$; 24 | 25 | 26 | 27 | 28 | DO $$ 29 | BEGIN 30 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231018224348_Initial') THEN 31 | 32 | RAISE NOTICE 'Inserting record for migration with Id = 20231018224348_Initial'; 33 | 34 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 35 | VALUES ('20231018224348_Initial', '1'); 36 | END IF; 37 | END $$; 38 | 39 | 40 | COMMIT; 41 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/IOtherDocumentStore/migrations/20231018224343_InfrastructureSetup.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Sable NoIdempotenceWrapper 3 | 4 | CREATE SCHEMA IF NOT EXISTS orders; 5 | 6 | CREATE TABLE IF NOT EXISTS orders.__sable_migrations ( 7 | migration_id character varying(150) NOT NULL, 8 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 9 | backfilled boolean NOT NULL DEFAULT false, 10 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 11 | ); 12 | 13 | DO $$ 14 | BEGIN 15 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231018224343_InfrastructureSetup') THEN 16 | 17 | RAISE NOTICE 'Inserting record for migration with Id = 20231018224343_InfrastructureSetup'; 18 | 19 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 20 | VALUES ('20231018224343_InfrastructureSetup', '0'); 21 | END IF; 22 | END $$; 23 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/IOtherDocumentStore/migrations/20231018224348_Initial.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION orders.mt_immutable_timestamp(value text) RETURNS timestamp without time zone LANGUAGE sql IMMUTABLE AS 2 | $function$ 3 | select value::timestamp 4 | 5 | $function$; 6 | 7 | 8 | CREATE OR REPLACE FUNCTION orders.mt_immutable_timestamptz(value text) RETURNS timestamp with time zone LANGUAGE sql IMMUTABLE AS 9 | $function$ 10 | select value::timestamptz 11 | 12 | $function$; 13 | 14 | 15 | CREATE OR REPLACE FUNCTION orders.mt_grams_vector(text) 16 | RETURNS tsvector 17 | LANGUAGE plpgsql 18 | IMMUTABLE STRICT 19 | AS $function$ 20 | BEGIN 21 | RETURN (SELECT array_to_string(mt_grams_array($1), ' ')::tsvector); 22 | END 23 | $function$; 24 | 25 | 26 | CREATE OR REPLACE FUNCTION orders.mt_grams_query(text) 27 | RETURNS tsquery 28 | LANGUAGE plpgsql 29 | IMMUTABLE STRICT 30 | AS $function$ 31 | BEGIN 32 | RETURN (SELECT array_to_string(mt_grams_array($1), ' & ')::tsquery); 33 | END 34 | $function$; 35 | 36 | 37 | CREATE OR REPLACE FUNCTION orders.mt_grams_array(words text) 38 | RETURNS text[] 39 | LANGUAGE plpgsql 40 | IMMUTABLE STRICT 41 | AS $function$ 42 | DECLARE result text[]; 43 | DECLARE word text; 44 | DECLARE clean_word text; 45 | BEGIN 46 | FOREACH word IN ARRAY string_to_array(words, ' ') 47 | LOOP 48 | clean_word = regexp_replace(word, '[^a-zA-Z0-9]+', '','g'); 49 | FOR i IN 1 .. length(clean_word) 50 | LOOP 51 | result := result || quote_literal(substr(lower(clean_word), i, 1)); 52 | result := result || quote_literal(substr(lower(clean_word), i, 2)); 53 | result := result || quote_literal(substr(lower(clean_word), i, 3)); 54 | END LOOP; 55 | END LOOP; 56 | 57 | RETURN ARRAY(SELECT DISTINCT e FROM unnest(result) AS a(e) ORDER BY e); 58 | END; 59 | $function$; 60 | 61 | 62 | CREATE TABLE IF NOT EXISTS orders.mt_doc_order ( 63 | id uuid NOT NULL, 64 | data jsonb NOT NULL, 65 | mt_last_modified timestamp with time zone NULL DEFAULT (transaction_timestamp()), 66 | mt_version uuid NOT NULL DEFAULT (md5(random()::text || clock_timestamp()::text)::uuid), 67 | mt_dotnet_type varchar NULL, 68 | CONSTRAINT pkey_mt_doc_order_id PRIMARY KEY (id) 69 | ); 70 | 71 | CREATE INDEX mt_doc_order_idx_customer_id ON orders.mt_doc_order USING btree ((CAST(data ->> 'CustomerId' as uuid))); 72 | 73 | CREATE OR REPLACE FUNCTION orders.mt_upsert_order(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 74 | DECLARE 75 | final_version uuid; 76 | BEGIN 77 | INSERT INTO orders.mt_doc_order ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()) 78 | ON CONFLICT ON CONSTRAINT pkey_mt_doc_order_id 79 | DO UPDATE SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp(); 80 | 81 | SELECT mt_version FROM orders.mt_doc_order into final_version WHERE id = docId ; 82 | RETURN final_version; 83 | END; 84 | $function$; 85 | 86 | 87 | CREATE OR REPLACE FUNCTION orders.mt_insert_order(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 88 | BEGIN 89 | INSERT INTO orders.mt_doc_order ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()); 90 | 91 | RETURN docVersion; 92 | END; 93 | $function$; 94 | 95 | 96 | CREATE OR REPLACE FUNCTION orders.mt_update_order(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 97 | DECLARE 98 | final_version uuid; 99 | BEGIN 100 | UPDATE orders.mt_doc_order SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp() where id = docId; 101 | 102 | SELECT mt_version FROM orders.mt_doc_order into final_version WHERE id = docId ; 103 | RETURN final_version; 104 | END; 105 | $function$; 106 | 107 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/IOtherDocumentStore/migrations/20231018224546_RemoveIndex.sql: -------------------------------------------------------------------------------- 1 | drop index if exists orders.mt_doc_order_idx_customer_id; 2 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/IOtherDocumentStore/migrations/20231018224612_Custom.sql: -------------------------------------------------------------------------------- 1 | -- Empty migration. -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/IOtherDocumentStore/schema.txt: -------------------------------------------------------------------------------- 1 | orders -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/IOtherDocumentStore/scripts/20231018224817_script.sql: -------------------------------------------------------------------------------- 1 | ---Generated by Sable on 10/18/2023 10:48:17 PM 2 | 3 | 4 | BEGIN; 5 | 6 | -- Sable NoIdempotenceWrapper 7 | 8 | CREATE SCHEMA IF NOT EXISTS orders; 9 | 10 | CREATE TABLE IF NOT EXISTS orders.__sable_migrations ( 11 | migration_id character varying(150) NOT NULL, 12 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 13 | backfilled boolean NOT NULL DEFAULT false, 14 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 15 | ); 16 | 17 | DO $$ 18 | BEGIN 19 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231018224343_InfrastructureSetup') THEN 20 | 21 | RAISE NOTICE 'Inserting record for migration with Id = 20231018224343_InfrastructureSetup'; 22 | 23 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 24 | VALUES ('20231018224343_InfrastructureSetup', '0'); 25 | END IF; 26 | END $$; 27 | 28 | COMMIT; 29 | 30 | 31 | 32 | BEGIN; 33 | 34 | DO $$ 35 | BEGIN 36 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231018224348_Initial') THEN 37 | 38 | RAISE NOTICE 'Running migration with Id = 20231018224348_Initial'; 39 | 40 | CREATE OR REPLACE FUNCTION orders.mt_immutable_timestamp(value text) RETURNS timestamp without time zone LANGUAGE sql IMMUTABLE AS 41 | $function$ 42 | select value::timestamp 43 | 44 | $function$; 45 | 46 | 47 | CREATE OR REPLACE FUNCTION orders.mt_immutable_timestamptz(value text) RETURNS timestamp with time zone LANGUAGE sql IMMUTABLE AS 48 | $function$ 49 | select value::timestamptz 50 | 51 | $function$; 52 | 53 | 54 | CREATE OR REPLACE FUNCTION orders.mt_grams_vector(text) 55 | RETURNS tsvector 56 | LANGUAGE plpgsql 57 | IMMUTABLE STRICT 58 | AS $function$ 59 | BEGIN 60 | RETURN (SELECT array_to_string(mt_grams_array($1), ' ')::tsvector); 61 | END 62 | $function$; 63 | 64 | 65 | CREATE OR REPLACE FUNCTION orders.mt_grams_query(text) 66 | RETURNS tsquery 67 | LANGUAGE plpgsql 68 | IMMUTABLE STRICT 69 | AS $function$ 70 | BEGIN 71 | RETURN (SELECT array_to_string(mt_grams_array($1), ' & ')::tsquery); 72 | END 73 | $function$; 74 | 75 | 76 | CREATE OR REPLACE FUNCTION orders.mt_grams_array(words text) 77 | RETURNS text[] 78 | LANGUAGE plpgsql 79 | IMMUTABLE STRICT 80 | AS $function$ 81 | DECLARE result text[]; 82 | DECLARE word text; 83 | DECLARE clean_word text; 84 | BEGIN 85 | FOREACH word IN ARRAY string_to_array(words, ' ') 86 | LOOP 87 | clean_word = regexp_replace(word, '[^a-zA-Z0-9]+', '','g'); 88 | FOR i IN 1 .. length(clean_word) 89 | LOOP 90 | result := result || quote_literal(substr(lower(clean_word), i, 1)); 91 | result := result || quote_literal(substr(lower(clean_word), i, 2)); 92 | result := result || quote_literal(substr(lower(clean_word), i, 3)); 93 | END LOOP; 94 | END LOOP; 95 | 96 | RETURN ARRAY(SELECT DISTINCT e FROM unnest(result) AS a(e) ORDER BY e); 97 | END; 98 | $function$; 99 | 100 | 101 | CREATE TABLE IF NOT EXISTS orders.mt_doc_order ( 102 | id uuid NOT NULL, 103 | data jsonb NOT NULL, 104 | mt_last_modified timestamp with time zone NULL DEFAULT (transaction_timestamp()), 105 | mt_version uuid NOT NULL DEFAULT (md5(random()::text || clock_timestamp()::text)::uuid), 106 | mt_dotnet_type varchar NULL, 107 | CONSTRAINT pkey_mt_doc_order_id PRIMARY KEY (id) 108 | ); 109 | 110 | CREATE INDEX mt_doc_order_idx_customer_id ON orders.mt_doc_order USING btree ((CAST(data ->> 'CustomerId' as uuid))); 111 | 112 | CREATE OR REPLACE FUNCTION orders.mt_upsert_order(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 113 | DECLARE 114 | final_version uuid; 115 | BEGIN 116 | INSERT INTO orders.mt_doc_order ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()) 117 | ON CONFLICT ON CONSTRAINT pkey_mt_doc_order_id 118 | DO UPDATE SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp(); 119 | 120 | SELECT mt_version FROM orders.mt_doc_order into final_version WHERE id = docId ; 121 | RETURN final_version; 122 | END; 123 | $function$; 124 | 125 | 126 | CREATE OR REPLACE FUNCTION orders.mt_insert_order(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 127 | BEGIN 128 | INSERT INTO orders.mt_doc_order ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()); 129 | 130 | RETURN docVersion; 131 | END; 132 | $function$; 133 | 134 | 135 | CREATE OR REPLACE FUNCTION orders.mt_update_order(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 136 | DECLARE 137 | final_version uuid; 138 | BEGIN 139 | UPDATE orders.mt_doc_order SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp() where id = docId; 140 | 141 | SELECT mt_version FROM orders.mt_doc_order into final_version WHERE id = docId ; 142 | RETURN final_version; 143 | END; 144 | $function$; 145 | 146 | 147 | 148 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 149 | VALUES ('20231018224348_Initial', '0'); 150 | END IF; 151 | END $$; 152 | 153 | COMMIT; 154 | 155 | 156 | 157 | BEGIN; 158 | 159 | DO $$ 160 | BEGIN 161 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231018224546_RemoveIndex') THEN 162 | 163 | RAISE NOTICE 'Running migration with Id = 20231018224546_RemoveIndex'; 164 | 165 | drop index if exists orders.mt_doc_order_idx_customer_id; 166 | 167 | 168 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 169 | VALUES ('20231018224546_RemoveIndex', '0'); 170 | END IF; 171 | END $$; 172 | 173 | COMMIT; 174 | 175 | 176 | 177 | BEGIN; 178 | 179 | DO $$ 180 | BEGIN 181 | IF NOT EXISTS(SELECT 1 FROM orders.__sable_migrations WHERE migration_id = '20231018224612_Custom') THEN 182 | 183 | RAISE NOTICE 'Running migration with Id = 20231018224612_Custom'; 184 | 185 | -- Empty migration. 186 | 187 | INSERT INTO orders.__sable_migrations (migration_id, backfilled) 188 | VALUES ('20231018224612_Custom', '0'); 189 | END IF; 190 | END $$; 191 | 192 | COMMIT; 193 | 194 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/Marten/migrations/20231013224417_InfrastructureSetup.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Sable NoIdempotenceWrapper 3 | 4 | CREATE SCHEMA IF NOT EXISTS books; 5 | 6 | CREATE TABLE IF NOT EXISTS books.__sable_migrations ( 7 | migration_id character varying(150) NOT NULL, 8 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 9 | backfilled boolean NOT NULL DEFAULT false, 10 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 11 | ); 12 | 13 | DO $$ 14 | BEGIN 15 | IF NOT EXISTS(SELECT 1 FROM books.__sable_migrations WHERE migration_id = '20231013224417_InfrastructureSetup') THEN 16 | 17 | RAISE NOTICE 'Inserting record for migration with Id = 20231013224417_InfrastructureSetup'; 18 | 19 | INSERT INTO books.__sable_migrations (migration_id, backfilled) 20 | VALUES ('20231013224417_InfrastructureSetup', '0'); 21 | END IF; 22 | END $$; 23 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/Marten/migrations/20231013224420_Initial.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamp(value text) RETURNS timestamp without time zone LANGUAGE sql IMMUTABLE AS 2 | $function$ 3 | select value::timestamp 4 | 5 | $function$; 6 | 7 | 8 | CREATE OR REPLACE FUNCTION books.mt_immutable_timestamptz(value text) RETURNS timestamp with time zone LANGUAGE sql IMMUTABLE AS 9 | $function$ 10 | select value::timestamptz 11 | 12 | $function$; 13 | 14 | 15 | CREATE OR REPLACE FUNCTION books.mt_grams_vector(text) 16 | RETURNS tsvector 17 | LANGUAGE plpgsql 18 | IMMUTABLE STRICT 19 | AS $function$ 20 | BEGIN 21 | RETURN (SELECT array_to_string(mt_grams_array($1), ' ')::tsvector); 22 | END 23 | $function$; 24 | 25 | 26 | CREATE OR REPLACE FUNCTION books.mt_grams_query(text) 27 | RETURNS tsquery 28 | LANGUAGE plpgsql 29 | IMMUTABLE STRICT 30 | AS $function$ 31 | BEGIN 32 | RETURN (SELECT array_to_string(mt_grams_array($1), ' & ')::tsquery); 33 | END 34 | $function$; 35 | 36 | 37 | CREATE OR REPLACE FUNCTION books.mt_grams_array(words text) 38 | RETURNS text[] 39 | LANGUAGE plpgsql 40 | IMMUTABLE STRICT 41 | AS $function$ 42 | DECLARE result text[]; 43 | DECLARE word text; 44 | DECLARE clean_word text; 45 | BEGIN 46 | FOREACH word IN ARRAY string_to_array(words, ' ') 47 | LOOP 48 | clean_word = regexp_replace(word, '[^a-zA-Z0-9]+', '','g'); 49 | FOR i IN 1 .. length(clean_word) 50 | LOOP 51 | result := result || quote_literal(substr(lower(clean_word), i, 1)); 52 | result := result || quote_literal(substr(lower(clean_word), i, 2)); 53 | result := result || quote_literal(substr(lower(clean_word), i, 3)); 54 | END LOOP; 55 | END LOOP; 56 | 57 | RETURN ARRAY(SELECT DISTINCT e FROM unnest(result) AS a(e) ORDER BY e); 58 | END; 59 | $function$; 60 | 61 | 62 | CREATE TABLE IF NOT EXISTS books.mt_doc_book ( 63 | id uuid NOT NULL, 64 | data jsonb NOT NULL, 65 | mt_last_modified timestamp with time zone NULL DEFAULT (transaction_timestamp()), 66 | mt_version uuid NOT NULL DEFAULT (md5(random()::text || clock_timestamp()::text)::uuid), 67 | mt_dotnet_type varchar NULL, 68 | CONSTRAINT pkey_mt_doc_book_id PRIMARY KEY (id) 69 | ); 70 | 71 | CREATE OR REPLACE FUNCTION books.mt_upsert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 72 | DECLARE 73 | final_version uuid; 74 | BEGIN 75 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()) 76 | ON CONFLICT ON CONSTRAINT pkey_mt_doc_book_id 77 | DO UPDATE SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp(); 78 | 79 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 80 | RETURN final_version; 81 | END; 82 | $function$; 83 | 84 | 85 | CREATE OR REPLACE FUNCTION books.mt_insert_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 86 | BEGIN 87 | INSERT INTO books.mt_doc_book ("data", "mt_dotnet_type", "id", "mt_version", mt_last_modified) VALUES (doc, docDotNetType, docId, docVersion, transaction_timestamp()); 88 | 89 | RETURN docVersion; 90 | END; 91 | $function$; 92 | 93 | 94 | CREATE OR REPLACE FUNCTION books.mt_update_book(doc JSONB, docDotNetType varchar, docId uuid, docVersion uuid) RETURNS UUID LANGUAGE plpgsql SECURITY INVOKER AS $function$ 95 | DECLARE 96 | final_version uuid; 97 | BEGIN 98 | UPDATE books.mt_doc_book SET "data" = doc, "mt_dotnet_type" = docDotNetType, "mt_version" = docVersion, mt_last_modified = transaction_timestamp() where id = docId; 99 | 100 | SELECT mt_version FROM books.mt_doc_book into final_version WHERE id = docId ; 101 | RETURN final_version; 102 | END; 103 | $function$; 104 | 105 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/Marten/migrations/20231013224536_AddIndexOnName.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX mt_doc_book_idx_name ON books.mt_doc_book USING btree ((data ->> 'Name')); 2 | -------------------------------------------------------------------------------- /samples/Sable.Samples.MultipleDatabases/sable/Marten/schema.txt: -------------------------------------------------------------------------------- 1 | books -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | true 7 | snupkg 8 | 9 | 10 | 11 | Sable 12 | Datababe migration tool for Marten. 13 | marten;sable;database;migration 14 | Joe Nathan Abellard 15 | Bloomberg Finance L.P. 16 | true 17 | MIT 18 | https://github.com/bloomberg/sable 19 | Icon.png 20 | README.md 21 | https://github.com/bloomberg/sable.git 22 | git 23 | https://github.com/bloomberg/sable/releases 24 | 25 | 26 | 27 | true 28 | ../../Key.snk 29 | 30 | 31 | 32 | 33 | true 34 | 35 | true 36 | 37 | $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/Sable.Cli/AnsiConsoleLogger.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Spectre.Console; 5 | 6 | namespace Sable.Cli; 7 | 8 | public class AnsiConsoleLogger : IConsoleLogger 9 | { 10 | private readonly IAnsiConsole _ansiConsole; 11 | 12 | public AnsiConsoleLogger(IAnsiConsole ansiConsole) 13 | { 14 | _ansiConsole = ansiConsole ?? throw new ArgumentNullException(nameof(ansiConsole)); 15 | } 16 | 17 | public void LogInfo(string message) 18 | { 19 | _ansiConsole.MarkupLine($"[bold mediumpurple3_1]INFO: {message.EscapeMarkup()}[/]"); 20 | } 21 | 22 | public void LogError(string message) 23 | { 24 | _ansiConsole.MarkupLine($"[bold red3_1]ERROR: {message.EscapeMarkup()}[/]"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Sable.Cli/Commands/AddMigrationCommand.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.ComponentModel; 5 | using System.Text.RegularExpressions; 6 | using Sable.Cli.Options; 7 | using Sable.Cli.Settings; 8 | using Sable.Cli.Utilities; 9 | using Newtonsoft.Json; 10 | using Spectre.Console; 11 | using Spectre.Console.Cli; 12 | 13 | namespace Sable.Cli.Commands; 14 | 15 | public class AddMigrationCommand : AsyncCommand 16 | { 17 | private readonly IMartenMigrationManager _martenMigrationManager; 18 | 19 | public AddMigrationCommand(IMartenMigrationManager martenMigrationManager) 20 | { 21 | _martenMigrationManager = 22 | martenMigrationManager ?? throw new ArgumentNullException(nameof(martenMigrationManager)); 23 | } 24 | 25 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 26 | { 27 | var result = await _martenMigrationManager.AddMigration(settings.ProjectFilePath, settings.DatabaseName, settings.Name, settings.PostgresContainerOptions, settings.NoIdempotenceWrapper, settings.NoTransactionWrapper); 28 | return result; 29 | } 30 | 31 | public class Settings : ProjectSettings 32 | { 33 | [Description("The name of the migration.")] 34 | [CommandArgument(0, "")] 35 | public string Name { get; set; } 36 | 37 | [Description("Path to a JSON file that contains options for buiding a custom Postgres container that is used as the shadow database for migration management.")] 38 | [CommandOption("-c|--container-options")] 39 | public string ContainerOptionsFilePath { get; init; } 40 | 41 | [Description("By default, when embedding a migration as part of a larger, aggregate migration script, Sable will wrap it in an anynomous function block to ensure it is executed idemtotently." + 42 | "Additionally, that code block will then be wrapped in a trasaction block to ensure the entire migration is executed in a single atomic operation." + 43 | "However, some Postgres statements must not be executed within a trasaction. For a migration that contains those type of statements, this flag must be set to" + 44 | "avoid running into issues when generating migration scripts. This option isreserved for advanced use cases. Do not use it unless you know what you are doing.")] 45 | [CommandOption("--no-transaction-wrapper")] 46 | public bool NoTransactionWrapper { get; init; } = false; 47 | 48 | [Description("By default, when embedding a migration as part of a larger, aggregate migration script, Sable will wrap it in an anynomous function block to ensure it is executed idemtotently." + 49 | "However, some Postgres statements must not be executed within such a block. For a migration that contains those type of statements, this flag must be set to" + 50 | "avoid running into issues when generating migration scripts. Additionally, given that those statements will execute outside of indempotent context, " + 51 | "they must be made to be indempotent (e.g., `CREATE INDEX CONCURRENTLY IF NOT EXISTS my_index ON my_table (column_name);` instead of `CREATE INDEX CONCURRENTLY my_index ON my_table (column_name);`)." + 52 | ". This option isreserved for advanced use cases. Do not use it unless you know what you are doing.")] 53 | [CommandOption("--no-idempotence-wrapper")] 54 | public bool NoIdempotenceWrapper { get; init; } = false; 55 | 56 | public PostgresContainerOptions PostgresContainerOptions { get; private set; } = new(); 57 | 58 | public override ValidationResult Validate() 59 | { 60 | if (!string.IsNullOrWhiteSpace(ContainerOptionsFilePath)) 61 | { 62 | var fileContents = File.ReadAllText(ContainerOptionsFilePath); 63 | PostgresContainerOptions = JsonConvert.DeserializeObject(fileContents); 64 | } 65 | 66 | var baseResult = base.Validate(); 67 | if (baseResult == ValidationResult.Error()) 68 | { 69 | return baseResult; 70 | } 71 | 72 | var nameIsValid = Regex.IsMatch(Name, "^[a-zA-Z0-9]+$"); 73 | if (!nameIsValid) 74 | { 75 | return ValidationResult.Error("The migration name must contain only alphanumeric characters."); 76 | } 77 | 78 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(ProjectFilePath); 79 | var migrationsDirectory = Path.Combine(projectDirectory, "sable", DatabaseName, "migrations"); 80 | var existingMigrationNames = 81 | Directory.EnumerateFiles(migrationsDirectory, "*.sql", SearchOption.TopDirectoryOnly) 82 | .Select(Path.GetFileNameWithoutExtension) 83 | .Select(n => n.Split("_").Last()) 84 | .ToHashSet(); 85 | if (existingMigrationNames.Contains(Name)) 86 | { 87 | return ValidationResult.Error("A migration with the specified name already exists."); 88 | } 89 | 90 | var validationResult = 91 | ValidationUtilities.MigrationsInfrastructureHasBeenInitialized(ProjectFilePath, DatabaseName); 92 | return validationResult; 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Sable.Cli/Commands/BackfillMigrationsCommand.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.ComponentModel; 5 | using Sable.Cli.Settings; 6 | using Sable.Cli.Utilities; 7 | using Spectre.Console; 8 | using Spectre.Console.Cli; 9 | 10 | namespace Sable.Cli.Commands; 11 | 12 | public class BackfillMigrationsCommand : AsyncCommand 13 | { 14 | private readonly IMartenMigrationManager _martenMigrationManager; 15 | private readonly IConsoleLogger _consoleLogger; 16 | 17 | public BackfillMigrationsCommand(IMartenMigrationManager martenMigrationManager, IConsoleLogger consoleLogger) 18 | { 19 | _martenMigrationManager = martenMigrationManager ?? throw new ArgumentNullException(nameof(martenMigrationManager)); 20 | _consoleLogger = consoleLogger ?? throw new ArgumentNullException(nameof(consoleLogger)); 21 | } 22 | 23 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 24 | { 25 | var script = await _martenMigrationManager.CreateBackfillMigrationScript(settings.ProjectFilePath, settings.DatabaseName); 26 | var scriptFilePath = settings.Output; 27 | if (string.IsNullOrWhiteSpace(settings.Output)) 28 | { 29 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(settings.ProjectFilePath); 30 | var currentTime = DateTime.UtcNow; 31 | var timestamp = currentTime.ToString(SableCliConstants.TimeSerializationFormat); 32 | var scriptName = $"{timestamp}_backfill.sql"; 33 | scriptFilePath = Path.Combine(projectDirectory, "sable", settings.DatabaseName, 34 | scriptName); 35 | } 36 | await File.WriteAllTextAsync(scriptFilePath, script); 37 | _consoleLogger.LogInfo($"Successfully saved backfill script to '{scriptFilePath}' file."); 38 | return 0; 39 | } 40 | 41 | 42 | public class Settings : ProjectSettings 43 | { 44 | [Description("Path of the file to save the script to. Defaults to a path within the 'sable' directory tree.")] 45 | [CommandOption("-o|--output")] 46 | public string Output { get; init; } 47 | public override ValidationResult Validate() 48 | { 49 | var baseResult = base.Validate(); 50 | if (!baseResult.Successful) 51 | { 52 | return baseResult; 53 | } 54 | var validationResult = 55 | ValidationUtilities.MigrationsInfrastructureHasBeenInitialized(ProjectFilePath, DatabaseName); 56 | return validationResult; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Sable.Cli/Commands/CreateMigrationScriptCommand.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.ComponentModel; 5 | using Sable.Cli.Settings; 6 | using Sable.Cli.Utilities; 7 | using Spectre.Console; 8 | using Spectre.Console.Cli; 9 | 10 | namespace Sable.Cli.Commands; 11 | 12 | public class CreateMigrationScriptCommand : AsyncCommand 13 | { 14 | private readonly IMartenMigrationManager _martenMigrationManager; 15 | private readonly IConsoleLogger _consoleLogger; 16 | 17 | public CreateMigrationScriptCommand(IMartenMigrationManager martenMigrationManager, IConsoleLogger consoleLogger) 18 | { 19 | _martenMigrationManager = martenMigrationManager ?? throw new ArgumentNullException(nameof(martenMigrationManager)); 20 | _consoleLogger = consoleLogger ?? throw new ArgumentNullException(nameof(consoleLogger)); 21 | } 22 | 23 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 24 | { 25 | var script = await _martenMigrationManager.CreateMigrationScript(settings.ProjectFilePath, settings.DatabaseName, settings.From, settings.To); 26 | var scriptFilePath = settings.Output; 27 | if (string.IsNullOrWhiteSpace(settings.Output)) 28 | { 29 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(settings.ProjectFilePath); 30 | var currentTime = DateTime.UtcNow; 31 | var timestamp = currentTime.ToString(SableCliConstants.TimeSerializationFormat); 32 | var scriptName = $"{timestamp}_script.sql"; 33 | scriptFilePath = Path.Combine(projectDirectory, "sable", settings.DatabaseName, "scripts", 34 | scriptName); 35 | } 36 | var fileInfo = new FileInfo(scriptFilePath); 37 | var scriptsDirectory = fileInfo.DirectoryName; 38 | Directory.CreateDirectory(scriptsDirectory!); 39 | await File.WriteAllTextAsync(scriptFilePath, script); 40 | _consoleLogger.LogInfo($"Successfully saved migration script to '{scriptFilePath}' file."); 41 | return 0; 42 | } 43 | 44 | public class Settings : ProjectSettings 45 | { 46 | 47 | [Description("Id or name of the first migration that should be included in the script. Defaults to the first migration that was generated.")] 48 | [CommandOption("-f|--from")] 49 | public string From { get; init; } 50 | 51 | [Description("Id or name of the last migration that should be included in the script. Defaults to the last migration that was generated.")] 52 | [CommandOption("-t|--to")] 53 | public string To { get; init; } 54 | 55 | [Description("Path of the file to save the script to. Defaults to a path within the 'sable' directory tree.")] 56 | [CommandOption("-o|--output")] 57 | public string Output { get; init; } 58 | 59 | public override ValidationResult Validate() 60 | { 61 | var baseResult = base.Validate(); 62 | if (!baseResult.Successful) 63 | { 64 | return baseResult; 65 | } 66 | var validationResult = 67 | ValidationUtilities.MigrationsInfrastructureHasBeenInitialized(ProjectFilePath, DatabaseName); 68 | return validationResult; 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Sable.Cli/Commands/InitializeInfrastructureCommand.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.ComponentModel; 5 | using Sable.Cli.Options; 6 | using Sable.Cli.Settings; 7 | using Sable.Cli.Utilities; 8 | using Newtonsoft.Json; 9 | using Spectre.Console; 10 | using Spectre.Console.Cli; 11 | 12 | namespace Sable.Cli.Commands; 13 | 14 | public class InitializeInfrastructureCommand : AsyncCommand 15 | { 16 | private readonly IMartenMigrationManager _martenMigrationManager; 17 | 18 | public InitializeInfrastructureCommand(IMartenMigrationManager martenMigrationManager) 19 | { 20 | _martenMigrationManager = martenMigrationManager ?? throw new ArgumentNullException(nameof(martenMigrationManager)); 21 | } 22 | 23 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 24 | { 25 | var result = await _martenMigrationManager.SetupInfrastructure(settings.ProjectFilePath, settings.DatabaseName, 26 | settings.DatabaseSchemaName, settings.PostgresContainerOptions); 27 | return result; 28 | } 29 | 30 | public class Settings : ProjectSettings 31 | { 32 | [Description("Name of the database schema. Defaults to the 'public' schema.")] 33 | [CommandOption("-s|--schema")] 34 | public string DatabaseSchemaName { get; init; } = SableCliConstants.DefaultDatabaseSchemaName; 35 | 36 | [Description("Path to a JSON file that contains options for buiding a custom Postgres container that is used as the shadow database for migration management.")] 37 | [CommandOption("-c|--container-options")] 38 | public string ContainerOptionsFilePath { get; init; } 39 | 40 | public PostgresContainerOptions PostgresContainerOptions { get; private set; } = new(); 41 | public override ValidationResult Validate() 42 | { 43 | if (!string.IsNullOrWhiteSpace(ContainerOptionsFilePath)) 44 | { 45 | var fileContents = File.ReadAllText(ContainerOptionsFilePath); 46 | PostgresContainerOptions = JsonConvert.DeserializeObject(fileContents); 47 | } 48 | 49 | var baseResult = base.Validate(); 50 | if (!baseResult.Successful) 51 | { 52 | return baseResult; 53 | } 54 | 55 | var validationResult = ValidationUtilities.MigrationsInfrastructureHasBeenInitialized(ProjectFilePath, DatabaseName); 56 | return !validationResult.Successful 57 | ? ValidationResult.Success() 58 | : ValidationResult.Error($"The migration infrastructure has already been initialized for the '{DatabaseName}' database."); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Sable.Cli/Commands/UpdateDatabaseCommand.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.ComponentModel; 5 | using Sable.Cli.Settings; 6 | using Sable.Cli.Utilities; 7 | using Npgsql; 8 | using Spectre.Console; 9 | using Spectre.Console.Cli; 10 | 11 | namespace Sable.Cli.Commands; 12 | 13 | public class UpdateDatabaseCommand : AsyncCommand 14 | { 15 | private readonly IMartenMigrationManager _martenMigrationManager; 16 | private readonly IConsoleLogger _consoleLogger; 17 | 18 | public UpdateDatabaseCommand(IMartenMigrationManager martenMigrationManager, IConsoleLogger consoleLogger) 19 | { 20 | _martenMigrationManager = 21 | martenMigrationManager ?? throw new ArgumentNullException(nameof(martenMigrationManager)); 22 | _consoleLogger = consoleLogger ?? throw new ArgumentNullException(nameof(consoleLogger)); 23 | } 24 | 25 | public override async Task ExecuteAsync(CommandContext context, Settings settings) 26 | { 27 | var script = await _martenMigrationManager.CreateMigrationScript(settings.ProjectFilePath, settings.DatabaseName, to: settings.TargetMigration); 28 | await using var dataSource = NpgsqlDataSource.Create(settings.ConnectionString); 29 | await using var command = dataSource.CreateCommand(script); 30 | await command.ExecuteNonQueryAsync(); 31 | _consoleLogger.LogInfo("Successfully updated the database."); 32 | return 0; 33 | } 34 | public class Settings : ProjectSettings 35 | { 36 | [Description("Connection string for the database that is to be updated.")] 37 | [CommandArgument(0, "")] 38 | public string ConnectionString { get; set; } 39 | 40 | [Description("Id or name of the latest migration that should be applied. Defaults to the last migration that was generated.")] 41 | [CommandOption("-m|--migration")] 42 | public string TargetMigration { get; init; } 43 | 44 | public override ValidationResult Validate() 45 | { 46 | var baseResult = base.Validate(); 47 | if (baseResult == ValidationResult.Error()) 48 | { 49 | return baseResult; 50 | } 51 | 52 | var validationResult = 53 | ValidationUtilities.MigrationsInfrastructureHasBeenInitialized(ProjectFilePath, DatabaseName); 54 | return validationResult; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Sable.Cli/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable.Cli.Extensions; 5 | 6 | public static class EnumerableExtensions 7 | { 8 | public static IEnumerable TakeWhile(this IEnumerable enumerable, Func predicate, bool isInclusive) 9 | { 10 | foreach (var entry in enumerable) 11 | { 12 | if (predicate(entry)) 13 | { 14 | yield return entry; 15 | } 16 | else 17 | { 18 | if (isInclusive) 19 | { 20 | yield return entry; 21 | } 22 | yield break; 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Sable.Cli/IConsoleLogger.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable.Cli; 5 | 6 | public interface IConsoleLogger 7 | { 8 | void LogInfo(string message); 9 | void LogError(string message); 10 | } 11 | -------------------------------------------------------------------------------- /src/Sable.Cli/IMartenMigrationManager.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Sable.Cli.Options; 5 | 6 | namespace Sable.Cli; 7 | 8 | public interface IMartenMigrationManager 9 | { 10 | public Task SetupInfrastructure(string projectFilePath, string databaseName, string databaseSchemaName, PostgresContainerOptions postgresContainerOptions); 11 | 12 | public Task AddMigration(string projectFilePath, string databaseName, 13 | string migrationName, PostgresContainerOptions postgresContainerOptions, bool noIdempotenceWrapper = false, bool noTransactionWrapper = false); 14 | 15 | public Task CreateMigrationScript(string projectFilePath, string databaseName, 16 | string from = null, string to = null); 17 | 18 | public Task CreateBackfillMigrationScript(string projectFilePath, string databaseName); 19 | } 20 | -------------------------------------------------------------------------------- /src/Sable.Cli/MartenMigrationManager.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.Text; 5 | using Sable.Cli.Extensions; 6 | using Sable.Cli.Options; 7 | using Sable.Cli.Utilities; 8 | using CliWrap; 9 | using DotNet.Testcontainers.Builders; 10 | using DotNet.Testcontainers.Containers; 11 | using Npgsql; 12 | using Scriban; 13 | 14 | namespace Sable.Cli; 15 | 16 | public class MartenMigrationManager : IMartenMigrationManager 17 | { 18 | private readonly IConsoleLogger _consoleLogger; 19 | 20 | public MartenMigrationManager(IConsoleLogger consoleLogger) 21 | { 22 | _consoleLogger = consoleLogger ?? throw new ArgumentNullException(nameof(consoleLogger)); 23 | } 24 | 25 | public async Task SetupInfrastructure(string projectFilePath, string databaseName, string databaseSchemaName, PostgresContainerOptions postgresContainerOptions) 26 | { 27 | var currentTime = DateTime.UtcNow + TimeSpan.FromSeconds(2); 28 | var timestamp = currentTime.ToString(SableCliConstants.TimeSerializationFormat); 29 | var migrationFileName = $"{timestamp}_{SableCliConstants.InfrastructureSetupMigrationName}.sql"; 30 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(projectFilePath); 31 | var migrationFilePath = Path.Combine(projectDirectory, "sable", databaseName, "migrations", 32 | migrationFileName); 33 | var fileInfo = new FileInfo(migrationFilePath); 34 | var migrationDirectory = fileInfo.DirectoryName; 35 | Directory.CreateDirectory(migrationDirectory!); 36 | var migrationId = migrationFileName.Replace(".sql", ""); 37 | var template = Template.Parse(Templates.InfrastructureSetupTemplate); 38 | var migration = await template.RenderAsync(new 39 | { 40 | DatabaseSchemaName = databaseSchemaName, 41 | MigrationId = migrationId, 42 | Date = currentTime.ToString(), 43 | }, member => member.Name); 44 | await File.WriteAllTextAsync(migrationFilePath, migration); 45 | 46 | var databaseSchemaNameFilePath = Path.Combine(projectDirectory, "sable", databaseName, "schema.txt"); 47 | await File.WriteAllTextAsync(databaseSchemaNameFilePath, databaseSchemaName); 48 | 49 | var result = await AddMigration(projectFilePath, databaseName, SableCliConstants.InitialMigrationName, postgresContainerOptions); 50 | if (result != 0) 51 | { 52 | _consoleLogger.LogError("Failed to create initial migration."); 53 | return result; 54 | } 55 | _consoleLogger.LogInfo($"Successfully initialized migration infrastructure in '{Path.Combine(projectDirectory, "sable", databaseName)}' directory."); 56 | return 0; 57 | } 58 | 59 | private IContainer CreatePostgresContainer(PostgresContainerOptions containerOptions) 60 | { 61 | var readinessProbeStrategy = new ReadinessProbeWaitStrategy(containerOptions.ConnectionString); 62 | var waitStrategy = Wait 63 | .ForUnixContainer() 64 | .AddCustomWaitStrategy(readinessProbeStrategy); 65 | var containerBuilder = new ContainerBuilder() 66 | .WithImage(containerOptions.Image) 67 | .WithEnvironment(containerOptions.EnvironmentVariables) 68 | .WithWaitStrategy(waitStrategy); 69 | foreach (var portBinding in containerOptions.PortBindings) 70 | { 71 | containerBuilder = containerBuilder.WithPortBinding(portBinding.HostPort, portBinding.ContainerPort); 72 | } 73 | var container = containerBuilder.Build(); 74 | return container; 75 | } 76 | 77 | public async Task AddMigration(string projectFilePath, string databaseName, string migrationName, 78 | PostgresContainerOptions postgresContainerOptions, bool noIdempotenceWrapper = false, 79 | bool noTransactionWrapper = false) 80 | { 81 | await using var container = CreatePostgresContainer(postgresContainerOptions); 82 | await container.StartAsync(); 83 | await using var dataSource = NpgsqlDataSource.Create(postgresContainerOptions.ConnectionString); 84 | var script = await CreateMigrationScript(projectFilePath, databaseName); 85 | await using var command = dataSource.CreateCommand(script); 86 | await command.ExecuteNonQueryAsync(); 87 | 88 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(projectFilePath); 89 | var currentTime = DateTime.UtcNow + TimeSpan.FromSeconds(2); 90 | var timestamp = currentTime.ToString(SableCliConstants.TimeSerializationFormat); 91 | var migrationFileName = $"{timestamp}_{migrationName}.sql"; 92 | var migrationFilePath = 93 | Path.Combine(projectDirectory, "sable", databaseName, "migrations", migrationFileName); 94 | //projectFilePath = projectFilePath.Replace(@"\\", @"\").Replace(@"\", @"\\"); 95 | //migrationFilePath = migrationFilePath.Replace(@"\\", @"\").Replace(@"\", @"\\"); 96 | var patchCommandExecutionResult = await CliWrap.Cli.Wrap("dotnet") 97 | .WithArguments(new[] { "run", "--project", projectFilePath, "--", "marten-patch", "--database", $"{databaseName}", migrationFilePath }) 98 | .WithWorkingDirectory(projectDirectory) 99 | .WithEnvironmentVariables(new Dictionary 100 | { 101 | [SableConstants.ConnectionStringOverride] = postgresContainerOptions.ConnectionString 102 | }) 103 | .WithValidation(CommandResultValidation.None) 104 | .ExecuteAsync(); 105 | if (patchCommandExecutionResult.ExitCode != 0) 106 | { 107 | _consoleLogger.LogError("Failed to add migration."); 108 | return patchCommandExecutionResult.ExitCode; 109 | } 110 | 111 | var migrationBuilder = new StringBuilder(); 112 | migrationBuilder.AppendLine($"-- Generated by Sable on {currentTime}"); 113 | if (noIdempotenceWrapper) 114 | { 115 | migrationBuilder.AppendLine($"{SableCliConstants.NoIdempotenceWrapperDirective}"); 116 | } 117 | if (noTransactionWrapper) 118 | { 119 | migrationBuilder.AppendLine($"{SableCliConstants.NoTransactionWrapperDirective}"); 120 | } 121 | 122 | var changeDetected = File.Exists(migrationFilePath); 123 | if (changeDetected) 124 | { 125 | var dropFilePath = migrationFilePath.Replace(".sql", ".drop.sql"); 126 | File.Delete(dropFilePath); 127 | migrationBuilder.AppendLine(); 128 | var migrationContents = await File.ReadAllTextAsync(migrationFilePath); 129 | migrationBuilder.Append(migrationContents); 130 | var enrichedMigration = migrationBuilder.ToString(); 131 | await File.WriteAllTextAsync(migrationFilePath, enrichedMigration); 132 | } 133 | else 134 | { 135 | var emptyMigration = migrationBuilder.ToString(); 136 | await File.WriteAllTextAsync(migrationFilePath, emptyMigration); 137 | } 138 | _consoleLogger.LogInfo($"Successfully saved migration to '{migrationFilePath}' file."); 139 | return 0; 140 | } 141 | 142 | public async Task CreateMigrationScript(string projectFilePath, string databaseName, string from = null, string to = null) 143 | { 144 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(projectFilePath); 145 | var databaseSchemaName = await FileSystemUtilities.ResolveDatabaseSchemaName(projectDirectory, databaseName); 146 | var scriptDirectoryPath = Path.Combine(projectDirectory, "sable", databaseName, "migrations"); 147 | var migrations = Directory.EnumerateFiles(scriptDirectoryPath, "*.sql", SearchOption.TopDirectoryOnly) 148 | .Select(p => new Migration(p, databaseSchemaName)) 149 | .OrderBy(m => m.Timestamp) 150 | .ToList(); 151 | if (!string.IsNullOrWhiteSpace(from)) 152 | { 153 | migrations = migrations 154 | .SkipWhile(m => m.Id != from && m.Name != from) 155 | .ToList(); 156 | } 157 | if (!string.IsNullOrWhiteSpace(to)) 158 | { 159 | migrations = migrations.TakeWhile(m => m.Id != from && m.Name != from, true).ToList(); 160 | } 161 | var transactions = migrations 162 | .OrderBy(m => m.Timestamp) 163 | .Select(m => m.GetTransactionalIdempotentScript()) 164 | .ToList(); 165 | var scriptBuilder = new StringBuilder(); 166 | scriptBuilder.Append($"-- Generated by Sable on {DateTime.UtcNow}{Environment.NewLine}"); 167 | foreach (var transactionSegment in transactions.Select(transaction => $"{Environment.NewLine}{transaction}")) 168 | { 169 | scriptBuilder.AppendLine(transactionSegment); 170 | } 171 | var script = scriptBuilder.ToString(); 172 | return script; 173 | } 174 | 175 | public async Task CreateBackfillMigrationScript(string projectFilePath, string databaseName) 176 | { 177 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(projectFilePath); 178 | var databaseSchemaName = await FileSystemUtilities.ResolveDatabaseSchemaName(projectDirectory, databaseName); 179 | var scriptDirectoryPath = Path.Combine(projectDirectory, "sable", databaseName, "migrations"); 180 | var migrationInsertionScripts = Directory.EnumerateFiles(scriptDirectoryPath, "*.sql", SearchOption.TopDirectoryOnly) 181 | .Select(p => new Migration(p, databaseSchemaName)) 182 | .OrderBy(m => m.Timestamp) 183 | .Select(m => m.GetIdempotentMigrationRecordInsertionScript(true)) 184 | .ToList(); 185 | var template = Template.Parse(Templates.BackfillScriptTemplate); 186 | var script = await template.RenderAsync(new 187 | { 188 | Date = DateTime.UtcNow.ToString(), 189 | DatabaseSchemaName = databaseSchemaName, 190 | MigrationInsertionScripts = migrationInsertionScripts 191 | }, member => member.Name); 192 | return script; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/Sable.Cli/Migration.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Scriban; 5 | 6 | namespace Sable.Cli; 7 | 8 | public class Migration 9 | { 10 | public string DatabaseSchemaName { get; } 11 | public string FilePath { get; } 12 | public string Id { get; } 13 | public string Name { get; } 14 | public string Script { get; } 15 | public string Timestamp { get; } 16 | private bool _wrapInTransaction = true; 17 | private bool _wrapInIdempotenceBlock = true; 18 | 19 | public Migration(string filePath, string databaseSchemaName) 20 | { 21 | DatabaseSchemaName = databaseSchemaName; 22 | FilePath = filePath; 23 | Id = Path.GetFileNameWithoutExtension(filePath); 24 | Timestamp = Id.Split("_").First(); 25 | Script = File.ReadAllText(FilePath); 26 | var scriptReader = new StringReader(Script); 27 | while (true) 28 | { 29 | var line = scriptReader.ReadLine(); 30 | line = line?.Trim(); 31 | if (line == string.Empty) 32 | { 33 | continue; 34 | } 35 | if (line is null) 36 | { 37 | break; 38 | } 39 | if (line.StartsWith("--")) 40 | { 41 | var isDirective = line 42 | .StartsWith(SableCliConstants.DirectivePrefix); 43 | if (isDirective) 44 | { 45 | switch (line) 46 | { 47 | case SableCliConstants.NoTransactionWrapperDirective: 48 | _wrapInTransaction = false; 49 | break; 50 | case SableCliConstants.NoIdempotenceWrapperDirective: 51 | _wrapInIdempotenceBlock = false; 52 | break; 53 | } 54 | } 55 | } 56 | else 57 | { 58 | break; 59 | } 60 | 61 | } 62 | } 63 | 64 | public string GetIdempotentScript() 65 | { 66 | if (!_wrapInIdempotenceBlock) 67 | { 68 | var template = Template.Parse(Templates.NoIdempotenceBlockTemplate); 69 | var result = template.Render(new 70 | { 71 | DatabaseSchemaName, 72 | MigrationId = Id, 73 | Script = Script 74 | }, member => member.Name); 75 | return result; 76 | } 77 | 78 | var idempotentMigrationScriptTemplate = Template.Parse(Templates.IdempotentMigrationScriptTemplate); 79 | var idempotentScript = idempotentMigrationScriptTemplate.Render(new 80 | { 81 | DatabaseSchemaName, 82 | MigrationId = Id, 83 | Backfilled = "0", 84 | Script = Script 85 | }, member => member.Name); 86 | return idempotentScript; 87 | } 88 | 89 | public string GetTransactionalIdempotentScript() 90 | { 91 | var idempotentScript = GetIdempotentScript(); 92 | if (!_wrapInTransaction) 93 | { 94 | return idempotentScript; 95 | } 96 | var template = Template.Parse(Templates.TransactionTemplate); 97 | var result = template.Render(new 98 | { 99 | Script = idempotentScript 100 | }, member => member.Name); 101 | return result; 102 | } 103 | 104 | public string GetIdempotentMigrationRecordInsertionScript(bool backfill = false) 105 | { 106 | var template = Template.Parse(Templates.IdempotentMigrationRecordInsertionTemplate); 107 | var result = template.Render(new 108 | { 109 | DatabaseSchemaName, 110 | MigrationId = Id, 111 | Backfilled = backfill ? "1" : "0" 112 | }, member => member.Name); 113 | return result; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Sable.Cli/Options/PostgresContainerOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | 5 | namespace Sable.Cli.Options; 6 | 7 | public class PostgresContainerOptions 8 | { 9 | public string Image { get; set; } = "postgres:15.1"; 10 | 11 | public List PortBindings { get; set; } = new() 12 | { 13 | new PortBinding { HostPort = 5470, ContainerPort = 5432 } 14 | }; 15 | 16 | public Dictionary EnvironmentVariables { get; set; } = new() 17 | { 18 | { "PGPORT", "5432" }, 19 | { "POSTGRES_DB", "postgres" }, 20 | { "POSTGRES_USER", "postgres" }, 21 | { "POSTGRES_PASSWORD", "postgres" }, 22 | }; 23 | 24 | public string ConnectionString { get; set; } = 25 | "Host=localhost;Port=5470;Username=postgres;Password=postgres;Database=postgres"; 26 | } 27 | 28 | public class PortBinding 29 | { 30 | public int HostPort { get; set; } 31 | public int ContainerPort { get; set; } 32 | } 33 | -------------------------------------------------------------------------------- /src/Sable.Cli/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Sable.Cli; 5 | using Sable.Cli.Commands; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Spectre.Console; 8 | using Spectre.Console.Cli; 9 | 10 | var serviceCollection = new ServiceCollection(); 11 | serviceCollection.AddSingleton(); 12 | serviceCollection.AddSingleton(); 13 | var typeRegistrar = new TypeRegistrar(serviceCollection); 14 | var commandApp = new CommandApp(typeRegistrar); 15 | 16 | commandApp.Configure(configurator => 17 | { 18 | configurator.SetApplicationName("sable"); 19 | 20 | configurator.AddCommand("init") 21 | .WithDescription("Initialize the migration infrastructure for a database."); 22 | 23 | configurator.AddBranch("migrations", migrations => 24 | { 25 | migrations.SetDescription("Commands to manage migrations."); 26 | migrations.AddCommand("add") 27 | .WithDescription("Add a new migration for a database."); 28 | migrations.AddCommand("script") 29 | .WithDescription("Create an idempotent migration script from existing migrations that can be used to bring a database up to date."); 30 | migrations.AddCommand("backfill") 31 | .WithDescription( 32 | "For an existing database that is already up to date, and for which the migration infrastructure has newly been initialized, backfill the newly created migrations."); 33 | }); 34 | 35 | configurator.AddBranch("database", database => 36 | { 37 | database.SetDescription("Commands to manage Marten databases."); 38 | database.AddCommand("update") 39 | .WithDescription("Use pending migrations to bring a database up to date."); 40 | }); 41 | }); 42 | 43 | try 44 | { 45 | commandApp.Run(args); 46 | } 47 | catch (Exception e) 48 | { 49 | AnsiConsole.WriteException(e, 50 | ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes | 51 | ExceptionFormats.ShortenMethods | ExceptionFormats.ShowLinks); 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/Sable.Cli/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Help": { 5 | "commandName": "Project", 6 | "commandLineArgs": "help", 7 | "dotnetRunMessages": false, 8 | "environmentVariables": { 9 | } 10 | }, 11 | "InitializeInfrastructure": { 12 | "commandName": "Project", 13 | "commandLineArgs": "init --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj --schema books ", 14 | "dotnetRunMessages": false, 15 | "environmentVariables": { 16 | } 17 | }, 18 | "AddMigrationNoTransactionWrapper": { 19 | "commandName": "Project", 20 | "commandLineArgs": "migrations add M4 --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj --no-transaction-wrapper", 21 | "dotnetRunMessages": false, 22 | "environmentVariables": { 23 | } 24 | }, 25 | "AddMigrationNoIdempotenceWrapper": { 26 | "commandName": "Project", 27 | "commandLineArgs": "migrations add M3 --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj --no-idempotence-wrapper", 28 | "dotnetRunMessages": false, 29 | "environmentVariables": { 30 | } 31 | }, 32 | "AddMigrationNoIdempotenceWrapperNoTransactionWrapper": { 33 | "commandName": "Project", 34 | "commandLineArgs": "migrations add M2 --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj --no-idempotence-wrapper --no-transaction-wrapper", 35 | "dotnetRunMessages": false, 36 | "environmentVariables": { 37 | } 38 | }, 39 | "AddMigration": { 40 | "commandName": "Project", 41 | "commandLineArgs": "migrations add M2 --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj", 42 | "dotnetRunMessages": false, 43 | "environmentVariables": { 44 | } 45 | }, 46 | "BackfillMigrations": { 47 | "commandName": "Project", 48 | "commandLineArgs": "migrations backfill --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj ", 49 | "dotnetRunMessages": false, 50 | "environmentVariables": { 51 | } 52 | }, 53 | "CreateScript": { 54 | "commandName": "Project", 55 | "commandLineArgs": "migrations script --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj ", 56 | "dotnetRunMessages": false, 57 | "environmentVariables": { 58 | } 59 | }, 60 | "UpdateDatabase": { 61 | "commandName": "Project", 62 | "commandLineArgs": "database update \"Host=localhost;Port=5432;Database=orders;Username=postgres;password=P0stG&e$;SSL Mode=Disable\" --project ..\\..\\..\\..\\..\\samples\\Sable.Samples.GettingStarted\\Sable.Samples.GettingStarted.csproj ", 63 | "dotnetRunMessages": false, 64 | "environmentVariables": { 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Sable.Cli/ReadinessProbeWaitStrategy.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using DotNet.Testcontainers.Configurations; 5 | using DotNet.Testcontainers.Containers; 6 | using Npgsql; 7 | 8 | namespace Sable.Cli; 9 | 10 | public class ReadinessProbeWaitStrategy : IWaitUntil 11 | { 12 | private readonly string _connectionString; 13 | 14 | public ReadinessProbeWaitStrategy(string connectionString) 15 | { 16 | _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); 17 | } 18 | public async Task UntilAsync(IContainer container) 19 | { 20 | await using var dataSource = NpgsqlDataSource.Create(_connectionString); 21 | await using var command = dataSource.CreateCommand(); 22 | command.CommandText = "SELECT 1;"; 23 | try 24 | { 25 | await command.ExecuteScalarAsync(); 26 | return true; 27 | } 28 | catch (Exception) 29 | { 30 | return false; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Sable.Cli/Sable.Cli.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | true 6 | sable 7 | net8.0 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Always 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/Sable.Cli/SableCliConstants.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable.Cli; 5 | 6 | public static class SableCliConstants 7 | { 8 | public const string InfrastructureSetupMigrationName = "InfrastructureSetup"; 9 | public const string InitialMigrationName = "Initial"; 10 | public const string TimeSerializationFormat = "yyyyMMddHHmmss"; 11 | public const string DirectivePrefix = "-- Sable"; 12 | public const string NoTransactionWrapperDirective = $"{DirectivePrefix} NoTransactionWrapper"; 13 | public const string NoIdempotenceWrapperDirective = $"{DirectivePrefix} NoIdempotenceWrapper"; 14 | public const string DefaultDatabaseName = "Marten"; 15 | public const string DefaultDatabaseSchemaName = "public"; 16 | } 17 | -------------------------------------------------------------------------------- /src/Sable.Cli/Settings/ProjectSettings.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.ComponentModel; 5 | using Spectre.Console; 6 | using Spectre.Console.Cli; 7 | 8 | namespace Sable.Cli.Settings; 9 | 10 | public class ProjectSettings : CommandSettings 11 | { 12 | [Description("Path to the project file of the Marten project. Defaults to using the project file in the current directory if it's the only one in there.")] 13 | [CommandOption("-p|--project")] 14 | public string ProjectFilePath { get; private set; } 15 | 16 | [Description("Which database to use. Defaults to the 'Marten' database.")] 17 | [CommandOption("-d|--database")] 18 | public string DatabaseName { get; init; } = SableCliConstants.DefaultDatabaseName; 19 | 20 | public override ValidationResult Validate() 21 | { 22 | if (string.IsNullOrWhiteSpace(ProjectFilePath)) 23 | { 24 | var projectDirectory = Directory.GetCurrentDirectory(); 25 | var projectFiles = Directory.EnumerateFiles(projectDirectory, "*.csproj", SearchOption.TopDirectoryOnly) 26 | .ToList(); 27 | if (projectFiles.Count != 1) 28 | { 29 | return ValidationResult.Error("The path to the project file must be specified."); 30 | } 31 | ProjectFilePath = projectFiles.First(); 32 | } 33 | ProjectFilePath = Path.GetFullPath(ProjectFilePath); 34 | var fileExists = File.Exists(ProjectFilePath); 35 | return fileExists 36 | ? ValidationResult.Success() 37 | : ValidationResult.Error("The specified project file does not exist."); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Sable.Cli/Templates.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable.Cli; 5 | 6 | public static class Templates 7 | { 8 | public const string TransactionTemplate = @" 9 | BEGIN; 10 | {{Script}} 11 | COMMIT; 12 | "; 13 | public const string IdempotentMigrationScriptTemplate = @" 14 | DO $$ 15 | BEGIN 16 | IF NOT EXISTS(SELECT 1 FROM {{DatabaseSchemaName}}.__sable_migrations WHERE migration_id = '{{MigrationId}}') THEN 17 | 18 | RAISE NOTICE 'Running migration with Id = {{MigrationId}}'; 19 | 20 | {{Script}} 21 | 22 | INSERT INTO {{DatabaseSchemaName}}.__sable_migrations (migration_id, backfilled) 23 | VALUES ('{{MigrationId}}', '{{Backfilled}}'); 24 | END IF; 25 | END $$; 26 | "; 27 | public const string IdempotentMigrationRecordInsertionTemplate = @" 28 | DO $$ 29 | BEGIN 30 | IF NOT EXISTS(SELECT 1 FROM {{DatabaseSchemaName}}.__sable_migrations WHERE migration_id = '{{MigrationId}}') THEN 31 | 32 | RAISE NOTICE 'Inserting record for migration with Id = {{MigrationId}}'; 33 | 34 | INSERT INTO {{DatabaseSchemaName}}.__sable_migrations (migration_id, backfilled) 35 | VALUES ('{{MigrationId}}', '{{Backfilled}}'); 36 | END IF; 37 | END $$; 38 | "; 39 | public const string InfrastructureSetupTemplate = @" 40 | -- Generated by Sable on {{Date}} 41 | -- Sable NoIdempotenceWrapper 42 | 43 | CREATE SCHEMA IF NOT EXISTS {{DatabaseSchemaName}}; 44 | 45 | CREATE TABLE IF NOT EXISTS {{DatabaseSchemaName}}.__sable_migrations ( 46 | migration_id character varying(150) NOT NULL, 47 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 48 | backfilled boolean NOT NULL DEFAULT false, 49 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 50 | ); 51 | "; 52 | 53 | public const string NoIdempotenceBlockTemplate = @" 54 | {{Script}} 55 | 56 | DO $$ 57 | BEGIN 58 | IF NOT EXISTS(SELECT 1 FROM {{DatabaseSchemaName}}.__sable_migrations WHERE migration_id = '{{MigrationId}}') THEN 59 | 60 | RAISE NOTICE 'Inserting record for migration with Id = {{MigrationId}}'; 61 | 62 | INSERT INTO {{DatabaseSchemaName}}.__sable_migrations (migration_id, backfilled) 63 | VALUES ('{{MigrationId}}', '0'); 64 | END IF; 65 | END $$; 66 | "; 67 | 68 | public const string BackfillScriptTemplate = @" 69 | -- Generated by Sable on {{Date}} 70 | 71 | BEGIN; 72 | CREATE TABLE IF NOT EXISTS {{DatabaseSchemaName}}.__sable_migrations ( 73 | migration_id character varying(150) NOT NULL, 74 | date_applied timestamp without time zone NOT NULL DEFAULT (now() at time zone 'utc'), 75 | backfilled boolean NOT NULL DEFAULT false, 76 | CONSTRAINT pkey___sable_migrations_migration_id PRIMARY KEY (migration_id) 77 | ); 78 | {{ for migrationInsertionScript in MigrationInsertionScripts }} 79 | 80 | {{migrationInsertionScript}} 81 | {{ end }} 82 | COMMIT; 83 | "; 84 | } 85 | -------------------------------------------------------------------------------- /src/Sable.Cli/TypeRegistrar.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Spectre.Console.Cli; 6 | 7 | namespace Sable.Cli; 8 | 9 | public sealed class TypeRegistrar : ITypeRegistrar 10 | { 11 | private readonly IServiceCollection _serviceCollection; 12 | 13 | public TypeRegistrar(IServiceCollection serviceCollection) 14 | { 15 | _serviceCollection = serviceCollection ?? throw new ArgumentNullException(nameof(serviceCollection)); 16 | } 17 | 18 | public ITypeResolver Build() 19 | { 20 | var serviceProvider = _serviceCollection.BuildServiceProvider(); 21 | return new TypeResolver(serviceProvider); 22 | } 23 | 24 | public void Register(Type serviceType, Type implementationType) 25 | { 26 | _serviceCollection.AddSingleton(serviceType, implementationType); 27 | } 28 | 29 | public void RegisterInstance(Type serviceType, object implementationType) 30 | { 31 | _serviceCollection.AddSingleton(serviceType, implementationType); 32 | } 33 | 34 | public void RegisterLazy(Type serviceType, Func implementationFactory) 35 | { 36 | if (implementationFactory is null) 37 | { 38 | throw new ArgumentNullException(nameof(implementationFactory)); 39 | } 40 | _serviceCollection.AddSingleton(serviceType, (_) => implementationFactory()); 41 | } 42 | 43 | public sealed class TypeResolver : ITypeResolver, IDisposable 44 | { 45 | private readonly IServiceProvider _serviceProvider; 46 | 47 | public TypeResolver(IServiceProvider serviceProvider) 48 | { 49 | _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); 50 | } 51 | 52 | public object Resolve(Type serviceType) 53 | { 54 | return serviceType == null ? null : _serviceProvider.GetService(serviceType); 55 | } 56 | 57 | public void Dispose() 58 | { 59 | if (_serviceProvider is IDisposable disposableServiceProvider) 60 | { 61 | disposableServiceProvider.Dispose(); 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Sable.Cli/Utilities/FileSystemUtilities.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable.Cli.Utilities; 5 | 6 | public static class FileSystemUtilities 7 | { 8 | public static string ResolveProjectDirectory(string projectFilePath) 9 | { 10 | var projectDirectory = string.IsNullOrWhiteSpace(projectFilePath) 11 | ? Directory.GetCurrentDirectory() 12 | : Path.GetDirectoryName(projectFilePath); 13 | return projectDirectory; 14 | } 15 | 16 | public static async Task ResolveDatabaseSchemaName(string projectDirectory, string database) 17 | { 18 | var databaseSchemaFilePath = Path.Combine(projectDirectory, "sable", database, "schema.txt"); 19 | var databaseSchemaName = await File.ReadAllTextAsync(databaseSchemaFilePath); 20 | databaseSchemaName = databaseSchemaName.Trim(); 21 | return databaseSchemaName; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Sable.Cli/Utilities/ValidationUtilities.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Spectre.Console; 5 | 6 | namespace Sable.Cli.Utilities; 7 | 8 | public static class ValidationUtilities 9 | { 10 | public static ValidationResult MigrationsInfrastructureHasBeenInitialized(string projectFilePath, string databaseName) 11 | { 12 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(projectFilePath); 13 | var infrastructureRootPath = Path.Combine(projectDirectory, "sable", databaseName); 14 | var initialized = Directory.Exists(infrastructureRootPath); 15 | return initialized 16 | ? ValidationResult.Success() 17 | : ValidationResult.Error($"The migration infrastructure must be initialized with the 'init' command for the '{databaseName}' database before this command can be run."); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Sable.Cli/containerOptions.upstream.json: -------------------------------------------------------------------------------- 1 | { 2 | "Image": "postgres:15.1", 3 | "PortBindings": [ 4 | { 5 | "HostPort": 5470, 6 | "ContainerPort": 5432 7 | } 8 | ], 9 | "EnvironmentVariables": { 10 | "PGPORT": "5432", 11 | "POSTGRES_DB": "postgres", 12 | "POSTGRES_USER": "postgres", 13 | "POSTGRES_PASSWORD": "postgres" 14 | 15 | }, 16 | "ConnectionString": "Host=localhost;Port=5470;Username=postgres;Password=postgres;Database=postgres" 17 | } -------------------------------------------------------------------------------- /src/Sable/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Marten; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Weasel.Core; 7 | 8 | namespace Sable.Extensions; 9 | 10 | public static class ServiceCollectionExtensions 11 | { 12 | /// 13 | /// Register the default store with support for Sable migration management. 14 | /// 15 | /// Func that will be used to build the options for the store. Takes in the application service provider as input. 16 | /// Service collection. 17 | public static MartenServiceCollectionExtensions.MartenConfigurationExpression AddMartenWithSableSupport( 18 | this IServiceCollection services, Func configure) 19 | { 20 | services.ConfigureMarten(options => 21 | { 22 | var connectionStringOverride = 23 | Environment.GetEnvironmentVariable(SableConstants.ConnectionStringOverride) ?? ""; 24 | if (string.IsNullOrWhiteSpace(connectionStringOverride)) 25 | return; 26 | options.Connection(connectionStringOverride); 27 | options.Advanced.Migrator.TableCreation = CreationStyle.CreateIfNotExists; 28 | }); 29 | var configurationExpression = services.AddMarten(configure); 30 | return configurationExpression; 31 | } 32 | 33 | /// 34 | /// Register the default store with support for Sable migration management. 35 | /// 36 | /// Action that will be used to build the options for the store. 37 | /// Service collection. 38 | public static MartenServiceCollectionExtensions.MartenConfigurationExpression AddMartenWithSableSupport( 39 | this IServiceCollection services, Action configure) 40 | { 41 | services.ConfigureMarten(options => 42 | { 43 | var connectionStringOverride = 44 | Environment.GetEnvironmentVariable(SableConstants.ConnectionStringOverride) ?? ""; 45 | if (string.IsNullOrWhiteSpace(connectionStringOverride)) 46 | return; 47 | options.Connection(connectionStringOverride); 48 | options.Advanced.Migrator.TableCreation = CreationStyle.CreateIfNotExists; 49 | }); 50 | var configurationExpression = services.AddMarten(configure); 51 | return configurationExpression; 52 | } 53 | 54 | /// 55 | /// Register a secondary store with support for Sable migration management. 56 | /// 57 | /// Action that will be used to build the options for the store. 58 | /// Service collection. 59 | public static MartenServiceCollectionExtensions.MartenStoreExpression AddMartenStoreWithSableSupport(this IServiceCollection services, 60 | Action configure) where T : class, IDocumentStore 61 | { 62 | services.ConfigureMarten(options => 63 | { 64 | var connectionStringOverride = 65 | Environment.GetEnvironmentVariable(SableConstants.ConnectionStringOverride) ?? ""; 66 | if (string.IsNullOrWhiteSpace(connectionStringOverride)) 67 | return; 68 | options.Connection(connectionStringOverride); 69 | options.Advanced.Migrator.TableCreation = CreationStyle.CreateIfNotExists; 70 | }); 71 | var configurationExpression = services.AddMartenStore(configure); 72 | return configurationExpression; 73 | } 74 | 75 | /// 76 | /// Register a secondary store with support for Sable migration management. 77 | /// 78 | /// Func that will be used to build the options for the store. Takes in the application service provider as input. 79 | /// Service collection. 80 | public static MartenServiceCollectionExtensions.MartenStoreExpression AddMartenStoreWithSableSupport(this IServiceCollection services, 81 | Func configure) where T : class, IDocumentStore 82 | { 83 | services.ConfigureMarten(options => 84 | { 85 | var connectionStringOverride = 86 | Environment.GetEnvironmentVariable(SableConstants.ConnectionStringOverride) ?? ""; 87 | if (string.IsNullOrWhiteSpace(connectionStringOverride)) 88 | return; 89 | options.Connection(connectionStringOverride); 90 | options.Advanced.Migrator.TableCreation = CreationStyle.CreateIfNotExists; 91 | }); 92 | var configurationExpression = services.AddMartenStore(configure); 93 | return configurationExpression; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Sable/Sable.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Sable/SableConstants.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | namespace Sable; 5 | 6 | public static class SableConstants 7 | { 8 | public const string ConnectionStringOverride = "SABLE_CONNECTION_STRING_OVERRIDE"; 9 | } 10 | -------------------------------------------------------------------------------- /tests/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | runtime; build; native; contentfiles; analyzers; buildtransitive 8 | 9 | 10 | 11 | 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/Sable.Cli.Tests/FileSystemUtilitiesTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using System.Runtime.InteropServices; 5 | using Sable.Cli.Utilities; 6 | using Xunit; 7 | 8 | namespace Sable.Cli.Tests; 9 | 10 | public class FileSystemUtilitiesTests 11 | { 12 | [Fact] 13 | public Task EnsureProjectDirectoryIsResolvedCase1() 14 | { 15 | var projectFilePath = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 16 | ? @"C:\Users\sable\dev\Sable\Sable.csproj" 17 | : "/home/sable/dev/Sable/Sable.csproj"; 18 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(projectFilePath); 19 | var expectedProjectDirectory = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) 20 | ? @"C:\Users\sable\dev\Sable" 21 | : "/home/sable/dev/Sable"; 22 | Assert.Equal(projectDirectory, expectedProjectDirectory); 23 | return Task.CompletedTask; 24 | } 25 | 26 | [Fact] 27 | public Task EnsureProjectDirectoryIsResolvedCase2() 28 | { 29 | var projectDirectory = FileSystemUtilities.ResolveProjectDirectory(null); 30 | var expectedProjectDirectory = Directory.GetCurrentDirectory(); 31 | Assert.Equal(projectDirectory, expectedProjectDirectory); 32 | return Task.CompletedTask; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Sable.Cli.Tests/Sable.Cli.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/Sable.Tests/IOtherDatabase.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Marten; 5 | 6 | namespace Sable.Tests; 7 | 8 | public interface IOtherDatabase : IDocumentStore 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /tests/Sable.Tests/Sable.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/Sable.Tests/ServiceRegistrationTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright 2024 Bloomberg Finance L.P. 2 | // Distributed under the terms of the MIT license. 3 | 4 | using Sable.Extensions; 5 | using Marten; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Weasel.Core; 8 | using Xunit; 9 | 10 | namespace Sable.Tests; 11 | 12 | public class ServiceRegistrationTests 13 | { 14 | [Fact] 15 | public Task EnsureConfigurationIsOverridenCase1() 16 | { 17 | Environment.SetEnvironmentVariable(SableConstants.ConnectionStringOverride, 18 | "Host=localhost;Port=5432;Database=sable;Username=postgres;password=P0stG&e$;SSL Mode=Disable", 19 | EnvironmentVariableTarget.Process); 20 | var services = new ServiceCollection(); 21 | services.AddMartenWithSableSupport(_ => 22 | { 23 | var options = new StoreOptions(); 24 | options.Advanced.Migrator.TableCreation = CreationStyle.DropThenCreate; 25 | return options; 26 | }); 27 | var serviceProvider = services.BuildServiceProvider(); 28 | var storeOptions = serviceProvider.GetRequiredService(); 29 | var actualCreationStyle = storeOptions.Advanced.Migrator.TableCreation; 30 | Assert.Equal(CreationStyle.CreateIfNotExists, actualCreationStyle); 31 | return Task.CompletedTask; 32 | } 33 | 34 | [Fact] 35 | public Task EnsureConfigurationIsOverridenCase2() 36 | { 37 | Environment.SetEnvironmentVariable(SableConstants.ConnectionStringOverride, 38 | "Host=localhost;Port=5432;Database=sable;Username=postgres;password=P0stG&e$;SSL Mode=Disable", 39 | EnvironmentVariableTarget.Process); 40 | var services = new ServiceCollection(); 41 | services.AddMartenWithSableSupport(options => 42 | { 43 | options.Advanced.Migrator.TableCreation = CreationStyle.DropThenCreate; 44 | }); 45 | var serviceProvider = services.BuildServiceProvider(); 46 | var storeOptions = serviceProvider.GetRequiredService(); 47 | var actualCreationStyle = storeOptions.Advanced.Migrator.TableCreation; 48 | Assert.Equal(CreationStyle.CreateIfNotExists, actualCreationStyle); 49 | return Task.CompletedTask; 50 | } 51 | 52 | [Fact] 53 | public Task EnsureConfigurationIsOverridenCase3() 54 | { 55 | Environment.SetEnvironmentVariable(SableConstants.ConnectionStringOverride, 56 | "Host=localhost;Port=5432;Database=sable;Username=postgres;password=P0stG&e$;SSL Mode=Disable", 57 | EnvironmentVariableTarget.Process); 58 | var services = new ServiceCollection(); 59 | services.AddMartenStoreWithSableSupport(_ => 60 | { 61 | var options = new StoreOptions(); 62 | options.Advanced.Migrator.TableCreation = CreationStyle.DropThenCreate; 63 | return options; 64 | }); 65 | var serviceProvider = services.BuildServiceProvider(); 66 | var otherDatabase = serviceProvider.GetRequiredService(); 67 | var actualCreationStyle = ((AdvancedOptions)otherDatabase.Options.Advanced).Migrator.TableCreation; 68 | Assert.Equal(CreationStyle.CreateIfNotExists, actualCreationStyle); 69 | return Task.CompletedTask; 70 | } 71 | 72 | [Fact] 73 | public Task EnsureConfigurationIsOverridenCase4() 74 | { 75 | Environment.SetEnvironmentVariable(SableConstants.ConnectionStringOverride, 76 | "Host=localhost;Port=5432;Database=sable;Username=postgres;password=P0stG&e$;SSL Mode=Disable", 77 | EnvironmentVariableTarget.Process); 78 | var services = new ServiceCollection(); 79 | services.AddMartenStoreWithSableSupport(options => 80 | { 81 | options.Advanced.Migrator.TableCreation = CreationStyle.DropThenCreate; 82 | }); 83 | var serviceProvider = services.BuildServiceProvider(); 84 | var otherDatabase = serviceProvider.GetRequiredService(); 85 | var actualCreationStyle = ((AdvancedOptions)otherDatabase.Options.Advanced).Migrator.TableCreation; 86 | Assert.Equal(CreationStyle.CreateIfNotExists, actualCreationStyle); 87 | return Task.CompletedTask; 88 | } 89 | } 90 | --------------------------------------------------------------------------------