adjustable_schema 0.5.1 → 0.6.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: e850de1792bf132577dfde2173875a6935a5f2e11a0d0b4394739d34585216b3
4
- data.tar.gz: 0c570f510d467df417755cb7b6fcff67744b2295ce499fbeb298b5f9efa427bd
3
+ metadata.gz: f9fab61b2df51275e0e3c9ebf681dc48a30378a48248b1f02c7bad89afc47075
4
+ data.tar.gz: 266abb846dba3f7119dd424f855e77942d3ffc98b012b44d2210902cff32a604
5
5
  SHA512:
6
- metadata.gz: 3a6568bb17944cda2fe8acfdbfde2044690bcbc947fdea72fa25e3a994e664b2d95cd8cd36a2562b43236327cba0205a8c84190bbf404cb859d7717e09abb1e3
7
- data.tar.gz: e8957e5c232b69467b07e684a134a07e1654e2038f62701d728eb8e6c544047548b54d4df28457ab8c3d5621b9cb2e19d0e2658e9711089d236fe826d300506c
6
+ metadata.gz: 9f2b270be59abff4ada4cfc1d9146f00bcbf43c8473588e23f411b71a5daf4f1928e1c075da4b3d4112e1883245249a5a6f42c5bbff148663ceca5fed8492bbe
7
+ data.tar.gz: f9ea77c562420ed47c701f963d2fcc9d7835ead91411e7e150944d9d9a9ba49a09b6bdc5ea44c4bcf7bf73e98e9767b8eb383b493f8d5f5aaae1821a4f10cf6c
@@ -1,16 +1,15 @@
1
1
  module AdjustableSchema
2
2
  class Relationship
3
3
  class Role < ApplicationRecord
4
- include Organizer::Identifiable.by :name
4
+ include Organizer::Identifiable.by :name, symbolized: true
5
5
 
6
6
  has_many :relationships
7
7
 
8
8
  validates :name, presence: true, uniqueness: true
9
9
 
10
- # FIXME: depends on default naming
11
- scope :available, -> { with_relationships { of :abstract } }
12
- scope :of, -> source { with_relationships { of source } }
13
- scope :for, -> target { with_relationships { to target } }
10
+ scope :available, -> { with_relationships { send Config.shortcuts[:source], :abstract } }
11
+ scope :of, -> source { with_relationships { send Config.shortcuts[:source], source } }
12
+ scope :for, -> target { with_relationships { send Config.shortcuts[:target], target } }
14
13
 
15
14
  def self.with_relationships(&)
16
15
  joins(:relationships)
@@ -19,11 +18,13 @@ module AdjustableSchema
19
18
 
20
19
  class << self
21
20
  def [] *names, **scopes
22
- return super *names if names.any?
23
-
24
- scopes
25
- .map { of(_1).for _2 }
26
- .reduce &:merge
21
+ if scopes.any?
22
+ with_relationships { self[**scopes] }
23
+ .distinct
24
+ else
25
+ all
26
+ end
27
+ .scoping { names.any? ? super(*names) : all }
27
28
  end
28
29
  end
29
30
  end
@@ -72,6 +72,16 @@ module AdjustableSchema
72
72
  end
73
73
 
74
74
  class << self
75
+ def [] **scopes
76
+ scopes
77
+ .map do
78
+ self
79
+ .send(Config.shortcuts[:source], _1)
80
+ .send(Config.shortcuts[:target], _2)
81
+ end
82
+ .reduce &:or
83
+ end
84
+
75
85
  def seed! *models, roles: [], **_models
76
86
  return seed!({ **Hash[*models], **_models }, roles:) if _models.any? # support keyword arguments syntax
77
87
 
@@ -5,20 +5,23 @@ module AdjustableSchema
5
5
  private
6
6
 
7
7
  def adjust_associations
8
- relationships.each do |direction, relationships|
9
- relationships
10
- .select(&:"#{direction}_type")
11
- .each do |relationship|
12
- setup_association direction, relationship.send("#{direction}_type").constantize, relationship.role
13
- end
14
- end
8
+ relationships
9
+ .flat_map do |direction, relationships|
10
+ relationships
11
+ .select(&:"#{direction}_type")
12
+ .each do |relationship|
13
+ setup_association direction, relationship.send("#{direction}_type").constantize, relationship.role
14
+ end
15
+ end
16
+ .presence
17
+ &.tap do # finally, if any relationships have been set up
18
+ include Relationships::InstanceMethods
19
+ end
15
20
  end
16
21
 
17
22
  def setup_association direction, target = self, role = nil
18
23
  adjustable_association(direction, target ).define
19
24
  adjustable_association(direction, target, role).define if role
20
-
21
- include Relationships::InstanceMethods
22
25
  end
23
26
 
24
27
  def adjustable_association(...)
@@ -1,12 +1,15 @@
1
+ require 'memery'
2
+
1
3
  module AdjustableSchema
2
4
  module ActiveRecord
3
5
  concern :Relationships do
4
6
  class_methods do
5
- def relationships
6
- @relationships ||= # cache
7
- Config.association_directions.to_h do
8
- [ _1, Relationship.abstract.send(Config.shortcuts.opposite[_1], self) ]
9
- end
7
+ include Memery
8
+
9
+ memoize def relationships
10
+ Config.association_directions.to_h do
11
+ [ _1, Relationship.abstract.send(Config.shortcuts.opposite[_1], self) ]
12
+ end
10
13
  end
11
14
 
12
15
  def roles(&) = Role.of self, &
@@ -14,76 +17,57 @@ module AdjustableSchema
14
17
  private
15
18
 
16
19
  def define_recursive_methods association_name, method
17
- redefine_method tree_method = "#{method.to_s.singularize}_tree" do
20
+ define_method method do
18
21
  send(association_name)
19
- .inject([]) do |tree, node|
20
- tree << node << node.send(tree_method)
21
- end
22
- .reject &:blank?
23
- end
24
-
25
- redefine_method "#{method}_with_distance" do
26
- (with_distance = -> (level, distance) {
27
- case level
28
- when Array
29
- level.inject({}) do |hash, node|
30
- hash.merge with_distance[node, distance.next]
31
- end
32
- else
33
- { level => distance }
34
- end
35
- })[send(tree_method), 0]
36
- end
37
-
38
- redefine_method method do
39
- send(tree_method).flatten
22
+ .recursive
40
23
  end
41
24
  end
42
25
  end
43
26
 
44
27
  concern :InstanceMethods do # to include when needed
45
28
  included do
29
+ scope :roleless, -> { merge Relationship.nameless }
30
+
46
31
  Config.association_directions.recursive
47
32
  .select { reflect_on_association _1 }
48
33
  .reject { method_defined? _2 }
49
- .each { define_recursive_methods _1, _2 }
34
+ .each { define_recursive_methods _1, _2 }
50
35
  end
51
36
 
52
37
  def related?(...)
53
38
  relationships(...)
54
- .values
55
- .reduce(&:or)
56
39
  .any?
57
40
  end
58
41
 
59
42
  def related(...)
60
43
  relationships(...)
61
- .flat_map do |direction, relationships|
62
- relationships
63
- .preload(direction)
64
- .map &direction
44
+ .preload(Config.association_directions)
45
+ .map do |relationship|
46
+ Config.association_directions
47
+ .map { relationship.send _1 } # both objects
48
+ .without(self) # the related one
49
+ .first or self # may be self-related
65
50
  end
66
51
  .uniq
67
52
  end
68
53
 
69
- def relationships *names, **options
70
- if (direction, scope = Config.find_direction options)
71
- {
72
- direction => relationships_to(direction)
73
- .try(Config.shortcuts[direction], scope) # filter by related objects
74
- }
75
- else # both directions
76
- Config.association_directions.to_h { [ _1, relationships_to(_1) ] }
54
+ def relationships **options
55
+ if (direction, scope = Config.find_direction **options) # filter by direction & related objects
56
+ relationships_to(direction)
57
+ .send Config.shortcuts[direction], scope
58
+ else # all in both directions
59
+ Config.association_directions
60
+ .map { relationships_to _1 }
61
+ .reduce(&:or)
77
62
  end
78
- .compact
79
- .tap do |relationships|
80
- break relationships.transform_values { _1.named names } if names.any? # filter by role
81
- end
82
63
  end
83
64
 
84
65
  private
85
66
 
86
- def relationships_to(direction) = try "#{direction}_relationships"
67
+ def relationships_to direction
68
+ try "#{direction}_relationships" or
69
+ Relationship.none
70
+ end
87
71
  end
88
72
  end
89
73
  end
@@ -0,0 +1,4 @@
1
+ # HACK: non-public Rails API used
2
+ ActiveModel::Name.class_eval do
3
+ def unnamespaced = @unnamespaced || name
4
+ end
@@ -1,7 +1,11 @@
1
+ require 'memery'
2
+
1
3
  module AdjustableSchema
2
4
  module ActiveRecord
3
5
  class Association
4
6
  concerning :Naming do
7
+ include Memery
8
+
5
9
  module Inflections
6
10
  refine String do
7
11
  def passivize
@@ -12,50 +16,46 @@ module AdjustableSchema
12
16
 
13
17
  using Inflections
14
18
 
15
- def name
16
- @name ||= # cache
17
- (role ? name_with_role : name_without_role)
18
- .to_s
19
- .tableize
20
- .to_sym
19
+ memoize def name
20
+ (role ? name_with_role : name_without_role)
21
+ .to_s
22
+ .tableize
23
+ .to_sym
21
24
  end
22
25
 
23
- def target_name
24
- @target_name ||= # cache
25
- target.name
26
- .split('::')
27
- .reverse
28
- .join
29
- .underscore
26
+ memoize def target_name
27
+ target.model_name.unnamespaced
28
+ .split('::')
29
+ .reverse
30
+ .join
31
+ .underscore
30
32
  end
31
33
 
32
34
  def relationships_name = :"#{role ? name_with_role : direction}_relationships"
33
35
 
34
36
  private
35
37
 
36
- def name_with_role
37
- @name_with_role ||= # cache
38
- if self_targeted?
39
- {
40
- source: role.name,
41
- target: "#{role.name.passivize}_#{target_name}",
42
- }[direction]
43
- else
44
- "#{{
45
- source: role.name,
46
- target: role.name.passivize,
47
- }[direction]}_#{target_name}"
48
- end
38
+ memoize def name_with_role
39
+ if self_targeted?
40
+ {
41
+ source: role.name,
42
+ target: "#{role.name.passivize}_#{target_name}",
43
+ }[direction]
44
+ else
45
+ "#{{
46
+ source: role.name,
47
+ target: role.name.passivize,
48
+ }[direction]}_#{target_name}"
49
+ end
49
50
  end
50
51
 
51
- def name_without_role
52
- @name_without_role ||= # cache
53
- if self_targeted?
54
- Config.association_directions
55
- .self_related[direction]
56
- else
57
- target_name
58
- end
52
+ memoize def name_without_role
53
+ if self_targeted?
54
+ Config.association_directions
55
+ .self_related[direction]
56
+ else
57
+ target_name
58
+ end
59
59
  end
60
60
  end
61
61
  end
@@ -0,0 +1,52 @@
1
+ module AdjustableSchema
2
+ module ActiveRecord
3
+ 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
10
+
11
+ 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
33
+ end
34
+
35
+ private
36
+
37
+ def association_name = @association.reflection.name
38
+
39
+ def inverse_association_name
40
+ Config.association_directions
41
+ .recursive
42
+ .keys
43
+ .without(association_name)
44
+ .sole
45
+ end
46
+
47
+ def recursive_table = Arel::Table.new [ :recursive, association_name, klass.table_name ] * '_'
48
+ def inverse_table = Arel::Table.new [ inverse_association_name, klass.table_name ] * '_' # HACK: depends on ActiveRecord internals
49
+ end
50
+ end
51
+ end
52
+ end
@@ -2,16 +2,28 @@ module AdjustableSchema
2
2
  module ActiveRecord
3
3
  class Association < Struct.new(:owner, :direction, :target, :role)
4
4
  require_relative 'association/naming'
5
+ require_relative 'association/scopes'
5
6
 
6
7
  def define
7
8
  name.tap do |association_name|
8
- has_many association_name,
9
+ association = self # save context
10
+
11
+ has_many association_name, **(options = {
9
12
  through: define_relationships,
10
13
  source: direction,
11
14
  source_type: target.base_class.name,
12
15
  class_name: target.name
16
+ }) do
17
+ include Scopes
18
+ include Scopes::Recursive if association.self_targeted?
19
+ end
20
+
21
+ unless role
22
+ has_many target_name.tableize.to_sym, -> { roleless }, **options if
23
+ self_targeted?
13
24
 
14
- define_role_methods unless role
25
+ define_role_methods
26
+ end
15
27
  end
16
28
  end
17
29
 
@@ -21,8 +33,9 @@ module AdjustableSchema
21
33
 
22
34
  def define_relationships
23
35
  relationships_name.tap do |association_name|
24
- has_many association_name, role && -> { where role: },
36
+ has_many association_name, (role = self.role) && -> { where role: },
25
37
  as: Config.association_directions.opposite(to: direction),
38
+ dependent: :destroy_async,
26
39
  class_name: 'AdjustableSchema::Relationship'
27
40
  end
28
41
  end
@@ -0,0 +1,103 @@
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
@@ -1,24 +1,29 @@
1
+ require 'memery'
2
+
1
3
  module AdjustableSchema
2
4
  module Config
5
+ include Memery
6
+
3
7
  module Naming
4
- def shortcuts
5
- @shortcuts ||= # cache
6
- config(:shortcut).tap do |shortcuts|
7
- def shortcuts.opposite to: nil
8
- if to
9
- values.reject { _1 == to }.sole
10
- else
11
- transform_values { opposite to: _1 }
12
- end
13
- end
8
+ include Memery
9
+
10
+ memoize def shortcuts
11
+ config(:shortcut).tap do |shortcuts|
12
+ def shortcuts.opposite to: nil
13
+ if to
14
+ values.reject { _1 == to }.sole
15
+ else
16
+ transform_values { opposite to: _1 }
14
17
  end
18
+ end
19
+ end
15
20
  end
16
21
 
17
22
  def self_related = config :self_related
18
23
 
19
24
  def recursive
20
25
  config.values.to_h do
21
- [ _1[:self_related].to_s.pluralize.to_sym, _1[:recursive] ]
26
+ [ _1[:self_related].to_s.pluralize.to_sym, _1[:recursive].to_sym ]
22
27
  end
23
28
  end
24
29
 
@@ -30,7 +35,7 @@ module AdjustableSchema
30
35
 
31
36
  def config section = nil
32
37
  if section
33
- config.transform_values { _1[section] }
38
+ config.transform_values { _1[section].to_sym }
34
39
  else
35
40
  Config.association_names # TODO: DRY
36
41
  end
@@ -39,13 +44,12 @@ module AdjustableSchema
39
44
 
40
45
  module_function
41
46
 
42
- def association_directions
43
- @association_directions ||= # cache
44
- association_names.keys.tap do |directions|
45
- class << directions
46
- include Naming
47
- end
47
+ memoize def association_directions
48
+ association_names.keys.tap do |directions|
49
+ class << directions
50
+ include Naming
48
51
  end
52
+ end
49
53
  end
50
54
 
51
55
  def find_direction(...)
@@ -67,6 +71,7 @@ module AdjustableSchema
67
71
 
68
72
  def normalize **options
69
73
  shortcuts
74
+ .tap { options.assert_valid_keys _1.keys, _1.values }
70
75
  .select { _2.in? options }
71
76
  .each { options[_1] = options.delete _2 }
72
77
 
@@ -1,3 +1,5 @@
1
+ require 'organizer'
2
+
1
3
  module AdjustableSchema
2
4
  class Engine < ::Rails::Engine
3
5
  isolate_namespace AdjustableSchema
@@ -1,3 +1,3 @@
1
1
  module AdjustableSchema
2
- VERSION = '0.5.1'
2
+ VERSION = '0.6.0'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: adjustable_schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Senko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-01-28 00:00:00.000000000 Z
11
+ date: 2024-02-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -40,6 +40,20 @@ dependencies:
40
40
  version: '0.2'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: organizer-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.2'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: memery
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
59
  - - ">="
@@ -77,12 +91,15 @@ files:
77
91
  - app/models/concerns/adjustable_schema/active_record/relationships.rb
78
92
  - app/views/layouts/adjustable_schema/application.html.erb
79
93
  - config/initializers/associations.rb
94
+ - config/initializers/model_names.rb
80
95
  - config/routes.rb
81
96
  - db/migrate/01_create_adjustable_schema_relationship_tables.rb
82
97
  - lib/adjustable_schema.rb
83
98
  - lib/adjustable_schema/active_record.rb
84
99
  - lib/adjustable_schema/active_record/association.rb
85
100
  - lib/adjustable_schema/active_record/association/naming.rb
101
+ - lib/adjustable_schema/active_record/association/scopes.rb
102
+ - lib/adjustable_schema/active_record/query_methods.rb
86
103
  - lib/adjustable_schema/authors.rb
87
104
  - lib/adjustable_schema/config.rb
88
105
  - lib/adjustable_schema/engine.rb
@@ -94,7 +111,7 @@ licenses:
94
111
  metadata:
95
112
  homepage_uri: https://github.com/Alexander-Senko/adjustable_schema
96
113
  source_code_uri: https://github.com/Alexander-Senko/adjustable_schema
97
- changelog_uri: https://github.com/Alexander-Senko/adjustable_schema/blob/v0.5.1/CHANGELOG.md
114
+ changelog_uri: https://github.com/Alexander-Senko/adjustable_schema/blob/v0.6.0/CHANGELOG.md
98
115
  post_install_message:
99
116
  rdoc_options: []
100
117
  require_paths:
@@ -110,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
110
127
  - !ruby/object:Gem::Version
111
128
  version: '0'
112
129
  requirements: []
113
- rubygems_version: 3.5.3
130
+ rubygems_version: 3.5.5
114
131
  signing_key:
115
132
  specification_version: 4
116
133
  summary: Adjustable data schemas for Rails