1use rustlavel_db::migration::Migration;
14use rustlavel_db::schema::{Schema, Table};
15use rustlavel_core::Result;
16
17pub const ROLES: &str = "roles";
20pub const PERMISSIONS: &str = "permissions";
21pub const ROLE_PERMISSION: &str = "role_permission";
22pub const USER_ROLE: &str = "user_role";
23pub const USER_PERMISSION: &str = "user_permission";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TableNames {
33 pub roles: String,
34 pub permissions: String,
35 pub role_permission: String,
36 pub user_role: String,
37 pub user_permission: String,
38}
39
40impl Default for TableNames {
41 fn default() -> Self {
42 TableNames {
43 roles: ROLES.to_string(),
44 permissions: PERMISSIONS.to_string(),
45 role_permission: ROLE_PERMISSION.to_string(),
46 user_role: USER_ROLE.to_string(),
47 user_permission: USER_PERMISSION.to_string(),
48 }
49 }
50}
51
52impl TableNames {
53 pub fn suffixed(suffix: &str) -> Self {
58 let base = TableNames::default();
59 TableNames {
60 roles: format!("{}{suffix}", base.roles),
61 permissions: format!("{}{suffix}", base.permissions),
62 role_permission: format!("{}{suffix}", base.role_permission),
63 user_role: format!("{}{suffix}", base.user_role),
64 user_permission: format!("{}{suffix}", base.user_permission),
65 }
66 }
67
68 pub fn validate(&self) -> Result<()> {
73 for name in self.all() {
74 rustlavel_db::validate_identifier(name)?;
75 }
76 Ok(())
77 }
78
79 pub fn all(&self) -> [&str; 5] {
82 [
83 &self.roles,
84 &self.permissions,
85 &self.role_permission,
86 &self.user_role,
87 &self.user_permission,
88 ]
89 }
90}
91
92pub async fn create_tables(schema: &Schema<'_>, names: &TableNames) -> Result<()> {
98 names.validate()?;
99
100 schema.create(&names.roles, define_named_table).await?;
101 schema.create(&names.permissions, define_named_table).await?;
102
103 let roles = names.roles.clone();
108 let permissions = names.permissions.clone();
109 schema
110 .create(&names.role_permission, move |t: &mut Table| {
111 t.id();
112 t.big_integer("role_id").references(&roles, "id").cascade_on_delete().index();
113 t.big_integer("permission_id")
114 .references(&permissions, "id")
115 .cascade_on_delete()
116 .index();
117 t.timestamps();
118 t.unique(&["role_id", "permission_id"]);
121 })
122 .await?;
123
124 let roles = names.roles.clone();
125 schema
126 .create(&names.user_role, move |t: &mut Table| {
127 t.big_integer("user_id").index();
134 t.big_integer("role_id").references(&roles, "id").cascade_on_delete().index();
135 t.timestamps();
136 t.unique(&["user_id", "role_id"]);
137 })
138 .await?;
139
140 let permissions = names.permissions.clone();
141 schema
142 .create(&names.user_permission, move |t: &mut Table| {
143 t.big_integer("user_id").index();
144 t.big_integer("permission_id")
145 .references(&permissions, "id")
146 .cascade_on_delete()
147 .index();
148 t.boolean("granted").default_bool(true);
153 t.timestamps();
154 t.unique(&["user_id", "permission_id"]);
155 })
156 .await?;
157
158 Ok(())
159}
160
161fn define_named_table(t: &mut Table) {
163 t.id();
164 t.string("name").unique();
165 t.text("description").nullable();
168 t.timestamps();
169}
170
171pub async fn drop_tables(schema: &Schema<'_>, names: &TableNames) -> Result<()> {
173 names.validate()?;
174
175 for table in [
176 &names.user_permission,
177 &names.user_role,
178 &names.role_permission,
179 &names.permissions,
180 &names.roles,
181 ] {
182 schema.drop(table).await?;
183 }
184 Ok(())
185}
186
187rustlavel_db::migration!(
188 CreateRbacTables,
189 "2026_09_02_000001_create_rbac_tables",
190 up: |schema| { crate::tables::create_tables(schema, &TableNames::default()).await },
191 down: |schema| { crate::tables::drop_tables(schema, &TableNames::default()).await },
192);
193
194pub fn migrations() -> Vec<&'static dyn Migration> {
201 vec![&CreateRbacTables]
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use rustlavel_db::dialect::{MySql, Postgres, SqlServer};
208 use rustlavel_db::schema::create_statements;
209
210 fn ddl(dialect: &dyn rustlavel_db::Dialect, table: &str, define: fn(&mut Table)) -> String {
212 create_statements(dialect, table, define).expect("valid definition").join(";\n")
213 }
214
215 #[test]
216 fn the_default_names_are_the_conventional_ones() {
217 let names = TableNames::default();
218
219 assert_eq!(
220 names.all(),
221 ["roles", "permissions", "role_permission", "user_role", "user_permission"]
222 );
223 assert!(names.validate().is_ok());
224 }
225
226 #[test]
227 fn a_suffix_reaches_every_table() {
228 let names = TableNames::suffixed("_t9");
229
230 assert_eq!(
231 names.all(),
232 [
233 "roles_t9",
234 "permissions_t9",
235 "role_permission_t9",
236 "user_role_t9",
237 "user_permission_t9"
238 ]
239 );
240 assert!(names.validate().is_ok());
241 }
242
243 #[test]
244 fn a_table_name_that_is_not_an_identifier_is_refused() {
245 let names =
246 TableNames { roles: "roles; drop table users".to_string(), ..TableNames::default() };
247
248 let error = names.validate().expect_err("a name carrying a statement must be refused");
249 assert!(error.to_string().contains("not a valid SQL identifier"), "{error}");
250 }
251
252 #[test]
253 fn the_named_tables_render_on_all_three_databases() {
254 for dialect in [&Postgres as &dyn rustlavel_db::Dialect, &MySql, &SqlServer] {
255 let sql = ddl(dialect, "roles", define_named_table);
256
257 assert!(sql.contains("create table"), "{}: {sql}", dialect.name());
258 assert!(sql.to_lowercase().contains("unique"), "{}: {sql}", dialect.name());
259 assert!(sql.contains("description"), "{}: {sql}", dialect.name());
260 }
261 }
262
263 #[test]
264 fn the_granted_column_is_a_boolean_defaulting_to_true() {
265 let define = |t: &mut Table| {
269 t.boolean("granted").default_bool(true);
270 };
271
272 let postgres = ddl(&Postgres, "user_permission", define);
273 assert!(postgres.contains("default true"), "{postgres}");
274
275 for dialect in [&MySql as &dyn rustlavel_db::Dialect, &SqlServer] {
276 let sql = ddl(dialect, "user_permission", define);
277 assert!(sql.contains("default 1"), "{}: {sql}", dialect.name());
278 }
279 }
280
281 #[test]
282 fn the_migration_is_named_the_way_the_cli_names_them() {
283 let name = CreateRbacTables.name();
284
285 assert_eq!(name, "2026_09_02_000001_create_rbac_tables");
286 let (date, rest) = name.split_at(11);
287 assert!(date.chars().all(|c| c.is_ascii_digit() || c == '_'), "{date}");
288 assert!(rest.starts_with("000001_"), "{rest}");
289 }
290
291 #[test]
292 fn the_registry_exposes_every_migration() {
293 let registry = migrations();
294
295 assert_eq!(registry.len(), 1);
296 assert_eq!(registry[0].name(), "2026_09_02_000001_create_rbac_tables");
297 }
298}