feat(repository): add CRUD layer for all domain entities

- Add server/repository/ module with networks, hosts, ports, applications
- Use sqlx::query() + manual row mapping (no compile-time DB required)
- Handle unique-constraint conflicts with is_unique_violation() for
  cross-database compatibility (SQLite + PostgreSQL via AnyPool)
- add_port_to_host auto-registers the port in the catalog (prevents FK errors)
- application_ports has no FK to ports (intentional: loose association)
- Add DbError::NotFound variant for missing-record cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 21:52:32 +02:00
parent f13097591c
commit a352a8edfd
7 changed files with 480 additions and 0 deletions

View File

@@ -21,6 +21,9 @@ pub enum DbError {
#[error("Migration failed: {0}")]
Migration(#[from] sqlx::migrate::MigrateError),
#[error("Record not found: {0}")]
NotFound(String),
}
// ─── Pool creation ────────────────────────────────────────────────────────────

View File

@@ -14,6 +14,9 @@ pub mod config;
#[cfg(feature = "ssr")]
pub mod db;
#[cfg(feature = "ssr")]
pub mod repository;
#[cfg(feature = "ssr")]
pub mod state;

View File

@@ -0,0 +1,134 @@
// repository/applications.rs — CRUD for applications and their port associations
use sqlx::{AnyPool, Row};
use crate::models::Application;
use crate::server::db::DbError;
// ─── Read ─────────────────────────────────────────────────────────────────────
/// Returns every application ordered by name.
pub async fn list_applications(pool: &AnyPool) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query("SELECT id, name FROM applications ORDER BY name")
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Returns a single application by id, or `None` if it does not exist.
pub async fn find_application(
pool: &AnyPool,
id: i64,
) -> Result<Option<Application>, DbError> {
let row = sqlx::query("SELECT id, name FROM applications WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.as_ref().map(row_to_application))
}
// ─── Write ────────────────────────────────────────────────────────────────────
/// Inserts a new application and returns the created record.
pub async fn create_application(pool: &AnyPool, name: &str) -> Result<Application, DbError> {
let row =
sqlx::query("INSERT INTO applications (name) VALUES ($1) RETURNING id, name")
.bind(name)
.fetch_one(pool)
.await?;
Ok(row_to_application(&row))
}
/// Deletes an application and its port associations (via `ON DELETE CASCADE`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.
pub async fn delete_application(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM applications WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Application-port associations ───────────────────────────────────────────
/// Returns all port numbers associated with an application, sorted numerically.
///
/// Note: the `application_ports` table does NOT have a FK to `ports`,
/// so an application can reference a port number that is not in the catalog.
/// Returns plain `u16` numbers, not full `Port` structs.
pub async fn list_ports_for_application(
pool: &AnyPool,
application_id: i64,
) -> Result<Vec<u16>, DbError> {
let rows = sqlx::query(
"SELECT port_number FROM application_ports
WHERE application_id = $1
ORDER BY port_number",
)
.bind(application_id)
.fetch_all(pool)
.await?;
Ok(rows
.iter()
.map(|r| r.get::<i64, _>("port_number") as u16)
.collect())
}
/// Associates a port number with an application.
///
/// If the association already exists, the call is a no-op (not an error).
/// The port does not need to be in the `ports` catalog.
pub async fn add_port_to_application(
pool: &AnyPool,
application_id: i64,
port_number: u16,
) -> Result<(), DbError> {
let result = sqlx::query(
"INSERT INTO application_ports (application_id, port_number) VALUES ($1, $2)",
)
.bind(application_id)
.bind(port_number as i64)
.execute(pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
Err(e) => Err(DbError::Connection(e)),
}
}
/// Removes a port association from an application.
///
/// If the association did not exist, this is a no-op (not an error).
pub async fn remove_port_from_application(
pool: &AnyPool,
application_id: i64,
port_number: u16,
) -> Result<(), DbError> {
sqlx::query(
"DELETE FROM application_ports
WHERE application_id = $1 AND port_number = $2",
)
.bind(application_id)
.bind(port_number as i64)
.execute(pool)
.await?;
Ok(())
}
// ─── Row mapping ──────────────────────────────────────────────────────────────
fn row_to_application(row: &sqlx::any::AnyRow) -> Application {
Application {
id: row.get("id"),
name: row.get("name"),
}
}

View File

@@ -0,0 +1,86 @@
// repository/hosts.rs — CRUD for the `hosts` table
use sqlx::{AnyPool, Row};
use crate::models::Host;
use crate::server::db::DbError;
// ─── Read ─────────────────────────────────────────────────────────────────────
/// Returns every host belonging to a network, sorted by name.
pub async fn list_hosts_by_network(
pool: &AnyPool,
network_id: i64,
) -> Result<Vec<Host>, DbError> {
let rows = sqlx::query(
"SELECT id, name, ip, network_id FROM hosts
WHERE network_id = $1
ORDER BY name",
)
.bind(network_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_host).collect())
}
/// Returns a single host by id, or `None` if it does not exist.
pub async fn find_host(pool: &AnyPool, id: i64) -> Result<Option<Host>, DbError> {
let row = sqlx::query(
"SELECT id, name, ip, network_id FROM hosts WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row.as_ref().map(row_to_host))
}
// ─── Write ────────────────────────────────────────────────────────────────────
/// Inserts a new host into the given network and returns the created record.
///
/// The caller must validate that `ip` falls within the CIDR range of `network_id`
/// before calling this function — use `server::validation::validate_ip_in_network`.
pub async fn create_host(
pool: &AnyPool,
name: &str,
ip: &str,
network_id: i64,
) -> Result<Host, DbError> {
let row = sqlx::query(
"INSERT INTO hosts (name, ip, network_id)
VALUES ($1, $2, $3)
RETURNING id, name, ip, network_id",
)
.bind(name)
.bind(ip)
.bind(network_id)
.fetch_one(pool)
.await?;
Ok(row_to_host(&row))
}
/// Deletes a host and all its port associations (via `ON DELETE CASCADE`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.
pub async fn delete_host(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
let result = sqlx::query("DELETE FROM hosts WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Row mapping ──────────────────────────────────────────────────────────────
fn row_to_host(row: &sqlx::any::AnyRow) -> Host {
Host {
id: row.get("id"),
name: row.get("name"),
ip: row.get("ip"),
network_id: row.get("network_id"),
}
}

View File

@@ -0,0 +1,19 @@
// server/repository — Database access layer (CRUD)
//
// Each sub-module owns the queries for one domain entity.
// All functions accept `&AnyPool` and return `Result<_, DbError>`,
// so the caller never has to think about raw SQL or connection management.
//
// Design choices:
// - Free functions (not struct methods) keep the API simple.
// - `sqlx::query()` is used instead of `sqlx::query!()` because the
// compile-time macro requires a live database at build time.
// - All integer columns are read as `i64` (the AnyPool normalizes
// INTEGER/BIGINT to 64-bit integers internally).
// - Port numbers are stored as i64 in the DB and cast to u16 in Rust
// (safe because valid port numbers 065535 fit in u16).
pub mod applications;
pub mod hosts;
pub mod networks;
pub mod ports;

View File

@@ -0,0 +1,79 @@
// repository/networks.rs — CRUD for the `networks` table
use sqlx::{AnyPool, Row};
use crate::models::Network;
use crate::server::db::DbError;
// ─── Read ─────────────────────────────────────────────────────────────────────
/// Returns every network ordered by id.
pub async fn list_networks(pool: &AnyPool) -> Result<Vec<Network>, DbError> {
// `fetch_all` runs the query and collects every row into a Vec.
// It returns an error if the query fails; an empty table returns Ok(vec![]).
let rows = sqlx::query("SELECT id, cidr FROM networks ORDER BY id")
.fetch_all(pool)
.await?;
// `.iter().map(...).collect()` transforms each raw DB row into a Network struct.
Ok(rows.iter().map(row_to_network).collect())
}
/// Returns a single network by id, or `None` if it does not exist.
pub async fn find_network(pool: &AnyPool, id: i64) -> Result<Option<Network>, DbError> {
// `fetch_optional` returns `Ok(None)` when no row matches — unlike
// `fetch_one`, which returns an error when nothing is found.
let row = sqlx::query("SELECT id, cidr FROM networks WHERE id = $1")
.bind(id) // `$1` is replaced with the value of `id` at runtime
.fetch_optional(pool)
.await?;
// `Option::map` applies the conversion only if the row is Some.
Ok(row.as_ref().map(row_to_network))
}
// ─── Write ────────────────────────────────────────────────────────────────────
/// Inserts a new network and returns the created record (with its auto-generated id).
///
/// Fails with `DbError::Connection` if the CIDR is already registered
/// (the `cidr` column has a UNIQUE constraint).
///
/// `RETURNING id, cidr` reads back the inserted row in a single round-trip,
/// avoiding a separate SELECT after the INSERT.
/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version.
pub async fn create_network(pool: &AnyPool, cidr: &str) -> Result<Network, DbError> {
let row = sqlx::query("INSERT INTO networks (cidr) VALUES ($1) RETURNING id, cidr")
.bind(cidr)
.fetch_one(pool) // exactly one row is returned by RETURNING
.await?;
Ok(row_to_network(&row))
}
/// Deletes a network by id and all its hosts (via `ON DELETE CASCADE`).
///
/// Returns `true` if a row was deleted, `false` if the id did not exist.
pub async fn delete_network(pool: &AnyPool, id: i64) -> Result<bool, DbError> {
// `execute` runs the query without fetching rows.
// `rows_affected()` tells us how many rows were actually deleted.
let result = sqlx::query("DELETE FROM networks WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Row mapping ──────────────────────────────────────────────────────────────
/// Converts a raw database row into a `Network` struct.
///
/// `row.get("col")` extracts a typed value by column name.
/// The type must implement `sqlx::Decode` for the `Any` backend.
fn row_to_network(row: &sqlx::any::AnyRow) -> Network {
Network {
id: row.get("id"),
cidr: row.get("cidr"),
}
}

View File

@@ -0,0 +1,156 @@
// repository/ports.rs — Port catalog and host-port associations
//
// The `ports` table is a global catalog: a port number (e.g. 22) is defined
// once with an optional description, then linked to hosts via `host_ports`.
//
// Port numbers in Rust are `u16` (065535).
// In the database they are stored as INTEGER (i64 in SQLx/AnyPool).
// `as u16` casts are safe because values above 65535 cannot be inserted
// (the schema and application code enforce this).
use sqlx::{AnyPool, Row};
use crate::models::Port;
use crate::server::db::DbError;
// ─── Port catalog ─────────────────────────────────────────────────────────────
/// Inserts or updates a port in the global catalog.
///
/// If the port number already exists, its description is updated.
/// If it does not exist, a new row is inserted.
///
/// We use a try-INSERT then UPDATE pattern instead of database-specific
/// upsert syntax (`INSERT OR REPLACE` for SQLite, `ON CONFLICT` for PostgreSQL)
/// so the same code works with `AnyPool` on both backends.
pub async fn upsert_port(
pool: &AnyPool,
number: u16,
description: Option<&str>,
) -> Result<Port, DbError> {
let insert_result = sqlx::query(
"INSERT INTO ports (number, description) VALUES ($1, $2)",
)
.bind(number as i64)
.bind(description)
.execute(pool)
.await;
match insert_result {
Ok(_) => {}
Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => {
// Port already exists: update the description instead.
// `is_unique_violation()` works for both SQLite and PostgreSQL.
sqlx::query("UPDATE ports SET description = $1 WHERE number = $2")
.bind(description)
.bind(number as i64)
.execute(pool)
.await?;
}
Err(e) => return Err(DbError::Connection(e)),
}
// Return the current state of the port row.
find_port(pool, number)
.await?
.ok_or_else(|| DbError::NotFound(format!("port {number}")))
}
/// Returns a port from the catalog, or `None` if the number is not registered.
pub async fn find_port(pool: &AnyPool, number: u16) -> Result<Option<Port>, DbError> {
let row = sqlx::query(
"SELECT number, description FROM ports WHERE number = $1",
)
.bind(number as i64)
.fetch_optional(pool)
.await?;
Ok(row.as_ref().map(row_to_port))
}
// ─── Host-port associations ───────────────────────────────────────────────────
/// Returns all ports currently associated with a host, sorted by port number.
///
/// Uses a JOIN so callers get full `Port` structs (with descriptions), not just numbers.
pub async fn list_ports_for_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Port>, DbError> {
let rows = sqlx::query(
"SELECT p.number, p.description
FROM ports p
JOIN host_ports hp ON hp.port_number = p.number
WHERE hp.host_id = $1
ORDER BY p.number",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_port).collect())
}
/// Adds a port to a host.
///
/// If the port is not yet in the global catalog, it is automatically registered
/// with a description from `Port::known_protocol` (e.g. port 22 → "SSH").
/// If the host already has this port, the call is a no-op (not an error).
pub async fn add_port_to_host(
pool: &AnyPool,
host_id: i64,
port_number: u16,
) -> Result<(), DbError> {
// Auto-register the port in the catalog if it is not already there.
// The `host_ports` table has a FK to `ports`, so the port must exist first.
let description = crate::models::Port::known_protocol(port_number);
upsert_port(pool, port_number, description).await?;
// Insert the host-port association.
// If it already exists, treat it as a no-op (unique violation is expected).
let result = sqlx::query(
"INSERT INTO host_ports (host_id, port_number) VALUES ($1, $2)",
)
.bind(host_id)
.bind(port_number as i64)
.execute(pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
Err(e) => Err(DbError::Connection(e)),
}
}
/// Removes a port from a host.
///
/// If the association did not exist, this is a no-op (not an error).
/// The port entry in the global catalog is NOT deleted.
pub async fn remove_port_from_host(
pool: &AnyPool,
host_id: i64,
port_number: u16,
) -> Result<(), DbError> {
sqlx::query(
"DELETE FROM host_ports WHERE host_id = $1 AND port_number = $2",
)
.bind(host_id)
.bind(port_number as i64)
.execute(pool)
.await?;
Ok(())
}
// ─── Row mapping ──────────────────────────────────────────────────────────────
fn row_to_port(row: &sqlx::any::AnyRow) -> Port {
Port {
// Port numbers are stored as INTEGER in the DB.
// SQLx/AnyPool decodes all integers to i64; `as u16` is safe
// because values above 65535 cannot reach the DB.
number: row.get::<i64, _>("number") as u16,
description: row.get("description"),
}
}