dbee 2.1.1 → 3.0.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.
@@ -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