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:
@@ -21,6 +21,9 @@ pub enum DbError {
|
|||||||
|
|
||||||
#[error("Migration failed: {0}")]
|
#[error("Migration failed: {0}")]
|
||||||
Migration(#[from] sqlx::migrate::MigrateError),
|
Migration(#[from] sqlx::migrate::MigrateError),
|
||||||
|
|
||||||
|
#[error("Record not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Pool creation ────────────────────────────────────────────────────────────
|
// ─── Pool creation ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ pub mod config;
|
|||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod db;
|
pub mod db;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod repository;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
#[cfg(feature = "ssr")]
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
|||||||
134
src/server/repository/applications.rs
Normal file
134
src/server/repository/applications.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/server/repository/hosts.rs
Normal file
86
src/server/repository/hosts.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/server/repository/mod.rs
Normal file
19
src/server/repository/mod.rs
Normal 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 0–65535 fit in u16).
|
||||||
|
|
||||||
|
pub mod applications;
|
||||||
|
pub mod hosts;
|
||||||
|
pub mod networks;
|
||||||
|
pub mod ports;
|
||||||
79
src/server/repository/networks.rs
Normal file
79
src/server/repository/networks.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/server/repository/ports.rs
Normal file
156
src/server/repository/ports.rs
Normal 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` (0–65535).
|
||||||
|
// 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user