Skip to content

Commit 9fa9715

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 9fa9715

22 files changed

+1638
-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

+20-3
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,33 @@ 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),
30+
2931
/// errors that are specific to a database implementation
3032
#[error("{0}")]
3133
DBError(#[source] BoxDynError),
34+
35+
/// email is already taken
36+
#[error("Unknown privacy specifier {}", _0)]
37+
UnknownPrivacySpecifier(String),
38+
39+
40+
/// Gist with specified characteristics not found
41+
#[error("Gist with specified characteristics not found")]
42+
GistNotFound,
43+
44+
/// Comment with specified characteristics not found
45+
#[error("Comment with specified characteristics not found")]
46+
CommentNotFound,
47+
48+
3249
}
3350

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

database/db-core/src/lib.rs

+168-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,47 @@ 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+
324+
async fn delete_gist(&self, owner: &str, public_id: &str) -> DBResult<()> {
325+
(**self).delete_gist(owner, public_id).await
326+
}
327+
328+
async fn new_comment(&self, comment: &CreateGistComment) -> DBResult<()> {
329+
(**self).new_comment(comment).await
330+
}
331+
332+
async fn get_comments_on_gist(&self, public_id: &str) -> DBResult<Vec<GistComment>> {
333+
(**self).get_comments_on_gist(public_id).await
334+
}
335+
336+
async fn get_comment_by_id(&self, id: i64) -> DBResult<GistComment> {
337+
(**self).get_comment_by_id(id).await
338+
}
339+
340+
async fn delete_comment(&self, owner: &str, id: i64) -> DBResult<()> {
341+
(**self).delete_comment(owner, id).await
342+
}
343+
344+
async fn privacy_exists(&self, privacy: &GistPrivacy) -> DBResult<bool> {
345+
(**self).privacy_exists(privacy).await
346+
}
180347
}
181348

182349
/// Trait to clone GistDatabase

database/db-core/src/tests.rs

+101
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,107 @@ 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+
].iter() {
49+
println!("Testing privacy: {}", p.to_str());
50+
assert!(db.privacy_exists(p).await.unwrap());
51+
}
52+
}
53+
54+
/// test all gist methods
55+
pub async fn gists_work<T: GistDatabase>(db: &T, username: &str, password: &str, secret: &str, public_id: &str) {
56+
fn assert_comments(lhs: &CreateGistComment, rhs: &GistComment) {
57+
println!("lhs: {:?} rhs: {:?}", lhs, rhs);
58+
assert_eq!(rhs.owner, lhs.owner);
59+
assert_eq!(rhs.comment, lhs.comment);
60+
assert_eq!(rhs.gist_public_id, lhs.gist_public_id);
61+
}
62+
63+
fn assert_gists(lhs: &CreateGist, rhs: &Gist) {
64+
assert_eq!(lhs.description, rhs.description);
65+
assert_eq!(lhs.owner, rhs.owner);
66+
assert_eq!(lhs.public_id, rhs.public_id);
67+
assert_eq!(lhs.privacy, rhs.privacy);
68+
}
69+
70+
71+
let _ = db.delete_account(username).await;
72+
let register_payload = UsernameRegisterPayload {
73+
username,
74+
password,
75+
secret,
76+
};
77+
78+
db.username_register(&register_payload).await.unwrap();
79+
80+
let create_gist = CreateGist {
81+
owner: username.into(),
82+
description: Some("foo".to_string()),
83+
public_id: public_id.to_string(),
84+
privacy: GistPrivacy::Public,
85+
};
86+
87+
assert!(!db.gist_exists(&create_gist.public_id).await.unwrap());
88+
// create gist
89+
assert!(
90+
db.get_user_gists(username).await.unwrap().is_empty()
91+
);
92+
93+
db.new_gist(&create_gist).await.unwrap();
94+
assert!(matches!(db.new_gist(&create_gist).await.err(), Some(DBError::GistIDTaken)));
95+
96+
assert!(db.gist_exists(&create_gist.public_id).await.unwrap());
97+
// get gist
98+
let db_gist = db.get_gist(&create_gist.public_id).await.unwrap();
99+
assert_gists(&create_gist, &db_gist);
100+
101+
let mut gists = db.get_user_gists(username).await.unwrap();
102+
assert_eq!(gists.len(), 1);
103+
let gist = gists.pop().unwrap();
104+
assert_gists(&create_gist, &gist);
105+
106+
107+
108+
// comment on gist
109+
let create_comment = CreateGistComment {
110+
owner: username.into(),
111+
gist_public_id: create_gist.public_id.clone(),
112+
comment: "foo".into(),
113+
};
114+
db.new_comment(&create_comment).await.unwrap();
115+
// get all comments on gist
116+
let mut comments = db.get_comments_on_gist(&create_gist.public_id).await.unwrap();
117+
assert!(comments.len() == 1);
118+
let comment = comments.pop().unwrap();
119+
assert_comments(&create_comment, &comment);
120+
121+
// get all comments by ID
122+
let comment = db.get_comment_by_id(comment.id).await.unwrap();
123+
assert_comments(&create_comment, &comment);
124+
125+
// delete comment
126+
db.delete_comment(username, comment.id).await.unwrap();
127+
128+
129+
assert!(matches!(
130+
db.get_comment_by_id(comment.id).await.err().unwrap(), DBError::CommentNotFound)
131+
);
132+
133+
// delete gist
134+
db.delete_gist(username, &create_gist.public_id).await.unwrap();
135+
assert!(matches!(
136+
db.get_gist(&create_gist.public_id).await.err().unwrap(), DBError::GistNotFound)
137+
);
138+
assert!(
139+
db.get_comments_on_gist(&create_gist.public_id).await.unwrap().is_empty()
140+
);
141+
}
142+
42143
/// test username registration implementation
43144
pub async fn username_register_works<T: GistDatabase>(
44145
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]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
CREATE TABLE IF NOT EXISTS gists_privacy (
2+
name VARCHAR(15) NOT NULL UNIQUE,
3+
ID SERIAL PRIMARY KEY NOT NULL
4+
);
5+
6+
INSERT INTO gists_privacy (name) VALUES('private') ON CONFLICT (name) DO NOTHING;
7+
INSERT INTO gists_privacy (name) VALUES('unlisted') ON CONFLICT (name) DO NOTHING;
8+
INSERT INTO gists_privacy (name) VALUES('public') ON CONFLICT (name) DO NOTHING;
9+
10+
CREATE TABLE IF NOT EXISTS gists_gists (
11+
owner_id INTEGER NOT NULL references gists_users(ID) ON DELETE CASCADE,
12+
privacy INTEGER NOT NULL references gists_privacy(ID),
13+
description TEXT DEFAULT NULL,
14+
created timestamptz NOT NULL,
15+
updated timestamptz NOT NULL,
16+
public_id VARCHAR(32) UNIQUE NOT NULL,
17+
ID SERIAL PRIMARY KEY NOT NULL
18+
);
19+
20+
CREATE INDEX ON gists_gists(public_id);
21+
22+
CREATE TABLE IF NOT EXISTS gists_comments (
23+
owner_id INTEGER NOT NULL references gists_users(ID) ON DELETE CASCADE,
24+
gist_id INTEGER NOT NULL references gists_gists(ID) ON DELETE CASCADE,
25+
comment TEXT DEFAULT NULL,
26+
created timestamptz NOT NULL DEFAULT now(),
27+
ID SERIAL PRIMARY KEY NOT NULL
28+
);

0 commit comments

Comments
 (0)