Skip to content

Commit e7cf143

Browse files
authored
Merge pull request #187 from Josue19-08/feat/advanced-search
Feat/advanced search
2 parents d7cd71a + 704d704 commit e7cf143

9 files changed

Lines changed: 822 additions & 82 deletions

File tree

‎contracts/course/course_registry/src/functions/list_courses_with_filters.rs‎

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
use crate::error::{handle_error, Error};
22
use crate::functions::utils::u32_to_string;
33

4-
use soroban_sdk::{symbol_short, Env, Symbol, Vec, String};
54
use crate::schema::{Course, CourseFilters, MAX_EMPTY_CHECKS};
5+
use soroban_sdk::{symbol_short, Env, Symbol, Vec, String};
6+
7+
/// Helper function to check if a Soroban String contains a substring
8+
/// For now, this implements exact match only due to Soroban String limitations
9+
/// TODO: Implement proper substring search when Soroban provides better string utilities
10+
fn string_contains(haystack: &String, needle: &String) -> bool {
11+
// For now, only exact match is supported
12+
// This can be enhanced later when Soroban provides better string utilities
13+
haystack == needle
14+
}
615

716
const COURSE_KEY: Symbol = symbol_short!("course");
817

@@ -73,6 +82,7 @@ pub fn list_courses_with_filters(
7382
// - Category filter
7483
// - Level filter
7584
// - Duration filter (min/max, only if course has duration)
85+
// - Text search filter (title and description)
7686
let passes_filters: bool = filters.min_price.map_or(true, |min| course.price >= min)
7787
&& filters.max_price.map_or(true, |max| course.price <= max)
7888
&& filters
@@ -82,13 +92,18 @@ pub fn list_courses_with_filters(
8292
&& filters
8393
.level
8494
.as_ref()
85-
.is_none_or(|lvl| course.level.as_ref() == Some(lvl))
86-
&& filters
87-
.min_duration
88-
.is_none_or(|min| course.duration_hours.is_some_and(|d| d >= min))
89-
&& filters
90-
.max_duration
91-
.is_none_or(|max| course.duration_hours.is_some_and(|d| d <= max));
95+
.map_or(true, |lvl| course.level.as_ref() == Some(lvl))
96+
&& filters.min_duration.map_or(true, |min| {
97+
course.duration_hours.map_or(false, |d| d >= min)
98+
})
99+
&& filters.max_duration.map_or(true, |max| {
100+
course.duration_hours.map_or(false, |d| d <= max)
101+
})
102+
&& filters.search_text.as_ref().map_or(true, |search| {
103+
// Text search in title and description
104+
// Note: Case-sensitive search due to Soroban String limitations
105+
string_contains(&course.title, search) || string_contains(&course.description, search)
106+
});
92107

93108
// If course passes all filters
94109
if passes_filters {
@@ -133,6 +148,7 @@ mod test {
133148
level: None,
134149
min_duration: None,
135150
max_duration: None,
151+
search_text: None,
136152
};
137153

138154
let results = client.list_courses_with_filters(&filters, &None, &None);
@@ -184,6 +200,7 @@ mod test {
184200
level: None,
185201
min_duration: None,
186202
max_duration: None,
203+
search_text: None,
187204
};
188205

189206
let results = client.list_courses_with_filters(&filters, &None, &None);
@@ -221,6 +238,7 @@ mod test {
221238
level: None,
222239
min_duration: None,
223240
max_duration: None,
241+
search_text: None,
224242
};
225243

226244
let results = client.list_courses_with_filters(&filters, &None, &None);
@@ -257,9 +275,105 @@ mod test {
257275
level: None,
258276
min_duration: None,
259277
max_duration: None,
278+
search_text: None,
260279
};
261280

262281
let results = client.list_courses_with_filters(&filters, &Some(0), &None);
263282
assert_eq!(results.len(), 0);
264283
}
284+
285+
#[test]
286+
fn test_text_search_filter() {
287+
let env = Env::default();
288+
env.mock_all_auths();
289+
290+
let contract_id = env.register(CourseRegistry, ());
291+
let client = CourseRegistryClient::new(&env, &contract_id);
292+
let creator = Address::generate(&env);
293+
294+
// Create courses with different titles and descriptions
295+
let course1 = client.create_course(
296+
&creator,
297+
&String::from_str(&env, "Rust Programming"),
298+
&String::from_str(&env, "Learn Rust language fundamentals"),
299+
&100,
300+
&None,
301+
&None,
302+
&None,
303+
&None,
304+
&None,
305+
);
306+
307+
let course2 = client.create_course(
308+
&creator,
309+
&String::from_str(&env, "JavaScript Basics"),
310+
&String::from_str(&env, "Introduction to web development"),
311+
&150,
312+
&None,
313+
&None,
314+
&None,
315+
&None,
316+
&None,
317+
);
318+
319+
// Publish both courses
320+
use crate::schema::EditCourseParams;
321+
let publish_params = EditCourseParams {
322+
new_title: None,
323+
new_description: None,
324+
new_price: None,
325+
new_category: None,
326+
new_language: None,
327+
new_thumbnail_url: None,
328+
new_published: Some(true),
329+
new_level: None,
330+
new_duration_hours: None,
331+
};
332+
client.edit_course(&creator, &course1.id, &publish_params);
333+
client.edit_course(&creator, &course2.id, &publish_params);
334+
335+
// Search for exact title match - should return only first course
336+
let exact_title_filters = CourseFilters {
337+
min_price: None,
338+
max_price: None,
339+
category: None,
340+
level: None,
341+
min_duration: None,
342+
max_duration: None,
343+
search_text: Some(String::from_str(&env, "Rust Programming")),
344+
};
345+
346+
let exact_title_results = client.list_courses_with_filters(&exact_title_filters, &None, &None);
347+
assert_eq!(exact_title_results.len(), 1);
348+
assert_eq!(exact_title_results.get(0).unwrap().title, String::from_str(&env, "Rust Programming"));
349+
350+
// Search for exact description match - should return only second course
351+
let exact_desc_filters = CourseFilters {
352+
min_price: None,
353+
max_price: None,
354+
category: None,
355+
level: None,
356+
min_duration: None,
357+
max_duration: None,
358+
search_text: Some(String::from_str(&env, "Introduction to web development")),
359+
};
360+
361+
let exact_desc_results = client.list_courses_with_filters(&exact_desc_filters, &None, &None);
362+
assert_eq!(exact_desc_results.len(), 1);
363+
assert_eq!(exact_desc_results.get(0).unwrap().title, String::from_str(&env, "JavaScript Basics"));
364+
365+
// Search for non-existent term
366+
let none_filters = CourseFilters {
367+
min_price: None,
368+
max_price: None,
369+
category: None,
370+
level: None,
371+
min_duration: None,
372+
max_duration: None,
373+
search_text: Some(String::from_str(&env, "Python")),
374+
};
375+
376+
let none_results = client.list_courses_with_filters(&none_filters, &None, &None);
377+
assert_eq!(none_results.len(), 0);
378+
}
265379
}

‎contracts/course/course_registry/src/schema.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ pub struct CourseFilters {
9696
pub level: Option<CourseLevel>,
9797
pub min_duration: Option<u32>,
9898
pub max_duration: Option<u32>,
99+
/// Text search in course title and description
100+
pub search_text: Option<String>,
99101
}
100102

101103
#[contracttype]

‎contracts/user_management/src/functions/create_user_profile.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ pub fn create_user_profile(env: Env, user: Address, profile: UserProfile) -> Use
131131

132132
// Validate field lengths and content
133133
if !validate_string_content(&env, &profile.full_name, MAX_NAME_LENGTH) {
134-
handle_error(&env, Error::InvalidName)
134+
handle_error(&env, Error::NameRequired)
135135
}
136136

137137
// Validate email format
@@ -147,14 +147,14 @@ pub fn create_user_profile(env: Env, user: Address, profile: UserProfile) -> Use
147147
// Validate profession field if provided
148148
if let Some(ref profession) = profile.profession {
149149
if !profession.is_empty() && !validate_string_content(&env, profession, MAX_PROFESSION_LENGTH) {
150-
handle_error(&env, Error::InvalidProfession)
150+
handle_error(&env, Error::InvalidField)
151151
}
152152
}
153153

154154
// Validate country field if provided
155155
if let Some(ref country) = profile.country {
156156
if !country.is_empty() && !validate_string_content(&env, country, MAX_COUNTRY_LENGTH) {
157-
handle_error(&env, Error::InvalidCountry)
157+
handle_error(&env, Error::InvalidField)
158158
}
159159
}
160160

‎contracts/user_management/src/functions/edit_user_profile.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ pub fn edit_user_profile(
101101
handle_error(&env, Error::NameRequired);
102102
}
103103
if !validate_string_content(&env, name, MAX_NAME_LENGTH) {
104-
handle_error(&env, Error::InvalidName);
104+
handle_error(&env, Error::NameRequired);
105105
}
106106
// For now, update the name field (could split into name/lastname later)
107107
// This is a simplified approach - in production you might want more sophisticated name parsing
@@ -110,22 +110,22 @@ pub fn edit_user_profile(
110110

111111
if let Some(ref profession) = updates.profession {
112112
if !profession.is_empty() && !validate_string_content(&env, profession, MAX_PROFESSION_LENGTH) {
113-
handle_error(&env, Error::InvalidProfession);
113+
handle_error(&env, Error::InvalidField);
114114
}
115115
profile.profession = if profession.is_empty() { None } else { Some(profession.clone()) };
116116
}
117117

118118
if let Some(ref country) = updates.country {
119119
if !country.is_empty() && !validate_string_content(&env, country, MAX_COUNTRY_LENGTH) {
120-
handle_error(&env, Error::InvalidCountry);
120+
handle_error(&env, Error::InvalidField);
121121
}
122122
profile.country = if country.is_empty() { None } else { Some(country.clone()) };
123123
}
124124

125125
// Handle purpose field update
126126
if let Some(ref purpose) = updates.purpose {
127127
if !purpose.is_empty() && !validate_string_content(&env, purpose, MAX_PROFESSION_LENGTH) {
128-
handle_error(&env, Error::InvalidProfession);
128+
handle_error(&env, Error::InvalidField);
129129
}
130130
profile.purpose = if purpose.is_empty() { None } else { Some(purpose.clone()) };
131131
}

0 commit comments

Comments
 (0)