organizations 0.1.0 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e1c3b465c2092c9234aed9d1751337abae3bfef4a86bf1bd2fd34bafb5bee7fb
4
- data.tar.gz: cc0b5a830649d91a464ba72f5603bec472c45d11818974c6d1567db4a65422ef
3
+ metadata.gz: 3ca06945acfae769816fdc7c19ba44778791697325d1f59967fb4794c7b8a274
4
+ data.tar.gz: 9601aa8b0835819532b1aed0a79a410ef4bf51f023b90088c463950dff5616c0
5
5
  SHA512:
6
- metadata.gz: 181defb79c40e269ea86b28d3f6d6d262d3834eaf31674ab66b33a807e685bec9ef085b3251490a26b0c02c870b680b803f020341c7555cebee261fd1984a0e9
7
- data.tar.gz: 34e5d034196756c2bf436341766a1889b9c665e7a42c70a7a7ad5ba52863da56a0a845d4842b171cd4fbf6354a8be44cbfef476716f6e8c78c665e6e403ed563
6
+ metadata.gz: 666c1b825cb64d0d822d654bf1db23c6672b993e7769369d43b0afb01480e13218511b1bd7da4c100b5761360049f9408c6c74a33a177a8e86b34ce6cab27efa
7
+ data.tar.gz: 6416fb315eb949d06672665f5909bcf463976d265a05bbcdbc508978f5f20c6ccf9367c64c8a94ccbe1e8da57eeb934b3d12354dd9adb40ed51f00a9421f2883
data/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## [0.1.1] - 2026-02-19
2
+
3
+ - Removed `slugifiable` dependency (deferred to host app)
4
+
1
5
  ## [0.1.0] - 2026-02-19
2
6
 
3
7
  Initial release.
data/README.md CHANGED
@@ -48,7 +48,7 @@ gem "organizations"
48
48
  ```
49
49
 
50
50
  > [!NOTE]
51
- > The `organizations` gem depends on [`slugifiable`](https://github.com/rameerez/slugifiable) for URL-friendly organization slugs (auto-included). For beautiful invitation emails, optionally add [`goodmail`](https://github.com/rameerez/goodmail).
51
+ > For beautiful invitation emails, optionally add [`goodmail`](https://github.com/rameerez/goodmail).
52
52
 
53
53
  Then:
54
54
 
@@ -458,10 +458,10 @@ The helper returns:
458
458
 
459
459
  ```ruby
460
460
  {
461
- current: { id: "...", name: "Acme Corp", slug: "acme-corp" },
461
+ current: { id: "...", name: "Acme Corp" },
462
462
  others: [
463
- { id: "...", name: "Personal", slug: "personal" },
464
- { id: "...", name: "StartupCo", slug: "startupco" }
463
+ { id: "...", name: "Personal" },
464
+ { id: "...", name: "StartupCo" }
465
465
  ],
466
466
  switch_path: ->(org_id) { "/organizations/switch/#{org_id}" }
467
467
  }
@@ -1026,7 +1026,6 @@ The gem works with Rails fixtures:
1026
1026
  # test/fixtures/organizations.yml
1027
1027
  acme:
1028
1028
  name: Acme Corp
1029
- slug: acme-corp
1030
1029
 
1031
1030
  # test/fixtures/memberships.yml
1032
1031
  john_at_acme:
@@ -1117,13 +1116,12 @@ The gem creates three tables:
1117
1116
  organizations
1118
1117
  - id (primary key, auto-detects UUID or integer from your app)
1119
1118
  - name (string, required)
1120
- - slug (string, unique, indexed) -- auto-generated via slugifiable gem
1121
1119
  - metadata (jsonb, default: {})
1122
1120
  - created_at
1123
1121
  - updated_at
1124
1122
  ```
1125
1123
 
1126
- > **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables. Slugs are auto-generated from the organization name using the [`slugifiable`](https://github.com/rameerez/slugifiable) gem.
1124
+ > **Note:** The gem automatically detects your app's primary key type (UUID or integer) and uses it for all tables.
1127
1125
 
1128
1126
  ### memberships
1129
1127
 
@@ -1181,7 +1179,6 @@ organization_invitations
1181
1179
  | Ownership transfer to removed user | Transaction lock, verifies membership exists before transfer |
1182
1180
  | Concurrent role changes on same user | Row-level lock on membership row |
1183
1181
  | Session points to org user was removed from | `current_organization` verifies membership, clears stale session |
1184
- | Duplicate org slug on creation | Unique constraint, retries with suffix (acme-corp-2) |
1185
1182
  | Token collision on invitation | Unique constraint, regenerates token |
1186
1183
 
1187
1184
  ## Performance notes
@@ -1243,12 +1240,12 @@ The `organization_switcher_data` helper is optimized for navbar use:
1243
1240
 
1244
1241
  ```ruby
1245
1242
  # Internally, it:
1246
- # 1. Selects only id, name, slug (not full objects)
1243
+ # 1. Selects only id, name (not full objects)
1247
1244
  # 2. Memoizes within the request
1248
1245
  # 3. Returns a lightweight hash, not ActiveRecord objects
1249
1246
 
1250
1247
  organization_switcher_data
1251
- # => { current: { id: "...", name: "Acme", slug: "acme" }, others: [...] }
1248
+ # => { current: { id: "...", name: "Acme" }, others: [...] }
1252
1249
  ```
1253
1250
 
1254
1251
  ### Counter caches for member counts
@@ -1333,7 +1330,6 @@ These constraints prevent duplicate data at the database level:
1333
1330
  | `memberships [user_id, organization_id]` | User can only have one membership per org |
1334
1331
  | `invitations [organization_id, email] WHERE accepted_at IS NULL` | Only one pending invitation per email per org |
1335
1332
  | `invitations [token]` | Invitation tokens are globally unique |
1336
- | `organizations [slug]` | Organization slugs are globally unique |
1337
1333
 
1338
1334
  ### Row-level locking
1339
1335
 
@@ -1423,10 +1419,6 @@ invitation.accept!
1423
1419
  # Adding an existing member
1424
1420
  org.add_member!(existing_user)
1425
1421
  # => Returns existing membership (doesn't raise)
1426
-
1427
- # Creating org with duplicate slug
1428
- user.create_organization!("Acme Corp") # slug: acme-corp exists
1429
- # => Creates with suffix: acme-corp-2 (via slugifiable gem)
1430
1422
  ```
1431
1423
 
1432
1424
  ### Session integrity
@@ -1461,9 +1453,6 @@ CREATE INDEX index_memberships_on_role ON memberships (role);
1461
1453
  CREATE UNIQUE INDEX index_invitations_on_token ON organization_invitations (token);
1462
1454
  CREATE INDEX index_invitations_on_email ON organization_invitations (email);
1463
1455
  CREATE UNIQUE INDEX index_invitations_pending ON organization_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
1464
-
1465
- -- Fast org lookups
1466
- CREATE UNIQUE INDEX index_organizations_on_slug ON organizations (slug);
1467
1456
  ```
1468
1457
 
1469
1458
  ## Migration from 1:1 relationships
@@ -150,7 +150,6 @@ module Organizations
150
150
  {
151
151
  id: org.id,
152
152
  name: org.name,
153
- slug: org.slug,
154
153
  member_count: counts[org.id] || 0,
155
154
  role: membership.role,
156
155
  created_at: org.created_at,
@@ -168,7 +167,6 @@ module Organizations
168
167
  {
169
168
  id: org.id,
170
169
  name: org.name,
171
- slug: org.slug,
172
170
  member_count: org.member_count,
173
171
  role: membership&.role,
174
172
  created_at: org.created_at,
@@ -19,7 +19,7 @@ module Organizations
19
19
 
20
20
  respond_to do |format|
21
21
  format.html { redirect_to after_switch_path, notice: "Switched to #{org.name}" }
22
- format.json { render json: { organization: { id: org.id, name: org.name, slug: org.slug } } }
22
+ format.json { render json: { organization: { id: org.id, name: org.name } } }
23
23
  end
24
24
  else
25
25
  respond_to do |format|
@@ -0,0 +1,25 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.2.3"
7
+
8
+ group :development do
9
+ gem "irb"
10
+ gem "rubocop", "~> 1.0"
11
+ gem "rubocop-minitest", "~> 0.35"
12
+ gem "rubocop-performance", "~> 1.0"
13
+ end
14
+
15
+ group :development, :test do
16
+ gem "appraisal"
17
+ gem "minitest", "~> 6.0"
18
+ gem "minitest-mock"
19
+ gem "rack-test"
20
+ gem "sqlite3", ">= 2.1"
21
+ gem "ostruct"
22
+ gem "simplecov", require: false
23
+ end
24
+
25
+ gemspec path: "../"
@@ -0,0 +1,25 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.1.2"
7
+
8
+ group :development do
9
+ gem "irb"
10
+ gem "rubocop", "~> 1.0"
11
+ gem "rubocop-minitest", "~> 0.35"
12
+ gem "rubocop-performance", "~> 1.0"
13
+ end
14
+
15
+ group :development, :test do
16
+ gem "appraisal"
17
+ gem "minitest", "~> 6.0"
18
+ gem "minitest-mock"
19
+ gem "rack-test"
20
+ gem "sqlite3", ">= 2.1"
21
+ gem "ostruct"
22
+ gem "simplecov", require: false
23
+ end
24
+
25
+ gemspec path: "../"
@@ -8,14 +8,11 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
8
8
  # Organizations table
9
9
  create_table :organizations, id: primary_key_type do |t|
10
10
  t.string :name, null: false
11
- t.string :slug, null: false
12
11
  t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
13
12
 
14
13
  t.timestamps
15
14
  end
16
15
 
17
- add_index :organizations, :slug, unique: true
18
-
19
16
  # Memberships join table (User ↔ Organization)
20
17
  create_table :memberships, id: primary_key_type do |t|
21
18
  t.references :user, null: false, type: foreign_key_type, foreign_key: true
@@ -71,13 +71,4 @@ Organizations.configure do |config|
71
71
  # Default: :current_organization_id
72
72
  # config.session_key = :current_organization_id
73
73
 
74
- # ============================================================================
75
- # URL SLUGS
76
- # ============================================================================
77
-
78
- # Organizations use slugifiable for URL-friendly slugs.
79
- # Slugs are auto-generated from the organization name.
80
- # Example: "Acme Corp" → "acme-corp"
81
- #
82
- # To customize slug generation, configure slugifiable separately.
83
74
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "slugifiable/model"
4
-
5
3
  module Organizations
6
4
  # Organization model representing a team, workspace, or account.
7
5
  # Users belong to organizations through memberships with specific roles.
@@ -19,11 +17,6 @@ module Organizations
19
17
  #
20
18
  class Organization < ActiveRecord::Base
21
19
  self.table_name = "organizations"
22
- include Slugifiable::Model
23
-
24
- # Keep slug semantics aligned with README and slugifiable defaults:
25
- # organization names become URL-friendly slugs, with collision handling.
26
- generate_slug_based_on :name
27
20
 
28
21
  # Error raised when trying to perform invalid operations on organization
29
22
  class CannotRemoveOwner < Organizations::Error; end
@@ -53,14 +46,6 @@ module Organizations
53
46
  # === Validations ===
54
47
 
55
48
  validates :name, presence: true
56
- validates :slug, presence: true, uniqueness: { case_sensitive: false }
57
-
58
- # === Callbacks ===
59
-
60
- # slugifiable persists slugs in after_create by default, but this gem keeps
61
- # organizations.slug as NOT NULL and validates presence. We therefore compute
62
- # a slug before validation when needed.
63
- before_validation :ensure_slug_present, on: :create, if: -> { slug.blank? && name.present? }
64
49
 
65
50
  # === Scopes ===
66
51
 
@@ -385,10 +370,6 @@ module Organizations
385
370
 
386
371
  private
387
372
 
388
- def ensure_slug_present
389
- self.slug = compute_slug if slug.blank?
390
- end
391
-
392
373
  # Defense in depth for organization-centric API usage.
393
374
  # The user-level API already checks this, but direct calls to `org.send_invite_to!`
394
375
  # must enforce membership and invite permission as well.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
@@ -24,7 +24,7 @@ module Organizations
24
24
  # === Organization Switcher ===
25
25
 
26
26
  # Returns optimized data for building an organization switcher
27
- # Only selects needed columns (id, name, slug) for performance
27
+ # Only selects needed columns (id, name) for performance
28
28
  # Memoized within the request
29
29
  #
30
30
  # @return [Hash] Hash with :current, :others, and :switch_path
@@ -32,10 +32,10 @@ module Organizations
32
32
  # @example
33
33
  # organization_switcher_data
34
34
  # # => {
35
- # # current: { id: "...", name: "Acme Corp", slug: "acme-corp" },
35
+ # # current: { id: "...", name: "Acme Corp" },
36
36
  # # others: [
37
- # # { id: "...", name: "Personal", slug: "personal" },
38
- # # { id: "...", name: "StartupCo", slug: "startupco" }
37
+ # # { id: "...", name: "Personal" },
38
+ # # { id: "...", name: "StartupCo" }
39
39
  # # ],
40
40
  # # switch_path: ->(org_id) { "/organizations/switch/#{org_id}" }
41
41
  # # }
@@ -259,7 +259,7 @@ module Organizations
259
259
  .includes(:organization)
260
260
  .joins(:organization)
261
261
  .select("memberships.id, memberships.organization_id, memberships.role, " \
262
- "organizations.id AS org_id, organizations.name AS org_name, organizations.slug AS org_slug")
262
+ "organizations.id AS org_id, organizations.name AS org_name")
263
263
 
264
264
  current_data = nil
265
265
  others = []
@@ -268,7 +268,6 @@ module Organizations
268
268
  org_data = {
269
269
  id: m.organization_id,
270
270
  name: m.org_name,
271
- slug: m.org_slug,
272
271
  role: m.role.to_sym,
273
272
  role_label: organization_role_label(m.role)
274
273
  }
@@ -281,7 +280,7 @@ module Organizations
281
280
  end
282
281
 
283
282
  # If no current org was found in memberships, user might not be a member anymore
284
- current_data ||= { id: nil, name: nil, slug: nil, role: nil, current: true }
283
+ current_data ||= { id: nil, name: nil, role: nil, current: true }
285
284
 
286
285
  {
287
286
  current: current_data,
@@ -292,7 +291,7 @@ module Organizations
292
291
 
293
292
  def empty_switcher_data
294
293
  {
295
- current: { id: nil, name: nil, slug: nil, role: nil, current: true },
294
+ current: { id: nil, name: nil, role: nil, current: true },
296
295
  others: [],
297
296
  switch_path: build_switch_path_lambda
298
297
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: organizations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
@@ -69,20 +69,6 @@ dependencies:
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
71
  version: '9.0'
72
- - !ruby/object:Gem::Dependency
73
- name: slugifiable
74
- requirement: !ruby/object:Gem::Requirement
75
- requirements:
76
- - - ">="
77
- - !ruby/object:Gem::Version
78
- version: 0.1.0
79
- type: :runtime
80
- prerelease: false
81
- version_requirements: !ruby/object:Gem::Requirement
82
- requirements:
83
- - - ">="
84
- - !ruby/object:Gem::Version
85
- version: 0.1.0
86
72
  description: Add organizations to any Rails app (with members, roles, and invitations).
87
73
  This gem implements the complete User → Membership → Organization pattern with scoped
88
74
  invitations, hierarchical roles (owner, admin, member, viewer), permissions, and
@@ -113,8 +99,8 @@ files:
113
99
  - app/views/organizations/invitation_mailer/invitation_email.html.erb
114
100
  - app/views/organizations/invitation_mailer/invitation_email.text.erb
115
101
  - config/routes.rb
116
- - examples/demo_insert_race_condition.rb
117
- - examples/demo_slugifiable_integration.rb
102
+ - gemfiles/rails_7.2.gemfile
103
+ - gemfiles/rails_8.1.gemfile
118
104
  - lib/generators/organizations/install/install_generator.rb
119
105
  - lib/generators/organizations/install/templates/create_organizations_tables.rb.erb
120
106
  - lib/generators/organizations/install/templates/initializer.rb
@@ -1,212 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Demo: Verify INSERT-time race condition handling works for Organizations
4
- #
5
- # This simulates the exact race condition that Codex identified:
6
- # 1. Two processes try to create orgs with same name simultaneously
7
- # 2. Both compute slug "acme-corp" in before_validation
8
- # 3. First INSERT succeeds
9
- # 4. Second INSERT fails with RecordNotUnique
10
- # 5. around_create retries with recomputed slug
11
- #
12
- # Run: bundle exec ruby test/demo_insert_race_condition.rb
13
-
14
- require "bundler/setup"
15
- require "active_record"
16
- require "sqlite3"
17
-
18
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
19
- require "organizations"
20
-
21
- puts "=" * 70
22
- puts "INSERT-TIME RACE CONDITION DEMO"
23
- puts "=" * 70
24
- puts
25
-
26
- # Setup
27
- ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
28
- ActiveRecord::Base.logger = Logger.new(nil)
29
-
30
- ActiveRecord::Schema.define do
31
- create_table :users, force: :cascade do |t|
32
- t.string :email, null: false
33
- t.timestamps
34
- end
35
-
36
- create_table :organizations, force: :cascade do |t|
37
- t.string :name, null: false
38
- t.string :slug, null: false # NOT NULL - key for this test
39
- t.timestamps
40
- end
41
- add_index :organizations, :slug, unique: true
42
-
43
- create_table :memberships, force: :cascade do |t|
44
- t.references :user, null: false
45
- t.references :organization, null: false
46
- t.string :role, null: false, default: "member"
47
- t.timestamps
48
- end
49
-
50
- create_table :organization_invitations, force: :cascade do |t|
51
- t.references :organization, null: false
52
- t.string :email, null: false
53
- t.string :token, null: false
54
- t.string :role, null: false, default: "member"
55
- t.datetime :accepted_at
56
- t.datetime :expires_at
57
- t.timestamps
58
- end
59
- end
60
-
61
- class User < ActiveRecord::Base
62
- extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
63
- has_organizations
64
- end
65
-
66
- # ============================================================================
67
- # Test 1: Verify around_create hook is available
68
- # ============================================================================
69
- puts "TEST 1: Verify around_create hook exists"
70
- puts "-" * 40
71
-
72
- org = Organizations::Organization.new(name: "Test")
73
- has_hook = org.respond_to?(:retry_create_on_slug_unique_violation, true)
74
- puts "Has retry_create_on_slug_unique_violation: #{has_hook ? '✅ YES' : '❌ NO'}"
75
- puts
76
-
77
- # ============================================================================
78
- # Test 2: Simulate race condition with injected conflicting INSERT
79
- # ============================================================================
80
- puts "TEST 2: Simulate INSERT-time race condition"
81
- puts "-" * 40
82
-
83
- # Create a subclass that injects a conflicting row on first INSERT attempt
84
- class RaceSimulationOrg < Organizations::Organization
85
- self.table_name = "organizations"
86
-
87
- class_attribute :insert_attempts, default: 0
88
- class_attribute :collision_injected, default: false
89
-
90
- before_create :inject_collision_once
91
-
92
- private
93
-
94
- def inject_collision_once
95
- self.class.insert_attempts += 1
96
- puts " [DEBUG] INSERT attempt ##{self.class.insert_attempts}, slug=#{slug}"
97
-
98
- return if self.class.collision_injected
99
-
100
- self.class.collision_injected = true
101
-
102
- # Inject a row with the same slug BEFORE this INSERT completes
103
- conn = self.class.connection
104
- now = Time.current
105
- conn.execute(<<~SQL)
106
- INSERT INTO organizations (name, slug, created_at, updated_at)
107
- VALUES (
108
- #{conn.quote("Injected by race simulation")},
109
- #{conn.quote(slug)},
110
- #{conn.quote(now)},
111
- #{conn.quote(now)}
112
- )
113
- SQL
114
- puts " [DEBUG] Injected conflicting row with slug=#{slug}"
115
- end
116
- end
117
-
118
- begin
119
- org = RaceSimulationOrg.create!(name: "Acme Corp")
120
-
121
- puts
122
- puts "Result:"
123
- puts " Created successfully: #{org.persisted? ? '✅ YES' : '❌ NO'}"
124
- puts " INSERT attempts: #{RaceSimulationOrg.insert_attempts}"
125
- puts " Final slug: #{org.slug}"
126
- puts " Slug changed after retry: #{org.slug != 'acme-corp' ? '✅ YES' : '❌ NO'}"
127
-
128
- if RaceSimulationOrg.insert_attempts == 2 && org.slug.start_with?("acme-corp-")
129
- puts
130
- puts "✅ PASS - Race condition handled correctly!"
131
- else
132
- puts
133
- puts "⚠️ Unexpected behavior - check debug output"
134
- end
135
- rescue => e
136
- puts
137
- puts "❌ FAIL - #{e.class}: #{e.message}"
138
- end
139
- puts
140
-
141
- # ============================================================================
142
- # Test 3: Verify non-slug unique violations still bubble up
143
- # ============================================================================
144
- puts "TEST 3: Non-slug unique violations bubble up"
145
- puts "-" * 40
146
-
147
- class NonSlugViolationOrg < Organizations::Organization
148
- self.table_name = "organizations"
149
-
150
- before_create do
151
- raise ActiveRecord::RecordNotUnique, "UNIQUE constraint failed: organizations.external_id"
152
- end
153
- end
154
-
155
- begin
156
- NonSlugViolationOrg.create!(name: "Should Fail")
157
- puts "❌ FAIL - Should have raised RecordNotUnique"
158
- rescue ActiveRecord::RecordNotUnique => e
159
- if e.message.include?("external_id")
160
- puts "✅ PASS - Non-slug violation bubbled up correctly"
161
- else
162
- puts "⚠️ Unexpected error: #{e.message}"
163
- end
164
- rescue => e
165
- puts "❌ FAIL - Wrong error type: #{e.class}"
166
- end
167
- puts
168
-
169
- # ============================================================================
170
- # Test 4: High-volume stress test with real database
171
- # ============================================================================
172
- puts "TEST 4: High-volume concurrent-like test (100 orgs, same name)"
173
- puts "-" * 40
174
-
175
- Organizations::Organization.delete_all
176
-
177
- start_time = Time.now
178
- orgs = []
179
- 100.times do |i|
180
- orgs << Organizations::Organization.create!(name: "Stress Test Corp")
181
- end
182
- elapsed = Time.now - start_time
183
-
184
- slugs = orgs.map(&:slug)
185
- unique_count = slugs.uniq.length
186
-
187
- puts "Created 100 organizations in #{(elapsed * 1000).round(2)}ms"
188
- puts "Unique slugs: #{unique_count}/100"
189
- puts "First slug: #{orgs.first.slug}"
190
- puts "Last slug: #{orgs.last.slug}"
191
- puts "Result: #{unique_count == 100 ? '✅ PASS' : '❌ FAIL'}"
192
- puts
193
-
194
- # ============================================================================
195
- # Summary
196
- # ============================================================================
197
- puts "=" * 70
198
- puts "SUMMARY"
199
- puts "=" * 70
200
- puts
201
- puts "The around_create hook in slugifiable correctly handles INSERT-time"
202
- puts "race conditions for NOT NULL slug columns."
203
- puts
204
- puts "Flow:"
205
- puts "1. before_validation: compute slug ('acme-corp')"
206
- puts "2. around_create: wrap INSERT"
207
- puts "3. INSERT fails: RecordNotUnique (slug collision)"
208
- puts "4. around_create: recompute slug ('acme-corp-123456')"
209
- puts "5. Retry INSERT: success"
210
- puts
211
- puts "This is the fix Codex added in slugifiable Round 9."
212
- puts "=" * 70
@@ -1,350 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Demo script to verify slugifiable integration with organizations gem
4
- #
5
- # This script tests:
6
- # 1. Basic slug generation from name
7
- # 2. Slug uniqueness handling
8
- # 3. Slug collision resolution with random suffixes
9
- # 4. NOT NULL constraint compatibility (before_validation hook)
10
- # 5. Race condition handling (simulated)
11
- #
12
- # Run with: bundle exec ruby test/demo_slugifiable_integration.rb
13
-
14
- require "bundler/setup"
15
- require "active_record"
16
- require "sqlite3"
17
- require "securerandom"
18
-
19
- # Setup in-memory SQLite
20
- ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
21
- ActiveRecord::Base.logger = Logger.new(nil)
22
-
23
- # Load organizations gem
24
- $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
25
- require "organizations"
26
-
27
- puts "=" * 70
28
- puts "SLUGIFIABLE INTEGRATION DEMO"
29
- puts "=" * 70
30
- puts
31
-
32
- # Create schema
33
- ActiveRecord::Schema.define do
34
- create_table :users, force: :cascade do |t|
35
- t.string :name
36
- t.string :email, null: false
37
- t.timestamps
38
- end
39
- add_index :users, :email, unique: true
40
-
41
- create_table :organizations, force: :cascade do |t|
42
- t.string :name, null: false
43
- t.string :slug, null: false # NOT NULL - must be present before INSERT
44
- t.timestamps
45
- end
46
- add_index :organizations, :slug, unique: true
47
-
48
- create_table :memberships, force: :cascade do |t|
49
- t.references :user, null: false, foreign_key: true
50
- t.references :organization, null: false, foreign_key: true
51
- t.string :role, null: false, default: "member"
52
- t.timestamps
53
- end
54
- add_index :memberships, [:user_id, :organization_id], unique: true
55
-
56
- create_table :organization_invitations, force: :cascade do |t|
57
- t.references :organization, null: false, foreign_key: true
58
- t.string :email, null: false
59
- t.string :token, null: false
60
- t.string :role, null: false, default: "member"
61
- t.datetime :accepted_at
62
- t.datetime :expires_at
63
- t.timestamps
64
- end
65
- add_index :organization_invitations, :token, unique: true
66
- end
67
-
68
- class User < ActiveRecord::Base
69
- extend Organizations::Models::Concerns::HasOrganizations::ClassMethods
70
- has_organizations
71
- validates :email, presence: true, uniqueness: true
72
- end
73
-
74
- # ============================================================================
75
- # Test 1: Basic Slug Generation
76
- # ============================================================================
77
- puts "TEST 1: Basic Slug Generation"
78
- puts "-" * 40
79
-
80
- org = Organizations::Organization.create!(name: "Acme Corporation")
81
- puts "Created: '#{org.name}'"
82
- puts "Slug: '#{org.slug}'"
83
- puts "Expected: slug should be 'acme-corporation'"
84
- puts "Result: #{org.slug == 'acme-corporation' ? '✅ PASS' : "❌ FAIL (got #{org.slug})"}"
85
- puts
86
-
87
- # ============================================================================
88
- # Test 2: Slug Uniqueness (Multiple orgs with same name)
89
- # ============================================================================
90
- puts "TEST 2: Slug Uniqueness (Collision Resolution)"
91
- puts "-" * 40
92
-
93
- Organizations::Organization.delete_all
94
-
95
- orgs = []
96
- 10.times do |i|
97
- org = Organizations::Organization.create!(name: "Test Company")
98
- orgs << org
99
- puts "Org #{i + 1}: slug = '#{org.slug}'"
100
- end
101
-
102
- slugs = orgs.map(&:slug)
103
- unique_slugs = slugs.uniq.length
104
- puts
105
- puts "Total orgs: #{orgs.length}"
106
- puts "Unique slugs: #{unique_slugs}"
107
- puts "Result: #{unique_slugs == 10 ? '✅ PASS - All slugs unique' : '❌ FAIL - Duplicate slugs found'}"
108
- puts
109
-
110
- # Verify first one has clean slug, others have suffixes
111
- puts "First org has clean slug: #{orgs.first.slug == 'test-company' ? '✅ PASS' : "❌ FAIL (got #{orgs.first.slug})"}"
112
- suffixed = orgs[1..].all? { |o| o.slug.start_with?('test-company-') }
113
- puts "Others have suffixes: #{suffixed ? '✅ PASS' : '❌ FAIL'}"
114
- puts
115
-
116
- # ============================================================================
117
- # Test 3: NOT NULL Constraint Compatibility
118
- # ============================================================================
119
- puts "TEST 3: NOT NULL Constraint (before_validation hook)"
120
- puts "-" * 40
121
-
122
- Organizations::Organization.delete_all
123
-
124
- # The schema has slug NOT NULL. Slugifiable normally uses after_create,
125
- # but organizations uses before_validation to compute slug early.
126
-
127
- begin
128
- org = Organizations::Organization.new(name: "Startup Inc")
129
- puts "Before save - slug: '#{org.slug.inspect}'"
130
- org.save!
131
- puts "After save - slug: '#{org.slug}'"
132
- puts "Result: #{org.slug.present? ? '✅ PASS - Slug computed before INSERT' : '❌ FAIL'}"
133
- rescue ActiveRecord::NotNullViolation => e
134
- puts "❌ FAIL - NOT NULL violation: #{e.message}"
135
- rescue => e
136
- puts "❌ FAIL - Unexpected error: #{e.class} - #{e.message}"
137
- end
138
- puts
139
-
140
- # ============================================================================
141
- # Test 4: Slug Generation from Slugifiable Module
142
- # ============================================================================
143
- puts "TEST 4: Slugifiable Module Integration"
144
- puts "-" * 40
145
-
146
- org = Organizations::Organization.new(name: "My Awesome Org")
147
-
148
- # Check that slugifiable methods are available
149
- has_compute_slug = org.respond_to?(:compute_slug)
150
- has_generate_unique_slug = org.respond_to?(:generate_unique_slug, true)
151
- has_generate_slug_based_on = org.respond_to?(:generate_slug_based_on)
152
-
153
- puts "Has compute_slug method: #{has_compute_slug ? '✅ YES' : '❌ NO'}"
154
- puts "Has generate_unique_slug method: #{has_generate_unique_slug ? '✅ YES' : '❌ NO'}"
155
- puts "Has generate_slug_based_on method: #{has_generate_slug_based_on ? '✅ YES' : '❌ NO'}"
156
-
157
- if has_compute_slug
158
- computed = org.compute_slug
159
- puts "compute_slug returns: '#{computed}'"
160
- puts "Result: #{computed == 'my-awesome-org' ? '✅ PASS' : "❌ FAIL (expected 'my-awesome-org')"}"
161
- end
162
- puts
163
-
164
- # ============================================================================
165
- # Test 5: High-Volume Collision Test
166
- # ============================================================================
167
- puts "TEST 5: High-Volume Collision Test (50 orgs, same name)"
168
- puts "-" * 40
169
-
170
- Organizations::Organization.delete_all
171
-
172
- start_time = Time.now
173
- orgs = []
174
- 50.times do
175
- orgs << Organizations::Organization.create!(name: "Popular Name")
176
- end
177
- elapsed = Time.now - start_time
178
-
179
- slugs = orgs.map(&:slug)
180
- unique_count = slugs.uniq.length
181
-
182
- puts "Created 50 organizations in #{(elapsed * 1000).round(2)}ms"
183
- puts "Unique slugs: #{unique_count}/50"
184
- puts "Result: #{unique_count == 50 ? '✅ PASS' : '❌ FAIL - Some slugs collided'}"
185
-
186
- # Show first few and last few slugs
187
- puts
188
- puts "Sample slugs:"
189
- puts " First: #{orgs.first.slug}"
190
- puts " #10: #{orgs[9].slug}"
191
- puts " #25: #{orgs[24].slug}"
192
- puts " Last: #{orgs.last.slug}"
193
- puts
194
-
195
- # ============================================================================
196
- # Test 6: Slug Stability (Reloading doesn't change slug)
197
- # ============================================================================
198
- puts "TEST 6: Slug Stability (Reload Test)"
199
- puts "-" * 40
200
-
201
- Organizations::Organization.delete_all
202
-
203
- org = Organizations::Organization.create!(name: "Stable Org")
204
- original_slug = org.slug
205
- puts "Original slug: '#{original_slug}'"
206
-
207
- org.reload
208
- reloaded_slug = org.slug
209
- puts "After reload: '#{reloaded_slug}'"
210
-
211
- org.touch
212
- touched_slug = org.slug
213
- puts "After touch: '#{touched_slug}'"
214
-
215
- stable = (original_slug == reloaded_slug) && (reloaded_slug == touched_slug)
216
- puts "Result: #{stable ? '✅ PASS - Slug is stable' : '❌ FAIL - Slug changed unexpectedly'}"
217
- puts
218
-
219
- # ============================================================================
220
- # Test 7: Name Update Doesn't Change Slug (by default)
221
- # ============================================================================
222
- puts "TEST 7: Name Update Behavior"
223
- puts "-" * 40
224
-
225
- Organizations::Organization.delete_all
226
-
227
- org = Organizations::Organization.create!(name: "Original Name")
228
- original_slug = org.slug
229
- puts "Created with name: '#{org.name}', slug: '#{original_slug}'"
230
-
231
- org.update!(name: "Updated Name")
232
- updated_slug = org.slug
233
- puts "Updated name to: '#{org.name}', slug: '#{updated_slug}'"
234
-
235
- # Slugifiable typically doesn't update slugs on name change (by design - URLs stay stable)
236
- puts "Result: #{original_slug == updated_slug ? '✅ PASS - Slug preserved (URL stability)' : '⚠️ NOTE - Slug changed to reflect new name'}"
237
- puts
238
-
239
- # ============================================================================
240
- # Test 8: Special Characters in Name
241
- # ============================================================================
242
- puts "TEST 8: Special Characters in Name"
243
- puts "-" * 40
244
-
245
- Organizations::Organization.delete_all
246
-
247
- test_cases = [
248
- ["Café & Bistro", "cafe-bistro"],
249
- ["100% Organic Co.", "100-organic-co"],
250
- ["日本語会社", nil], # Non-latin - depends on implementation
251
- [" Lots Of Spaces ", "lots-of-spaces"],
252
- ["UPPERCASE NAME", "uppercase-name"],
253
- ["name-with-dashes", "name-with-dashes"],
254
- ["name_with_underscores", "name-with-underscores"],
255
- ]
256
-
257
- test_cases.each do |name, expected|
258
- begin
259
- org = Organizations::Organization.create!(name: name)
260
- if expected
261
- result = org.slug == expected ? '✅' : "⚠️ got '#{org.slug}'"
262
- else
263
- result = org.slug.present? ? "✅ got '#{org.slug}'" : '❌ empty'
264
- end
265
- puts " '#{name}' → '#{org.slug}' #{result}"
266
- rescue => e
267
- puts " '#{name}' → ❌ ERROR: #{e.message}"
268
- end
269
- end
270
- puts
271
-
272
- # ============================================================================
273
- # Test 9: Simulated Race Condition
274
- # ============================================================================
275
- puts "TEST 9: Simulated Race Condition"
276
- puts "-" * 40
277
- puts "(Testing that slugifiable handles RecordNotUnique gracefully)"
278
-
279
- Organizations::Organization.delete_all
280
-
281
- # Create an org with a specific slug
282
- existing = Organizations::Organization.create!(name: "Race Test")
283
- existing_slug = existing.slug
284
- puts "Existing org slug: '#{existing_slug}'"
285
-
286
- # Now simulate what happens if two processes try to create the same slug simultaneously
287
- # We can't truly simulate threading in this demo, but we can verify the retry mechanism
288
- # exists in slugifiable
289
-
290
- # Check if slugifiable has the retry mechanism
291
- org_instance = Organizations::Organization.new(name: "Test")
292
- has_retry = org_instance.respond_to?(:set_slug_with_retry, true)
293
- puts "Has set_slug_with_retry: #{has_retry ? '✅ YES' : '❌ NO'}"
294
-
295
- if has_retry
296
- puts "Result: ✅ PASS - Race condition handling is available"
297
- else
298
- puts "Result: ⚠️ NOTE - set_slug_with_retry not found (may be in older slugifiable version)"
299
- end
300
- puts
301
-
302
- # ============================================================================
303
- # Summary
304
- # ============================================================================
305
- puts "=" * 70
306
- puts "SUMMARY"
307
- puts "=" * 70
308
-
309
- # Count passing tests
310
- Organizations::Organization.delete_all
311
-
312
- tests_passed = 0
313
- tests_total = 9
314
-
315
- # Re-run quick checks
316
- org1 = Organizations::Organization.create!(name: "Summary Test")
317
- tests_passed += 1 if org1.slug == "summary-test"
318
-
319
- org2 = Organizations::Organization.create!(name: "Summary Test")
320
- tests_passed += 1 if org2.slug != org1.slug
321
-
322
- tests_passed += 1 if org1.slug.present? # NOT NULL works
323
-
324
- tests_passed += 1 if org1.respond_to?(:compute_slug)
325
-
326
- 50.times { Organizations::Organization.create!(name: "Bulk") }
327
- tests_passed += 1 if Organizations::Organization.where("slug LIKE 'bulk%'").count == 50
328
-
329
- org1.reload
330
- tests_passed += 1 if org1.slug == "summary-test"
331
-
332
- original = org1.slug
333
- org1.update!(name: "Changed")
334
- tests_passed += 1 if org1.slug == original # Slug stability
335
-
336
- org3 = Organizations::Organization.create!(name: "Café Test")
337
- tests_passed += 1 if org3.slug.present?
338
-
339
- tests_passed += 1 if org1.respond_to?(:set_slug_with_retry, true)
340
-
341
- puts
342
- puts "Tests Passed: #{tests_passed}/#{tests_total}"
343
- puts
344
- if tests_passed == tests_total
345
- puts "🎉 ALL TESTS PASSED - Slugifiable integration is working correctly!"
346
- else
347
- puts "⚠️ Some tests need attention - see details above"
348
- end
349
- puts
350
- puts "=" * 70