Skip to content

Commit

Permalink
feat(groups): added group model and controller
Browse files Browse the repository at this point in the history
  • Loading branch information
soulsam480 committed Nov 3, 2024
1 parent a02ea1c commit 7d27aa1
Show file tree
Hide file tree
Showing 13 changed files with 396 additions and 22 deletions.
8 changes: 4 additions & 4 deletions gleam.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ licences = ["MIT"]
repository = { type = "github", user = "soulsam480", repo = "okane" }
# links = [{ title = "Website", href = "https://gleam.run" }]

# feather config
migrations_dir = "./priv/migrations"
schemafile = "./src/app/db/schema.sql"

[tailwind]
args = [
"--minify",
Expand All @@ -15,10 +19,6 @@ args = [
]
path = "./node_modules/.bin/tailwind"

migrations_dir = "./src/app/db/migrations"
schemafile = "./src/app/db/schema.sql"


[dependencies]
gleam_stdlib = "~> 0.40.0"
wisp = "~> 1.2.0"
Expand Down
12 changes: 10 additions & 2 deletions priv/migrations/1729363922_add_users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ CREATE TABLE users (
password TEXT NOT NULL
);

CREATE INDEX index_users_on_email ON users(email);

-- groups
CREATE TABLE groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
deleted_at TEXT
deleted_at TEXT,
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE SET NULL
);

CREATE INDEX index_groups_on_owner_id ON groups(owner_id);

-- user_group_memberships
CREATE TABLE user_group_memberships (
CREATE TABLE group_memberships (
user_id INTEGER,
group_id INTEGER,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE
);

CREATE INDEX index_group_memberships_on_user_id ON group_memberships(user_id);
CREATE INDEX index_group_memberships_on_group_id ON group_memberships(group_id);
18 changes: 13 additions & 5 deletions src/app/config.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,26 @@ pub type Context {
)
}

/// creates base context with the db connection
pub fn acquire_context(db: sqlight.Connection, run: fn(Context) -> a) -> a {
run(Context(db, None, []))
}

pub fn set_user(ctx: Context, user: user.User) {
let Context(db, _, scope) = ctx

Context(db, Some(user), scope)
Context(..ctx, user: Some(user))
}

/// scope this router context to following path
/// basically this eliminates the need to pattern match the prefix
/// e.g.
/// for a path /auth/users/home -> if auth is already matched -> context can be narrowed to ["users","home"]
pub fn scope_to(ctx: Context, scoped_segments: List(String)) {
let Context(db, user, ..) = ctx
Context(..ctx, scoped_segments:)
}

Context(db, user, scoped_segments)
/// get user from context or crash
/// ! Only intended to be used in places where auth is taken care prior in the chain
pub fn get_user_or_crash(ctx: Context) {
let assert Some(u) = ctx.user
u
}
90 changes: 90 additions & 0 deletions src/app/controllers/groups.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import app/config
import app/db/models/group
import app/serializers/base_serializer
import app/serializers/group_serializer
import gleam/http
import gleam/int
import gleam/list
import gleam/result
import wisp

fn fetch_create_group_params(
form_data: wisp.FormData,
ctx: config.Context,
next: fn(group.InsertableGroup) -> wisp.Response,
) -> wisp.Response {
let group = {
use name <- result.try(list.key_find(form_data.values, "name"))

Ok(group.InsertableGroup(name:, owner_id: config.get_user_or_crash(ctx).id))
}

case group {
Ok(params) -> next(params)
Error(_) -> wisp.bad_request()
}
}

fn handle_create_group(req: wisp.Request, ctx: config.Context) -> wisp.Response {
use form <- wisp.require_form(req)
use create_group_params <- fetch_create_group_params(form, ctx)

case group.insert_group(create_group_params, ctx.db) {
Ok(group) -> wisp.ok() |> wisp.json_body(group_serializer.run(group))
Error(_) ->
wisp.internal_server_error()
|> wisp.json_body(base_serializer.serialize_error(
"Unable to create group!",
))
}
}

fn handle_show_all_groups(ctx: config.Context) -> wisp.Response {
case group.find_groups_of(config.get_user_or_crash(ctx).id, ctx.db) {
Ok(groups) ->
wisp.ok() |> wisp.json_body(group_serializer.run_array(groups))

Error(_) -> wisp.bad_request()
}
}

fn fetch_group_id(
group_id: String,
handle: fn(Int) -> wisp.Response,
) -> wisp.Response {
case int.parse(group_id) {
Ok(id) -> handle(id)
Error(_) ->
wisp.bad_request()
|> wisp.json_body(base_serializer.serialize_error(
"Invalid group id" <> group_id,
))
}
}

fn handle_show_group(group_id: String, ctx: config.Context) -> wisp.Response {
use id <- fetch_group_id(group_id)

case group.find_by_id(id, ctx.db) {
Ok(group) -> wisp.ok() |> wisp.json_body(group_serializer.run(group))
Error(_) -> wisp.not_found()
}
}

pub fn controller(req: wisp.Request, ctx: config.Context) -> wisp.Response {
case ctx.scoped_segments, req.method {
[], http.Post -> handle_create_group(req, ctx)

[], http.Get -> handle_show_all_groups(ctx)

[group_id], http.Get -> handle_show_group(group_id, ctx)

[group_id], http.Put -> {
wisp.not_found()
}

[group_id], http.Delete -> wisp.method_not_allowed([http.Delete])

_, __ -> wisp.not_found()
}
}
95 changes: 95 additions & 0 deletions src/app/db/migrations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
## Migrations cheat sheet
```sql
-- Original migration tracking
CREATE TABLE migrations (
id INTEGER PRIMARY KEY,
name TEXT,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Basic table operations
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT
);

ALTER TABLE users ADD COLUMN email TEXT;
ALTER TABLE users RENAME TO old_users;
DROP TABLE users;

-- Indexes
CREATE INDEX idx_user_email ON users(email);
DROP INDEX idx_user_email;

-- Foreign Keys with ON DELETE/UPDATE actions
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
ON DELETE CASCADE -- Delete posts when user deleted
ON UPDATE CASCADE -- Update if user_id changes
);

CREATE TABLE comments (
id INTEGER PRIMARY KEY,
post_id INTEGER,
FOREIGN KEY(post_id) REFERENCES posts(id)
ON DELETE SET NULL -- Set post_id to NULL when post deleted
);

CREATE TABLE logs (
id INTEGER PRIMARY KEY,
user_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
ON DELETE RESTRICT -- Prevent user deletion if logs exist
);

-- Triggers
CREATE TRIGGER update_user_timestamp
AFTER UPDATE ON users
BEGIN
UPDATE users SET updated_at = DATETIME('now')
WHERE id = NEW.id;
END;

-- Views
CREATE VIEW active_users AS
SELECT * FROM users
WHERE last_login > date('now', '-30 days');

-- Rolling back
BEGIN TRANSACTION;
-- your changes
ROLLBACK; -- or COMMIT;

-- Common column constraints
NOT NULL
UNIQUE
DEFAULT value
CHECK (condition)
PRIMARY KEY
AUTOINCREMENT

-- Data types
INTEGER
TEXT
REAL
BLOB
NULL
DATETIME
BOOLEAN

-- Useful date functions
DATE('now')
DATETIME('now')
DATE('now', '+1 day')
DATE('now', '-1 month')
STRFTIME('%Y-%m-%d', 'now')

-- Version tracking example
INSERT INTO migrations (name) VALUES ('create_users_20240101');

-- Common Queries with constraints
PRAGMA foreign_keys = ON; -- Enable foreign key support
PRAGMA journal_mode = WAL; -- Write-Ahead Logging mode
```
10 changes: 10 additions & 0 deletions src/app/db/models/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## How to add a model
to use a DB resource in Okane, we need to add following utils to make it work

- record itself
- decoder
- select query fields <- should be in the same order as the decoder fields
- insert query record
- insert query record encoder <- converts to cake params

that's it :pray:
106 changes: 106 additions & 0 deletions src/app/db/models/group.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import app/db/connection
import app/db/models/helpers
import app/db/models/user
import app/lib/generic_error
import cake
import cake/insert
import cake/select
import cake/where
import decode/zero
import gleam/dynamic
import gleam/list
import gleam/option.{type Option}
import gleam/result
import sqlight

// 1. record
pub type Group {
Group(
id: Int,
name: String,
owner_id: Int,
created_at: String,
deleted_at: Option(String),
)
}

// 2. decoder

fn decode_group(row: dynamic.Dynamic) {
let decoder = {
use id <- zero.field(0, zero.int)
use name <- zero.field(1, zero.string)
use owner_id <- zero.field(2, zero.int)
use created_at <- zero.field(2, zero.string)
use deleted_at <- zero.field(3, zero.optional(zero.string))

zero.success(Group(id:, name:, owner_id:, created_at:, deleted_at:))
}

zero.run(row, decoder)
}

// 3. select props init
fn select_group_query_init() {
select.new()
|> select.from_table("groups")
|> select.selects([
select.col("groups.id"),
select.col("griups.name"),
select.col("groups.owner_id"),
select.col("groups.created_at"),
select.col("groups.deleted_at"),
])
}

// 4. insertable record
pub type InsertableGroup {
InsertableGroup(name: String, owner_id: Int)
}

// 5. insertable encoder
fn insertable_group_encoder(group: InsertableGroup) {
[group.name |> insert.string, group.owner_id |> insert.int]
|> helpers.with_created_at_param
|> insert.row
}

pub fn insert_group(
insertable group: InsertableGroup,
connection conn: sqlight.Connection,
) {
insert.from_records(
records: [group],
table_name: "groups",
columns: ["name", "owner_id"] |> helpers.with_created_at_column,
encoder: insertable_group_encoder,
)
|> insert.returning(["id", "name", "owner_id", "created_at", "deleted_at"])
|> insert.to_query
|> cake.to_write_query
|> connection.run_query_with(conn, decode_group)
|> result.map_error(fn(e) { generic_error.new(e) })
|> helpers.first
}

pub fn find_by_id(id: Int, conn: sqlight.Connection) {
select_group_query_init()
|> select.where(where.col("groups.id") |> where.eq(where.int(id)))
|> select.comment("find group by id")
|> select.to_query
|> cake.to_read_query
|> connection.run_query_with(conn, decode_group)
|> helpers.first
}

pub fn find_groups_of(user_id: Int, conn: sqlight.Connection) {
select_group_query_init()
|> select.where(
// TODO: query where memberships has user_id = user_id
where.or([where.col("groups.owner_id") |> where.eq(where.int(user_id))]),
)
|> select.to_query
|> cake.to_read_query
|> connection.run_query_with(conn, decode_group)
|> generic_error.replace
}
Loading

0 comments on commit 7d27aa1

Please sign in to comment.