dbee 2.1.1 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2019-present, Blue Marble Payroll, LLC
5
+ #
6
+ # This source code is licensed under the MIT license found in the
7
+ # LICENSE file in the root directory of this source tree.
8
+ #
9
+
10
+ module Dbee
11
+ # Builds a Dbee::Schema given a Dbee::Base (DSL) model. Note that this class
12
+ # does not exist in the "Dsl" module as it is not used for defining the DSL
13
+ # itself.
14
+ class DslSchemaBuilder # :nodoc:
15
+ attr_reader :dsl_model, :key_chain
16
+
17
+ def initialize(dsl_model, key_chain)
18
+ @dsl_model = dsl_model || ArgumentError('dsl_model is required')
19
+ @key_chain = key_chain || ArgumentError('key_chain is required')
20
+
21
+ freeze
22
+ end
23
+
24
+ def to_schema
25
+ schema_spec = { dsl_model.inflected_class_name => model_config(dsl_model) }
26
+
27
+ ancestor_paths(key_chain).each do |key_path|
28
+ start_model = dsl_model
29
+
30
+ key_path.ancestor_names.each do |association_name|
31
+ start_model = append_model_and_relationship(schema_spec, start_model, association_name)
32
+ end
33
+ end
34
+
35
+ Schema.new(schema_spec)
36
+ end
37
+
38
+ private
39
+
40
+ def model_config(dsl_model) #:nodoc:
41
+ {
42
+ name: dsl_model.inflected_class_name,
43
+ partitioners: dsl_model.inherited_partitioners,
44
+ table: dsl_model.inherited_table_name,
45
+ relationships: {}
46
+ }
47
+ end
48
+
49
+ def append_model_and_relationship(schema_spec, base_model, association_name)
50
+ association = find_association!(base_model, association_name)
51
+ target_model = association.model_constant
52
+
53
+ schema_spec[target_model.inflected_class_name] ||= model_config(target_model)
54
+
55
+ schema_spec[base_model.inflected_class_name][:relationships][association.name] = {
56
+ constraints: association.constraints,
57
+ model: relationship_model_name(association, target_model)
58
+ }
59
+
60
+ target_model
61
+ end
62
+
63
+ # Returns a unique list of ancestor paths from a key_chain. Omits any
64
+ # fields on the base model.
65
+ def ancestor_paths(key_chain)
66
+ key_chain.to_unique_ancestors.key_path_set.select do |key_path|
67
+ key_path.ancestor_names.any?
68
+ end
69
+ end
70
+
71
+ def find_association!(base_model, association_name)
72
+ base_model.inherited_associations.find { |assoc| assoc.name == association_name } \
73
+ ||
74
+ raise(
75
+ ArgumentError,
76
+ "no association #{association_name} exists on model #{base_model.name}"
77
+ )
78
+ end
79
+
80
+ def relationship_model_name(association, target_model)
81
+ return nil if association.name == target_model.inflected_class_name
82
+
83
+ target_model.inflected_class_name
84
+ end
85
+ end
86
+ end
@@ -38,5 +38,16 @@ module Dbee
38
38
 
39
39
  ancestor_path_set.include?(path)
40
40
  end
41
+
42
+ # Returns a unique set of ancestors by considering all column names to be the same.
43
+ def to_unique_ancestors # :nodoc:
44
+ normalized_paths = key_path_set.map do |kp|
45
+ KeyPath.new((kp.ancestor_names + COLUMN_PLACEHOLDER).join(KeyPath::SPLIT_CHAR))
46
+ end
47
+ self.class.new(normalized_paths.uniq)
48
+ end
49
+
50
+ COLUMN_PLACEHOLDER = ['any_column'].freeze
51
+ private_constant :COLUMN_PLACEHOLDER
41
52
  end
42
53
  end
data/lib/dbee/model.rb CHANGED
@@ -9,65 +9,53 @@
9
9
 
10
10
  require_relative 'model/constraints'
11
11
  require_relative 'model/partitioner'
12
+ require_relative 'model/relationships'
13
+ require_relative 'util/make_keyed_by'
12
14
 
13
15
  module Dbee
14
16
  # In DB terms, a Model is usually a table, but it does not have to be. You can also re-model
15
17
  # your DB schema using Dbee::Models.
16
18
  class Model
17
- extend Forwardable
18
19
  acts_as_hashable
20
+ extend Dbee::Util::MakeKeyedBy
21
+ extend Forwardable
19
22
 
20
23
  class ModelNotFoundError < StandardError; end
21
24
 
22
- attr_reader :constraints, :filters, :name, :partitioners, :table
25
+ attr_reader :constraints, :filters, :name, :partitioners, :relationships, :table
23
26
 
24
27
  def_delegator :models_by_name, :values, :models
25
28
  def_delegator :models, :sort, :sorted_models
26
29
  def_delegator :constraints, :sort, :sorted_constraints
27
30
  def_delegator :partitioners, :sort, :sorted_partitioners
28
31
 
29
- def initialize(name:, constraints: [], models: [], partitioners: [], table: '')
30
- raise ArgumentError, 'name is required' if name.to_s.empty?
31
-
32
- @name = name.to_s
33
- @constraints = Constraints.array(constraints).uniq
32
+ def initialize(
33
+ name:,
34
+ constraints: [], # Exists here for tree based model backward compatibility.
35
+ relationships: [],
36
+ models: [], # Exists here for tree based model backward compatibility.
37
+ partitioners: [],
38
+ table: ''
39
+ )
40
+ @name = name
41
+ @constraints = Constraints.array(constraints || []).uniq
42
+ @relationships = Relationships.make_keyed_by(:name, relationships)
34
43
  @models_by_name = name_hash(Model.array(models))
35
44
  @partitioners = Partitioner.array(partitioners).uniq
36
45
  @table = table.to_s.empty? ? @name : table.to_s
37
46
 
47
+ ensure_input_is_valid
48
+
38
49
  freeze
39
50
  end
40
51
 
41
- # This recursive method will walk a path of model names (parts) and return back a
42
- # flattened hash instead of a nested object structure.
43
- # The hash key will be an array of strings (model names) and the value will be the
44
- # identified model.
45
- def ancestors!(parts = [], visited_parts = [], found = {})
46
- return found if Array(parts).empty?
47
-
48
- # Take the first entry in parts
49
- model_name = parts.first.to_s
50
-
51
- # Ensure we have it registered as a child, or raise error
52
- model = assert_model(model_name, visited_parts)
53
-
54
- # Push onto visited list
55
- visited_parts += [model_name]
56
-
57
- # Add found model to flattened structure
58
- found[visited_parts] = model
59
-
60
- # Recursively call for next parts in the chain
61
- model.ancestors!(parts[1..-1], visited_parts, found)
52
+ def relationship_for_name(relationship_name)
53
+ relationships[relationship_name]
62
54
  end
63
55
 
64
56
  def ==(other)
65
57
  other.instance_of?(self.class) &&
66
- other.name == name &&
67
- other.table == table &&
68
- other.sorted_constraints == sorted_constraints &&
69
- other.sorted_partitioners == sorted_partitioners &&
70
- other.sorted_models == sorted_models
58
+ other.name == name && other.table == table && children_are_equal(other)
71
59
  end
72
60
  alias eql? ==
73
61
 
@@ -75,17 +63,41 @@ module Dbee
75
63
  name <=> other.name
76
64
  end
77
65
 
66
+ def hash
67
+ [
68
+ name.hash,
69
+ table.hash,
70
+ relationships.hash,
71
+ sorted_constraints.hash,
72
+ sorted_partitioners.hash,
73
+ sorted_models.hash
74
+ ].hash
75
+ end
76
+
77
+ def to_s
78
+ name
79
+ end
80
+
78
81
  private
79
82
 
80
83
  attr_reader :models_by_name
81
84
 
82
- def assert_model(model_name, visited_parts)
83
- models_by_name[model_name] ||
84
- raise(ModelNotFoundError, "Missing: #{model_name}, after: #{visited_parts}")
85
- end
86
-
87
85
  def name_hash(array)
88
86
  array.map { |a| [a.name, a] }.to_h
89
87
  end
88
+
89
+ def children_are_equal(other)
90
+ other.relationships == relationships &&
91
+ other.sorted_constraints == sorted_constraints &&
92
+ other.sorted_partitioners == sorted_partitioners &&
93
+ other.sorted_models == sorted_models
94
+ end
95
+
96
+ def ensure_input_is_valid
97
+ raise ArgumentError, 'name is required' if name.to_s.empty?
98
+
99
+ constraints&.any? && relationships&.any? && \
100
+ raise(ArgumentError, 'constraints and relationships are mutually exclusive')
101
+ end
90
102
  end
91
103
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2019-present, Blue Marble Payroll, LLC
5
+ #
6
+ # This source code is licensed under the MIT license found in the
7
+ # LICENSE file in the root directory of this source tree.
8
+ #
9
+
10
+ require_relative 'relationships/basic'
11
+ require_relative '../util/make_keyed_by'
12
+
13
+ module Dbee
14
+ class Model
15
+ # Top-level class that allows for the making of relationships.
16
+ class Relationships
17
+ acts_as_hashable_factory
18
+ extend Dbee::Util::MakeKeyedBy
19
+
20
+ register 'basic', Basic
21
+ register '', Basic # When type is not present this will be the default
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2019-present, Blue Marble Payroll, LLC
5
+ #
6
+ # This source code is licensed under the MIT license found in the
7
+ # LICENSE file in the root directory of this source tree.
8
+ #
9
+
10
+ module Dbee
11
+ class Model
12
+ class Relationships
13
+ # A relationship from one model to another.
14
+ class Basic
15
+ acts_as_hashable
16
+
17
+ attr_reader :constraints, :model, :name
18
+
19
+ def initialize(name:, constraints: [], model: nil)
20
+ @name = name
21
+ raise ArgumentError, 'name is required' if name.to_s.empty?
22
+
23
+ @constraints = Constraints.array(constraints || []).uniq
24
+ @model = model
25
+
26
+ freeze
27
+ end
28
+
29
+ def model_name
30
+ model || name
31
+ end
32
+
33
+ def hash
34
+ [self.class.hash, name.hash, constraints.hash, model.hash].hash
35
+ end
36
+
37
+ def ==(other)
38
+ other.instance_of?(self.class) &&
39
+ other.name == name &&
40
+ other.constraints == constraints &&
41
+ other.model == model
42
+ end
43
+ alias eql? ==
44
+ end
45
+ end
46
+ end
47
+ end
data/lib/dbee/query.rb CHANGED
@@ -23,6 +23,7 @@ module Dbee
23
23
 
24
24
  attr_reader :fields,
25
25
  :filters,
26
+ :from,
26
27
  :limit,
27
28
  :sorters
28
29
 
@@ -32,12 +33,14 @@ module Dbee
32
33
 
33
34
  def initialize(
34
35
  fields: [],
36
+ from: nil,
35
37
  filters: [],
36
38
  limit: nil,
37
39
  sorters: []
38
40
  )
39
41
  @fields = Field.array(fields)
40
42
  @filters = Filters.array(filters).uniq
43
+ @from = from.to_s
41
44
  @limit = limit.to_s.empty? ? nil : limit.to_i
42
45
  @sorters = Sorters.array(sorters).uniq
43
46
 
@@ -47,6 +50,7 @@ module Dbee
47
50
  def ==(other)
48
51
  other.instance_of?(self.class) &&
49
52
  other.limit == limit &&
53
+ other.from == from &&
50
54
  other.sorted_fields == sorted_fields &&
51
55
  other.sorted_filters == sorted_filters &&
52
56
  other.sorted_sorters == sorted_sorters
@@ -26,12 +26,7 @@ module Dbee
26
26
 
27
27
  attr_reader :aggregator, :display, :filters, :key_path
28
28
 
29
- def initialize(
30
- aggregator: nil,
31
- display: nil,
32
- filters: [],
33
- key_path:
34
- )
29
+ def initialize(key_path:, aggregator: nil, display: nil, filters: [])
35
30
  raise ArgumentError, 'key_path is required' if key_path.to_s.empty?
36
31
 
37
32
  @aggregator = aggregator ? Aggregator.const_get(aggregator.to_s.upcase.to_sym) : nil
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2019-present, Blue Marble Payroll, LLC
5
+ #
6
+ # This source code is licensed under the MIT license found in the
7
+ # LICENSE file in the root directory of this source tree.
8
+ #
9
+
10
+ module Dbee
11
+ # A schema represents an entire graph of related models.
12
+ class Schema
13
+ attr_reader :models
14
+
15
+ extend Forwardable
16
+ def initialize(schema_config)
17
+ @models_by_name = Model.make_keyed_by(:name, schema_config)
18
+
19
+ freeze
20
+ end
21
+
22
+ # Given a Dbee::Model and Dbee::KeyPath, this returns a list of
23
+ # Dbee::Relationship and Dbee::Model tuples that lie on the key path.
24
+ # The returned list is a two dimensional array in
25
+ # the form of <tt>[[relationship, model], [relationship2, model2]]</tt>,
26
+ # etc. The relationships and models correspond to each ancestor part of the
27
+ # key path.
28
+ #
29
+ # The key_path argument can be either a Dbee::KeyPath or an array of
30
+ # string ancestor names.
31
+ #
32
+ # An exception is raised of the provided key_path contains relationship
33
+ # names that do not exist in this schema.
34
+ def expand_query_path(model, key_path, query_path = [])
35
+ ancestors = key_path.respond_to?(:ancestor_names) ? key_path.ancestor_names : key_path
36
+ relationship_name = ancestors.first
37
+ return query_path unless relationship_name
38
+
39
+ relationship = relationship_for_name!(model, relationship_name)
40
+ join_model = model_for_name!(relationship.model_name)
41
+ expand_query_path(
42
+ join_model,
43
+ ancestors.drop(1),
44
+ query_path + [[relationship_for_name!(model, relationship_name), join_model]]
45
+ )
46
+ end
47
+
48
+ def model_for_name!(model_name)
49
+ models_by_name[model_name.to_s] || raise(Model::ModelNotFoundError, model_name)
50
+ end
51
+
52
+ def ==(other)
53
+ other.instance_of?(self.class) && other.send(:models_by_name) == models_by_name
54
+ end
55
+ alias eql? ==
56
+
57
+ private
58
+
59
+ attr_reader :models_by_name
60
+
61
+ def relationship_for_name!(model, rel_name)
62
+ model.relationship_for_name(rel_name) ||
63
+ raise("model '#{model.name}' does not have a '#{rel_name}' relationship")
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Copyright (c) 2019-present, Blue Marble Payroll, LLC
5
+ #
6
+ # This source code is licensed under the MIT license found in the
7
+ # LICENSE file in the root directory of this source tree.
8
+ #
9
+
10
+ module Dbee
11
+ # This creates a Dbee::Schema from a variety of different inputs:
12
+ #
13
+ # 1. The hash representation of a schema.
14
+ # 2. A Dbee::Base subclass (code based models).
15
+ #
16
+ # For backward compatibility, tree based models are also supported in the
17
+ # following formats:
18
+ #
19
+ # 3. The hash representation of a tree based model.
20
+ # 4. Dbee::Model instances in tree based form (using the deprecated constraints and models
21
+ # attributes).
22
+ class SchemaCreator # :nodoc:
23
+ attr_reader :schema
24
+
25
+ # An ArgumentError is raised if the query "from" attribute differs from the name of the root
26
+ # model of a tree based model or if the "from" attribute is blank.
27
+ def initialize(schema_or_model, query)
28
+ @orig_query = Query.make(query) || raise(ArgumentError, 'query is required')
29
+ raise ArgumentError, 'a schema or model is required' unless schema_or_model
30
+
31
+ @schema = make_schema(schema_or_model)
32
+
33
+ # Note that for backward compatibility reasons, this validation does not
34
+ # exist in the DBee::Query class. This allows continued support for
35
+ # old callers who depend on the "from" field being inferred from the root
36
+ # tree model name.
37
+ raise ArgumentError, 'query requires a from model name' if expected_from_model.empty?
38
+
39
+ validate_query_from_model!
40
+
41
+ freeze
42
+ end
43
+
44
+ # Returns a Dbee::Query instance with a "from" attribute which is
45
+ # sometimes derived for tree based models.
46
+ def query
47
+ return orig_query if expected_from_model == orig_query.from
48
+
49
+ Query.new(
50
+ from: expected_from_model,
51
+ fields: orig_query.fields,
52
+ filters: orig_query.filters,
53
+ sorters: orig_query.sorters,
54
+ limit: orig_query.limit
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :orig_query, :expected_from_model
61
+
62
+ def make_schema(input)
63
+ @expected_from_model = orig_query.from
64
+
65
+ return input if input.is_a?(Dbee::Schema)
66
+
67
+ if input.respond_to?(:to_schema)
68
+ @expected_from_model = input.inflected_class_name
69
+ return input.to_schema(orig_query.key_chain)
70
+ end
71
+
72
+ model_or_schema = to_object(input)
73
+ if model_or_schema.is_a?(Model)
74
+ @expected_from_model = model_or_schema.name.to_s
75
+ SchemaFromTreeBasedModel.convert(model_or_schema)
76
+ else
77
+ model_or_schema
78
+ end
79
+ end
80
+
81
+ def to_object(input)
82
+ return input unless input.is_a?(Hash)
83
+
84
+ if tree_based_hash?(input)
85
+ Model.make(input)
86
+ else
87
+ Schema.new(input)
88
+ end
89
+ end
90
+
91
+ def validate_query_from_model!
92
+ !orig_query.from.empty? && expected_from_model.to_s != orig_query.from.to_s && \
93
+ raise(
94
+ ArgumentError,
95
+ "expected from model to be '#{expected_from_model}' but got '#{orig_query.from}'"
96
+ )
97
+ end
98
+
99
+ def tree_based_hash?(hash)
100
+ name = hash[:name] || hash['name']
101
+
102
+ # In the unlikely event that schema based hash had a model called "name",
103
+ # its value would either be nil or a hash.
104
+ name.is_a?(String) || name.is_a?(Symbol)
105
+ end
106
+ end
107
+ end