active_record-json_associations 0.13.0 → 1.0.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: c6e92de811720d3a6f9ed242d374f6f4b6e604e09977476f619355918c22b6ea
4
- data.tar.gz: 31e0f02c6b52dbc6994d3d4c2ae7016287a2d2488ad1371ddac6ba65bf9598d8
3
+ metadata.gz: 0425a5d0c7935d72007daa4961178471c58a88dac30522a9137e89923f158dbd
4
+ data.tar.gz: cfbc21cae4d8655ad0c62c2a73c95715d6207f882e3de6433dc079abc3eff9d9
5
5
  SHA512:
6
- metadata.gz: 1773bd6dd9c36865e772b81c6e0c7ac9ece43028cdc1c5e254047dcc5349bb0cc4a8073b30de3cd114bed20be88ab30495b6f5b2f9a241caa78f9fd8c1a67908
7
- data.tar.gz: 4312ec59acc30da426d32c53dea432bcdd2064a21fea69faa826f6dd1347887668e6fc7280be9a107973ad9ac6f19745e43df382c573831b71623ed3f8889a32
6
+ metadata.gz: 15b8f1ea8aebceea06a143635cfb8242fec793f319196d1af11e90a5dbc77bbc68e43d08ff6e9c893d94f79180bb9d1f11db2bf8c4a6f3048e2c870a9f70cffc
7
+ data.tar.gz: 2862a70dfb5c0db59dbf30facb73f0c5d810e7c4c00404c5c5eb74daf7aa6c1cacdca86560db5727b1c7e6537f3d0d3bdea2515837c4af0ee3a18b4a5b680fb4
@@ -1,21 +1,57 @@
1
1
  name: CI
2
+
2
3
  on: [push, pull_request]
4
+
3
5
  jobs:
4
6
  test:
7
+ runs-on: ubuntu-latest
5
8
  strategy:
6
9
  fail-fast: false
7
10
  matrix:
8
- gemfile: [ rails_7.0, rails_7.1, rails_7.2 ]
9
- ruby: [ 3.1, 3.2, 3.3 ]
10
-
11
- runs-on: ubuntu-latest
12
- env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps
13
- BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile
11
+ ruby: ["3.2", "3.3", "3.4", "4.0"]
12
+ gemfile:
13
+ - gemfiles/rails_7.2.gemfile
14
+ - gemfiles/rails_8.0.gemfile
15
+ - gemfiles/rails_8.1.gemfile
16
+ database: [sqlite, postgres, mysql]
17
+ services:
18
+ postgres:
19
+ image: postgres:16
20
+ env:
21
+ POSTGRES_PASSWORD: postgres
22
+ options: >-
23
+ --health-cmd pg_isready
24
+ --health-interval 10s
25
+ --health-timeout 5s
26
+ --health-retries 5
27
+ ports:
28
+ - 5432:5432
29
+ mysql:
30
+ image: mysql:8
31
+ env:
32
+ MYSQL_ALLOW_EMPTY_PASSWORD: yes
33
+ options: >-
34
+ --health-cmd "mysqladmin ping"
35
+ --health-interval 10s
36
+ --health-timeout 5s
37
+ --health-retries 5
38
+ ports:
39
+ - 3306:3306
40
+ env:
41
+ BUNDLE_GEMFILE: ${{ matrix.gemfile }}
14
42
  steps:
15
- - uses: actions/checkout@v2
43
+ - uses: actions/checkout@v4
16
44
  - uses: ruby/setup-ruby@v1
17
45
  with:
18
46
  ruby-version: ${{ matrix.ruby }}
19
- bundler-cache: true # runs 'bundle install' and caches installed gems automatically
20
- - run: bundle exec rake
21
-
47
+ bundler-cache: true
48
+ - name: Create test database
49
+ if: matrix.database == 'postgres'
50
+ run: PGPASSWORD=postgres createdb -h localhost -U postgres test
51
+ - name: Create test database
52
+ if: matrix.database == 'mysql'
53
+ run: mysql -h 127.0.0.1 -u root -e "CREATE DATABASE test"
54
+ - name: Run tests
55
+ run: bundle exec rake
56
+ env:
57
+ DATABASE_URL: ${{ matrix.database == 'postgres' && 'postgres://postgres:postgres@localhost:5432/test' || matrix.database == 'mysql' && 'trilogy://root@127.0.0.1:3306/test' || 'sqlite3::memory:' }}
data/Appraisals CHANGED
@@ -1,13 +1,11 @@
1
- appraise "rails-7.0" do
2
- gem "rails", "~>7.0.0"
3
- gem "sqlite3", "~>1.0"
1
+ appraise "rails-7.2" do
2
+ gem "rails", "~>7.2.0"
4
3
  end
5
4
 
6
- appraise "rails-7.1" do
7
- gem "rails", "~>7.1.0"
5
+ appraise "rails-8.0" do
6
+ gem "rails", "~>8.0.0"
8
7
  end
9
8
 
10
- appraise "rails-7.2" do
11
- gem "rails", "~>7.2.0"
9
+ appraise "rails-8.1" do
10
+ gem "rails", "~>8.1.0"
12
11
  end
13
-
data/CLAUDE.md ADDED
@@ -0,0 +1,38 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## About
6
+
7
+ ActiveRecord::JsonAssociations is a Ruby gem that provides an alternative to traditional many-to-many join tables by storing foreign keys in a JSON array on the parent record.
8
+
9
+ ## Development Commands
10
+
11
+ ```bash
12
+ # Run all tests
13
+ bundle exec rake
14
+
15
+ # Run a single spec file
16
+ bundle exec rspec spec/belongs_to_many_spec.rb
17
+
18
+ # Run a specific test by line number
19
+ bundle exec rspec spec/belongs_to_many_spec.rb:42
20
+
21
+ # Test against specific Rails versions using Appraisal
22
+ BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake
23
+ BUNDLE_GEMFILE=gemfiles/rails_8.0.gemfile bundle exec rake
24
+ ```
25
+
26
+ ## Architecture
27
+
28
+ The gem extends `ActiveRecord::Base` with a single module (`ActiveRecord::JsonAssociations`) that provides two main class methods:
29
+
30
+ - **`belongs_to_many`** - Stores foreign keys as a JSON array in a text/json column on the parent model. Provides `children`, `children=`, `child_ids`, `child_ids=`, `children?` methods plus a `child_ids_including` scope for querying.
31
+
32
+ - **`has_many :json_foreign_key`** - The inverse relationship. When a child model uses this option, it can find parents that reference it via their JSON arrays. Also provides `build_parent`, `create_parent`, `create_parent!` builder methods.
33
+
34
+ Both methods support native JSON columns (using `JSON_CONTAINS`) or text columns (using `LIKE` queries with serialized JSON).
35
+
36
+ ## Testing
37
+
38
+ Tests use RSpec with an in-memory SQLite database. Each spec file sets up its own schema and model classes. Use `focus: true` on individual specs during development.
data/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # ActiveRecord::JsonAssociations
2
2
 
3
3
  [![CI Status](https://github.com/botandrose/active_record-json_associations/actions/workflows/ci.yml/badge.svg)](https://github.com/botandrose/active_record-json_associations/actions/workflows/ci.yml)
4
- [![Code Climate](https://codeclimate.com/github/botandrose/active_record-json_associations/badges/gpa.svg)](https://codeclimate.com/github/botandrose/active_record-json_associations)
5
4
 
6
5
  Instead of keeping the foreign keys on the children, or in a many-to-many join table, let's keep them in a JSON array on the parent.
7
6
 
@@ -12,7 +11,7 @@ require "active_record/json_associations"
12
11
 
13
12
  ActiveRecord::Schema.define do
14
13
  create_table :parents do |t|
15
- t.text :child_ids
14
+ t.json :child_ids, default: []
16
15
  end
17
16
 
18
17
  create_table :children
@@ -23,6 +22,8 @@ class Parent < ActiveRecord::Base
23
22
  end
24
23
  ```
25
24
 
25
+ **Note:** The `child_ids` column must be a native JSON type. Text columns are not supported.
26
+
26
27
  This will add some familiar `has_many`-style methods:
27
28
 
28
29
  ```ruby
@@ -37,16 +38,16 @@ parent.child_ids #=> [1,2]
37
38
  parent.children? #=> true
38
39
  ```
39
40
 
40
- And a scope method for finding records assocatied with an id:
41
+ And a scope method for finding records associated with an id:
41
42
 
42
43
  ```ruby
43
44
  Parent.child_ids_including(2) # => [<Parent child_ids: [1,2,3]>]
44
45
  ```
45
46
 
46
- Or any of specified array of ids:
47
+ Or any of a specified array of ids:
47
48
 
48
49
  ```ruby
49
- Parent.child_ids_including([2,4,5]) # => [<Parent child_ids: [1,2,3]>]
50
+ Parent.child_ids_including(any: [2,4,5]) # => [<Parent child_ids: [1,2,3]>]
50
51
  ```
51
52
 
52
53
  `touch: true` can be specified on belongs_to_many to touch the associated records' timestamps when the record is modified.
@@ -77,7 +78,9 @@ child.build_parent(name: "Momma")
77
78
 
78
79
  ## Requirements
79
80
 
80
- * ActiveRecord 5.0+
81
+ * Ruby 3.2+
82
+ * ActiveRecord 7.2+
83
+ * Database with JSON column support (MySQL, PostgreSQL, SQLite 3.9+)
81
84
 
82
85
  ## Contributing
83
86
 
@@ -12,6 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = %q{Instead of a many-to-many join table, serialize the ids into a JSON array.}
13
13
  spec.homepage = "https://github.com/botandrose/active_record-json_associations"
14
14
  spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2"
15
16
 
16
17
  spec.files = `git ls-files -z`.split("\x0")
17
18
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
@@ -25,6 +26,8 @@ Gem::Specification.new do |spec|
25
26
  spec.add_development_dependency "rake"
26
27
  spec.add_development_dependency "rspec"
27
28
  spec.add_development_dependency "sqlite3"
29
+ spec.add_development_dependency "pg"
30
+ spec.add_development_dependency "trilogy"
28
31
  spec.add_development_dependency "byebug"
29
32
  spec.add_development_dependency "timecop"
30
33
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "rails", "~>7.1.0"
5
+ gem "rails", "~>8.0.0"
6
6
 
7
7
  gemspec path: "../"
@@ -2,7 +2,6 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- gem "rails", "~>7.0.0"
6
- gem "sqlite3", "~>1.0"
5
+ gem "rails", "~>8.1.0"
7
6
 
8
7
  gemspec path: "../"
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord
2
2
  module JsonAssociations
3
- VERSION = "0.13.0"
3
+ VERSION = "1.0.0"
4
4
  end
5
5
  end
@@ -1,18 +1,44 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_record"
2
4
  require "json"
3
5
 
4
6
  module ActiveRecord
5
7
  module JsonAssociations
8
+ ORDER_BY_IDS_PROC = proc do |scope, ids|
9
+ if ids.empty?
10
+ scope
11
+ else
12
+ pk = scope.klass.primary_key
13
+ quoted_ids = ids.map { |id| scope.connection.quote(id) }
14
+ order_sql = case scope.connection.adapter_name
15
+ when "Mysql2", "Trilogy"
16
+ "FIELD(#{pk}, #{quoted_ids.join(",")})"
17
+ when "PostgreSQL"
18
+ "array_position(ARRAY[#{quoted_ids.join(",")}], #{pk})"
19
+ else
20
+ fragments = ids.each_with_index.map { |id, i| "WHEN #{quoted_ids[i]} THEN #{i}" }
21
+ "CASE #{pk} #{fragments.join(" ")} END"
22
+ end
23
+ scope.order!(Arel.sql(order_sql))
24
+ end
25
+ end
26
+ private_constant :ORDER_BY_IDS_PROC
27
+
6
28
  FIELD_INCLUDE_SCOPE_BUILDER_PROC = proc do |context, field, id|
7
- using_json = context.columns_hash[field.to_s].type == :json
29
+ unless context.columns_hash[field.to_s].type == :json
30
+ raise ArgumentError, "#{field} column must be of type :json"
31
+ end
32
+
33
+ sanitized_id = id.to_i
8
34
 
9
- if using_json
10
- context.where("JSON_CONTAINS(#{field}, ?, '$')", id.to_json)
35
+ case context.connection.adapter_name
36
+ when "Mysql2", "Trilogy"
37
+ context.where("JSON_CONTAINS(#{field}, ?, '$')", sanitized_id.to_json)
38
+ when "PostgreSQL"
39
+ context.where("#{field}::jsonb @> ?::jsonb", [sanitized_id].to_json)
11
40
  else
12
- context.where("#{field}='[#{id}]'").or(
13
- context.where("#{field} LIKE '[#{id},%'")).or(
14
- context.where("#{field} LIKE '%,#{id},%'")).or(
15
- context.where("#{field} LIKE '%,#{id}]'"))
41
+ context.where("EXISTS (SELECT 1 FROM json_each(#{field}) WHERE value = ?)", sanitized_id)
16
42
  end
17
43
  end
18
44
  private_constant :FIELD_INCLUDE_SCOPE_BUILDER_PROC
@@ -26,34 +52,16 @@ module ActiveRecord
26
52
 
27
53
  class_name ||= one.classify
28
54
 
29
- using_json = columns_hash[one_ids.to_s].type == :json
30
-
31
- if !using_json
32
- if ActiveRecord.version >= Gem::Version.new("7.1")
33
- serialize one_ids, coder: JSON
34
- else
35
- serialize one_ids, JSON
36
- end
55
+ unless columns_hash[one_ids.to_s].type == :json
56
+ raise ArgumentError, "#{one_ids} column must be of type :json"
37
57
  end
38
58
 
39
59
  if touch
40
60
  after_commit do
41
61
  unless no_touching?
42
- method = respond_to?(:saved_changes) ? :saved_changes : :previous_changes
43
- old_ids, new_ids = send(method)[one_ids.to_s]
62
+ old_ids, new_ids = saved_changes[one_ids.to_s]
44
63
  ids = Array(send(one_ids)) | Array(old_ids) | Array(new_ids)
45
- scope = class_name.constantize.where(self.class.primary_key => ids)
46
-
47
- if scope.respond_to?(:touch) # AR 6.0+
48
- scope.touch_all
49
- elsif self.class.respond_to?(:touch_attributes_with_time) # AR 5.1+
50
- scope.update_all self.class.touch_attributes_with_time
51
- else # AR 5.0
52
- attributes = timestamp_attributes_for_update_in_model.inject({}) do |attributes, key|
53
- attributes.merge(key => current_time_from_proper_timezone)
54
- end
55
- scope.update_all attributes
56
- end
64
+ class_name.constantize.where(self.class.primary_key => ids).touch_all
57
65
  end
58
66
  end
59
67
  end
@@ -79,21 +87,14 @@ module ActiveRecord
79
87
  end
80
88
 
81
89
  define_method one_ids_equals do |ids|
82
- super Array(ids).select(&:present?).map(&:to_i)
90
+ super Array(ids).select(&:present?).map(&:to_i).uniq
83
91
  end
84
92
 
85
93
  define_method many do
86
94
  klass = class_name.constantize
87
- scope = klass.all
88
-
89
- ids = send(one_ids)
90
- scope.where!(klass.primary_key => ids)
91
-
92
- fragments = []
93
- fragments += ["#{klass.primary_key} NOT IN (#{ids.map(&:to_s).join(",")})"] if ids.any?
94
- fragments += ids.reverse.map { |id| "#{klass.primary_key}=#{id}" }
95
- order_by_ids = fragments.join(", ")
96
- scope.order!(Arel.sql(order_by_ids))
95
+ ids = send(one_ids).map(&:to_i)
96
+ scope = klass.where(klass.primary_key => ids)
97
+ ORDER_BY_IDS_PROC.call(scope, ids)
97
98
  end
98
99
 
99
100
  define_method many_equals do |collection|
@@ -126,30 +127,43 @@ module ActiveRecord
126
127
  create_one_bang = :"create_#{one}!"
127
128
 
128
129
  class_name = options[:class_name] || one.classify
129
- klass = class_name.constantize
130
130
 
131
131
  foreign_key = options[:json_foreign_key]
132
132
  foreign_key = :"#{model_name.singular}_ids" if foreign_key == true
133
133
 
134
+ pending_associations_var = :"@pending_#{many}"
135
+
136
+ after_create do
137
+ if (pending = instance_variable_get(pending_associations_var))
138
+ instance_variable_set(pending_associations_var, nil)
139
+ send(many_equals, pending)
140
+ end
141
+ end
142
+
134
143
  include Module.new {
135
144
  define_method one_ids do
136
145
  send(many).pluck(:id)
137
146
  end
138
147
 
139
148
  define_method one_ids_equals do |ids|
140
- normalized_ids = Array(ids).select(&:present?).map(&:to_i)
149
+ klass = class_name.constantize
150
+ normalized_ids = Array(ids).select(&:present?).map(&:to_i).uniq
141
151
  send many_equals, klass.find(normalized_ids)
142
152
  end
143
153
 
144
154
  define_method many do
155
+ klass = class_name.constantize
145
156
  FIELD_INCLUDE_SCOPE_BUILDER_PROC.call(klass, foreign_key, id)
146
157
  end
147
158
 
148
159
  define_method many_equals do |collection|
149
- collection.each do |record|
150
- new_id_array = Array(record.send(foreign_key)) | [id]
151
- raise "FIXME: Cannot assign during creation, because no id has yet been reified." if new_id_array.any?(&:nil?)
152
- record.update foreign_key => new_id_array
160
+ if new_record?
161
+ instance_variable_set(pending_associations_var, collection)
162
+ else
163
+ collection.each do |record|
164
+ new_id_array = Array(record.send(foreign_key)) | [id]
165
+ record.update foreign_key => new_id_array
166
+ end
153
167
  end
154
168
  end
155
169
 
@@ -158,14 +172,17 @@ module ActiveRecord
158
172
  end
159
173
 
160
174
  define_method build_one do |attributes={}|
175
+ klass = class_name.constantize
161
176
  klass.new attributes.merge!(foreign_key => [id])
162
177
  end
163
178
 
164
179
  define_method create_one do |attributes={}|
180
+ klass = class_name.constantize
165
181
  klass.create attributes.merge!(foreign_key => [id])
166
182
  end
167
183
 
168
184
  define_method create_one_bang do |attributes={}|
185
+ klass = class_name.constantize
169
186
  klass.create! attributes.merge!(foreign_key => [id])
170
187
  end
171
188
  }
@@ -2,22 +2,22 @@ require "active_record/json_associations"
2
2
 
3
3
  describe ActiveRecord::JsonAssociations do
4
4
  before do
5
- ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
5
+ ActiveRecord::Base.establish_connection database_config
6
6
 
7
7
  silence_stream(STDOUT) do
8
8
  ActiveRecord::Schema.define do
9
- create_table :parents do |t|
9
+ create_table :parents, force: true do |t|
10
10
  t.string :name
11
- t.text :child_ids
12
- t.text :fuzzy_ids
11
+ t.json :child_ids
12
+ t.json :fuzzy_ids
13
13
  t.timestamps
14
14
  end
15
15
 
16
- create_table :children do |t|
16
+ create_table :children, force: true do |t|
17
17
  t.timestamps
18
18
  end
19
19
 
20
- create_table :pets do |t|
20
+ create_table :pets, force: true do |t|
21
21
  t.timestamps
22
22
  end
23
23
  end
@@ -274,216 +274,4 @@ describe ActiveRecord::JsonAssociations do
274
274
  end
275
275
  end
276
276
  end
277
-
278
- describe ".has_many :parents, json_foreign_key: true" do
279
- subject { Child.create! }
280
-
281
- let(:parents) { [Parent.create!, Parent.create!, Parent.create!] }
282
-
283
- describe "#parent_ids" do
284
- it "is empty by default" do
285
- expect(subject.parent_ids).to eq []
286
- end
287
-
288
- it "is an accessor" do
289
- subject.parent_ids = parents.map(&:id)
290
- expect(subject.parent_ids).to eq parents.map(&:id)
291
- end
292
- end
293
-
294
- describe "#parent_ids=" do
295
- before { parents } # ensure parents exist
296
-
297
- it "normalizes to integers" do
298
- subject.parent_ids = ["1",2,"3"]
299
- expect(subject.parent_ids).to eq [1,2,3]
300
- end
301
-
302
- it "ignores empty strings" do
303
- subject.parent_ids = ["","1","2","3"]
304
- expect(subject.parent_ids).to eq [1,2,3]
305
- end
306
- end
307
-
308
- describe "#parents" do
309
- it "returns an empty array when there are no parents" do
310
- expect(subject.parents).to eq []
311
- end
312
-
313
- it "finds the children by id" do
314
- subject.parent_ids = parents.map(&:id)
315
- expect(subject.parents).to eq parents
316
- end
317
-
318
- it "is an accessor" do
319
- subject.parents = parents
320
- expect(subject.parents).to eq parents
321
- end
322
-
323
- context "finds records with the specified id" do
324
- let(:child) { Child.create! }
325
-
326
- it "as the whole json array" do
327
- parent = Parent.create(children: [child])
328
- expect(child.parents).to eq [parent]
329
- end
330
-
331
- it "at the beginning of the json array" do
332
- parent = Parent.create(children: [child, Child.create!])
333
- expect(child.parents).to eq [parent]
334
- end
335
-
336
- it "in the middle of the json array" do
337
- parent = Parent.create(children: [Child.create!, child, Child.create!])
338
- expect(child.parents).to eq [parent]
339
- end
340
-
341
- it "at the end of the json array" do
342
- parent = Parent.create(children: [Child.create!, child])
343
- expect(child.parents).to eq [parent]
344
- end
345
- end
346
- end
347
-
348
- describe "#parents?" do
349
- it "returns false when there are no parents" do
350
- expect(subject.parents?).to be_falsey
351
- end
352
-
353
- it "returns true when there are parents" do
354
- subject.parents = parents
355
- expect(subject.parents?).to be_truthy
356
- end
357
- end
358
-
359
- describe "#build_parent" do
360
- it "doesnt save the record" do
361
- parent = subject.build_parent
362
- expect(parent).to be_new_record
363
- end
364
-
365
- it "sets the foreign key column" do
366
- parent = subject.build_parent
367
- expect(parent.children).to eq([subject])
368
- end
369
-
370
- it "passes attributes through" do
371
- parent = subject.build_parent(name: "Parent")
372
- expect(parent.name).to eq("Parent")
373
- end
374
- end
375
-
376
- describe "#create_parent" do
377
- it "saves the record" do
378
- parent = subject.create_parent
379
- expect(parent).to be_persisted
380
- end
381
-
382
- it "sets the foreign key column" do
383
- parent = subject.create_parent
384
- expect(parent.children).to eq([subject])
385
- end
386
-
387
- it "passes attributes through" do
388
- parent = subject.create_parent(name: "Parent")
389
- expect(parent.name).to eq("Parent")
390
- end
391
-
392
- it "calls create on the model" do
393
- expect(Parent).to receive(:create)
394
- subject.create_parent
395
- end
396
- end
397
-
398
- describe "#create_parent!" do
399
- it "saves the record" do
400
- parent = subject.create_parent!
401
- expect(parent).to be_persisted
402
- end
403
-
404
- it "sets the foreign key column" do
405
- parent = subject.create_parent!
406
- expect(parent.children).to eq([subject])
407
- end
408
-
409
- it "passes attributes through" do
410
- parent = subject.create_parent!(name: "Parent")
411
- expect(parent.name).to eq("Parent")
412
- end
413
-
414
- it "calls create! on the model" do
415
- expect(Parent).to receive(:create!)
416
- subject.create_parent!
417
- end
418
- end
419
- end
420
-
421
- describe ".has_many :parents, json_foreign_key: :fuzzy_ids" do
422
- subject { Pet.create! }
423
-
424
- let(:parents) { [Parent.create!, Parent.create!, Parent.create!] }
425
-
426
- describe "#parent_ids" do
427
- it "is empty by default" do
428
- expect(subject.parent_ids).to eq []
429
- end
430
-
431
- it "is an accessor" do
432
- subject.parent_ids = parents.map(&:id)
433
- expect(subject.parent_ids).to eq parents.map(&:id)
434
- end
435
- end
436
-
437
- describe "#parents" do
438
- it "returns an empty array when there are no parents" do
439
- expect(subject.parents).to eq []
440
- end
441
-
442
- it "finds the parents by id" do
443
- subject.parent_ids = parents.map(&:id)
444
- expect(subject.parents).to eq parents
445
- end
446
-
447
- it "is an accessor" do
448
- subject.parents = parents
449
- expect(subject.parents).to eq parents
450
- end
451
-
452
- context "finds records with the specified id" do
453
- let(:pet) { Pet.create! }
454
-
455
- it "as the whole json array" do
456
- parent = Parent.create(fuzzies: [pet])
457
- expect(pet.parents).to eq [parent]
458
- end
459
-
460
- it "at the beginning of the json array" do
461
- parent = Parent.create(fuzzies: [pet, Pet.create!])
462
- expect(pet.parents).to eq [parent]
463
- end
464
-
465
- it "in the middle of the json array" do
466
- parent = Parent.create(fuzzies: [Pet.create!, pet, Pet.create!])
467
- expect(pet.parents).to eq [parent]
468
- end
469
-
470
- it "at the end of the json array" do
471
- parent = Parent.create(fuzzies: [Pet.create!, pet])
472
- expect(pet.parents).to eq [parent]
473
- end
474
- end
475
- end
476
-
477
- describe "#parents?" do
478
- it "returns false when there are no parents" do
479
- expect(subject.parents?).to be_falsey
480
- end
481
-
482
- it "returns true when there are parents" do
483
- subject.parents = parents
484
- expect(subject.parents?).to be_truthy
485
- end
486
- end
487
- end
488
277
  end
489
-
@@ -0,0 +1,263 @@
1
+ require "active_record/json_associations"
2
+
3
+ describe ActiveRecord::JsonAssociations do
4
+ before do
5
+ ActiveRecord::Base.establish_connection database_config
6
+
7
+ silence_stream(STDOUT) do
8
+ ActiveRecord::Schema.define do
9
+ create_table :parents, force: true do |t|
10
+ t.string :name
11
+ t.json :child_ids
12
+ t.json :fuzzy_ids
13
+ t.timestamps
14
+ end
15
+
16
+ create_table :children, force: true do |t|
17
+ t.timestamps
18
+ end
19
+
20
+ create_table :pets, force: true do |t|
21
+ t.timestamps
22
+ end
23
+ end
24
+ end
25
+
26
+ class Parent < ActiveRecord::Base
27
+ belongs_to_many :children, touch: true
28
+ belongs_to_many :fuzzies, class_name: "Pet"
29
+ end
30
+
31
+ class Child < ActiveRecord::Base
32
+ has_many :parents, json_foreign_key: true
33
+ end
34
+
35
+ class Pet < ActiveRecord::Base
36
+ has_many :parents, json_foreign_key: :fuzzy_ids
37
+
38
+ # ensure that regular .has_many invocations still work
39
+ has_many :fallback_parents
40
+ has_many :fallback_parents_with_options, class_name: "Pet"
41
+ has_many :fallback_parents_with_scope, -> { order(:id) }
42
+ end
43
+ end
44
+
45
+ describe ".has_many :parents, json_foreign_key: true" do
46
+ subject { Child.create! }
47
+
48
+ let(:parents) { [Parent.create!, Parent.create!, Parent.create!] }
49
+
50
+ describe "#parent_ids" do
51
+ it "is empty by default" do
52
+ expect(subject.parent_ids).to eq []
53
+ end
54
+
55
+ it "is an accessor" do
56
+ subject.parent_ids = parents.map(&:id)
57
+ expect(subject.parent_ids).to eq parents.map(&:id)
58
+ end
59
+ end
60
+
61
+ describe "#parent_ids=" do
62
+ before { parents } # ensure parents exist
63
+
64
+ it "normalizes to integers" do
65
+ subject.parent_ids = ["1",2,"3"]
66
+ expect(subject.parent_ids).to eq [1,2,3]
67
+ end
68
+
69
+ it "ignores empty strings" do
70
+ subject.parent_ids = ["","1","2","3"]
71
+ expect(subject.parent_ids).to eq [1,2,3]
72
+ end
73
+ end
74
+
75
+ describe "#parents" do
76
+ it "returns an empty array when there are no parents" do
77
+ expect(subject.parents).to eq []
78
+ end
79
+
80
+ it "finds the children by id" do
81
+ subject.parent_ids = parents.map(&:id)
82
+ expect(subject.parents).to eq parents
83
+ end
84
+
85
+ it "is an accessor" do
86
+ subject.parents = parents
87
+ expect(subject.parents).to eq parents
88
+ end
89
+
90
+ it "defers assignment until after create for new records" do
91
+ child = Child.new
92
+ child.parents = parents
93
+ expect(parents.map { |p| p.reload.child_ids }).to eq [[], [], []]
94
+ child.save!
95
+ expect(parents.map { |p| p.reload.child_ids }).to eq [[child.id], [child.id], [child.id]]
96
+ end
97
+
98
+ context "finds records with the specified id" do
99
+ let(:child) { Child.create! }
100
+
101
+ it "as the whole json array" do
102
+ parent = Parent.create(children: [child])
103
+ expect(child.parents).to eq [parent]
104
+ end
105
+
106
+ it "at the beginning of the json array" do
107
+ parent = Parent.create(children: [child, Child.create!])
108
+ expect(child.parents).to eq [parent]
109
+ end
110
+
111
+ it "in the middle of the json array" do
112
+ parent = Parent.create(children: [Child.create!, child, Child.create!])
113
+ expect(child.parents).to eq [parent]
114
+ end
115
+
116
+ it "at the end of the json array" do
117
+ parent = Parent.create(children: [Child.create!, child])
118
+ expect(child.parents).to eq [parent]
119
+ end
120
+ end
121
+ end
122
+
123
+ describe "#parents?" do
124
+ it "returns false when there are no parents" do
125
+ expect(subject.parents?).to be_falsey
126
+ end
127
+
128
+ it "returns true when there are parents" do
129
+ subject.parents = parents
130
+ expect(subject.parents?).to be_truthy
131
+ end
132
+ end
133
+
134
+ describe "#build_parent" do
135
+ it "doesnt save the record" do
136
+ parent = subject.build_parent
137
+ expect(parent).to be_new_record
138
+ end
139
+
140
+ it "sets the foreign key column" do
141
+ parent = subject.build_parent
142
+ expect(parent.children).to eq([subject])
143
+ end
144
+
145
+ it "passes attributes through" do
146
+ parent = subject.build_parent(name: "Parent")
147
+ expect(parent.name).to eq("Parent")
148
+ end
149
+ end
150
+
151
+ describe "#create_parent" do
152
+ it "saves the record" do
153
+ parent = subject.create_parent
154
+ expect(parent).to be_persisted
155
+ end
156
+
157
+ it "sets the foreign key column" do
158
+ parent = subject.create_parent
159
+ expect(parent.children).to eq([subject])
160
+ end
161
+
162
+ it "passes attributes through" do
163
+ parent = subject.create_parent(name: "Parent")
164
+ expect(parent.name).to eq("Parent")
165
+ end
166
+
167
+ it "calls create on the model" do
168
+ expect(Parent).to receive(:create)
169
+ subject.create_parent
170
+ end
171
+ end
172
+
173
+ describe "#create_parent!" do
174
+ it "saves the record" do
175
+ parent = subject.create_parent!
176
+ expect(parent).to be_persisted
177
+ end
178
+
179
+ it "sets the foreign key column" do
180
+ parent = subject.create_parent!
181
+ expect(parent.children).to eq([subject])
182
+ end
183
+
184
+ it "passes attributes through" do
185
+ parent = subject.create_parent!(name: "Parent")
186
+ expect(parent.name).to eq("Parent")
187
+ end
188
+
189
+ it "calls create! on the model" do
190
+ expect(Parent).to receive(:create!)
191
+ subject.create_parent!
192
+ end
193
+ end
194
+ end
195
+
196
+ describe ".has_many :parents, json_foreign_key: :fuzzy_ids" do
197
+ subject { Pet.create! }
198
+
199
+ let(:parents) { [Parent.create!, Parent.create!, Parent.create!] }
200
+
201
+ describe "#parent_ids" do
202
+ it "is empty by default" do
203
+ expect(subject.parent_ids).to eq []
204
+ end
205
+
206
+ it "is an accessor" do
207
+ subject.parent_ids = parents.map(&:id)
208
+ expect(subject.parent_ids).to eq parents.map(&:id)
209
+ end
210
+ end
211
+
212
+ describe "#parents" do
213
+ it "returns an empty array when there are no parents" do
214
+ expect(subject.parents).to eq []
215
+ end
216
+
217
+ it "finds the parents by id" do
218
+ subject.parent_ids = parents.map(&:id)
219
+ expect(subject.parents).to eq parents
220
+ end
221
+
222
+ it "is an accessor" do
223
+ subject.parents = parents
224
+ expect(subject.parents).to eq parents
225
+ end
226
+
227
+ context "finds records with the specified id" do
228
+ let(:pet) { Pet.create! }
229
+
230
+ it "as the whole json array" do
231
+ parent = Parent.create(fuzzies: [pet])
232
+ expect(pet.parents).to eq [parent]
233
+ end
234
+
235
+ it "at the beginning of the json array" do
236
+ parent = Parent.create(fuzzies: [pet, Pet.create!])
237
+ expect(pet.parents).to eq [parent]
238
+ end
239
+
240
+ it "in the middle of the json array" do
241
+ parent = Parent.create(fuzzies: [Pet.create!, pet, Pet.create!])
242
+ expect(pet.parents).to eq [parent]
243
+ end
244
+
245
+ it "at the end of the json array" do
246
+ parent = Parent.create(fuzzies: [Pet.create!, pet])
247
+ expect(pet.parents).to eq [parent]
248
+ end
249
+ end
250
+ end
251
+
252
+ describe "#parents?" do
253
+ it "returns false when there are no parents" do
254
+ expect(subject.parents?).to be_falsey
255
+ end
256
+
257
+ it "returns true when there are parents" do
258
+ subject.parents = parents
259
+ expect(subject.parents?).to be_truthy
260
+ end
261
+ end
262
+ end
263
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,6 +1,14 @@
1
1
  require "byebug"
2
2
  require "timecop"
3
3
 
4
+ def database_config
5
+ if ENV["DATABASE_URL"]
6
+ { url: ENV["DATABASE_URL"] }
7
+ else
8
+ { adapter: "sqlite3", database: ":memory:" }
9
+ end
10
+ end
11
+
4
12
  RSpec.configure do |config|
5
13
  config.filter_run focus: true
6
14
  config.run_all_when_everything_filtered = true
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record-json_associations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Micah Geisel
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-21 00:00:00.000000000 Z
11
+ date: 2026-01-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -94,6 +94,34 @@ dependencies:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pg
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: trilogy
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
97
125
  - !ruby/object:Gem::Dependency
98
126
  name: byebug
99
127
  requirement: !ruby/object:Gem::Requirement
@@ -134,24 +162,26 @@ files:
134
162
  - ".gitignore"
135
163
  - ".rspec"
136
164
  - Appraisals
165
+ - CLAUDE.md
137
166
  - Gemfile
138
167
  - LICENSE.txt
139
168
  - README.md
140
169
  - Rakefile
141
170
  - active_record-json_associations.gemspec
142
171
  - bin/setup
143
- - gemfiles/rails_7.0.gemfile
144
- - gemfiles/rails_7.1.gemfile
145
172
  - gemfiles/rails_7.2.gemfile
173
+ - gemfiles/rails_8.0.gemfile
174
+ - gemfiles/rails_8.1.gemfile
146
175
  - lib/active_record/json_associations.rb
147
176
  - lib/active_record/json_associations/version.rb
148
- - spec/json_associations_spec.rb
177
+ - spec/belongs_to_many_spec.rb
178
+ - spec/has_many_spec.rb
149
179
  - spec/spec_helper.rb
150
180
  homepage: https://github.com/botandrose/active_record-json_associations
151
181
  licenses:
152
182
  - MIT
153
183
  metadata: {}
154
- post_install_message:
184
+ post_install_message:
155
185
  rdoc_options: []
156
186
  require_paths:
157
187
  - lib
@@ -159,7 +189,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
159
189
  requirements:
160
190
  - - ">="
161
191
  - !ruby/object:Gem::Version
162
- version: '0'
192
+ version: '3.2'
163
193
  required_rubygems_version: !ruby/object:Gem::Requirement
164
194
  requirements:
165
195
  - - ">="
@@ -167,9 +197,10 @@ required_rubygems_version: !ruby/object:Gem::Requirement
167
197
  version: '0'
168
198
  requirements: []
169
199
  rubygems_version: 3.5.11
170
- signing_key:
200
+ signing_key:
171
201
  specification_version: 4
172
202
  summary: Instead of a many-to-many join table, serialize the ids into a JSON array.
173
203
  test_files:
174
- - spec/json_associations_spec.rb
204
+ - spec/belongs_to_many_spec.rb
205
+ - spec/has_many_spec.rb
175
206
  - spec/spec_helper.rb