adjustable_schema 0.5.1 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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