Ajoute la migration initiale (migrations/0001_initial_schema.sql, incluant la table users nécessaire à l'adapter d'auth du ticket #48), le runner de migration (bin/migrate.dart) et l'accès Postgres (infrastructure/postgres/postgres_database.dart). dart pub get OK, dart analyze clean, dart test vert (le test PostgreSQL réel est skip faute de TEST_DATABASE_URL/Docker disponible dans ce sandbox ; SQL de migration revu manuellement). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
4.6 KiB
Dart
177 lines
4.6 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:postgres/postgres.dart';
|
|
|
|
final class PostgresConnectionConfig {
|
|
const PostgresConnectionConfig({
|
|
required this.host,
|
|
required this.port,
|
|
required this.database,
|
|
required this.username,
|
|
required this.password,
|
|
this.sslMode = SslMode.disable,
|
|
});
|
|
|
|
factory PostgresConnectionConfig.fromEnvironment([
|
|
Map<String, String>? environment,
|
|
]) {
|
|
final env = environment ?? Platform.environment;
|
|
return PostgresConnectionConfig(
|
|
host: _requiredEnv(env, 'DATABASE_HOST'),
|
|
port: int.tryParse(env['DATABASE_PORT'] ?? '') ?? 5432,
|
|
database: _requiredEnv(env, 'DATABASE_NAME'),
|
|
username: _requiredEnv(env, 'DATABASE_USER'),
|
|
password: _requiredEnv(env, 'DATABASE_PASSWORD'),
|
|
);
|
|
}
|
|
|
|
final String host;
|
|
final int port;
|
|
final String database;
|
|
final String username;
|
|
final String password;
|
|
final SslMode sslMode;
|
|
}
|
|
|
|
final class PostgresConnectionFactory {
|
|
const PostgresConnectionFactory(this.config) : databaseUrl = null;
|
|
|
|
const PostgresConnectionFactory.fromDatabaseUrl(this.databaseUrl)
|
|
: config = null;
|
|
|
|
final PostgresConnectionConfig? config;
|
|
final String? databaseUrl;
|
|
|
|
Future<Connection> open() {
|
|
final databaseUrl = this.databaseUrl;
|
|
if (databaseUrl != null) {
|
|
return Connection.openFromUrl(databaseUrl);
|
|
}
|
|
final config = this.config!;
|
|
return Connection.open(
|
|
Endpoint(
|
|
host: config.host,
|
|
port: config.port,
|
|
database: config.database,
|
|
username: config.username,
|
|
password: config.password,
|
|
),
|
|
settings: ConnectionSettings(sslMode: config.sslMode),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class PostgresMigrator {
|
|
const PostgresMigrator(this.connection);
|
|
|
|
final Connection connection;
|
|
|
|
Future<void> applyMigrations({String migrationsPath = 'migrations'}) async {
|
|
final directory = Directory(migrationsPath);
|
|
if (!await directory.exists()) {
|
|
throw StateError('Migrations directory not found: $migrationsPath');
|
|
}
|
|
|
|
final files =
|
|
await directory
|
|
.list()
|
|
.where((entity) => entity is File && entity.path.endsWith('.sql'))
|
|
.cast<File>()
|
|
.toList()
|
|
..sort((left, right) => left.path.compareTo(right.path));
|
|
|
|
for (final file in files) {
|
|
final sql = await file.readAsString();
|
|
for (final statement in _splitSqlStatements(sql)) {
|
|
await connection.execute(statement);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
String _requiredEnv(Map<String, String> environment, String key) {
|
|
final value = environment[key];
|
|
if (value == null || value.trim().isEmpty) {
|
|
throw StateError('Missing required environment variable: $key');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
List<String> _splitSqlStatements(String sql) {
|
|
final statements = <String>[];
|
|
final buffer = StringBuffer();
|
|
var inSingleQuote = false;
|
|
var inDoubleQuote = false;
|
|
String? dollarQuoteTag;
|
|
|
|
for (var index = 0; index < sql.length; index++) {
|
|
final char = sql[index];
|
|
final next = index + 1 < sql.length ? sql[index + 1] : null;
|
|
|
|
if (dollarQuoteTag != null) {
|
|
if (sql.startsWith(dollarQuoteTag, index)) {
|
|
buffer.write(dollarQuoteTag);
|
|
index += dollarQuoteTag.length - 1;
|
|
dollarQuoteTag = null;
|
|
} else {
|
|
buffer.write(char);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!inSingleQuote && !inDoubleQuote && char == r'$') {
|
|
final tag = _readDollarQuoteTag(sql, index);
|
|
if (tag != null) {
|
|
dollarQuoteTag = tag;
|
|
buffer.write(tag);
|
|
index += tag.length - 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (!inDoubleQuote && char == "'") {
|
|
buffer.write(char);
|
|
if (inSingleQuote && next == "'") {
|
|
index++;
|
|
buffer.write(next);
|
|
} else {
|
|
inSingleQuote = !inSingleQuote;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!inSingleQuote && char == '"') {
|
|
inDoubleQuote = !inDoubleQuote;
|
|
buffer.write(char);
|
|
continue;
|
|
}
|
|
|
|
if (!inSingleQuote && !inDoubleQuote && char == ';') {
|
|
final statement = buffer.toString().trim();
|
|
if (statement.isNotEmpty) {
|
|
statements.add(statement);
|
|
}
|
|
buffer.clear();
|
|
continue;
|
|
}
|
|
|
|
buffer.write(char);
|
|
}
|
|
|
|
final statement = buffer.toString().trim();
|
|
if (statement.isNotEmpty) {
|
|
statements.add(statement);
|
|
}
|
|
return statements;
|
|
}
|
|
|
|
String? _readDollarQuoteTag(String sql, int start) {
|
|
final end = sql.indexOf(r'$', start + 1);
|
|
if (end == -1) {
|
|
return null;
|
|
}
|
|
final tagName = sql.substring(start + 1, end);
|
|
final validTag = RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$|^$').hasMatch(tagName);
|
|
return validTag ? sql.substring(start, end + 1) : null;
|
|
}
|