From 8a9e3ea5d9f632075371987b82baaaeac3c8d5a7 Mon Sep 17 00:00:00 2001 From: Leo Valais Date: Mon, 1 Jul 2024 17:33:08 +0200 Subject: [PATCH] editoast: isolate search engine in a dedicated crate - Moves everything in `editoast_search` expect the endpoint. - Adapt paths generated by the `Search` derive macro. - Changes functions returning an editoast custom `Result` to return a standard one instead. - Forwards search engine errors up to the API level, with updated i18n keys. - Merges `views/search/mod.rs` and `views/search/objects.rs` into `views/search.rs`. --- editoast/Cargo.lock | 13 + editoast/Cargo.toml | 6 +- editoast/editoast_derive/src/search.rs | 44 +- editoast/editoast_search/Cargo.toml | 13 + .../search => editoast_search/src}/context.rs | 32 +- .../search => editoast_search/src}/dsl.rs | 89 ++-- editoast/editoast_search/src/lib.rs | 90 ++++ .../search => editoast_search/src}/process.rs | 18 +- .../src}/search_object.rs | 0 .../src}/searchast.rs | 28 +- .../src}/sql/insert_trigger_template.sql | 0 .../src}/sql/update_trigger_template.sql | 0 .../src}/sqlquery.rs | 4 +- .../search => editoast_search/src}/typing.rs | 31 +- editoast/openapi.yaml | 382 +-------------- editoast/src/main.rs | 3 +- .../src/views/{search/mod.rs => search.rs} | 449 +++++++++++++----- editoast/src/views/search/objects.rs | 313 ------------ front/public/locales/en/errors.json | 1 + front/public/locales/fr/errors.json | 1 + 20 files changed, 597 insertions(+), 920 deletions(-) create mode 100644 editoast/editoast_search/Cargo.toml rename editoast/{src/views/search => editoast_search/src}/context.rs (88%) rename editoast/{src/views/search => editoast_search/src}/dsl.rs (79%) create mode 100644 editoast/editoast_search/src/lib.rs rename editoast/{src/views/search => editoast_search/src}/process.rs (97%) rename editoast/{src/views/search => editoast_search/src}/search_object.rs (100%) rename editoast/{src/views/search => editoast_search/src}/searchast.rs (91%) rename editoast/{src/views/search => editoast_search/src}/sql/insert_trigger_template.sql (100%) rename editoast/{src/views/search => editoast_search/src}/sql/update_trigger_template.sql (100%) rename editoast/{src/views/search => editoast_search/src}/sqlquery.rs (99%) rename editoast/{src/views/search => editoast_search/src}/typing.rs (97%) rename editoast/src/views/{search/mod.rs => search.rs} (50%) delete mode 100644 editoast/src/views/search/objects.rs diff --git a/editoast/Cargo.lock b/editoast/Cargo.lock index cc9d2e9d572..b2988fcc96a 100644 --- a/editoast/Cargo.lock +++ b/editoast/Cargo.lock @@ -1365,6 +1365,7 @@ dependencies = [ "editoast_derive", "editoast_models", "editoast_schemas", + "editoast_search", "enum-map", "enumset", "futures 0.3.30", @@ -1481,6 +1482,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "editoast_search" +version = "0.1.0" +dependencies = [ + "itertools 0.13.0", + "serde", + "serde_json", + "strum", + "thiserror", + "utoipa", +] + [[package]] name = "either" version = "1.13.0" diff --git a/editoast/Cargo.toml b/editoast/Cargo.toml index 0c3383f4c27..893f1f99d2e 100644 --- a/editoast/Cargo.toml +++ b/editoast/Cargo.toml @@ -11,6 +11,7 @@ members = [ "editoast_derive", "editoast_models", "editoast_schemas", + "editoast_search", "osm_to_railjson", ] @@ -33,11 +34,13 @@ editoast_common = { path = "./editoast_common" } editoast_derive = { path = "./editoast_derive" } editoast_models = { path = "./editoast_models" } editoast_schemas = { path = "./editoast_schemas" } +editoast_search = { path = "./editoast_search" } enum-map = "2.7.3" futures = "0.3.30" futures-util = "0.3.30" geojson = "0.24" geos = { version = "8.3.1", features = ["json"] } +itertools = "0.13.0" mvt = "0.9.3" openssl = "0.10.64" paste = "1.0.15" @@ -82,6 +85,7 @@ editoast_common = { workspace = true } editoast_derive.workspace = true editoast_models.workspace = true editoast_schemas.workspace = true +editoast_search = { workspace = true } enum-map.workspace = true enumset = "1.1.3" futures.workspace = true @@ -90,7 +94,7 @@ geos.workspace = true heck = "0.5.0" image = "0.25.1" inventory = "0.3" -itertools = "0.13.0" +itertools.workspace = true json-patch = { version = "2.0.0", features = ["utoipa"] } mvt.workspace = true openssl.workspace = true diff --git a/editoast/editoast_derive/src/search.rs b/editoast/editoast_derive/src/search.rs index be8bde5f9ff..5569294419c 100644 --- a/editoast/editoast_derive/src/search.rs +++ b/editoast/editoast_derive/src/search.rs @@ -135,31 +135,31 @@ impl ColumnType { fn to_type_spec(&self) -> TokenStream { match self { ColumnType::Integer => { - quote! { crate::views::search::TypeSpec::Type(crate::views::search::AstType::Integer) } + quote! { editoast_search::TypeSpec::Type(editoast_search::AstType::Integer) } } ColumnType::Float => { - quote! { crate::views::search::TypeSpec::Type(crate::views::search::AstType::Float) } + quote! { editoast_search::TypeSpec::Type(editoast_search::AstType::Float) } } ColumnType::String | ColumnType::TextualSearchString => { - quote! { crate::views::search::TypeSpec::Type(crate::views::search::AstType::String) } + quote! { editoast_search::TypeSpec::Type(editoast_search::AstType::String) } } ColumnType::Boolean => { - quote! { crate::views::search::TypeSpec::Type(crate::views::search::AstType::Boolean) } + quote! { editoast_search::TypeSpec::Type(editoast_search::AstType::Boolean) } } ColumnType::Null => { - quote! { crate::views::search::TypeSpec::Type(crate::views::search::AstType::Null) } + quote! { editoast_search::TypeSpec::Type(editoast_search::AstType::Null) } } ColumnType::Sequence(ct) => { let ts = ct.to_type_spec(); - quote! { crate::views::search::TypeSpec::Sequence(Box::new(#ts)) } + quote! { editoast_search::TypeSpec::Sequence(Box::new(#ts)) } } } } fn index(&self) -> TokenStream { match self { - ColumnType::TextualSearchString => quote! { crate::views::search::Index::GinTrgm }, - _ => quote! { crate::views::search::Index::Default }, + ColumnType::TextualSearchString => quote! { editoast_search::Index::GinTrgm }, + _ => quote! { editoast_search::Index::Default }, } } } @@ -202,9 +202,9 @@ pub fn expand_search(input: &DeriveInput) -> Result { ))); } st = ColumnType::TextualSearchString; - quote! { crate::views::search::SearchType::Textual } + quote! { editoast_search::SearchType::Textual } } else { - quote! { crate::views::search::SearchType::None } + quote! { editoast_search::SearchType::None } }; let Some(sql) = sql else { return Err(Error::custom(format!( @@ -219,7 +219,7 @@ pub fn expand_search(input: &DeriveInput) -> Result { quote! { None } }; quote! { - Some(crate::views::search::CriteriaMigration { + Some(editoast_search::CriteriaMigration { sql_type: #data_type.to_owned(), sql: #sql.to_owned(), index: #index, @@ -230,7 +230,7 @@ pub fn expand_search(input: &DeriveInput) -> Result { quote! { None } }; criterias.push(quote! { - crate::views::search::Criteria { + editoast_search::Criteria { name: #name.to_owned(), data_type: #ts, migration: #migration, @@ -253,7 +253,7 @@ pub fn expand_search(input: &DeriveInput) -> Result { None => quote! { None }, }; let sql = prop.sql; - properties.push(quote! { crate::views::search::Property { + properties.push(quote! { editoast_search::Property { name: #name.to_owned(), sql: #sql.to_owned(), data_type: #ts, @@ -278,7 +278,7 @@ pub fn expand_search(input: &DeriveInput) -> Result { None => quote! { None }, }; quote! { - Some(crate::views::search::Migration { + Some(editoast_search::Migration { src_table: #src_table.to_owned(), src_primary_key: #src_primary_key.to_owned(), query_joins: #query_joins.to_owned(), @@ -298,9 +298,9 @@ pub fn expand_search(input: &DeriveInput) -> Result { .push((name.clone(), struct_name.to_string())); } Ok(quote! { - impl crate::views::search::SearchObject for #struct_name { - fn search_config() -> crate::views::search::SearchConfig { - crate::views::search::SearchConfig { + impl editoast_search::SearchObject for #struct_name { + fn search_config() -> editoast_search::SearchConfig { + editoast_search::SearchConfig { name: #name.to_owned(), table: #table.to_owned(), joins: #joins, @@ -324,16 +324,16 @@ pub fn expand_store(input: &DeriveInput) -> Result { }) .unzip(); Ok(quote! { - impl crate::views::search::SearchConfigStore for #name { - fn find>(object_name: S) -> Option { + impl editoast_search::SearchConfigStore for #name { + fn find>(object_name: S) -> Option { match object_name.as_ref() { - #(#object_name => Some(< #ident as crate::views::search::SearchObject > :: search_config())),* , + #(#object_name => Some(< #ident as editoast_search::SearchObject > :: search_config())),* , _ => None } } - fn all() -> Vec { - vec![#(< #ident as crate::views::search::SearchObject > :: search_config()),*] + fn all() -> Vec { + vec![#(< #ident as editoast_search::SearchObject > :: search_config()),*] } } diff --git a/editoast/editoast_search/Cargo.toml b/editoast/editoast_search/Cargo.toml new file mode 100644 index 00000000000..448872c1f08 --- /dev/null +++ b/editoast/editoast_search/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "editoast_search" +version = "0.1.0" +edition = "2021" +license = "LGPL-3.0" + +[dependencies] +itertools.workspace = true +serde.workspace = true +serde_json.workspace = true +strum.workspace = true +thiserror.workspace = true +utoipa.workspace = true diff --git a/editoast/src/views/search/context.rs b/editoast/editoast_search/src/context.rs similarity index 88% rename from editoast/src/views/search/context.rs rename to editoast/editoast_search/src/context.rs index bba994c230b..8f2539c238e 100644 --- a/editoast/src/views/search/context.rs +++ b/editoast/editoast_search/src/context.rs @@ -3,13 +3,9 @@ use std::collections::HashMap; use std::rc::Rc; -use editoast_derive::EditoastError; -use thiserror::Error; - use super::sqlquery::SqlQuery; use super::typing::AstType; use super::typing::TypeSpec; -use crate::error::Result; /// Represents a [super::searchast::SearchAst] that also carries valid type information /// @@ -84,28 +80,32 @@ where } } -#[derive(Debug, Error, EditoastError)] -#[editoast_error(base_id = "search")] +#[derive(Debug, thiserror::Error)] pub enum ProcessingError { #[error("undefined function '{function}'")] UndefinedFunction { function: String }, #[error("no suitable overload of '{0}' found for {1}")] UndefinedOverload(String, String), + #[error("type mismatch in function '{in_function}' call: {type_error}")] + FunctionTypeMismatch { + type_error: crate::typing::TypeCheckError, + in_function: String, + }, #[error("unexpected column '{column}'")] UnexpectedColumn { column: String }, #[error("expected type {expected}, got value '{value}' of type {actual} instead")] - #[editoast_error(no_context)] RuntimeTypeCheckFail { value: String, expected: TypeSpec, actual: TypeSpec, }, #[error("expected value of type {expected}, but got ersatz '{value}'")] - #[editoast_error(no_context)] UnexpectedErsatz { value: String, expected: TypeSpec }, + #[error("an error occurred while running function '{function}': {error}")] + FunctionError { function: String, error: String }, } -pub type QueryFunctionFn = Rc) -> Result>; +pub type QueryFunctionFn = Rc) -> Result>; /// Represents a context function, with a name and a type signature pub struct QueryFunction { @@ -153,7 +153,7 @@ impl QueryContext { &self, function_name: &String, arglist_types: &[TypeSpec], - ) -> Result<&QueryFunction> { + ) -> Result<&QueryFunction, ProcessingError> { let functions = self.functions.get(function_name).ok_or_else(|| { ProcessingError::UndefinedFunction { function: function_name.to_owned(), @@ -168,7 +168,10 @@ impl QueryContext { function .signature .typecheck_args(arglist_types) - .map_err(|err| err.with_context("in function", function_name.to_owned()))?; + .map_err(|err| ProcessingError::FunctionTypeMismatch { + type_error: err, + in_function: function_name.to_owned(), + })?; Ok(function) } _ => 'find_overload: { @@ -195,7 +198,11 @@ impl QueryContext { } /// Calls the function `function_name` with `args` and returns its result - pub fn call>(&self, function_name: S, args: Vec) -> Result { + pub fn call>( + &self, + function_name: S, + args: Vec, + ) -> Result { let function_name: String = function_name.as_ref().into(); let values_types = args .iter() @@ -203,6 +210,5 @@ impl QueryContext { .collect::>(); let function = self.find_function(&function_name, &values_types)?; (function.fun)(args) - .map_err(|err| err.with_context("in function", function_name.to_owned())) } } diff --git a/editoast/src/views/search/dsl.rs b/editoast/editoast_search/src/dsl.rs similarity index 79% rename from editoast/src/views/search/dsl.rs rename to editoast/editoast_search/src/dsl.rs index a41e2d3b971..f3517c2dd0c 100644 --- a/editoast/src/views/search/dsl.rs +++ b/editoast/editoast_search/src/dsl.rs @@ -9,8 +9,7 @@ use super::context::QueryContext; use super::typing::TypeSpec; use super::AstType; use super::TypedAst; -use crate::error::Result; -use crate::views::search::sqlquery::SqlQuery; +use crate::sqlquery::SqlQuery; /// Trait that should be implemented by all DSL specifiers types to provide /// the required compile-time information to the [QueryContext::def_function_1()]-like @@ -25,7 +24,7 @@ use crate::views::search::sqlquery::SqlQuery; /// defining a binary function multiply that accepts two integers, possibly NULL, that /// cannot be ersatzes (cf. [TypedAst::is_ersatz()]): /// -/// ``` +/// ```ignore /// context.def_function( /// "*", /// TypeSpec::or(AstType::Integer, AstType::Null) @@ -57,7 +56,7 @@ use crate::views::search::sqlquery::SqlQuery; /// /// Rewriting the function defined above using the DSL could be done this way: /// -/// ``` +/// ```ignore /// context /// .def_function_2::, dsl::Nullable, dsl::Integer>( /// "*", @@ -88,23 +87,21 @@ pub trait Type { type ReturnType; fn type_spec() -> TypeSpec; - fn into_arg(value: TypedAst) -> Result; - fn from_return(value: Self::ReturnType) -> Result; + fn into_arg(value: TypedAst) -> Result; + fn from_return(value: Self::ReturnType) -> Result; - fn typecheck(value: &TypedAst) -> Result<()> { + fn typecheck(value: &TypedAst) -> Result<(), ProcessingError> { if value.is_ersatz() { Err(ProcessingError::UnexpectedErsatz { value: format!("{value:?}"), expected: Self::type_spec(), - } - .into()) + }) } else if !Self::type_spec().is_supertype_spec(&value.type_spec()) { Err(ProcessingError::RuntimeTypeCheckFail { value: format!("{value:?}"), expected: Self::type_spec(), actual: value.type_spec(), - } - .into()) + }) } else { Ok(()) } @@ -141,12 +138,12 @@ impl Type for Null { TypeSpec::Type(AstType::Null) } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; Ok(()) } - fn from_return(_: Self::ReturnType) -> Result { + fn from_return(_: Self::ReturnType) -> Result { Ok(TypedAst::Null) } } @@ -159,7 +156,7 @@ impl Type for Boolean { TypeSpec::Type(AstType::Boolean) } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; let TypedAst::Boolean(b) = value else { unreachable!(); @@ -167,7 +164,7 @@ impl Type for Boolean { Ok(b) } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { Ok(TypedAst::Boolean(value)) } } @@ -180,7 +177,7 @@ impl Type for Integer { TypeSpec::Type(AstType::Integer) } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; let TypedAst::Integer(n) = value else { unreachable!(); @@ -188,7 +185,7 @@ impl Type for Integer { Ok(n) } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { Ok(TypedAst::Integer(value)) } } @@ -201,7 +198,7 @@ impl Type for Float { TypeSpec::Type(AstType::Float) } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; let TypedAst::Float(n) = value else { unreachable!(); @@ -209,7 +206,7 @@ impl Type for Float { Ok(n) } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { Ok(TypedAst::Float(value)) } } @@ -222,7 +219,7 @@ impl Type for String { TypeSpec::Type(AstType::String) } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; let TypedAst::String(s) = value else { unreachable!(); @@ -230,7 +227,7 @@ impl Type for String { Ok(s) } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { Ok(TypedAst::String(value)) } } @@ -243,7 +240,7 @@ impl Type for Sql { T::type_spec() } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; let TypedAst::Sql(sql, _) = value else { unreachable!(); @@ -251,7 +248,7 @@ impl Type for Sql { Ok(*sql) } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { if let SqlQuery::Value(value) = value { Ok(value) } else { @@ -268,17 +265,17 @@ impl Type for Ersatz { T::type_spec() } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; Ok(value) } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { Self::typecheck(&value)?; Ok(value) } - fn typecheck(value: &TypedAst) -> Result<()> { + fn typecheck(value: &TypedAst) -> Result<(), ProcessingError> { match value { TypedAst::Column { spec, .. } | TypedAst::Sql(_, spec) => { if Self::type_spec().is_supertype_spec(spec) { @@ -288,8 +285,7 @@ impl Type for Ersatz { value: format!("{value:?}"), expected: Self::type_spec(), actual: value.type_spec(), - } - .into()) + }) } } value => T::typecheck(value), @@ -305,7 +301,7 @@ impl Type for Nullable { TypeSpec::Union(Box::new(T::type_spec()), Box::new(Null::type_spec())) } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { if let TypedAst::Null = value { Ok(None) } else { @@ -313,7 +309,7 @@ impl Type for Nullable { } } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { Ok(match value { Some(value) => T::from_return(value)?, None => TypedAst::Null, @@ -329,7 +325,7 @@ impl Type for Array { TypeSpec::seq(T::type_spec()) } - fn into_arg(value: TypedAst) -> Result { + fn into_arg(value: TypedAst) -> Result { Self::typecheck(&value)?; let TypedAst::Sequence(items, _) = value else { unreachable!() @@ -337,12 +333,12 @@ impl Type for Array { items.into_iter().map(T::into_arg).collect() } - fn from_return(value: Self::ReturnType) -> Result { + fn from_return(value: Self::ReturnType) -> Result { Ok(TypedAst::Sequence( value .into_iter() .map(T::from_return) - .collect::>>()?, + .collect::, ProcessingError>>()?, T::type_spec(), )) } @@ -351,7 +347,7 @@ impl Type for Array { impl QueryContext { /// Defines a unary function using the [Type] DSL /// - /// ``` + /// ```ignore /// let mut context = QueryContext::default(); /// context.def_function_1::>, dsl::Sql>( /// "not", @@ -360,23 +356,31 @@ impl QueryContext { /// ``` /// /// See [Type] for information about the DSL itself + #[allow(clippy::type_complexity)] pub fn def_function_1( &mut self, name: &'static str, - fun: Rc Result>, + fun: Rc Result>, ) { self.def_function( name, P1::type_spec() >> R::type_spec(), Rc::new(move |args| { - R::from_return(fun(P1::into_arg(args.into_iter().next().unwrap())?)?) + let value = + fun(P1::into_arg(args.into_iter().next().unwrap())?).map_err(|error| { + ProcessingError::FunctionError { + function: name.to_owned(), + error, + } + })?; + R::from_return(value) }), ) } /// Defines a binary function using the [Type] DSL /// - /// ``` + /// ```ignore /// let mut env = create_processing_context(); /// env.def_function_2::( /// "+", @@ -389,17 +393,22 @@ impl QueryContext { pub fn def_function_2( &mut self, name: &'static str, - fun: Rc Result>, + fun: Rc Result>, ) { self.def_function( name, P1::type_spec() >> P2::type_spec() >> R::type_spec(), Rc::new(move |args| { let mut args = args.into_iter(); - R::from_return(fun( + let value = fun( P1::into_arg(args.next().unwrap())?, P2::into_arg(args.next().unwrap())?, - )?) + ) + .map_err(|error| ProcessingError::FunctionError { + function: name.to_owned(), + error, + })?; + R::from_return(value) }), ) } diff --git a/editoast/editoast_search/src/lib.rs b/editoast/editoast_search/src/lib.rs new file mode 100644 index 00000000000..66af4d5c8b9 --- /dev/null +++ b/editoast/editoast_search/src/lib.rs @@ -0,0 +1,90 @@ +pub mod context; +pub mod dsl; +pub mod process; +pub mod search_object; +pub mod searchast; +pub mod sqlquery; +pub mod typing; + +// TODO: figure out public api, such visibility is not needed, much wow + +pub use context::*; +pub use process::*; +pub use search_object::*; +pub use searchast::*; +pub use sqlquery::*; +pub use typing::*; + +#[derive(Debug, thiserror::Error)] +pub enum SearchError { + #[error("object type '{object_type}' is invalid")] + ObjectType { object_type: String }, + #[error("query has type '{query_type}' but Boolean is expected")] + QueryAst { query_type: String }, + #[error(transparent)] + TypeCheckError(#[from] TypeCheckError), + #[error(transparent)] + ProcessingError(#[from] ProcessingError), + #[error(transparent)] + SearchAstError(#[from] SearchAstError), +} + +impl SearchConfig { + fn result_columns(&self) -> String { + self.properties + .iter() + .map(|Property { name, sql, .. }| format!("({sql}) AS \"{name}\"")) + .collect::>() + .join(", ") + } + + fn create_context(&self) -> QueryContext { + let mut context = create_processing_context(); + context.search_table_name = Some(self.table.to_owned()); + // Register known columns with their expected type + for Criteria { + name, data_type, .. + } in self.criterias.iter() + { + context + .columns_type + .insert(name.to_string(), data_type.clone()); + } + context + } +} + +pub fn query_into_sql( + query: serde_json::Value, + search_config: &SearchConfig, + limit: i64, + offset: i64, + column_name: &'static str, +) -> Result<(String, Vec), SearchError> { + let ast = SearchAst::build_ast(query)?; + let context = search_config.create_context(); + let search_ast_expression_type = context.typecheck_search_query(&ast)?; + if !AstType::Boolean.is_supertype_spec(&search_ast_expression_type) { + return Err(SearchError::QueryAst { + query_type: search_ast_expression_type.to_string(), + }); + } + let where_expression = context.search_ast_to_sql(&ast)?; + let table = &search_config.table; + let joins = search_config.joins.as_ref().cloned().unwrap_or_default(); + let result_columns = search_config.result_columns(); + let mut bindings = Default::default(); + let constraints = where_expression.to_sql(&mut bindings); + let sql_code = format!( + "WITH _RESULT AS ( + SELECT {result_columns} + FROM {table} + {joins} + WHERE {constraints} + LIMIT {limit} OFFSET {offset} + ) + SELECT to_jsonb(_RESULT) AS {column_name} + FROM _RESULT" + ); + Ok((sql_code, bindings)) +} diff --git a/editoast/src/views/search/process.rs b/editoast/editoast_search/src/process.rs similarity index 97% rename from editoast/src/views/search/process.rs rename to editoast/editoast_search/src/process.rs index d4349f9c4ff..b1db0a7c512 100644 --- a/editoast/src/views/search/process.rs +++ b/editoast/editoast_search/src/process.rs @@ -10,12 +10,14 @@ use super::searchast::SearchAst; use super::sqlquery::SqlQuery; use super::typing::AstType; use super::typing::TypeSpec; -use crate::error::Result; impl QueryContext { /// Typechecks a [SearchAst], returning the type of the whole expressions /// if typechecking succeeds - pub fn typecheck_search_query(&self, search_ast: &SearchAst) -> Result { + pub fn typecheck_search_query( + &self, + search_ast: &SearchAst, + ) -> Result { let typed_ast = match search_ast { SearchAst::Null => AstType::Null.into(), SearchAst::Boolean(_) => AstType::Boolean.into(), @@ -33,7 +35,7 @@ impl QueryContext { let arglist_types = args .iter() .map(|arg| self.typecheck_search_query(arg)) - .collect::>>()?; + .collect::, _>>()?; let function = self.find_function(function_name, &arglist_types)?; let TypeSpec::Function { result, .. } = &function.signature else { unreachable!() @@ -46,7 +48,7 @@ impl QueryContext { /// Evaluates a [SearchAst] within the context, producing a [TypedAst] /// that can later be converted to an [SqlQuery] - pub fn evaluate_ast(&self, ast: &SearchAst) -> Result { + pub fn evaluate_ast(&self, ast: &SearchAst) -> Result { match ast { SearchAst::Null => Ok(TypedAst::Null), SearchAst::Boolean(b) => Ok(TypedAst::Boolean(*b)), @@ -66,7 +68,7 @@ impl QueryContext { let args = query_args .iter() .map(|sub| self.evaluate_ast(sub)) - .collect::>>()?; + .collect::, _>>()?; self.call(function_name, args) } } @@ -75,7 +77,7 @@ impl QueryContext { /// Evaluates a [SearchAst] within the context and returns the generated [SqlQuery] /// /// See [Self::evaluate_ast()] - pub fn search_ast_to_sql(&self, ast: &SearchAst) -> Result { + pub fn search_ast_to_sql(&self, ast: &SearchAst) -> Result { Ok(self.evaluate_ast(ast)?.into()) } } @@ -235,13 +237,13 @@ mod tests { env } - fn typecheck(query: Value) -> Result { + fn typecheck(query: Value) -> Result { let expr = SearchAst::build_ast(query).unwrap(); let env = test_env(); env.typecheck_search_query(&expr) } - fn try_eval(query: Value) -> Result { + fn try_eval(query: Value) -> Result { let expr = SearchAst::build_ast(query).unwrap(); let env = test_env(); env.evaluate_ast(&expr) diff --git a/editoast/src/views/search/search_object.rs b/editoast/editoast_search/src/search_object.rs similarity index 100% rename from editoast/src/views/search/search_object.rs rename to editoast/editoast_search/src/search_object.rs diff --git a/editoast/src/views/search/searchast.rs b/editoast/editoast_search/src/searchast.rs similarity index 91% rename from editoast/src/views/search/searchast.rs rename to editoast/editoast_search/src/searchast.rs index b8a7d6f2fd2..81b25421d7b 100644 --- a/editoast/src/views/search/searchast.rs +++ b/editoast/editoast_search/src/searchast.rs @@ -2,14 +2,9 @@ use std::fmt::Debug; -use editoast_derive::EditoastError; use serde_json::Value; -use thiserror::Error; -use crate::error::Result; - -#[derive(Debug, PartialEq, Error, EditoastError)] -#[editoast_error(base_id = "search")] +#[derive(Debug, thiserror::Error)] pub enum SearchAstError { #[error("could not convert {value} to i64")] IntegerConversion { value: u64 }, @@ -39,7 +34,7 @@ pub enum SearchAstError { /// /// Note that the empty array `[]` and all objects `{...}` are invalid queries. /// -/// ``` +/// ```ignore /// assert!(SearchAst::build_ast(json!( /// ["and", ["=", ["a"], 12], /// ["=", ["b"], ["=", true, ["c"]]], @@ -58,28 +53,25 @@ pub enum SearchAst { } impl SearchAst { - pub fn build_ast(value: Value) -> Result { + pub fn build_ast(value: Value) -> Result { match value { Value::Null => Ok(SearchAst::Null), Value::Bool(b) => Ok(SearchAst::Boolean(b)), Value::Number(n) if n.is_u64() => i64::try_from(n.as_u64().unwrap()) .map(SearchAst::Integer) - .map_err(|_| { - SearchAstError::IntegerConversion { - value: n.as_u64().unwrap(), - } - .into() + .map_err(|_| SearchAstError::IntegerConversion { + value: n.as_u64().unwrap(), }), Value::Number(n) if n.is_i64() => Ok(SearchAst::Integer(n.as_i64().unwrap())), Value::Number(n) => Ok(SearchAst::Float(n.as_f64().unwrap())), Value::String(s) => Ok(SearchAst::String(s)), - Value::Array(arr) if arr.is_empty() => Err(SearchAstError::EmptyArray.into()), + Value::Array(arr) if arr.is_empty() => Err(SearchAstError::EmptyArray), Value::Array(mut arr) if arr.len() == 1 => { let first = arr.pop().unwrap(); if first.is_string() && !first.as_str().unwrap().is_empty() { Ok(SearchAst::Column(first.as_str().unwrap().into())) } else { - Err(SearchAstError::InvalidColumnName { value: first }.into()) + Err(SearchAstError::InvalidColumnName { value: first }) } } Value::Array(mut arr) => { @@ -89,13 +81,13 @@ impl SearchAst { let args = args .iter() .map(|val| SearchAst::build_ast(val.clone())) - .collect::>>()?; + .collect::, _>>()?; Ok(SearchAst::Call(first.as_str().unwrap().into(), args)) } else { - Err(SearchAstError::InvalidFunctionIdentifier { value: first }.into()) + Err(SearchAstError::InvalidFunctionIdentifier { value: first }) } } - Value::Object(_) => Err(SearchAstError::InvalidSyntax { value }.into()), + Value::Object(_) => Err(SearchAstError::InvalidSyntax { value }), } } } diff --git a/editoast/src/views/search/sql/insert_trigger_template.sql b/editoast/editoast_search/src/sql/insert_trigger_template.sql similarity index 100% rename from editoast/src/views/search/sql/insert_trigger_template.sql rename to editoast/editoast_search/src/sql/insert_trigger_template.sql diff --git a/editoast/src/views/search/sql/update_trigger_template.sql b/editoast/editoast_search/src/sql/update_trigger_template.sql similarity index 100% rename from editoast/src/views/search/sql/update_trigger_template.sql rename to editoast/editoast_search/src/sql/update_trigger_template.sql diff --git a/editoast/src/views/search/sqlquery.rs b/editoast/editoast_search/src/sqlquery.rs similarity index 99% rename from editoast/src/views/search/sqlquery.rs rename to editoast/editoast_search/src/sqlquery.rs index d8d7faf5a3a..d41c33ce9ee 100644 --- a/editoast/src/views/search/sqlquery.rs +++ b/editoast/editoast_search/src/sqlquery.rs @@ -172,8 +172,8 @@ fn value_to_sql(value: &TypedAst, string_bindings: &mut Vec) -> String { mod test { use super::SqlQuery; - use crate::views::search::context::TypedAst; - use crate::views::search::typing::AstType; + use crate::context::TypedAst; + use crate::typing::AstType; #[test] fn render_literal() { diff --git a/editoast/src/views/search/typing.rs b/editoast/editoast_search/src/typing.rs similarity index 97% rename from editoast/src/views/search/typing.rs rename to editoast/editoast_search/src/typing.rs index bdedc65266c..b88ade1a9fc 100644 --- a/editoast/src/views/search/typing.rs +++ b/editoast/editoast_search/src/typing.rs @@ -7,33 +7,24 @@ use std::hash::Hasher; use std::hash::{self}; use std::ops::Shr; -use editoast_derive::EditoastError; use serde::Deserialize; -use thiserror::Error; -use crate::error::Result; - -#[derive(Debug, PartialEq, Error, EditoastError)] -#[editoast_error(base_id = "search")] -enum TypeCheckError { +#[derive(Debug, thiserror::Error)] +pub enum TypeCheckError { #[error("expected variadic argument of type {expected}, but got {actual}")] - #[editoast_error(no_context)] VariadicArgTypeMismatch { expected: TypeSpec, actual: TypeSpec, }, #[error("expected argument of type {expected} at position {arg_pos}, but got {actual}")] - #[editoast_error(no_context)] ArgTypeMismatch { expected: TypeSpec, actual: TypeSpec, arg_pos: usize, }, #[error("expected argument of type {expected} at position {arg_pos} is missing")] - #[editoast_error(no_context)] ArgMissing { expected: TypeSpec, arg_pos: usize }, #[error("unexpected argument of type {arg_type} found")] - #[editoast_error(no_context)] UnexpectedArg { arg_type: TypeSpec }, } @@ -199,7 +190,7 @@ impl TypeSpec { /// list as arguments /// /// Panics if `self` is not a [TypeSpec::Function] signature. - pub fn typecheck_args(&self, args: &[TypeSpec]) -> Result<()> { + pub fn typecheck_args(&self, args: &[TypeSpec]) -> Result<(), TypeCheckError> { let TypeSpec::Function { args: fargs, .. } = self else { panic!("typecheck_args called with {self:?} which is not a function signature"); }; @@ -213,8 +204,7 @@ impl TypeSpec { return Err(TypeCheckError::VariadicArgTypeMismatch { expected: (**expected).clone(), actual: (*arg).clone(), - } - .into()); + }); } } // We need to explicitly break here otherwise rust will try @@ -228,15 +218,13 @@ impl TypeSpec { expected: (*expected).clone(), actual: (*arg).clone(), arg_pos: i, - } - .into()) + }) } None => { return Err(TypeCheckError::ArgMissing { expected: (*expected).clone(), arg_pos: i, - } - .into()) + }) } } } @@ -244,8 +232,7 @@ impl TypeSpec { match args_iter.next() { Some(arg) => Err(TypeCheckError::UnexpectedArg { arg_type: (*arg).clone(), - } - .into()), + }), None => Ok(()), } } @@ -331,8 +318,8 @@ impl Eq for TypeSpec {} mod tests { use super::AstType; - use crate::views::search::typing::hash_eq; - use crate::views::search::typing::TypeSpec; + use crate::typing::hash_eq; + use crate::typing::TypeSpec; #[test] fn test_rhs_function_signature() { diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 3d232b3be50..c7d40dcecc0 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -2109,7 +2109,7 @@ paths: Where: - `object` can be any search object declared in `search.yml` - - `query` is a JSON document which can be deserialized into a [SearchAst]. + - `query` is a JSON document which can be deserialized into a [editoast_search::SearchAst]. Check out examples below. # Response @@ -2127,7 +2127,7 @@ paths: # Available functions - See [create_processing_context()]. + See [editoast_search::create_processing_context()]. # A few query examples @@ -2137,7 +2137,7 @@ paths: * All railway stations with "Paris" in their name but not PNO : `["and", ["search", ["name"], "Paris"], ["not", ["=", ["trigram"], "pno"]]]` - See [SearchAst] for a more detailed view of the query language. + See [editoast_search::SearchAst] for a more detailed view of the query language. parameters: - name: page in: query @@ -4298,11 +4298,6 @@ components: - $ref: '#/components/schemas/EditoastPostgresConfigErrorPassword' - $ref: '#/components/schemas/EditoastPostgresConfigErrorPort' - $ref: '#/components/schemas/EditoastPostgresConfigErrorUsername' - - $ref: '#/components/schemas/EditoastProcessingErrorRuntimeTypeCheckFail' - - $ref: '#/components/schemas/EditoastProcessingErrorUndefinedFunction' - - $ref: '#/components/schemas/EditoastProcessingErrorUndefinedOverload' - - $ref: '#/components/schemas/EditoastProcessingErrorUnexpectedColumn' - - $ref: '#/components/schemas/EditoastProcessingErrorUnexpectedErsatz' - $ref: '#/components/schemas/EditoastProjectErrorImageError' - $ref: '#/components/schemas/EditoastProjectErrorImageNotFound' - $ref: '#/components/schemas/EditoastProjectErrorNotFound' @@ -4323,13 +4318,8 @@ components: - $ref: '#/components/schemas/EditoastScenarioErrorNotFound' - $ref: '#/components/schemas/EditoastScenarioErrorNotFound' - $ref: '#/components/schemas/EditoastScenarioErrorTimetableNotFound' - - $ref: '#/components/schemas/EditoastSearchAstErrorEmptyArray' - - $ref: '#/components/schemas/EditoastSearchAstErrorIntegerConversion' - - $ref: '#/components/schemas/EditoastSearchAstErrorInvalidColumnName' - - $ref: '#/components/schemas/EditoastSearchAstErrorInvalidFunctionIdentifier' - - $ref: '#/components/schemas/EditoastSearchAstErrorInvalidSyntax' - - $ref: '#/components/schemas/EditoastSearchErrorObjectType' - - $ref: '#/components/schemas/EditoastSearchErrorQueryAst' + - $ref: '#/components/schemas/EditoastSearchApiErrorObjectType' + - $ref: '#/components/schemas/EditoastSearchApiErrorSearchEngineError' - $ref: '#/components/schemas/EditoastSingleSimulationErrorElectricalProfileSetNotFound' - $ref: '#/components/schemas/EditoastSingleSimulationErrorPathNotFound' - $ref: '#/components/schemas/EditoastSingleSimulationErrorRollingStockNotFound' @@ -4355,10 +4345,6 @@ components: - $ref: '#/components/schemas/EditoastTrainScheduleErrorBatchTrainScheduleNotFound' - $ref: '#/components/schemas/EditoastTrainScheduleErrorInfraNotFound' - $ref: '#/components/schemas/EditoastTrainScheduleErrorNotFound' - - $ref: '#/components/schemas/EditoastTypeCheckErrorArgMissing' - - $ref: '#/components/schemas/EditoastTypeCheckErrorArgTypeMismatch' - - $ref: '#/components/schemas/EditoastTypeCheckErrorUnexpectedArg' - - $ref: '#/components/schemas/EditoastTypeCheckErrorVariadicArgTypeMismatch' - $ref: '#/components/schemas/EditoastWorkScheduleErrorNameAlreadyUsed' description: Generated error type for Editoast discriminator: @@ -5062,130 +5048,6 @@ components: type: string enum: - editoast:postgres:Username - EditoastProcessingErrorRuntimeTypeCheckFail: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - actual - - expected - - value - properties: - actual: - type: object - expected: - type: object - value: - type: string - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:RuntimeTypeCheckFail - EditoastProcessingErrorUndefinedFunction: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - function - properties: - function: - type: string - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:UndefinedFunction - EditoastProcessingErrorUndefinedOverload: - type: object - required: - - type - - status - - message - properties: - context: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:UndefinedOverload - EditoastProcessingErrorUnexpectedColumn: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - column - properties: - column: - type: string - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:UnexpectedColumn - EditoastProcessingErrorUnexpectedErsatz: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - expected - - value - properties: - expected: - type: object - value: - type: string - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:UnexpectedErsatz EditoastProjectErrorImageError: type: object required: @@ -5636,122 +5498,7 @@ components: type: string enum: - editoast:scenario:TimetableNotFound - EditoastSearchAstErrorEmptyArray: - type: object - required: - - type - - status - - message - properties: - context: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:EmptyArray - EditoastSearchAstErrorIntegerConversion: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - value - properties: - value: - type: integer - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:IntegerConversion - EditoastSearchAstErrorInvalidColumnName: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - value - properties: - value: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:InvalidColumnName - EditoastSearchAstErrorInvalidFunctionIdentifier: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - value - properties: - value: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:InvalidFunctionIdentifier - EditoastSearchAstErrorInvalidSyntax: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - value - properties: - value: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:InvalidSyntax - EditoastSearchErrorObjectType: + EditoastSearchApiErrorObjectType: type: object required: - type @@ -5775,7 +5522,7 @@ components: type: string enum: - editoast:search:ObjectType - EditoastSearchErrorQueryAst: + EditoastSearchApiErrorSearchEngineError: type: object required: - type @@ -5784,11 +5531,6 @@ components: properties: context: type: object - required: - - query_type - properties: - query_type: - type: string message: type: string status: @@ -5798,7 +5540,7 @@ components: type: type: string enum: - - editoast:search:QueryAst + - editoast:search:SearchEngineError EditoastSingleSimulationErrorElectricalProfileSetNotFound: type: object required: @@ -6302,114 +6044,6 @@ components: type: string enum: - editoast:train_schedule:UnsimulatedTrainSchedule - EditoastTypeCheckErrorArgMissing: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - arg_pos - - expected - properties: - arg_pos: - type: integer - expected: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:ArgMissing - EditoastTypeCheckErrorArgTypeMismatch: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - actual - - arg_pos - - expected - properties: - actual: - type: object - arg_pos: - type: integer - expected: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:ArgTypeMismatch - EditoastTypeCheckErrorUnexpectedArg: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - arg_type - properties: - arg_type: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:UnexpectedArg - EditoastTypeCheckErrorVariadicArgTypeMismatch: - type: object - required: - - type - - status - - message - properties: - context: - type: object - required: - - actual - - expected - properties: - actual: - type: object - expected: - type: object - message: - type: string - status: - type: integer - enum: - - 400 - type: - type: string - enum: - - editoast:search:VariadicArgTypeMismatch EditoastWorkScheduleErrorNameAlreadyUsed: type: object required: diff --git a/editoast/src/main.rs b/editoast/src/main.rs index d6f8363e3e9..6a2b2b32f0e 100644 --- a/editoast/src/main.rs +++ b/editoast/src/main.rs @@ -52,6 +52,7 @@ use diesel_async::RunQueryDsl; use diesel_json::Json as DieselJson; use editoast_models::DbConnection; use editoast_schemas::infra::RailJson; +use editoast_search::{SearchConfig, SearchConfigStore}; use infra_cache::InfraCache; use map::MapLayers; use modelsv2::electrical_profiles::ElectricalProfileSet; @@ -71,7 +72,7 @@ use tracing::{error, info}; use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _, Layer as _}; use validator::ValidationErrorsKind; use views::infra::InfraApiError; -use views::search::{SearchConfig, SearchConfigFinder, SearchConfigStore}; +use views::search::SearchConfigFinder; /// The mode editoast is running in /// diff --git a/editoast/src/views/search/mod.rs b/editoast/src/views/search.rs similarity index 50% rename from editoast/src/views/search/mod.rs rename to editoast/src/views/search.rs index 56c4de16244..231aedc3869 100644 --- a/editoast/src/views/search/mod.rs +++ b/editoast/src/views/search.rs @@ -1,3 +1,6 @@ +// Clippy doesn't seem to understand the `Search` derive macro +#![allow(clippy::duplicated_attributes)] + //! Defines the route [search] that can efficiently search all objects declared //! in `search.yml` in a generic way //! @@ -123,9 +126,9 @@ //! //! The query in the payload is a JSON value that represent a boolean expression //! with operators and function calls in prefix notation. It is first checked -//! and converted to a [SearchAst] which is a formalization of the AST. +//! and converted to a [editoast_search::SearchAst] which is a formalization of the AST. //! -//! See [searchast]. +//! See [editoast_search::searchast]. //! //! # Type checking //! @@ -148,9 +151,9 @@ //! makes sense because the SQL LIKE operator expects a string pattern and //! not an integer. //! -//! The structure that represents that context is [QueryContext] which provides -//! the function [QueryContext::typecheck_search_query()]. The functions the -//! route [search] uses are defined by [create_processing_context()] and +//! The structure that represents that context is [editoast_search::QueryContext] which provides +//! the function [editoast_search::QueryContext::typecheck_search_query()]. The functions the +//! route [search] uses are defined by [editoast_search::create_processing_context()] and //! the columns defined in the `search.yml` configuration file are added to //! that context. //! @@ -159,7 +162,7 @@ //! # Evaluation //! //! In order to turn the ✨typechecked✨ query into an SQL statement, it is "evaluated" -//! within a [QueryContext] ; which contains several things: +//! within a [editoast_search::QueryContext] ; which contains several things: //! //! - The list of expected columns and their type. That data is extracted from `search.yml`. //! - The name of the search table (e.g.: `search_operational_point`). That @@ -169,13 +172,13 @@ //! (with possible overloads) and their definition. //! //! The transformation of the search expression into a valid SQL query occurs -//! in [QueryContext::search_ast_to_sql()], which will evaluate every function call, +//! in [editoast_search::QueryContext::search_ast_to_sql()], which will evaluate every function call, //! each one using the input arguments to build an SQL expression that will //! be part of the final SQL query. //! //! # SQL query construction and execution //! -//! The evaluation step produces an [sqlquery::SqlQuery] object that can +//! The evaluation step produces an [editoast_search::sqlquery::SqlQuery] object that can //! be converted to a string containing valid PostgreSQL code ready to be inserted //! into the search query's WHERE statement. //! @@ -194,43 +197,31 @@ // TODO: the documentation of this file needs to be updated (no more search.yml) -pub mod context; -pub mod dsl; -mod objects; -pub mod process; -mod search_object; -pub mod searchast; -pub mod sqlquery; -pub mod typing; - use actix_web::post; use actix_web::web::Data; use actix_web::web::Json; use actix_web::web::Query; use actix_web::HttpResponse; use actix_web::Responder; +use chrono::NaiveDateTime; use diesel::pg::Pg; -use diesel::query_builder::BoxedSqlQuery; use diesel::sql_query; use diesel::sql_types::Jsonb; use diesel::sql_types::Text; use diesel::QueryableByName; use diesel_async::RunQueryDsl; +use editoast_common::geometry::GeoJsonPoint; use editoast_derive::EditoastError; -pub use objects::SearchConfigFinder; +use editoast_derive::Search; +use editoast_derive::SearchConfigStore; +use editoast_search::query_into_sql; +use editoast_search::SearchConfigStore as _; +use editoast_search::SearchError; use serde::Deserialize; use serde::Serialize; use serde_json::value::Value as JsonValue; -use thiserror::Error; use utoipa::ToSchema; -use self::context::QueryContext; -use self::context::TypedAst; -use self::process::create_processing_context; -pub use self::search_object::*; -use self::searchast::SearchAst; -use self::typing::AstType; -use self::typing::TypeSpec; use crate::error::Result; use crate::views::pagination::PaginationQueryParam; use editoast_models::DbConnectionPool; @@ -242,41 +233,16 @@ crate::routes! { editoast_common::schemas! { SearchPayload, SearchQuery, - objects::SearchResultItem::schemas(), + SearchResultItem::schemas(), } -#[derive(Debug, Error, EditoastError)] +#[derive(Debug, thiserror::Error, EditoastError)] #[editoast_error(base_id = "search")] -enum SearchError { +enum SearchApiError { #[error("object type '{object_type}' is invalid")] ObjectType { object_type: String }, - #[error("query has type '{query_type}' but Boolean is expected")] - QueryAst { query_type: String }, -} - -impl SearchConfig { - fn result_columns(&self) -> String { - self.properties - .iter() - .map(|Property { name, sql, .. }| format!("({sql}) AS \"{name}\"")) - .collect::>() - .join(", ") - } - - fn create_context(&self) -> QueryContext { - let mut context = create_processing_context(); - context.search_table_name = Some(self.table.to_owned()); - // Register known columns with their expected type - for Criteria { - name, data_type, .. - } in self.criterias.iter() - { - context - .columns_type - .insert(name.to_string(), data_type.clone()); - } - context - } + #[error(transparent)] + SearchEngineError(#[from] SearchError), } /// A search query @@ -309,49 +275,10 @@ pub struct SearchPayload { dry: bool, } -fn create_sql_query( - query: JsonValue, - search_config: &SearchConfig, - limit: i64, - offset: i64, -) -> Result> { - let ast = SearchAst::build_ast(query)?; - let context = search_config.create_context(); - let search_ast_expression_type = context.typecheck_search_query(&ast)?; - if !AstType::Boolean.is_supertype_spec(&search_ast_expression_type) { - return Err(SearchError::QueryAst { - query_type: search_ast_expression_type.to_string(), - } - .into()); - } - let where_expression = context.search_ast_to_sql(&ast)?; - let table = &search_config.table; - let joins = search_config.joins.as_ref().cloned().unwrap_or_default(); - let result_columns = search_config.result_columns(); - let mut bindings = Default::default(); - let constraints = where_expression.to_sql(&mut bindings); - let sql_code = format!( - "WITH _RESULT AS ( - SELECT {result_columns} - FROM {table} - {joins} - WHERE {constraints} - LIMIT {limit} OFFSET {offset} - ) - SELECT to_jsonb(_RESULT) AS result - FROM _RESULT" - ); - let mut sql_query = sql_query(sql_code).into_boxed(); - for string in bindings { - sql_query = sql_query.bind::(string.to_owned()); - } - Ok(sql_query) -} - -#[derive(QueryableByName, Debug, Clone, Serialize, Deserialize)] +#[derive(QueryableByName)] struct SearchDBResult { #[diesel(sql_type = Jsonb)] - result: JsonValue, + result: diesel_json::Json, } /// Returns all infra objects of some type according to a hierarchical query. @@ -366,7 +293,7 @@ struct SearchDBResult { /// /// Where: /// - `object` can be any search object declared in `search.yml` -/// - `query` is a JSON document which can be deserialized into a [SearchAst]. +/// - `query` is a JSON document which can be deserialized into a [editoast_search::SearchAst]. /// Check out examples below. /// /// # Response @@ -384,7 +311,7 @@ struct SearchDBResult { /// /// # Available functions /// -/// See [create_processing_context()]. +/// See [editoast_search::create_processing_context()]. /// /// # A few query examples /// @@ -394,7 +321,7 @@ struct SearchDBResult { /// * All railway stations with "Paris" in their name but not PNO : /// `["and", ["search", ["name"], "Paris"], ["not", ["=", ["trigram"], "pno"]]]` /// -/// See [SearchAst] for a more detailed view of the query language. +/// See [editoast_search::SearchAst] for a more detailed view of the query language. #[utoipa::path( tag = "search", params(PaginationQueryParam), @@ -412,19 +339,329 @@ pub async fn search( let (page, per_page) = query_params.validate(1000)?.warn_page_size(100).unpack(); let Json(SearchPayload { object, query, dry }) = payload; let search_config = - SearchConfigFinder::find(&object).ok_or_else(|| SearchError::ObjectType { + SearchConfigFinder::find(&object).ok_or_else(|| SearchApiError::ObjectType { object_type: object.to_owned(), })?; let offset = (page - 1) * per_page; - let sql = create_sql_query(query, &search_config, per_page, offset)?; + let (sql, bindings) = query_into_sql(query, &search_config, per_page, offset, "result") + .map_err(SearchApiError::from)?; + + let mut query = sql_query(sql).into_boxed(); + for string in bindings { + query = query.bind::(string.to_owned()); + } if dry { - let query = diesel::debug_query::(&sql).to_string(); + let query = diesel::debug_query::(&query).to_string(); return Ok(HttpResponse::Ok().body(query)); } - let mut conn = db_pool.get().await?; - let objects: Vec = sql.load(&mut conn).await?; + let conn = &mut db_pool.get().await?; + let objects = query.load::(conn).await?; let results: Vec<_> = objects.into_iter().map(|r| r.result).collect(); Ok(HttpResponse::Ok().json(results)) } + +// NOTE: every structure deriving `Search` here might have to `#[allow(unused)]` +// because while the name and type information of the fields are read by the macro, +// they might not be explicitly used in the code. (Their JSON representation extracted +// from the DB query is direcly forwarded into the endpoint response, so these +// structs are never deserialized, hence their "non-usage".) +// +// These structs also derive Serialize because utoipa reads some `#[serde(...)]` +// annotations to alter the schema. That's not ideal since none of them are ever +// serialized, but that's life. + +#[derive(Search, Serialize, ToSchema)] +#[search( + name = "track", + table = "search_track", + column(name = "infra_id", data_type = "INT"), + column(name = "line_code", data_type = "INT"), + column(name = "line_name", data_type = "TEXT") +)] +#[allow(unused)] +/// A search result item for a query with `object = "track"` +/// +/// **IMPORTANT**: Please note that any modification to this struct should be reflected in [crate::modelsv2::infra::Infra::clone] +pub(super) struct SearchResultItemTrack { + #[search(sql = "search_track.infra_id")] + infra_id: i64, + #[search(sql = "search_track.unprocessed_line_name")] + line_name: String, + #[search(sql = "search_track.line_code")] + line_code: i64, +} + +#[derive(Search, Serialize, ToSchema)] +#[search( + name = "operationalpoint", + table = "search_operational_point", + migration(src_table = "infra_object_operational_point"), + joins = " + INNER JOIN infra_object_operational_point AS OP ON OP.id = search_operational_point.id + INNER JOIN (SELECT DISTINCT ON (infra_id, obj_id) * FROM infra_layer_operational_point) + AS lay ON OP.obj_id = lay.obj_id AND OP.infra_id = lay.infra_id", + column( + name = "obj_id", + data_type = "varchar(255)", + sql = "infra_object_operational_point.obj_id", + ), + column( + name = "infra_id", + data_type = "integer", + sql = "infra_object_operational_point.infra_id", + ), + column( + name = "uic", + data_type = "integer", + sql = "(infra_object_operational_point.data->'extensions'->'identifier'->>'uic')::integer", + ), + column( + name = "trigram", + data_type = "varchar(3)", + sql = "infra_object_operational_point.data->'extensions'->'sncf'->>'trigram'", + ), + column( + name = "ci", + data_type = "integer", + sql = "(infra_object_operational_point.data->'extensions'->'sncf'->>'ci')::integer", + ), + column( + name = "ch", + data_type = "text", + sql = "infra_object_operational_point.data->'extensions'->'sncf'->>'ch'", + ), + column( + name = "name", + data_type = "text", + sql = "infra_object_operational_point.data->'extensions'->'identifier'->>'name'", + textual_search, + ) +)] +#[allow(unused)] +/// A search result item for a query with `object = "operationalpoint"` +/// +/// **IMPORTANT**: Please note that any modification to this struct should be reflected in [crate::modelsv2::infra::Infra::clone] +pub(super) struct SearchResultItemOperationalPoint { + #[search(sql = "OP.obj_id")] + obj_id: String, + #[search(sql = "OP.infra_id")] + infra_id: i64, + #[search(sql = "OP.data->'extensions'->'identifier'->'uic'")] + uic: i64, + #[search(sql = "OP.data#>>'{extensions,identifier,name}'")] + name: String, + #[search(sql = "OP.data#>>'{extensions,sncf,trigram}'")] + trigram: String, + #[search(sql = "OP.data#>>'{extensions,sncf,ch}'")] + ch: String, + #[search(sql = "OP.data#>>'{extensions,sncf,ci}'")] + ci: u64, + #[search(sql = "ST_AsGeoJSON(ST_Transform(lay.geographic, 4326))::json")] + geographic: GeoJsonPoint, + #[search(sql = "OP.data->'parts'")] + #[schema(inline)] + track_sections: Vec, +} +#[derive(Serialize, ToSchema)] +#[allow(unused)] +pub(super) struct SearchResultItemOperationalPointTrackSections { + track: String, + position: f64, +} + +#[derive(Search, Serialize, ToSchema)] +#[search( + name = "signal", + table = "search_signal", + migration( + src_table = "infra_object_signal", + query_joins = " + INNER JOIN infra_object_track_section AS track_section + ON track_section.infra_id = infra_object_signal.infra_id + AND track_section.obj_id = infra_object_signal.data->>'track'", + ), + column( + name = "label", + data_type = "text", + sql = "infra_object_signal.data->'extensions'->'sncf'->>'label'", + textual_search + ), + column( + name = "line_name", + data_type = "text", + sql = "track_section.data->'extensions'->'sncf'->>'line_name'", + textual_search + ), + column( + name = "infra_id", + data_type = "integer", + sql = "infra_object_signal.infra_id" + ), + column( + name = "obj_id", + data_type = "VARCHAR(255)", + sql = "infra_object_signal.obj_id" + ), + column( + name = "signaling_systems", + data_type = "TEXT[]", + sql = "ARRAY(SELECT jsonb_path_query(infra_object_signal.data, '$.logical_signals[*].signaling_system')->>0)" + ), + column( + name = "settings", + data_type = "TEXT[]", + sql = "ARRAY(SELECT jsonb_path_query(infra_object_signal.data, '$.logical_signals[*].settings.keyvalue().key')->>0)" + ), + column( + name = "line_code", + data_type = "integer", + sql = "(track_section.data->'extensions'->'sncf'->>'line_code')::integer" + ), + joins = " + INNER JOIN infra_object_signal AS sig ON sig.id = search_signal.id + INNER JOIN infra_object_track_section AS track_section ON track_section.obj_id = sig.data->>'track' AND track_section.infra_id = sig.infra_id + INNER JOIN infra_layer_signal AS lay ON lay.infra_id = sig.infra_id AND lay.obj_id = sig.obj_id" +)] +#[allow(unused)] +/// A search result item for a query with `object = "signal"` +/// +/// **IMPORTANT**: Please note that any modification to this struct should be reflected in [crate::modelsv2::infra::Infra::clone] +pub(super) struct SearchResultItemSignal { + #[search(sql = "sig.infra_id")] + infra_id: i64, + #[search(sql = "sig.data->'extensions'->'sncf'->>'label'")] + label: String, + #[search(sql = "search_signal.signaling_systems")] + signaling_systems: Vec, + #[search(sql = "search_signal.settings")] + settings: Vec, + #[search(sql = "search_signal.line_code")] + line_code: u64, + #[search(sql = "track_section.data->'extensions'->'sncf'->>'line_name'")] + line_name: String, + #[search(sql = "ST_AsGeoJSON(ST_Transform(lay.geographic, 4326))::json")] + geographic: GeoJsonPoint, + #[search(sql = "lay.signaling_system")] + sprite_signaling_system: Option, + #[search(sql = "lay.sprite")] + sprite: Option, +} + +#[derive(Search, Serialize, ToSchema)] +#[search( + name = "project", + table = "search_project", + joins = "INNER JOIN project ON project.id = search_project.id", + column(name = "id", data_type = "integer"), + column(name = "name", data_type = "string"), + column(name = "description", data_type = "string"), + column(name = "tags", data_type = "string") +)] +#[allow(unused)] +/// A search result item for a query with `object = "project"` +pub(super) struct SearchResultItemProject { + #[search(sql = "project.id")] + id: u64, + #[search(sql = "project.image_id")] + #[schema(required)] + image: Option, + #[search(sql = "project.name")] + name: String, + #[search( + sql = "(SELECT COUNT(study.id) FROM study WHERE search_project.id = study.project_id)" + )] + studies_count: u64, + #[search(sql = "project.description")] + description: String, + #[search(sql = "project.last_modification")] + last_modification: NaiveDateTime, + #[search(sql = "project.tags")] + tags: Vec, +} + +#[derive(Search, Serialize, ToSchema)] +#[search( + name = "study", + table = "search_study", + migration(src_table = "study"), + joins = "INNER JOIN study ON study.id = search_study.id", + column(name = "name", data_type = "TEXT", sql = "study.name"), + column(name = "description", data_type = "TEXT", sql = "study.description"), + column( + name = "tags", + data_type = "TEXT", + sql = "osrd_prepare_for_search_tags(study.tags)" + ), + column(name = "project_id", data_type = "INTEGER", sql = "study.project_id") +)] +#[allow(unused)] +/// A search result item for a query with `object = "study"` +pub(super) struct SearchResultItemStudy { + #[search(sql = "study.id")] + id: u64, + #[search(sql = "study.project_id")] + project_id: u64, + #[search(sql = "study.name")] + name: String, + #[search( + sql = "(SELECT COUNT(scenario.id) FROM scenario WHERE search_study.id = scenario.study_id)" + )] + scenarios_count: u64, + #[search(sql = "study.description")] + #[schema(required)] + description: Option, + #[search(sql = "study.last_modification")] + last_modification: NaiveDateTime, + #[search(sql = "study.tags")] + tags: Vec, + #[search(sql = "study.budget")] + #[schema(required)] + budget: Option, +} + +#[derive(Search, Serialize, ToSchema)] +#[search( + name = "scenario", + table = "search_scenario", + joins = " + INNER JOIN scenario ON scenario.id = search_scenario.id + INNER JOIN infra ON infra.id = scenario.infra_id", + column(name = "id", data_type = "integer"), + column(name = "name", data_type = "string"), + column(name = "description", data_type = "string"), + column(name = "tags", data_type = "string"), + column(name = "study_id", data_type = "integer") +)] +#[allow(unused)] +/// A search result item for a query with `object = "scenario"` +pub(super) struct SearchResultItemScenario { + #[search(sql = "scenario.id")] + id: u64, + #[search(sql = "scenario.study_id")] + study_id: u64, + #[search(sql = "scenario.name")] + name: String, + #[search(sql = "scenario.electrical_profile_set_id")] + #[schema(required)] + electrical_profile_set_id: Option, + #[search(sql = "scenario.infra_id")] + infra_id: u64, + #[search(sql = "infra.name")] + infra_name: String, + #[search( + sql = "(SELECT COUNT(trains.id) FROM train_schedule AS trains WHERE scenario.timetable_id = trains.timetable_id)" + )] + trains_count: u64, + #[search(sql = "scenario.description")] + description: String, + #[search(sql = "scenario.last_modification")] + last_modification: NaiveDateTime, + #[search(sql = "scenario.tags")] + tags: Vec, +} + +/// See [editoast_search::SearchConfigStore::find] +#[derive(SearchConfigStore)] +pub struct SearchConfigFinder; diff --git a/editoast/src/views/search/objects.rs b/editoast/src/views/search/objects.rs deleted file mode 100644 index 602b7392123..00000000000 --- a/editoast/src/views/search/objects.rs +++ /dev/null @@ -1,313 +0,0 @@ -#![allow(clippy::duplicated_attributes)] - -use chrono::NaiveDateTime; -use editoast_derive::Search; -use editoast_derive::SearchConfigStore; -use serde_derive::Serialize; -use utoipa::ToSchema; - -use editoast_common::geometry::GeoJsonPoint; - -// NOTE: every structure deriving `Search` here might have to `#[allow(unused)]` -// because while the name and type information of the fields are read by the macro, -// they might not be explicitly used in the code. (Their JSON representation extracted -// from the DB query is direcly forwarded into the endpoint response, so these -// structs are never deserialized, hence their "non-usage".) -// -// These structs also derive Serialize because utoipa reads some `#[serde(...)]` -// annotations to alter the schema. That's not ideal since none of them are ever -// serialized, but that's life. - -#[derive(Search, Serialize, ToSchema)] -#[search( - name = "track", - table = "search_track", - column(name = "infra_id", data_type = "INT"), - column(name = "line_code", data_type = "INT"), - column(name = "line_name", data_type = "TEXT") -)] -#[allow(unused)] -/// A search result item for a query with `object = "track"` -/// -/// **IMPORTANT**: Please note that any modification to this struct should be reflected in [crate::modelsv2::infra::Infra::clone] -pub(super) struct SearchResultItemTrack { - #[search(sql = "search_track.infra_id")] - infra_id: i64, - #[search(sql = "search_track.unprocessed_line_name")] - line_name: String, - #[search(sql = "search_track.line_code")] - line_code: i64, -} - -#[derive(Search, Serialize, ToSchema)] -#[search( - name = "operationalpoint", - table = "search_operational_point", - migration(src_table = "infra_object_operational_point"), - joins = " - INNER JOIN infra_object_operational_point AS OP ON OP.id = search_operational_point.id - INNER JOIN (SELECT DISTINCT ON (infra_id, obj_id) * FROM infra_layer_operational_point) - AS lay ON OP.obj_id = lay.obj_id AND OP.infra_id = lay.infra_id", - column( - name = "obj_id", - data_type = "varchar(255)", - sql = "infra_object_operational_point.obj_id", - ), - column( - name = "infra_id", - data_type = "integer", - sql = "infra_object_operational_point.infra_id", - ), - column( - name = "uic", - data_type = "integer", - sql = "(infra_object_operational_point.data->'extensions'->'identifier'->>'uic')::integer", - ), - column( - name = "trigram", - data_type = "varchar(3)", - sql = "infra_object_operational_point.data->'extensions'->'sncf'->>'trigram'", - ), - column( - name = "ci", - data_type = "integer", - sql = "(infra_object_operational_point.data->'extensions'->'sncf'->>'ci')::integer", - ), - column( - name = "ch", - data_type = "text", - sql = "infra_object_operational_point.data->'extensions'->'sncf'->>'ch'", - ), - column( - name = "name", - data_type = "text", - sql = "infra_object_operational_point.data->'extensions'->'identifier'->>'name'", - textual_search, - ) -)] -#[allow(unused)] -/// A search result item for a query with `object = "operationalpoint"` -/// -/// **IMPORTANT**: Please note that any modification to this struct should be reflected in [crate::modelsv2::infra::Infra::clone] -pub(super) struct SearchResultItemOperationalPoint { - #[search(sql = "OP.obj_id")] - obj_id: String, - #[search(sql = "OP.infra_id")] - infra_id: i64, - #[search(sql = "OP.data->'extensions'->'identifier'->'uic'")] - uic: i64, - #[search(sql = "OP.data#>>'{extensions,identifier,name}'")] - name: String, - #[search(sql = "OP.data#>>'{extensions,sncf,trigram}'")] - trigram: String, - #[search(sql = "OP.data#>>'{extensions,sncf,ch}'")] - ch: String, - #[search(sql = "OP.data#>>'{extensions,sncf,ci}'")] - ci: u64, - #[search(sql = "ST_AsGeoJSON(ST_Transform(lay.geographic, 4326))::json")] - geographic: GeoJsonPoint, - #[search(sql = "OP.data->'parts'")] - #[schema(inline)] - track_sections: Vec, -} -#[derive(Serialize, ToSchema)] -#[allow(unused)] -pub(super) struct SearchResultItemOperationalPointTrackSections { - track: String, - position: f64, -} - -#[derive(Search, Serialize, ToSchema)] -#[search( - name = "signal", - table = "search_signal", - migration( - src_table = "infra_object_signal", - query_joins = " - INNER JOIN infra_object_track_section AS track_section - ON track_section.infra_id = infra_object_signal.infra_id - AND track_section.obj_id = infra_object_signal.data->>'track'", - ), - column( - name = "label", - data_type = "text", - sql = "infra_object_signal.data->'extensions'->'sncf'->>'label'", - textual_search - ), - column( - name = "line_name", - data_type = "text", - sql = "track_section.data->'extensions'->'sncf'->>'line_name'", - textual_search - ), - column( - name = "infra_id", - data_type = "integer", - sql = "infra_object_signal.infra_id" - ), - column( - name = "obj_id", - data_type = "VARCHAR(255)", - sql = "infra_object_signal.obj_id" - ), - column( - name = "signaling_systems", - data_type = "TEXT[]", - sql = "ARRAY(SELECT jsonb_path_query(infra_object_signal.data, '$.logical_signals[*].signaling_system')->>0)" - ), - column( - name = "settings", - data_type = "TEXT[]", - sql = "ARRAY(SELECT jsonb_path_query(infra_object_signal.data, '$.logical_signals[*].settings.keyvalue().key')->>0)" - ), - column( - name = "line_code", - data_type = "integer", - sql = "(track_section.data->'extensions'->'sncf'->>'line_code')::integer" - ), - joins = " - INNER JOIN infra_object_signal AS sig ON sig.id = search_signal.id - INNER JOIN infra_object_track_section AS track_section ON track_section.obj_id = sig.data->>'track' AND track_section.infra_id = sig.infra_id - INNER JOIN infra_layer_signal AS lay ON lay.infra_id = sig.infra_id AND lay.obj_id = sig.obj_id" -)] -#[allow(unused)] -/// A search result item for a query with `object = "signal"` -/// -/// **IMPORTANT**: Please note that any modification to this struct should be reflected in [crate::modelsv2::infra::Infra::clone] -pub(super) struct SearchResultItemSignal { - #[search(sql = "sig.infra_id")] - infra_id: i64, - #[search(sql = "sig.data->'extensions'->'sncf'->>'label'")] - label: String, - #[search(sql = "search_signal.signaling_systems")] - signaling_systems: Vec, - #[search(sql = "search_signal.settings")] - settings: Vec, - #[search(sql = "search_signal.line_code")] - line_code: u64, - #[search(sql = "track_section.data->'extensions'->'sncf'->>'line_name'")] - line_name: String, - #[search(sql = "ST_AsGeoJSON(ST_Transform(lay.geographic, 4326))::json")] - geographic: GeoJsonPoint, - #[search(sql = "lay.signaling_system")] - sprite_signaling_system: Option, - #[search(sql = "lay.sprite")] - sprite: Option, -} - -#[derive(Search, Serialize, ToSchema)] -#[search( - name = "project", - table = "search_project", - joins = "INNER JOIN project ON project.id = search_project.id", - column(name = "id", data_type = "integer"), - column(name = "name", data_type = "string"), - column(name = "description", data_type = "string"), - column(name = "tags", data_type = "string") -)] -#[allow(unused)] -/// A search result item for a query with `object = "project"` -pub(super) struct SearchResultItemProject { - #[search(sql = "project.id")] - id: u64, - #[search(sql = "project.image_id")] - #[schema(required)] - image: Option, - #[search(sql = "project.name")] - name: String, - #[search( - sql = "(SELECT COUNT(study.id) FROM study WHERE search_project.id = study.project_id)" - )] - studies_count: u64, - #[search(sql = "project.description")] - description: String, - #[search(sql = "project.last_modification")] - last_modification: NaiveDateTime, - #[search(sql = "project.tags")] - tags: Vec, -} - -#[derive(Search, Serialize, ToSchema)] -#[search( - name = "study", - table = "search_study", - migration(src_table = "study"), - joins = "INNER JOIN study ON study.id = search_study.id", - column(name = "name", data_type = "TEXT", sql = "study.name"), - column(name = "description", data_type = "TEXT", sql = "study.description"), - column( - name = "tags", - data_type = "TEXT", - sql = "osrd_prepare_for_search_tags(study.tags)" - ), - column(name = "project_id", data_type = "INTEGER", sql = "study.project_id") -)] -#[allow(unused)] -/// A search result item for a query with `object = "study"` -pub(super) struct SearchResultItemStudy { - #[search(sql = "study.id")] - id: u64, - #[search(sql = "study.project_id")] - project_id: u64, - #[search(sql = "study.name")] - name: String, - #[search( - sql = "(SELECT COUNT(scenario.id) FROM scenario WHERE search_study.id = scenario.study_id)" - )] - scenarios_count: u64, - #[search(sql = "study.description")] - #[schema(required)] - description: Option, - #[search(sql = "study.last_modification")] - last_modification: NaiveDateTime, - #[search(sql = "study.tags")] - tags: Vec, - #[search(sql = "study.budget")] - #[schema(required)] - budget: Option, -} - -#[derive(Search, Serialize, ToSchema)] -#[search( - name = "scenario", - table = "search_scenario", - joins = " - INNER JOIN scenario ON scenario.id = search_scenario.id - INNER JOIN infra ON infra.id = scenario.infra_id", - column(name = "id", data_type = "integer"), - column(name = "name", data_type = "string"), - column(name = "description", data_type = "string"), - column(name = "tags", data_type = "string"), - column(name = "study_id", data_type = "integer") -)] -#[allow(unused)] -/// A search result item for a query with `object = "scenario"` -pub(super) struct SearchResultItemScenario { - #[search(sql = "scenario.id")] - id: u64, - #[search(sql = "scenario.study_id")] - study_id: u64, - #[search(sql = "scenario.name")] - name: String, - #[search(sql = "scenario.electrical_profile_set_id")] - #[schema(required)] - electrical_profile_set_id: Option, - #[search(sql = "scenario.infra_id")] - infra_id: u64, - #[search(sql = "infra.name")] - infra_name: String, - #[search( - sql = "(SELECT COUNT(trains.id) FROM train_schedule AS trains WHERE scenario.timetable_id = trains.timetable_id)" - )] - trains_count: u64, - #[search(sql = "scenario.description")] - description: String, - #[search(sql = "scenario.last_modification")] - last_modification: NaiveDateTime, - #[search(sql = "scenario.tags")] - tags: Vec, -} - -/// See [crate::views::search::SearchConfigStore::find] -#[derive(SearchConfigStore)] -pub struct SearchConfigFinder; diff --git a/front/public/locales/en/errors.json b/front/public/locales/en/errors.json index 50451a130b6..48d05934e23 100644 --- a/front/public/locales/en/errors.json +++ b/front/public/locales/en/errors.json @@ -125,6 +125,7 @@ "NotFound": "Scenario not found" }, "search": { + "SearchEngineError": "Internal search engine error", "ArgMissing": "Expected argument of type {{expected}} at position {{arg_pos}} is missing", "ArgTypeMismatch": "Expected argument of type {{expected}} at position {{arg_pos}}, but got {{actual}}", "EmptyArray": "Empty arrays are invalid syntax", diff --git a/front/public/locales/fr/errors.json b/front/public/locales/fr/errors.json index 58273664649..7c33123b3b6 100644 --- a/front/public/locales/fr/errors.json +++ b/front/public/locales/fr/errors.json @@ -127,6 +127,7 @@ "TimetableNotFound": "Grille horaire '{{timetable_id}}' non trouvée" }, "search": { + "SearchEngineError": "Erreur interne du moteur de recherche", "ArgMissing": "Argument de type {{expected}} manquant à la position {{arg_pos}}", "ArgTypeMismatch": "Argument de type {{expected}} attendu à la position {{arg_pos}}, mais {{actual}} reçu", "EmptyArray": "Les tableaux vides sont interdits",