diff --git a/examples/blog/app/models/post.rb b/examples/blog/app/models/post.rb index c1f15e8..a05270d 100644 --- a/examples/blog/app/models/post.rb +++ b/examples/blog/app/models/post.rb @@ -81,4 +81,170 @@ class Post < Prism::Orm::Model accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: :all_blank validates :title, presence: true, length: { minimum: 3 } + validates :body, presence: true + + before_destroy :remove_cover_file + + class << self + # Builds and saves a post, deriving a URL-safe slug from its title and + # retrying with a numeric suffix (-2, -3, ...) on a real collision from + # the unique index on posts.slug (see the add_slug_to_posts migration) + # instead of surfacing a raw Store::UniqueConstraintViolation to the + # caller. Each attempt runs in its own transaction(requires_new: true) + # SAVEPOINT (lib/prism/orm/transactions.rb, session 94) rather than a + # plain nested transaction - this is not decorative: a post whose + # insert loses the unique-slug race is still enlisted as a transaction + # participant (Model#save calls enlist_in_transaction before the + # insert that might fail), so without a savepoint to pop that doomed + # participant back out, it would ride along in the SAME frame as + # whatever else the surrounding transaction commits and wrongly fire + # this model's after_commit(on: :create) - our NotificationMailer + # hook below - for a post that was never actually created (id nil). + # requires_new's rollback_frame removes it from the frame entirely and + # fires after_rollback for it immediately instead. See .import_csv, + # the one caller that puts multiple attempts inside a shared outer + # transaction where this distinction is observable. + # + # A candidate that collides with a RESERVED slug (see #reserved_slugs + # below) is treated exactly like a real unique-index collision and + # retried with the same numeric suffix - raising the store's own + # UniqueConstraintViolation before ever attempting the insert reuses + # the retry/give-up loop below instead of duplicating it, and is + # accurate: both cases mean this exact slug can never resolve to this + # post's show page. + def create_with_unique_slug(attributes) + base_slug = slugify(attributes['title'] || attributes[:title]) + post = new(attributes) + return post unless post.valid? + + attempt = 0 + begin + attempt += 1 + candidate = attempt == 1 ? base_slug : "#{base_slug}-#{attempt}" + raise Prism::Orm::Store::UniqueConstraintViolation if reserved_slugs.include?(candidate) + + post.slug = candidate + transaction(requires_new: true) { post.save! } + rescue Prism::Orm::Store::UniqueConstraintViolation + retry if attempt < MAX_SLUG_ATTEMPTS + post.errors.add(:slug, "could not be made unique after #{MAX_SLUG_ATTEMPTS} attempts") + end + post + end + + # Every literal (non-`:param`) single-segment route Blog registers + # directly under /posts/ - i.e. `collection do get(:trash) end` and + # friends in app.rb. A post whose slug happened to literally equal one + # of these (say, a post titled "Trash") would be permanently + # unreachable at its own /posts/ URL: Application#dispatch tries + # routes in registration order (see the "Custom collection/member/ + # nested routes go first" comment in Application.resources) and the + # collection route, being literal, is registered - and therefore + # tried - before the generic /posts/:slug show route it would + # otherwise shadow. Derived from the router itself rather than a + # hand-kept word list, so a future collection action added in app.rb + # is automatically reserved too instead of silently reintroducing + # this bug the day someone forgets to update a hardcoded list. + def reserved_slugs + @reserved_slugs ||= Blog.router.routes.filter_map do |route| + match = route.pattern.match(%r{\A/posts/([^:/][^/]*)\z}) + match && match[1] + end.uniq + end + + # Bulk-creates posts from an uploaded CSV (title,body columns) in ONE + # outer transaction for the whole batch - the counterpart to + # PostsController#export, which streams the same shape back out. A row + # that can't be imported (blank title, or an exhausted slug retry) is + # skipped and reported instead of aborting rows already committed + # earlier in the same batch, which is exactly the guarantee + # .create_with_unique_slug's per-row savepoint above exists to give: + # without it, Store#within_transaction flattens every nested `transaction` + # call into ONE shared in-memory table (see TransactionRegistry), so a + # single bad row deep in a large CSV would otherwise either roll back + # every row already imported ahead of it, or - worse - stay silently + # enlisted and fire a phantom "new post" notification for a row that + # was never actually saved. + def import_csv(io, author:) + imported = [] + skipped = [] + transaction do + CSV.parse(io, headers: true) do |row| + title = row['title'].to_s + post = create_with_unique_slug( + 'title' => title, 'body' => row['body'].to_s, 'status' => :published, 'user_id' => author.id + ) + if post.persisted? + imported << post + else + skipped << { title: title, errors: post.errors.full_messages } + end + end + end + ImportResult.new(imported, skipped) + end + + private + + def slugify(title) + base = title.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '') + base.empty? ? 'post' : base + end + end + + # Queued from after_commit (not right after #save returns truthy) so the + # notification only ever goes out for a post that actually survived to + # disk - a failed after_create elsewhere in the callback chain rolls the + # whole save back and this never fires, whereas a plain post-save call + # site can't tell the difference between "saved" and "saved, but the + # transaction it was part of later rolled back". + after_commit(on: :create) { NotificationMailer.new_post(id).deliver_later } + + # Overrides Prism::Orm::Model#to_param (id.to_s) so every post-URL + # call site that writes `post.to_param` gets the friendly slug instead + # of the numeric id - see app.rb's `resources :posts, param: :slug`. + def to_param + slug + end + + # nil giveaway_slots (the column default) means this post was never + # opted into the giveaway feature at all - distinct from a real 0, which + # can only be reached by every slot actually being claimed (see + # #giveaway_remaining/#giveaway_sold_out?). + def giveaway? + !giveaway_slots.nil? + end + + # claims_count, not claims.count/claims.size - the counter cache column + # ClaimsController#create's Post#with_lock critical section reads (via + # #giveaway_sold_out? below) has to be the SAME read a concurrent, + # blocked-on-the-file-lock request will see the instant it gets its own + # turn, and only the counter cache is guaranteed to be updated inside + # that same transaction (see Claim's belongs_to counter_cache: true) - + # counting the claims association fresh here would still be correct, but + # would cost an extra query for no benefit. + def giveaway_remaining + return nil unless giveaway? + [giveaway_slots - claims_count, 0].max + end + + def giveaway_sold_out? + giveaway? && giveaway_remaining.zero? + end + + private + + # Without this, every hard-deleted post left its uploaded cover behind in + # public/uploads forever - PostsController#store_cover writes only the + # bare filename onto the row, so nothing else ever points back at the file. + # Skipped for a plain trash (#soft_deleting?, see acts_as_paranoid above): + # the row can still come back via #restore, and a restored post whose + # cover image was already deleted from disk would be a broken restore, + # not a real one. + def remove_cover_file + return if soft_deleting? + return unless cover && !cover.empty? + path = File.join(APP_ROOT, 'public', 'uploads', cover) + File.delete(path) if File.exist?(path) + end end