adjustable_schema 0.7.2 → 0.9.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -0
  3. data/README.md +2 -2
  4. data/Rakefile +7 -5
  5. data/app/models/adjustable_schema/application_record.rb +5 -3
  6. data/app/models/adjustable_schema/relationship/role.rb +8 -1
  7. data/app/models/adjustable_schema/relationship.rb +39 -21
  8. data/app/models/concerns/adjustable_schema/active_record/associations.rb +3 -1
  9. data/app/models/concerns/adjustable_schema/active_record/relationships.rb +10 -8
  10. data/config/initializers/associations.rb +2 -0
  11. data/config/initializers/model_names.rb +2 -0
  12. data/db/migrate/01_create_adjustable_schema_relationship_tables.rb +4 -2
  13. data/lib/adjustable_schema/active_record/association/hierarchy.rb +33 -0
  14. data/lib/adjustable_schema/active_record/association/naming.rb +49 -30
  15. data/lib/adjustable_schema/active_record/association/roleless.rb +27 -0
  16. data/lib/adjustable_schema/active_record/association/scopes.rb +30 -27
  17. data/lib/adjustable_schema/active_record/association.rb +64 -47
  18. data/lib/adjustable_schema/active_record.rb +3 -1
  19. data/lib/adjustable_schema/authors.rb +3 -1
  20. data/lib/adjustable_schema/config.rb +10 -11
  21. data/lib/adjustable_schema/engine.rb +3 -1
  22. data/lib/adjustable_schema/version.rb +3 -1
  23. data/lib/adjustable_schema.rb +5 -3
  24. data/lib/tasks/adjustable_schema_tasks.rake +2 -0
  25. metadata +9 -19
  26. data/app/assets/config/adjustable_schema_manifest.js +0 -1
  27. data/app/assets/stylesheets/adjustable_schema/application.css +0 -15
  28. data/app/controllers/adjustable_schema/application_controller.rb +0 -4
  29. data/app/helpers/adjustable_schema/application_helper.rb +0 -4
  30. data/app/jobs/adjustable_schema/application_job.rb +0 -4
  31. data/app/mailers/adjustable_schema/application_mailer.rb +0 -6
  32. data/app/views/layouts/adjustable_schema/application.html.erb +0 -15
  33. data/config/routes.rb +0 -2
  34. data/lib/adjustable_schema/active_record/query_methods.rb +0 -103
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f44c8a85801250e7f4d0e26af1984a77f7e59d0d0f31f416e8d55343fdefb94a
4
- data.tar.gz: fd68da65c3953097520bd12263924482d11330b7862c5bb7cf4aeffef972ecc8
3
+ metadata.gz: 3b1390e4f564f54d0e8a840fae882bf7a494b51323846ab06e05a786c332fc37
4
+ data.tar.gz: 5458b1a98b4b3a3dea0c3a8e2cd66e42003683cbbd4af95c939af8fe1810ce34
5
5
  SHA512:
6
- metadata.gz: 05bc3d492e1794d4549668cf853b07fc824b5285fb6062287f11875ba8d408f1f2c1fc9fcaa26becd797ee04e9ed1c9ed997a4a998eb21dc70ba77486a1725e3
7
- data.tar.gz: 44e8c6b9fef8f06d4bedd14213ae08a62e425825fe3a30c6843117c1d1b2e4cabbd66ec00e2bef5b5fb9ab1bd7e4e3696e3e784b24a62832bab66ee2d54d6b4b
6
+ metadata.gz: dee15ab2468fca2b6bf3739df1b65508dd80ae87830833d27895e05a21c59392e3856ffbd78742cc0c067bfd1db66b364a880f0bf5401a3a3cc5cac2879ec59e
7
+ data.tar.gz: 984449dfcba1f1797830b5153439d2873187b0fe32aaefe23cb21bab76b49f49906f70dc562eacb92653a531eb828c262449bf41a71b4507782de25ac38214d0
data/CHANGELOG.md CHANGED
@@ -0,0 +1,124 @@
1
+ ## [0.9.0] — 2025-03-10
2
+
3
+ ### Changed
4
+
5
+ - Requires Rails 8 and Ruby 3.4+.
6
+ - Protected `AdjustableSchema::Relationship::Role` from being deleted when used.
7
+
8
+ ### Added
9
+
10
+ - `Relationship#sourced` and `Relationship#targeted` scopes to filter relationships by presence of _source_ and _target_ records respectively.
11
+
12
+ ### Fixed
13
+
14
+ - `Relationship#applied` scope used to return relationships with at least one record attached, instead of ones with both records present.
15
+
16
+
17
+ ## [0.8.0] — 2024-11-08
18
+
19
+ ### Changed
20
+
21
+ - Renamed checks for related records:
22
+ `<associat>ful` form is now used instead of a passive one (`<associat>ed`) to check for related records’ presence.
23
+ - `.<associat>ful` — scope records having associated ones.
24
+ - `#<associat>ful?` — are there any records associated?
25
+ - Naming: improved passive forms for words ending with `or`/`ant`/`ion`/`ment`/`ing`.
26
+
27
+ ### Added
28
+
29
+ - Checks for related records’ presence on roleless recursive associations:
30
+ - `.<associat>ful` —
31
+ records having associated ones;
32
+ - `.<association>less` —
33
+ records not having associated ones;
34
+ - `#<associat>ful?` —
35
+ if there are records associated;
36
+ - `#<association>less?` —
37
+ if there are no records associated;
38
+ - `#intermediate?` —
39
+ whether is only one child record associated (_Is the node just a link between two other nodes like?_);
40
+ - `#branching?` —
41
+ whether are several child records associated.
42
+
43
+ ### Fixed
44
+
45
+ - Naming: passive form for `author`.
46
+
47
+
48
+ ## [0.7.2] — 2024-04-02
49
+
50
+ ### Fixed
51
+
52
+ - `roleless` scope used to generate wrong queries.
53
+
54
+
55
+ ## [0.7.1] — 2024-03-31
56
+
57
+ ### Fixed
58
+
59
+ - DB constraints:
60
+ - `roles.name` is `NOT NULL`,
61
+ - `UNIQUE` constraints should treat `NULLS` as `NOT DISTINCT`.
62
+ - Roleless recursive associations used to fail on `joins`.
63
+
64
+
65
+ ## [0.7.0] — 2024-02-25
66
+
67
+ ### Changed
68
+
69
+ - Naming: improved passive forms a bit.
70
+ - Configuration: renamed `self_related` to `self`.
71
+
72
+ ### Added
73
+
74
+ - Checks for related records’ presence:
75
+ - `.<associat>ed` —
76
+ records having associated ones;
77
+ - `.<association>less` —
78
+ records not having associated ones;
79
+ - `#<associat>ed?` —
80
+ if there are records associated;
81
+ - `#<association>less?` —
82
+ if there are no records associated.
83
+ - Documentation: self-targeted relationships in README.
84
+
85
+ ### Fixed
86
+
87
+ - Documentation: examples in the README.
88
+
89
+
90
+ ## [0.6.0] — 2024-02-20
91
+
92
+ ### Changed
93
+
94
+ - Destroy orphaned relationships of an object on destroy.
95
+ - Symbolize configurable names used for associations, methods, etc.
96
+ - Raise `ArgumentError` for unknown names passed to the API.
97
+
98
+ ### Added
99
+
100
+ - `Relationship[]` to filter relationships by related objects/classes.
101
+ - `Role[]` accepts `Hash`-like parameters to filter roles by relationships.
102
+ - Methods for related records:
103
+ - `related?` to check for related objects,
104
+ - `related` to fetch them,
105
+ - and the basic `relationships`.
106
+ - Recursive methods for related records:
107
+ - flat `ancestors` & `descendants` with distance,
108
+ - based on `recursive` association scope.
109
+ - `roleless` scope for related records without a role.
110
+ - A dedicated association for roleless children.
111
+
112
+ ### Fixed
113
+
114
+ - Faulty scopes in role-based relationship associations.
115
+ - Naming for namespaced models, e.g., in Rails Engines.
116
+
117
+
118
+ ## [0.5.0] — 2023-12-29
119
+
120
+ Refactored from [Rails Dynamic Associations](
121
+ https://github.com/Alexander-Senko/rails_dynamic_associations
122
+ ).
123
+
124
+ Some experimental features are missing and can be found in the `api` branch.
data/README.md CHANGED
@@ -75,7 +75,7 @@ It includes `User` by default.
75
75
 
76
76
  ##### Self-referencing models
77
77
 
78
- You may want to set up self-targeted relationships:
78
+ You may want to set up recursive relationships:
79
79
 
80
80
  ``` ruby
81
81
  AdjustableSchema::Relationship.seed! Person, roles: %w[friend]
@@ -100,7 +100,7 @@ AdjustableSchema::Engine.configure do
100
100
  end
101
101
  ```
102
102
 
103
- Thus, for the self-referenced `Event`s, you'll get:
103
+ Thus, for hierarchical `Event`s, you'll get:
104
104
 
105
105
  ``` ruby
106
106
  event.causes
data/Rakefile CHANGED
@@ -1,8 +1,10 @@
1
- require "bundler/setup"
1
+ # frozen_string_literal: true
2
2
 
3
- APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
- load "rails/tasks/engine.rake"
3
+ require 'bundler/setup'
5
4
 
6
- load "rails/tasks/statistics.rake"
5
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
6
+ load 'rails/tasks/engine.rake'
7
7
 
8
- require "bundler/gem_tasks"
8
+ load 'rails/tasks/statistics.rake'
9
+
10
+ require 'bundler/gem_tasks'
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AdjustableSchema
2
- class ApplicationRecord < ::ActiveRecord::Base
3
- self.abstract_class = true
4
- end
4
+ class ApplicationRecord < ::ActiveRecord::Base # :nodoc:
5
+ self.abstract_class = true
6
+ end
5
7
  end
@@ -1,9 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AdjustableSchema
2
4
  class Relationship
5
+ # = Relationship roles
6
+ #
7
+ # `AdjustableSchema::Relationship::Role` serves to distinguish
8
+ # between several associations of the same pair of models.
3
9
  class Role < ApplicationRecord
4
10
  include Organizer::Identifiable.by :name, symbolized: true
5
11
 
6
- has_many :relationships
12
+ has_many :relationships,
13
+ dependent: :restrict_with_exception
7
14
 
8
15
  validates :name, presence: true, uniqueness: true
9
16
 
@@ -1,4 +1,29 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AdjustableSchema
4
+ # = Relationships
5
+ #
6
+ # `Relationship` is a core class of Adjustable Schema representing both
7
+ # associations of model classes and connections of individual records.
8
+ #
9
+ # No constraints are supported yet, so only many-to-many associations
10
+ # are available for now.
11
+ #
12
+ # == Model associations
13
+ #
14
+ # To represent an association, a `Relationship` record should have both
15
+ # `source_type` and `target_type` columns set, with both ID columns set
16
+ # to `NULL`.
17
+ #
18
+ # == Record relationships
19
+ #
20
+ # To connect individual records, both `source` and `target` polymorphic
21
+ # associations shoud be set.
22
+ #
23
+ # == Roles
24
+ #
25
+ # Many associations with different semantics between the same models
26
+ # can be set using roles (see `Relationship::Role`).
2
27
  class Relationship < ApplicationRecord
3
28
  belongs_to :source, polymorphic: true, optional: true
4
29
  belongs_to :target, polymorphic: true, optional: true
@@ -8,14 +33,14 @@ module AdjustableSchema
8
33
  includes :role
9
34
  end
10
35
 
11
- Config.shortcuts.each &-> ((association, method)) do
36
+ Config.shortcuts.each &-> ((association, method)) do # rubocop:disable Style
12
37
  scope method, -> object {
13
38
  case object
14
39
  when ::ActiveRecord::Base, nil
15
40
  where association => object
16
41
  when Class
17
42
  where "#{association}_type" => object.ancestors
18
- .select { _1 <= object.base_class }
43
+ .grep(..object.base_class)
19
44
  .map(&:name)
20
45
  when ::ActiveRecord::Relation
21
46
  send(method, object.klass)
@@ -29,8 +54,7 @@ module AdjustableSchema
29
54
 
30
55
  scope "#{method}_abstract", -> object = nil {
31
56
  if object
32
- send(__method__).
33
- send method, object
57
+ send(__method__).send method, object
34
58
  else
35
59
  where "#{association}_id" => nil
36
60
  end
@@ -39,20 +63,14 @@ module AdjustableSchema
39
63
 
40
64
  scope :abstract, -> {
41
65
  Config.shortcuts.values
42
- .map { send _1, :abstract }
66
+ .map { send it, :abstract }
43
67
  .reduce &:merge
44
68
  }
45
69
 
46
- scope :general, -> {
47
- where target: nil
48
- }
49
-
50
- scope :applied, -> {
51
- where.not(
52
- source: nil,
53
- target: nil,
54
- )
55
- }
70
+ scope :general, -> { where target: nil }
71
+ scope :sourced, -> { where.not source: nil }
72
+ scope :targeted, -> { where.not target: nil }
73
+ scope :applied, -> { sourced.targeted }
56
74
 
57
75
  scope :named, -> *names {
58
76
  case names
@@ -74,15 +92,15 @@ module AdjustableSchema
74
92
  def [] **scopes
75
93
  scopes
76
94
  .map do
77
- self
95
+ self # rubocop:disable Style
78
96
  .send(Config.shortcuts[:source], _1)
79
97
  .send(Config.shortcuts[:target], _2)
80
98
  end
81
99
  .reduce &:or
82
100
  end
83
101
 
84
- def seed! *models, roles: [], **_models
85
- return seed!({ **Hash[*models], **_models }, roles:) if _models.any? # support keyword arguments syntax
102
+ def seed! *models, roles: [], **mapping # rubocop:disable Metrics
103
+ return seed!({ **Hash[*models], **mapping }, roles:) if mapping.any? # support keyword arguments syntax
86
104
 
87
105
  case models
88
106
  in [
@@ -91,7 +109,7 @@ module AdjustableSchema
91
109
  ]
92
110
  roles
93
111
  .map { |name| Role.find_or_create_by! name: }
94
- .then { _1.presence or [ nil ] } # no roles => nameless relationship
112
+ .then { it.presence or [ nil ] } # no roles => nameless relationship
95
113
  .map { |role| create! source_type:, target_type:, role: }
96
114
  in [ Hash => models ]
97
115
  for sources, targets in models do
@@ -100,7 +118,7 @@ module AdjustableSchema
100
118
  end
101
119
  end
102
120
  in [ Class => source ]
103
- seed! source, source, roles: # self-related
121
+ seed! source, source, roles: # recursive
104
122
  end
105
123
  end
106
124
  end
@@ -122,7 +140,7 @@ module AdjustableSchema
122
140
  # https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#module-ActiveRecord::Associations::ClassMethods-label-Polymorphic+Associations
123
141
  reflections
124
142
  .values
125
- .select { _1.options[:polymorphic] }
143
+ .select { it.options[:polymorphic] }
126
144
  .each do |reflection|
127
145
  define_method "#{reflection.name}_type=" do |type|
128
146
  super type && type.to_s.classify.constantize.base_class.to_s
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AdjustableSchema
2
- module ActiveRecord
4
+ module ActiveRecord # :nodoc:
3
5
  concern :Associations do
4
6
  class_methods do
5
7
  private
@@ -1,14 +1,16 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'memery'
2
4
 
3
5
  module AdjustableSchema
4
- module ActiveRecord
6
+ module ActiveRecord # :nodoc:
5
7
  concern :Relationships do
6
8
  class_methods do
7
9
  include Memery
8
10
 
9
11
  memoize def relationships
10
- Config.association_directions.to_h do
11
- [ _1, Relationship.abstract.send(Config.shortcuts.opposite[_1], self) ]
12
+ Config.association_directions.index_with do
13
+ Relationship.abstract.send Config.shortcuts.opposite[it], self
12
14
  end
13
15
  end
14
16
 
@@ -44,20 +46,20 @@ module AdjustableSchema
44
46
  .preload(Config.association_directions)
45
47
  .map do |relationship|
46
48
  Config.association_directions
47
- .map { relationship.send _1 } # both objects
49
+ .map { relationship.send it } # both objects
48
50
  .without(self) # the related one
49
- .first or self # may be self-related
51
+ .first or self # may be recursive
50
52
  end
51
53
  .uniq
52
54
  end
53
55
 
54
- def relationships **options
55
- if (direction, scope = Config.find_direction **options) # filter by direction & related objects
56
+ def relationships(...)
57
+ if (direction, scope = Config.find_direction(...)) # filter by direction & related objects
56
58
  relationships_to(direction)
57
59
  .send Config.shortcuts[direction], scope
58
60
  else # all in both directions
59
61
  Config.association_directions
60
- .map { relationships_to _1 }
62
+ .map { relationships_to it }
61
63
  .reduce(&:or)
62
64
  end
63
65
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  ActiveSupport.on_load :model_class do
2
4
  next if self == AdjustableSchema::Relationship
3
5
  next unless AdjustableSchema.available?
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # HACK: non-public Rails API used
2
4
  ActiveModel::Name.class_eval do
3
5
  def unnamespaced = @unnamespaced || name
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class CreateAdjustableSchemaRelationshipTables < ActiveRecord::Migration[7.1]
2
4
  def change
3
5
  # Use Active Record's configured type for primary and foreign keys
@@ -22,13 +24,13 @@ class CreateAdjustableSchemaRelationshipTables < ActiveRecord::Migration[7.1]
22
24
  target_id target_type
23
25
  role_id
24
26
  ].tap { |columns|
25
- columns.reject! { _1.ends_with? '_type' } if foreign_key_type == :uuid # OPTIMIZATION: IDs are unique
27
+ columns.reject! { it.ends_with? '_type' } if foreign_key_type == :uuid # OPTIMIZATION: IDs are unique
26
28
 
27
29
  # NULLS are DISTINCT by default.
28
30
  # One can use `ADD CONSTRAINT … UNIQUE NULLS NOT DISTINCT (…)` instead
29
31
  t.index columns,
30
32
  unique: true, where: 'role_id IS NOT NULL', name: :index_adjustable_schema_relationships_uniqueness_with_role
31
- t.index columns.excluding(:role_id),
33
+ t.index columns.without(:role_id),
32
34
  unique: true, where: 'role_id IS NULL', name: :index_adjustable_schema_relationships_uniqueness_without_role
33
35
  }
34
36
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdjustableSchema
4
+ module ActiveRecord
5
+ module Association::Hierarchy # :nodoc:
6
+ private
7
+
8
+ def scopes
9
+ name = roleless_name # save the context
10
+
11
+ {
12
+ **super,
13
+
14
+ name_for_any( target_name) => -> { where.associated name },
15
+ name_for_none(target_name) => -> { where.missing name },
16
+ }
17
+ end
18
+
19
+ def flags
20
+ name = roleless_name # save the context
21
+
22
+ {
23
+ **super,
24
+
25
+ name_for_any( target_name) => -> { send(name).any? },
26
+ name_for_none(target_name) => -> { send(name).none? },
27
+ intermediate: -> { send(name).one? },
28
+ branching: -> { send(name).many? },
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,30 +1,63 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'memery'
2
4
 
3
5
  module AdjustableSchema
4
6
  module ActiveRecord
5
- class Association
7
+ class Association # :nodoc:
6
8
  concerning :Naming do
7
9
  include Memery
8
10
 
9
- module Inflections
11
+ module Inflections # :nodoc:
10
12
  refine String do
11
13
  def passivize
12
- self
13
- .sub(/(author)$/, '\\2ed')
14
- .sub(/(e*|ed|ing|[eo]r|ant|(t)ion)$/, '\\2ed')
14
+ presence
15
+ &.sub(/((:?[aeiou]+[^aeiou]+){2,})(?:or|ant|ion|e?ment)$/, '\1ed')
16
+ &.sub(/((:?[aeiou]+[^aeiou]+){1,})(?:ing)$/, '\1ed')
17
+ &.sub(/(?:e*|ed|er)$/, '\1ed')
18
+ .to_s
15
19
  end
16
20
  end
17
21
  end
18
22
 
19
23
  using Inflections
20
24
 
21
- memoize def name
22
- (role ? name_with_role : name_without_role)
25
+ module Recursive # :nodoc:
26
+ include Memery
27
+
28
+ memoize def name_with_role = {
29
+ source: role.name,
30
+ target: "#{role.name.passivize}_#{target_name}",
31
+ }[direction]
32
+
33
+ def name_without_role
34
+ Config.association_directions
35
+ .self[direction]
36
+ .to_s
37
+ end
38
+ end
39
+
40
+ def initialize(...)
41
+ super
42
+
43
+ extend Recursive if recursive?
44
+ end
45
+
46
+ memoize def name name = object_name
47
+ name
23
48
  .to_s
24
49
  .tableize
25
50
  .to_sym
26
51
  end
27
52
 
53
+ def object_name
54
+ if role
55
+ name_with_role
56
+ else
57
+ name_without_role
58
+ end
59
+ end
60
+
28
61
  memoize def target_name
29
62
  target.model_name.unnamespaced
30
63
  .split('::')
@@ -37,31 +70,17 @@ module AdjustableSchema
37
70
 
38
71
  private
39
72
 
40
- memoize def name_with_role
41
- if recursive?
42
- {
43
- source: role.name,
44
- target: "#{role.name.passivize}_#{target_name}",
45
- }[direction]
46
- else
47
- "#{{
48
- source: role.name,
49
- target: role.name.passivize,
50
- }[direction]}_#{target_name}"
51
- end
52
- end
73
+ memoize def name_with_role = "#{{
74
+ source: role.name,
75
+ target: role.name.passivize,
76
+ }[direction]}_#{target_name}"
53
77
 
54
- memoize def name_without_role
55
- if recursive?
56
- Config.association_directions
57
- .self[direction]
58
- else
59
- target_name
60
- end
61
- end
78
+ def name_without_role = target_name
79
+
80
+ def roleless_name = name(target_name)
62
81
 
63
- def name_for_any = :"#{name.to_s.singularize.passivize}"
64
- def name_for_none = :"#{name.to_s.singularize}less"
82
+ def name_for_any (name = object_name) = :"#{name}ful"
83
+ def name_for_none(name = object_name) = :"#{name}less"
65
84
  end
66
85
  end
67
86
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AdjustableSchema
4
+ module ActiveRecord
5
+ module Association::Roleless # :nodoc:
6
+ def define
7
+ super
8
+
9
+ has_many roleless_name, -> { roleless }, **options if
10
+ child?
11
+ end
12
+
13
+ private
14
+
15
+ def define_methods
16
+ super
17
+
18
+ name = self.name # save the context
19
+
20
+ owner.redefine_method "#{name}_with_roles" do |*roles|
21
+ send(name)
22
+ .merge Relationship.named *roles
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,39 +1,40 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AdjustableSchema
2
4
  module ActiveRecord
3
5
  module Association::Scopes
4
- concern :Recursive do
5
- require_relative '../query_methods'
6
-
7
- included do
8
- ::ActiveRecord::QueryMethods.prepend QueryMethods # HACK: to bring `with.recursive` in
9
- end
6
+ module Recursive # :nodoc:
7
+ # rubocop:disable Layout
10
8
 
11
9
  def recursive
12
- all._exec_scope do
13
- all
14
- .select(
15
- select_values = self.select_values.presence || arel_table[Arel.star],
16
- Arel.sql('1').as('distance'),
17
- )
18
- .with.recursive(recursive_table.name => unscoped
19
- .select(
20
- select_values,
21
- (recursive_table[:distance] + 1).as('distance'),
22
- )
23
- .joins(inverse_association_name)
24
- .arel
25
- .join(recursive_table)
26
- .on(recursive_table[primary_key].eq inverse_table[primary_key])
27
- )
28
- .unscope(:select, :joins, :where)
29
- .from(recursive_table.alias table_name)
30
- .distinct
31
- .unscope(:order) # for SELECT DISTINCT, ORDER BY expressions must appear in select list
32
- end
10
+ with_recursive(recursive_table.name => [ recursion_base, recursive_step ])
11
+ .unscope(:select, :joins, :where)
12
+ .from(recursive_table.alias table_name)
13
+ .distinct
14
+ .unscope(:order) # for SELECT DISTINCT, ORDER BY expressions must appear in select list
33
15
  end
34
16
 
35
17
  private
36
18
 
19
+ def recursion_base
20
+ unscope(:order, :group, :having)
21
+ .select(recursive_select_values,
22
+ Arel.sql('1').as('distance'),
23
+ )
24
+ end
25
+
26
+ def recursive_step
27
+ unscoped
28
+ .select(recursive_select_values,
29
+ (recursive_table[:distance] + 1).as('distance'),
30
+ )
31
+ .joins(inverse_association_name)
32
+ .joins(<<~SQL.squish)
33
+ JOIN #{recursive_table.name}
34
+ ON #{recursive_table.name}.#{primary_key} = #{inverse_table.name}.#{primary_key}
35
+ SQL
36
+ end
37
+
37
38
  def association_name = @association.reflection.name
38
39
 
39
40
  def inverse_association_name
@@ -46,6 +47,8 @@ module AdjustableSchema
46
47
 
47
48
  def recursive_table = Arel::Table.new [ :recursive, association_name, klass.table_name ] * '_'
48
49
  def inverse_table = Arel::Table.new [ inverse_association_name, klass.table_name ] * '_' # HACK: depends on ActiveRecord internals
50
+
51
+ def recursive_select_values = select_values.presence || arel_table[Arel.star]
49
52
  end
50
53
  end
51
54
  end
@@ -1,38 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'memery'
4
+
1
5
  module AdjustableSchema
2
6
  module ActiveRecord
3
- class Association < Struct.new(:owner, :direction, :target, :role)
7
+ # rubocop:disable Style/StructInheritance
8
+ class Association < Struct.new( # :nodoc:
9
+ :owner,
10
+ :direction,
11
+ :target,
12
+ :role,
13
+ )
4
14
  require_relative 'association/naming'
5
15
  require_relative 'association/scopes'
16
+ require_relative 'association/roleless'
17
+ require_relative 'association/hierarchy'
18
+
19
+ include Memery
20
+
21
+ def initialize(...)
22
+ super
23
+
24
+ extend Roleless if roleless?
25
+ extend Hierarchy if hierarchy?
26
+ end
6
27
 
7
28
  def define
8
- name.tap do |association_name|
9
- association = self # save context
10
-
11
- has_many association_name, **(options = {
12
- through: define_relationships,
13
- source: direction,
14
- source_type: target.base_class.name,
15
- class_name: target.name
16
- }) do
17
- include Scopes
18
- include Scopes::Recursive if association.recursive?
19
- end
20
-
21
- define_scopes
22
- define_methods
23
-
24
- unless role
25
- # HACK: using `try` to overcome a Rails bug
26
- # (see https://github.com/rails/rails/issues/40109)
27
- has_many target_name.tableize.to_sym, -> { try :roleless }, **options if
28
- recursive?
29
-
30
- define_role_methods
31
- end
29
+ association = self # save the context
30
+
31
+ has_many name, **options do
32
+ include Scopes
33
+ include Scopes::Recursive if association.recursive?
32
34
  end
35
+
36
+ define_scopes
37
+ define_methods
33
38
  end
34
39
 
35
40
  def recursive? = target == owner
41
+ def roleless? = !role
42
+ def source? = direction == :source
43
+ def target? = direction == :target
44
+ def child? = (recursive? and source?)
45
+ def parent? = (recursive? and target?)
46
+ def hierarchy? = (child? and roleless?)
36
47
 
37
48
  private
38
49
 
@@ -46,42 +57,48 @@ module AdjustableSchema
46
57
  end
47
58
 
48
59
  def define_scopes
49
- name = relationships_name
50
-
51
- {
52
- name_for_any => -> { where.associated name },
53
- name_for_none => -> { where.missing name },
54
- }
60
+ scopes
55
61
  .reject { owner.singleton_class.method_defined? _1 }
56
62
  .each { owner.scope _1, _2 }
57
63
  end
58
64
 
59
65
  def define_methods
60
- name = self.name
61
-
62
- {
63
- name_for_any => -> { send(name).any? },
64
- name_for_none => -> { send(name).none? },
65
- }
66
- .transform_keys {"#{_1}?" }
66
+ flags
67
+ .transform_keys { :"#{it}?" }
67
68
  .reject { owner.method_defined? _1 }
68
69
  .each { owner.define_method _1, &_2 }
69
70
  end
70
71
 
71
- def define_role_methods
72
- name = self.name
73
-
74
- owner.redefine_method "#{name}_with_roles" do |*roles|
75
- send(name)
76
- .merge Relationship.named *roles
77
- end
78
- end
79
-
80
72
  def has_many(association_name, ...)
81
73
  return if owner.reflect_on_association association_name
82
74
 
83
75
  owner.has_many(association_name, ...)
84
76
  end
77
+
78
+ def scopes
79
+ name = relationships_name # save the context
80
+
81
+ {
82
+ name_for_any => -> { where.associated name },
83
+ name_for_none => -> { where.missing name },
84
+ }
85
+ end
86
+
87
+ def flags
88
+ name = self.name # save the context
89
+
90
+ {
91
+ name_for_any => -> { send(name).any? },
92
+ name_for_none => -> { send(name).none? },
93
+ }
94
+ end
95
+
96
+ memoize def options = {
97
+ through: define_relationships,
98
+ source: direction,
99
+ source_type: target.base_class.name,
100
+ class_name: target.name,
101
+ }
85
102
  end
86
103
  end
87
104
  end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AdjustableSchema
2
- module ActiveRecord
4
+ module ActiveRecord # :nodoc:
3
5
  autoload :Association, 'adjustable_schema/active_record/association'
4
6
 
5
7
  ActiveSupport.on_load :active_record do
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  Gem::Author ||= Struct.new(
2
4
  :name,
3
5
  :email,
@@ -11,5 +13,5 @@ module AdjustableSchema
11
13
  email: 'Alexander.Senko@gmail.com',
12
14
  github_url: 'https://github.com/Alexander-Senko',
13
15
  ),
14
- ]
16
+ ].freeze
15
17
  end
@@ -1,19 +1,21 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'memery'
2
4
 
3
5
  module AdjustableSchema
4
- module Config
6
+ module Config # :nodoc:
5
7
  include Memery
6
8
 
7
- module Naming
9
+ module Naming # :nodoc:
8
10
  include Memery
9
11
 
10
12
  memoize def shortcuts
11
13
  config(:shortcut).tap do |shortcuts|
12
14
  def shortcuts.opposite to: nil
13
15
  if to
14
- values.reject { _1 == to }.sole
16
+ values.grep_v(to).sole
15
17
  else
16
- transform_values { opposite to: _1 }
18
+ transform_values { opposite to: it }
17
19
  end
18
20
  end
19
21
  end
@@ -23,19 +25,19 @@ module AdjustableSchema
23
25
 
24
26
  def recursive
25
27
  config.values.to_h do
26
- [ _1[:self].to_s.pluralize.to_sym, _1[:recursive].to_sym ]
28
+ [ it[:self].to_s.pluralize.to_sym, it[:recursive].to_sym ]
27
29
  end
28
30
  end
29
31
 
30
32
  def opposite to:
31
- reject { _1 == to }.sole
33
+ grep_v(to).sole
32
34
  end
33
35
 
34
36
  private
35
37
 
36
38
  def config section = nil
37
39
  if section
38
- config.transform_values { _1[section].to_sym }
40
+ config.transform_values { it[section].to_sym }
39
41
  else
40
42
  Config.association_names # TODO: DRY
41
43
  end
@@ -64,14 +66,11 @@ module AdjustableSchema
64
66
  module_function method
65
67
  end
66
68
 
67
- private
68
- module_function
69
-
70
69
  def association_names = Engine.config.names[:associations]
71
70
 
72
71
  def normalize **options
73
72
  shortcuts
74
- .tap { options.assert_valid_keys _1.keys, _1.values }
73
+ .tap { options.assert_valid_keys it.keys, it.values }
75
74
  .select { _2.in? options }
76
75
  .each { options[_1] = options.delete _2 }
77
76
 
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'organizer'
2
4
 
3
5
  module AdjustableSchema
4
- class Engine < ::Rails::Engine
6
+ class Engine < ::Rails::Engine # :nodoc:
5
7
  isolate_namespace AdjustableSchema
6
8
 
7
9
  config.names = {
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module AdjustableSchema
2
- VERSION = '0.7.2'
4
+ VERSION = '0.9.0'
3
5
  end
@@ -1,10 +1,12 @@
1
- require "adjustable_schema/version"
2
- require "adjustable_schema/engine"
1
+ # frozen_string_literal: true
2
+
3
+ require 'adjustable_schema/version'
4
+ require 'adjustable_schema/engine'
3
5
  require 'adjustable_schema/active_record'
4
6
 
5
7
  require 'rails_model_load_hook' # should be loaded
6
8
 
7
- module AdjustableSchema
9
+ module AdjustableSchema # :nodoc:
8
10
  autoload :Config, 'adjustable_schema/config'
9
11
 
10
12
  module_function
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # desc "Explaining what the task does"
2
4
  # task :adjustable_schema do
3
5
  # # Task goes here
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: adjustable_schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.2
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Senko
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-04-02 00:00:00.000000000 Z
10
+ date: 2025-03-09 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '7.1'
18
+ version: '8.0'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '7.1'
25
+ version: '8.0'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: rails_model_load_hook
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -78,28 +77,21 @@ files:
78
77
  - MIT-LICENSE
79
78
  - README.md
80
79
  - Rakefile
81
- - app/assets/config/adjustable_schema_manifest.js
82
- - app/assets/stylesheets/adjustable_schema/application.css
83
- - app/controllers/adjustable_schema/application_controller.rb
84
- - app/helpers/adjustable_schema/application_helper.rb
85
- - app/jobs/adjustable_schema/application_job.rb
86
- - app/mailers/adjustable_schema/application_mailer.rb
87
80
  - app/models/adjustable_schema/application_record.rb
88
81
  - app/models/adjustable_schema/relationship.rb
89
82
  - app/models/adjustable_schema/relationship/role.rb
90
83
  - app/models/concerns/adjustable_schema/active_record/associations.rb
91
84
  - app/models/concerns/adjustable_schema/active_record/relationships.rb
92
- - app/views/layouts/adjustable_schema/application.html.erb
93
85
  - config/initializers/associations.rb
94
86
  - config/initializers/model_names.rb
95
- - config/routes.rb
96
87
  - db/migrate/01_create_adjustable_schema_relationship_tables.rb
97
88
  - lib/adjustable_schema.rb
98
89
  - lib/adjustable_schema/active_record.rb
99
90
  - lib/adjustable_schema/active_record/association.rb
91
+ - lib/adjustable_schema/active_record/association/hierarchy.rb
100
92
  - lib/adjustable_schema/active_record/association/naming.rb
93
+ - lib/adjustable_schema/active_record/association/roleless.rb
101
94
  - lib/adjustable_schema/active_record/association/scopes.rb
102
- - lib/adjustable_schema/active_record/query_methods.rb
103
95
  - lib/adjustable_schema/authors.rb
104
96
  - lib/adjustable_schema/config.rb
105
97
  - lib/adjustable_schema/engine.rb
@@ -111,8 +103,7 @@ licenses:
111
103
  metadata:
112
104
  homepage_uri: https://github.com/Alexander-Senko/adjustable_schema
113
105
  source_code_uri: https://github.com/Alexander-Senko/adjustable_schema
114
- changelog_uri: https://github.com/Alexander-Senko/adjustable_schema/blob/v0.7.2/CHANGELOG.md
115
- post_install_message:
106
+ changelog_uri: https://github.com/Alexander-Senko/adjustable_schema/blob/v0.9.0/CHANGELOG.md
116
107
  rdoc_options: []
117
108
  require_paths:
118
109
  - lib
@@ -120,15 +111,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
120
111
  requirements:
121
112
  - - ">="
122
113
  - !ruby/object:Gem::Version
123
- version: '3.2'
114
+ version: '3.4'
124
115
  required_rubygems_version: !ruby/object:Gem::Requirement
125
116
  requirements:
126
117
  - - ">="
127
118
  - !ruby/object:Gem::Version
128
119
  version: '0'
129
120
  requirements: []
130
- rubygems_version: 3.5.6
131
- signing_key:
121
+ rubygems_version: 3.6.5
132
122
  specification_version: 4
133
123
  summary: Adjustable data schemas for Rails
134
124
  test_files: []
@@ -1 +0,0 @@
1
- //= link_directory ../stylesheets/adjustable_schema .css
@@ -1,15 +0,0 @@
1
- /*
2
- * This is a manifest file that'll be compiled into application.css, which will include all the files
3
- * listed below.
4
- *
5
- * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
- * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
- *
8
- * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
- * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
- * files in this directory. Styles in this file should be added after the last require_* statement.
11
- * It is generally better to create a new file per style scope.
12
- *
13
- *= require_tree .
14
- *= require_self
15
- */
@@ -1,4 +0,0 @@
1
- module AdjustableSchema
2
- class ApplicationController < ActionController::Base
3
- end
4
- end
@@ -1,4 +0,0 @@
1
- module AdjustableSchema
2
- module ApplicationHelper
3
- end
4
- end
@@ -1,4 +0,0 @@
1
- module AdjustableSchema
2
- class ApplicationJob < ActiveJob::Base
3
- end
4
- end
@@ -1,6 +0,0 @@
1
- module AdjustableSchema
2
- class ApplicationMailer < ActionMailer::Base
3
- default from: "from@example.com"
4
- layout "mailer"
5
- end
6
- end
@@ -1,15 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title>Adjustable schema</title>
5
- <%= csrf_meta_tags %>
6
- <%= csp_meta_tag %>
7
-
8
- <%= stylesheet_link_tag "adjustable_schema/application", media: "all" %>
9
- </head>
10
- <body>
11
-
12
- <%= yield %>
13
-
14
- </body>
15
- </html>
data/config/routes.rb DELETED
@@ -1,2 +0,0 @@
1
- AdjustableSchema::Engine.routes.draw do
2
- end
@@ -1,103 +0,0 @@
1
- module AdjustableSchema
2
- module ActiveRecord
3
- module QueryMethods
4
- class WithChain
5
- def initialize scope
6
- @scope = scope
7
- end
8
-
9
- # Returns a new relation expressing WITH RECURSIVE statement.
10
- #
11
- # #recursive accepts conditions as a hash. See QueryMethods#with for
12
- # more details on each format.
13
- #
14
- # User.with.recursive(
15
- # descendants: User.joins('INNER JOIN descendants ON users.parent_id = descendants.id')
16
- # )
17
- # # WITH RECURSIVE descendants AS (
18
- # # SELECT * FROM users
19
- # # UNION
20
- # # SELECT * FROM users INNER JOIN descendants ON users.parent_id = descendants.id
21
- # # ) SELECT * FROM descendants
22
- #
23
- # WARNING! Due to how Arel works,
24
- # * `recursive` can't be chained with any prior non-recursive calls to `with` and
25
- # * all subsequent non-recursive calls to `with` will be treated as recursive ones.
26
- def recursive *args
27
- args.map! do
28
- next _1 unless _1.is_a? Hash
29
-
30
- _1
31
- .map(&method(:with_recursive_union))
32
- .to_h
33
- end
34
-
35
- case @scope.with_values
36
- in []
37
- @scope = @scope.with :recursive, *args
38
- in [ :recursive, * ]
39
- @scope = @scope.with *args
40
- else
41
- raise ArgumentError, "can't chain `WITH RECURSIVE` with non-recursive one"
42
- end
43
-
44
- @scope
45
- end
46
-
47
- private
48
-
49
- def with_recursive_union name, scope
50
- scope = scope.arel if scope.respond_to? :arel
51
-
52
- [
53
- name,
54
- @scope
55
- .unscope(:order, :group, :having)
56
- .arel
57
- .union(scope)
58
- ]
59
- end
60
- end
61
-
62
- def with *args
63
- if args.empty?
64
- WithChain.new spawn
65
- else
66
- super
67
- end
68
- end
69
-
70
- private
71
-
72
- # OVERWRITE: allow a Symbol to be passes as the first argument
73
- def build_with(arel)
74
- return if with_values.empty?
75
-
76
- with_statements = with_values.map.with_index do |with_value, i|
77
- next with_value if with_value.is_a? Symbol and i == 0
78
-
79
- raise ArgumentError, "Unsupported argument type: #{with_value} #{with_value.class}" unless with_value.is_a?(Hash)
80
-
81
- build_with_value_from_hash(with_value)
82
- end
83
-
84
- arel.with(*with_statements)
85
- end
86
-
87
- # OVERWRITE: allow Arel Nodes
88
- def build_with_value_from_hash(hash)
89
- hash.map do |name, value|
90
- expression =
91
- case value
92
- when Arel::Nodes::SqlLiteral then Arel::Nodes::Grouping.new(value)
93
- when ::ActiveRecord::Relation then value.arel
94
- when Arel::SelectManager, Arel::Nodes::Node then value
95
- else
96
- raise ArgumentError, "Unsupported argument type: `#{value}` #{value.class}"
97
- end
98
- Arel::Nodes::TableAlias.new(expression, name)
99
- end
100
- end
101
- end
102
- end
103
- end