feat(models): extend post with create_with_unique_slug

This commit is contained in:
dualfroz committed 2026-09-04 17:39:03 +02:00
1 parent 6a702a1ece
commit 3fa9e92acc
1 file changed
+166
+166
View File
@@ -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/<slug> 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