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 |
--------------------------------------------------------------------------------
/_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('
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 |
--------------------------------------------------------------------------------
/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