autoworkflow 3.1.4 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/analyze.md +19 -0
- package/.claude/commands/audit.md +174 -11
- package/.claude/commands/build.md +39 -0
- package/.claude/commands/commit.md +25 -0
- package/.claude/commands/fix.md +23 -0
- package/.claude/commands/plan.md +18 -0
- package/.claude/commands/suggest.md +23 -0
- package/.claude/commands/verify.md +18 -0
- package/.claude/hooks/post-bash-router.sh +20 -0
- package/.claude/hooks/post-commit.sh +140 -0
- package/.claude/hooks/pre-edit.sh +129 -0
- package/.claude/hooks/session-check.sh +79 -0
- package/.claude/settings.json +40 -6
- package/.claude/settings.local.json +3 -1
- package/.claude/skills/actix.md +337 -0
- package/.claude/skills/alembic.md +504 -0
- package/.claude/skills/angular.md +237 -0
- package/.claude/skills/api-design.md +187 -0
- package/.claude/skills/aspnet-core.md +377 -0
- package/.claude/skills/astro.md +245 -0
- package/.claude/skills/auth-clerk.md +327 -0
- package/.claude/skills/auth-firebase.md +367 -0
- package/.claude/skills/auth-nextauth.md +359 -0
- package/.claude/skills/auth-supabase.md +368 -0
- package/.claude/skills/axum.md +386 -0
- package/.claude/skills/blazor.md +456 -0
- package/.claude/skills/chi.md +348 -0
- package/.claude/skills/code-review.md +133 -0
- package/.claude/skills/csharp.md +296 -0
- package/.claude/skills/css-modules.md +325 -0
- package/.claude/skills/cypress.md +343 -0
- package/.claude/skills/debugging.md +133 -0
- package/.claude/skills/diesel.md +392 -0
- package/.claude/skills/django.md +301 -0
- package/.claude/skills/docker.md +319 -0
- package/.claude/skills/doctrine.md +473 -0
- package/.claude/skills/documentation.md +182 -0
- package/.claude/skills/dotnet.md +409 -0
- package/.claude/skills/drizzle.md +293 -0
- package/.claude/skills/echo.md +321 -0
- package/.claude/skills/eloquent.md +256 -0
- package/.claude/skills/emotion.md +426 -0
- package/.claude/skills/entity-framework.md +370 -0
- package/.claude/skills/express.md +316 -0
- package/.claude/skills/fastapi.md +329 -0
- package/.claude/skills/fastify.md +299 -0
- package/.claude/skills/fiber.md +315 -0
- package/.claude/skills/flask.md +322 -0
- package/.claude/skills/gin.md +342 -0
- package/.claude/skills/git.md +116 -0
- package/.claude/skills/github-actions.md +353 -0
- package/.claude/skills/go.md +377 -0
- package/.claude/skills/gorm.md +409 -0
- package/.claude/skills/graphql.md +478 -0
- package/.claude/skills/hibernate.md +379 -0
- package/.claude/skills/hono.md +306 -0
- package/.claude/skills/java.md +400 -0
- package/.claude/skills/jest.md +313 -0
- package/.claude/skills/jpa.md +282 -0
- package/.claude/skills/kotlin.md +347 -0
- package/.claude/skills/kubernetes.md +363 -0
- package/.claude/skills/laravel.md +414 -0
- package/.claude/skills/mcp-browser.md +320 -0
- package/.claude/skills/mcp-database.md +219 -0
- package/.claude/skills/mcp-fetch.md +241 -0
- package/.claude/skills/mcp-filesystem.md +204 -0
- package/.claude/skills/mcp-github.md +217 -0
- package/.claude/skills/mcp-memory.md +240 -0
- package/.claude/skills/mcp-search.md +218 -0
- package/.claude/skills/mcp-slack.md +262 -0
- package/.claude/skills/micronaut.md +388 -0
- package/.claude/skills/mongodb.md +319 -0
- package/.claude/skills/mongoose.md +355 -0
- package/.claude/skills/mysql.md +281 -0
- package/.claude/skills/nestjs.md +335 -0
- package/.claude/skills/nextjs-app-router.md +260 -0
- package/.claude/skills/nextjs-pages.md +172 -0
- package/.claude/skills/nuxt.md +202 -0
- package/.claude/skills/openapi.md +489 -0
- package/.claude/skills/performance.md +199 -0
- package/.claude/skills/php.md +398 -0
- package/.claude/skills/playwright.md +371 -0
- package/.claude/skills/postgresql.md +257 -0
- package/.claude/skills/prisma.md +293 -0
- package/.claude/skills/pydantic.md +304 -0
- package/.claude/skills/pytest.md +313 -0
- package/.claude/skills/python.md +272 -0
- package/.claude/skills/quarkus.md +377 -0
- package/.claude/skills/react.md +230 -0
- package/.claude/skills/redis.md +391 -0
- package/.claude/skills/refactoring.md +143 -0
- package/.claude/skills/remix.md +246 -0
- package/.claude/skills/rest-api.md +490 -0
- package/.claude/skills/rocket.md +366 -0
- package/.claude/skills/rust.md +341 -0
- package/.claude/skills/sass.md +380 -0
- package/.claude/skills/sea-orm.md +382 -0
- package/.claude/skills/security.md +167 -0
- package/.claude/skills/sequelize.md +395 -0
- package/.claude/skills/spring-boot.md +416 -0
- package/.claude/skills/sqlalchemy.md +269 -0
- package/.claude/skills/sqlx-rust.md +408 -0
- package/.claude/skills/state-jotai.md +346 -0
- package/.claude/skills/state-mobx.md +353 -0
- package/.claude/skills/state-pinia.md +431 -0
- package/.claude/skills/state-redux.md +337 -0
- package/.claude/skills/state-tanstack-query.md +434 -0
- package/.claude/skills/state-zustand.md +340 -0
- package/.claude/skills/styled-components.md +403 -0
- package/.claude/skills/svelte.md +238 -0
- package/.claude/skills/sveltekit.md +207 -0
- package/.claude/skills/symfony.md +437 -0
- package/.claude/skills/tailwind.md +279 -0
- package/.claude/skills/terraform.md +394 -0
- package/.claude/skills/testing-library.md +371 -0
- package/.claude/skills/trpc.md +426 -0
- package/.claude/skills/typeorm.md +368 -0
- package/.claude/skills/vitest.md +330 -0
- package/.claude/skills/vue.md +202 -0
- package/.claude/skills/warp.md +365 -0
- package/README.md +135 -52
- package/package.json +1 -1
- package/system/triggers.md +152 -11
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# .NET Skill
|
|
2
|
+
|
|
3
|
+
## Clean Architecture Structure
|
|
4
|
+
\`\`\`
|
|
5
|
+
src/
|
|
6
|
+
├── MyApp.Api/ # Presentation layer (Controllers, Middleware)
|
|
7
|
+
│ ├── Controllers/
|
|
8
|
+
│ ├── Middleware/
|
|
9
|
+
│ └── Program.cs
|
|
10
|
+
├── MyApp.Application/ # Application layer (Use cases, DTOs)
|
|
11
|
+
│ ├── Users/
|
|
12
|
+
│ │ ├── Commands/
|
|
13
|
+
│ │ ├── Queries/
|
|
14
|
+
│ │ └── DTOs/
|
|
15
|
+
│ └── Common/
|
|
16
|
+
│ ├── Interfaces/
|
|
17
|
+
│ └── Behaviors/
|
|
18
|
+
├── MyApp.Domain/ # Domain layer (Entities, Value Objects)
|
|
19
|
+
│ ├── Entities/
|
|
20
|
+
│ ├── ValueObjects/
|
|
21
|
+
│ ├── Enums/
|
|
22
|
+
│ └── Events/
|
|
23
|
+
├── MyApp.Infrastructure/ # Infrastructure (EF, External Services)
|
|
24
|
+
│ ├── Persistence/
|
|
25
|
+
│ ├── Services/
|
|
26
|
+
│ └── DependencyInjection.cs
|
|
27
|
+
tests/
|
|
28
|
+
├── MyApp.UnitTests/
|
|
29
|
+
├── MyApp.IntegrationTests/
|
|
30
|
+
└── MyApp.ArchitectureTests/
|
|
31
|
+
\`\`\`
|
|
32
|
+
|
|
33
|
+
## Dependency Injection
|
|
34
|
+
\`\`\`csharp
|
|
35
|
+
// Register services by convention
|
|
36
|
+
public static class DependencyInjection
|
|
37
|
+
{
|
|
38
|
+
public static IServiceCollection AddApplication(this IServiceCollection services)
|
|
39
|
+
{
|
|
40
|
+
// Register all services implementing interface
|
|
41
|
+
services.Scan(scan => scan
|
|
42
|
+
.FromAssemblyOf<IUserService>()
|
|
43
|
+
.AddClasses(classes => classes.AssignableTo(typeof(IRequestHandler<,>)))
|
|
44
|
+
.AsImplementedInterfaces()
|
|
45
|
+
.WithScopedLifetime());
|
|
46
|
+
|
|
47
|
+
// MediatR
|
|
48
|
+
services.AddMediatR(cfg =>
|
|
49
|
+
cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
|
50
|
+
|
|
51
|
+
// FluentValidation
|
|
52
|
+
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
|
|
53
|
+
|
|
54
|
+
return services;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public static IServiceCollection AddInfrastructure(
|
|
58
|
+
this IServiceCollection services,
|
|
59
|
+
IConfiguration configuration)
|
|
60
|
+
{
|
|
61
|
+
// Database
|
|
62
|
+
services.AddDbContext<AppDbContext>(options =>
|
|
63
|
+
options.UseNpgsql(configuration.GetConnectionString("Default")));
|
|
64
|
+
|
|
65
|
+
// Repositories
|
|
66
|
+
services.AddScoped<IUserRepository, UserRepository>();
|
|
67
|
+
|
|
68
|
+
// External services
|
|
69
|
+
services.AddHttpClient<IEmailService, EmailService>(client =>
|
|
70
|
+
{
|
|
71
|
+
client.BaseAddress = new Uri(configuration["Email:BaseUrl"]!);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return services;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Program.cs
|
|
79
|
+
var builder = WebApplication.CreateBuilder(args);
|
|
80
|
+
|
|
81
|
+
builder.Services
|
|
82
|
+
.AddApplication()
|
|
83
|
+
.AddInfrastructure(builder.Configuration);
|
|
84
|
+
\`\`\`
|
|
85
|
+
|
|
86
|
+
## Configuration with Options Pattern
|
|
87
|
+
\`\`\`csharp
|
|
88
|
+
// Settings classes
|
|
89
|
+
public class DatabaseSettings
|
|
90
|
+
{
|
|
91
|
+
public const string SectionName = "Database";
|
|
92
|
+
|
|
93
|
+
public required string ConnectionString { get; init; }
|
|
94
|
+
public int MaxRetryCount { get; init; } = 3;
|
|
95
|
+
public int CommandTimeout { get; init; } = 30;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public class JwtSettings
|
|
99
|
+
{
|
|
100
|
+
public const string SectionName = "Jwt";
|
|
101
|
+
|
|
102
|
+
public required string Secret { get; init; }
|
|
103
|
+
public required string Issuer { get; init; }
|
|
104
|
+
public required string Audience { get; init; }
|
|
105
|
+
public int ExpirationMinutes { get; init; } = 60;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Registration with validation
|
|
109
|
+
services.AddOptions<JwtSettings>()
|
|
110
|
+
.Bind(configuration.GetSection(JwtSettings.SectionName))
|
|
111
|
+
.ValidateDataAnnotations()
|
|
112
|
+
.ValidateOnStart();
|
|
113
|
+
|
|
114
|
+
// Usage
|
|
115
|
+
public class TokenService
|
|
116
|
+
{
|
|
117
|
+
private readonly JwtSettings _settings;
|
|
118
|
+
|
|
119
|
+
public TokenService(IOptions<JwtSettings> options)
|
|
120
|
+
{
|
|
121
|
+
_settings = options.Value;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Or for settings that can change at runtime
|
|
125
|
+
public TokenService(IOptionsMonitor<JwtSettings> options)
|
|
126
|
+
{
|
|
127
|
+
options.OnChange(settings => _settings = settings);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
\`\`\`
|
|
131
|
+
|
|
132
|
+
## Result Pattern (No Exceptions for Expected Failures)
|
|
133
|
+
\`\`\`csharp
|
|
134
|
+
// Result type
|
|
135
|
+
public class Result<T>
|
|
136
|
+
{
|
|
137
|
+
public bool IsSuccess { get; }
|
|
138
|
+
public T? Value { get; }
|
|
139
|
+
public Error? Error { get; }
|
|
140
|
+
|
|
141
|
+
private Result(T value)
|
|
142
|
+
{
|
|
143
|
+
IsSuccess = true;
|
|
144
|
+
Value = value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private Result(Error error)
|
|
148
|
+
{
|
|
149
|
+
IsSuccess = false;
|
|
150
|
+
Error = error;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public static Result<T> Success(T value) => new(value);
|
|
154
|
+
public static Result<T> Failure(Error error) => new(error);
|
|
155
|
+
|
|
156
|
+
public TResult Match<TResult>(
|
|
157
|
+
Func<T, TResult> onSuccess,
|
|
158
|
+
Func<Error, TResult> onFailure) =>
|
|
159
|
+
IsSuccess ? onSuccess(Value!) : onFailure(Error!);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public record Error(string Code, string Message);
|
|
163
|
+
|
|
164
|
+
public static class Errors
|
|
165
|
+
{
|
|
166
|
+
public static class User
|
|
167
|
+
{
|
|
168
|
+
public static Error NotFound(string id) =>
|
|
169
|
+
new("User.NotFound", $"User with id '{id}' not found");
|
|
170
|
+
|
|
171
|
+
public static Error EmailTaken(string email) =>
|
|
172
|
+
new("User.EmailTaken", $"Email '{email}' is already in use");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Usage in service
|
|
177
|
+
public async Task<Result<UserResponse>> GetUserAsync(string id, CancellationToken ct)
|
|
178
|
+
{
|
|
179
|
+
var user = await _repository.GetByIdAsync(id, ct);
|
|
180
|
+
|
|
181
|
+
if (user is null)
|
|
182
|
+
return Result<UserResponse>.Failure(Errors.User.NotFound(id));
|
|
183
|
+
|
|
184
|
+
return Result<UserResponse>.Success(UserResponse.From(user));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Usage in controller
|
|
188
|
+
[HttpGet("{id}")]
|
|
189
|
+
public async Task<IActionResult> GetUser(string id, CancellationToken ct)
|
|
190
|
+
{
|
|
191
|
+
var result = await _userService.GetUserAsync(id, ct);
|
|
192
|
+
|
|
193
|
+
return result.Match<IActionResult>(
|
|
194
|
+
onSuccess: user => Ok(user),
|
|
195
|
+
onFailure: error => error.Code switch
|
|
196
|
+
{
|
|
197
|
+
"User.NotFound" => NotFound(error),
|
|
198
|
+
_ => BadRequest(error)
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
\`\`\`
|
|
202
|
+
|
|
203
|
+
## MediatR with CQRS
|
|
204
|
+
\`\`\`csharp
|
|
205
|
+
// Query
|
|
206
|
+
public record GetUserQuery(string Id) : IRequest<Result<UserResponse>>;
|
|
207
|
+
|
|
208
|
+
public class GetUserQueryHandler : IRequestHandler<GetUserQuery, Result<UserResponse>>
|
|
209
|
+
{
|
|
210
|
+
private readonly IUserRepository _repository;
|
|
211
|
+
|
|
212
|
+
public GetUserQueryHandler(IUserRepository repository)
|
|
213
|
+
{
|
|
214
|
+
_repository = repository;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public async Task<Result<UserResponse>> Handle(
|
|
218
|
+
GetUserQuery request,
|
|
219
|
+
CancellationToken ct)
|
|
220
|
+
{
|
|
221
|
+
var user = await _repository.GetByIdAsync(request.Id, ct);
|
|
222
|
+
|
|
223
|
+
if (user is null)
|
|
224
|
+
return Result<UserResponse>.Failure(Errors.User.NotFound(request.Id));
|
|
225
|
+
|
|
226
|
+
return Result<UserResponse>.Success(UserResponse.From(user));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Command
|
|
231
|
+
public record CreateUserCommand(string Email, string Name, string Password)
|
|
232
|
+
: IRequest<Result<UserResponse>>;
|
|
233
|
+
|
|
234
|
+
public class CreateUserCommandHandler : IRequestHandler<CreateUserCommand, Result<UserResponse>>
|
|
235
|
+
{
|
|
236
|
+
private readonly IUserRepository _repository;
|
|
237
|
+
private readonly IPasswordHasher _hasher;
|
|
238
|
+
|
|
239
|
+
public CreateUserCommandHandler(IUserRepository repository, IPasswordHasher hasher)
|
|
240
|
+
{
|
|
241
|
+
_repository = repository;
|
|
242
|
+
_hasher = hasher;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
public async Task<Result<UserResponse>> Handle(
|
|
246
|
+
CreateUserCommand request,
|
|
247
|
+
CancellationToken ct)
|
|
248
|
+
{
|
|
249
|
+
if (await _repository.ExistsByEmailAsync(request.Email, ct))
|
|
250
|
+
return Result<UserResponse>.Failure(Errors.User.EmailTaken(request.Email));
|
|
251
|
+
|
|
252
|
+
var user = new User
|
|
253
|
+
{
|
|
254
|
+
Email = request.Email,
|
|
255
|
+
Name = request.Name,
|
|
256
|
+
PasswordHash = _hasher.Hash(request.Password)
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
await _repository.AddAsync(user, ct);
|
|
260
|
+
|
|
261
|
+
return Result<UserResponse>.Success(UserResponse.From(user));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Controller usage
|
|
266
|
+
[HttpPost]
|
|
267
|
+
public async Task<IActionResult> CreateUser(
|
|
268
|
+
CreateUserRequest request,
|
|
269
|
+
CancellationToken ct)
|
|
270
|
+
{
|
|
271
|
+
var command = new CreateUserCommand(request.Email, request.Name, request.Password);
|
|
272
|
+
var result = await _mediator.Send(command, ct);
|
|
273
|
+
|
|
274
|
+
return result.Match<IActionResult>(
|
|
275
|
+
onSuccess: user => CreatedAtAction(nameof(GetUser), new { id = user.Id }, user),
|
|
276
|
+
onFailure: error => BadRequest(error));
|
|
277
|
+
}
|
|
278
|
+
\`\`\`
|
|
279
|
+
|
|
280
|
+
## Pipeline Behaviors (Cross-Cutting Concerns)
|
|
281
|
+
\`\`\`csharp
|
|
282
|
+
// Validation behavior
|
|
283
|
+
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
|
284
|
+
where TRequest : IRequest<TResponse>
|
|
285
|
+
{
|
|
286
|
+
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
|
287
|
+
|
|
288
|
+
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
|
289
|
+
{
|
|
290
|
+
_validators = validators;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
public async Task<TResponse> Handle(
|
|
294
|
+
TRequest request,
|
|
295
|
+
RequestHandlerDelegate<TResponse> next,
|
|
296
|
+
CancellationToken ct)
|
|
297
|
+
{
|
|
298
|
+
if (!_validators.Any())
|
|
299
|
+
return await next();
|
|
300
|
+
|
|
301
|
+
var context = new ValidationContext<TRequest>(request);
|
|
302
|
+
var failures = _validators
|
|
303
|
+
.Select(v => v.Validate(context))
|
|
304
|
+
.SelectMany(r => r.Errors)
|
|
305
|
+
.Where(f => f is not null)
|
|
306
|
+
.ToList();
|
|
307
|
+
|
|
308
|
+
if (failures.Any())
|
|
309
|
+
throw new ValidationException(failures);
|
|
310
|
+
|
|
311
|
+
return await next();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Logging behavior
|
|
316
|
+
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
|
317
|
+
where TRequest : IRequest<TResponse>
|
|
318
|
+
{
|
|
319
|
+
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
|
|
320
|
+
|
|
321
|
+
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
|
322
|
+
{
|
|
323
|
+
_logger = logger;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
public async Task<TResponse> Handle(
|
|
327
|
+
TRequest request,
|
|
328
|
+
RequestHandlerDelegate<TResponse> next,
|
|
329
|
+
CancellationToken ct)
|
|
330
|
+
{
|
|
331
|
+
var requestName = typeof(TRequest).Name;
|
|
332
|
+
_logger.LogInformation("Handling {RequestName}", requestName);
|
|
333
|
+
|
|
334
|
+
var stopwatch = Stopwatch.StartNew();
|
|
335
|
+
var response = await next();
|
|
336
|
+
stopwatch.Stop();
|
|
337
|
+
|
|
338
|
+
_logger.LogInformation("Handled {RequestName} in {ElapsedMs}ms",
|
|
339
|
+
requestName, stopwatch.ElapsedMilliseconds);
|
|
340
|
+
|
|
341
|
+
return response;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Registration
|
|
346
|
+
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
|
|
347
|
+
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
|
|
348
|
+
\`\`\`
|
|
349
|
+
|
|
350
|
+
## Testing
|
|
351
|
+
\`\`\`csharp
|
|
352
|
+
public class GetUserQueryHandlerTests
|
|
353
|
+
{
|
|
354
|
+
private readonly Mock<IUserRepository> _repositoryMock;
|
|
355
|
+
private readonly GetUserQueryHandler _handler;
|
|
356
|
+
|
|
357
|
+
public GetUserQueryHandlerTests()
|
|
358
|
+
{
|
|
359
|
+
_repositoryMock = new Mock<IUserRepository>();
|
|
360
|
+
_handler = new GetUserQueryHandler(_repositoryMock.Object);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
[Fact]
|
|
364
|
+
public async Task Handle_WhenUserExists_ReturnsSuccess()
|
|
365
|
+
{
|
|
366
|
+
// Arrange
|
|
367
|
+
var user = new User { Id = "1", Email = "test@example.com", Name = "Test" };
|
|
368
|
+
_repositoryMock.Setup(r => r.GetByIdAsync("1", It.IsAny<CancellationToken>()))
|
|
369
|
+
.ReturnsAsync(user);
|
|
370
|
+
|
|
371
|
+
// Act
|
|
372
|
+
var result = await _handler.Handle(new GetUserQuery("1"), CancellationToken.None);
|
|
373
|
+
|
|
374
|
+
// Assert
|
|
375
|
+
result.IsSuccess.Should().BeTrue();
|
|
376
|
+
result.Value!.Email.Should().Be("test@example.com");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
[Fact]
|
|
380
|
+
public async Task Handle_WhenUserNotExists_ReturnsFailure()
|
|
381
|
+
{
|
|
382
|
+
// Arrange
|
|
383
|
+
_repositoryMock.Setup(r => r.GetByIdAsync("999", It.IsAny<CancellationToken>()))
|
|
384
|
+
.ReturnsAsync((User?)null);
|
|
385
|
+
|
|
386
|
+
// Act
|
|
387
|
+
var result = await _handler.Handle(new GetUserQuery("999"), CancellationToken.None);
|
|
388
|
+
|
|
389
|
+
// Assert
|
|
390
|
+
result.IsSuccess.Should().BeFalse();
|
|
391
|
+
result.Error!.Code.Should().Be("User.NotFound");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
\`\`\`
|
|
395
|
+
|
|
396
|
+
## ✅ DO
|
|
397
|
+
- Use Clean Architecture / Vertical Slice Architecture
|
|
398
|
+
- Use dependency injection for all dependencies
|
|
399
|
+
- Use IOptions<T> for configuration
|
|
400
|
+
- Use Result pattern instead of exceptions for expected failures
|
|
401
|
+
- Use MediatR for CQRS (optional but recommended)
|
|
402
|
+
- Use pipeline behaviors for cross-cutting concerns
|
|
403
|
+
|
|
404
|
+
## ❌ DON'T
|
|
405
|
+
- Don't inject IConfiguration directly
|
|
406
|
+
- Don't use static classes for business logic
|
|
407
|
+
- Don't throw exceptions for validation errors
|
|
408
|
+
- Don't put business logic in controllers
|
|
409
|
+
- Don't reference Infrastructure from Domain
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Drizzle ORM Skill
|
|
2
|
+
|
|
3
|
+
## Setup & Configuration
|
|
4
|
+
\`\`\`typescript
|
|
5
|
+
// drizzle.config.ts
|
|
6
|
+
import type { Config } from 'drizzle-kit';
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
schema: './src/db/schema.ts',
|
|
10
|
+
out: './drizzle',
|
|
11
|
+
driver: 'pg',
|
|
12
|
+
dbCredentials: {
|
|
13
|
+
connectionString: process.env.DATABASE_URL!,
|
|
14
|
+
},
|
|
15
|
+
} satisfies Config;
|
|
16
|
+
|
|
17
|
+
// src/db/index.ts
|
|
18
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
19
|
+
import postgres from 'postgres';
|
|
20
|
+
import * as schema from './schema';
|
|
21
|
+
|
|
22
|
+
const client = postgres(process.env.DATABASE_URL!);
|
|
23
|
+
export const db = drizzle(client, { schema });
|
|
24
|
+
\`\`\`
|
|
25
|
+
|
|
26
|
+
## Schema Definition
|
|
27
|
+
\`\`\`typescript
|
|
28
|
+
// src/db/schema.ts
|
|
29
|
+
import {
|
|
30
|
+
pgTable, uuid, text, timestamp, boolean, integer,
|
|
31
|
+
pgEnum, primaryKey, index, uniqueIndex
|
|
32
|
+
} from 'drizzle-orm/pg-core';
|
|
33
|
+
import { relations } from 'drizzle-orm';
|
|
34
|
+
|
|
35
|
+
// Enums
|
|
36
|
+
export const roleEnum = pgEnum('role', ['user', 'admin', 'moderator']);
|
|
37
|
+
|
|
38
|
+
// Users table
|
|
39
|
+
export const users = pgTable('users', {
|
|
40
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
41
|
+
email: text('email').notNull().unique(),
|
|
42
|
+
name: text('name'),
|
|
43
|
+
role: roleEnum('role').default('user').notNull(),
|
|
44
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
45
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
46
|
+
deletedAt: timestamp('deleted_at'),
|
|
47
|
+
}, (table) => ({
|
|
48
|
+
emailIdx: uniqueIndex('email_idx').on(table.email),
|
|
49
|
+
roleIdx: index('role_idx').on(table.role),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Posts table
|
|
53
|
+
export const posts = pgTable('posts', {
|
|
54
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
55
|
+
title: text('title').notNull(),
|
|
56
|
+
content: text('content'),
|
|
57
|
+
published: boolean('published').default(false).notNull(),
|
|
58
|
+
authorId: uuid('author_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
|
59
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
60
|
+
}, (table) => ({
|
|
61
|
+
authorIdx: index('author_idx').on(table.authorId),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
// Categories with many-to-many
|
|
65
|
+
export const categories = pgTable('categories', {
|
|
66
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
67
|
+
name: text('name').notNull().unique(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const postsToCategories = pgTable('posts_to_categories', {
|
|
71
|
+
postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
|
|
72
|
+
categoryId: uuid('category_id').notNull().references(() => categories.id, { onDelete: 'cascade' }),
|
|
73
|
+
}, (table) => ({
|
|
74
|
+
pk: primaryKey({ columns: [table.postId, table.categoryId] }),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
// Relations (for query builder)
|
|
78
|
+
export const usersRelations = relations(users, ({ many }) => ({
|
|
79
|
+
posts: many(posts),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
export const postsRelations = relations(posts, ({ one, many }) => ({
|
|
83
|
+
author: one(users, {
|
|
84
|
+
fields: [posts.authorId],
|
|
85
|
+
references: [users.id],
|
|
86
|
+
}),
|
|
87
|
+
categories: many(postsToCategories),
|
|
88
|
+
}));
|
|
89
|
+
\`\`\`
|
|
90
|
+
|
|
91
|
+
## Type Inference
|
|
92
|
+
\`\`\`typescript
|
|
93
|
+
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
|
94
|
+
import { users, posts } from './schema';
|
|
95
|
+
|
|
96
|
+
// Infer types from schema
|
|
97
|
+
export type User = InferSelectModel<typeof users>;
|
|
98
|
+
export type NewUser = InferInsertModel<typeof users>;
|
|
99
|
+
export type Post = InferSelectModel<typeof posts>;
|
|
100
|
+
export type NewPost = InferInsertModel<typeof posts>;
|
|
101
|
+
|
|
102
|
+
// With relations
|
|
103
|
+
export type UserWithPosts = User & { posts: Post[] };
|
|
104
|
+
\`\`\`
|
|
105
|
+
|
|
106
|
+
## Query Patterns
|
|
107
|
+
\`\`\`typescript
|
|
108
|
+
import { eq, and, or, gt, lt, like, isNull, sql, desc, asc } from 'drizzle-orm';
|
|
109
|
+
|
|
110
|
+
// Basic CRUD
|
|
111
|
+
// Create
|
|
112
|
+
const [newUser] = await db.insert(users)
|
|
113
|
+
.values({ email: 'user@example.com', name: 'John' })
|
|
114
|
+
.returning();
|
|
115
|
+
|
|
116
|
+
// Read
|
|
117
|
+
const allUsers = await db.select().from(users);
|
|
118
|
+
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
|
119
|
+
|
|
120
|
+
// Update
|
|
121
|
+
const [updated] = await db.update(users)
|
|
122
|
+
.set({ name: 'Jane', updatedAt: new Date() })
|
|
123
|
+
.where(eq(users.id, userId))
|
|
124
|
+
.returning();
|
|
125
|
+
|
|
126
|
+
// Delete
|
|
127
|
+
await db.delete(users).where(eq(users.id, userId));
|
|
128
|
+
|
|
129
|
+
// Complex filters
|
|
130
|
+
const filteredUsers = await db.select()
|
|
131
|
+
.from(users)
|
|
132
|
+
.where(
|
|
133
|
+
and(
|
|
134
|
+
eq(users.role, 'user'),
|
|
135
|
+
isNull(users.deletedAt),
|
|
136
|
+
or(
|
|
137
|
+
like(users.name, '%John%'),
|
|
138
|
+
like(users.email, '%@company.com')
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
.orderBy(desc(users.createdAt))
|
|
143
|
+
.limit(20)
|
|
144
|
+
.offset(0);
|
|
145
|
+
|
|
146
|
+
// Select specific columns
|
|
147
|
+
const emails = await db.select({
|
|
148
|
+
id: users.id,
|
|
149
|
+
email: users.email,
|
|
150
|
+
}).from(users);
|
|
151
|
+
|
|
152
|
+
// Aggregations
|
|
153
|
+
const [{ count }] = await db.select({
|
|
154
|
+
count: sql<number>\`count(*)\`::int,
|
|
155
|
+
}).from(users);
|
|
156
|
+
|
|
157
|
+
const stats = await db.select({
|
|
158
|
+
role: users.role,
|
|
159
|
+
count: sql<number>\`count(*)\`::int,
|
|
160
|
+
}).from(users).groupBy(users.role);
|
|
161
|
+
\`\`\`
|
|
162
|
+
|
|
163
|
+
## Joins & Relations
|
|
164
|
+
\`\`\`typescript
|
|
165
|
+
// Manual joins
|
|
166
|
+
const usersWithPosts = await db.select({
|
|
167
|
+
user: users,
|
|
168
|
+
post: posts,
|
|
169
|
+
})
|
|
170
|
+
.from(users)
|
|
171
|
+
.leftJoin(posts, eq(users.id, posts.authorId))
|
|
172
|
+
.where(eq(posts.published, true));
|
|
173
|
+
|
|
174
|
+
// Inner join
|
|
175
|
+
const authoredPosts = await db.select()
|
|
176
|
+
.from(posts)
|
|
177
|
+
.innerJoin(users, eq(posts.authorId, users.id));
|
|
178
|
+
|
|
179
|
+
// Using relational queries (requires relations defined)
|
|
180
|
+
const usersWithRelations = await db.query.users.findMany({
|
|
181
|
+
with: {
|
|
182
|
+
posts: {
|
|
183
|
+
where: eq(posts.published, true),
|
|
184
|
+
orderBy: desc(posts.createdAt),
|
|
185
|
+
limit: 5,
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
where: isNull(users.deletedAt),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Find first with relations
|
|
192
|
+
const user = await db.query.users.findFirst({
|
|
193
|
+
where: eq(users.id, userId),
|
|
194
|
+
with: { posts: true },
|
|
195
|
+
});
|
|
196
|
+
\`\`\`
|
|
197
|
+
|
|
198
|
+
## Transactions
|
|
199
|
+
\`\`\`typescript
|
|
200
|
+
// Basic transaction
|
|
201
|
+
const result = await db.transaction(async (tx) => {
|
|
202
|
+
const [user] = await tx.insert(users)
|
|
203
|
+
.values({ email: 'test@example.com', name: 'Test' })
|
|
204
|
+
.returning();
|
|
205
|
+
|
|
206
|
+
const [post] = await tx.insert(posts)
|
|
207
|
+
.values({ title: 'First Post', authorId: user.id })
|
|
208
|
+
.returning();
|
|
209
|
+
|
|
210
|
+
return { user, post };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// With rollback on error
|
|
214
|
+
await db.transaction(async (tx) => {
|
|
215
|
+
await tx.update(users)
|
|
216
|
+
.set({ balance: sql\`balance - 100\` })
|
|
217
|
+
.where(eq(users.id, senderId));
|
|
218
|
+
|
|
219
|
+
const [sender] = await tx.select({ balance: users.balance })
|
|
220
|
+
.from(users)
|
|
221
|
+
.where(eq(users.id, senderId));
|
|
222
|
+
|
|
223
|
+
if (sender.balance < 0) {
|
|
224
|
+
tx.rollback(); // Abort transaction
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await tx.update(users)
|
|
228
|
+
.set({ balance: sql\`balance + 100\` })
|
|
229
|
+
.where(eq(users.id, receiverId));
|
|
230
|
+
});
|
|
231
|
+
\`\`\`
|
|
232
|
+
|
|
233
|
+
## Prepared Statements
|
|
234
|
+
\`\`\`typescript
|
|
235
|
+
// Prepare for better performance
|
|
236
|
+
const getUserById = db.select()
|
|
237
|
+
.from(users)
|
|
238
|
+
.where(eq(users.id, sql.placeholder('id')))
|
|
239
|
+
.prepare('get_user_by_id');
|
|
240
|
+
|
|
241
|
+
// Execute with params
|
|
242
|
+
const user = await getUserById.execute({ id: userId });
|
|
243
|
+
|
|
244
|
+
// Prepared insert
|
|
245
|
+
const insertUser = db.insert(users)
|
|
246
|
+
.values({
|
|
247
|
+
email: sql.placeholder('email'),
|
|
248
|
+
name: sql.placeholder('name'),
|
|
249
|
+
})
|
|
250
|
+
.returning()
|
|
251
|
+
.prepare('insert_user');
|
|
252
|
+
|
|
253
|
+
const [newUser] = await insertUser.execute({
|
|
254
|
+
email: 'user@example.com',
|
|
255
|
+
name: 'John',
|
|
256
|
+
});
|
|
257
|
+
\`\`\`
|
|
258
|
+
|
|
259
|
+
## Migrations
|
|
260
|
+
\`\`\`bash
|
|
261
|
+
# Generate migration from schema changes
|
|
262
|
+
npx drizzle-kit generate:pg
|
|
263
|
+
|
|
264
|
+
# Push schema directly (dev only)
|
|
265
|
+
npx drizzle-kit push:pg
|
|
266
|
+
|
|
267
|
+
# View in Drizzle Studio
|
|
268
|
+
npx drizzle-kit studio
|
|
269
|
+
\`\`\`
|
|
270
|
+
|
|
271
|
+
\`\`\`typescript
|
|
272
|
+
// Run migrations programmatically
|
|
273
|
+
import { migrate } from 'drizzle-orm/postgres-js/migrator';
|
|
274
|
+
import { db } from './db';
|
|
275
|
+
|
|
276
|
+
await migrate(db, { migrationsFolder: './drizzle' });
|
|
277
|
+
\`\`\`
|
|
278
|
+
|
|
279
|
+
## ❌ DON'T
|
|
280
|
+
- Forget to define relations for relational queries
|
|
281
|
+
- Use raw SQL when Drizzle operators exist
|
|
282
|
+
- Skip type inference (use InferSelectModel/InferInsertModel)
|
|
283
|
+
- Ignore indexes on frequently queried columns
|
|
284
|
+
- Use \`db.select().from()\` without limits on large tables
|
|
285
|
+
|
|
286
|
+
## ✅ DO
|
|
287
|
+
- Define relations for the query builder
|
|
288
|
+
- Use type inference for type safety
|
|
289
|
+
- Use prepared statements for repeated queries
|
|
290
|
+
- Use transactions for multi-step operations
|
|
291
|
+
- Add indexes in schema definition
|
|
292
|
+
- Use \`returning()\` after insert/update when you need the result
|
|
293
|
+
- Use relational queries (\`db.query\`) for nested data
|