Skip to content

Commit 9e71281

Browse files
committed
Update database-migrations rule file to PostgreSQL conventions with snake_case naming
1 parent 8b70a6a commit 9e71281

3 files changed

Lines changed: 65 additions & 50 deletions

File tree

Lines changed: 64 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,50 @@
11
---
22
paths: **/Database/Migrations/*.cs
3-
description: Rules for creating database migrations
3+
description: Rules for creating database migrations with PostgreSQL conventions
44
---
55

66
# Database Migrations
77

8-
Guidelines for creating database migrations.
8+
Guidelines for creating database migrations using PostgreSQL conventions with snake_case naming.
99

1010
## Implementation
1111

1212
1. Create migrations manually rather than using Entity Framework tooling:
1313
- Place migrations in `/[scs-name]/Core/Database/Migrations`
1414
- Name migration files with 14-digit timestamp prefix: `YYYYMMDDHHmmss_MigrationName.cs`
15-
- Only implement the `Up` methoddon't create `Down` migration
15+
- Only implement the `Up` method--don't create `Down` migration
1616

1717
2. Follow this strict column ordering in table creation statements:
18-
- `TenantId` (if applicable)
19-
- `Id` (always required)
18+
- `tenant_id` (if applicable)
19+
- `id` (always required)
2020
- Foreign keys (if applicable)
21-
- `CreatedAt` and `ModifiedAt` as non-nullable `datetimeoffset`
21+
- `created_at` and `modified_at` as non-nullable/nullable `timestamptz`
2222
- All other properties in the same order as they appear in the C# Aggregate class
2323

24-
3. Use appropriate SQL Server data types:
25-
- Use `varchar(32)` for strongly typed IDs (ULID is 26 chars + underscore + max 5-char prefix = exactly 32)
26-
- Intelligently deduce varchar vs nvarchar based on property type, validators, enum values, etc.
27-
- Use `datetimeoffset` (default), `datetime2` (timezone agnostic), or `date`—never `datetime`
28-
- Default to `varchar(10)` or `varchar(20)` for enum values
29-
4. Create appropriate constraints and indexes:
30-
- Primary keys: `PK_TableName`
31-
- Foreign keys: `FK_ChildTable_ParentTable_ColumnName`
32-
- Indexes: `IX_TableName_ColumnName`
33-
34-
5. Migrate existing data:
35-
- Use `migrationBuilder.Sql("UPDATE [table] SET [column] = [value] WHERE [condition]")` with care
36-
6. Use standard SQL Server naming conventions:
37-
- Table names should be plural (e.g., `Users`, not `User`)
38-
- Constraint and index names should follow the patterns above
24+
3. Use snake_case naming for everything:
25+
- Table names: plural, lowercase (e.g., `users`, `email_logins`, `stripe_events`)
26+
- Column names: lowercase with underscores (e.g., `tenant_id`, `created_at`, `email_confirmed`)
27+
- C# anonymous type members must also be snake_case (e.g., `tenant_id = table.Column<long>(...)`)
28+
- Constraint names: `pk_table_name`, `fk_child_table_parent_table_column_name`, `ix_table_name_column_name`
29+
30+
4. Use appropriate PostgreSQL data types:
31+
- Use `text` for all string columns--PostgreSQL stores `text`, `varchar`, and `varchar(N)` identically with no performance difference
32+
- Use `timestamptz` for `DateTimeOffset` columns--never `timestamp`, `datetime`, or `datetimeoffset`
33+
- Use `boolean` for bool properties
34+
- Use `integer` for int properties
35+
- Use `bigint` for long properties (e.g., `TenantId`)
36+
- Use `numeric(18,2)` for decimal properties and always add `HasPrecision(18, 2)` in the EF Core configuration
37+
- Use `jsonb` for columns mapped with `OwnsOne(..., b => b.ToJson())`--Npgsql requires `jsonb`, not `text`, for JSON-mapped owned entities
38+
- Enforce length constraints at the application level using FluentValidation, not at the database level
39+
40+
5. Create appropriate constraints and indexes:
41+
- Primary keys: `pk_table_name`
42+
- Foreign keys: `fk_child_table_parent_table_column_name`
43+
- Indexes: `ix_table_name_column_name`
44+
- Filtered indexes use PostgreSQL `WHERE` clause syntax (e.g., `filter: "deleted_at IS NULL"`)
45+
46+
6. Migrate existing data:
47+
- Use `migrationBuilder.Sql("UPDATE table_name SET column_name = value WHERE condition")` with care
3948

4049
## Examples
4150

@@ -49,76 +58,83 @@ public sealed class AddUserPreferences : Migration
4958
protected override void Up(MigrationBuilder migrationBuilder)
5059
{
5160
migrationBuilder.CreateTable(
52-
"UserPreferences",
61+
"user_preferences", // ✅ DO: Use snake_case plural table name
5362
table => new
5463
{
55-
TenantId = table.Column<long>("bigint", nullable: false), // ✅ DO: Add TenantId as first column
56-
Id = table.Column<string>("varchar(32)", nullable: false), // ✅ DO: Make Id varchar(32) by default
57-
UserId = table.Column<string>("varchar(32)", nullable: false), // ✅ DO: Add Foreginkey before CreatedAt/ModifiedAt
58-
CreatedAt = table.Column<DateTimeOffset>("datetimeoffset", nullable: false),
59-
ModifiedAt = table.Column<DateTimeOffset>("datetimeoffset", nullable: true),
60-
Language = table.Column<string>("varchar(10)", nullable: false) // ✅ DO: Use varchar when colum has known values
64+
tenant_id = table.Column<long>("bigint", nullable: false), // ✅ DO: Add tenant_id as first column
65+
id = table.Column<string>("text", nullable: false), // ✅ DO: Use text for all string columns
66+
user_id = table.Column<string>("text", nullable: false), // ✅ DO: Add foreign key before created_at/modified_at
67+
created_at = table.Column<DateTimeOffset>("timestamptz", nullable: false), // ✅ DO: Use timestamptz
68+
modified_at = table.Column<DateTimeOffset>("timestamptz", nullable: true),
69+
language = table.Column<string>("text", nullable: false) // ✅ DO: Use text for string columns
6170
},
6271
constraints: table =>
6372
{
64-
table.PrimaryKey("PK_UserPreferences", x => x.Id);
65-
table.ForeignKey("FK_UserPreferences_Users_UserId", x => x.UserId, "Users", "Id");
73+
table.PrimaryKey("pk_user_preferences", x => x.id); // ✅ DO: Use pk_table_name
74+
table.ForeignKey("fk_user_preferences_users_user_id", x => x.user_id, "users", "id"); // ✅ DO: Use fk_child_parent_column
6675
}
6776
);
6877

69-
migrationBuilder.CreateIndex("IX_UserPreferences_TenantId", "UserPreferences", "TenantId");
70-
migrationBuilder.CreateIndex("IX_UserPreferences_UserId", "UserPreferences", "UserId");
78+
migrationBuilder.CreateIndex("ix_user_preferences_tenant_id", "user_preferences", "tenant_id"); // ✅ DO: Use ix_table_column
79+
migrationBuilder.CreateIndex("ix_user_preferences_user_id", "user_preferences", "user_id");
7180
}
7281
}
7382

74-
// ❌ DON'T: Forget to add the attribute [DbContext(typeof(XxxDbContext))] for the self-contained system
83+
// ❌ DON'T: Forget to add the attribute [DbContext(typeof(XxxDbContext))] for the self-contained system
7584
[Migration("20250507_AddUserPrefs")] // ❌ Missing proper 14-digit timestamp
7685
public class AddUserPrefsMigration : Migration // ❌ Not sealed, incorrect naming, suffixed with Migration
7786
{
7887
protected override void Up(MigrationBuilder migrationBuilder)
7988
{
80-
// Create UserPreferences table // ❌ DON'T: Add comments
8189
migrationBuilder.CreateTable(
82-
"UserPreference", // ❌ DON'T: use singular name for table
90+
"UserPreference", // ❌ DON'T: Use PascalCase or singular name for table
8391
table => new
8492
{
85-
Id = table.Column<string>("varchar(30)", nullable: false), // ❌ DON'T: Use varchar(30) for ULID
86-
Theme = table.Column<string>("varchar(20)", nullable: false), // ❌ DON'T: Add properties before CreatedAt/ModifiedAt
87-
TenantId = table.Column<long>("bigint", nullable: false), //TenantId should be first
88-
CreatedAt = table.Column<DateTimeOffset>("datetimeoffset", nullable: false),
93+
Id = table.Column<string>("varchar(30)", nullable: false), // ❌ DON'T: Use PascalCase column names or varchar(N)--use text instead
94+
Theme = table.Column<string>("varchar(20)", nullable: false), // ❌ DON'T: Add properties before created_at/modified_at or use varchar(N)
95+
TenantId = table.Column<long>("bigint", nullable: false), //tenant_id should be first, and snake_case
96+
CreatedAt = table.Column<DateTimeOffset>("datetimeoffset", nullable: false), // ❌ DON'T: Use SQL Server types
8997
ModifiedAt = table.Column<DateTimeOffset>("datetime", nullable: true), // ❌ DON'T: Use datetime
90-
UserId = table.Column<string>("varchar(32)", nullable: false), // ❌ Foreign key after CreatedAt/ModifiedAt
91-
Language = table.Column<string>("varchar(10)", nullable: false), // ❌ Trailing comma
98+
UserId = table.Column<string>("varchar(32)", nullable: false), // ❌ Foreign key after created_at/modified_at, use text not varchar
9299
},
93100
constraints: table =>
94101
{
95-
table.PrimaryKey("PrimaryKey_UserPreference", i => i.Id); //Incorrect PK naming, variable should be x not i
96-
table.ForeignKey("ForeignKey_UserPreference_User", x => x.UserId, "Users", "Id"); //Incorrect FK naming
102+
table.PrimaryKey("PK_UserPreference", i => i.Id); //PascalCase PK naming, variable should be x not i
103+
table.ForeignKey("FK_UserPreference_User", x => x.UserId, "Users", "Id"); //PascalCase FK naming
97104
}
98105
);
99106
}
100-
107+
101108
protected override void Down(MigrationBuilder migrationBuilder) // ❌ DON'T: Create a down method
102109
{
103110
migrationBuilder.DropTable("UserPreference");
104111
}
105112
}
106113
```
107114

108-
### Example 2 - Determining column sizes from validators
115+
### Example 2 - Filtered indexes and data migrations
116+
117+
```csharp
118+
// ✅ DO: Use PostgreSQL WHERE clause syntax for filtered indexes
119+
migrationBuilder.CreateIndex("ix_users_tenant_id_email", "users", ["tenant_id", "email"], unique: true, filter: "deleted_at IS NULL");
120+
migrationBuilder.CreateIndex("ix_subscriptions_stripe_customer_id", "subscriptions", "stripe_customer_id", unique: true, filter: "stripe_customer_id IS NOT NULL");
121+
122+
// ❌ DON'T: Use SQL Server bracket notation
123+
migrationBuilder.CreateIndex("IX_Users_TenantId_Email", "Users", ["TenantId", "Email"], unique: true, filter: "[DeletedAt] IS NULL");
124+
```
109125

110126
```csharp
127+
// ✅ DO: Use text for string columns, enforce length in validators
111128
public sealed class UpdateUserValidator : AbstractValidator<UpdateUserCommand>
112129
{
113130
public UpdateUserValidator()
114131
{
115-
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(50); // ✅ DO: Use column sizes based on command validators
132+
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(50);
116133
}
117134
}
118135

119136
protected override void Up(MigrationBuilder migrationBuilder)
120137
{
121-
migrationBuilder.AddColumn<string>("TimeZone", "Users", "varchar(50)", nullable: false, defaultValue: "UTC"); // ✅ DO: Match column size to validator
122-
// ✅ DO: Consider running complex logic here to update existing records
138+
migrationBuilder.AddColumn<string>("time_zone", "users", "text", nullable: false, defaultValue: "UTC");
123139
}
124140
```

.claude/rules/backend/domain-modeling.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@ public sealed class InvoiceConfiguration : IEntityTypeConfiguration<Invoice>
134134
135135
// ✅ DO: Map collection with custom JsonSerializer
136136
builder.Property(i => i.InvoiceLines)
137-
.HasColumnName("InvoiceLines")
138137
.HasConversion(
139138
v => JsonSerializer.Serialize(v.ToArray(), JsonSerializerOptions),
140139
v => JsonSerializer.Deserialize<ImmutableArray<InvoiceLine>>(v, JsonSerializerOptions)

.claude/rules/backend/strongly-typed-ids.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Guidelines for implementing strongly typed IDs in the backend, covering type saf
1111

1212
1. Use strongly typed IDs to provide type safety and prevent mixing different ID types, improving readability and maintainability
1313
2. By default, use `StronglyTypedUlid<T>` as the base class—it provides chronological ordering and includes a prefix for easy recognition (e.g., `usr_01JMVAW4T4320KJ3A7EJMCG8R0`)
14-
3. Use the `[IdPrefix]` attribute with a short prefix (max 5 characters)—ULIDs are 26 chars, plus 5-char prefix and underscore = 32 chars for varchar(32)
14+
3. Use the `[IdPrefix]` attribute with a short prefix (max 5 characters)—ULIDs are 26 chars, plus 5-char prefix and underscore = 32 chars max
1515
4. Follow the naming convention `[Entity]Id`
1616
5. Include the `[JsonConverter]` attribute for proper serialization
1717
6. Always override `ToString()` in the concrete class—record types don't inherit this from the base class

0 commit comments

Comments
 (0)