Moved code & started split into binary & library
							parent
							
								
									26e2f5f3b1
								
							
						
					
					
						commit
						2ab88c09db
					
				|  | @ -0,0 +1,2 @@ | |||
| # This file is used by diesel to find the development database | ||||
| DATABASE_URL=postgres://rb:rb@localhost:5434/rb | ||||
|  | @ -347,21 +347,6 @@ version = "1.0.7" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "foreign-types" | ||||
| version = "0.3.2" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" | ||||
| dependencies = [ | ||||
|  "foreign-types-shared", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "foreign-types-shared" | ||||
| version = "0.1.1" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "futures" | ||||
| version = "0.3.17" | ||||
|  | @ -848,33 +833,6 @@ version = "0.3.0" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "openssl" | ||||
| version = "0.10.38" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "0c7ae222234c30df141154f159066c5093ff73b63204dcda7121eb082fc56a95" | ||||
| dependencies = [ | ||||
|  "bitflags", | ||||
|  "cfg-if", | ||||
|  "foreign-types", | ||||
|  "libc", | ||||
|  "once_cell", | ||||
|  "openssl-sys", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "openssl-sys" | ||||
| version = "0.9.70" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c6517987b3f8226b5da3661dad65ff7f300cc59fb5ea8333ca191fc65fde3edf" | ||||
| dependencies = [ | ||||
|  "autocfg", | ||||
|  "cc", | ||||
|  "libc", | ||||
|  "pkg-config", | ||||
|  "vcpkg", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "parking_lot" | ||||
| version = "0.11.2" | ||||
|  | @ -941,12 +899,6 @@ version = "0.1.0" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "pkg-config" | ||||
| version = "0.3.22" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "12295df4f294471248581bc09bef3c38a5e46f1e36d6a37353621a0c6c357e1f" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "ppv-lite86" | ||||
| version = "0.2.15" | ||||
|  | @ -1056,6 +1008,23 @@ dependencies = [ | |||
|  "rand_core", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "rb" | ||||
| version = "0.1.0" | ||||
| source = "git+https://git.hackbever.be/rusty-bever/common-rs.git#1ce6c47124533a9f6b9e276b7bae23f07db0bf68" | ||||
| dependencies = [ | ||||
|  "base64", | ||||
|  "chrono", | ||||
|  "figment", | ||||
|  "hmac", | ||||
|  "jwt", | ||||
|  "rand", | ||||
|  "rocket", | ||||
|  "serde", | ||||
|  "sha2", | ||||
|  "uuid", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "rb-gw" | ||||
| version = "0.1.0" | ||||
|  | @ -1068,8 +1037,8 @@ dependencies = [ | |||
|  "hmac", | ||||
|  "jwt", | ||||
|  "mimalloc", | ||||
|  "openssl", | ||||
|  "rand", | ||||
|  "rb", | ||||
|  "rocket", | ||||
|  "rocket_sync_db_pools", | ||||
|  "rust-argon2", | ||||
|  |  | |||
|  | @ -14,6 +14,7 @@ path = "src/main.rs" | |||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| 
 | ||||
| [dependencies] | ||||
| rb = { git = "https://git.hackbever.be/rusty-bever/common-rs.git" } | ||||
| # Backend web framework | ||||
| rocket = { version = "0.5.0-rc.1", features = [ "json", "uuid" ] } | ||||
| # Used to provide Rocket routes with database connections | ||||
|  | @ -23,8 +24,6 @@ serde = { version = "1.0.127", features = [ "derive" ] } | |||
| # ORM | ||||
| diesel = { version = "1.4.7", features = ["postgres", "uuidv07", "chrono"] } | ||||
| diesel_migrations = "1.4.0" | ||||
| # To properly compile libpq statically | ||||
| openssl = "0.10.36" | ||||
| # For password hashing & verification | ||||
| rust-argon2 = "0.8.3" | ||||
| rand = "0.8.4" | ||||
|  |  | |||
|  | @ -0,0 +1,18 @@ | |||
| # I just use this compose file to easily start up test databases | ||||
| version: '3' | ||||
| 
 | ||||
| services: | ||||
|   db: | ||||
|     image: 'postgres:14-alpine' | ||||
|     restart: 'always' | ||||
| 
 | ||||
|     environment: | ||||
|       - 'POSTGRES_USER=rb' | ||||
|       - 'POSTGRES_PASSWORD=rb' | ||||
|     ports: | ||||
|       - '5434:5432' | ||||
|     # volumes: | ||||
|     #   - 'db-data:/var/lib/postgresql/data' | ||||
| 
 | ||||
| # volumes: | ||||
| #   db-data: | ||||
|  | @ -0,0 +1,6 @@ | |||
| -- This file was automatically created by Diesel to setup helper functions | ||||
| -- and other internal bookkeeping. This file is safe to edit, any future | ||||
| -- changes will be added to existing projects as new migrations. | ||||
| 
 | ||||
| DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); | ||||
| DROP FUNCTION IF EXISTS diesel_set_updated_at(); | ||||
|  | @ -0,0 +1,36 @@ | |||
| -- This file was automatically created by Diesel to setup helper functions | ||||
| -- and other internal bookkeeping. This file is safe to edit, any future | ||||
| -- changes will be added to existing projects as new migrations. | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| -- Sets up a trigger for the given table to automatically set a column called | ||||
| -- `updated_at` whenever the row is modified (unless `updated_at` was included | ||||
| -- in the modified columns) | ||||
| -- | ||||
| -- # Example | ||||
| -- | ||||
| -- ```sql | ||||
| -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); | ||||
| -- | ||||
| -- SELECT diesel_manage_updated_at('users'); | ||||
| -- ``` | ||||
| CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ | ||||
| BEGIN | ||||
|     EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s | ||||
|                     FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); | ||||
| END; | ||||
| $$ LANGUAGE plpgsql; | ||||
| 
 | ||||
| CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ | ||||
| BEGIN | ||||
|     IF ( | ||||
|         NEW IS DISTINCT FROM OLD AND | ||||
|         NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at | ||||
|     ) THEN | ||||
|         NEW.updated_at := current_timestamp; | ||||
|     END IF; | ||||
|     RETURN NEW; | ||||
| END; | ||||
| $$ LANGUAGE plpgsql; | ||||
|  | @ -0,0 +1,2 @@ | |||
| -- This file should undo anything in `up.sql` | ||||
| DROP TABLE IF EXISTS users, refresh_tokens CASCADE; | ||||
|  | @ -0,0 +1,23 @@ | |||
| CREATE TABLE users ( | ||||
|     id uuid DEFAULT gen_random_uuid() PRIMARY KEY, | ||||
| 
 | ||||
|     username varchar(32) UNIQUE NOT NULL, | ||||
|     -- Hashed + salted representation of the username | ||||
|     password text NOT NULL, | ||||
|     -- Wether the user is currently blocked | ||||
|     blocked boolean NOT NULL DEFAULT false, | ||||
|     -- Wether the user is an admin | ||||
|     admin boolean NOT NULL DEFAULT false | ||||
| ); | ||||
| 
 | ||||
| -- Stores refresh tokens | ||||
| CREATE TABLE refresh_tokens ( | ||||
|     -- This is more efficient than storing the text | ||||
|     token bytea PRIMARY KEY, | ||||
|     -- The user for whom the token was created | ||||
|     user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||||
|     -- When the token expires | ||||
|     expires_at timestamp NOT NULL, | ||||
|     -- When the token was last used (is NULL until used) | ||||
|     last_used_at timestamp | ||||
| ); | ||||
|  | @ -3,15 +3,14 @@ use diesel::PgConnection; | |||
| use hmac::{Hmac, NewMac}; | ||||
| use jwt::SignWithKey; | ||||
| use rand::{thread_rng, Rng}; | ||||
| use rb::{ | ||||
|     auth::JwtConf, | ||||
|     errors::{RbError, RbResult}, | ||||
| }; | ||||
| use rb_gw::db; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use sha2::Sha256; | ||||
| 
 | ||||
| use crate::{ | ||||
|     db, | ||||
|     errors::{RbError, RbResult}, | ||||
|     RbJwtConf, | ||||
| }; | ||||
| 
 | ||||
| #[derive(Serialize)] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub struct JWTResponse | ||||
|  | @ -31,7 +30,7 @@ pub struct Claims | |||
| 
 | ||||
| pub fn generate_jwt_token( | ||||
|     conn: &PgConnection, | ||||
|     jwt: &RbJwtConf, | ||||
|     jwt: &JwtConf, | ||||
|     user: &db::User, | ||||
| ) -> RbResult<JWTResponse> | ||||
| { | ||||
|  | @ -78,7 +77,7 @@ pub fn generate_jwt_token( | |||
| 
 | ||||
| pub fn refresh_token( | ||||
|     conn: &PgConnection, | ||||
|     jwt: &RbJwtConf, | ||||
|     jwt: &JwtConf, | ||||
|     refresh_token: &str, | ||||
| ) -> RbResult<JWTResponse> | ||||
| { | ||||
|  |  | |||
|  | @ -0,0 +1,8 @@ | |||
| //! The db module contains all Diesel-related logic. This is to prevent the various Diesel imports
 | ||||
| //! from poluting other modules' namespaces.
 | ||||
| 
 | ||||
| pub mod tokens; | ||||
| pub mod users; | ||||
| 
 | ||||
| pub use tokens::{NewRefreshToken, RefreshToken}; | ||||
| pub use users::{NewUser, User}; | ||||
|  | @ -0,0 +1,120 @@ | |||
| //! Handles refresh token-related database operations.
 | ||||
| 
 | ||||
| use diesel::{insert_into, prelude::*, Insertable, PgConnection, Queryable}; | ||||
| use rb::errors::{RbError, RbResult}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use uuid::Uuid; | ||||
| 
 | ||||
| use crate::schema::{refresh_tokens, refresh_tokens::dsl::*}; | ||||
| 
 | ||||
| /// A refresh token as stored in the database
 | ||||
| #[derive(Queryable, Serialize)] | ||||
| pub struct RefreshToken | ||||
| { | ||||
|     pub token: Vec<u8>, | ||||
|     pub user_id: Uuid, | ||||
|     pub expires_at: chrono::NaiveDateTime, | ||||
|     pub last_used_at: Option<chrono::NaiveDateTime>, | ||||
| } | ||||
| 
 | ||||
| /// A new refresh token to be added into the database
 | ||||
| #[derive(Deserialize, Insertable)] | ||||
| #[table_name = "refresh_tokens"] | ||||
| pub struct NewRefreshToken | ||||
| { | ||||
|     pub token: Vec<u8>, | ||||
|     pub user_id: Uuid, | ||||
|     pub expires_at: chrono::NaiveDateTime, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, AsChangeset)] | ||||
| #[table_name = "refresh_tokens"] | ||||
| pub struct PatchRefreshToken | ||||
| { | ||||
|     pub expires_at: Option<chrono::NaiveDateTime>, | ||||
|     pub last_used_at: Option<chrono::NaiveDateTime>, | ||||
| } | ||||
| 
 | ||||
| pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult<Vec<RefreshToken>> | ||||
| { | ||||
|     Ok(refresh_tokens | ||||
|         .offset(offset_.into()) | ||||
|         .limit(limit_.into()) | ||||
|         .load(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't query tokens."))?) | ||||
| } | ||||
| 
 | ||||
| pub fn create(conn: &PgConnection, new_token: &NewRefreshToken) -> RbResult<RefreshToken> | ||||
| { | ||||
|     Ok(insert_into(refresh_tokens) | ||||
|         .values(new_token) | ||||
|         .get_result(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't insert refresh token."))?) | ||||
| 
 | ||||
|     // TODO check for conflict?
 | ||||
| } | ||||
| 
 | ||||
| pub fn update( | ||||
|     conn: &PgConnection, | ||||
|     token_: &[u8], | ||||
|     patch_token: &PatchRefreshToken, | ||||
| ) -> RbResult<RefreshToken> | ||||
| { | ||||
|     Ok(diesel::update(refresh_tokens.filter(token.eq(token_))) | ||||
|         .set(patch_token) | ||||
|         .get_result(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't update token."))?) | ||||
| } | ||||
| 
 | ||||
| pub fn delete(conn: &PgConnection, token_: &[u8]) -> RbResult<()> | ||||
| { | ||||
|     diesel::delete(refresh_tokens.filter(token.eq(token_))) | ||||
|         .execute(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't delete token."))?; | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| /// Returns the token & user data associated with the given refresh token value.
 | ||||
| ///
 | ||||
| /// # Arguments
 | ||||
| ///
 | ||||
| /// * `conn` - database connection to use
 | ||||
| /// * `token_val` - token value to search for
 | ||||
| pub fn find_with_user( | ||||
|     conn: &PgConnection, | ||||
|     token_: &[u8], | ||||
| ) -> Option<(RefreshToken, super::users::User)> | ||||
| { | ||||
|     // TODO actually check for errors here
 | ||||
|     refresh_tokens | ||||
|         .inner_join(crate::schema::users::dsl::users) | ||||
|         .filter(token.eq(token_)) | ||||
|         .first::<(RefreshToken, super::users::User)>(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't get refresh token & user.")) | ||||
|         .ok() | ||||
| } | ||||
| 
 | ||||
| /// Updates a token's `last_used_at` column value.
 | ||||
| ///
 | ||||
| /// # Arguments
 | ||||
| ///
 | ||||
| /// * `conn` - database connection to use
 | ||||
| /// * `token_` - value of the refresh token to update
 | ||||
| /// * `last_used_at_` - date value to update column with
 | ||||
| ///
 | ||||
| /// **NOTE**: argument names use trailing underscores as to not conflict with Diesel's imported dsl
 | ||||
| /// names.
 | ||||
| pub fn update_last_used_at( | ||||
|     conn: &PgConnection, | ||||
|     token_: &[u8], | ||||
|     last_used_at_: chrono::NaiveDateTime, | ||||
| ) -> RbResult<()> | ||||
| { | ||||
|     diesel::update(refresh_tokens.filter(token.eq(token_))) | ||||
|         .set(last_used_at.eq(last_used_at_)) | ||||
|         .execute(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't update last_used_at."))?; | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
|  | @ -0,0 +1,129 @@ | |||
| use diesel::{prelude::*, AsChangeset, Insertable, Queryable}; | ||||
| use rb::errors::{RbError, RbResult}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use uuid::Uuid; | ||||
| 
 | ||||
| use crate::schema::{users, users::dsl::*}; | ||||
| 
 | ||||
| #[derive(Queryable, Serialize)] | ||||
| pub struct User | ||||
| { | ||||
|     pub id: Uuid, | ||||
|     pub username: String, | ||||
|     #[serde(skip_serializing)] | ||||
|     pub password: String, | ||||
|     pub blocked: bool, | ||||
|     pub admin: bool, | ||||
| } | ||||
| 
 | ||||
| #[derive(Insertable, Deserialize)] | ||||
| #[table_name = "users"] | ||||
| pub struct NewUser | ||||
| { | ||||
|     pub username: String, | ||||
|     pub password: String, | ||||
|     pub admin: bool, | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize, AsChangeset)] | ||||
| #[table_name = "users"] | ||||
| #[serde(rename_all = "camelCase")] | ||||
| pub struct PatchSection | ||||
| { | ||||
|     username: Option<String>, | ||||
|     admin: Option<bool>, | ||||
| } | ||||
| 
 | ||||
| pub fn get(conn: &PgConnection, offset_: u32, limit_: u32) -> RbResult<Vec<User>> | ||||
| { | ||||
|     Ok(users | ||||
|         .offset(offset_.into()) | ||||
|         .limit(limit_.into()) | ||||
|         .load(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't query users."))?) | ||||
| } | ||||
| 
 | ||||
| pub fn find(conn: &PgConnection, user_id: Uuid) -> Option<User> | ||||
| { | ||||
|     users.find(user_id).first::<User>(conn).ok() | ||||
| } | ||||
| 
 | ||||
| pub fn find_by_username(conn: &PgConnection, username_: &str) -> RbResult<User> | ||||
| { | ||||
|     Ok(users | ||||
|         .filter(username.eq(username_)) | ||||
|         .first::<User>(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't find users by username."))?) | ||||
| } | ||||
| 
 | ||||
| /// Insert a new user into the database
 | ||||
| ///
 | ||||
| /// # Arguments
 | ||||
| ///
 | ||||
| /// * `conn` - database connection to use
 | ||||
| /// * `new_user` - user to insert
 | ||||
| pub fn create(conn: &PgConnection, new_user: &NewUser) -> RbResult<()> | ||||
| { | ||||
|     let count = diesel::insert_into(users) | ||||
|         .values(new_user) | ||||
|         .execute(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't create user."))?; | ||||
| 
 | ||||
|     if count == 0 { | ||||
|         return Err(RbError::UMDuplicateUser); | ||||
|     } | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| /// Either create a new user or update an existing one on conflict.
 | ||||
| ///
 | ||||
| /// # Arguments
 | ||||
| ///
 | ||||
| /// * `conn` - database connection to use
 | ||||
| /// * `new_user` - user to insert/update
 | ||||
| // pub fn create_or_update(conn: &PgConnection, new_user: &NewUser) -> RbResult<()>
 | ||||
| // {
 | ||||
| //     diesel::insert_into(users)
 | ||||
| //         .values(new_user)
 | ||||
| //         .on_conflict(username)
 | ||||
| //         .do_update()
 | ||||
| //         .set(new_user)
 | ||||
| //         .execute(conn)
 | ||||
| //         .map_err(|_| RbError::DbError("Couldn't create or update user."))?;
 | ||||
| 
 | ||||
| //     Ok(())
 | ||||
| // }
 | ||||
| 
 | ||||
| /// Delete the user with the given ID.
 | ||||
| ///
 | ||||
| /// # Arguments
 | ||||
| ///
 | ||||
| /// `conn` - database connection to use
 | ||||
| /// `user_id` - ID of user to delete
 | ||||
| pub fn delete(conn: &PgConnection, user_id: Uuid) -> RbResult<()> | ||||
| { | ||||
|     diesel::delete(users.filter(id.eq(user_id))) | ||||
|         .execute(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't delete user."))?; | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| /// Block a user given an ID.
 | ||||
| /// In practice, this means updating the user's entry so that the `blocked` column is set to
 | ||||
| /// `true`.
 | ||||
| ///
 | ||||
| /// # Arguments
 | ||||
| ///
 | ||||
| /// `conn` - database connection to use
 | ||||
| /// `user_id` - ID of user to block
 | ||||
| pub fn block(conn: &PgConnection, user_id: Uuid) -> RbResult<()> | ||||
| { | ||||
|     diesel::update(users.filter(id.eq(user_id))) | ||||
|         .set(blocked.eq(true)) | ||||
|         .execute(conn) | ||||
|         .map_err(|_| RbError::DbError("Couldn't block user."))?; | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
|  | @ -0,0 +1,5 @@ | |||
| #[macro_use] | ||||
| extern crate diesel; | ||||
| 
 | ||||
| pub mod db; | ||||
| pub(crate) mod schema; | ||||
|  | @ -1,13 +1,3 @@ | |||
| table! { | ||||
|     posts (id) { | ||||
|         id -> Uuid, | ||||
|         section_id -> Uuid, | ||||
|         title -> Nullable<Varchar>, | ||||
|         publish_date -> Date, | ||||
|         content -> Text, | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| table! { | ||||
|     refresh_tokens (token) { | ||||
|         token -> Bytea, | ||||
|  | @ -17,17 +7,6 @@ table! { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| table! { | ||||
|     sections (id) { | ||||
|         id -> Uuid, | ||||
|         title -> Varchar, | ||||
|         shortname -> Varchar, | ||||
|         description -> Nullable<Text>, | ||||
|         is_default -> Bool, | ||||
|         has_titles -> Bool, | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| table! { | ||||
|     users (id) { | ||||
|         id -> Uuid, | ||||
|  | @ -38,12 +17,9 @@ table! { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| joinable!(posts -> sections (section_id)); | ||||
| joinable!(refresh_tokens -> users (user_id)); | ||||
| 
 | ||||
| allow_tables_to_appear_in_same_query!( | ||||
|     posts, | ||||
|     refresh_tokens, | ||||
|     sections, | ||||
|     users, | ||||
| ); | ||||
|  |  | |||
		Reference in New Issue