diff --git a/examples/blog/app/models/claim.rb b/examples/blog/app/models/claim.rb new file mode 100644 index 0000000..cf6dddc --- /dev/null +++ b/examples/blog/app/models/claim.rb @@ -0,0 +1,22 @@ +class Claim < Prism::Orm::Model + # counter_cache keeps Post#claims_count (and therefore + # Post#giveaway_remaining/#giveaway_sold_out?) O(1) to read - the whole + # point of ClaimsController#create locking the post first is to make that + # read-then-decide safe under concurrency, which would be undermined if + # the read itself required walking every Claim row instead. + belongs_to :post, counter_cache: true + belongs_to :user + + # Not a user-facing "unclaim" - a claim has no destroy action anywhere in + # this app (see ClaimsController, create-only). Paranoid purely so + # Post#acts_as_paranoid's `dependent: :destroy` cascade (see the model) + # soft-deletes a giveaway's claims along with a trashed post instead of + # erasing the record of who already won a slot, and #restore(recursive: + # true) brings them back - same shape as Comment/Like. + acts_as_paranoid + + # One claim per user per post - see the unique index on [:post_id, + # :user_id] in the create_claims migration for the same rule enforced at + # the Store level too, inside whatever transaction the insert runs in. + validates :user_id, presence: true, uniqueness: { scope: :post_id } +end diff --git a/examples/blog/app/models/comment.rb b/examples/blog/app/models/comment.rb new file mode 100644 index 0000000..b057dec --- /dev/null +++ b/examples/blog/app/models/comment.rb @@ -0,0 +1,34 @@ +class Comment < Prism::Orm::Model + # touch: true keeps Post#updated_at reflecting the most recent comment + # activity (created, edited, deleted), not just edits to the post itself - + # shown as "Last activity" on the post list/show pages. + belongs_to :post, counter_cache: true, touch: true + belongs_to :user + has_many :likes, as: :likeable, dependent: :destroy + + # Paranoid so PostsController#restore(recursive: true) can bring a + # trashed post's comments back along with it - see Post#acts_as_paranoid. + acts_as_paranoid + + # Every comment starts life pending (column default 0, see the + # add_status_to_comments migration) so a post's author gets a chance to + # approve or reject it before strangers can read it - see + # CommentsController#approve/#reject and the `visible_to` scope below. + enum :status, { pending: 0, approved: 1, rejected: 2 } + + # Who gets to see a comment depends on who's asking, not just its status: + # everyone sees approved comments, the post's author (the moderator) sees + # everything on their own post regardless of status, and a signed-in + # commenter always sees their own comment even while it's still pending. + # `self` inside the block is whatever relation this scope was chained + # onto (e.g. already narrowed to one post's comments by `post.comments`), + # so `approved`/`where` compose with that instead of re-querying globally. + scope :visible_to, lambda { |user, post_author_id| + next self if user && user.id == post_author_id + next approved if user.nil? + approved.or(where(user_id: user.id)) + } + + validates :author, presence: true, length: { minimum: 2 } + validates :body, presence: true +end diff --git a/examples/blog/app/models/like.rb b/examples/blog/app/models/like.rb new file mode 100644 index 0000000..ae48954 --- /dev/null +++ b/examples/blog/app/models/like.rb @@ -0,0 +1,10 @@ +class Like < Prism::Orm::Model + belongs_to :user + belongs_to :likeable, polymorphic: true, counter_cache: true + + # Same reasoning as Comment#acts_as_paranoid - a trashed post's likes (and + # a trashed post's comments' own likes) come back with it on restore. + acts_as_paranoid + + validates :user_id, presence: true, uniqueness: { scope: %i[likeable_id likeable_type] } +end diff --git a/examples/blog/app/models/login_session.rb b/examples/blog/app/models/login_session.rb new file mode 100644 index 0000000..fe3bb69 --- /dev/null +++ b/examples/blog/app/models/login_session.rb @@ -0,0 +1,9 @@ +class LoginSession < Prism::Orm::Model + belongs_to :user + + # The persistent-login cookie's actual credential - never rendered + # anywhere (see LoginSessionsController#index), and hidden from + # serialization so a stray as_json never leaks it either. + has_secure_token :token + hide_from_json :token +end diff --git a/examples/blog/app/models/post.rb b/examples/blog/app/models/post.rb index 8ccece0..c1f15e8 100644 --- a/examples/blog/app/models/post.rb +++ b/examples/blog/app/models/post.rb @@ -1,3 +1,84 @@ +require 'csv' + class Post < Prism::Orm::Model + # Result of .import_csv below - `imported` are posts that actually made + # it to disk, `skipped` are { title:, errors: } pairs for rows that + # never did (blank title, or a slug collision that outlived + # MAX_SLUG_ATTEMPTS). + ImportResult = Struct.new(:imported, :skipped) + + MAX_SLUG_ATTEMPTS = 20 + + belongs_to :user + has_many :comments, dependent: :destroy + has_many :likes, as: :likeable, dependent: :destroy + # dependent: :destroy, not :nullify - same reasoning as comments/likes + # above: Claim is paranoid too (see the model), so trashing this post + # soft-deletes its claims along with it and #restore(recursive: true) + # brings them back, rather than a plain trash permanently erasing the + # record of who already won a giveaway slot. + has_many :claims, dependent: :destroy + + # A deleted post lands in the trash (see PostsController#destroy/#trash/ + # #restore) instead of vanishing outright - Post.all/#find already hide + # it everywhere else in the app for free. Comment and Like are paranoid + # too, so the `dependent: :destroy` cascades above soft-delete them the + # moment a post is trashed instead of removing them for real, and + # PostsController#restore(recursive: true) brings them all back together + # with the post (see Associable#cascade_restore_dependent_associations). + acts_as_paranoid + + # Every post starts life as a draft (column default 0, see the + # add_status_to_posts migration) - only #publish! on posts#show makes it + # show up on the public index (see PostsController#index below). + enum :status, { draft: 0, published: 1, archived: 2 } + + # Prism::Orm::Attributes: casts the "featured" checkbox's form value + # ("1"/"0", always a String off an HTTP request - see the hidden-field + # trick in _form.erb) into a real TrueClass/FalseClass on write, instead + # of leaving the literal string "1" sitting in the featured column. That + # distinction is not cosmetic: `where(featured: true)` (see the + # .featured scope below and PostsController#index's `?featured=1` + # filter) does a plain Ruby `==` against whatever is actually stored, + # so a stored String would never match a query for the Boolean `true`. + attribute :featured, :boolean, default: false + + # Same reasoning as TypeCasting.cast_integer's own comment: blank input + # from the (optional) giveaway_slots form field casts to nil, not 0 - nil + # means "not a giveaway post" (see #giveaway? below), 0 means "was a + # giveaway, every slot already claimed", and those are not the same + # state for #giveaway_sold_out? to report. + attribute :giveaway_slots, :integer + + scope :featured, -> { where(featured: true) } + + # acts_as_list scoped by the featured flag itself: every post gets a + # position within its own featured/non-featured group (the non-featured + # group's numbers are never shown or reordered - see posts#index and + # PostsController#move_up/#move_down, both gated on featured_only), so + # toggling a post's own featured flag on/off cheaply hops it between two + # already-maintained lists (append to the bottom of whichever group it + # lands in) instead of needing a bespoke third state to track "not in + # any list yet". Editors use #move_up/#move_down to curate which + # featured posts lead the homepage instead of always showing the most + # recently featured one first. + acts_as_list column: :position, scope: :featured + + # Backed by the range-capable secondary index on created_at + # (db/migrate/20260907000004_add_index_on_posts_created_at.rb) - the + # gte/lte hash this builds is exactly what + # Query::ConditionNode#indexable_ranges recognizes, so PostsController#index + # chaining this onto .published (see below) hits + # Store#lookup_by_index_range with an O(log n) binary search over + # sorted_values instead of a full table scan. See Store#lookup_by_index_range. + scope :created_within, ->(days) { where(created_at: { gte: Time.now.utc - (days.to_i * 86_400) }) } + + # Lets the author moderate comments (edit or remove) from the same form + # used to edit the post itself - reject_if: :all_blank so a moderation + # form that only ever submits EXISTING comment rows (no "add a comment" + # field on this screen) never has to worry about accidentally building + # a blank new one. + accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: :all_blank + validates :title, presence: true, length: { minimum: 3 } end