dbee 2.0.2 → 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.
data/dbee.gemspec CHANGED
@@ -15,20 +15,31 @@ Gem::Specification.new do |s|
15
15
  s.email = ['mruggio@bluemarblepayroll.com']
16
16
  s.files = `git ls-files`.split("\n")
17
17
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
- s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
18
+ s.bindir = 'exe'
19
+ s.executables = []
19
20
  s.homepage = 'https://github.com/bluemarblepayroll/dbee'
20
21
  s.license = 'MIT'
22
+ s.metadata = {
23
+ 'bug_tracker_uri' => 'https://github.com/bluemarblepayroll/dbee/issues',
24
+ 'changelog_uri' => 'https://github.com/bluemarblepayroll/dbee/blob/master/CHANGELOG.md',
25
+ 'documentation_uri' => 'https://www.rubydoc.info/gems/dbee',
26
+ 'homepage_uri' => s.homepage,
27
+ 'source_code_uri' => s.homepage
28
+ }
21
29
 
22
- s.required_ruby_version = '>= 2.3.8'
30
+ s.required_ruby_version = '>= 2.5'
23
31
 
24
- s.add_dependency('acts_as_hashable', '~>1', '>=1.1.0')
32
+ s.add_dependency('acts_as_hashable', '~>1', '>=1.2.0')
25
33
  s.add_dependency('dry-inflector', '~>0')
26
34
 
27
35
  s.add_development_dependency('guard-rspec', '~>4.7')
28
36
  s.add_development_dependency('pry', '~>0')
37
+ s.add_development_dependency('pry-byebug')
29
38
  s.add_development_dependency('rake', '~> 13')
30
39
  s.add_development_dependency('rspec')
31
- s.add_development_dependency('rubocop', '~>0.76.0')
32
- s.add_development_dependency('simplecov', '~>0.17.0')
33
- s.add_development_dependency('simplecov-console', '~>0.5.0')
40
+ s.add_development_dependency('rubocop', '~> 1')
41
+ s.add_development_dependency('rubocop-rake')
42
+ s.add_development_dependency('rubocop-rspec')
43
+ s.add_development_dependency('simplecov', '~>0.19.0')
44
+ s.add_development_dependency('simplecov-console', '~>0.7.0')
34
45
  end
data/exe/.gitkeep ADDED
File without changes
data/lib/dbee.rb CHANGED
@@ -12,11 +12,16 @@ require 'dry/inflector'
12
12
  require 'forwardable'
13
13
 
14
14
  require_relative 'dbee/base'
15
+ require_relative 'dbee/constant_resolver'
16
+ require_relative 'dbee/dsl_schema_builder'
15
17
  require_relative 'dbee/key_chain'
16
18
  require_relative 'dbee/key_path'
17
19
  require_relative 'dbee/model'
18
- require_relative 'dbee/query'
19
20
  require_relative 'dbee/providers'
21
+ require_relative 'dbee/query'
22
+ require_relative 'dbee/schema'
23
+ require_relative 'dbee/schema_creator'
24
+ require_relative 'dbee/schema_from_tree_based_model'
20
25
 
21
26
  # Top-level namespace that provides the main public API.
22
27
  module Dbee
@@ -30,16 +35,11 @@ module Dbee
30
35
  @inflector ||= Dry::Inflector.new
31
36
  end
32
37
 
33
- def sql(model, query, provider)
34
- query = Query.make(query)
35
- model =
36
- if model.is_a?(Hash) || model.is_a?(Model)
37
- Model.make(model)
38
- else
39
- model.to_model(query.key_chain)
40
- end
38
+ def sql(schema_or_model, query_input, provider)
39
+ raise ArgumentError, 'a provider is required' unless provider
41
40
 
42
- provider.sql(model, query)
41
+ schema_compat = SchemaCreator.new(schema_or_model, query_input)
42
+ provider.sql(schema_compat.schema, schema_compat.query)
43
43
  end
44
44
  end
45
45
  end
data/lib/dbee/base.rb CHANGED
@@ -7,8 +7,8 @@
7
7
  # LICENSE file in the root directory of this source tree.
8
8
  #
9
9
 
10
- require_relative 'dsl/association'
11
10
  require_relative 'dsl/association_builder'
11
+ require_relative 'dsl/association'
12
12
  require_relative 'dsl/methods'
13
13
  require_relative 'dsl/reflectable'
14
14
 
@@ -22,23 +22,9 @@ module Dbee
22
22
  BASE_CLASS_CONSTANT = Dbee::Base
23
23
 
24
24
  class << self
25
- # This method is cycle-resistant due to the fact that it is a requirement to send in a
26
- # key_chain. That means each model produced using to_model is specific to a set of desired
27
- # fields. Basically, you cannot derive a Model from a Base subclass without the context
28
- # of a Query. This is not true for configuration-first Model definitions because, in that
29
- # case, cycles do not exist since the nature of the configuration is flat.
30
- def to_model(key_chain, name = nil, constraints = [], path_parts = [])
31
- derived_name = name.to_s.empty? ? inflected_class_name(self.name) : name.to_s
32
- key = [key_chain, derived_name, constraints, path_parts]
33
-
34
- to_models[key] ||= Model.make(
35
- model_config(
36
- key_chain,
37
- derived_name,
38
- constraints,
39
- path_parts + [name]
40
- )
41
- )
25
+ # Returns the smallest needed Dbee::Schema for the provided key_chain.
26
+ def to_schema(key_chain)
27
+ DslSchemaBuilder.new(self, key_chain).to_schema
42
28
  end
43
29
 
44
30
  def inherited_table_name
@@ -58,43 +44,15 @@ module Dbee
58
44
  end
59
45
  end
60
46
 
61
- private
62
-
63
- def model_config(key_chain, name, constraints, path_parts)
64
- {
65
- constraints: constraints,
66
- models: associations(key_chain, path_parts),
67
- name: name,
68
- partitioners: inherited_partitioners,
69
- table: inherited_table_name
70
- }
71
- end
72
-
73
- def associations(key_chain, path_parts)
74
- inherited_associations.select { |c| key_chain.ancestor_path?(path_parts, c.name) }
75
- .map do |association|
76
- model_constant = association.model_constant
77
-
78
- model_constant.to_model(
79
- key_chain,
80
- association.name,
81
- association.constraints,
82
- path_parts
83
- )
84
- end
47
+ def inflected_class_name
48
+ inflector.underscore(inflector.demodulize(name))
85
49
  end
86
50
 
87
- def to_models
88
- @to_models ||= {}
89
- end
51
+ private
90
52
 
91
53
  def inflected_table_name(name)
92
54
  inflector.pluralize(inflector.underscore(inflector.demodulize(name)))
93
55
  end
94
-
95
- def inflected_class_name(name)
96
- inflector.underscore(inflector.demodulize(name))
97
- end
98
56
  end
99
57
  end
100
58
  end
@@ -0,0 +1,34 @@
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 class is responsible for turning strings and symbols into constants.
12
+ # It does not deal with inflection, simply just constant resolution.
13
+ class ConstantResolver
14
+ # Only use Module constant resolution if a string or symbol was passed in.
15
+ # Any other type is defined as an acceptable constant and is simply returned.
16
+ def constantize(value)
17
+ value.is_a?(String) || value.is_a?(Symbol) ? object_constant(value) : value
18
+ end
19
+
20
+ private
21
+
22
+ # If the constant has been loaded, we can safely use it through const_get.
23
+ # If the constant has not been loaded, we need to defer to const_missing to resolve it.
24
+ # If we blindly call const_get, it may return false positives for namespaced constants
25
+ # or anything nested.
26
+ def object_constant(value)
27
+ if Object.const_defined?(value, false)
28
+ Object.const_get(value, false)
29
+ else
30
+ Object.const_missing(value)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -19,16 +19,17 @@ module Dbee
19
19
  raise ArgumentError, 'inflector is required' unless inflector
20
20
  raise ArgumentError, 'name is required' if name.to_s.empty?
21
21
 
22
- @on_class_name = on_class_name
23
- @inflector = inflector
24
- @name = name.to_s
25
- @opts = opts || {}
22
+ @on_class_name = on_class_name
23
+ @inflector = inflector
24
+ @name = name.to_s
25
+ @opts = opts || {}
26
+ @constant_resolver = ConstantResolver.new
26
27
 
27
28
  freeze
28
29
  end
29
30
 
30
31
  def model_constant
31
- constantize(class_name)
32
+ constant_resolver.constantize(class_name)
32
33
  end
33
34
 
34
35
  def constraints
@@ -37,6 +38,8 @@ module Dbee
37
38
 
38
39
  private
39
40
 
41
+ attr_reader :constant_resolver
42
+
40
43
  def class_name
41
44
  opts[:model] || relative_class_name
42
45
  end
@@ -47,20 +50,6 @@ module Dbee
47
50
  def relative_class_name
48
51
  (on_class_name.split('::')[0...-1] + [inflector.classify(name)]).join('::')
49
52
  end
50
-
51
- # Only use Module constant resolution if a string or symbol was passed in.
52
- # Any other type is defined as an acceptable constant and is simply returned.
53
- def constantize(value)
54
- value.is_a?(String) || value.is_a?(Symbol) ? object_constant(value) : value
55
- end
56
-
57
- # If the constant has been loaded, we can safely use it through const_get.
58
- # If the constant has not been loaded, we need to defer to const_missing to resolve it.
59
- # If we blindly call const_get, it may return false positives for namespaced constants
60
- # or anything nested.
61
- def object_constant(value)
62
- Object.const_defined?(value) ? Object.const_get(value) : Object.const_missing(value)
63
- end
64
53
  end
65
54
  end
66
55
  end
@@ -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