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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +124 -0
- data/README.md +2 -2
- data/Rakefile +7 -5
- data/app/models/adjustable_schema/application_record.rb +5 -3
- data/app/models/adjustable_schema/relationship/role.rb +8 -1
- data/app/models/adjustable_schema/relationship.rb +39 -21
- data/app/models/concerns/adjustable_schema/active_record/associations.rb +3 -1
- data/app/models/concerns/adjustable_schema/active_record/relationships.rb +10 -8
- data/config/initializers/associations.rb +2 -0
- data/config/initializers/model_names.rb +2 -0
- data/db/migrate/01_create_adjustable_schema_relationship_tables.rb +4 -2
- data/lib/adjustable_schema/active_record/association/hierarchy.rb +33 -0
- data/lib/adjustable_schema/active_record/association/naming.rb +49 -30
- data/lib/adjustable_schema/active_record/association/roleless.rb +27 -0
- data/lib/adjustable_schema/active_record/association/scopes.rb +30 -27
- data/lib/adjustable_schema/active_record/association.rb +64 -47
- data/lib/adjustable_schema/active_record.rb +3 -1
- data/lib/adjustable_schema/authors.rb +3 -1
- data/lib/adjustable_schema/config.rb +10 -11
- data/lib/adjustable_schema/engine.rb +3 -1
- data/lib/adjustable_schema/version.rb +3 -1
- data/lib/adjustable_schema.rb +5 -3
- data/lib/tasks/adjustable_schema_tasks.rake +2 -0
- metadata +9 -19
- data/app/assets/config/adjustable_schema_manifest.js +0 -1
- data/app/assets/stylesheets/adjustable_schema/application.css +0 -15
- data/app/controllers/adjustable_schema/application_controller.rb +0 -4
- data/app/helpers/adjustable_schema/application_helper.rb +0 -4
- data/app/jobs/adjustable_schema/application_job.rb +0 -4
- data/app/mailers/adjustable_schema/application_mailer.rb +0 -6
- data/app/views/layouts/adjustable_schema/application.html.erb +0 -15
- data/config/routes.rb +0 -2
- 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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3b1390e4f564f54d0e8a840fae882bf7a494b51323846ab06e05a786c332fc37
|
4
|
+
data.tar.gz: 5458b1a98b4b3a3dea0c3a8e2cd66e42003683cbbd4af95c939af8fe1810ce34
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
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
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
load "rails/tasks/engine.rake"
|
3
|
+
require 'bundler/setup'
|
5
4
|
|
6
|
-
|
5
|
+
APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
|
6
|
+
load 'rails/tasks/engine.rake'
|
7
7
|
|
8
|
-
|
8
|
+
load 'rails/tasks/statistics.rake'
|
9
|
+
|
10
|
+
require 'bundler/gem_tasks'
|
@@ -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
|
-
.
|
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
|
66
|
+
.map { send it, :abstract }
|
43
67
|
.reduce &:merge
|
44
68
|
}
|
45
69
|
|
46
|
-
scope :general,
|
47
|
-
|
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: [], **
|
85
|
-
return seed!({ **Hash[*models], **
|
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 {
|
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: #
|
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 {
|
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,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.
|
11
|
-
|
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
|
49
|
+
.map { relationship.send it } # both objects
|
48
50
|
.without(self) # the related one
|
49
|
-
.first or self # may be
|
51
|
+
.first or self # may be recursive
|
50
52
|
end
|
51
53
|
.uniq
|
52
54
|
end
|
53
55
|
|
54
|
-
def relationships
|
55
|
-
if (direction, scope = Config.find_direction
|
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
|
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
|
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! {
|
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.
|
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
|
-
|
13
|
-
|
14
|
-
|
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
|
-
|
22
|
-
|
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
|
-
|
42
|
-
|
43
|
-
|
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
|
-
|
55
|
-
|
56
|
-
|
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
|
64
|
-
def name_for_none = :"#{name
|
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
|
-
|
5
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
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
|
-
|
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
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
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
|
-
|
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,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.
|
16
|
+
values.grep_v(to).sole
|
15
17
|
else
|
16
|
-
transform_values { opposite to:
|
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
|
-
[
|
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
|
-
|
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 {
|
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
|
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
|
|
data/lib/adjustable_schema.rb
CHANGED
@@ -1,10 +1,12 @@
|
|
1
|
-
|
2
|
-
|
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
|
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.
|
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:
|
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: '
|
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: '
|
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.
|
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.
|
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
|
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,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,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
|