Skip to content

Commit 6b60e2a

Browse files
committed
feat: add gists_gists, gists_comments and gists_comments & related methods
The following changes are implemented for both db-sqlx-postgres and db-sqlx-sqlite: TABLE gists_gists Stores gist metadata with unique index on gists_gists.public_id for fast lookups TABLE gists_comments Stores comment metadata TABLE gists_privacy Stores gist privacy: sqlx currently doesn't have support Postgres enums(ref: launchbadge/sqlx#1171), so storing possible privacy values as references from this table. This table shouldn't be mutated during runtime. Possible values are already recorded in database during migrations. All runtime operations on this table must only take references. Each implementation of GistDatabase also includes a method called privacy_exists, which is called during tests to ensure that migrations are successful. VIEW gists_gists_view Gist lookups combines data from gists_users, gists_gists and gists_privacy. This SQL view boots performance(I think?). At any rate, it is much nicer to work with. QUIRKS Database indexes are i64 in SQLite while i32 in Postgres
1 parent e96550b commit 6b60e2a

22 files changed

+1632
-214
lines changed

Makefile

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ clean: ## Clean all build artifacts and dependencies
55
@-/bin/rm -rf target/
66
@-/bin/rm -rf database/migrator/target/
77
@-/bin/rm -rf database/*/target/
8+
@-/bin/rm -rf database/*/tmp/
89
@cargo clean
910

1011
coverage: migrate ## Generate coverage report in HTML format

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<h1> Gists </h1>
33
<p>
44

5-
**Self-Hosted GitHub Gists\***
5+
**Self-Hosted GitHub Gists**
66

77
</p>
88

@@ -33,4 +33,4 @@ can rival GitHub Gists.
3333

3434
1. All configuration is done through
3535
[./config/default.toml](./config/default.toml)(can be moved to
36-
`/etc/static-gists/config.toml`).
36+
`/etc/gists/config.toml`).

database/db-core/src/errors.rs

+16-3
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,29 @@ pub enum DBError {
1919
#[error("Email not available")]
2020
DuplicateEmail,
2121

22+
/// Gist public ID taken
23+
#[error("Gist ID not available")]
24+
GistIDTaken,
25+
2226
/// Account with specified characteristics not found
2327
#[error("Account with specified characteristics not found")]
2428
AccountNotFound,
2529

26-
// /// errors that are specific to a database implementation
27-
// #[error("Database error: {:?}", _0)]
28-
// DBError(#[error(not(source))] String),
2930
/// errors that are specific to a database implementation
3031
#[error("{0}")]
3132
DBError(#[source] BoxDynError),
33+
34+
/// email is already taken
35+
#[error("Unknown privacy specifier {}", _0)]
36+
UnknownPrivacySpecifier(String),
37+
38+
/// Gist with specified characteristics not found
39+
#[error("Gist with specified characteristics not found")]
40+
GistNotFound,
41+
42+
/// Comment with specified characteristics not found
43+
#[error("Comment with specified characteristics not found")]
44+
CommentNotFound,
3245
}
3346

3447
/// Convenience type alias for grouping driver-specific errors

database/db-core/src/lib.rs

+167-1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,108 @@ pub struct Password {
5151
pub password: String,
5252
}
5353

54+
#[derive(Clone, Debug)]
55+
/// Data required to create a gist in DB
56+
/// creation date defaults to time at which creation method is called
57+
pub struct CreateGist {
58+
/// owner of the gist
59+
pub owner: String,
60+
/// description of the gist
61+
pub description: Option<String>,
62+
/// public ID of the gist
63+
pub public_id: String,
64+
/// gist privacy
65+
pub privacy: GistPrivacy,
66+
}
67+
68+
/// Gist privacy
69+
#[derive(Clone, PartialEq, Debug)]
70+
pub enum GistPrivacy {
71+
/// Everyone can see the gist, will be displayed on /explore and
72+
/// search engines might index it too
73+
Public,
74+
/// Everyone with the link can see it, won't be listed on /explore and
75+
/// search engines won't index them
76+
Unlisted,
77+
/// Only the owner can see gist
78+
Private,
79+
}
80+
81+
impl GistPrivacy {
82+
/// Convert [GistPrivacy] to [str]
83+
pub const fn to_str(&self) -> &'static str {
84+
match self {
85+
GistPrivacy::Private => "private",
86+
GistPrivacy::Unlisted => "unlisted",
87+
GistPrivacy::Public => "public",
88+
}
89+
}
90+
91+
/// Convert [str] to [GistPrivacy]
92+
pub fn from_str(s: &str) -> DBResult<Self> {
93+
const PRIVATE: &str = GistPrivacy::Private.to_str();
94+
const PUBLIC: &str = GistPrivacy::Public.to_str();
95+
const UNLISTED: &str = GistPrivacy::Unlisted.to_str();
96+
let s = s.trim();
97+
match s {
98+
PRIVATE => Ok(Self::Private),
99+
PUBLIC => Ok(Self::Public),
100+
UNLISTED => Ok(Self::Unlisted),
101+
_ => Err(DBError::UnknownPrivacySpecifier(s.to_owned())),
102+
}
103+
}
104+
}
105+
106+
impl From<GistPrivacy> for String {
107+
fn from(gp: GistPrivacy) -> String {
108+
gp.to_str().into()
109+
}
110+
}
111+
112+
#[derive(Clone, Debug)]
113+
/// Represents a gist
114+
pub struct Gist {
115+
/// owner of the gist
116+
pub owner: String,
117+
/// description of the gist
118+
pub description: Option<String>,
119+
/// public ID of the gist
120+
pub public_id: String,
121+
/// gist creation time
122+
pub created: i64,
123+
/// gist updated time
124+
pub updated: i64,
125+
/// gist privacy
126+
pub privacy: GistPrivacy,
127+
}
128+
129+
#[derive(Clone, Debug)]
130+
/// Represents a comment on a Gist
131+
pub struct GistComment {
132+
/// Unique identifier, possible database assigned, auto-incremented ID
133+
pub id: i64,
134+
/// owner of the comment
135+
pub owner: String,
136+
/// public ID of the gist on which this comment was made
137+
pub gist_public_id: String,
138+
/// comment text
139+
pub comment: String,
140+
/// comment creation time
141+
pub created: i64,
142+
}
143+
144+
#[derive(Clone, Debug)]
145+
/// Data required to create a comment on a Gist
146+
/// creation date defaults to time at which creation method is called
147+
pub struct CreateGistComment {
148+
/// owner of the comment
149+
pub owner: String,
150+
/// public ID of the gist on which this comment was made
151+
pub gist_public_id: String,
152+
/// comment text
153+
pub comment: String,
154+
}
155+
54156
/// payload to register a user with username _and_ email
55157
pub struct EmailRegisterPayload<'a> {
56158
/// username of new user
@@ -118,9 +220,33 @@ pub trait GistDatabase: std::marker::Send + std::marker::Sync + CloneGistDatabas
118220
async fn email_register(&self, payload: &EmailRegisterPayload) -> DBResult<()>;
119221
/// register with username
120222
async fn username_register(&self, payload: &UsernameRegisterPayload) -> DBResult<()>;
121-
122223
/// ping DB
123224
async fn ping(&self) -> bool;
225+
226+
/// Check if a Gist with the given ID exists
227+
async fn gist_exists(&self, public_id: &str) -> DBResult<bool>;
228+
/// Create new gists
229+
async fn new_gist(&self, gist: &CreateGist) -> DBResult<()>;
230+
/// Retrieve gist from database
231+
async fn get_gist(&self, public_id: &str) -> DBResult<Gist>;
232+
233+
/// Retrieve gists belonging to user
234+
async fn get_user_gists(&self, owner: &str) -> DBResult<Vec<Gist>>;
235+
236+
/// Delete gist
237+
async fn delete_gist(&self, owner: &str, public_id: &str) -> DBResult<()>;
238+
239+
/// Create new comment
240+
async fn new_comment(&self, comment: &CreateGistComment) -> DBResult<()>;
241+
/// Get comments on a gist
242+
async fn get_comments_on_gist(&self, public_id: &str) -> DBResult<Vec<GistComment>>;
243+
/// Get a specific comment using its database assigned ID
244+
async fn get_comment_by_id(&self, id: i64) -> DBResult<GistComment>;
245+
/// Delete comment
246+
async fn delete_comment(&self, owner: &str, id: i64) -> DBResult<()>;
247+
248+
/// check if privacy mode exists
249+
async fn privacy_exists(&self, privacy: &GistPrivacy) -> DBResult<bool>;
124250
}
125251

126252
#[async_trait]
@@ -177,6 +303,46 @@ impl GistDatabase for Box<dyn GistDatabase> {
177303
async fn ping(&self) -> bool {
178304
(**self).ping().await
179305
}
306+
307+
async fn gist_exists(&self, public_id: &str) -> DBResult<bool> {
308+
(**self).gist_exists(public_id).await
309+
}
310+
311+
async fn new_gist(&self, gist: &CreateGist) -> DBResult<()> {
312+
(**self).new_gist(gist).await
313+
}
314+
315+
async fn get_gist(&self, public_id: &str) -> DBResult<Gist> {
316+
(**self).get_gist(public_id).await
317+
}
318+
319+
async fn get_user_gists(&self, owner: &str) -> DBResult<Vec<Gist>> {
320+
(**self).get_user_gists(owner).await
321+
}
322+
323+
async fn delete_gist(&self, owner: &str, public_id: &str) -> DBResult<()> {
324+
(**self).delete_gist(owner, public_id).await
325+
}
326+
327+
async fn new_comment(&self, comment: &CreateGistComment) -> DBResult<()> {
328+
(**self).new_comment(comment).await
329+
}
330+
331+
async fn get_comments_on_gist(&self, public_id: &str) -> DBResult<Vec<GistComment>> {
332+
(**self).get_comments_on_gist(public_id).await
333+
}
334+
335+
async fn get_comment_by_id(&self, id: i64) -> DBResult<GistComment> {
336+
(**self).get_comment_by_id(id).await
337+
}
338+
339+
async fn delete_comment(&self, owner: &str, id: i64) -> DBResult<()> {
340+
(**self).delete_comment(owner, id).await
341+
}
342+
343+
async fn privacy_exists(&self, privacy: &GistPrivacy) -> DBResult<bool> {
344+
(**self).privacy_exists(privacy).await
345+
}
180346
}
181347

182348
/// Trait to clone GistDatabase

database/db-core/src/tests.rs

+115
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,121 @@ pub async fn email_register_works<T: GistDatabase>(
3939
assert!(matches!(err, Some(DBError::DuplicateEmail)));
4040
}
4141

42+
/// test if all privacy modes are available on database
43+
pub async fn privacy_works<T: GistDatabase>(db: &T) {
44+
for p in [
45+
GistPrivacy::Public,
46+
GistPrivacy::Unlisted,
47+
GistPrivacy::Private,
48+
]
49+
.iter()
50+
{
51+
println!("Testing privacy: {}", p.to_str());
52+
assert!(db.privacy_exists(p).await.unwrap());
53+
}
54+
}
55+
56+
/// test all gist methods
57+
pub async fn gists_work<T: GistDatabase>(
58+
db: &T,
59+
username: &str,
60+
password: &str,
61+
secret: &str,
62+
public_id: &str,
63+
) {
64+
fn assert_comments(lhs: &CreateGistComment, rhs: &GistComment) {
65+
println!("lhs: {:?} rhs: {:?}", lhs, rhs);
66+
assert_eq!(rhs.owner, lhs.owner);
67+
assert_eq!(rhs.comment, lhs.comment);
68+
assert_eq!(rhs.gist_public_id, lhs.gist_public_id);
69+
}
70+
71+
fn assert_gists(lhs: &CreateGist, rhs: &Gist) {
72+
assert_eq!(lhs.description, rhs.description);
73+
assert_eq!(lhs.owner, rhs.owner);
74+
assert_eq!(lhs.public_id, rhs.public_id);
75+
assert_eq!(lhs.privacy, rhs.privacy);
76+
}
77+
78+
let _ = db.delete_account(username).await;
79+
let register_payload = UsernameRegisterPayload {
80+
username,
81+
password,
82+
secret,
83+
};
84+
85+
db.username_register(&register_payload).await.unwrap();
86+
87+
let create_gist = CreateGist {
88+
owner: username.into(),
89+
description: Some("foo".to_string()),
90+
public_id: public_id.to_string(),
91+
privacy: GistPrivacy::Public,
92+
};
93+
94+
assert!(!db.gist_exists(&create_gist.public_id).await.unwrap());
95+
// create gist
96+
assert!(db.get_user_gists(username).await.unwrap().is_empty());
97+
98+
db.new_gist(&create_gist).await.unwrap();
99+
assert!(matches!(
100+
db.new_gist(&create_gist).await.err(),
101+
Some(DBError::GistIDTaken)
102+
));
103+
104+
assert!(db.gist_exists(&create_gist.public_id).await.unwrap());
105+
// get gist
106+
let db_gist = db.get_gist(&create_gist.public_id).await.unwrap();
107+
assert_gists(&create_gist, &db_gist);
108+
109+
let mut gists = db.get_user_gists(username).await.unwrap();
110+
assert_eq!(gists.len(), 1);
111+
let gist = gists.pop().unwrap();
112+
assert_gists(&create_gist, &gist);
113+
114+
// comment on gist
115+
let create_comment = CreateGistComment {
116+
owner: username.into(),
117+
gist_public_id: create_gist.public_id.clone(),
118+
comment: "foo".into(),
119+
};
120+
db.new_comment(&create_comment).await.unwrap();
121+
// get all comments on gist
122+
let mut comments = db
123+
.get_comments_on_gist(&create_gist.public_id)
124+
.await
125+
.unwrap();
126+
assert!(comments.len() == 1);
127+
let comment = comments.pop().unwrap();
128+
assert_comments(&create_comment, &comment);
129+
130+
// get all comments by ID
131+
let comment = db.get_comment_by_id(comment.id).await.unwrap();
132+
assert_comments(&create_comment, &comment);
133+
134+
// delete comment
135+
db.delete_comment(username, comment.id).await.unwrap();
136+
137+
assert!(matches!(
138+
db.get_comment_by_id(comment.id).await.err().unwrap(),
139+
DBError::CommentNotFound
140+
));
141+
142+
// delete gist
143+
db.delete_gist(username, &create_gist.public_id)
144+
.await
145+
.unwrap();
146+
assert!(matches!(
147+
db.get_gist(&create_gist.public_id).await.err().unwrap(),
148+
DBError::GistNotFound
149+
));
150+
assert!(db
151+
.get_comments_on_gist(&create_gist.public_id)
152+
.await
153+
.unwrap()
154+
.is_empty());
155+
}
156+
42157
/// test username registration implementation
43158
pub async fn username_register_works<T: GistDatabase>(
44159
db: &T,

database/db-sqlx-postgres/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ include = ["./mgrations/"]
1414

1515
[dependencies]
1616
db-core = {path = "../db-core"}
17-
sqlx = { version = "0.5.10", features = [ "postgres", "time", "offline" ] }
17+
sqlx = { version = "0.5.10", features = [ "postgres", "time", "offline", "runtime-actix-rustls"] }
1818
async-trait = "0.1.51"
1919

2020
[dev-dependencies]

0 commit comments

Comments
 (0)