Better database layout with uuid primary keys

Serialize and deserialize the uuid ids as base62 strings in the URLs.
This commit is contained in:
Tyler Hallada 2023-06-27 14:03:52 -04:00
parent 4e41bbd6e1
commit abd540d2ff
17 changed files with 290 additions and 121 deletions

3
Cargo.lock generated
View File

@ -385,6 +385,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"url",
"uuid",
"validator",
]
@ -2286,6 +2287,7 @@ dependencies = [
"thiserror",
"tokio-stream",
"url",
"uuid",
"whoami",
]
@ -2771,6 +2773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dad5567ad0cf5b760e5665964bec1b47dfd077ba8a2544b513f3556d3d239a2"
dependencies = [
"getrandom",
"serde",
]
[[package]]

View File

@ -32,6 +32,7 @@ sqlx = { version = "0.6", features = [
"macros",
"migrate",
"chrono",
"uuid",
] }
thiserror = "1"
tokio = { version = "1", features = ["full"] }
@ -39,8 +40,9 @@ tokio-stream = { version = "0.1", features = ["sync"] }
tower = "0.4"
tower-livereload = "0.8"
tower-http = { version = "0.4", features = ["trace", "fs"] }
tracing = "0.1"
tracing = { version = "0.1", features = ["valuable"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.3", features = ["serde"] }
url = "2.4"
validator = { version = "0.16", features = ["derive"] }

View File

@ -1,8 +0,0 @@
/* !!! THIS DROPS ALL TABLES IN THE DATABASE WHICH DELETES ALL DATA IN THE DATABASE !!!
*
* ONLY RUN IN DEVELOPMENT!
*/
DROP TABLE _sqlx_migrations CASCADE;
DROP TABLE entries CASCADE;
DROP TABLE feeds CASCADE;
DROP TYPE feed_type;

9
drop_all.sql Normal file
View File

@ -0,0 +1,9 @@
/* !!! THIS DROPS ALL TABLES IN THE DATABASE WHICH DELETES ALL DATA IN THE DATABASE !!!
*
* ONLY RUN IN DEVELOPMENT!
*/
drop table _sqlx_migrations cascade;
drop collation case_insensitive;
drop table entry cascade;
drop table feed cascade;
drop type feed_type;

View File

@ -1,29 +1,64 @@
CREATE TYPE feed_type AS ENUM ('atom', 'rss');
-- This extension gives us `uuid_generate_v1mc()` which generates UUIDs that cluster better than `gen_random_uuid()`
-- while still being difficult to predict and enumerate.
-- Also, while unlikely, `gen_random_uuid()` can in theory produce collisions which can trigger spurious errors on
-- insertion, whereas it's much less likely with `uuid_generate_v1mc()`.
create extension if not exists "uuid-ossp";
CREATE TABLE IF NOT EXISTS "feeds" (
"id" SERIAL PRIMARY KEY NOT NULL,
"title" VARCHAR(255),
"url" VARCHAR(2048) NOT NULL,
"type" feed_type NOT NULL,
"description" TEXT,
"created_at" timestamp(3) NOT NULL,
"updated_at" timestamp(3) NOT NULL,
"deleted_at" timestamp(3)
);
CREATE INDEX "feeds_deleted_at" ON "feeds" ("deleted_at");
CREATE UNIQUE INDEX "feeds_url" ON "feeds" ("url");
-- Set up trigger to auto-set `updated_at` columns when rows are modified
create or replace function set_updated_at()
returns trigger as
$$
begin
NEW.updated_at = now();
return NEW;
end;
$$ language plpgsql;
CREATE TABLE IF NOT EXISTS "entries" (
"id" SERIAL PRIMARY KEY NOT NULL,
"title" VARCHAR(255),
"url" VARCHAR(2048) NOT NULL,
"description" TEXT,
"html_content" TEXT,
"feed_id" INTEGER REFERENCES "feeds"(id) NOT NULL,
"published_at" timestamp(3) NOT NULL,
"created_at" timestamp(3) NOT NULL,
"updated_at" timestamp(3) NOT NULL,
"deleted_at" timestamp(3)
create or replace function trigger_updated_at(tablename regclass)
returns void as
$$
begin
execute format('CREATE TRIGGER set_updated_at
BEFORE UPDATE
ON %s
FOR EACH ROW
WHEN (OLD is distinct from NEW)
EXECUTE FUNCTION set_updated_at();', tablename);
end;
$$ language plpgsql;
-- This is a text collation that sorts text case-insensitively, useful for `UNIQUE` indexes
-- over things like usernames and emails, ithout needing to remember to do case-conversion.
create collation case_insensitive (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
create type feed_type as enum ('atom', 'rss');
create table if not exists "feed" (
feed_id uuid primary key default uuid_generate_v1mc(),
title text,
url varchar(2048) not null,
type feed_type not null,
description text,
created_at timestamptz not null default now(),
updated_at timestamptz,
deleted_at timestamptz
);
CREATE INDEX "entries_published_at_where_deleted_at_is_null" ON "entries" ("published_at" DESC) WHERE "deleted_at" IS NULL;
CREATE UNIQUE INDEX "entries_url_and_feed_id" ON "entries" ("url", "feed_id");
create index on "feed" (deleted_at);
create unique index on "feed" (url);
select trigger_updated_at('"feed"');
create table if not exists "entry" (
entry_id uuid primary key default uuid_generate_v1mc(),
title text,
url varchar(2048) not null,
description text,
html_content text,
feed_id uuid not null references "feed" (feed_id) on delete cascade,
published_at timestamptz not null,
created_at timestamptz not null default now(),
updated_at timestamptz,
deleted_at timestamptz
);
create index on "entry" (published_at desc) where deleted_at is null;
create unique index on "entry" (url, feed_id);
select trigger_updated_at('"entry"');

View File

@ -5,10 +5,12 @@ use dotenvy::dotenv;
use sqlx::postgres::PgPoolOptions;
use std::env;
use tracing::info;
use uuid::Uuid;
use lib::jobs::crawl::crawl;
use lib::models::feed::{create_feed, delete_feed, CreateFeed, FeedType};
use lib::models::entry::{create_entry, delete_entry, CreateEntry};
use lib::uuid::Base62Uuid;
#[derive(FromArgs)]
/// CLI for crawlnicle
@ -51,7 +53,7 @@ struct AddFeed {
struct DeleteFeed {
#[argh(positional)]
/// id of the feed to delete
id: i32,
id: Uuid,
}
#[derive(FromArgs)]
@ -69,7 +71,7 @@ struct AddEntry {
description: Option<String>,
#[argh(option)]
/// source feed for the entry
feed_id: i32,
feed_id: Uuid,
}
#[derive(FromArgs)]
@ -78,7 +80,7 @@ struct AddEntry {
struct DeleteEntry {
#[argh(positional)]
/// id of the entry to delete
id: i32,
id: Uuid,
}
#[derive(FromArgs)]
@ -111,11 +113,11 @@ pub async fn main() -> Result<()> {
},
)
.await?;
info!("Created feed with id {}", feed.id);
info!("Created feed with id {}", Base62Uuid::from(feed.feed_id));
}
Commands::DeleteFeed(args) => {
delete_feed(&pool, args.id).await?;
info!("Deleted feed with id {}", args.id);
info!("Deleted feed with id {}", Base62Uuid::from(args.id));
}
Commands::AddEntry(args) => {
let entry = create_entry(
@ -126,15 +128,15 @@ pub async fn main() -> Result<()> {
description: args.description,
html_content: None,
feed_id: args.feed_id,
published_at: Utc::now().naive_utc(),
published_at: Utc::now(),
},
)
.await?;
info!("Created entry with id {}", entry.id);
info!("Created entry with id {}", Base62Uuid::from(entry.entry_id));
}
Commands::DeleteEntry(args) => {
delete_entry(&pool, args.id).await?;
info!("Deleted entry with id {}", args.id);
info!("Deleted entry with id {}", Base62Uuid::from(args.id));
}
Commands::Crawl(_) => {
info!("Crawling...");

View File

@ -3,6 +3,7 @@ use axum::response::{IntoResponse, Response};
use axum::Json;
use tracing::error;
use serde_with::DisplayFromStr;
use uuid::Uuid;
use validator::ValidationErrors;
/// An API-friendly error type.
@ -23,7 +24,7 @@ pub enum Error {
InvalidEntity(#[from] ValidationErrors),
#[error("{0}: {1} not found")]
NotFound(&'static str, i32),
NotFound(&'static str, Uuid),
#[error("referenced {0} not found")]
RelationNotFound(&'static str),

View File

@ -6,9 +6,13 @@ use sqlx::PgPool;
use crate::error::Error;
use crate::models::entry::{create_entry, get_entry, CreateEntry, Entry};
use crate::uuid::Base62Uuid;
pub async fn get(State(pool): State<PgPool>, Path(id): Path<i32>) -> Result<Json<Entry>, Error> {
Ok(Json(get_entry(&pool, id).await?))
pub async fn get(
State(pool): State<PgPool>,
Path(id): Path<Base62Uuid>,
) -> Result<Json<Entry>, Error> {
Ok(Json(get_entry(&pool, id.as_uuid()).await?))
}
pub async fn post(

View File

@ -5,10 +5,11 @@ use axum::{
use sqlx::PgPool;
use crate::error::{Error, Result};
use crate::models::feed::{create_feed, get_feed, delete_feed, CreateFeed, Feed};
use crate::models::feed::{create_feed, delete_feed, get_feed, CreateFeed, Feed};
use crate::uuid::Base62Uuid;
pub async fn get(State(pool): State<PgPool>, Path(id): Path<i32>) -> Result<Json<Feed>> {
Ok(Json(get_feed(&pool, id).await?))
pub async fn get(State(pool): State<PgPool>, Path(id): Path<Base62Uuid>) -> Result<Json<Feed>> {
Ok(Json(get_feed(&pool, id.as_uuid()).await?))
}
pub async fn post(
@ -18,6 +19,6 @@ pub async fn post(
Ok(Json(create_feed(&pool, payload).await?))
}
pub async fn delete(State(pool): State<PgPool>, Path(id): Path<i32>) -> Result<()> {
delete_feed(&pool, id).await
pub async fn delete(State(pool): State<PgPool>, Path(id): Path<Base62Uuid>) -> Result<()> {
delete_feed(&pool, id.as_uuid()).await
}

View File

@ -1,4 +1,4 @@
use axum::extract::{State, Path};
use axum::extract::{Path, State};
use axum::response::Response;
use maud::{html, PreEscaped};
use sqlx::PgPool;
@ -6,9 +6,14 @@ use sqlx::PgPool;
use crate::error::Result;
use crate::models::entry::get_entry;
use crate::partials::layout::Layout;
use crate::uuid::Base62Uuid;
pub async fn get(Path(id): Path<i32>, State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
let entry = get_entry(&pool, id).await?;
pub async fn get(
Path(id): Path<Base62Uuid>,
State(pool): State<PgPool>,
layout: Layout,
) -> Result<Response> {
let entry = get_entry(&pool, id.as_uuid()).await?;
Ok(layout.render(html! {
@let title = entry.title.unwrap_or_else(|| "Untitled".to_string());
h1 { a href=(entry.url) { (title) } }

View File

@ -7,6 +7,7 @@ use crate::error::Result;
use crate::models::entry::{get_entries, GetEntriesOptions};
use crate::partials::layout::Layout;
use crate::utils::get_domain;
use crate::uuid::Base62Uuid;
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
let entries = get_entries(&pool, GetEntriesOptions::default()).await?;
@ -14,7 +15,7 @@ pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response>
ul class="entries" {
@for entry in entries {
@let title = entry.title.unwrap_or_else(|| "Untitled".to_string());
@let url = format!("/entry/{}", entry.id);
@let url = format!("/entry/{}", Base62Uuid::from(entry.entry_id));
@let domain = get_domain(&entry.url).unwrap_or_default();
li { a href=(url) { (title) } em class="domain" { (domain) }}
}

View File

@ -7,6 +7,7 @@ use tracing::{info, info_span, warn};
use crate::models::feed::get_feeds;
use crate::models::entry::{upsert_entries, CreateEntry};
use crate::uuid::Base62Uuid;
/// For every feed in the database, fetches the feed, parses it, and saves new entries to the
/// database.
@ -15,7 +16,8 @@ pub async fn crawl(pool: &PgPool) -> anyhow::Result<()> {
let client = Client::new();
let feeds = get_feeds(pool).await?;
for feed in feeds {
let feed_span = info_span!("feed", id = feed.id, url = feed.url.as_str());
let feed_id_str: String = Base62Uuid::from(feed.feed_id).into();
let feed_span = info_span!("feed", id = feed_id_str, url = feed.url.as_str());
let _feed_span_guard = feed_span.enter();
info!("Fetching feed");
// TODO: handle these results
@ -28,20 +30,20 @@ pub async fn crawl(pool: &PgPool) -> anyhow::Result<()> {
let _entry_span_guard = entry_span.enter();
if let Some(link) = entry.links.get(0) {
// if no scraped or feed date is available, fallback to the current time
let published_at = entry.published.unwrap_or_else(Utc::now).naive_utc();
let published_at = entry.published.unwrap_or_else(Utc::now);
let mut entry = CreateEntry {
title: entry.title.map(|t| t.content),
url: link.href.clone(),
description: entry.summary.map(|s| s.content),
html_content: None,
feed_id: feed.id,
feed_id: feed.feed_id,
published_at,
};
info!("Fetching and parsing entry link: {}", link.href);
if let Ok(article) = scraper.parse(&Url::parse(&link.href)?, true, &client, None).await {
if let Some(date) = article.date {
// prefer scraped date over rss feed date
entry.published_at = date.naive_utc()
entry.published_at = date;
};
entry.html_content = article.get_content();
} else {

View File

@ -7,3 +7,4 @@ pub mod models;
pub mod partials;
pub mod state;
pub mod utils;
pub mod uuid;

View File

@ -1,6 +1,7 @@
use chrono::NaiveDateTime;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use validator::{Validate, ValidationErrors};
use crate::error::{Error, Result};
@ -9,16 +10,16 @@ const DEFAULT_ENTRIES_PAGE_SIZE: i64 = 50;
#[derive(Debug, Serialize, Deserialize)]
pub struct Entry {
pub id: i32,
pub entry_id: Uuid,
pub title: Option<String>,
pub url: String,
pub description: Option<String>,
pub html_content: Option<String>,
pub feed_id: i32,
pub published_at: NaiveDateTime,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub feed_id: Uuid,
pub published_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize, Validate)]
@ -30,18 +31,17 @@ pub struct CreateEntry {
#[validate(length(max = 524288))]
pub description: Option<String>,
pub html_content: Option<String>,
#[validate(range(min = 1))]
pub feed_id: i32,
pub published_at: NaiveDateTime,
pub feed_id: Uuid,
pub published_at: DateTime<Utc>,
}
pub async fn get_entry(pool: &PgPool, id: i32) -> Result<Entry> {
sqlx::query_as!(Entry, "SELECT * FROM entries WHERE id = $1", id)
pub async fn get_entry(pool: &PgPool, entry_id: Uuid) -> Result<Entry> {
sqlx::query_as!(Entry, "select * from entry where entry_id = $1", entry_id)
.fetch_one(pool)
.await
.map_err(|error| {
if let sqlx::error::Error::RowNotFound = error {
return Error::NotFound("entry", id);
return Error::NotFound("entry", entry_id);
}
Error::Sqlx(error)
})
@ -49,7 +49,7 @@ pub async fn get_entry(pool: &PgPool, id: i32) -> Result<Entry> {
#[derive(Default)]
pub struct GetEntriesOptions {
pub published_before: Option<NaiveDateTime>,
pub published_before: Option<DateTime<Utc>>,
pub limit: Option<i64>,
}
@ -60,11 +60,11 @@ pub async fn get_entries(
if let Some(published_before) = options.published_before {
sqlx::query_as!(
Entry,
"SELECT * FROM entries
WHERE deleted_at IS NULL
AND published_at < $1
ORDER BY published_at DESC
LIMIT $2
"select * from entry
where deleted_at is null
and published_at < $1
order by published_at desc
limit $2
",
published_before,
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
@ -74,10 +74,10 @@ pub async fn get_entries(
} else {
sqlx::query_as!(
Entry,
"SELECT * FROM entries
WHERE deleted_at IS NULL
ORDER BY published_at DESC
LIMIT $1
"select * from entry
where deleted_at is null
order by published_at desc
limit $1
",
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
)
@ -91,11 +91,11 @@ pub async fn create_entry(pool: &PgPool, payload: CreateEntry) -> Result<Entry>
payload.validate()?;
sqlx::query_as!(
Entry,
"INSERT INTO entries (
title, url, description, html_content, feed_id, published_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, now(), now()
) RETURNING *",
"insert into entry (
title, url, description, html_content, feed_id, published_at
) values (
$1, $2, $3, $4, $5, $6
) returning *",
payload.title,
payload.url,
payload.description,
@ -136,10 +136,10 @@ pub async fn create_entries(pool: &PgPool, payload: Vec<CreateEntry>) -> Result<
.collect::<Result<Vec<()>, ValidationErrors>>()?;
sqlx::query_as!(
Entry,
"INSERT INTO entries (
title, url, description, html_content, feed_id, published_at, created_at, updated_at
) SELECT *, now(), now() FROM UNNEST($1::text[], $2::text[], $3::text[], $4::text[], $5::int[], $6::timestamp(3)[])
RETURNING *",
"insert into entry (
title, url, description, html_content, feed_id, published_at
) select * from unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::uuid[], $6::timestamptz[])
returning *",
titles.as_slice() as &[Option<String>],
urls.as_slice(),
descriptions.as_slice() as &[Option<String>],
@ -180,11 +180,11 @@ pub async fn upsert_entries(pool: &PgPool, payload: Vec<CreateEntry>) -> Result<
.collect::<Result<Vec<()>, ValidationErrors>>()?;
sqlx::query_as!(
Entry,
"INSERT INTO entries (
title, url, description, html_content, feed_id, published_at, created_at, updated_at
) SELECT *, now(), now() FROM UNNEST($1::text[], $2::text[], $3::text[], $4::text[], $5::int[], $6::timestamp(3)[])
ON CONFLICT DO NOTHING
RETURNING *",
"insert into entry (
title, url, description, html_content, feed_id, published_at
) select * from unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::uuid[], $6::timestamptz[])
on conflict do nothing
returning *",
titles.as_slice() as &[Option<String>],
urls.as_slice(),
descriptions.as_slice() as &[Option<String>],
@ -204,8 +204,8 @@ pub async fn upsert_entries(pool: &PgPool, payload: Vec<CreateEntry>) -> Result<
})
}
pub async fn delete_entry(pool: &PgPool, id: i32) -> Result<()> {
sqlx::query!("UPDATE entries SET deleted_at = now() WHERE id = $1", id)
pub async fn delete_entry(pool: &PgPool, entry_id: Uuid) -> Result<()> {
sqlx::query!("update entry set deleted_at = now() where entry_id = $1", entry_id)
.execute(pool)
.await?;
Ok(())

View File

@ -1,8 +1,9 @@
use std::str::FromStr;
use chrono::NaiveDateTime;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use validator::Validate;
use crate::error::{Error, Result};
@ -28,15 +29,15 @@ impl FromStr for FeedType {
#[derive(Debug, Serialize, Deserialize)]
pub struct Feed {
pub id: i32,
pub feed_id: Uuid,
pub title: Option<String>,
pub url: String,
#[serde(rename = "type")]
pub feed_type: FeedType,
pub description: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub created_at: DateTime<Utc>,
pub updated_at: Option<DateTime<Utc>>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize, Validate)]
@ -51,12 +52,13 @@ pub struct CreateFeed {
pub description: Option<String>,
}
pub async fn get_feed(pool: &PgPool, id: i32) -> Result<Feed> {
pub async fn get_feed(pool: &PgPool, feed_id: Uuid) -> Result<Feed> {
sqlx::query_as!(
Feed,
// Unable to SELECT * here due to https://github.com/launchbadge/sqlx/issues/1004
r#"SELECT
id,
// language=PostGreSQL
r#"select
feed_id,
title,
url,
type as "feed_type: FeedType",
@ -64,14 +66,14 @@ pub async fn get_feed(pool: &PgPool, id: i32) -> Result<Feed> {
created_at,
updated_at,
deleted_at
FROM feeds WHERE id = $1"#,
id
from feed where feed_id = $1"#,
feed_id
)
.fetch_one(pool)
.await
.map_err(|error| {
if let sqlx::error::Error::RowNotFound = error {
return Error::NotFound("feed", id);
return Error::NotFound("feed", feed_id);
}
Error::Sqlx(error)
})
@ -80,8 +82,8 @@ pub async fn get_feed(pool: &PgPool, id: i32) -> Result<Feed> {
pub async fn get_feeds(pool: &PgPool) -> sqlx::Result<Vec<Feed>> {
sqlx::query_as!(
Feed,
r#"SELECT
id,
r#"select
feed_id,
title,
url,
type as "feed_type: FeedType",
@ -89,8 +91,8 @@ pub async fn get_feeds(pool: &PgPool) -> sqlx::Result<Vec<Feed>> {
created_at,
updated_at,
deleted_at
FROM feeds
WHERE deleted_at IS NULL"#
from feed
where deleted_at is null"#
)
.fetch_all(pool)
.await
@ -100,12 +102,12 @@ pub async fn create_feed(pool: &PgPool, payload: CreateFeed) -> Result<Feed> {
payload.validate()?;
Ok(sqlx::query_as!(
Feed,
r#"INSERT INTO feeds (
title, url, type, description, created_at, updated_at
) VALUES (
$1, $2, $3, $4, now(), now()
) RETURNING
id,
r#"insert into feed (
title, url, type, description
) values (
$1, $2, $3, $4
) returning
feed_id,
title,
url,
type as "feed_type: FeedType",
@ -123,8 +125,8 @@ pub async fn create_feed(pool: &PgPool, payload: CreateFeed) -> Result<Feed> {
.await?)
}
pub async fn delete_feed(pool: &PgPool, id: i32) -> Result<()> {
sqlx::query!("UPDATE feeds SET deleted_at = now() WHERE id = $1", id)
pub async fn delete_feed(pool: &PgPool, feed_id: Uuid) -> Result<()> {
sqlx::query!("update feed set deleted_at = now() where feed_id = $1", feed_id)
.execute(pool)
.await?;
Ok(())

View File

@ -1,5 +1,7 @@
use url::Url;
const BASE62_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
pub fn get_domain(url: &str) -> Option<String> {
Url::parse(url)
.ok()

107
src/uuid.rs Normal file
View File

@ -0,0 +1,107 @@
use std::fmt::{Display, Formatter, self};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const BASE62_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
#[derive(Debug, Serialize, Deserialize)]
pub struct Base62Uuid(
#[serde(deserialize_with = "uuid_from_base62_str")]
#[serde(serialize_with = "uuid_to_base62_str")]
Uuid
);
impl Base62Uuid {
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl From<Uuid> for Base62Uuid {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
impl Display for Base62Uuid {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", base62_encode(self.0.as_u128()))
}
}
impl From<&str> for Base62Uuid {
fn from(s: &str) -> Self {
Self(Uuid::from_u128(base62_decode(s)))
}
}
impl From<Base62Uuid> for String {
fn from(s: Base62Uuid) -> Self {
base62_encode(s.0.as_u128())
}
}
fn uuid_to_base62_str<S>(uuid: &Uuid, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
s.serialize_str(&base62_encode(uuid.as_u128()))
}
fn uuid_from_base62_str<'de, D>(deserializer: D) -> Result<Uuid, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(Uuid::from_u128(base62_decode(&s)))
}
pub fn base62_encode(mut number: u128) -> String {
let base = BASE62_CHARS.len() as u128;
let mut encoded = Vec::new();
while number > 0 {
let remainder = (number % base) as usize;
number /= base;
encoded.push(BASE62_CHARS[remainder]);
}
encoded.reverse();
String::from_utf8(encoded).unwrap()
}
pub fn base62_decode(input: &str) -> u128 {
let base = BASE62_CHARS.len() as u128;
let mut number = 0u128;
for &byte in input.as_bytes() {
number = number * base + (BASE62_CHARS.iter().position(|&ch| ch == byte).unwrap() as u128);
}
number
}
#[cfg(test)]
mod tests {
use uuid::Uuid;
use super::*;
#[test]
fn test_encode_decode() {
let original_uuids = [
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
Uuid::new_v4(),
];
for original_uuid in original_uuids.iter() {
let encoded = base62_encode(original_uuid.as_u128());
let decoded_uuid = Uuid::from_u128(base62_decode(&encoded));
assert_eq!(*original_uuid, decoded_uuid);
}
}
}