mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-20 05:28:22 +02:00
Au démarrage, la configuration météo était illisible (« Rust type Option<NaiveDateTime> (as SQL type TIMESTAMP) is not compatible with SQL type TIMESTAMPTZ ») et /api/weather/current renvoyait 500 : weather_settings.created_at est timestamptz en production alors que shared/schema.ts et init.sql le déclarent timestamp. Les types réels de la production ne figurent dans aucune migration du dépôt : plutôt que de deviner colonne par colonne, chaque colonne timestamp des structures générées est lue par crate::db::AnyTimestamp(Opt), qui accepte timestamp et timestamptz (rendu en UTC). Le champ reste NaiveDateTime et la sérialisation (crate::json::ts, « …Z ») ne change pas. scripts/gen-rust-schema.ts émet le même attribut #[sqlx(try_from)] ; schema.rs est modifié à l'identique (62 colonnes). Non compilé : pas de chaîne Rust sur ce poste. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
8.4 KiB
TypeScript
230 lines
8.4 KiB
TypeScript
// Génère server-rs/src/schema.rs à partir de shared/schema.ts :
|
|
// - une structure par table, avec les mêmes clés JSON, dans le même ordre et
|
|
// aux mêmes formats que ce que Drizzle renvoie à l'interface ;
|
|
// - le schéma de validation que drizzle-zod (createInsertSchema) en tire.
|
|
// Les colonnes nulles dans la base réelle le sont aussi en Rust (DATABASE_URL).
|
|
//
|
|
// Usage : DATABASE_URL=postgres://… npx tsx scripts/gen-rust-schema.ts
|
|
import { execFileSync } from "child_process";
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import pg from "pg";
|
|
import { getTableColumns } from "drizzle-orm";
|
|
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
|
import * as schema from "../shared/schema";
|
|
|
|
const OUTPUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../server-rs/src/schema.rs");
|
|
|
|
const RUST_KEYWORDS = new Set(["type", "match", "ref", "move", "use", "loop", "where", "self", "struct", "enum"]);
|
|
|
|
function pascal(name: string): string {
|
|
return name.split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join("");
|
|
}
|
|
|
|
interface ColumnInfo {
|
|
jsKey: string;
|
|
name: string;
|
|
columnType: string;
|
|
notNull: boolean;
|
|
hasDefault: boolean;
|
|
length?: number;
|
|
scale?: number;
|
|
}
|
|
|
|
// Type SQL vers lequel le texte d'un paramètre est converti (comme node-pg, qui
|
|
// envoie tous les paramètres en texte)
|
|
function sqlType(column: ColumnInfo): string {
|
|
switch (column.columnType) {
|
|
case "PgSerial":
|
|
case "PgInteger":
|
|
return "int4";
|
|
case "PgBigInt53":
|
|
return "int8";
|
|
case "PgVarchar":
|
|
case "PgText":
|
|
return "text";
|
|
case "PgBoolean":
|
|
return "bool";
|
|
case "PgTimestamp":
|
|
return "timestamp";
|
|
case "PgDateString":
|
|
return "date";
|
|
case "PgNumeric":
|
|
return "numeric";
|
|
case "PgJsonb":
|
|
return "jsonb";
|
|
default:
|
|
throw new Error(`Type de colonne non géré : ${column.columnType} (${column.name})`);
|
|
}
|
|
}
|
|
|
|
function rustType(column: ColumnInfo): string {
|
|
switch (column.columnType) {
|
|
case "PgSerial":
|
|
case "PgInteger":
|
|
return "i32";
|
|
case "PgBigInt53":
|
|
return "i64";
|
|
case "PgVarchar":
|
|
case "PgText":
|
|
return "String";
|
|
case "PgBoolean":
|
|
return "bool";
|
|
case "PgTimestamp":
|
|
return "NaiveDateTime";
|
|
case "PgDateString":
|
|
return "NaiveDate";
|
|
case "PgNumeric":
|
|
return "Decimal";
|
|
case "PgJsonb":
|
|
return "serde_json::Value";
|
|
default:
|
|
throw new Error(`Type de colonne non géré : ${column.columnType} (${column.name})`);
|
|
}
|
|
}
|
|
|
|
// Même correspondance que drizzle-zod 0.7 (columnToSchema)
|
|
function zodField(column: ColumnInfo): string {
|
|
let field: string;
|
|
switch (column.columnType) {
|
|
case "PgSerial":
|
|
case "PgInteger":
|
|
field = `Field::int32("${column.jsKey}")`;
|
|
break;
|
|
case "PgBigInt53":
|
|
field = `Field::safe_integer("${column.jsKey}")`;
|
|
break;
|
|
case "PgVarchar":
|
|
field = column.length ? `Field::string("${column.jsKey}").max_len(${column.length})` : `Field::string("${column.jsKey}")`;
|
|
break;
|
|
case "PgText":
|
|
case "PgDateString":
|
|
case "PgNumeric":
|
|
field = `Field::string("${column.jsKey}")`;
|
|
break;
|
|
case "PgBoolean":
|
|
field = `Field::boolean("${column.jsKey}")`;
|
|
break;
|
|
case "PgTimestamp":
|
|
field = `Field::date("${column.jsKey}")`;
|
|
break;
|
|
case "PgJsonb":
|
|
field = `Field::json("${column.jsKey}")`;
|
|
break;
|
|
default:
|
|
throw new Error(`Type de colonne non géré : ${column.columnType}`);
|
|
}
|
|
if (!column.notNull) field += ".nullable()";
|
|
if (!column.notNull || column.hasDefault) field += ".optional()";
|
|
return field;
|
|
}
|
|
|
|
async function main() {
|
|
const nullableInDb = new Set<string>();
|
|
if (process.env.DATABASE_URL) {
|
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
await client.connect();
|
|
const { rows } = await client.query(
|
|
`SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND is_nullable = 'YES'`,
|
|
);
|
|
for (const row of rows) nullableInDb.add(`${row.table_name}.${row.column_name}`);
|
|
await client.end();
|
|
} else {
|
|
console.warn("DATABASE_URL absent : nullabilité tirée de shared/schema.ts seulement");
|
|
}
|
|
|
|
const tables = new Map<string, ColumnInfo[]>();
|
|
for (const value of Object.values(schema)) {
|
|
if (!(value instanceof PgTable)) continue;
|
|
const config = getTableConfig(value);
|
|
if (tables.has(config.name)) continue;
|
|
const columns = Object.entries(getTableColumns(value)).map(([jsKey, column]: [string, any]) => ({
|
|
jsKey,
|
|
name: column.name,
|
|
columnType: column.columnType,
|
|
notNull: column.notNull,
|
|
hasDefault: column.hasDefault,
|
|
length: column.length,
|
|
scale: column.scale,
|
|
}));
|
|
tables.set(config.name, columns);
|
|
}
|
|
|
|
const out: string[] = [];
|
|
out.push("// Généré par scripts/gen-rust-schema.ts à partir de shared/schema.ts : ne pas modifier à la main.");
|
|
out.push("// Une structure par table (clés, ordre et formats JSON de Drizzle) et son schéma");
|
|
out.push("// d'insertion drizzle-zod (createInsertSchema).");
|
|
out.push("#![allow(dead_code)]");
|
|
out.push("");
|
|
out.push("use chrono::{NaiveDate, NaiveDateTime};");
|
|
out.push("use rust_decimal::Decimal;");
|
|
out.push("use serde::Serialize;");
|
|
out.push("use sqlx::FromRow;");
|
|
out.push("");
|
|
out.push("use crate::db::{Col, Table};");
|
|
out.push("use crate::zod::{Field, Object};");
|
|
out.push("");
|
|
|
|
for (const [table, columns] of [...tables].sort(([a], [b]) => a.localeCompare(b))) {
|
|
const name = `${pascal(table)}Row`;
|
|
out.push(`/// Table ${table}`);
|
|
out.push("#[derive(Debug, Clone, Serialize, FromRow)]");
|
|
out.push(`pub struct ${name} {`);
|
|
for (const column of columns) {
|
|
const nullable = !column.notNull || nullableInDb.has(`${table}.${column.name}`);
|
|
const base = rustType(column);
|
|
const type = nullable ? `Option<${base}>` : base;
|
|
const attributes: string[] = [];
|
|
const field = RUST_KEYWORDS.has(column.name) ? `${column.name}_` : column.name;
|
|
if (field !== column.name) attributes.push(`#[sqlx(rename = "${column.name}")]`);
|
|
// timestamp dans shared/schema.ts, mais parfois timestamptz en production
|
|
// (tables créées hors des migrations du dépôt) : lu dans les deux cas
|
|
if (base === "NaiveDateTime") {
|
|
attributes.push(`#[sqlx(try_from = "${nullable ? "crate::db::AnyTimestampOpt" : "crate::db::AnyTimestamp"}")]`);
|
|
}
|
|
const serde = [`rename = "${column.jsKey}"`];
|
|
if (base === "NaiveDateTime") serde.push(`serialize_with = "${nullable ? "crate::json::ts_opt" : "crate::json::ts"}"`);
|
|
if (base === "Decimal") {
|
|
// Échelle de la colonne : PostgreSQL rend "0.00", sqlx lit 0
|
|
if (typeof column.scale !== "number") throw new Error(`Échelle inconnue : ${table}.${column.name}`);
|
|
serde.push(`serialize_with = "${nullable ? "crate::json::numeric_opt" : "crate::json::numeric"}::<${column.scale}, _>"`);
|
|
}
|
|
attributes.push(`#[serde(${serde.join(", ")})]`);
|
|
for (const attribute of attributes) out.push(` ${attribute}`);
|
|
out.push(` pub ${field}: ${type},`);
|
|
}
|
|
out.push("}");
|
|
out.push("");
|
|
out.push(`impl Table for ${name} {`);
|
|
out.push(` const TABLE: &'static str = "${table}";`);
|
|
out.push(` const COLUMNS: &'static str = "${columns.map((c) => `\\"${c.name}\\"`).join(", ")}";`);
|
|
out.push(" const FIELDS: &'static [Col] = &[");
|
|
for (const column of columns) {
|
|
out.push(` Col { key: "${column.jsKey}", column: "${column.name}", sql_type: "${sqlType(column)}" },`);
|
|
}
|
|
out.push(" ];");
|
|
out.push("}");
|
|
out.push("");
|
|
out.push(`impl ${name} {`);
|
|
out.push(` /// createInsertSchema(${table})`);
|
|
out.push(" pub fn insert_schema() -> Object {");
|
|
out.push(" Object::new(vec![");
|
|
for (const column of columns) out.push(` ${zodField(column)},`);
|
|
out.push(" ])");
|
|
out.push(" }");
|
|
out.push("}");
|
|
out.push("");
|
|
}
|
|
|
|
fs.writeFileSync(OUTPUT, out.join("\n"));
|
|
// Même mise en forme que cargo fmt (server-rs/rustfmt.toml)
|
|
execFileSync("rustfmt", ["--edition", "2024", "--config-path", path.dirname(path.dirname(OUTPUT)), OUTPUT]);
|
|
console.log(`${tables.size} tables écrites dans ${path.relative(process.cwd(), OUTPUT)}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|