diff --git a/core/services/mysql/src/backend.rs b/core/services/mysql/src/backend.rs index a40b8d098bfc..ff0d68f0ae64 100644 --- a/core/services/mysql/src/backend.rs +++ b/core/services/mysql/src/backend.rs @@ -25,6 +25,7 @@ use super::MYSQL_SCHEME; use super::config::MysqlConfig; use super::core::*; use super::deleter::MysqlDeleter; +use super::lister::MysqlLister; use super::writer::MysqlWriter; use opendal_core::raw::oio; use opendal_core::raw::*; @@ -164,6 +165,8 @@ impl MysqlBackend { info.set_root("/"); info.set_native_capability(Capability { read: true, + list: true, + list_with_recursive: true, stat: true, write: true, write_can_empty: true, @@ -189,7 +192,7 @@ impl MysqlBackend { impl Access for MysqlBackend { type Reader = Buffer; type Writer = MysqlWriter; - type Lister = (); + type Lister = oio::HierarchyLister; type Deleter = oio::OneShotDeleter; fn info(&self) -> Arc { @@ -232,4 +235,11 @@ impl Access for MysqlBackend { oio::OneShotDeleter::new(MysqlDeleter::new(self.core.clone(), self.root.clone())), )) } + + async fn list(&self, path: &str, args: OpList) -> Result<(RpList, Self::Lister)> { + let lister = + MysqlLister::new(self.core.clone(), self.root.clone(), path.to_string()).await?; + let lister = oio::HierarchyLister::new(lister, path, args.recursive()); + Ok((RpList::default(), lister)) + } } diff --git a/core/services/mysql/src/core.rs b/core/services/mysql/src/core.rs index 3a366dcedc37..f977eba416e7 100644 --- a/core/services/mysql/src/core.rs +++ b/core/services/mysql/src/core.rs @@ -88,8 +88,76 @@ impl MysqlCore { Ok(()) } + + pub async fn list(&self, path: &str) -> Result> { + let pool = self.get_client().await?; + + let mut sql = format!( + "SELECT `{}` FROM `{}` WHERE `{}` LIKE ?", + self.key_field, self.table, self.key_field + ); + sql.push_str(&format!(" ORDER BY `{}`", self.key_field)); + + let escaped = escape_like(path); + sqlx::query_scalar(&sql) + .bind(format!("{escaped}%")) + .fetch_all(pool) + .await + .map_err(parse_mysql_error) + } +} + +fn escape_like(s: &str) -> String { + const ESCAPE_CHAR: char = '\\'; + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + c if c == ESCAPE_CHAR => { + out.push(ESCAPE_CHAR); + out.push(ESCAPE_CHAR); + } + '%' | '_' => { + out.push(ESCAPE_CHAR); + out.push(ch); + } + _ => out.push(ch), + } + } + out } fn parse_mysql_error(err: sqlx::Error) -> Error { Error::new(ErrorKind::Unexpected, "unhandled error from mysql").set_source(err) } + +#[cfg(test)] +mod tests { + use crate::core::escape_like; + + #[test] + fn test_escape_like_basic() { + assert_eq!(escape_like("abc"), "abc"); + assert_eq!(escape_like("foo"), "foo"); + } + + #[test] + fn test_escape_like_wildcards() { + assert_eq!(escape_like("%"), r"\%"); + assert_eq!(escape_like("_"), r"\_"); + assert_eq!(escape_like("a%b_c"), r"a\%b\_c"); + } + + #[test] + fn test_escape_like_escape_char() { + assert_eq!(escape_like(r"\"), r"\\"); + assert_eq!(escape_like(r"\%"), r"\\\%"); + } + + #[test] + fn test_escape_like_mixed() { + let input = r"foo\%bar_baz%"; + let expected = r"foo\\\%bar\_baz\%"; + + assert_eq!(escape_like(input), expected); + } +} diff --git a/core/services/mysql/src/docs.md b/core/services/mysql/src/docs.md index c7b6839b843f..65a3282493e6 100644 --- a/core/services/mysql/src/docs.md +++ b/core/services/mysql/src/docs.md @@ -7,7 +7,7 @@ This service can be used to: - [x] read - [x] write - [x] delete -- [ ] list +- [x] list - [ ] copy - [ ] rename - [ ] ~~presign~~ diff --git a/core/services/mysql/src/lib.rs b/core/services/mysql/src/lib.rs index c16bcdc0665c..ffde72a8e523 100644 --- a/core/services/mysql/src/lib.rs +++ b/core/services/mysql/src/lib.rs @@ -24,6 +24,7 @@ mod backend; mod config; mod core; mod deleter; +mod lister; mod writer; pub use backend::MysqlBuilder as Mysql; diff --git a/core/services/mysql/src/lister.rs b/core/services/mysql/src/lister.rs new file mode 100644 index 000000000000..d1c4f93604f4 --- /dev/null +++ b/core/services/mysql/src/lister.rs @@ -0,0 +1,53 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::sync::Arc; +use std::vec::IntoIter; + +use opendal_core::Result; +use opendal_core::raw::oio::{Entry, List}; +use opendal_core::raw::{build_abs_path, build_rel_path}; +use opendal_core::{EntryMode, Metadata}; + +use super::core::MysqlCore; + +pub struct MysqlLister { + root: String, + entries: IntoIter, +} + +impl MysqlLister { + pub async fn new(core: Arc, root: String, path: String) -> Result { + let entries = core.list(&build_abs_path(&root, &path)).await?.into_iter(); + Ok(Self { root, entries }) + } +} + +impl List for MysqlLister { + async fn next(&mut self) -> Result> { + let Some(key) = self.entries.next() else { + return Ok(None); + }; + + let mut path = build_rel_path(&self.root, &key); + if path.is_empty() { + path = "/".to_string(); + } + let meta = Metadata::new(EntryMode::from_path(&path)); + Ok(Some(Entry::new(&path, meta))) + } +}