slimak 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 551f7dee790133d299f2990df101c7f66aa6d206932469cbfb84bd2dd7359b36
4
+ data.tar.gz: 07b366ca92776c71ac9e5802205ab0784358337040180ee2ed4ac81eff075894
5
+ SHA512:
6
+ metadata.gz: 14414f7f4238926c9dc980caa7c9f813812409afdb1f7ebf4b36db27aa59889554db238f005304dae9990d6262565d7b7173482140a4cf24f2fcddc2e14463d4
7
+ data.tar.gz: 991c4c516ab9901e93ca03f9fe01aa904ca17b6e54281752966e1009fedda26f265c8b599858b927b627133b0b7c92616db7094730d57f6a16a00e66bd43b81c
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ ```markdown
2
+ # Slimak
3
+
4
+ Slimak generates slugs from multiple attributes (columns) and helps persist them to the database with uniqueness and fast lookup.
5
+
6
+ Features
7
+ - Configure which columns to include in a slug: `slug_columns :name, :assignee_name, :urgency`
8
+ - Returns readable, parameterized slugs (uses ActiveSupport if available)
9
+ - Optionally persists slug to a DB column (default :slug)
10
+ - Ensures uniqueness by appending a numeric suffix when needed (e.g., `paint-the-wall`, `paint-the-wall-2`)
11
+ - Fast lookup via `Model.find_by_slug("...")` and optional DB index/unique constraint
12
+ - Rails Railtie automatically includes into ActiveRecord models
13
+
14
+ Installation
15
+ Add to your Gemfile:
16
+
17
+ ```ruby
18
+ gem "slimak"
19
+ ```
20
+
21
+ Usage (ActiveRecord)
22
+ 1) Add a slug column and index to your model's table:
23
+
24
+ ```ruby
25
+ class AddSlugToTasks < ActiveRecord::Migration[6.0]
26
+ def change
27
+ add_column :tasks, :slug, :string
28
+ add_index :tasks, :slug, unique: true
29
+ # If you scope uniqueness (e.g., per project):
30
+ # add_index :tasks, [:project_id, :slug], unique: true
31
+ end
32
+ end
33
+ ```
34
+
35
+ 2) Configure the model:
36
+
37
+ ```ruby
38
+ class Task < ApplicationRecord
39
+ include Slimak::Sluggable # is auto-included by Railtie, but explicit include is fine
40
+ slug_columns :name, :urgency, :assignee_name
41
+ # optional:
42
+ # slug_options column: :permalink
43
+ # slug_options scope: :project_id
44
+ end
45
+ ```
46
+
47
+ By default, a slug will be generated and saved before validation if the slug column is blank. If a generated slug collides with an existing record, a numeric suffix will be appended (e.g., -2, -3) until uniqueness is achieved.
48
+
49
+ 3) Finding by slug:
50
+
51
+ ```ruby
52
+ Task.find_by_slug("paint-the-wall-2")
53
+ # or
54
+ Task.find_by_slug!("paint-the-wall")
55
+ ```
56
+
57
+ Usage (Plain Ruby object)
58
+ ```ruby
59
+ class FakeTask
60
+ include Slimak::Sluggable
61
+ attr_accessor :name, :urgency, :assignee_name
62
+ slug_columns :name, :urgency, :assignee_name
63
+ end
64
+
65
+ t = FakeTask.new
66
+ t.name = "Paint the wall"
67
+ t.urgency = "Critical"
68
+ t.assignee_name = "Mark"
69
+ t.slug # => "paint-the-wall-critical-mark"
70
+ ```
71
+
72
+ Generators
73
+ ```ruby
74
+ rails generate slimak:add_slug ModelName
75
+
76
+ Options: --column=NAME Column to add/store slug (default: slug) --scope=COLUMN_NAME Optional scope column (creates composite unique index)
77
+ ```
78
+
79
+
80
+ Notes & Next steps
81
+ - For robust concurrency-safe uniqueness you should also enforce a UNIQUE index in the database and handle possible race conditions (e.g., retry on unique constraint violation). Slimak already appends numeric suffixes to avoid collisions, but an index prevents races.
82
+ - You can scope uniqueness via `slug_options scope: :project_id` and add a composite unique index [project_id, slug].
83
+ - If you'd like, I can add a Rails generator to create migrations, or implement a DB-retry strategy to handle high concurrency collisions.
84
+
85
+ ```
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require "rspec/core/rake_task"
2
+
3
+ RSpec::Core::RakeTask.new(:spec) do |t|
4
+ t.rspec_opts = "--format documentation"
5
+ end
6
+
7
+ task default: :spec
@@ -0,0 +1,58 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module Slimak
5
+ module Generators
6
+ # Usage:
7
+ # rails generate slimak:add_slug ModelName --column=slug --scope=project_id --migration_version=6.1
8
+ class AddSlugGenerator < Rails::Generators::NamedBase
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :column, type: :string, default: "slug", desc: "Column name to store slug"
14
+ class_option :scope, type: :string, default: nil, desc: "Optional scope column name for scoped uniqueness"
15
+ class_option :migration_version, type: :string, default: nil, desc: "Rails migration version to use (e.g. 6.0). Defaults to current."
16
+
17
+ # Required by Rails::Generators::Migration to generate unique migration numbers
18
+ def self.next_migration_number(dirname)
19
+ if ActiveRecord::Base.timestamped_migrations
20
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
21
+ else
22
+ "%.3d" % (current_migration_number(dirname) + 1)
23
+ end
24
+ end
25
+
26
+ def create_migration_file
27
+ migration_filename = "add_#{options['column'] || 'slug'}_to_#{file_name.pluralize}.rb"
28
+ migration_template "add_slug_migration.rb.erb", "db/migrate/#{migration_filename}", migration_version: migration_version_option
29
+ end
30
+
31
+ def create_initializer_file
32
+ template "slimak_initializer.rb.erb", "config/initializers/slimak.rb"
33
+ end
34
+
35
+ private
36
+
37
+ # prefer provided migration_version option; otherwise infer "6.0" if Rails version not available
38
+ def migration_version_option
39
+ return options["migration_version"] if options["migration_version"].present?
40
+ rails_major = if defined?(Rails) && Rails.respond_to?(:version)
41
+ Rails.version.split(".").first(2).join(".")
42
+ else
43
+ "6.0"
44
+ end
45
+ rails_major
46
+ end
47
+
48
+ # helper for templates
49
+ def column_name
50
+ options["column"] || "slug"
51
+ end
52
+
53
+ def scope_name
54
+ options["scope"]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,10 @@
1
+ class Add<%= (column_name + "_to_" + file_name.pluralize).camelize %> < ActiveRecord::Migration[<%= migration_version %>]
2
+ def change
3
+ add_column :<%= file_name.pluralize %>, :<%= column_name %>, :string
4
+ add_index :<%= file_name.pluralize %>, :<%= column_name %>, unique: true
5
+ <% if scope_name.present? -%>
6
+ # Scoped uniqueness: composite unique index on [<%= scope_name %>, :<%= column_name %>]
7
+ add_index :<%= file_name.pluralize %>, [:<%= scope_name %>, :<%= column_name %>], unique: true, name: "index_<%= file_name.pluralize %>_on_<%= scope_name %>_and_<%= column_name %>"
8
+ <% end -%>
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ ```ruby
2
+ # Slimak initializer
3
+ # Configure global defaults for Slimak gem here, for example:
4
+ #
5
+ # Slimak.configure do |config|
6
+ # config.column = :slug # default column name to use when generating slugs
7
+ # config.separator = "-" # default separator
8
+ # config.conflict_strategy = :sequence # or :random
9
+ # config.random_suffix_length = 4
10
+ # config.slug_column_limits = { name: 20 } # global per-column limits
11
+ # end
12
+ #
13
+ # Per-model configuration (slug_columns, slug_options, slug_column_limits) still overrides these defaults.
14
+ ```
@@ -0,0 +1,41 @@
1
+ module Slimak
2
+ # Global configuration object for Slimak.
3
+ # Configure in an initializer or test with:
4
+ # Slimak.configure do |config|
5
+ # config.separator = "_"
6
+ # config.conflict_strategy = :random
7
+ # config.random_suffix_length = 6
8
+ # config.slug_column_limits = { name: 10 }
9
+ # end
10
+ class Configuration
11
+ attr_accessor :column,
12
+ :separator,
13
+ :conflict_strategy,
14
+ :sequence_separator,
15
+ :random_suffix_length,
16
+ :scope,
17
+ :slug_column_limits
18
+
19
+ def initialize
20
+ @column = :slug
21
+ @separator = "-"
22
+ @conflict_strategy = :sequence
23
+ @sequence_separator = "-"
24
+ @random_suffix_length = 4
25
+ @scope = nil
26
+ @slug_column_limits = {}
27
+ end
28
+
29
+ # Convert config to a plain hash used by Sluggable defaults
30
+ def to_hash
31
+ {
32
+ column: @column,
33
+ separator: @separator,
34
+ conflict_strategy: @conflict_strategy,
35
+ sequence_separator: @sequence_separator,
36
+ random_suffix_length: @random_suffix_length,
37
+ scope: @scope
38
+ }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ # Railtie: auto-include Slimak::Sluggable into ActiveRecord::Base for Rails apps.
2
+ if defined?(Rails)
3
+ require "rails/railtie"
4
+
5
+ module Slimak
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "slimak.active_record" do
8
+ ActiveSupport.on_load(:active_record) do
9
+ include Slimak::Sluggable
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,186 @@
1
+ require "securerandom"
2
+
3
+ module Slimak
4
+ # Sluggable concern for ActiveRecord models (also usable on plain Ruby objects).
5
+ module Sluggable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ if defined?(ActiveRecord) && self <= ActiveRecord::Base
10
+ before_validation :generate_slug_if_blank
11
+ end
12
+ end
13
+
14
+ class_methods do
15
+ def slug_columns(*cols)
16
+ @_slimak_slug_columns = cols.flatten.map(&:to_sym)
17
+ @_slimak_slug_column_limits ||= {}
18
+ @_slimak_slug_options ||= default_slug_options
19
+ end
20
+
21
+ def slug_column_limits(hash = nil)
22
+ # Merge global limits with model-specific limits; model-specific wins.
23
+ global = Slimak.config.slug_column_limits || {}
24
+ @_slimak_slug_column_limits ||= global.dup
25
+ if hash
26
+ @_slimak_slug_column_limits.merge!(hash.transform_keys(&:to_sym))
27
+ end
28
+ @_slimak_slug_column_limits
29
+ end
30
+
31
+ def slug_options(opts = nil)
32
+ @_slimak_slug_options ||= default_slug_options
33
+ @_slimak_slug_options.merge!(opts) if opts.is_a?(Hash)
34
+ @_slimak_slug_options
35
+ end
36
+
37
+ # Default options come from global Slimak.config so users can set global defaults.
38
+ def default_slug_options
39
+ Slimak.config.to_hash
40
+ end
41
+
42
+ def _slimak_slug_columns
43
+ @_slimak_slug_columns || []
44
+ end
45
+
46
+ def _slimak_slug_column_limits
47
+ @_slimak_slug_column_limits || Slimak.config.slug_column_limits || {}
48
+ end
49
+
50
+ def _slimak_slug_options
51
+ @_slimak_slug_options ||= default_slug_options
52
+ end
53
+
54
+ def find_by_slug(value)
55
+ col = _slimak_slug_options[:column]
56
+ where(col => value).first
57
+ end
58
+
59
+ def find_by_slug!(value)
60
+ find_by_slug(value) or raise ActiveRecord::RecordNotFound, "Couldn't find #{name} with slug=#{value}"
61
+ end
62
+ end
63
+
64
+ def _slug
65
+ col = self.class._slimak_slug_options[:column]
66
+ if respond_to?(col) && !send(col).to_s.strip.empty?
67
+ send(col).to_s
68
+ else
69
+ build_slug_string
70
+ end
71
+ end
72
+
73
+ def generate_slug_if_blank
74
+ return unless self.class._slimak_slug_columns.any?
75
+
76
+ slug_col = self.class._slimak_slug_options[:column]
77
+ if respond_to?(slug_col) && !send(slug_col).to_s.strip.empty?
78
+ return
79
+ end
80
+
81
+ candidate = build_unique_slug
82
+ assign_slug_column(slug_col, candidate)
83
+ nil
84
+ end
85
+
86
+ def build_slug_string
87
+ parts = self.class._slimak_slug_columns.map do |col|
88
+ v = safe_read(col)
89
+ next nil if v.nil?
90
+ formatted = format_component(v.to_s, col)
91
+ formatted unless formatted.to_s.empty?
92
+ end.compact
93
+
94
+ return "" if parts.empty?
95
+
96
+ parameterize(parts.join(" "), self.class._slimak_slug_options[:separator])
97
+ end
98
+
99
+ def build_unique_slug
100
+ base = build_slug_string
101
+ return base if base.to_s.strip.empty?
102
+ return base unless defined?(ActiveRecord) && self.class.respond_to?(:unscoped)
103
+
104
+ # Merge global options with model options so model options override global.
105
+ opts = Slimak.config.to_hash.merge(self.class._slimak_slug_options || {})
106
+ col = opts[:column]
107
+ scope = opts[:scope]
108
+
109
+ case opts[:conflict_strategy].to_sym
110
+ when :random
111
+ candidate = base.dup
112
+ while slug_exists?(candidate, col, scope)
113
+ suffix = SecureRandom.alphanumeric(opts[:random_suffix_length]).downcase
114
+ candidate = [base, suffix].join(opts[:sequence_separator])
115
+ end
116
+ candidate
117
+ else
118
+ candidate = base.dup
119
+ seq = 2
120
+ while slug_exists?(candidate, col, scope)
121
+ candidate = [base, seq].join(opts[:sequence_separator])
122
+ seq += 1
123
+ end
124
+ candidate
125
+ end
126
+ end
127
+
128
+ # Safe read helper
129
+ def safe_read(name)
130
+ if respond_to?(name)
131
+ send(name)
132
+ else
133
+ nil
134
+ end
135
+ rescue => _
136
+ nil
137
+ end
138
+
139
+ def format_component(str, column)
140
+ s = str.dup
141
+ s = ActiveSupport::Inflector.transliterate(s) if defined?(ActiveSupport::Inflector)
142
+ s = s.strip
143
+ limits = self.class._slimak_slug_column_limits || {}
144
+ max = limits && limits[column.to_sym]
145
+ s = s[0, max] if max && max > 0
146
+ s.gsub(/[[:space:]]+/, " ")
147
+ end
148
+
149
+ def assign_slug_column(slug_col, value)
150
+ if respond_to?("#{slug_col}=")
151
+ send("#{slug_col}=", value)
152
+ elsif respond_to?(:write_attribute)
153
+ write_attribute(slug_col, value)
154
+ else
155
+ (class << self; self; end).class_eval do
156
+ attr_accessor slug_col unless method_defined?(slug_col)
157
+ end
158
+ instance_variable_set("@#{slug_col}", value)
159
+ end
160
+ end
161
+
162
+ def parameterize(str, separator = "-")
163
+ s = str.to_s
164
+ begin
165
+ s.parameterize(separator: separator)
166
+ rescue ArgumentError
167
+ s.parameterize(separator)
168
+ end
169
+ end
170
+
171
+ def slug_exists?(candidate, col, scope)
172
+ return false unless defined?(ActiveRecord) && self.class.respond_to?(:unscoped)
173
+ rel = self.class.unscoped.where(col => candidate)
174
+ if scope
175
+ Array(scope).each do |sc|
176
+ val = safe_read(sc)
177
+ rel = rel.where(sc => val)
178
+ end
179
+ end
180
+ if respond_to?(:persisted?) && persisted?
181
+ rel = rel.where.not(id: id)
182
+ end
183
+ rel.exists?
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,3 @@
1
+ module Slimak
2
+ VERSION = "0.2.1"
3
+ end
data/lib/slimak.rb ADDED
@@ -0,0 +1,40 @@
1
+ require 'logger'
2
+ require_relative "slimak/version"
3
+
4
+ # helpers from ActiveSupport for parameterize
5
+ require "active_support"
6
+ require "active_support/core_ext/string/inflections"
7
+ require "active_support/inflector"
8
+ require "active_support/concern"
9
+
10
+ require_relative "slimak/configuration"
11
+ require_relative "slimak/sluggable"
12
+ require_relative "slimak/railtie" if defined?(Rails)
13
+
14
+ module Slimak
15
+ # Global config object; you can reassign in tests if necessary
16
+ @config = Configuration.new
17
+
18
+ class << self
19
+ # Accessor for global config
20
+ def config
21
+ @config
22
+ end
23
+
24
+ # Setter for global config (useful for tests)
25
+ def config=(c)
26
+ @config = c
27
+ end
28
+
29
+ # Convenience configure block: Slimak.configure { |c| c.separator = "_" }
30
+ def configure
31
+ yield config if block_given?
32
+ config
33
+ end
34
+
35
+ # Reset global config to defaults
36
+ def reset!
37
+ @config = Configuration.new
38
+ end
39
+ end
40
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: slimak
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Stanislaw Zawadzki
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-12-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '5.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '8.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '8.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: sqlite3
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '2.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: pry
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Slimak creates slugs from multiple model attributes (slug_columns) and
98
+ supports storing them in the DB with uniqueness and fast lookup.
99
+ email:
100
+ - st.zawadzki@gmail.com
101
+ executables: []
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - README.md
106
+ - Rakefile
107
+ - lib/generators/slimak/add_slug/add_slug_generator.rb
108
+ - lib/generators/slimak/add_slug/templates/add_slug_migration.rb.erb
109
+ - lib/generators/slimak/add_slug/templates/slimak_initializer.rb.erb
110
+ - lib/slimak.rb
111
+ - lib/slimak/configuration.rb
112
+ - lib/slimak/railtie.rb
113
+ - lib/slimak/sluggable.rb
114
+ - lib/slimak/version.rb
115
+ homepage: ''
116
+ licenses:
117
+ - MIT
118
+ metadata: {}
119
+ post_install_message:
120
+ rdoc_options: []
121
+ require_paths:
122
+ - lib
123
+ required_ruby_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: 2.6.0
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ requirements: []
134
+ rubygems_version: 3.4.10
135
+ signing_key:
136
+ specification_version: 4
137
+ summary: Multi-column slugs with ActiveRecord persistence & fast lookup
138
+ test_files: []