jet_set 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: fbe98545a86e3ed348ad663f51d537b02da5c016
4
+ data.tar.gz: 885290e0e7a62b94b21c78710897c722e784dd57
5
+ SHA512:
6
+ metadata.gz: 5a2da70455acaf1b6e6d5cab4374b6f3a664fbb9a8b6672be44af38e1ea8889cfc26ffbf48c705fd454465095bc07a0db2cca596b1f8802999befc828fcd3173
7
+ data.tar.gz: 0a574a40910abb7dfef33015d11cd1c0b6b53bffe25a078c409287e7c7fd904e0d9e525cecb89682cc91b4c4f80efcf00117e6db01c6abb82cf7e3e778df82b5
data/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
13
+ .idea
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.1
5
+ before_install: gem install bundler -v 1.15.4
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem 'hypo', path: '../hypo'
4
+
5
+ gemspec
6
+
7
+
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Vladimir Kalinkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Vladimir Kalinkin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # JetSet
2
+
3
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/jet_set`. To experiment with that code, run `bin/console` for an interactive prompt.
4
+
5
+ TODO: Delete this and the text above, and describe your gem
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'jet_set'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install jet_set
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/jet_set.
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "jet_set"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/jet_set.gemspec ADDED
@@ -0,0 +1,40 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'jet_set/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'jet_set'
8
+ spec.version = JetSet::VERSION
9
+ spec.authors = ['Vladimir Kalinkin']
10
+ spec.email = ['vova.kalinkin@gmail.com']
11
+
12
+ spec.summary = 'JetSet is a microscopic ORM for DDD projects.'
13
+ spec.description = ''
14
+ spec.homepage = 'https://github.com/cylon-v/jet_set'
15
+ spec.license = 'MIT'
16
+
17
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
21
+ else
22
+ raise "RubyGems 2.0 or newer is required to protect against " \
23
+ "public gem pushes."
24
+ end
25
+
26
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
27
+ f.match(%r{^(test|spec|features)/})
28
+ end
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.add_dependency 'sequel', '~> 5.4.0'
34
+
35
+ spec.add_development_dependency 'bundler', '~> 1.15'
36
+ spec.add_development_dependency 'rake', '~> 10.0'
37
+ spec.add_development_dependency 'rspec', '~> 3.0'
38
+ spec.add_development_dependency 'sqlite3', '~> 1.3'
39
+ spec.add_development_dependency 'simplecov', '~> 0.16'
40
+ end
data/lib/jet_set.rb ADDED
@@ -0,0 +1,13 @@
1
+ require 'hypo'
2
+ require 'sequel'
3
+ require 'sequel/extensions/inflector'
4
+ require 'jet_set/environment'
5
+ require 'jet_set/mapping'
6
+ require 'jet_set/version'
7
+
8
+ module JetSet
9
+ Sequel.extension :inflector
10
+ include JetSet::Environment
11
+
12
+ module_function :init, :open_session, :register_session, :map
13
+ end
@@ -0,0 +1,19 @@
1
+ module JetSet
2
+ # A structure for tracking an entity attribute state.
3
+ class Attribute
4
+ attr_reader :name, :value
5
+
6
+ # Initializes the attribute state
7
+ def initialize(name, value)
8
+ @name = name
9
+ @value = value
10
+ end
11
+
12
+ # Returns +true+ if the attribute is changed and +false+ if it's not.
13
+ # Parameters:
14
+ # +value+:: current value to compare.
15
+ def changed?(value)
16
+ @value != value
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,17 @@
1
+ module JetSet
2
+ # Collection represents a collection attribute mapping.
3
+ # Should be instantiated by method +collection+ of +JetSet::EntityMapping+ instance.
4
+ class Collection
5
+ attr_reader :name, :type, :using
6
+
7
+ # Parameters:
8
+ # +name+:: name of the attribute
9
+ # +type+:: class of an entity
10
+ # +using+:: (optional) name of many-to-many association table if needed.
11
+ def initialize(name, type, using = nil)
12
+ @name = name
13
+ @type = type
14
+ @using = using
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,32 @@
1
+ module JetSet
2
+ # Dependency graph stores a matrix of entities dependencies and is used by +JetSet::Session+
3
+ # for ordering entities for persistence process using method +order+.
4
+ class DependencyGraph
5
+ # Initializes a dependency graph using a mapping definition.
6
+ # Parameters:
7
+ # +mapping+:: JetSet::Mapping
8
+ def initialize(mapping)
9
+ @matrix = {}
10
+
11
+ mapping.entity_mappings.keys.each do |key|
12
+ entity = mapping.entity_mappings[key]
13
+ @matrix[entity.type.name] = entity.dependencies.map{|d| d.name}
14
+ end
15
+ end
16
+
17
+ # Orders entities according their dependencies in mapping definition.
18
+ # Parameters:
19
+ # +entities+:: entities to order.
20
+ def order(entities)
21
+ groups = {}
22
+ entities.each do |entity|
23
+ groups[entity.class.name] ||= []
24
+ groups[entity.class.name] << entity
25
+ end
26
+
27
+ type_order = groups.keys.sort{|a, b| @matrix[b].include?(a) ? -1 : 1}
28
+ entity_order = type_order.map{|type| groups[type]}
29
+ entity_order.flatten
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ require 'jet_set/mixin/identity'
2
+ require 'jet_set/mixin/entity'
3
+
4
+ module JetSet
5
+ # A converter of a pure Ruby object to JetSet trackable object.
6
+ class EntityBuilder
7
+
8
+ # Parameters:
9
+ # +mapping+:: an instance of +JetSet::Mapping+
10
+ def initialize(mapping)
11
+ @mapping = mapping
12
+ end
13
+
14
+ # Makes passed object to be trackable.
15
+ # +object+:: pure Ruby object
16
+ def create(object)
17
+ object.instance_variable_set('@__attributes', {})
18
+ object.instance_variable_set('@__references', {})
19
+ object.instance_variable_set('@__collections', {})
20
+ object.instance_variable_set('@__mapping', @mapping)
21
+ object.instance_variable_set('@__factory', self)
22
+
23
+ object.extend(Identity)
24
+ object.extend(Entity)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,63 @@
1
+ require 'jet_set/reference'
2
+ require 'jet_set/collection'
3
+
4
+ module JetSet
5
+ # Entity mapping is an element of JetSet mapping definition, see +JetSet::Mapping+.
6
+ # Should be instantiated by method +entity+ of +JetSet::Mapping+ instance.
7
+ class EntityMapping
8
+ attr_reader :references, :collections, :fields, :type, :dependencies
9
+
10
+ # Initializes the mapping using Ruby block.
11
+ # Parameters:
12
+ # +type+:: an entity class
13
+ # +&block+:: should contain attributes definitions see methods +field+, +collection+, +reference+.
14
+ def initialize(type, &block)
15
+ @type = type
16
+ @references = {}
17
+ @collections = {}
18
+ @dependencies = []
19
+ @fields = ['id']
20
+
21
+ if block_given?
22
+ instance_eval(&block)
23
+ end
24
+ end
25
+
26
+ # Defines an attribute of a simple type (String, Integer, etc)
27
+ # Parameters:
28
+ # +name+:: attribute name
29
+ def field(name)
30
+ @fields << name.to_s
31
+ end
32
+
33
+ # Defines an attribute of a complex type - another entity defined in the mapping.
34
+ # Parameters:
35
+ # +name+:: attribute name
36
+ # +params+::
37
+ # +type+:: class of the entity
38
+ # +weak+:: (optional) a flag for making a reference to an entity which is not directly
39
+ # associated for skipping persistence steps for it
40
+ def reference(name, params = {})
41
+ unless params.has_key? :type
42
+ raise MapperError, "Reference \"#{name}\" should have a type. Example:\n reference '#{name}', type: User\n"
43
+ end
44
+
45
+ @references[name] = Reference.new(name, params[:type])
46
+ @dependencies << params[:type] unless params[:weak]
47
+ end
48
+
49
+ # Defines an attribute-collection of a complex type - another entity defined in the mapping.
50
+ # Parameters:
51
+ # +name+:: attribute name
52
+ # +params+::
53
+ # +type+:: class of the entity
54
+ # +using+:: (optional) a name of many-to-many association table if needed.
55
+ def collection(name, params = {})
56
+ unless params.has_key? :type
57
+ raise MapperError, "Collection \"#{name}\" should have a type. Example:\n collection '#{name}', type: User\n"
58
+ end
59
+
60
+ @collections[name] = Collection.new(name, params[:type], params[:using])
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,91 @@
1
+ require 'hypo'
2
+ require 'sequel'
3
+ require 'jet_set/entity_builder'
4
+ require 'jet_set/session'
5
+ require 'jet_set/mapping'
6
+ require 'jet_set/mapper'
7
+ require 'jet_set/entity_mapping'
8
+ require 'jet_set/query_parser'
9
+ require 'jet_set/dependency_graph'
10
+
11
+ module JetSet
12
+ module Environment
13
+ # Initializes JetSet environment.
14
+ # Parameters:
15
+ # +mapping+:: JetSet mapping definition. Instance of JetSet::Mapping class.
16
+ # +container+:: (optional) Existing Hypo::Container instance.
17
+ def init(mapping, container = Hypo::Container.new)
18
+ @container = container
19
+
20
+ @container.register_instance(mapping, :mapping)
21
+
22
+ @container.register(JetSet::EntityBuilder, :entity_builder)
23
+ .using_lifetime(:singleton)
24
+
25
+ @container.register(JetSet::Mapper, :mapper)
26
+ .using_lifetime(:singleton)
27
+
28
+ @container.register(JetSet::QueryParser, :query_parser)
29
+ .using_lifetime(:singleton)
30
+ end
31
+
32
+ # Creates JetSet session and registers it in Hypo container.
33
+ # Parameters:
34
+ # +scope+:: a name of registered component which manages the session lifetime.
35
+ def register_session(scope = nil)
36
+ session_component = @container.register(JetSet::Session, :session)
37
+ dependency_graph_component = @container.register(JetSet::DependencyGraph, :dependency_graph)
38
+
39
+ if scope.nil?
40
+ @container.register_instance(nil, :session_scope)
41
+ session_component.use_lifetime(:transient)
42
+ dependency_graph_component.use_lifetime(:transient)
43
+ else
44
+ @container.register_instance(scope, :session_scope)
45
+ session_component.use_lifetime(:scope).bind_to(scope)
46
+ dependency_graph_component.use_lifetime(:scope).bind_to(scope)
47
+ end
48
+
49
+ end
50
+
51
+ # Creates JetSet session and registers it in Hypo container.
52
+ # Parameters:
53
+ # +connection+:: Sequel connection.
54
+ # +scope+:: a name of registered component which manages the session lifetime.
55
+ # Returns the session object.
56
+ def open_session(connection, scope = nil)
57
+ @container.register_instance(connection, :connection)
58
+
59
+ register_session(scope)
60
+ @container.resolve(:session)
61
+ end
62
+
63
+
64
+ # Accepts Ruby block with mapping definition.
65
+ # Parameters:
66
+ # +&block+: Ruby block
67
+ # Usage:
68
+ # JetSet::Mapping.new do
69
+ # entity Invoice do
70
+ # field :amount
71
+ # field :created_at
72
+ # collection :line_items
73
+ # reference :subscription, type: Subscription
74
+ # end
75
+ # ...
76
+ # entity User do
77
+ # field :amount
78
+ # field :created_at
79
+ # collection :line_items
80
+ # reference :subscription, type: Subscription
81
+ # end
82
+ # end
83
+ def map(&block)
84
+ unless block_given?
85
+ raise MapperError, 'Mapping should be defined as Ruby block.'
86
+ end
87
+
88
+ Mapping.new(&block)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,119 @@
1
+ require 'sequel'
2
+ require 'jet_set/mapper_error'
3
+ require 'jet_set/row'
4
+
5
+ module JetSet
6
+ # A converter of a data rows to object model according to a mapping.
7
+ class Mapper
8
+ # Parameters:
9
+ # +entity_builder+:: an instance of +JetSet::EntityBuilder+
10
+ # +mapping+:: an instance of +JetSet::Mapping+
11
+ # +container+:: IoC container (+Hypo::Container+) for resolving entity dependencies
12
+ def initialize(entity_builder, mapping, container)
13
+ @entity_builder = entity_builder
14
+ @mapping = mapping
15
+ @container = container
16
+
17
+ @mapping.entity_mappings.values.each do |entity_mapping|
18
+ container.register(entity_mapping.type)
19
+ end
20
+ end
21
+
22
+ # Converts a table row to an object
23
+ # Parameters:
24
+ # +type+:: entity type defined in the mapping
25
+ # +row_hash+:: hash representation of table row
26
+ # +session+:: instance of +JetSet::Session+
27
+ # +prefix+:: (optional) custom prefix for extracting the type attributes,
28
+ # i.e."customer" for query:
29
+ # "SELECT u.name AS customer__name from users u"
30
+ def map(type, row_hash, session, prefix = type.name.underscore)
31
+ entity_name = type.name.underscore.to_sym
32
+ entity_mapping = @mapping.get(entity_name)
33
+ row = Row.new(row_hash, entity_mapping.fields, prefix)
34
+ object = @container.resolve(entity_name)
35
+ entity = @entity_builder.create(object)
36
+ entity.load_attributes!(row.attributes)
37
+
38
+ row.reference_names.each do |reference_name|
39
+ if entity_mapping.references.key? reference_name.to_sym
40
+ type = entity_mapping.references[reference_name.to_sym].type
41
+ entity.set_reference! reference_name, map(type, row_hash, session, reference_name)
42
+ end
43
+ end
44
+
45
+ session.attach(entity)
46
+ entity
47
+ end
48
+
49
+ # Constructs object model relationships between of complex objects.
50
+ # Parameters:
51
+ # +target+:: an instance or an array of entity instances
52
+ # +name+:: name of the target attribute to bind
53
+ # +rows+:: an array of database rows (hashes)
54
+ # +session+:: an instance of +JetSet::Session+
55
+ def map_association(target, name, rows, session)
56
+ singular_name = name.to_s.singularize.to_sym
57
+ entity_mapping = @mapping.get(singular_name)
58
+
59
+ if target.is_a? Array
60
+ relations = {}
61
+ target_name = target[0].class.name.underscore
62
+ back_relations = {}
63
+
64
+ if rows.length > 0
65
+ target_id_name = "#{target_name.underscore}_id"
66
+ target_reference = entity_mapping.references[target_name.to_sym]
67
+
68
+ rows.each do |row|
69
+ relation = map(entity_mapping.type, row, session, singular_name.to_s)
70
+ target_id = row[target_id_name.to_sym]
71
+
72
+ if target_id.nil?
73
+ raise MapperError, "Field \"#{target_id_name}\" is not defined in the query but it's required to construct \"#{name} to #{target_name}\" association. Just add it to SELECT clause."
74
+ end
75
+
76
+ relations[target_id] ||= []
77
+ relations[target_id] << relation
78
+ back_relations[relation.id] = target.select{|t| t.id == target_id}
79
+ end
80
+
81
+ target.each do |entry|
82
+ target_id = entry.id
83
+ relation_objects = relations[target_id]
84
+
85
+ if relation_objects
86
+ if target_reference
87
+ relation_objects.each {|obj| obj.set_reference!(target_reference.name, entry)}
88
+ end
89
+
90
+ # set forward collection relation
91
+ entry.set_collection!(name, relations[target_id])
92
+
93
+ # set reverse collection relation if it's present
94
+ if entity_mapping.collections[target_name.pluralize.to_sym]
95
+ relation_objects.each{|obj| obj.set_collection!(target_name.pluralize.to_sym, back_relations[obj.id])}
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ ids = []
102
+ relations.values.each do |associations|
103
+ ids << associations.map{|a| a.id}
104
+ end
105
+
106
+ {result: relations, ids: ids.flatten.uniq}
107
+ else
108
+ result = rows.map do |row|
109
+ map(entity_mapping.type, row, session, singular_name.to_s)
110
+ end
111
+
112
+ target.set_collection!(name, result)
113
+
114
+ {result: result, ids: result.map {|i| i.id}}
115
+ end
116
+
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,5 @@
1
+ module JetSet
2
+ # A common error for handling an unexpected states.
3
+ class MapperError < StandardError
4
+ end
5
+ end
@@ -0,0 +1,41 @@
1
+ require 'jet_set/entity_mapping'
2
+
3
+ module JetSet
4
+ # Represents JetSet Mapping.
5
+ class Mapping
6
+ attr_reader :entity_mappings
7
+
8
+ # Initializes the mapping using Ruby block.
9
+ # Parameters:
10
+ # +&block+:: should contain "entity" definitions see method +entity+.
11
+ def initialize(&block)
12
+ @entity_mappings = {}
13
+ instance_eval(&block)
14
+ end
15
+
16
+ # Defines an entity mapping
17
+ # Parameters:
18
+ # +type+:: an entity class
19
+ # +&block+:: should contain mapping definitions of the entity attributes. See +JetSet::EntityMapping+ class.
20
+ # Returns an instance of +EntityMapping+
21
+ def entity(type, &block)
22
+ unless type.is_a? Class
23
+ raise MapperError, 'Mapping definition of an entity should begin from a type declaration which should be a Class.'
24
+ end
25
+
26
+ name = type.name.underscore.to_sym
27
+ if @entity_mappings.has_key?(name)
28
+ raise MapperError, "Mapping definition for entity of type #{type} is already registered."
29
+ end
30
+
31
+ @entity_mappings[name] = EntityMapping.new(type, &block)
32
+ end
33
+
34
+ # Returns an entity mapping by its +name+.
35
+ # Parameters:
36
+ # +name+:: string
37
+ def get(name)
38
+ @entity_mappings[name]
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,174 @@
1
+ require 'jet_set/attribute'
2
+
3
+ module JetSet
4
+ # A decorator for domain objects.
5
+ # It adds JetSet specific stuff for object changes tracking and persistence.
6
+ module Entity
7
+ # Loads the entity attributes.
8
+ # Parameters:
9
+ # +attributes+:: a hash of attributes in format :field => :value
10
+ def load_attributes!(attributes)
11
+ attributes.each do |attribute|
12
+ name = "@#{attribute[:field]}"
13
+ value = attribute[:value]
14
+ instance_variable_set(name, value)
15
+ @__attributes[name] = Attribute.new(name, value)
16
+ end
17
+ end
18
+
19
+ # Sets a reference to another entity.
20
+ # Parameters:
21
+ # +name+:: name of an entity defined in the mapping
22
+ # +value+:: an instance of an entity
23
+ def set_reference!(name, value)
24
+ @__references[name] = value
25
+ instance_variable_set("@#{name}", value)
26
+ end
27
+
28
+ # Sets a collection of related entities.
29
+ # Parameters:
30
+ # +name+:: name of an entity defined in the mapping
31
+ # +value+:: an array of instances of the entity
32
+ def set_collection!(name, value)
33
+ @__collections[name] = value.map{|item| item.respond_to?(:id) ? item.id : nil}.select{|item| !item.nil?}
34
+ instance_variable_set("@#{name}", value)
35
+ end
36
+
37
+ # Returns +true+ if entity is not loaded from the database and +false+ if it is.
38
+ def new?
39
+ @id.nil?
40
+ end
41
+
42
+ # Returns +true+ if the entity contains unsaved changes (attributes, references, collection)
43
+ # or if it's new (see +new?+ method).
44
+ def dirty?
45
+ attributes_changed = @__attributes.keys.any? do |name|
46
+ attribute = @__attributes[name]
47
+ current_value = instance_variable_get(attribute.name)
48
+ attribute.changed?(current_value)
49
+ end
50
+
51
+ collections_changed = @__collections.keys.any? do |name|
52
+ initial_state = @__collections[name]
53
+ current_state = instance_variable_get("@#{name}").map{|item| item.id}.select{|id| !id.nil?}
54
+ to_delete = initial_state - current_state
55
+ to_insert = current_state.select {|item| !item.respond_to?(:id)}
56
+ to_insert.length > 0 || to_delete.length > 0
57
+ end
58
+
59
+ references_changed = @__references.keys.any? do |name|
60
+ initial_state = @__references[name]
61
+ current_state = instance_variable_get("@#{name}")
62
+ current_state != initial_state
63
+ end
64
+
65
+ attributes_changed || references_changed || collections_changed || new?
66
+ end
67
+
68
+ # Enumerates changed attributes
69
+ def dirty_attributes
70
+ @__attributes.keys.select {|name|
71
+ current_value = instance_variable_get(name)
72
+ @__attributes[name].changed?(current_value)
73
+ }.map {|name| @__attributes[name]}
74
+ end
75
+
76
+ # Flushes current state and saves a changes to the database.
77
+ # Parameters:
78
+ # +connection+:: Sequel connection
79
+ def flush(connection)
80
+ table_name = self.class.name.underscore.pluralize.to_sym
81
+ table = connection[table_name]
82
+ entity_name = self.class.name.underscore.to_sym
83
+ my_column_name = self.class.name.underscore + '_id'
84
+
85
+ entity = @__mapping.get(entity_name)
86
+
87
+ if new?
88
+ attributes = []
89
+ entity.fields.each do |field|
90
+ attributes << {field: field, value: instance_variable_get("@#{field}")}
91
+ end
92
+
93
+ load_attributes!(attributes)
94
+
95
+ fields = @__attributes.keys.map {|name| name.sub('@', '')}.select {|a| a != 'id'}
96
+ values = @__attributes.keys.select {|name| name.sub('@', '') != 'id'}.map {|name| @__attributes[name].value}
97
+
98
+ entity.references.keys.each do |key|
99
+ value = instance_variable_get("@#{key}")
100
+ if value
101
+ reference_id = value.instance_variable_get('@id')
102
+ if reference_id.nil?
103
+ @__factory.create(value)
104
+ value.flush(connection)
105
+ end
106
+ set_reference!(key, value)
107
+ fields << "#{key}_id"
108
+
109
+ values << value.instance_variable_get('@id')
110
+ end
111
+ end
112
+ new_id = table.insert(fields, values)
113
+ @__attributes['@id'] = Attribute.new('@id', new_id)
114
+ @id = new_id
115
+ elsif dirty?
116
+ attributes = {}
117
+ dirty_attributes.each {|attribute| attributes[attribute.name.sub('@', '')] = instance_variable_get(attribute.name)}
118
+ if attributes.keys.length > 0
119
+ table.where(id: @id).update(attributes)
120
+ end
121
+ end
122
+
123
+ # synchronize collections
124
+ entity.collections.keys.each do |key|
125
+ unless @__collections.key? key
126
+ set_collection!(key, instance_variable_get("@#{key}"))
127
+ end
128
+ end
129
+
130
+ @__collections.keys.each do |name|
131
+ initial_state = @__collections[name]
132
+ current_state = instance_variable_get("@#{name}")
133
+
134
+ to_delete = initial_state.select{|item| !item.nil?} - current_state.select{|item| item.respond_to?(:id)}.map{|item| item.id}
135
+ to_insert = current_state.select{|item| !item.respond_to?(:id)}
136
+
137
+ if to_delete.length > 0
138
+ if entity.collections[name].using
139
+ foreign_column_name = name.to_s.singularize.underscore + '_id'
140
+ to_delete.each do |foreign_id|
141
+ connection[entity.collections[name].using.to_sym].where(my_column_name => id, foreign_column_name => foreign_id).delete
142
+ end
143
+ else
144
+ connection[name].where(id: to_delete).delete
145
+ end
146
+ end
147
+
148
+ to_insert.each do |item|
149
+ @__factory.create(item)
150
+ item.flush(connection)
151
+ end
152
+
153
+ if entity.collections[name].using
154
+ to_association_insert = []
155
+ current_state.each do |current_item|
156
+ to_association_insert << current_item unless initial_state.any?{|item| item == current_item}
157
+ end
158
+
159
+ relation_table = entity.collections[name].using.to_sym
160
+
161
+ to_association_insert.each do |item|
162
+ unless item.id
163
+ @__factory.create(item)
164
+ item.flush(connection)
165
+ end
166
+
167
+ foreign_column_name = item.class.name.underscore + '_id'
168
+ connection[relation_table].insert([my_column_name, foreign_column_name], [@id, item.id])
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,14 @@
1
+ module JetSet
2
+ # Identity decorator. Adds identifier to pure Ruby objects.
3
+ module Identity
4
+ # Compares the object with another object using their types and IDs.
5
+ def ==(object)
6
+ self.class == object.class && @id == object.id
7
+ end
8
+
9
+ # Object identifier
10
+ def id
11
+ @id
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ module JetSet
2
+ # Parsed JetSet query
3
+ class Query
4
+ attr_reader :sql
5
+
6
+ # Parameters:
7
+ # attrs:
8
+ # sql: parsed valid SQL expression
9
+ # returns_single_item: does SQL expression return single row? (LIMIT 1)
10
+ # entities: entities (+JetSet:EntityMapping+) enumerated in the query statements
11
+ def initialize(attrs = {})
12
+ @sql = attrs[:sql]
13
+ @returns_single_item = attrs[:returns_single_item] || false
14
+ @entities = attrs[:entities] || []
15
+ end
16
+
17
+ # Does SQL expression return single row? (LIMIT 1)
18
+ def returns_single_item?
19
+ @returns_single_item
20
+ end
21
+
22
+ # Is entity type enumerated in ENTITY statements?
23
+ def refers_to?(type)
24
+ @entities.any?{|entity| entity.type == type}
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ require 'jet_set/query'
2
+
3
+ module JetSet
4
+ # A converter of JetSet syntax to SQL queries.
5
+ class QueryParser
6
+ # Initializes the parser
7
+ # Parameters:
8
+ # +mapping+:: JetSet mapping +JetSet:Mapping+.
9
+ def initialize(mapping)
10
+ @mapping = mapping
11
+ end
12
+
13
+ # Parses JetSet query and returns SQL query.
14
+ # Parameters:
15
+ # +expression+:: an SQL query with trivial extensions
16
+ def parse(expression)
17
+ sql = expression.dup
18
+
19
+ returns_single_item = sql.scan(/LIMIT 1(\\n|;|\s)*\z/i).any?
20
+ entity_matches = sql.scan(/(\s*)(\w+)\.\*\s+AS\s+ENTITY\s+(\w+)/i)
21
+ entity_expressions = sql.scan(/(\w+\.\*\s+AS\s+ENTITY\s+\w+)/i).flatten
22
+
23
+ entities = []
24
+ entity_matches.each_with_index do |match, index|
25
+ spaces_str = match[0]
26
+ alias_name = match[1]
27
+ entity_name = match[2]
28
+ entity = @mapping.get(entity_name.to_sym)
29
+
30
+ if entity.nil?
31
+ raise MapperError, "Entity \"#{entity_name}\" is not defined in the mapping. Query:\n#{expression}"
32
+ end
33
+
34
+ entities << entity
35
+ fields_sql = entity.fields.map {|field| "#{alias_name}.#{field} AS #{entity_name}__#{field}"}.join(",#{spaces_str}")
36
+ sql.sub!(entity_expressions[index], fields_sql)
37
+ end
38
+
39
+ Query.new({
40
+ sql: sql,
41
+ returns_single_item: returns_single_item,
42
+ entities: entities
43
+ })
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,15 @@
1
+ module JetSet
2
+ # Reference represents a mapping of complex type attribute (another entity).
3
+ # Should be instantiated by method +reference+ of +JetSet::EntityMapping+ instance.
4
+ class Reference
5
+ attr_reader :name, :type
6
+
7
+ # Parameters:
8
+ # +name+:: name of the attribute
9
+ # +type+:: class of an entity
10
+ def initialize(name, type)
11
+ @name = name
12
+ @type = type
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ module JetSet
2
+ # A container for fields/references extraction logic
3
+ class Row
4
+ attr_reader :attributes, :reference_names
5
+
6
+ def initialize(row_hash, entity_fields, prefix)
7
+ keys = row_hash.keys.map {|key| key.to_s}
8
+
9
+ @attributes = keys.select {|key| key.to_s.start_with? prefix + '__'}
10
+ .select {|key| entity_fields.include? key.sub(prefix + '__', '')}
11
+ .map {|key| {field: key.sub(prefix + '__', ''), value: row_hash[key.to_sym]}}
12
+
13
+ @reference_names = keys.select {|key| !key.start_with?(prefix) && key.include?('__')}
14
+ .map {|key| key.split('__')[0]}
15
+ .uniq
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,110 @@
1
+ require 'sequel'
2
+
3
+ module JetSet
4
+ class Session
5
+ # Initializes +Session+ object.
6
+ # Parameters:
7
+ # +connection+:: Sequel connection object.
8
+ # +mapper+:: Sequel rows to Ruby objects mapper.
9
+ # +query_parser+:: a parser which evaluates JetSet extensions in SQL-expressions.
10
+ def initialize(connection, mapper, query_parser, entity_builder, dependency_graph)
11
+ @connection = connection
12
+ @mapper = mapper
13
+ @objects = []
14
+ @query_parser = query_parser
15
+ @entity_builder = entity_builder
16
+ @dependency_graph = dependency_graph
17
+ end
18
+
19
+ # Fetches root entity using a result of +execute+ method.
20
+ # Parameters:
21
+ # +type+:: Ruby class of an object to map.
22
+ # +expression+:: SQL-like query
23
+ # +params+:: +query+ params
24
+ # +&block+:: further handling of the result.
25
+ def fetch(type, expression, params = {}, &block)
26
+ unless type.is_a? Class
27
+ raise MapperError, 'Parameter "type" should be a Class.'
28
+ end
29
+
30
+ query = @query_parser.parse(expression)
31
+ unless query.refers_to?(type)
32
+ raise MapperError, "The query doesn't contain \"AS ENTITY #{type.name.underscore}\" statement."
33
+ end
34
+
35
+ rows = @connection.fetch(query.sql, params).to_a
36
+ if rows.length == 0
37
+ result = nil
38
+ elsif rows.length == 1 && query.returns_single_item?
39
+ result = @mapper.map(type, rows[0], self)
40
+ else
41
+ if query.returns_single_item?
42
+ raise MapperError, "A single row was expected to map but the query returned #{rows.length} rows."
43
+ end
44
+
45
+ result = []
46
+ rows.each do |row|
47
+ result << @mapper.map(type, row, self)
48
+ end
49
+ end
50
+
51
+ if block_given?
52
+ instance_exec(result, &block)
53
+ end
54
+
55
+ result
56
+ end
57
+
58
+ # Loads nested references and collections using sub-query
59
+ # for previously loaded aggregation root, see +map+ method.
60
+ # Parameters:
61
+ # +target+:: single or multiple entities that are a Ruby objects constructed by +map+ or +preload+ method.
62
+ # +relation+:: an object reference or collection name defined in JetSet mapping for the +target+.
63
+ def preload(target, relation, query, params = {}, &block)
64
+ query = @query_parser.parse(query)
65
+ rows = @connection.fetch(query.sql, params).to_a
66
+ result = @mapper.map_association(target, relation, rows, self)
67
+
68
+ if block_given?
69
+ instance_exec(result[:result], result[:ids], &block)
70
+ end
71
+ end
72
+
73
+ # Makes an object to be tracked by the session.
74
+ # Since this moment all related to object changes will be saved on session finalization.
75
+ # Use this method for newly created aggregation roots. No need to use it for new objects
76
+ # that were bound to a root which is already attached. All objects loaded from the database
77
+ # are already under the session tracking.
78
+ # Parameters:
79
+ # +objects+:: any Ruby objects defined in the mapping.
80
+ def attach(*objects)
81
+ to_attach = []
82
+ objects.each do |object|
83
+ if object.is_a? Array
84
+ object.each{|o| to_attach << o}
85
+ else
86
+ to_attach << object
87
+ end
88
+ end
89
+
90
+ to_attach.each do |object|
91
+ obj = object.kind_of?(Entity) ? object : @entity_builder.create(object)
92
+ @objects << obj
93
+ end
94
+ end
95
+
96
+ # Saves all changes of attached objects to the database.
97
+ # * Compatible with +Hypo::Scope+ +finalize+ interface,
98
+ # see Hypo docs at https://github.com/cylon-v/hypo.
99
+ def finalize
100
+ dirty_objects = @objects.select {|object| object.dirty?}
101
+ ordered_objects = @dependency_graph.order(dirty_objects)
102
+
103
+ if ordered_objects.length > 0
104
+ @connection.transaction do
105
+ ordered_objects.each{|obj| obj.flush(@connection)}
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,3 @@
1
+ module JetSet
2
+ VERSION = "0.2.0"
3
+ end
data/test.rb ADDED
@@ -0,0 +1,16 @@
1
+ class Test
2
+ def initialize
3
+ @method = 0
4
+ end
5
+
6
+ def calc
7
+ @method = 1
8
+ puts @method
9
+ end
10
+ end
11
+
12
+ Test.define_method '@method=' do |value|
13
+ @method = value + 5
14
+ end
15
+ test = Test.new
16
+ test.calc
metadata ADDED
@@ -0,0 +1,159 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jet_set
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Vladimir Kalinkin
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-04-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sequel
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 5.4.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 5.4.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.15'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.15'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: simplecov
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.16'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.16'
97
+ description: ''
98
+ email:
99
+ - vova.kalinkin@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - ".rspec"
106
+ - ".travis.yml"
107
+ - Gemfile
108
+ - LICENSE
109
+ - LICENSE.txt
110
+ - README.md
111
+ - Rakefile
112
+ - bin/console
113
+ - bin/setup
114
+ - jet_set.gemspec
115
+ - lib/jet_set.rb
116
+ - lib/jet_set/attribute.rb
117
+ - lib/jet_set/collection.rb
118
+ - lib/jet_set/dependency_graph.rb
119
+ - lib/jet_set/entity_builder.rb
120
+ - lib/jet_set/entity_mapping.rb
121
+ - lib/jet_set/environment.rb
122
+ - lib/jet_set/mapper.rb
123
+ - lib/jet_set/mapper_error.rb
124
+ - lib/jet_set/mapping.rb
125
+ - lib/jet_set/mixin/entity.rb
126
+ - lib/jet_set/mixin/identity.rb
127
+ - lib/jet_set/query.rb
128
+ - lib/jet_set/query_parser.rb
129
+ - lib/jet_set/reference.rb
130
+ - lib/jet_set/row.rb
131
+ - lib/jet_set/session.rb
132
+ - lib/jet_set/version.rb
133
+ - test.rb
134
+ homepage: https://github.com/cylon-v/jet_set
135
+ licenses:
136
+ - MIT
137
+ metadata:
138
+ allowed_push_host: https://rubygems.org
139
+ post_install_message:
140
+ rdoc_options: []
141
+ require_paths:
142
+ - lib
143
+ required_ruby_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '0'
148
+ required_rubygems_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ requirements: []
154
+ rubyforge_project:
155
+ rubygems_version: 2.6.12
156
+ signing_key:
157
+ specification_version: 4
158
+ summary: JetSet is a microscopic ORM for DDD projects.
159
+ test_files: []