jet_set 0.2.0

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