refactor(data): limit query abstraction to postgres and sqlite

This commit is contained in:
Kayphoon
2026-05-17 15:40:44 +08:00
parent 77640d51a6
commit f29cca72ba
13 changed files with 498 additions and 563 deletions

View File

@@ -4,14 +4,12 @@ use sqlx::{Database, Encode, QueryBuilder, Type};
pub enum SqlDialect {
Postgres,
Sqlite,
Mysql,
}
impl SqlDialect {
pub fn quote_ident(self, ident: &str) -> String {
let quote = match self {
Self::Postgres | Self::Sqlite => '"',
Self::Mysql => '`',
};
let escaped = ident.replace(quote, &format!("{quote}{quote}"));
format!("{quote}{escaped}{quote}")
@@ -31,7 +29,6 @@ pub struct DialectSql<'a> {
common: Option<&'a str>,
postgres: Option<&'a str>,
sqlite: Option<&'a str>,
mysql: Option<&'a str>,
}
impl<'a> DialectSql<'a> {
@@ -40,16 +37,14 @@ impl<'a> DialectSql<'a> {
common: Some(sql),
postgres: None,
sqlite: None,
mysql: None,
}
}
pub const fn dialect(postgres: &'a str, sqlite: &'a str, mysql: &'a str) -> Self {
pub const fn dialect(postgres: &'a str, sqlite: &'a str) -> Self {
Self {
common: None,
postgres: Some(postgres),
sqlite: Some(sqlite),
mysql: Some(mysql),
}
}
@@ -63,16 +58,10 @@ impl<'a> DialectSql<'a> {
self
}
pub fn with_mysql(mut self, sql: &'a str) -> Self {
self.mysql = Some(sql);
self
}
pub fn sql(self, dialect: SqlDialect) -> &'a str {
match dialect {
SqlDialect::Postgres => self.postgres.or(self.common),
SqlDialect::Sqlite => self.sqlite.or(self.common),
SqlDialect::Mysql => self.mysql.or(self.common),
}
.expect("dialect SQL expression is missing for selected dialect")
}
@@ -470,7 +459,7 @@ fn push_ci_contains_predicate<'args, DB>(
.push(" ILIKE ")
.push_bind(format!("%{trimmed}%"));
}
SqlDialect::Sqlite | SqlDialect::Mysql => {
SqlDialect::Sqlite => {
builder
.push("LOWER(")
.push(column_sql)
@@ -522,13 +511,12 @@ pub fn push_order_by<DB>(
#[cfg(test)]
mod tests {
use super::*;
use sqlx::{Execute, MySql, Postgres, QueryBuilder, Sqlite};
use sqlx::{Execute, Postgres, QueryBuilder, Sqlite};
#[test]
fn quotes_identifiers_by_dialect() {
assert_eq!(SqlDialect::Postgres.quote_ident("trigger"), "\"trigger\"");
assert_eq!(SqlDialect::Sqlite.quote_ident("trigger"), "\"trigger\"");
assert_eq!(SqlDialect::Mysql.quote_ident("trigger"), "`trigger`");
assert_eq!(
SqlDialect::Postgres.quote_path(&["usage", "id"]),
"\"usage\".\"id\""
@@ -571,7 +559,7 @@ mod tests {
}
#[test]
fn ci_contains_uses_lower_like_for_sqlite_and_mysql() {
fn ci_contains_uses_lower_like_for_sqlite() {
let mut sqlite_builder = QueryBuilder::<Sqlite>::new("SELECT * FROM items");
let mut sqlite_where = WhereClause::new();
push_ci_contains(
@@ -585,20 +573,6 @@ mod tests {
.build()
.sql()
.contains(" WHERE LOWER(task_key) LIKE ?"));
let mut mysql_builder = QueryBuilder::<MySql>::new("SELECT * FROM items");
let mut mysql_where = WhereClause::new();
push_ci_contains(
&mut mysql_builder,
&mut mysql_where,
SqlDialect::Mysql,
"task_key",
" Fetch ",
);
assert!(mysql_builder
.build()
.sql()
.contains(" WHERE LOWER(task_key) LIKE ?"));
}
#[test]
@@ -652,7 +626,6 @@ mod tests {
SelectColumn::expr(DialectSql::dialect(
"CAST(monthly_quota_usd AS DOUBLE PRECISION)",
"CAST(monthly_quota_usd AS REAL)",
"monthly_quota_usd",
))
.alias("monthly_quota_usd"),
]);
@@ -662,8 +635,8 @@ mod tests {
"SELECT id AS \"provider_id\", CAST(monthly_quota_usd AS DOUBLE PRECISION) AS \"monthly_quota_usd\" FROM providers"
);
assert_eq!(
query.render(SqlDialect::Mysql),
"SELECT id AS `provider_id`, monthly_quota_usd AS `monthly_quota_usd` FROM providers"
query.render(SqlDialect::Sqlite),
"SELECT id AS \"provider_id\", CAST(monthly_quota_usd AS REAL) AS \"monthly_quota_usd\" FROM providers"
);
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
use sqlx::{mysql::MySqlRow, Row};
use super::types::{
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
@@ -8,7 +8,6 @@ use super::types::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{push_eq, push_limit, push_limit_offset, WhereClause};
const ANNOUNCEMENT_SELECT: &str = r#"
SELECT
@@ -45,27 +44,6 @@ impl MysqlAnnouncementRepository {
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
self.find_by_id(announcement_id).await
}
fn apply_active_filter(
builder: &mut QueryBuilder<'_, MySql>,
where_clause: &mut WhereClause,
active_only: bool,
now_unix_secs: u64,
) -> Result<(), DataLayerError> {
if !active_only {
return Ok(());
}
let now = i64_from_u64(now_unix_secs, "announcements.now")?;
where_clause.push_next(builder);
builder
.push("a.is_active = 1 AND (a.start_time IS NULL OR a.start_time <= ")
.push_bind(now)
.push(") AND (a.end_time IS NULL OR a.end_time >= ")
.push_bind(now)
.push(")");
Ok(())
}
}
#[async_trait]
@@ -74,17 +52,8 @@ impl AnnouncementReadRepository for MysqlAnnouncementRepository {
&self,
announcement_id: &str,
) -> Result<Option<StoredAnnouncement>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(ANNOUNCEMENT_SELECT);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"a.id",
announcement_id.to_string(),
);
push_limit(&mut builder, 1);
let row = builder
.build()
let row = sqlx::query(&format!("{ANNOUNCEMENT_SELECT} WHERE a.id = ? LIMIT 1"))
.bind(announcement_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -96,38 +65,49 @@ impl AnnouncementReadRepository for MysqlAnnouncementRepository {
query: &AnnouncementListQuery,
) -> Result<StoredAnnouncementPage, DataLayerError> {
let now_unix_secs = query.now_unix_secs.unwrap_or_else(current_unix_secs);
let mut count_builder =
QueryBuilder::<MySql>::new("SELECT COUNT(a.id) AS total FROM announcements a");
let mut count_where = WhereClause::new();
Self::apply_active_filter(
&mut count_builder,
&mut count_where,
query.active_only,
now_unix_secs,
)?;
let total = count_builder
.build_query_scalar::<i64>()
.fetch_one(&self.pool)
.await
.map_sql_err()?
.max(0) as u64;
let total_row = sqlx::query(
r#"
SELECT COUNT(a.id) AS total
FROM announcements a
WHERE (
NOT ? OR (
a.is_active = 1
AND (a.start_time IS NULL OR a.start_time <= ?)
AND (a.end_time IS NULL OR a.end_time >= ?)
)
)
"#,
)
.bind(query.active_only)
.bind(now_unix_secs as i64)
.bind(now_unix_secs as i64)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
let total = total_row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64;
let mut list_builder = QueryBuilder::<MySql>::new(ANNOUNCEMENT_SELECT);
let mut list_where = WhereClause::new();
Self::apply_active_filter(
&mut list_builder,
&mut list_where,
query.active_only,
now_unix_secs,
)?;
list_builder
.push(" ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC");
push_limit_offset(&mut list_builder, query.limit as i64, query.offset as i64);
let rows = list_builder
.build()
.fetch_all(&self.pool)
.await
.map_sql_err()?;
let rows = sqlx::query(&format!(
r#"
{ANNOUNCEMENT_SELECT}
WHERE (
NOT ? OR (
a.is_active = 1
AND (a.start_time IS NULL OR a.start_time <= ?)
AND (a.end_time IS NULL OR a.end_time >= ?)
)
)
ORDER BY a.is_pinned DESC, a.priority DESC, a.created_at DESC, a.id ASC
LIMIT ? OFFSET ?
"#
))
.bind(query.active_only)
.bind(now_unix_secs as i64)
.bind(now_unix_secs as i64)
.bind(query.limit as i64)
.bind(query.offset as i64)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
let items = rows
.iter()
.map(map_announcement_row)
@@ -141,22 +121,28 @@ impl AnnouncementReadRepository for MysqlAnnouncementRepository {
user_id: &str,
now_unix_secs: u64,
) -> Result<u64, DataLayerError> {
let mut builder =
QueryBuilder::<MySql>::new("SELECT COUNT(a.id) AS total FROM announcements a");
let mut where_clause = WhereClause::new();
Self::apply_active_filter(&mut builder, &mut where_clause, true, now_unix_secs)?;
where_clause.push_next(&mut builder);
builder
.push("NOT EXISTS (SELECT 1 FROM announcement_reads r WHERE r.user_id = ")
.push_bind(user_id.to_string())
.push(" AND r.announcement_id = a.id)");
let total = builder
.build_query_scalar::<i64>()
.fetch_one(&self.pool)
.await
.map_sql_err()?
.max(0) as u64;
Ok(total)
let row = sqlx::query(
r#"
SELECT COUNT(a.id) AS total
FROM announcements a
WHERE a.is_active = 1
AND (a.start_time IS NULL OR a.start_time <= ?)
AND (a.end_time IS NULL OR a.end_time >= ?)
AND NOT EXISTS (
SELECT 1
FROM announcement_reads r
WHERE r.user_id = ?
AND r.announcement_id = a.id
)
"#,
)
.bind(now_unix_secs as i64)
.bind(now_unix_secs as i64)
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
Ok(row.try_get::<i64, _>("total").map_sql_err()?.max(0) as u64)
}
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
use sqlx::{mysql::MySqlRow, Row};
use super::types::{
AuthModuleReadRepository, AuthModuleWriteRepository, StoredLdapModuleConfig,
@@ -8,9 +8,8 @@ use super::types::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{push_eq, push_limit, WhereClause};
const OAUTH_PROVIDER_COLUMNS: &str = r#"
const LIST_ENABLED_OAUTH_PROVIDERS_SQL: &str = r#"
SELECT
provider_type,
display_name,
@@ -18,9 +17,11 @@ SELECT
client_secret_encrypted,
redirect_uri
FROM oauth_providers
WHERE is_enabled = 1
ORDER BY provider_type ASC
"#;
const LDAP_CONFIG_COLUMNS: &str = r#"
const GET_LDAP_CONFIG_SQL: &str = r#"
SELECT
server_url,
bind_dn,
@@ -35,6 +36,8 @@ SELECT
use_starttls,
connect_timeout
FROM ldap_configs
ORDER BY id ASC
LIMIT 1
"#;
#[derive(Debug, Clone)]
@@ -59,37 +62,24 @@ impl MysqlAuthModuleRepository {
}
}
async fn list_enabled_oauth_providers(
pool: &MysqlPool,
) -> Result<Vec<StoredOAuthProviderModuleConfig>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(OAUTH_PROVIDER_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(&mut builder, &mut where_clause, "is_enabled", true);
builder.push(" ORDER BY provider_type ASC");
let rows = builder.build().fetch_all(pool).await.map_sql_err()?;
rows.iter().map(map_oauth_row).collect()
}
async fn get_ldap_config(
pool: &MysqlPool,
) -> Result<Option<StoredLdapModuleConfig>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(LDAP_CONFIG_COLUMNS);
builder.push(" ORDER BY id ASC");
push_limit(&mut builder, 1);
let row = builder.build().fetch_optional(pool).await.map_sql_err()?;
row.as_ref().map(map_ldap_row).transpose()
}
#[async_trait]
impl AuthModuleReadRepository for MysqlAuthModuleReadRepository {
async fn list_enabled_oauth_providers(
&self,
) -> Result<Vec<StoredOAuthProviderModuleConfig>, DataLayerError> {
list_enabled_oauth_providers(&self.pool).await
let rows = sqlx::query(LIST_ENABLED_OAUTH_PROVIDERS_SQL)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_oauth_row).collect()
}
async fn get_ldap_config(&self) -> Result<Option<StoredLdapModuleConfig>, DataLayerError> {
get_ldap_config(&self.pool).await
let row = sqlx::query(GET_LDAP_CONFIG_SQL)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_ldap_row).transpose()
}
}
@@ -98,11 +88,19 @@ impl AuthModuleReadRepository for MysqlAuthModuleRepository {
async fn list_enabled_oauth_providers(
&self,
) -> Result<Vec<StoredOAuthProviderModuleConfig>, DataLayerError> {
list_enabled_oauth_providers(&self.pool).await
let rows = sqlx::query(LIST_ENABLED_OAUTH_PROVIDERS_SQL)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_oauth_row).collect()
}
async fn get_ldap_config(&self) -> Result<Option<StoredLdapModuleConfig>, DataLayerError> {
get_ldap_config(&self.pool).await
let row = sqlx::query(GET_LDAP_CONFIG_SQL)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_ldap_row).transpose()
}
}

View File

@@ -10,9 +10,6 @@ use super::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{
push_ci_contains, push_eq, push_limit, push_limit_offset, SqlDialect, WhereClause,
};
const RUN_COLUMNS: &str = r#"
SELECT
@@ -60,29 +57,44 @@ impl MysqlBackgroundTaskRepository {
}
fn apply_run_filter(builder: &mut QueryBuilder<'_, MySql>, query: &BackgroundTaskListQuery) {
let mut where_clause = WhereClause::new();
let mut has_where = false;
if let Some(kind) = query.kind {
push_eq(builder, &mut where_clause, "kind", kind.as_database());
if !has_where {
builder.push(" WHERE ");
has_where = true;
} else {
builder.push(" AND ");
}
builder.push("kind = ").push_bind(kind.as_database());
}
if let Some(status) = query.status {
push_eq(builder, &mut where_clause, "status", status.as_database());
if !has_where {
builder.push(" WHERE ");
has_where = true;
} else {
builder.push(" AND ");
}
builder.push("status = ").push_bind(status.as_database());
}
if let Some(trigger) = query.trigger.as_deref() {
push_eq(
builder,
&mut where_clause,
&SqlDialect::Mysql.quote_ident("trigger"),
trigger.to_string(),
);
if !has_where {
builder.push(" WHERE ");
has_where = true;
} else {
builder.push(" AND ");
}
builder.push("`trigger` = ").push_bind(trigger.to_string());
}
if let Some(task_key_substring) = query.task_key_substring.as_deref() {
push_ci_contains(
builder,
&mut where_clause,
SqlDialect::Mysql,
"task_key",
task_key_substring,
);
if !has_where {
builder.push(" WHERE ");
} else {
builder.push(" AND ");
}
builder.push("LOWER(task_key) LIKE ").push_bind(format!(
"%{}%",
task_key_substring.trim().to_ascii_lowercase()
));
}
}
}
@@ -93,12 +105,8 @@ impl BackgroundTaskReadRepository for MysqlBackgroundTaskRepository {
&self,
run_id: &str,
) -> Result<Option<StoredBackgroundTaskRun>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(RUN_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(&mut builder, &mut where_clause, "id", run_id.to_string());
push_limit(&mut builder, 1);
let row = builder
.build()
let row = sqlx::query(&format!("{RUN_COLUMNS} WHERE id = ? LIMIT 1"))
.bind(run_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -121,12 +129,12 @@ impl BackgroundTaskReadRepository for MysqlBackgroundTaskRepository {
let mut builder = QueryBuilder::<MySql>::new(RUN_COLUMNS);
Self::apply_run_filter(&mut builder, query);
builder.push(" ORDER BY created_at_unix_secs DESC, updated_at_unix_secs DESC");
push_limit_offset(
&mut builder,
i64_from_usize(limit, "run limit")?,
i64_from_usize(query.offset, "run offset")?,
);
builder
.push(" ORDER BY created_at_unix_secs DESC, updated_at_unix_secs DESC")
.push(" LIMIT ")
.push_bind(i64_from_usize(limit, "run limit")?)
.push(" OFFSET ")
.push_bind(i64_from_usize(query.offset, "run offset")?);
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
let items = rows
.iter()
@@ -145,21 +153,15 @@ impl BackgroundTaskReadRepository for MysqlBackgroundTaskRepository {
limit: usize,
) -> Result<Vec<StoredBackgroundTaskEvent>, DataLayerError> {
let limit = limit.max(1);
let mut builder = QueryBuilder::<MySql>::new(EVENT_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"run_id",
run_id.to_string(),
);
builder.push(" ORDER BY created_at_unix_secs ASC, id ASC");
push_limit_offset(
&mut builder,
i64_from_usize(limit, "event limit")?,
i64_from_usize(offset, "event offset")?,
);
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
let rows = sqlx::query(&format!(
"{EVENT_COLUMNS} WHERE run_id = ? ORDER BY created_at_unix_secs ASC, id ASC LIMIT ? OFFSET ?"
))
.bind(run_id)
.bind(i64_from_usize(limit, "event limit")?)
.bind(i64_from_usize(offset, "event offset")?)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_event_row).collect()
}

View File

@@ -11,7 +11,6 @@ use super::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{push_in, WhereClause};
const CANDIDATE_COLUMNS: &str = r#"
SELECT
@@ -133,8 +132,7 @@ impl RequestCandidateReadRepository for MysqlRequestCandidateRepository {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(CANDIDATE_COLUMNS);
let mut where_clause = WhereClause::new();
push_in(&mut builder, &mut where_clause, "endpoint_id", endpoint_ids);
push_endpoint_in_clause(&mut builder, endpoint_ids);
builder
.push(" AND created_at >= ")
.push_bind(unix_secs_to_ms_i64(since_unix_secs)?)
@@ -156,8 +154,7 @@ impl RequestCandidateReadRepository for MysqlRequestCandidateRepository {
let mut builder = QueryBuilder::<MySql>::new(
"SELECT endpoint_id, status, COUNT(id) AS count FROM request_candidates",
);
let mut where_clause = WhereClause::new();
push_in(&mut builder, &mut where_clause, "endpoint_id", endpoint_ids);
push_endpoint_in_clause(&mut builder, endpoint_ids);
builder
.push(" AND created_at >= ")
.push_bind(unix_secs_to_ms_i64(since_unix_secs)?)
@@ -196,8 +193,7 @@ impl RequestCandidateReadRepository for MysqlRequestCandidateRepository {
let since_ms = unix_secs_to_ms_i64(since_unix_secs)?;
let until_ms = unix_secs_to_ms_i64(until_unix_secs)?;
let mut builder = QueryBuilder::<MySql>::new(CANDIDATE_COLUMNS);
let mut where_clause = WhereClause::new();
push_in(&mut builder, &mut where_clause, "endpoint_id", endpoint_ids);
push_endpoint_in_clause(&mut builder, endpoint_ids);
builder
.push(" AND created_at >= ")
.push_bind(since_ms)
@@ -343,6 +339,20 @@ ON DUPLICATE KEY UPDATE
Ok(())
}
fn push_endpoint_in_clause<'args>(
builder: &mut QueryBuilder<'args, MySql>,
endpoint_ids: &'args [String],
) {
builder.push(" WHERE endpoint_id IN (");
{
let mut separated = builder.separated(", ");
for endpoint_id in endpoint_ids {
separated.push_bind(endpoint_id);
}
}
builder.push(")");
}
fn merge_candidate(
candidate: UpsertRequestCandidateRecord,
existing: Option<StoredRequestCandidate>,

View File

@@ -9,7 +9,6 @@ use super::types::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{push_ci_contains_any, push_limit_offset, SqlDialect, WhereClause};
#[derive(Debug, Clone)]
pub struct MysqlGeminiFileMappingRepository {
@@ -243,9 +242,8 @@ LIMIT 1
fn build_list_count_query(query: &GeminiFileMappingListQuery) -> QueryBuilder<'_, MySql> {
let mut builder =
QueryBuilder::<MySql>::new("SELECT COUNT(*) AS total FROM gemini_file_mappings");
let mut where_clause = WhereClause::new();
apply_list_filters(&mut builder, &mut where_clause, query);
QueryBuilder::<MySql>::new("SELECT COUNT(*) AS total FROM gemini_file_mappings WHERE 1=1");
apply_list_filters(&mut builder, query);
builder
}
@@ -263,27 +261,20 @@ SELECT
created_at AS created_at_unix_ms,
expires_at AS expires_at_unix_secs
FROM gemini_file_mappings
WHERE 1=1
"#,
);
let mut where_clause = WhereClause::new();
apply_list_filters(&mut builder, &mut where_clause, query);
builder.push(" ORDER BY created_at DESC, file_name ASC");
push_limit_offset(
&mut builder,
i64::try_from(query.limit).unwrap_or(i64::MAX),
i64::try_from(query.offset).unwrap_or(i64::MAX),
);
apply_list_filters(&mut builder, query);
builder.push(" ORDER BY created_at DESC, file_name ASC LIMIT ");
builder.push_bind(i64::try_from(query.limit).unwrap_or(i64::MAX));
builder.push(" OFFSET ");
builder.push_bind(i64::try_from(query.offset).unwrap_or(i64::MAX));
builder
}
fn apply_list_filters(
builder: &mut QueryBuilder<'_, MySql>,
where_clause: &mut WhereClause,
query: &GeminiFileMappingListQuery,
) {
fn apply_list_filters(builder: &mut QueryBuilder<'_, MySql>, query: &GeminiFileMappingListQuery) {
if !query.include_expired {
where_clause.push_next(builder);
builder.push("expires_at > ");
builder.push(" AND expires_at > ");
builder.push_bind(query.now_unix_secs as i64);
}
if let Some(search) = query
@@ -292,13 +283,12 @@ fn apply_list_filters(
.map(str::trim)
.filter(|value| !value.is_empty())
{
push_ci_contains_any(
builder,
where_clause,
SqlDialect::Mysql,
&["file_name", "COALESCE(display_name, '')"],
search,
);
let pattern = format!("%{}%", search.to_ascii_lowercase());
builder.push(" AND (LOWER(file_name) LIKE ");
builder.push_bind(pattern.clone());
builder.push(" OR LOWER(COALESCE(display_name, '')) LIKE ");
builder.push_bind(pattern);
builder.push(")");
}
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
use sqlx::{mysql::MySqlRow, Row};
use super::types::{
CreateManagementTokenRecord, ManagementTokenListQuery, ManagementTokenReadRepository,
@@ -10,7 +10,6 @@ use super::types::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{push_eq, push_limit, push_limit_offset, push_optional_eq, WhereClause};
#[derive(Debug, Clone)]
pub struct MysqlManagementTokenRepository {
@@ -26,12 +25,8 @@ impl MysqlManagementTokenRepository {
&self,
token_id: &str,
) -> Result<Option<StoredManagementToken>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(TOKEN_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(&mut builder, &mut where_clause, "id", token_id.to_string());
push_limit(&mut builder, 1);
let row = builder
.build()
let row = sqlx::query(TOKEN_BY_ID_SQL)
.bind(token_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -39,7 +34,7 @@ impl MysqlManagementTokenRepository {
}
}
const TOKEN_COLUMNS: &str = r#"
const TOKEN_BY_ID_SQL: &str = r#"
SELECT
id,
user_id,
@@ -56,9 +51,11 @@ SELECT
created_at AS created_at_unix_ms,
updated_at AS updated_at_unix_secs
FROM management_tokens
WHERE id = ?
LIMIT 1
"#;
const TOKEN_WITH_USER_COLUMNS: &str = r#"
const LIST_MANAGEMENT_TOKENS_SQL: &str = r#"
SELECT
mt.id,
mt.user_id,
@@ -80,6 +77,69 @@ SELECT
u.role AS user_role
FROM management_tokens mt
JOIN users u ON u.id = mt.user_id
WHERE (? IS NULL OR mt.user_id = ?)
AND (? IS NULL OR mt.is_active = ?)
ORDER BY mt.created_at DESC, mt.id DESC
LIMIT ? OFFSET ?
"#;
const COUNT_MANAGEMENT_TOKENS_SQL: &str = r#"
SELECT COUNT(mt.id) AS total
FROM management_tokens mt
WHERE (? IS NULL OR mt.user_id = ?)
AND (? IS NULL OR mt.is_active = ?)
"#;
const GET_MANAGEMENT_TOKEN_WITH_USER_SQL: &str = r#"
SELECT
mt.id,
mt.user_id,
mt.name,
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
COALESCE(mt.usage_count, 0) AS usage_count,
mt.is_active,
mt.created_at AS created_at_unix_ms,
mt.updated_at AS updated_at_unix_secs,
u.id AS user_row_id,
u.email AS user_email,
u.username AS user_username,
u.role AS user_role
FROM management_tokens mt
JOIN users u ON u.id = mt.user_id
WHERE mt.id = ?
LIMIT 1
"#;
const GET_MANAGEMENT_TOKEN_WITH_USER_BY_HASH_SQL: &str = r#"
SELECT
mt.id,
mt.user_id,
mt.name,
mt.description,
mt.token_prefix,
mt.allowed_ips,
mt.permissions,
mt.expires_at AS expires_at_unix_secs,
mt.last_used_at AS last_used_at_unix_secs,
mt.last_used_ip,
COALESCE(mt.usage_count, 0) AS usage_count,
mt.is_active,
mt.created_at AS created_at_unix_ms,
mt.updated_at AS updated_at_unix_secs,
u.id AS user_row_id,
u.email AS user_email,
u.username AS user_username,
u.role AS user_role
FROM management_tokens mt
JOIN users u ON u.id = mt.user_id
WHERE mt.token_hash = ?
LIMIT 1
"#;
#[async_trait]
@@ -88,27 +148,23 @@ impl ManagementTokenReadRepository for MysqlManagementTokenRepository {
&self,
query: &ManagementTokenListQuery,
) -> Result<StoredManagementTokenListPage, DataLayerError> {
let mut count_builder =
QueryBuilder::<MySql>::new("SELECT COUNT(mt.id) AS total FROM management_tokens mt");
let mut count_where = WhereClause::new();
apply_management_token_filters(&mut count_builder, &mut count_where, query);
let total = count_builder
.build_query_scalar::<i64>()
let count_row = sqlx::query(COUNT_MANAGEMENT_TOKENS_SQL)
.bind(query.user_id.as_deref())
.bind(query.user_id.as_deref())
.bind(query.is_active)
.bind(query.is_active)
.fetch_one(&self.pool)
.await
.map_sql_err()?;
let total = count_row.try_get::<i64, _>("total").map_sql_err()?;
let mut list_builder = QueryBuilder::<MySql>::new(TOKEN_WITH_USER_COLUMNS);
let mut list_where = WhereClause::new();
apply_management_token_filters(&mut list_builder, &mut list_where, query);
list_builder.push(" ORDER BY mt.created_at DESC, mt.id DESC");
push_limit_offset(
&mut list_builder,
i64::try_from(query.limit).unwrap_or(i64::MAX),
i64::try_from(query.offset).unwrap_or(i64::MAX),
);
let rows = list_builder
.build()
let rows = sqlx::query(LIST_MANAGEMENT_TOKENS_SQL)
.bind(query.user_id.as_deref())
.bind(query.user_id.as_deref())
.bind(query.is_active)
.bind(query.is_active)
.bind(i64::try_from(query.limit).unwrap_or(i64::MAX))
.bind(i64::try_from(query.offset).unwrap_or(i64::MAX))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
@@ -126,17 +182,8 @@ impl ManagementTokenReadRepository for MysqlManagementTokenRepository {
&self,
token_id: &str,
) -> Result<Option<StoredManagementTokenWithUser>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(TOKEN_WITH_USER_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"mt.id",
token_id.to_string(),
);
push_limit(&mut builder, 1);
let row = builder
.build()
let row = sqlx::query(GET_MANAGEMENT_TOKEN_WITH_USER_SQL)
.bind(token_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -147,17 +194,8 @@ impl ManagementTokenReadRepository for MysqlManagementTokenRepository {
&self,
token_hash: &str,
) -> Result<Option<StoredManagementTokenWithUser>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(TOKEN_WITH_USER_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"mt.token_hash",
token_hash.to_string(),
);
push_limit(&mut builder, 1);
let row = builder
.build()
let row = sqlx::query(GET_MANAGEMENT_TOKEN_WITH_USER_BY_HASH_SQL)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -165,15 +203,6 @@ impl ManagementTokenReadRepository for MysqlManagementTokenRepository {
}
}
fn apply_management_token_filters(
builder: &mut QueryBuilder<'_, MySql>,
where_clause: &mut WhereClause,
query: &ManagementTokenListQuery,
) {
push_optional_eq(builder, where_clause, "mt.user_id", query.user_id.clone());
push_optional_eq(builder, where_clause, "mt.is_active", query.is_active);
}
#[async_trait]
impl ManagementTokenWriteRepository for MysqlManagementTokenRepository {
async fn create_management_token(

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
use sqlx::{mysql::MySqlRow, Row};
use super::types::{
OAuthProviderReadRepository, OAuthProviderWriteRepository, StoredOAuthProviderConfig,
@@ -8,7 +8,6 @@ use super::types::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{push_eq, push_limit, WhereClause};
#[derive(Debug, Clone)]
pub struct MysqlOAuthProviderRepository {
@@ -24,17 +23,8 @@ impl MysqlOAuthProviderRepository {
&self,
provider_type: &str,
) -> Result<Option<StoredOAuthProviderConfig>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(OAUTH_PROVIDER_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"provider_type",
provider_type.to_string(),
);
push_limit(&mut builder, 1);
let row = builder
.build()
let row = sqlx::query(GET_OAUTH_PROVIDER_CONFIG_SQL)
.bind(provider_type)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -42,7 +32,7 @@ impl MysqlOAuthProviderRepository {
}
}
const OAUTH_PROVIDER_COLUMNS: &str = r#"
const LIST_OAUTH_PROVIDER_CONFIGS_SQL: &str = r#"
SELECT
provider_type,
display_name,
@@ -60,6 +50,29 @@ SELECT
created_at AS created_at_unix_ms,
updated_at AS updated_at_unix_secs
FROM oauth_providers
ORDER BY provider_type ASC
"#;
const GET_OAUTH_PROVIDER_CONFIG_SQL: &str = r#"
SELECT
provider_type,
display_name,
client_id,
client_secret_encrypted,
authorization_url_override,
token_url_override,
userinfo_url_override,
scopes,
redirect_uri,
frontend_callback_url,
attribute_mapping,
extra_config,
is_enabled,
created_at AS created_at_unix_ms,
updated_at AS updated_at_unix_secs
FROM oauth_providers
WHERE provider_type = ?
LIMIT 1
"#;
const COUNT_LOCKED_USERS_IF_PROVIDER_DISABLED_SQL: &str = r#"
@@ -104,9 +117,10 @@ impl OAuthProviderReadRepository for MysqlOAuthProviderRepository {
async fn list_oauth_provider_configs(
&self,
) -> Result<Vec<StoredOAuthProviderConfig>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(OAUTH_PROVIDER_COLUMNS);
builder.push(" ORDER BY provider_type ASC");
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
let rows = sqlx::query(LIST_OAUTH_PROVIDER_CONFIGS_SQL)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_oauth_provider_row).collect()
}

View File

@@ -12,7 +12,6 @@ use super::{
use crate::error::SqlResultExt;
use crate::repository::pool_scores::merge_score_reason_patch;
use crate::DataLayerError;
use aether_data_query::{push_eq, push_in, push_limit, push_limit_offset, WhereClause};
const SCORE_COLUMNS: &str = r#"
SELECT
@@ -58,54 +57,25 @@ impl MysqlPoolMemberScoreRepository {
scope: Option<&PoolScoreScope>,
) -> Result<Vec<StoredPoolMemberScore>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(SCORE_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"pool_kind",
identity.pool_kind.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"pool_id",
identity.pool_id.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"member_kind",
identity.member_kind.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"member_id",
identity.member_id.clone(),
);
builder
.push(" WHERE pool_kind = ")
.push_bind(identity.pool_kind.clone())
.push(" AND pool_id = ")
.push_bind(identity.pool_id.clone())
.push(" AND member_kind = ")
.push_bind(identity.member_kind.clone())
.push(" AND member_id = ")
.push_bind(identity.member_id.clone());
if let Some(scope) = scope {
push_eq(
&mut builder,
&mut where_clause,
"capability",
scope.capability.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"scope_kind",
scope.scope_kind.clone(),
);
builder
.push(" AND capability = ")
.push_bind(scope.capability.clone())
.push(" AND scope_kind = ")
.push_bind(scope.scope_kind.clone());
if let Some(scope_id) = &scope.scope_id {
push_eq(
&mut builder,
&mut where_clause,
"scope_id",
scope_id.clone(),
);
builder.push(" AND scope_id = ").push_bind(scope_id.clone());
} else {
where_clause.push_next(&mut builder);
builder.push("scope_id IS NULL");
builder.push(" AND scope_id IS NULL");
}
}
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
@@ -120,65 +90,44 @@ impl PoolScoreReadRepository for MysqlPoolMemberScoreRepository {
query: &ListRankedPoolMembersQuery,
) -> Result<Vec<StoredPoolMemberScore>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(SCORE_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"pool_kind",
query.pool_kind.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"pool_id",
query.pool_id.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"capability",
query.capability.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"scope_kind",
query.scope_kind.clone(),
);
builder
.push(" WHERE pool_kind = ")
.push_bind(query.pool_kind.clone())
.push(" AND pool_id = ")
.push_bind(query.pool_id.clone())
.push(" AND capability = ")
.push_bind(query.capability.clone())
.push(" AND scope_kind = ")
.push_bind(query.scope_kind.clone());
if let Some(scope_id) = &query.scope_id {
push_eq(
&mut builder,
&mut where_clause,
"scope_id",
scope_id.clone(),
);
builder.push(" AND scope_id = ").push_bind(scope_id.clone());
} else {
where_clause.push_next(&mut builder);
builder.push("scope_id IS NULL");
builder.push(" AND scope_id IS NULL");
}
if !query.hard_states.is_empty() {
let states = query
.hard_states
.iter()
.map(|state| state.as_database())
.collect::<Vec<_>>();
push_in(&mut builder, &mut where_clause, "hard_state", &states);
builder.push(" AND hard_state IN (");
let mut separated = builder.separated(", ");
for state in &query.hard_states {
separated.push_bind(state.as_database());
}
separated.push_unseparated(")");
}
if let Some(statuses) = &query.probe_statuses {
if !statuses.is_empty() {
let statuses = statuses
.iter()
.map(|status| status.as_database())
.collect::<Vec<_>>();
push_in(&mut builder, &mut where_clause, "probe_status", &statuses);
builder.push(" AND probe_status IN (");
let mut separated = builder.separated(", ");
for status in statuses {
separated.push_bind(status.as_database());
}
separated.push_unseparated(")");
}
}
builder.push(" ORDER BY score DESC, last_ranked_at DESC, member_id ASC, id ASC");
push_limit_offset(
&mut builder,
i64_from_usize(query.limit.max(1), "pool score limit")?,
i64_from_usize(query.offset, "pool score offset")?,
);
builder
.push(" ORDER BY score DESC, last_ranked_at DESC, member_id ASC, id ASC")
.push(" LIMIT ")
.push_bind(i64_from_usize(query.limit.max(1), "pool score limit")?)
.push(" OFFSET ")
.push_bind(i64_from_usize(query.offset, "pool score offset")?);
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_score_row).collect()
}
@@ -188,66 +137,48 @@ impl PoolScoreReadRepository for MysqlPoolMemberScoreRepository {
query: &ListPoolMemberScoresQuery,
) -> Result<Vec<StoredPoolMemberScore>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(SCORE_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"pool_kind",
query.pool_kind.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"pool_id",
query.pool_id.clone(),
);
builder
.push(" WHERE pool_kind = ")
.push_bind(query.pool_kind.clone())
.push(" AND pool_id = ")
.push_bind(query.pool_id.clone());
if let Some(capability) = &query.capability {
push_eq(
&mut builder,
&mut where_clause,
"capability",
capability.clone(),
);
builder
.push(" AND capability = ")
.push_bind(capability.clone());
}
if let Some(scope_kind) = &query.scope_kind {
push_eq(
&mut builder,
&mut where_clause,
"scope_kind",
scope_kind.clone(),
);
builder
.push(" AND scope_kind = ")
.push_bind(scope_kind.clone());
}
if let Some(scope_id) = &query.scope_id {
push_eq(
&mut builder,
&mut where_clause,
"scope_id",
scope_id.clone(),
);
builder.push(" AND scope_id = ").push_bind(scope_id.clone());
}
if !query.hard_states.is_empty() {
let states = query
.hard_states
.iter()
.map(|state| state.as_database())
.collect::<Vec<_>>();
push_in(&mut builder, &mut where_clause, "hard_state", &states);
builder.push(" AND hard_state IN (");
let mut separated = builder.separated(", ");
for state in &query.hard_states {
separated.push_bind(state.as_database());
}
separated.push_unseparated(")");
}
if let Some(statuses) = &query.probe_statuses {
if !statuses.is_empty() {
let statuses = statuses
.iter()
.map(|status| status.as_database())
.collect::<Vec<_>>();
push_in(&mut builder, &mut where_clause, "probe_status", &statuses);
builder.push(" AND probe_status IN (");
let mut separated = builder.separated(", ");
for status in statuses {
separated.push_bind(status.as_database());
}
separated.push_unseparated(")");
}
}
builder.push(" ORDER BY score DESC, last_ranked_at DESC, member_id ASC, id ASC");
push_limit_offset(
&mut builder,
i64_from_usize(query.limit.max(1), "pool score limit")?,
i64_from_usize(query.offset, "pool score offset")?,
);
builder
.push(" ORDER BY score DESC, last_ranked_at DESC, member_id ASC, id ASC")
.push(" LIMIT ")
.push_bind(i64_from_usize(query.limit.max(1), "pool score limit")?)
.push(" OFFSET ")
.push_bind(i64_from_usize(query.offset, "pool score offset")?);
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_score_row).collect()
}
@@ -257,30 +188,18 @@ impl PoolScoreReadRepository for MysqlPoolMemberScoreRepository {
query: &ListPoolMemberProbeCandidatesQuery,
) -> Result<Vec<StoredPoolMemberScore>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(SCORE_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"pool_kind",
query.pool_kind.clone(),
);
push_eq(
&mut builder,
&mut where_clause,
"pool_id",
query.pool_id.clone(),
);
if let Some(capability) = &query.capability {
push_eq(
&mut builder,
&mut where_clause,
"capability",
capability.clone(),
);
}
where_clause.push_next(&mut builder);
builder
.push("hard_state IN ('available','unknown','cooldown','quota_exhausted')")
.push(" WHERE pool_kind = ")
.push_bind(query.pool_kind.clone())
.push(" AND pool_id = ")
.push_bind(query.pool_id.clone());
if let Some(capability) = &query.capability {
builder
.push(" AND capability = ")
.push_bind(capability.clone());
}
builder
.push(" AND hard_state IN ('available','unknown','cooldown','quota_exhausted')")
.push(" AND (probe_status IN ('never','failed','stale')")
.push(" OR (probe_status = 'ok' AND (last_probe_success_at IS NULL OR last_probe_success_at <= ")
.push_bind(i64_from_u64(
@@ -309,11 +228,12 @@ impl PoolScoreReadRepository for MysqlPoolMemberScoreRepository {
COALESCE(last_scheduled_at, 0) DESC,
member_id ASC
"#,
);
push_limit(
&mut builder,
i64_from_usize(query.limit.max(1), "pool probe candidate limit")?,
);
)
.push(" LIMIT ")
.push_bind(i64_from_usize(
query.limit.max(1),
"pool probe candidate limit",
)?);
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_score_row).collect()
}
@@ -326,8 +246,12 @@ impl PoolScoreReadRepository for MysqlPoolMemberScoreRepository {
return Ok(Vec::new());
}
let mut builder = QueryBuilder::<MySql>::new(SCORE_COLUMNS);
let mut where_clause = WhereClause::new();
push_in(&mut builder, &mut where_clause, "id", &query.ids);
builder.push(" WHERE id IN (");
let mut separated = builder.separated(", ");
for id in &query.ids {
separated.push_bind(id.clone());
}
separated.push_unseparated(")");
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_score_row).collect()
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
use sqlx::{mysql::MySqlRow, Row};
use super::types::{
bucket_start_unix_secs, build_tunnel_error_event_detail, build_tunnel_metrics_sample,
@@ -14,7 +14,6 @@ use super::types::{
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::{push_eq, push_limit, WhereClause};
#[derive(Debug, Clone)]
pub struct MysqlProxyNodeReadRepository {
@@ -338,23 +337,13 @@ SELECT
FROM proxy_nodes
"#;
const PROXY_NODE_EVENT_COLUMNS: &str = r#"
SELECT
id,
node_id,
event_type,
detail,
event_metadata,
created_at AS created_at_unix_ms
FROM proxy_node_events
"#;
#[async_trait]
impl ProxyNodeReadRepository for MysqlProxyNodeReadRepository {
async fn list_proxy_nodes(&self) -> Result<Vec<StoredProxyNode>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(PROXY_NODE_COLUMNS);
builder.push(" ORDER BY name ASC, id ASC");
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
let rows = sqlx::query(&format!("{PROXY_NODE_COLUMNS} ORDER BY name ASC, id ASC"))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_proxy_node_row).collect()
}
@@ -362,12 +351,8 @@ impl ProxyNodeReadRepository for MysqlProxyNodeReadRepository {
&self,
node_id: &str,
) -> Result<Option<StoredProxyNode>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(PROXY_NODE_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(&mut builder, &mut where_clause, "id", node_id.to_string());
push_limit(&mut builder, 1);
let row = builder
.build()
let row = sqlx::query(&format!("{PROXY_NODE_COLUMNS} WHERE id = ? LIMIT 1"))
.bind(node_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -379,17 +364,26 @@ impl ProxyNodeReadRepository for MysqlProxyNodeReadRepository {
node_id: &str,
limit: usize,
) -> Result<Vec<StoredProxyNodeEvent>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(PROXY_NODE_EVENT_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"node_id",
node_id.to_string(),
);
builder.push(" ORDER BY created_at DESC, id DESC");
push_limit(&mut builder, i64::try_from(limit).unwrap_or(i64::MAX));
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
let rows = sqlx::query(
r#"
SELECT
id,
node_id,
event_type,
detail,
event_metadata,
created_at AS created_at_unix_ms
FROM proxy_node_events
WHERE node_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
"#,
)
.bind(node_id)
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_proxy_node_event_row).collect()
}
@@ -398,36 +392,51 @@ impl ProxyNodeReadRepository for MysqlProxyNodeReadRepository {
node_id: &str,
query: &ProxyNodeEventQuery,
) -> Result<Vec<StoredProxyNodeEvent>, DataLayerError> {
let mut builder = QueryBuilder::<MySql>::new(PROXY_NODE_EVENT_COLUMNS);
let mut where_clause = WhereClause::new();
push_eq(
&mut builder,
&mut where_clause,
"node_id",
node_id.to_string(),
);
if let Some(from_unix_secs) = query.from_unix_secs {
where_clause.push_next(&mut builder);
builder
.push("created_at >= ")
.push_bind(i64::try_from(from_unix_secs).unwrap_or(i64::MAX));
}
if let Some(to_unix_secs) = query.to_unix_secs {
where_clause.push_next(&mut builder);
builder
.push("created_at <= ")
.push_bind(i64::try_from(to_unix_secs).unwrap_or(i64::MAX));
}
if let Some(event_type) = query.event_type.as_deref() {
where_clause.push_next(&mut builder);
builder
.push("LOWER(event_type) = LOWER(")
.push_bind(event_type.to_string())
.push(")");
}
builder.push(" ORDER BY created_at DESC, id DESC");
push_limit(&mut builder, i64::try_from(query.limit).unwrap_or(i64::MAX));
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
let rows = sqlx::query(
r#"
SELECT
id,
node_id,
event_type,
detail,
event_metadata,
created_at AS created_at_unix_ms
FROM proxy_node_events
WHERE node_id = ?
AND (? IS NULL OR created_at >= ?)
AND (? IS NULL OR created_at <= ?)
AND (? IS NULL OR LOWER(event_type) = LOWER(?))
ORDER BY created_at DESC, id DESC
LIMIT ?
"#,
)
.bind(node_id)
.bind(
query
.from_unix_secs
.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
)
.bind(
query
.from_unix_secs
.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
)
.bind(
query
.to_unix_secs
.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
)
.bind(
query
.to_unix_secs
.map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
)
.bind(query.event_type.as_deref())
.bind(query.event_type.as_deref())
.bind(i64::try_from(query.limit).unwrap_or(i64::MAX))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_proxy_node_event_row).collect()
}

View File

@@ -25,26 +25,22 @@ fn quota_snapshot_select() -> SelectQuery<'static> {
SelectColumn::expr(DialectSql::dialect(
"CAST(monthly_quota_usd AS DOUBLE PRECISION)",
"CAST(monthly_quota_usd AS REAL)",
"monthly_quota_usd",
))
.alias("monthly_quota_usd"),
SelectColumn::expr(DialectSql::dialect(
"CAST(COALESCE(monthly_used_usd, 0) AS DOUBLE PRECISION)",
"CAST(COALESCE(monthly_used_usd, 0) AS REAL)",
"COALESCE(monthly_used_usd, 0)",
))
.alias("monthly_used_usd"),
SelectColumn::expr("quota_reset_day"),
SelectColumn::expr(DialectSql::dialect(
"CAST(EXTRACT(EPOCH FROM quota_last_reset_at) AS BIGINT)",
"quota_last_reset_at",
"quota_last_reset_at",
))
.alias("quota_last_reset_at_unix_secs"),
SelectColumn::expr(DialectSql::dialect(
"CAST(EXTRACT(EPOCH FROM quota_expires_at) AS BIGINT)",
"quota_expires_at",
"quota_expires_at",
))
.alias("quota_expires_at_unix_secs"),
SelectColumn::expr("is_active"),

View File

@@ -1,14 +1,25 @@
use async_trait::async_trait;
use sqlx::{mysql::MySqlRow, MySql, Row};
use sqlx::{mysql::MySqlRow, MySql, QueryBuilder, Row};
use super::{
quota_snapshot_select, ProviderQuotaReadRepository, ProviderQuotaWriteRepository,
StoredProviderQuotaSnapshot,
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, StoredProviderQuotaSnapshot,
};
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
use aether_data_query::SqlDialect;
const QUOTA_COLUMNS: &str = r#"
SELECT
id AS provider_id,
billing_type,
monthly_quota_usd,
COALESCE(monthly_used_usd, 0) AS monthly_used_usd,
quota_reset_day,
quota_last_reset_at AS quota_last_reset_at_unix_secs,
quota_expires_at AS quota_expires_at_unix_secs,
is_active
FROM providers
"#;
#[derive(Debug, Clone)]
pub struct MysqlProviderQuotaRepository {
@@ -27,11 +38,8 @@ impl ProviderQuotaReadRepository for MysqlProviderQuotaRepository {
&self,
provider_id: &str,
) -> Result<Option<StoredProviderQuotaSnapshot>, DataLayerError> {
let mut statement = quota_snapshot_select().statement::<MySql>(SqlDialect::Mysql);
statement.where_eq("id", provider_id.to_string()).limit(1);
let row = statement
.finish()
.build()
let row = sqlx::query(&format!("{QUOTA_COLUMNS} WHERE id = ? LIMIT 1"))
.bind(provider_id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
@@ -46,16 +54,16 @@ impl ProviderQuotaReadRepository for MysqlProviderQuotaRepository {
return Ok(Vec::new());
}
let mut statement = quota_snapshot_select().statement::<MySql>(SqlDialect::Mysql);
statement
.where_in("id", provider_ids)
.order_by_sql("id ASC");
let rows = statement
.finish()
.build()
.fetch_all(&self.pool)
.await
.map_sql_err()?;
let mut builder = QueryBuilder::<MySql>::new(QUOTA_COLUMNS);
builder.push(" WHERE id IN (");
{
let mut separated = builder.separated(", ");
for provider_id in provider_ids {
separated.push_bind(provider_id);
}
}
builder.push(") ORDER BY id ASC");
let rows = builder.build().fetch_all(&self.pool).await.map_sql_err()?;
rows.iter().map(map_row).collect()
}
}

View File

@@ -3,8 +3,7 @@
This inventory tracks repository read paths that are intended to use the
internal `aether-data-query` helpers. The first layer centralizes SQL fragments;
the newer `SelectQuery` layer lets repositories describe simple `SELECT`
queries once and render dialect-specific projections for Postgres, SQLite, and
MySQL.
queries once and render dialect-specific projections for Postgres and SQLite.
## Included In This Pass
@@ -27,7 +26,7 @@ MySQL.
- `find_by_provider_id`
- `find_by_provider_ids`
- now uses one `SelectQuery` specification for the quota snapshot projection
across Postgres, SQLite, and MySQL
across Postgres and SQLite
- `provider_catalog`
- provider by-id/provider list reads in PG/SQLite
- endpoint/key by-id and by-provider-id `IN` reads in PG/SQLite
@@ -63,9 +62,6 @@ MySQL.
- `wallet` ledger, order, refund, callback, and redeem-code list logic.
- write/upsert/delete paths, transactions, `RETURNING`, CTEs, window functions,
advisory locks, and schema compatibility probes.
- MySQL `provider_catalog` still delegates read paths through the existing
memory adapter; migrate it in a focused follow-up so MySQL parity can be tested
independently.
- `users/auth` and `global_models` still contain additional simple read paths.
`global_models/sqlite.rs` had pre-existing local edits and must be handled
carefully in a dedicated slice.