organizations 0.1.0 → 0.2.0

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: bc99dabb60fe5f7d0c4ae8567fd62c4920f4a403b804dfe4e8dcac4d3c66fa11
4
+ data.tar.gz: '024976f0f5cd19fd8530179977a34e21d7ca54968bbf62d50eeff0d5f70dc1ea'
5
5
  SHA512:
6
- metadata.gz: 181defb79c40e269ea86b28d3f6d6d262d3834eaf31674ab66b33a807e685bec9ef085b3251490a26b0c02c870b680b803f020341c7555cebee261fd1984a0e9
7
- data.tar.gz: 34e5d034196756c2bf436341766a1889b9c665e7a42c70a7a7ad5ba52863da56a0a845d4842b171cd4fbf6354a8be44cbfef476716f6e8c78c665e6e403ed563
6
+ metadata.gz: b14290ba7ecfc72544a5b4553fc5fd633ab51af9bcf1e3e1ed2bd90769ce2099a658f815191f1cf8c5e4706978d0070b80654e6b6da978b636ba6a87e60e11c9
7
+ data.tar.gz: 07bb21cfe8a870b8f672069ac7cb3f9512dad17ad1ea472b4cc34db9eaaf292773f05c6eb105dc5e220dc3b4b2139bfcdee1d097201774ca92cb79356373c8a3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [0.2.0] - 2026-02-20
2
+
3
+ - Namespaced all tables with `organizations_` prefix to prevent collisions with host apps
4
+
5
+ ## [0.1.1] - 2026-02-19
6
+
7
+ - Removed `slugifiable` dependency (deferred to host app)
8
+
1
9
  ## [0.1.0] - 2026-02-19
2
10
 
3
11
  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:
@@ -1065,9 +1064,9 @@ The gem provides `Organizations::Organization` as the base model. You can extend
1065
1064
  # db/migrate/xxx_add_custom_fields_to_organizations.rb
1066
1065
  class AddCustomFieldsToOrganizations < ActiveRecord::Migration[8.0]
1067
1066
  def change
1068
- add_column :organizations, :support_email, :string
1069
- add_column :organizations, :billing_address, :text
1070
- add_column :organizations, :settings, :jsonb, default: {}
1067
+ add_column :organizations_organizations, :support_email, :string
1068
+ add_column :organizations_organizations, :billing_address, :text
1069
+ add_column :organizations_organizations, :settings, :jsonb, default: {}
1071
1070
  end
1072
1071
  end
1073
1072
  ```
@@ -1111,24 +1110,23 @@ This is standard Rails practice — the gem provides the foundation (memberships
1111
1110
 
1112
1111
  The gem creates three tables:
1113
1112
 
1114
- ### organizations
1113
+ ### organizations_organizations
1115
1114
 
1116
1115
  ```sql
1117
- organizations
1116
+ organizations_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
- ### memberships
1126
+ ### organizations_memberships
1129
1127
 
1130
1128
  ```sql
1131
- memberships
1129
+ organizations_memberships
1132
1130
  - id (primary key)
1133
1131
  - user_id (foreign key, indexed)
1134
1132
  - organization_id (foreign key, indexed)
@@ -1140,10 +1138,10 @@ memberships
1140
1138
  unique index: [user_id, organization_id]
1141
1139
  ```
1142
1140
 
1143
- ### organization_invitations
1141
+ ### organizations_invitations
1144
1142
 
1145
1143
  ```sql
1146
- organization_invitations
1144
+ organizations_invitations
1147
1145
  - id (primary key)
1148
1146
  - organization_id (foreign key, indexed)
1149
1147
  - email (string, required, indexed)
@@ -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
@@ -1257,7 +1254,7 @@ If you display member counts frequently (pricing pages, org listings), consider
1257
1254
 
1258
1255
  ```ruby
1259
1256
  # In a migration
1260
- add_column :organizations, :memberships_count, :integer, default: 0, null: false
1257
+ add_column :organizations_organizations, :memberships_count, :integer, default: 0, null: false
1261
1258
 
1262
1259
  # Reset existing counts
1263
1260
  Organization.find_each do |org|
@@ -1278,9 +1275,9 @@ org.member_count
1278
1275
  Boolean checks use efficient SQL `EXISTS` queries:
1279
1276
 
1280
1277
  ```ruby
1281
- user.belongs_to_any_organization? # SELECT 1 FROM memberships WHERE ... LIMIT 1
1282
- user.has_pending_organization_invitations? # SELECT 1 FROM organization_invitations WHERE ... LIMIT 1
1283
- org.has_any_members? # SELECT 1 FROM memberships WHERE ... LIMIT 1
1278
+ user.belongs_to_any_organization? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
1279
+ user.has_pending_organization_invitations? # SELECT 1 FROM organizations_invitations WHERE ... LIMIT 1
1280
+ org.has_any_members? # SELECT 1 FROM organizations_memberships WHERE ... LIMIT 1
1284
1281
  ```
1285
1282
 
1286
1283
  ### Scoped associations use JOINs
@@ -1290,13 +1287,13 @@ Methods like `org.admins` and `user.owned_organizations` use proper SQL JOINs:
1290
1287
  ```ruby
1291
1288
  org.admins
1292
1289
  # SELECT users.* FROM users
1293
- # INNER JOIN memberships ON memberships.user_id = users.id
1294
- # WHERE memberships.organization_id = ? AND memberships.role IN ('admin', 'owner')
1290
+ # INNER JOIN organizations_memberships ON organizations_memberships.user_id = users.id
1291
+ # WHERE organizations_memberships.organization_id = ? AND organizations_memberships.role IN ('admin', 'owner')
1295
1292
 
1296
1293
  user.owned_organizations
1297
- # SELECT organizations.* FROM organizations
1298
- # INNER JOIN memberships ON memberships.organization_id = organizations.id
1299
- # WHERE memberships.user_id = ? AND memberships.role = 'owner'
1294
+ # SELECT organizations_organizations.* FROM organizations_organizations
1295
+ # INNER JOIN organizations_memberships ON organizations_memberships.organization_id = organizations_organizations.id
1296
+ # WHERE organizations_memberships.user_id = ? AND organizations_memberships.role = 'owner'
1300
1297
  ```
1301
1298
 
1302
1299
  ### Current organization memoization
@@ -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
@@ -1453,17 +1445,14 @@ The gem creates these indexes automatically:
1453
1445
 
1454
1446
  ```sql
1455
1447
  -- Fast membership lookups
1456
- CREATE UNIQUE INDEX index_memberships_on_user_and_org ON memberships (user_id, organization_id);
1457
- CREATE INDEX index_memberships_on_organization_id ON memberships (organization_id);
1458
- CREATE INDEX index_memberships_on_role ON memberships (role);
1448
+ CREATE UNIQUE INDEX index_organizations_memberships_on_user_and_org ON organizations_memberships (user_id, organization_id);
1449
+ CREATE INDEX index_organizations_memberships_on_organization_id ON organizations_memberships (organization_id);
1450
+ CREATE INDEX index_organizations_memberships_on_role ON organizations_memberships (role);
1459
1451
 
1460
1452
  -- Fast invitation lookups
1461
- CREATE UNIQUE INDEX index_invitations_on_token ON organization_invitations (token);
1462
- CREATE INDEX index_invitations_on_email ON organization_invitations (email);
1463
- 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);
1453
+ CREATE UNIQUE INDEX index_organizations_invitations_on_token ON organizations_invitations (token);
1454
+ CREATE INDEX index_organizations_invitations_on_email ON organizations_invitations (email);
1455
+ CREATE UNIQUE INDEX index_organizations_invitations_pending ON organizations_invitations (organization_id, LOWER(email)) WHERE accepted_at IS NULL;
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: "../"
@@ -6,20 +6,17 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
6
6
  adapter = connection.adapter_name.downcase
7
7
 
8
8
  # Organizations table
9
- create_table :organizations, id: primary_key_type do |t|
9
+ create_table :organizations_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
- create_table :memberships, id: primary_key_type do |t|
17
+ create_table :organizations_memberships, id: primary_key_type do |t|
21
18
  t.references :user, null: false, type: foreign_key_type, foreign_key: true
22
- t.references :organization, null: false, type: foreign_key_type, foreign_key: true
19
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
23
20
  t.references :invited_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
24
21
  t.string :role, null: false, default: "member"
25
22
  t.send(json_column_type, :metadata, null: json_column_null, default: json_column_default)
@@ -27,27 +24,22 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
27
24
  t.timestamps
28
25
  end
29
26
 
30
- add_index :memberships, [:user_id, :organization_id], unique: true
31
- add_index :memberships, :role
27
+ add_index :organizations_memberships, [:user_id, :organization_id], unique: true
28
+ add_index :organizations_memberships, :role
32
29
 
33
30
  # Enforce "at most one owner membership per organization" at DB level where possible.
34
- if adapter.include?("postgresql")
35
- execute <<-SQL
36
- CREATE UNIQUE INDEX index_memberships_single_owner
37
- ON memberships (organization_id)
38
- WHERE role = 'owner'
39
- SQL
40
- elsif adapter.include?("sqlite")
31
+ # Both PostgreSQL and SQLite support partial indexes with identical syntax.
32
+ if adapter.include?("postgresql") || adapter.include?("sqlite")
41
33
  execute <<-SQL
42
- CREATE UNIQUE INDEX index_memberships_single_owner
43
- ON memberships (organization_id)
34
+ CREATE UNIQUE INDEX index_organizations_memberships_single_owner
35
+ ON organizations_memberships (organization_id)
44
36
  WHERE role = 'owner'
45
37
  SQL
46
38
  end
47
39
 
48
40
  # Invitations table
49
- create_table :organization_invitations, id: primary_key_type do |t|
50
- t.references :organization, null: false, type: foreign_key_type, foreign_key: true
41
+ create_table :organizations_invitations, id: primary_key_type do |t|
42
+ t.references :organization, null: false, type: foreign_key_type, foreign_key: { to_table: :organizations_organizations }
51
43
  # invited_by is nullable to support dependent: :nullify when user is deleted
52
44
  t.references :invited_by, null: true, type: foreign_key_type, foreign_key: { to_table: :users }
53
45
  t.string :email, null: false
@@ -59,29 +51,22 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
59
51
  t.timestamps
60
52
  end
61
53
 
62
- add_index :organization_invitations, :token, unique: true
63
- add_index :organization_invitations, :email
54
+ add_index :organizations_invitations, :token, unique: true
55
+ add_index :organizations_invitations, :email
64
56
 
65
57
  # Unique partial index: only one pending (non-accepted) invitation per email per org
66
- # Both PostgreSQL and SQLite support partial indexes
67
- if adapter.include?("postgresql")
68
- execute <<-SQL
69
- CREATE UNIQUE INDEX index_invitations_pending_unique
70
- ON organization_invitations (organization_id, LOWER(email))
71
- WHERE accepted_at IS NULL
72
- SQL
73
- elsif adapter.include?("sqlite")
74
- # SQLite supports partial indexes since 3.8.0
58
+ # Both PostgreSQL and SQLite (3.8.0+) support partial indexes with identical syntax.
59
+ if adapter.include?("postgresql") || adapter.include?("sqlite")
75
60
  execute <<-SQL
76
- CREATE UNIQUE INDEX index_invitations_pending_unique
77
- ON organization_invitations (organization_id, LOWER(email))
61
+ CREATE UNIQUE INDEX index_organizations_invitations_pending_unique
62
+ ON organizations_invitations (organization_id, LOWER(email))
78
63
  WHERE accepted_at IS NULL
79
64
  SQL
80
65
  elsif adapter.include?("mysql")
81
66
  # MySQL doesn't support partial indexes, so we use a generated column that is
82
67
  # only non-NULL for pending invitations and enforce uniqueness on that value.
83
68
  execute <<-SQL
84
- ALTER TABLE organization_invitations
69
+ ALTER TABLE organizations_invitations
85
70
  ADD COLUMN pending_email VARCHAR(255)
86
71
  GENERATED ALWAYS AS (
87
72
  CASE
@@ -91,10 +76,10 @@ class CreateOrganizationsTables < ActiveRecord::Migration<%= migration_version %
91
76
  ) STORED
92
77
  SQL
93
78
 
94
- add_index :organization_invitations, [:organization_id, :pending_email], unique: true, name: "index_invitations_pending_unique"
79
+ add_index :organizations_invitations, [:organization_id, :pending_email], unique: true, name: "index_organizations_invitations_pending_unique"
95
80
  else
96
81
  # For other adapters, fall back to app-level validation.
97
- add_index :organization_invitations, [:organization_id, :email], name: "index_invitations_on_org_and_email"
82
+ add_index :organizations_invitations, [:organization_id, :email], name: "index_organizations_invitations_on_org_and_email"
98
83
  end
99
84
  end
100
85
 
@@ -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
@@ -74,7 +74,7 @@ module Organizations
74
74
 
75
75
  # Organizations where user is owner (efficient JOIN)
76
76
  has_many :owned_organizations,
77
- -> { where(memberships: { role: "owner" }) },
77
+ -> { where(organizations_memberships: { role: "owner" }) },
78
78
  through: :memberships,
79
79
  source: :organization,
80
80
  class_name: "Organizations::Organization"
@@ -18,7 +18,7 @@ module Organizations
18
18
  # invitation.expired? # => false
19
19
  #
20
20
  class Invitation < ActiveRecord::Base
21
- self.table_name = "organization_invitations"
21
+ self.table_name = "organizations_invitations"
22
22
 
23
23
  # === Associations ===
24
24
 
@@ -16,7 +16,7 @@ module Organizations
16
16
  # membership.demote_to!(:member)
17
17
  #
18
18
  class Membership < ActiveRecord::Base
19
- self.table_name = "memberships"
19
+ self.table_name = "organizations_memberships"
20
20
 
21
21
  # Error raised when trying to demote below current role
22
22
  class CannotDemoteOwner < Organizations::Error; 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.
@@ -18,12 +16,7 @@ module Organizations
18
16
  # org.member_count # => 5
19
17
  #
20
18
  class Organization < ActiveRecord::Base
21
- 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
19
+ self.table_name = "organizations_organizations"
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
 
@@ -69,7 +54,7 @@ module Organizations
69
54
  # @param user [User] The user
70
55
  # @return [ActiveRecord::Relation]
71
56
  scope :with_member, ->(user) {
72
- joins(:memberships).where(memberships: { user_id: user.id })
57
+ joins(:memberships).where(organizations_memberships: { user_id: user.id })
73
58
  }
74
59
 
75
60
  # === Member Query Methods ===
@@ -90,7 +75,7 @@ module Organizations
90
75
  # Uses efficient JOIN query to avoid N+1
91
76
  # @return [ActiveRecord::Relation<User>]
92
77
  def admins
93
- users.where(memberships: { role: %w[owner admin] }).distinct
78
+ users.where(organizations_memberships: { role: %w[owner admin] }).distinct
94
79
  end
95
80
 
96
81
  # Alias for users (semantic convenience)
@@ -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.2.0"
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
  # # }
@@ -258,8 +258,8 @@ module Organizations
258
258
  memberships = user.memberships
259
259
  .includes(:organization)
260
260
  .joins(:organization)
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")
261
+ .select("organizations_memberships.id, organizations_memberships.organization_id, organizations_memberships.role, " \
262
+ "organizations_organizations.id AS org_id, organizations_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,13 +1,13 @@
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-02-19 00:00:00.000000000 Z
10
+ date: 2026-02-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -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