feat: add routing profile scheduling policies

This commit is contained in:
fawney19
2026-05-18 11:03:49 +08:00
parent a2f91b4108
commit 92813e6122
124 changed files with 11681 additions and 578 deletions

View File

@@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS routing_groups (
`id` VARCHAR(64) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` LONGTEXT,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`is_system_default` TINYINT(1) NOT NULL DEFAULT 0,
`config_json` JSON NOT NULL,
`version` BIGINT NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
`published_at` BIGINT,
PRIMARY KEY (`id`),
UNIQUE KEY routing_groups_name_key (`name`),
KEY routing_groups_system_default_idx (`is_system_default`, `enabled`)
);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`subject_type` VARCHAR(32) NOT NULL,
`subject_id` VARCHAR(64) NOT NULL,
`is_default` TINYINT(1) NOT NULL DEFAULT 0,
`allow_explicit_select` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY routing_group_bindings_group_id_idx (`group_id`),
KEY routing_group_bindings_subject_idx (`subject_type`, `subject_id`)
);
CREATE TABLE IF NOT EXISTS routing_group_versions (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`version` BIGINT NOT NULL,
`config_json` JSON NOT NULL,
`created_at` BIGINT NOT NULL,
`created_by` VARCHAR(64),
PRIMARY KEY (`id`),
UNIQUE KEY routing_group_versions_group_version_key (`group_id`, `version`),
KEY routing_group_versions_group_id_idx (`group_id`)
);

View File

@@ -0,0 +1,82 @@
CREATE TABLE IF NOT EXISTS public.routing_groups (
id character varying(64) NOT NULL,
name character varying(255) NOT NULL,
description text,
enabled boolean DEFAULT true NOT NULL,
is_system_default boolean DEFAULT false NOT NULL,
config_json jsonb NOT NULL,
version bigint DEFAULT 1 NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
published_at bigint
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_groups_pkey'
) THEN
ALTER TABLE ONLY public.routing_groups
ADD CONSTRAINT routing_groups_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_groups_name_key'
) THEN
ALTER TABLE ONLY public.routing_groups
ADD CONSTRAINT routing_groups_name_key UNIQUE (name);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx
ON public.routing_groups USING btree (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS public.routing_group_bindings (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
subject_type character varying(32) NOT NULL,
subject_id character varying(64) NOT NULL,
is_default boolean DEFAULT false NOT NULL,
allow_explicit_select boolean DEFAULT true NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_group_bindings_pkey'
) THEN
ALTER TABLE ONLY public.routing_group_bindings
ADD CONSTRAINT routing_group_bindings_pkey PRIMARY KEY (id);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx
ON public.routing_group_bindings USING btree (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx
ON public.routing_group_bindings USING btree (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS public.routing_group_versions (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
version bigint NOT NULL,
config_json jsonb NOT NULL,
created_at bigint NOT NULL,
created_by character varying(64)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_group_versions_pkey'
) THEN
ALTER TABLE ONLY public.routing_group_versions
ADD CONSTRAINT routing_group_versions_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'routing_group_versions_group_version_key'
) THEN
ALTER TABLE ONLY public.routing_group_versions
ADD CONSTRAINT routing_group_versions_group_version_key UNIQUE (group_id, version);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx
ON public.routing_group_versions USING btree (group_id);

View File

@@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS routing_groups (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
is_system_default INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
UNIQUE (name)
);
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx
ON routing_groups (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
allow_explicit_select INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx
ON routing_group_bindings (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx
ON routing_group_bindings (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS routing_group_versions (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
version INTEGER NOT NULL,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT,
UNIQUE (group_id, version)
);
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx
ON routing_group_versions (group_id);

View File

@@ -383,3 +383,45 @@ CREATE TABLE IF NOT EXISTS global_models (
UNIQUE KEY global_models_name_key (`name`)
);
CREATE TABLE IF NOT EXISTS routing_groups (
`id` VARCHAR(64) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` LONGTEXT,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`is_system_default` TINYINT(1) NOT NULL DEFAULT 0,
`config_json` JSON NOT NULL,
`version` BIGINT NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
`published_at` BIGINT,
PRIMARY KEY (`id`),
UNIQUE KEY routing_groups_name_key (`name`),
KEY routing_groups_system_default_idx (`is_system_default`, `enabled`)
);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`subject_type` VARCHAR(32) NOT NULL,
`subject_id` VARCHAR(64) NOT NULL,
`is_default` TINYINT(1) NOT NULL DEFAULT 0,
`allow_explicit_select` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` BIGINT NOT NULL,
`updated_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY routing_group_bindings_group_id_idx (`group_id`),
KEY routing_group_bindings_subject_idx (`subject_type`, `subject_id`)
);
CREATE TABLE IF NOT EXISTS routing_group_versions (
`id` VARCHAR(64) NOT NULL,
`group_id` VARCHAR(64) NOT NULL,
`version` BIGINT NOT NULL,
`config_json` JSON NOT NULL,
`created_at` BIGINT NOT NULL,
`created_by` VARCHAR(64),
PRIMARY KEY (`id`),
UNIQUE KEY routing_group_versions_group_version_key (`group_id`, `version`),
KEY routing_group_versions_group_id_idx (`group_id`)
);

View File

@@ -396,3 +396,48 @@ CREATE TABLE IF NOT EXISTS public.global_models (
ALTER TABLE ONLY public.global_models ADD CONSTRAINT global_models_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.global_models ADD CONSTRAINT global_models_name_key UNIQUE (name);
CREATE TABLE IF NOT EXISTS public.routing_groups (
id character varying(64) NOT NULL,
name character varying(255) NOT NULL,
description text,
enabled boolean DEFAULT true NOT NULL,
is_system_default boolean DEFAULT false NOT NULL,
config_json jsonb NOT NULL,
version bigint DEFAULT 1 NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL,
published_at bigint
);
ALTER TABLE ONLY public.routing_groups ADD CONSTRAINT routing_groups_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.routing_groups ADD CONSTRAINT routing_groups_name_key UNIQUE (name);
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON public.routing_groups USING btree (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS public.routing_group_bindings (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
subject_type character varying(32) NOT NULL,
subject_id character varying(64) NOT NULL,
is_default boolean DEFAULT false NOT NULL,
allow_explicit_select boolean DEFAULT true NOT NULL,
created_at bigint NOT NULL,
updated_at bigint NOT NULL
);
ALTER TABLE ONLY public.routing_group_bindings ADD CONSTRAINT routing_group_bindings_pkey PRIMARY KEY (id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx ON public.routing_group_bindings USING btree (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx ON public.routing_group_bindings USING btree (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS public.routing_group_versions (
id character varying(64) NOT NULL,
group_id character varying(64) NOT NULL,
version bigint NOT NULL,
config_json jsonb NOT NULL,
created_at bigint NOT NULL,
created_by character varying(64)
);
ALTER TABLE ONLY public.routing_group_versions ADD CONSTRAINT routing_group_versions_pkey PRIMARY KEY (id);
ALTER TABLE ONLY public.routing_group_versions ADD CONSTRAINT routing_group_versions_group_version_key UNIQUE (group_id, version);
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx ON public.routing_group_versions USING btree (group_id);

View File

@@ -370,3 +370,42 @@ CREATE TABLE IF NOT EXISTS global_models (
UNIQUE (name)
);
CREATE TABLE IF NOT EXISTS routing_groups (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
is_system_default INTEGER NOT NULL DEFAULT 0,
config_json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
published_at INTEGER,
UNIQUE (name)
);
CREATE INDEX IF NOT EXISTS routing_groups_system_default_idx ON routing_groups (is_system_default, enabled);
CREATE TABLE IF NOT EXISTS routing_group_bindings (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
subject_type TEXT NOT NULL,
subject_id TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
allow_explicit_select INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS routing_group_bindings_group_id_idx ON routing_group_bindings (group_id);
CREATE INDEX IF NOT EXISTS routing_group_bindings_subject_idx ON routing_group_bindings (subject_type, subject_id);
CREATE TABLE IF NOT EXISTS routing_group_versions (
id TEXT PRIMARY KEY NOT NULL,
group_id TEXT NOT NULL,
version INTEGER NOT NULL,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT,
UNIQUE (group_id, version)
);
CREATE INDEX IF NOT EXISTS routing_group_versions_group_id_idx ON routing_group_versions (group_id);

View File

@@ -1694,3 +1694,155 @@ type = "unix_seconds"
[[table.global_models.uniques]]
name = "global_models_name_key"
columns = ["name"]
[table.routing_groups]
domain = "provider_catalog"
order = 110
primary_key = ["id"]
[[table.routing_groups.columns]]
name = "id"
type = "text_id"
length = 64
[[table.routing_groups.columns]]
name = "name"
type = "text"
length = 255
[[table.routing_groups.columns]]
name = "description"
type = "long_text"
nullable = true
[[table.routing_groups.columns]]
name = "enabled"
type = "bool"
default = true
[[table.routing_groups.columns]]
name = "is_system_default"
type = "bool"
default = false
[[table.routing_groups.columns]]
name = "config_json"
type = "json"
[[table.routing_groups.columns]]
name = "version"
type = "int64"
default = 1
[[table.routing_groups.columns]]
name = "created_at"
type = "unix_seconds"
[[table.routing_groups.columns]]
name = "updated_at"
type = "unix_seconds"
[[table.routing_groups.columns]]
name = "published_at"
type = "unix_seconds"
nullable = true
[[table.routing_groups.uniques]]
name = "routing_groups_name_key"
columns = ["name"]
[[table.routing_groups.indexes]]
name = "routing_groups_system_default_idx"
columns = ["is_system_default", "enabled"]
[table.routing_group_bindings]
domain = "provider_catalog"
order = 111
primary_key = ["id"]
[[table.routing_group_bindings.columns]]
name = "id"
type = "text_id"
length = 64
[[table.routing_group_bindings.columns]]
name = "group_id"
type = "text_id"
length = 64
[[table.routing_group_bindings.columns]]
name = "subject_type"
type = "text"
length = 32
[[table.routing_group_bindings.columns]]
name = "subject_id"
type = "text_id"
length = 64
[[table.routing_group_bindings.columns]]
name = "is_default"
type = "bool"
default = false
[[table.routing_group_bindings.columns]]
name = "allow_explicit_select"
type = "bool"
default = true
[[table.routing_group_bindings.columns]]
name = "created_at"
type = "unix_seconds"
[[table.routing_group_bindings.columns]]
name = "updated_at"
type = "unix_seconds"
[[table.routing_group_bindings.indexes]]
name = "routing_group_bindings_group_id_idx"
columns = ["group_id"]
[[table.routing_group_bindings.indexes]]
name = "routing_group_bindings_subject_idx"
columns = ["subject_type", "subject_id"]
[table.routing_group_versions]
domain = "provider_catalog"
order = 112
primary_key = ["id"]
[[table.routing_group_versions.columns]]
name = "id"
type = "text_id"
length = 64
[[table.routing_group_versions.columns]]
name = "group_id"
type = "text_id"
length = 64
[[table.routing_group_versions.columns]]
name = "version"
type = "int64"
[[table.routing_group_versions.columns]]
name = "config_json"
type = "json"
[[table.routing_group_versions.columns]]
name = "created_at"
type = "unix_seconds"
[[table.routing_group_versions.columns]]
name = "created_by"
type = "text_id"
length = 64
nullable = true
[[table.routing_group_versions.uniques]]
name = "routing_group_versions_group_version_key"
columns = ["group_id", "version"]
[[table.routing_group_versions.indexes]]
name = "routing_group_versions_group_id_idx"
columns = ["group_id"]

View File

@@ -50,6 +50,9 @@ use crate::repository::proxy_nodes::{
use crate::repository::quota::{
MysqlProviderQuotaRepository, ProviderQuotaReadRepository, ProviderQuotaWriteRepository,
};
use crate::repository::routing_profiles::{
MysqlRoutingGroupRepository, RoutingGroupReadRepository, RoutingGroupWriteRepository,
};
use crate::repository::settlement::{MysqlSettlementRepository, SettlementWriteRepository};
use crate::repository::usage::{
MysqlUsageReadRepository, MysqlUsageWriteRepository, UsageReadRepository, UsageWriteRepository,
@@ -195,6 +198,14 @@ impl MysqlBackend {
Arc::new(MysqlPoolMemberScoreRepository::new(self.pool_clone()))
}
pub fn routing_group_read_repository(&self) -> Arc<dyn RoutingGroupReadRepository> {
Arc::new(MysqlRoutingGroupRepository::new(self.pool_clone()))
}
pub fn routing_group_write_repository(&self) -> Arc<dyn RoutingGroupWriteRepository> {
Arc::new(MysqlRoutingGroupRepository::new(self.pool_clone()))
}
pub fn proxy_node_read_repository(&self) -> Arc<dyn ProxyNodeReadRepository> {
Arc::new(MysqlProxyNodeReadRepository::new(self.pool_clone()))
}

View File

@@ -52,6 +52,9 @@ use crate::repository::proxy_nodes::{
use crate::repository::quota::{
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, SqlxProviderQuotaRepository,
};
use crate::repository::routing_profiles::{
PostgresRoutingGroupRepository, RoutingGroupReadRepository, RoutingGroupWriteRepository,
};
use crate::repository::settlement::{SettlementWriteRepository, SqlxSettlementRepository};
use crate::repository::usage::{
SqlxUsageReadRepository, UsageReadRepository, UsageWriteRepository,
@@ -206,6 +209,14 @@ impl PostgresBackend {
Arc::new(PostgresPoolMemberScoreRepository::new(self.pool_clone()))
}
pub fn routing_group_read_repository(&self) -> Arc<dyn RoutingGroupReadRepository> {
Arc::new(PostgresRoutingGroupRepository::new(self.pool_clone()))
}
pub fn routing_group_write_repository(&self) -> Arc<dyn RoutingGroupWriteRepository> {
Arc::new(PostgresRoutingGroupRepository::new(self.pool_clone()))
}
pub fn provider_quota_read_repository(&self) -> Arc<dyn ProviderQuotaReadRepository> {
Arc::new(SqlxProviderQuotaRepository::new(self.pool_clone()))
}

View File

@@ -18,6 +18,7 @@ use crate::repository::pool_scores::PoolScoreReadRepository;
use crate::repository::provider_catalog::ProviderCatalogReadRepository;
use crate::repository::proxy_nodes::ProxyNodeReadRepository;
use crate::repository::quota::ProviderQuotaReadRepository;
use crate::repository::routing_profiles::RoutingGroupReadRepository;
use crate::repository::usage::UsageReadRepository;
use crate::repository::users::UserReadRepository;
use crate::repository::video_tasks::VideoTaskReadRepository;
@@ -41,6 +42,7 @@ pub struct DataReadRepositories {
request_candidates: Option<Arc<dyn RequestCandidateReadRepository>>,
provider_catalog: Option<Arc<dyn ProviderCatalogReadRepository>>,
provider_quotas: Option<Arc<dyn ProviderQuotaReadRepository>>,
routing_groups: Option<Arc<dyn RoutingGroupReadRepository>>,
usage: Option<Arc<dyn UsageReadRepository>>,
users: Option<Arc<dyn UserReadRepository>>,
video_tasks: Option<Arc<dyn VideoTaskReadRepository>>,
@@ -72,6 +74,7 @@ impl fmt::Debug for DataReadRepositories {
.field("has_request_candidates", &self.request_candidates.is_some())
.field("has_provider_catalog", &self.provider_catalog.is_some())
.field("has_provider_quotas", &self.provider_quotas.is_some())
.field("has_routing_groups", &self.routing_groups.is_some())
.field("has_usage", &self.usage.is_some())
.field("has_users", &self.users.is_some())
.field("has_video_tasks", &self.video_tasks.is_some())
@@ -151,6 +154,10 @@ impl DataReadRepositories {
.map(PostgresBackend::provider_quota_read_repository)
.or_else(|| mysql.map(MysqlBackend::provider_quota_read_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_quota_read_repository)),
routing_groups: postgres
.map(PostgresBackend::routing_group_read_repository)
.or_else(|| mysql.map(MysqlBackend::routing_group_read_repository))
.or_else(|| sqlite.map(SqliteBackend::routing_group_read_repository)),
usage: postgres
.map(PostgresBackend::usage_read_repository)
.or_else(|| mysql.map(MysqlBackend::usage_read_repository))
@@ -241,6 +248,10 @@ impl DataReadRepositories {
self.provider_quotas.clone()
}
pub fn routing_groups(&self) -> Option<Arc<dyn RoutingGroupReadRepository>> {
self.routing_groups.clone()
}
pub fn usage(&self) -> Option<Arc<dyn UsageReadRepository>> {
self.usage.clone()
}
@@ -274,6 +285,7 @@ impl DataReadRepositories {
|| self.request_candidates.is_some()
|| self.provider_catalog.is_some()
|| self.provider_quotas.is_some()
|| self.routing_groups.is_some()
|| self.usage.is_some()
|| self.users.is_some()
|| self.video_tasks.is_some()

View File

@@ -50,6 +50,9 @@ use crate::repository::proxy_nodes::{
use crate::repository::quota::{
ProviderQuotaReadRepository, ProviderQuotaWriteRepository, SqliteProviderQuotaRepository,
};
use crate::repository::routing_profiles::{
RoutingGroupReadRepository, RoutingGroupWriteRepository, SqliteRoutingGroupRepository,
};
use crate::repository::settlement::{SettlementWriteRepository, SqliteSettlementRepository};
use crate::repository::usage::{
SqliteUsageReadRepository, SqliteUsageWriteRepository, UsageReadRepository,
@@ -208,6 +211,14 @@ impl SqliteBackend {
Arc::new(SqlitePoolMemberScoreRepository::new(self.pool_clone()))
}
pub fn routing_group_read_repository(&self) -> Arc<dyn RoutingGroupReadRepository> {
Arc::new(SqliteRoutingGroupRepository::new(self.pool_clone()))
}
pub fn routing_group_write_repository(&self) -> Arc<dyn RoutingGroupWriteRepository> {
Arc::new(SqliteRoutingGroupRepository::new(self.pool_clone()))
}
pub fn proxy_node_read_repository(&self) -> Arc<dyn ProxyNodeReadRepository> {
Arc::new(SqliteProxyNodeReadRepository::new(self.pool_clone()))
}

View File

@@ -15,6 +15,7 @@ use crate::repository::pool_scores::PoolMemberScoreWriteRepository;
use crate::repository::provider_catalog::ProviderCatalogWriteRepository;
use crate::repository::proxy_nodes::ProxyNodeWriteRepository;
use crate::repository::quota::ProviderQuotaWriteRepository;
use crate::repository::routing_profiles::RoutingGroupWriteRepository;
use crate::repository::settlement::SettlementWriteRepository;
use crate::repository::usage::UsageWriteRepository;
use crate::repository::video_tasks::VideoTaskWriteRepository;
@@ -35,6 +36,7 @@ pub struct DataWriteRepositories {
proxy_nodes: Option<Arc<dyn ProxyNodeWriteRepository>>,
provider_catalog: Option<Arc<dyn ProviderCatalogWriteRepository>>,
provider_quotas: Option<Arc<dyn ProviderQuotaWriteRepository>>,
routing_groups: Option<Arc<dyn RoutingGroupWriteRepository>>,
settlement: Option<Arc<dyn SettlementWriteRepository>>,
usage: Option<Arc<dyn UsageWriteRepository>>,
video_tasks: Option<Arc<dyn VideoTaskWriteRepository>>,
@@ -60,6 +62,7 @@ impl fmt::Debug for DataWriteRepositories {
.field("has_proxy_nodes", &self.proxy_nodes.is_some())
.field("has_provider_catalog", &self.provider_catalog.is_some())
.field("has_provider_quotas", &self.provider_quotas.is_some())
.field("has_routing_groups", &self.routing_groups.is_some())
.field("has_settlement", &self.settlement.is_some())
.field("has_usage", &self.usage.is_some())
.field("has_video_tasks", &self.video_tasks.is_some())
@@ -127,6 +130,10 @@ impl DataWriteRepositories {
.map(PostgresBackend::provider_quota_write_repository)
.or_else(|| mysql.map(MysqlBackend::provider_quota_write_repository))
.or_else(|| sqlite.map(SqliteBackend::provider_quota_write_repository)),
routing_groups: postgres
.map(PostgresBackend::routing_group_write_repository)
.or_else(|| mysql.map(MysqlBackend::routing_group_write_repository))
.or_else(|| sqlite.map(SqliteBackend::routing_group_write_repository)),
settlement: postgres
.map(PostgresBackend::settlement_write_repository)
.or_else(|| mysql.map(MysqlBackend::settlement_write_repository))
@@ -203,6 +210,10 @@ impl DataWriteRepositories {
self.provider_quotas.clone()
}
pub fn routing_groups(&self) -> Option<Arc<dyn RoutingGroupWriteRepository>> {
self.routing_groups.clone()
}
pub fn provider_catalog(&self) -> Option<Arc<dyn ProviderCatalogWriteRepository>> {
self.provider_catalog.clone()
}
@@ -233,6 +244,7 @@ impl DataWriteRepositories {
|| self.proxy_nodes.is_some()
|| self.provider_catalog.is_some()
|| self.provider_quotas.is_some()
|| self.routing_groups.is_some()
|| self.settlement.is_some()
|| self.usage.is_some()
|| self.video_tasks.is_some()

View File

@@ -7,7 +7,7 @@ use tracing::info;
// Generated by build.rs from schema/bootstrap/postgres.
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260515000000;
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260516000000;
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
SELECT COUNT(*)::BIGINT
@@ -29,6 +29,7 @@ WHERE table_schema = 'public'
'oauth_providers',
'provider_api_keys',
'proxy_nodes',
'routing_groups',
'user_groups',
'usage_routing_snapshots',
'usage_settlement_snapshots'

View File

@@ -305,6 +305,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
20260512090000,
20260512110000,
20260515000000,
20260516000000,
]
);
}
@@ -587,6 +588,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512000000,
20260512090000,
20260512110000,
20260516000000,
]
);
assert_eq!(
@@ -603,6 +605,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
20260512000000,
20260512090000,
20260512110000,
20260516000000,
]
);
}
@@ -1119,6 +1122,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20260512090000,
20260512110000,
20260515000000,
20260516000000,
]
);
}

View File

@@ -21,6 +21,7 @@ pub mod provider_catalog;
pub mod provider_oauth;
pub mod proxy_nodes;
pub mod quota;
pub mod routing_profiles;
pub mod settlement;
pub mod system;
pub mod usage;

View File

@@ -0,0 +1,342 @@
use std::collections::BTreeMap;
use std::sync::RwLock;
use async_trait::async_trait;
use super::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, CreateRoutingGroupVersionRecord,
RoutingGroupBindingQuery, RoutingGroupLookupKey, RoutingGroupReadRepository,
RoutingGroupWriteRepository, StoredRoutingGroup, StoredRoutingGroupBinding,
StoredRoutingGroupVersion, UpdateRoutingGroupBindingRecord, UpdateRoutingGroupRecord,
};
use crate::DataLayerError;
#[derive(Debug, Default)]
pub struct InMemoryRoutingGroupRepository {
groups: RwLock<BTreeMap<String, StoredRoutingGroup>>,
bindings: RwLock<BTreeMap<String, StoredRoutingGroupBinding>>,
versions: RwLock<BTreeMap<String, StoredRoutingGroupVersion>>,
}
impl InMemoryRoutingGroupRepository {
pub fn seed<I, B, V>(groups: I, bindings: B, versions: V) -> Self
where
I: IntoIterator<Item = StoredRoutingGroup>,
B: IntoIterator<Item = StoredRoutingGroupBinding>,
V: IntoIterator<Item = StoredRoutingGroupVersion>,
{
Self {
groups: RwLock::new(
groups
.into_iter()
.map(|item| (item.id.clone(), item))
.collect(),
),
bindings: RwLock::new(
bindings
.into_iter()
.map(|item| (item.id.clone(), item))
.collect(),
),
versions: RwLock::new(
versions
.into_iter()
.map(|item| (item.id.clone(), item))
.collect(),
),
}
}
}
#[async_trait]
impl RoutingGroupReadRepository for InMemoryRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let mut groups = self
.groups
.read()
.expect("routing group repository lock")
.values()
.cloned()
.collect::<Vec<_>>();
groups.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id)));
Ok(groups)
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let groups = self.groups.read().expect("routing group repository lock");
Ok(match lookup {
RoutingGroupLookupKey::Id(id) => groups.get(id).cloned(),
RoutingGroupLookupKey::Name(name) => {
groups.values().find(|group| group.name == name).cloned()
}
RoutingGroupLookupKey::SystemDefault => groups
.values()
.find(|group| group.is_system_default && group.enabled)
.cloned(),
})
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let mut rows = self
.bindings
.read()
.expect("routing group binding repository lock")
.values()
.filter(|row| {
query
.group_id
.as_ref()
.is_none_or(|group_id| &row.group_id == group_id)
&& query
.subject_type
.as_ref()
.is_none_or(|subject_type| &row.subject_type == subject_type)
&& query
.subject_id
.as_ref()
.is_none_or(|subject_id| &row.subject_id == subject_id)
})
.cloned()
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
left.created_at
.cmp(&right.created_at)
.then(left.id.cmp(&right.id))
});
Ok(rows)
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let mut rows = self
.versions
.read()
.expect("routing group version repository lock")
.values()
.filter(|row| row.group_id == group_id)
.cloned()
.collect::<Vec<_>>();
rows.sort_by(|left, right| {
right
.version
.cmp(&left.version)
.then(right.created_at.cmp(&left.created_at))
});
Ok(rows)
}
}
#[async_trait]
impl RoutingGroupWriteRepository for InMemoryRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
self.groups
.write()
.expect("routing group repository lock")
.insert(group.id.clone(), group.clone());
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let mut groups = self.groups.write().expect("routing group repository lock");
let Some(group) = groups.get_mut(id) else {
return Ok(None);
};
if let Some(name) = patch.name {
if name.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_groups.name is empty".to_string(),
));
}
group.name = name;
}
if let Some(description) = patch.description {
group.description = description;
}
if let Some(enabled) = patch.enabled {
group.enabled = enabled;
}
if let Some(is_system_default) = patch.is_system_default {
group.is_system_default = is_system_default;
}
if let Some(config_json) = patch.config_json {
if !config_json.is_object() {
return Err(DataLayerError::InvalidInput(
"routing_groups.config_json must be a JSON object".to_string(),
));
}
group.config_json = config_json;
}
if let Some(version) = patch.version {
group.version = version.max(1);
}
if let Some(published_at) = patch.published_at {
group.published_at = published_at;
}
group.updated_at = patch.updated_at;
Ok(Some(group.clone()))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(self
.groups
.write()
.expect("routing group repository lock")
.remove(id)
.is_some())
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
self.bindings
.write()
.expect("routing group binding repository lock")
.insert(binding.id.clone(), binding.clone());
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(self
.bindings
.write()
.expect("routing group binding repository lock")
.remove(id)
.is_some())
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let mut bindings = self
.bindings
.write()
.expect("routing group binding repository lock");
let Some(binding) = bindings.get_mut(id) else {
return Ok(None);
};
if let Some(group_id) = patch.group_id {
if group_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.group_id is empty".to_string(),
));
}
binding.group_id = group_id;
}
if let Some(subject_type) = patch.subject_type {
binding.subject_type = subject_type;
}
if let Some(subject_id) = patch.subject_id {
if subject_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.subject_id is empty".to_string(),
));
}
binding.subject_id = subject_id;
}
if let Some(is_default) = patch.is_default {
binding.is_default = is_default;
}
if let Some(allow_explicit_select) = patch.allow_explicit_select {
binding.allow_explicit_select = allow_explicit_select;
}
binding.updated_at = patch.updated_at;
Ok(Some(binding.clone()))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
self.versions
.write()
.expect("routing group version repository lock")
.insert(version.id.clone(), version.clone());
Ok(version)
}
}
#[cfg(test)]
mod tests {
use aether_data_contracts::repository::routing_profiles::RoutingGroupBindingSubject;
use serde_json::json;
use super::*;
#[tokio::test]
async fn stores_groups_bindings_and_versions() {
let repository = InMemoryRoutingGroupRepository::default();
let group = repository
.create_routing_group(CreateRoutingGroupRecord {
id: "group-1".to_string(),
name: "default".to_string(),
description: None,
enabled: true,
is_system_default: true,
config_json: json!({}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.expect("group should store");
assert_eq!(
repository
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
.await
.unwrap()
.as_ref()
.map(|group| group.id.as_str()),
Some(group.id.as_str())
);
repository
.create_routing_group_binding(CreateRoutingGroupBindingRecord {
id: "binding-1".to_string(),
group_id: "group-1".to_string(),
subject_type: RoutingGroupBindingSubject::ApiKey,
subject_id: "api-key-1".to_string(),
is_default: true,
allow_explicit_select: true,
created_at: 1,
updated_at: 1,
})
.await
.unwrap();
assert_eq!(
repository
.list_routing_group_bindings(&RoutingGroupBindingQuery {
subject_type: Some(RoutingGroupBindingSubject::ApiKey),
subject_id: Some("api-key-1".to_string()),
group_id: None,
})
.await
.unwrap()
.len(),
1
);
}
}

View File

@@ -0,0 +1,16 @@
mod memory;
mod mysql;
mod postgres;
mod sqlite;
pub(crate) use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, CreateRoutingGroupVersionRecord,
RoutingGroupBindingQuery, RoutingGroupBindingSubject, RoutingGroupLookupKey,
RoutingGroupReadRepository, RoutingGroupWriteRepository, StoredRoutingGroup,
StoredRoutingGroupBinding, StoredRoutingGroupVersion, UpdateRoutingGroupBindingRecord,
UpdateRoutingGroupRecord,
};
pub use memory::InMemoryRoutingGroupRepository;
pub use mysql::MysqlRoutingGroupRepository;
pub use postgres::PostgresRoutingGroupRepository;
pub use sqlite::SqliteRoutingGroupRepository;

View File

@@ -0,0 +1,417 @@
use async_trait::async_trait;
use serde_json::Value;
use sqlx::{mysql::MySqlRow, Row};
use super::postgres::{
apply_binding_patch, apply_group_patch, binding_subject_from_database,
binding_subject_to_database,
};
use super::*;
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
const ROUTING_GROUP_SELECT: &str = r#"
SELECT
id,
name,
description,
enabled,
is_system_default,
config_json,
version,
created_at,
updated_at,
published_at
FROM routing_groups
"#;
const ROUTING_GROUP_BINDING_SELECT: &str = r#"
SELECT
id,
group_id,
subject_type,
subject_id,
is_default,
allow_explicit_select,
created_at,
updated_at
FROM routing_group_bindings
"#;
const ROUTING_GROUP_VERSION_SELECT: &str = r#"
SELECT
id,
group_id,
version,
config_json,
created_at,
created_by
FROM routing_group_versions
"#;
#[derive(Debug, Clone)]
pub struct MysqlRoutingGroupRepository {
pool: MysqlPool,
}
impl MysqlRoutingGroupRepository {
pub fn new(pool: MysqlPool) -> Self {
Self { pool }
}
async fn reload_group(&self, id: &str) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
self.find_routing_group(RoutingGroupLookupKey::Id(id)).await
}
async fn find_binding_by_id(
&self,
id: &str,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let row = sqlx::query(&format!(
"{ROUTING_GROUP_BINDING_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_binding_row).transpose()
}
}
#[async_trait]
impl RoutingGroupReadRepository for MysqlRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let rows = sqlx::query(&format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC"))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_group_row).collect()
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let row = match lookup {
RoutingGroupLookupKey::Id(id) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::Name(name) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE name = ? LIMIT 1"
))
.bind(name)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::SystemDefault => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE is_system_default = 1 AND enabled = 1 ORDER BY updated_at DESC, id ASC LIMIT 1"
))
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
};
row.as_ref().map(map_group_row).transpose()
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ROUTING_GROUP_BINDING_SELECT}
WHERE (? IS NULL OR group_id = ?)
AND (? IS NULL OR subject_type = ?)
AND (? IS NULL OR subject_id = ?)
ORDER BY created_at ASC, id ASC
"#
))
.bind(query.group_id.as_deref())
.bind(query.group_id.as_deref())
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_id.as_deref())
.bind(query.subject_id.as_deref())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_binding_row).collect()
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let rows = sqlx::query(&format!(
"{ROUTING_GROUP_VERSION_SELECT} WHERE group_id = ? ORDER BY version DESC, created_at DESC, id ASC"
))
.bind(group_id)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_version_row).collect()
}
}
#[async_trait]
impl RoutingGroupWriteRepository for MysqlRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json,
version, created_at, updated_at, published_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&group.id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.created_at)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let Some(mut group) = self.reload_group(id).await? else {
return Ok(None);
};
apply_group_patch(&mut group, patch)?;
sqlx::query(
r#"
UPDATE routing_groups
SET name = ?,
description = ?,
enabled = ?,
is_system_default = ?,
config_json = ?,
version = ?,
updated_at = ?,
published_at = ?
WHERE id = ?
"#,
)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.updated_at)
.bind(group.published_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(group))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
let mut tx = self.pool.begin().await.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_bindings WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_versions WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let rows_affected = sqlx::query("DELETE FROM routing_groups WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected > 0)
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_bindings (
id, group_id, subject_type, subject_id, is_default,
allow_explicit_select, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&binding.id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.created_at)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(
sqlx::query("DELETE FROM routing_group_bindings WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?
.rows_affected()
> 0,
)
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let Some(mut binding) = self.find_binding_by_id(id).await? else {
return Ok(None);
};
apply_binding_patch(&mut binding, patch)?;
sqlx::query(
r#"
UPDATE routing_group_bindings
SET group_id = ?,
subject_type = ?,
subject_id = ?,
is_default = ?,
allow_explicit_select = ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.updated_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(binding))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_versions (
id, group_id, version, config_json, created_at, created_by
)
VALUES (?, ?, ?, ?, ?, ?)
"#,
)
.bind(&version.id)
.bind(&version.group_id)
.bind(version.version)
.bind(json_to_string(
&version.config_json,
"routing_group_versions.config_json",
)?)
.bind(version.created_at)
.bind(&version.created_by)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(version)
}
}
fn map_group_row(row: &MySqlRow) -> Result<StoredRoutingGroup, DataLayerError> {
Ok(StoredRoutingGroup {
id: row.try_get("id").map_sql_err()?,
name: row.try_get("name").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
is_system_default: row.try_get("is_system_default").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_groups.config_json",
)?,
version: row.try_get("version").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
published_at: row.try_get("published_at").map_sql_err()?,
})
}
fn map_binding_row(row: &MySqlRow) -> Result<StoredRoutingGroupBinding, DataLayerError> {
Ok(StoredRoutingGroupBinding {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
subject_type: binding_subject_from_database(row.try_get("subject_type").map_sql_err()?)?,
subject_id: row.try_get("subject_id").map_sql_err()?,
is_default: row.try_get("is_default").map_sql_err()?,
allow_explicit_select: row.try_get("allow_explicit_select").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
})
}
fn map_version_row(row: &MySqlRow) -> Result<StoredRoutingGroupVersion, DataLayerError> {
Ok(StoredRoutingGroupVersion {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
version: row.try_get("version").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_group_versions.config_json",
)?,
created_at: row.try_get("created_at").map_sql_err()?,
created_by: row.try_get("created_by").map_sql_err()?,
})
}
fn json_to_string(value: &Value, field_name: &str) -> Result<String, DataLayerError> {
serde_json::to_string(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains unserializable JSON: {err}"))
})
}
fn json_from_string(value: String, field_name: &str) -> Result<Value, DataLayerError> {
serde_json::from_str(&value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains invalid JSON: {err}"))
})
}

View File

@@ -0,0 +1,483 @@
use async_trait::async_trait;
use futures_util::TryStreamExt;
use sqlx::{postgres::PgRow, PgPool, Row};
use super::*;
use crate::error::SqlxResultExt;
use crate::DataLayerError;
const ROUTING_GROUP_SELECT: &str = r#"
SELECT
id,
name,
description,
enabled,
is_system_default,
config_json,
version,
created_at,
updated_at,
published_at
FROM routing_groups
"#;
const ROUTING_GROUP_BINDING_SELECT: &str = r#"
SELECT
id,
group_id,
subject_type,
subject_id,
is_default,
allow_explicit_select,
created_at,
updated_at
FROM routing_group_bindings
"#;
const ROUTING_GROUP_VERSION_SELECT: &str = r#"
SELECT
id,
group_id,
version,
config_json,
created_at,
created_by
FROM routing_group_versions
"#;
#[derive(Debug, Clone)]
pub struct PostgresRoutingGroupRepository {
pool: PgPool,
}
impl PostgresRoutingGroupRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
async fn reload_group(&self, id: &str) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
self.find_routing_group(RoutingGroupLookupKey::Id(id)).await
}
async fn find_binding_by_id(
&self,
id: &str,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let row = sqlx::query(&format!(
"{ROUTING_GROUP_BINDING_SELECT} WHERE id = $1 LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?;
row.as_ref().map(map_binding_row).transpose()
}
}
#[async_trait]
impl RoutingGroupReadRepository for PostgresRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let sql = format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC");
let mut rows = sqlx::query(&sql).fetch(&self.pool);
let mut groups = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
groups.push(map_group_row(&row)?);
}
Ok(groups)
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let row = match lookup {
RoutingGroupLookupKey::Id(id) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE id = $1 LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?,
RoutingGroupLookupKey::Name(name) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE name = $1 LIMIT 1"
))
.bind(name)
.fetch_optional(&self.pool)
.await
.map_postgres_err()?,
RoutingGroupLookupKey::SystemDefault => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE is_system_default = TRUE AND enabled = TRUE ORDER BY updated_at DESC, id ASC LIMIT 1"
))
.fetch_optional(&self.pool)
.await
.map_postgres_err()?,
};
row.as_ref().map(map_group_row).transpose()
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let sql = format!(
r#"
{ROUTING_GROUP_BINDING_SELECT}
WHERE ($1::text IS NULL OR group_id = $1)
AND ($2::text IS NULL OR subject_type = $2)
AND ($3::text IS NULL OR subject_id = $3)
ORDER BY created_at ASC, id ASC
"#
);
let mut rows = sqlx::query(&sql)
.bind(query.group_id.as_deref())
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_id.as_deref())
.fetch(&self.pool);
let mut bindings = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
bindings.push(map_binding_row(&row)?);
}
Ok(bindings)
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let sql = format!(
"{ROUTING_GROUP_VERSION_SELECT} WHERE group_id = $1 ORDER BY version DESC, created_at DESC, id ASC"
);
let mut rows = sqlx::query(&sql).bind(group_id).fetch(&self.pool);
let mut versions = Vec::new();
while let Some(row) = rows.try_next().await.map_postgres_err()? {
versions.push(map_version_row(&row)?);
}
Ok(versions)
}
}
#[async_trait]
impl RoutingGroupWriteRepository for PostgresRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json,
version, created_at, updated_at, published_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
)
.bind(&group.id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(&group.config_json)
.bind(group.version)
.bind(group.created_at)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let Some(mut group) = self.reload_group(id).await? else {
return Ok(None);
};
apply_group_patch(&mut group, patch)?;
sqlx::query(
r#"
UPDATE routing_groups
SET name = $2,
description = $3,
enabled = $4,
is_system_default = $5,
config_json = $6,
version = $7,
updated_at = $8,
published_at = $9
WHERE id = $1
"#,
)
.bind(id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(&group.config_json)
.bind(group.version)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(group))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
let mut tx = self.pool.begin().await.map_postgres_err()?;
sqlx::query("DELETE FROM routing_group_bindings WHERE group_id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_postgres_err()?;
sqlx::query("DELETE FROM routing_group_versions WHERE group_id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_postgres_err()?;
let rows_affected = sqlx::query("DELETE FROM routing_groups WHERE id = $1")
.bind(id)
.execute(&mut *tx)
.await
.map_postgres_err()?
.rows_affected();
tx.commit().await.map_postgres_err()?;
Ok(rows_affected > 0)
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_bindings (
id, group_id, subject_type, subject_id, is_default,
allow_explicit_select, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
"#,
)
.bind(&binding.id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.created_at)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(
sqlx::query("DELETE FROM routing_group_bindings WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_postgres_err()?
.rows_affected()
> 0,
)
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let Some(mut binding) = self.find_binding_by_id(id).await? else {
return Ok(None);
};
apply_binding_patch(&mut binding, patch)?;
sqlx::query(
r#"
UPDATE routing_group_bindings
SET group_id = $2,
subject_type = $3,
subject_id = $4,
is_default = $5,
allow_explicit_select = $6,
updated_at = $7
WHERE id = $1
"#,
)
.bind(id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(Some(binding))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_versions (
id, group_id, version, config_json, created_at, created_by
)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
)
.bind(&version.id)
.bind(&version.group_id)
.bind(version.version)
.bind(&version.config_json)
.bind(version.created_at)
.bind(&version.created_by)
.execute(&self.pool)
.await
.map_postgres_err()?;
Ok(version)
}
}
pub(super) fn apply_group_patch(
group: &mut StoredRoutingGroup,
patch: UpdateRoutingGroupRecord,
) -> Result<(), DataLayerError> {
if let Some(name) = patch.name {
if name.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_groups.name is empty".to_string(),
));
}
group.name = name;
}
if let Some(description) = patch.description {
group.description = description;
}
if let Some(enabled) = patch.enabled {
group.enabled = enabled;
}
if let Some(is_system_default) = patch.is_system_default {
group.is_system_default = is_system_default;
}
if let Some(config_json) = patch.config_json {
if !config_json.is_object() {
return Err(DataLayerError::InvalidInput(
"routing_groups.config_json must be a JSON object".to_string(),
));
}
group.config_json = config_json;
}
if let Some(version) = patch.version {
group.version = version.max(1);
}
if let Some(published_at) = patch.published_at {
group.published_at = published_at;
}
group.updated_at = patch.updated_at;
Ok(())
}
pub(super) fn apply_binding_patch(
binding: &mut StoredRoutingGroupBinding,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<(), DataLayerError> {
if let Some(group_id) = patch.group_id {
if group_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.group_id is empty".to_string(),
));
}
binding.group_id = group_id;
}
if let Some(subject_type) = patch.subject_type {
binding.subject_type = subject_type;
}
if let Some(subject_id) = patch.subject_id {
if subject_id.trim().is_empty() {
return Err(DataLayerError::InvalidInput(
"routing_group_bindings.subject_id is empty".to_string(),
));
}
binding.subject_id = subject_id;
}
if let Some(is_default) = patch.is_default {
binding.is_default = is_default;
}
if let Some(allow_explicit_select) = patch.allow_explicit_select {
binding.allow_explicit_select = allow_explicit_select;
}
binding.updated_at = patch.updated_at;
Ok(())
}
pub(super) fn binding_subject_to_database(subject: RoutingGroupBindingSubject) -> &'static str {
match subject {
RoutingGroupBindingSubject::User => "user",
RoutingGroupBindingSubject::ApiKey => "api_key",
RoutingGroupBindingSubject::UserGroup => "user_group",
}
}
pub(super) fn binding_subject_from_database(
value: String,
) -> Result<RoutingGroupBindingSubject, DataLayerError> {
match value.as_str() {
"user" => Ok(RoutingGroupBindingSubject::User),
"api_key" => Ok(RoutingGroupBindingSubject::ApiKey),
"user_group" => Ok(RoutingGroupBindingSubject::UserGroup),
_ => Err(DataLayerError::UnexpectedValue(format!(
"invalid routing_group_bindings.subject_type: {value}"
))),
}
}
fn map_group_row(row: &PgRow) -> Result<StoredRoutingGroup, DataLayerError> {
Ok(StoredRoutingGroup {
id: row.try_get("id").map_postgres_err()?,
name: row.try_get("name").map_postgres_err()?,
description: row.try_get("description").map_postgres_err()?,
enabled: row.try_get("enabled").map_postgres_err()?,
is_system_default: row.try_get("is_system_default").map_postgres_err()?,
config_json: row.try_get("config_json").map_postgres_err()?,
version: row.try_get("version").map_postgres_err()?,
created_at: row.try_get("created_at").map_postgres_err()?,
updated_at: row.try_get("updated_at").map_postgres_err()?,
published_at: row.try_get("published_at").map_postgres_err()?,
})
}
fn map_binding_row(row: &PgRow) -> Result<StoredRoutingGroupBinding, DataLayerError> {
Ok(StoredRoutingGroupBinding {
id: row.try_get("id").map_postgres_err()?,
group_id: row.try_get("group_id").map_postgres_err()?,
subject_type: binding_subject_from_database(
row.try_get("subject_type").map_postgres_err()?,
)?,
subject_id: row.try_get("subject_id").map_postgres_err()?,
is_default: row.try_get("is_default").map_postgres_err()?,
allow_explicit_select: row.try_get("allow_explicit_select").map_postgres_err()?,
created_at: row.try_get("created_at").map_postgres_err()?,
updated_at: row.try_get("updated_at").map_postgres_err()?,
})
}
fn map_version_row(row: &PgRow) -> Result<StoredRoutingGroupVersion, DataLayerError> {
Ok(StoredRoutingGroupVersion {
id: row.try_get("id").map_postgres_err()?,
group_id: row.try_get("group_id").map_postgres_err()?,
version: row.try_get("version").map_postgres_err()?,
config_json: row.try_get("config_json").map_postgres_err()?,
created_at: row.try_get("created_at").map_postgres_err()?,
created_by: row.try_get("created_by").map_postgres_err()?,
})
}

View File

@@ -0,0 +1,524 @@
use async_trait::async_trait;
use serde_json::Value;
use sqlx::{sqlite::SqliteRow, Row};
use super::postgres::{
apply_binding_patch, apply_group_patch, binding_subject_from_database,
binding_subject_to_database,
};
use super::*;
use crate::driver::sqlite::SqlitePool;
use crate::error::SqlResultExt;
use crate::DataLayerError;
const ROUTING_GROUP_SELECT: &str = r#"
SELECT
id,
name,
description,
enabled,
is_system_default,
config_json,
version,
created_at,
updated_at,
published_at
FROM routing_groups
"#;
const ROUTING_GROUP_BINDING_SELECT: &str = r#"
SELECT
id,
group_id,
subject_type,
subject_id,
is_default,
allow_explicit_select,
created_at,
updated_at
FROM routing_group_bindings
"#;
const ROUTING_GROUP_VERSION_SELECT: &str = r#"
SELECT
id,
group_id,
version,
config_json,
created_at,
created_by
FROM routing_group_versions
"#;
#[derive(Debug, Clone)]
pub struct SqliteRoutingGroupRepository {
pool: SqlitePool,
}
impl SqliteRoutingGroupRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
async fn reload_group(&self, id: &str) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
self.find_routing_group(RoutingGroupLookupKey::Id(id)).await
}
async fn find_binding_by_id(
&self,
id: &str,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let row = sqlx::query(&format!(
"{ROUTING_GROUP_BINDING_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?;
row.as_ref().map(map_binding_row).transpose()
}
}
#[async_trait]
impl RoutingGroupReadRepository for SqliteRoutingGroupRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
let rows = sqlx::query(&format!("{ROUTING_GROUP_SELECT} ORDER BY name ASC, id ASC"))
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_group_row).collect()
}
async fn find_routing_group(
&self,
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let row = match lookup {
RoutingGroupLookupKey::Id(id) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE id = ? LIMIT 1"
))
.bind(id)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::Name(name) => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE name = ? LIMIT 1"
))
.bind(name)
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
RoutingGroupLookupKey::SystemDefault => sqlx::query(&format!(
"{ROUTING_GROUP_SELECT} WHERE is_system_default = 1 AND enabled = 1 ORDER BY updated_at DESC, id ASC LIMIT 1"
))
.fetch_optional(&self.pool)
.await
.map_sql_err()?,
};
row.as_ref().map(map_group_row).transpose()
}
async fn list_routing_group_bindings(
&self,
query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
let rows = sqlx::query(&format!(
r#"
{ROUTING_GROUP_BINDING_SELECT}
WHERE (? IS NULL OR group_id = ?)
AND (? IS NULL OR subject_type = ?)
AND (? IS NULL OR subject_id = ?)
ORDER BY created_at ASC, id ASC
"#
))
.bind(query.group_id.as_deref())
.bind(query.group_id.as_deref())
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_type.map(binding_subject_to_database))
.bind(query.subject_id.as_deref())
.bind(query.subject_id.as_deref())
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_binding_row).collect()
}
async fn list_routing_group_versions(
&self,
group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
let rows = sqlx::query(&format!(
"{ROUTING_GROUP_VERSION_SELECT} WHERE group_id = ? ORDER BY version DESC, created_at DESC, id ASC"
))
.bind(group_id)
.fetch_all(&self.pool)
.await
.map_sql_err()?;
rows.iter().map(map_version_row).collect()
}
}
#[async_trait]
impl RoutingGroupWriteRepository for SqliteRoutingGroupRepository {
async fn create_routing_group(
&self,
record: CreateRoutingGroupRecord,
) -> Result<StoredRoutingGroup, DataLayerError> {
let group = StoredRoutingGroup::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_groups (
id, name, description, enabled, is_system_default, config_json,
version, created_at, updated_at, published_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&group.id)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.created_at)
.bind(group.updated_at)
.bind(group.published_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(group)
}
async fn update_routing_group(
&self,
id: &str,
patch: UpdateRoutingGroupRecord,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let Some(mut group) = self.reload_group(id).await? else {
return Ok(None);
};
apply_group_patch(&mut group, patch)?;
sqlx::query(
r#"
UPDATE routing_groups
SET name = ?,
description = ?,
enabled = ?,
is_system_default = ?,
config_json = ?,
version = ?,
updated_at = ?,
published_at = ?
WHERE id = ?
"#,
)
.bind(&group.name)
.bind(&group.description)
.bind(group.enabled)
.bind(group.is_system_default)
.bind(json_to_string(
&group.config_json,
"routing_groups.config_json",
)?)
.bind(group.version)
.bind(group.updated_at)
.bind(group.published_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(group))
}
async fn delete_routing_group(&self, id: &str) -> Result<bool, DataLayerError> {
let mut tx = self.pool.begin().await.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_bindings WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
sqlx::query("DELETE FROM routing_group_versions WHERE group_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?;
let rows_affected = sqlx::query("DELETE FROM routing_groups WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_sql_err()?
.rows_affected();
tx.commit().await.map_sql_err()?;
Ok(rows_affected > 0)
}
async fn create_routing_group_binding(
&self,
record: CreateRoutingGroupBindingRecord,
) -> Result<StoredRoutingGroupBinding, DataLayerError> {
let binding = StoredRoutingGroupBinding::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_bindings (
id, group_id, subject_type, subject_id, is_default,
allow_explicit_select, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&binding.id)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.created_at)
.bind(binding.updated_at)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(binding)
}
async fn delete_routing_group_binding(&self, id: &str) -> Result<bool, DataLayerError> {
Ok(
sqlx::query("DELETE FROM routing_group_bindings WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?
.rows_affected()
> 0,
)
}
async fn update_routing_group_binding(
&self,
id: &str,
patch: UpdateRoutingGroupBindingRecord,
) -> Result<Option<StoredRoutingGroupBinding>, DataLayerError> {
let Some(mut binding) = self.find_binding_by_id(id).await? else {
return Ok(None);
};
apply_binding_patch(&mut binding, patch)?;
sqlx::query(
r#"
UPDATE routing_group_bindings
SET group_id = ?,
subject_type = ?,
subject_id = ?,
is_default = ?,
allow_explicit_select = ?,
updated_at = ?
WHERE id = ?
"#,
)
.bind(&binding.group_id)
.bind(binding_subject_to_database(binding.subject_type))
.bind(&binding.subject_id)
.bind(binding.is_default)
.bind(binding.allow_explicit_select)
.bind(binding.updated_at)
.bind(id)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(Some(binding))
}
async fn create_routing_group_version(
&self,
record: CreateRoutingGroupVersionRecord,
) -> Result<StoredRoutingGroupVersion, DataLayerError> {
let version = StoredRoutingGroupVersion::new(record)?;
sqlx::query(
r#"
INSERT INTO routing_group_versions (
id, group_id, version, config_json, created_at, created_by
)
VALUES (?, ?, ?, ?, ?, ?)
"#,
)
.bind(&version.id)
.bind(&version.group_id)
.bind(version.version)
.bind(json_to_string(
&version.config_json,
"routing_group_versions.config_json",
)?)
.bind(version.created_at)
.bind(&version.created_by)
.execute(&self.pool)
.await
.map_sql_err()?;
Ok(version)
}
}
fn map_group_row(row: &SqliteRow) -> Result<StoredRoutingGroup, DataLayerError> {
Ok(StoredRoutingGroup {
id: row.try_get("id").map_sql_err()?,
name: row.try_get("name").map_sql_err()?,
description: row.try_get("description").map_sql_err()?,
enabled: row.try_get("enabled").map_sql_err()?,
is_system_default: row.try_get("is_system_default").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_groups.config_json",
)?,
version: row.try_get("version").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
published_at: row.try_get("published_at").map_sql_err()?,
})
}
fn map_binding_row(row: &SqliteRow) -> Result<StoredRoutingGroupBinding, DataLayerError> {
Ok(StoredRoutingGroupBinding {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
subject_type: binding_subject_from_database(row.try_get("subject_type").map_sql_err()?)?,
subject_id: row.try_get("subject_id").map_sql_err()?,
is_default: row.try_get("is_default").map_sql_err()?,
allow_explicit_select: row.try_get("allow_explicit_select").map_sql_err()?,
created_at: row.try_get("created_at").map_sql_err()?,
updated_at: row.try_get("updated_at").map_sql_err()?,
})
}
fn map_version_row(row: &SqliteRow) -> Result<StoredRoutingGroupVersion, DataLayerError> {
Ok(StoredRoutingGroupVersion {
id: row.try_get("id").map_sql_err()?,
group_id: row.try_get("group_id").map_sql_err()?,
version: row.try_get("version").map_sql_err()?,
config_json: json_from_string(
row.try_get("config_json").map_sql_err()?,
"routing_group_versions.config_json",
)?,
created_at: row.try_get("created_at").map_sql_err()?,
created_by: row.try_get("created_by").map_sql_err()?,
})
}
fn json_to_string(value: &Value, field_name: &str) -> Result<String, DataLayerError> {
serde_json::to_string(value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains unserializable JSON: {err}"))
})
}
fn json_from_string(value: String, field_name: &str) -> Result<Value, DataLayerError> {
serde_json::from_str(&value).map_err(|err| {
DataLayerError::UnexpectedValue(format!("{field_name} contains invalid JSON: {err}"))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::lifecycle::migrate::run_sqlite_migrations;
#[tokio::test]
async fn sqlite_routing_group_repository_round_trips() {
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
run_sqlite_migrations(&pool)
.await
.expect("sqlite migrations should run");
let repository = SqliteRoutingGroupRepository::new(pool);
repository
.create_routing_group(CreateRoutingGroupRecord {
id: "routing-group-1".to_string(),
name: "default".to_string(),
description: Some("initial".to_string()),
enabled: true,
is_system_default: true,
config_json: json!({"allowed_models": ["gpt-*"]}),
version: 1,
created_at: 10,
updated_at: 10,
published_at: None,
})
.await
.expect("group should create");
let system_default = repository
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
.await
.expect("group lookup should succeed")
.expect("system default should exist");
assert_eq!(system_default.id, "routing-group-1");
repository
.update_routing_group(
"routing-group-1",
UpdateRoutingGroupRecord {
description: Some(None),
version: Some(2),
updated_at: 20,
published_at: Some(Some(20)),
..UpdateRoutingGroupRecord::default()
},
)
.await
.expect("group should update");
let binding = repository
.create_routing_group_binding(CreateRoutingGroupBindingRecord {
id: "binding-1".to_string(),
group_id: "routing-group-1".to_string(),
subject_type: RoutingGroupBindingSubject::ApiKey,
subject_id: "api-key-1".to_string(),
is_default: true,
allow_explicit_select: true,
created_at: 10,
updated_at: 10,
})
.await
.expect("binding should create");
assert_eq!(binding.subject_type, RoutingGroupBindingSubject::ApiKey);
assert_eq!(
repository
.list_routing_group_bindings(&RoutingGroupBindingQuery {
group_id: Some("routing-group-1".to_string()),
subject_type: Some(RoutingGroupBindingSubject::ApiKey),
subject_id: Some("api-key-1".to_string()),
})
.await
.expect("bindings should list")
.len(),
1
);
repository
.create_routing_group_version(CreateRoutingGroupVersionRecord {
id: "version-1".to_string(),
group_id: "routing-group-1".to_string(),
version: 2,
config_json: json!({"allowed_models": ["gpt-*"]}),
created_at: 20,
created_by: Some("admin".to_string()),
})
.await
.expect("version should create");
assert_eq!(
repository
.list_routing_group_versions("routing-group-1")
.await
.expect("versions should list")
.len(),
1
);
}
}