diff --git a/.sqlx/query-03cac7cb5b485c93b71cdbe03400b9423c1b63146c2d93e3f1b08857f28a6c33.json b/.sqlx/query-03cac7cb5b485c93b71cdbe03400b9423c1b63146c2d93e3f1b08857f28a6c33.json new file mode 100644 index 00000000..a561a5c2 --- /dev/null +++ b/.sqlx/query-03cac7cb5b485c93b71cdbe03400b9423c1b63146c2d93e3f1b08857f28a6c33.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO template (name, template, instrument)\n VALUES (?, ?, (SELECT id FROM instrument WHERE name = ?)) RETURNING name, template;", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "template", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false + ] + }, + "hash": "03cac7cb5b485c93b71cdbe03400b9423c1b63146c2d93e3f1b08857f28a6c33" +} diff --git a/.sqlx/query-4323ce25def2ee41c1019820cad67f86fe119aa5b5d7f36e49bece9ab6048c38.json b/.sqlx/query-4323ce25def2ee41c1019820cad67f86fe119aa5b5d7f36e49bece9ab6048c38.json new file mode 100644 index 00000000..39be7927 --- /dev/null +++ b/.sqlx/query-4323ce25def2ee41c1019820cad67f86fe119aa5b5d7f36e49bece9ab6048c38.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT name, template FROM instrument_template WHERE instrument = ?", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "template", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "4323ce25def2ee41c1019820cad67f86fe119aa5b5d7f36e49bece9ab6048c38" +} diff --git a/migrations/0005_template_table.down.sql b/migrations/0005_template_table.down.sql new file mode 100644 index 00000000..9f22b14d --- /dev/null +++ b/migrations/0005_template_table.down.sql @@ -0,0 +1,2 @@ +DROP VIEW instrument_template; +DROP TABLE template; diff --git a/migrations/0005_template_table.up.sql b/migrations/0005_template_table.up.sql new file mode 100644 index 00000000..f0dab661 --- /dev/null +++ b/migrations/0005_template_table.up.sql @@ -0,0 +1,19 @@ +-- Add new table for additional templates +CREATE TABLE template ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + template TEXT NOT NULL, + instrument INTEGER NOT NULL REFERENCES instrument(id) ON DELETE CASCADE ON UPDATE CASCADE, + + CONSTRAINT duplicate_names UNIQUE (name, instrument) ON CONFLICT REPLACE, + + CONSTRAINT empty_template CHECK (length(template) > 0), + CONSTRAINT empty_name CHECK (length(name) > 0) +); + +CREATE VIEW instrument_template (instrument, name, template) AS + SELECT + instrument.name, template.name, template.template + FROM instrument + JOIN template + ON instrument.id = template.instrument; diff --git a/src/db_service.rs b/src/db_service.rs index 04450149..4e01ef49 100644 --- a/src/db_service.rs +++ b/src/db_service.rs @@ -16,8 +16,8 @@ use std::fmt; use std::marker::PhantomData; use std::path::Path; -pub use error::ConfigurationError; use error::NewConfigurationError; +pub use error::{ConfigurationError, NamedTemplateError}; use sqlx::sqlite::{SqliteConnectOptions, SqliteRow}; use sqlx::{query_as, FromRow, QueryBuilder, Row, Sqlite, SqlitePool}; use tracing::{info, instrument, trace}; @@ -35,6 +35,21 @@ pub struct SqliteScanPathService { pool: SqlitePool, } +#[derive(Debug)] +pub struct NamedTemplate { + pub name: String, + pub template: String, +} + +impl<'r> FromRow<'r, SqliteRow> for NamedTemplate { + fn from_row(row: &'r SqliteRow) -> Result { + Ok(Self { + name: row.try_get("name")?, + template: row.try_get("template")?, + }) + } +} + #[derive(Debug, PartialEq, Eq)] struct RawPathTemplate(String, PhantomData); @@ -341,6 +356,54 @@ impl SqliteScanPathService { .ok_or(ConfigurationError::MissingInstrument(instrument.into())) } + pub async fn all_additional_templates( + &self, + instrument: &str, + ) -> Result, ConfigurationError> { + Ok(query_as!( + NamedTemplate, + "SELECT name, template FROM instrument_template WHERE instrument = ?", + instrument + ) + .fetch_all(&self.pool) + .await?) + } + pub async fn additional_templates( + &self, + instrument: &str, + names: Vec, + ) -> Result, ConfigurationError> { + let mut q = + QueryBuilder::new("SELECT name, template FROM instrument_template WHERE instrument = "); + q.push_bind(instrument); + q.push(" AND name IN ("); + let mut name_query = q.separated(", "); + for name in names { + name_query.push_bind(name); + } + q.push(")"); + let query = q.build_query_as(); + Ok(query.fetch_all(&self.pool).await?) + } + + pub async fn register_template( + &self, + instrument: &str, + name: String, + template: String, + ) -> Result { + Ok(query_as!( + NamedTemplate, + "INSERT INTO template (name, template, instrument) + VALUES (?, ?, (SELECT id FROM instrument WHERE name = ?)) RETURNING name, template;", + name, + template, + instrument + ) + .fetch_one(&self.pool) + .await?) + } + /// Create a db service from a new empty/schema-less DB #[cfg(test)] pub(crate) async fn uninitialised() -> Self { @@ -368,7 +431,9 @@ impl fmt::Debug for SqliteScanPathService { } mod error { + use derive_more::{Display, Error, From}; + use sqlx::error::ErrorKind; #[derive(Debug, Display, Error, From)] pub enum ConfigurationError { @@ -392,6 +457,46 @@ mod error { Self::MissingField(value.into()) } } + + #[derive(Debug, Display, Error)] + pub enum NamedTemplateError { + #[display("No configuration for instrument")] + MissingInstrument, + #[display("Template name was empty")] + EmptyName, + #[display("Template was empty")] + EmptyTemplate, + #[display("Error accessing named template: {_0}")] + DbError(sqlx::Error), + } + + impl From for NamedTemplateError { + fn from(value: sqlx::Error) -> Self { + match value { + sqlx::Error::Database(err) => match (err.kind(), err.message().split_once(": ")) { + (ErrorKind::NotNullViolation, Some((_, "template.instrument"))) => { + NamedTemplateError::MissingInstrument + } + // pretty sure these two are not possible as strings can't be null + (ErrorKind::NotNullViolation, Some((_, "template.name"))) => { + NamedTemplateError::EmptyName + } + (ErrorKind::NotNullViolation, Some((_, "template.template"))) => { + NamedTemplateError::EmptyTemplate + } + // Values are empty - these rely on the named checks in the schema + (ErrorKind::CheckViolation, Some((_, "empty_name"))) => { + NamedTemplateError::EmptyName + } + (ErrorKind::CheckViolation, Some((_, "empty_template"))) => { + NamedTemplateError::EmptyTemplate + } + (_, _) => NamedTemplateError::DbError(sqlx::Error::Database(err)), + }, + err => err.into(), + } + } + } } #[cfg(test)] diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 6edc8ca2..34e8ca2c 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -14,6 +14,7 @@ use std::any; use std::borrow::Cow; +use std::collections::HashMap; use std::future::Future; use std::io::Write; use std::path::{Component, PathBuf}; @@ -41,7 +42,7 @@ use tracing::{debug, info, instrument, trace, warn}; use crate::build_info::ServerStatus; use crate::cli::ServeOptions; use crate::db_service::{ - InstrumentConfiguration, InstrumentConfigurationUpdate, SqliteScanPathService, + InstrumentConfiguration, InstrumentConfigurationUpdate, NamedTemplate, SqliteScanPathService, }; use crate::numtracker::NumTracker; use crate::paths::{ @@ -144,6 +145,7 @@ struct DirectoryPath { /// GraphQL type to provide path data for the next scan for a given instrument session struct ScanPaths { directory: DirectoryPath, + extra_templates: HashMap>, subdirectory: Subdirectory, } @@ -224,6 +226,15 @@ impl ScanPaths { self.directory.info.scan_number() } + async fn template(&self, name: String) -> async_graphql::Result { + Ok(path_to_string( + self.extra_templates + .get(&name) + .ok_or(NoSuchTemplate(name))? + .render(self), + )?) + } + /// The paths where the given detectors should write their files. /// /// Detector names are normalised before being used in file names by replacing any @@ -302,6 +313,27 @@ impl CurrentConfiguration { } } +#[derive(Debug, InputObject)] +struct NamedTemplateInput { + name: String, + template: InputTemplate, +} + +#[derive(Debug, Display, Error)] +#[display("Template {_0:?} not found")] +struct NoSuchTemplate(#[error(ignore)] String); + +#[Object] +impl NamedTemplate { + async fn name(&self) -> &str { + &self.name + } + + async fn template(&self) -> &str { + &self.template + } +} + impl FieldSource for ScanPaths { fn resolve(&self, field: &ScanField) -> Cow<'_, str> { match field { @@ -384,6 +416,24 @@ impl Query { .into_iter() .collect() } + + #[instrument(skip(self, ctx))] + async fn named_templates<'ctx>( + &self, + ctx: &Context<'ctx>, + instrument: String, + names: Option>, + ) -> async_graphql::Result> { + check_auth(ctx, |policy, token| { + policy.check_instrument_admin(token, &instrument) + }) + .await?; + let db = ctx.data::()?; + match names { + Some(names) => Ok(db.additional_templates(&instrument, names).await?), + None => Ok(db.all_additional_templates(&instrument).await?), + } + } } #[Object] @@ -420,11 +470,35 @@ impl Mutation { warn!("Failed to increment tracker file: {e}"); } + let required_templates = ctx + .field() + .selection_set() + .filter(|slct| slct.name() == "template") + .flat_map(|slct| slct.arguments()) + .filter_map(|args| { + args.first().map(|arg| { + let Value::String(name) = &arg.1 else { + panic!("name isn't a string") + }; + name.into() + }) + }) + .collect::>(); + let extra_templates = db + .additional_templates(&instrument, required_templates) + .await? + .into_iter() + .map(|template| { + ScanTemplate::new_checked(&template.template).map(|tmpl| (template.name, tmpl)) + }) + .collect::>()?; + Ok(ScanPaths { directory: DirectoryPath { instrument_session, info: next_scan, }, + extra_templates, subdirectory: sub.unwrap_or_default(), }) } @@ -451,6 +525,23 @@ impl Mutation { }; CurrentConfiguration::for_config(db_config, nt).await } + + #[instrument(skip(self, ctx))] + async fn register_template<'ctx>( + &self, + ctx: &Context<'ctx>, + instrument: String, + template: NamedTemplateInput, + ) -> async_graphql::Result { + check_auth(ctx, |pc, token| { + pc.check_instrument_admin(token, &instrument) + }) + .await?; + let db = ctx.data::()?; + Ok(db + .register_template(&instrument, template.name, template.template.to_string()) + .await?) + } } async fn check_auth<'ctx, Check, R>(ctx: &Context<'ctx>, check: Check) -> async_graphql::Result<()> diff --git a/static/service_schema.graphql b/static/service_schema.graphql index ddaf37f2..3cbbdc9b 100644 --- a/static/service_schema.graphql +++ b/static/service_schema.graphql @@ -124,6 +124,17 @@ type Mutation { Add or modify the stored configuration for an instrument """ configure(instrument: String!, config: ConfigurationUpdates!): CurrentConfiguration! + registerTemplate(instrument: String!, template: NamedTemplateInput!): NamedTemplate! +} + +type NamedTemplate { + name: String! + template: String! +} + +input NamedTemplateInput { + name: String! + template: ScanTemplate! } """ @@ -144,6 +155,7 @@ type Query { Can be filtered to provide one or more specific instruments """ configurations(instrumentFilters: [String!]): [CurrentConfiguration!]! + namedTemplates(instrument: String!, names: [String!]): [NamedTemplate!]! } """ @@ -163,6 +175,7 @@ type ScanPaths { The scan number for this scan. This should be unique for the requested instrument. """ scanNumber: Int! + template(name: String!): String! """ The paths where the given detectors should write their files.