preserves 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f8431d69f3de10e3ee28a0e6e2c96b86afadfeee
4
+ data.tar.gz: 6c87236337e01dbb1c5e95a95920fc82ef8f7f18
5
+ SHA512:
6
+ metadata.gz: 3e693759454aea1c88303fd6ed6b5e916991ad1491b3217ee9f1558b1c99b094c1be2eed26db95e26ab25cf82d7734829fa3292b4f531626071b1a5c5930aac7
7
+ data.tar.gz: 25d5b244aebae726bc06607b192c094672e5b96199501a9bb6f915d0bb21c2de5aa27d226f1efb9dbace3445f4fe78ad8ba86060ea2babecc384f80d2f87ef43
@@ -0,0 +1,23 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
23
+ vendor/bundle
data/.irbrc ADDED
@@ -0,0 +1,9 @@
1
+ # NOTE: For this file to work, your ~/.irbrc file must contain snippet from http://www.samuelmullen.com/2010/04/irb-global-local-irbrc/.
2
+
3
+
4
+ # Allow reloading our gem.
5
+ def reload!
6
+ @gem_name = Dir["#{Dir.pwd}/*.gemspec"].first.split('/').last.sub('.gemspec', '')
7
+ files = $LOADED_FEATURES.select { |feat| feat =~ %r[/#{@gem_name}/] }
8
+ files.each { |file| load file }
9
+ end
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --warnings
3
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Gem dependencies are specified in `preserves.gemspec`.
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 BoochTek, LLC
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.
@@ -0,0 +1,178 @@
1
+ Preserves
2
+ =========
3
+
4
+ Preserves is a minimalist ORM (object-relational mapper) for Ruby, using the
5
+ Repository and Data Mapper patterns.
6
+
7
+ We're trying to answer these questions:
8
+
9
+ * How simple can we make an ORM that is still useful?
10
+ * Developers have to know SQL anyway, so why try to hide the SQL from them?
11
+ * Is the complexity of a typical ORM really better than the complexity of SQL?
12
+ * ORMs are a leaky abstraction. What if we made it so leaky that it doesn't matter?
13
+
14
+ This ORM is based on a few strong opinions:
15
+
16
+ * The Data Mapper pattern is generally better than the Active Record pattern.
17
+ * Unless you're just writing a CRUD front-end, with little interesting behavior.
18
+ * Declaring attributes in the domain model is better than hiding them elsewhere.
19
+ * Declaring relationships in one place and attributes in another is true madness.
20
+ * NoSQL as a main data store is usually misguided.
21
+ * PostgreSQL can do just about anything you need, using SQL.
22
+ * Projects are unlikely to need to abstract SQL to allow them to use different RDBMSes.
23
+ * Developer workstations are fast enough to run "full" RDBMSes.
24
+ * If you're not using "interesting" features, then you're probably using "standard" SQL.
25
+
26
+ The Data Mapper pattern provides several advantages:
27
+
28
+ * Domain objects don't have to know anything about the database or its schema.
29
+ * Instead, the mapper knows about the domain objects and the database.
30
+ * DB schema can change without having to change to domain objects; only the mapper changes.
31
+ * The domain objects are self-contained.
32
+ * Don't have to look elsewhere to understand everything a class contains.
33
+ * Better meets the Single Responsibility Principle (SRP).
34
+ * Domain model classes handle business logic.
35
+ * Repository classes handle persistence.
36
+ * Mapper classes handle mapping database fields to object attributes.
37
+
38
+ It's been pointed out that Preserves might not in fact even be an ORM, because it doesn't have a complete model of the relations between objects.
39
+
40
+
41
+ Installation
42
+ ------------
43
+
44
+ Add this line to your application's Gemfile:
45
+
46
+ gem 'preserves'
47
+
48
+ And then execute:
49
+
50
+ $ bundle
51
+
52
+ Or install it yourself as:
53
+
54
+ $ gem install preserves
55
+
56
+
57
+ Example Usage
58
+ -------------
59
+
60
+ First, create your domain model class. You can use a [Struct], an
61
+ [OpenStruct], a [Virtus] model, or a plain old Ruby object (PORO) class.
62
+ We'll use a Struct in the examples, so we can initialize the fields easily.
63
+
64
+ ~~~ ruby
65
+ User = Struct.new(:id, :name, :age) do
66
+ end
67
+ ~~~
68
+
69
+ Next, configure the Preserves data store.
70
+
71
+ ~~~ ruby
72
+ Preserves.data_store = Preserves::PostgreSQL("my_database")
73
+ ~~~
74
+
75
+ Then create a repository linked to the domain model class.
76
+ By default, all attributes will be assumed to be Strings.
77
+ For other attribute types, you'll need to supply the mapping.
78
+ (We'll have some default mappings determined from the DB or model later.)
79
+ Your repository should then define methods to access model objects
80
+ in the database. (These will mostly be like ActiveRecord scopes.)
81
+
82
+ ~~~ ruby
83
+ UserRepository = Preserves.repository(model: User) do
84
+ mapping do
85
+ map id: 'username' # The database field named 'username' corresponds to the 'id' attribute in the model.
86
+ map :age, Integer # The 'age' field should be mapped to an Integer in the model.
87
+ end
88
+
89
+ # We'll likely provide `insert`, but this gives an idea of how minimal we'll be to start off.
90
+ def insert(user)
91
+ result = query("INSERT INTO 'users' (username, name, age) VALUES ($1, $2, $3)",
92
+ user.id, user.name, user.age)
93
+ raise "Could not insert User #{user.id} into database" unless result.size == 1
94
+ end
95
+
96
+ def older_than(age)
97
+ select("SELECT *, username AS id FROM 'users' WHERE age > $1 ORDER BY $2", age, :name)
98
+ end
99
+
100
+ def with_id(id)
101
+ select("SELECT *, username AS id FROM 'users' WHERE username = $1", id)
102
+ end
103
+ end
104
+ ~~~
105
+
106
+ Now we can create model objects and use the repository to save them to and
107
+ retrieve them from the database:
108
+
109
+ ~~~ ruby
110
+ craig = User.new("booch", "Craig", 42)
111
+ UserRepository.insert(craig)
112
+ users_over_40 = UserRepository.older_than(40) # Returns an Enumerable set of User objects.
113
+ beth = UserRepository.with_id("beth").one # Returns a single User object or nil.
114
+ ~~~
115
+
116
+
117
+ API Summary
118
+ -----------
119
+
120
+ NOTE: This project is in very early exploratory stages. The API **will** change.
121
+
122
+
123
+ ### Repository ###
124
+
125
+ Most of the API you'll use will be in the your repository object.
126
+ The mixin provides the following methods:
127
+
128
+ ~~~ ruby
129
+ fetch(id) # Fetch a single domain model object by its primary key.
130
+ [id] # Fetch a single domain model object by its primary key.
131
+ query(sql_string) # Runs SQL and returns a Preserves::SQL::ResultSet.
132
+ select(sql_string) # Runs SQL and returns a Preserves::Selection.
133
+ select(sql_string, param1, param2) # Include bind params for the SQL query.
134
+ select(sql_string, association_name: sql_result) # Include associations.
135
+ ~~~
136
+
137
+
138
+ ### Preserves::SQL::ResultSet ###
139
+
140
+ ~~~ ruby
141
+ result.size # Number of rows that were affected by the SQL query.
142
+ ~~~
143
+
144
+
145
+ ### Preserves::Selection ###
146
+
147
+ A Selection is an Enumerable, representing the results of a SELECT query,
148
+ mapped to domain model objects.
149
+ Most of your interactions with Selections will be through the Enumerable interface.
150
+
151
+ ~~~ ruby
152
+ selection.each # Iterates through the resulting domain objects.
153
+ selection.first # Returns the first result. Returns nil if there are no results.
154
+ selection.first! # Returns the first result. Raises an exception if there are no results.
155
+ selection.last # Returns the last result. Returns nil if there are no results.
156
+ selection.last! # Returns the last result. Raises an exception if there are no results.
157
+ selection.only # Returns the only result. Returns nil if there are no results. Raises an exception if there's more than 1 result. (Aliased as `one`.)
158
+ selection.only! # Returns the only result. Raises an exception if there's not exactly 1 result. (Aliased as `one!`.)
159
+ ~~~
160
+
161
+
162
+ Contributing
163
+ ------------
164
+
165
+ 1. Fork the [project repo].
166
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
167
+ 3. Make sure tests pass (`rspec` or `rake spec`).
168
+ 4. Commit your changes (`git commit -am 'Add some feature'`).
169
+ 5. Push to the branch (`git push origin my-new-feature`).
170
+ 6. Create a new [pull request].
171
+
172
+
173
+ [Struct]: http://ruby-doc.org/core-2.2.0/Struct.html
174
+ [OpenStruct]: http://ruby-doc.org/stdlib-2.2.0/libdoc/ostruct/rdoc/OpenStruct.html
175
+ [Virtus]: https://github.com/solnic/virtus#readme
176
+
177
+ [project repo]: https://github.com/boochtek/ruby_preserves/fork
178
+ [pull request]: https://github.com/boochtek/ruby_preserves/pulls
@@ -0,0 +1,2 @@
1
+ require 'rubygems/tasks'
2
+ Gem::Tasks.new
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+
3
+ case "$(uname)" in
4
+ Darwin)
5
+ brew install postgresql
6
+ ln -sfv /usr/local/opt/postgresql/*.plist ~/Library/LaunchAgents
7
+ launchctl load ~/Library/LaunchAgents/homebrew.mxcl.postgresql.plist
8
+ bundle --path vendor/bundle
9
+ createdb 'preserves_test'
10
+ ;;
11
+ *)
12
+ echo "Don't know how to install on this system. Please submit a pull request."
13
+ exit
14
+ ;;
15
+ esac
data/TODO.md ADDED
@@ -0,0 +1,122 @@
1
+ TODO
2
+ ====
3
+
4
+
5
+ Presentation
6
+ ------------
7
+
8
+ * Create the short URL. (NO UNDERSCORES on TinyURL)
9
+ * http://craigbuchek.com/ruby-preserves-rubyconf
10
+ * http://tinyurl.com/ruby-preserves-rubyconf
11
+ * https://rawgit.com/booch/presentations/Ruby_Preserves-RubyConf-2015-11-15/Ruby_Preserves/slides.html
12
+
13
+
14
+ ASAP
15
+ ----
16
+
17
+ * README: Document has_many and belongs_to.
18
+ * Advise to avoid belongs_to mappings, if possible.
19
+ * Especially don't want circular dependencies.
20
+ * README: Show an example of pagination.
21
+ * README: Show how to use a different repository for tests, if necessary.
22
+ * Since we require SQL, we can't really do in-memory.
23
+ * So the repository would not be a Preserves repository.
24
+ * Saving.
25
+ * Not sure if we should just have subclasses use query().
26
+ * I'm starting to lean that way.
27
+ * We probably have all the info we need to build the INSERT programmatically.
28
+ * But that would violate our "just use SQL everywhere" mantra.
29
+ * insert / update / save / delete
30
+ * Preserves.repository() should return a module to mix in, and not take a block.
31
+ * And should not be singletons.
32
+ * Might use method_missing on class to allow usage as if it's a singleton.
33
+ * Use the Module Factory pattern.
34
+
35
+
36
+ Soonish
37
+ -------
38
+
39
+ * Convenience methods.
40
+ * create_table
41
+ * scope
42
+ * More coercions.
43
+ * Boolean
44
+ * Date
45
+ * Rename to serialize/deserialize.
46
+ * Move serializers to their own class(es).
47
+ * Or should we use solnic/coercible gem?
48
+ * Allow a way to specify more type mappings/serializers.
49
+ * Registration?
50
+ * Get default mappings from DB schema.
51
+ * INTEGER
52
+ * DATE
53
+ * TIME
54
+ * Prepared statements.
55
+ * Can we just prepare every SQL query we run?
56
+ * Have a cache mapping the SQL query string to the prepared statement.
57
+ * Would obviously want to make this a LRU cache eventually.
58
+ * Ensure we can use PostgreSQL arrays.
59
+ * Be consistent between strings and symbols.
60
+ * Can we initialize the domain model objects, instead of using setters?
61
+ * Would initialize with a Hash of attributes and values.
62
+ * Might allow both variants, to work with different kinds of classes.
63
+ * Would need a way to know if the model class supports the initializer we'd be using.
64
+ * Allow strings in place of class names for specifying repositories.
65
+ * Because we'll have circular references for belongs_to/has_many pairs.
66
+ * Or should we not allow that, because it's bad for OOP to have circular dependencies?
67
+ * Better exceptions -- add some Exception classes.
68
+ * Have Selection class lazily do mapping, instead of eagerly in the repository?
69
+ * Unit tests.
70
+ * We currently only have integration/acceptance tests.
71
+ * Pluralize.
72
+ * Will have to move it to its own file and make it public.
73
+ * Add has_many :through relations.
74
+ * Might already work with the existing code, and just need testing.
75
+ * Allow, but don't require, join table to have an associated Repository object.
76
+ * Use ActiveRecord syntax, but store them separately in Mapper.
77
+ * Should we catch exceptions from the DB?
78
+ * Should we reraise them with our own exception class?
79
+ * Should we swallow them?
80
+ * Cleanup.
81
+ * Clean up Mapper a bit more.
82
+ * Setting up the DB in spec_helper is terrible.
83
+ * At least move it to a separate file.
84
+ * Better documentation.
85
+ * README isn't great at explaining how to use it.
86
+ * Should make recommendations on how to use this.
87
+ * In Rails, recommend putting repositories in app/repositories/.
88
+ * Add that to LOAD_PATH, but don't auto-load.
89
+ * Repository file should require domain model file, but never vice-versa.
90
+ * Recommend they consider using 'Users' instead of 'UserRepository'.
91
+ * Handle Virtus models.
92
+ * Get list of default mappings from model attributes list.
93
+ * New up the object with all attributes, instead of setting them individually.
94
+ * Will probably make this a separate gem.
95
+ * Can layer on top, or inject extra strategies for object creation and default mappings.
96
+ * Identity map.
97
+ * For cases where we're creating a bunch of objects, but some already exist.
98
+ * Allow a way to specify that a model is a value type (which doesn't have an identity).
99
+ * Does it make sense to have these in the database?
100
+ * Test for mapping both type and name.
101
+ * Probably already works.
102
+ * Fit into ActiveModel.
103
+ * Would require picking one base class for models.
104
+ * Would lose the ability to use POROs (at least when using ActiveModel).
105
+ * Would require mutual dependencies.
106
+ * The model will have to call to the repo to persist itself.
107
+ * The repo will need to know about the model.
108
+ * Maybe there's a way to actually break this, since our mapping doesn't need it immediately.
109
+ * Would require also including a validation layer (I think).
110
+ * Connection pooling.
111
+ * Is there a way we could do dirty tracking/updating?
112
+ * This would require some help from the model.
113
+ * So we'd probably make it optional, depending on whether the model supports it.
114
+ * Is there a way we could do optimistic locking?
115
+ * Is there a way we could do lazy loading of associations?
116
+ * Transactions / Unit of Work.
117
+ * Composite keys.
118
+ * Use Mutant for testing.
119
+ * Use a CI service.
120
+ * Use Ruby 2.1 keyword arguments.
121
+ * Use cursors.
122
+ * Performance testing.
@@ -0,0 +1,24 @@
1
+ require "preserves/version"
2
+ require "preserves/repository"
3
+ require "preserves/sql"
4
+
5
+
6
+ module Preserves
7
+ def self.repository(options={}, &block)
8
+ repository = Repository.new(options)
9
+ repository.instance_eval(&block)
10
+ repository
11
+ end
12
+
13
+ def self.data_store=(connection)
14
+ @data_store = connection
15
+ end
16
+
17
+ def self.data_store
18
+ @data_store or raise "You must define a default data store"
19
+ end
20
+
21
+ def self.PostgreSQL(db_name)
22
+ SQL.connection(dbname: db_name)
23
+ end
24
+ end
@@ -0,0 +1,108 @@
1
+ # Terminology note: A field is associated with a single row/record. A column pertains to all rows/records.
2
+
3
+ require "preserves/mapping"
4
+ require "preserves/mapper/has_many"
5
+ require "preserves/mapper/belongs_to"
6
+
7
+
8
+ module Preserves
9
+ class Mapper
10
+
11
+ attr_accessor :mapping
12
+
13
+ def initialize(mapping)
14
+ self.mapping = mapping
15
+ end
16
+
17
+ def map(result, relations={})
18
+ result.map do |record|
19
+ map_one(record, relations)
20
+ end
21
+ end
22
+
23
+ def map_one(record, relations={})
24
+ mapping.model_class.new.tap do |object|
25
+ map_attributes(object, record)
26
+ map_relations(object, record, relations)
27
+ end
28
+ end
29
+
30
+ protected
31
+
32
+ def primary_key_attribute
33
+ column_name_to_attribute_name(mapping.primary_key)
34
+ end
35
+
36
+ private
37
+
38
+ def map_attributes(object, record)
39
+ record.each_pair do |column_name, field_value|
40
+ attribute_name = column_name_to_attribute_name(column_name)
41
+ if object.respond_to?("#{attribute_name}=")
42
+ object.send("#{attribute_name}=", field_value_to_attribute_value(attribute_name, field_value))
43
+ end
44
+ end
45
+ end
46
+
47
+ def column_name_to_attribute_name(column_name)
48
+ mapping.name_mappings.fetch(column_name) { column_name }
49
+ end
50
+
51
+ def field_value_to_attribute_value(attribute_name, field_value)
52
+ attribute_type = attribute_name_to_attribute_type(attribute_name)
53
+ coerce(field_value, to: attribute_type)
54
+ end
55
+
56
+ def attribute_value_to_field_value(attribute_name, attribute_value)
57
+ attribute_type = attribute_name_to_attribute_type(attribute_name)
58
+ uncoerce(attribute_value, to: attribute_type)
59
+ end
60
+
61
+ def attribute_name_to_attribute_type(attribute_name)
62
+ mapping.type_mappings.fetch(attribute_name.to_sym) { String }
63
+ end
64
+
65
+ def map_relations(object, record, relations)
66
+ has_many_relations = relations.select{ |k, _v| mapping.has_many_mappings.keys.include?(k) }
67
+ map_has_many_relations(object, record, has_many_relations)
68
+ belongs_to_relations = relations.select{ |k, _v| mapping.belongs_to_mappings.keys.include?(k) }
69
+ map_belongs_to_relations(object, record, belongs_to_relations)
70
+ # TODO: Raise an exception if any of the relations weren't found in any of the relation mappings.
71
+ end
72
+
73
+ def map_has_many_relations(object, record, relations)
74
+ # TODO: Ensure that there's a setter for every relation_name before we iterate through the relations.
75
+ relations.each do |relation_name, relation_result_set|
76
+ Mapper::HasMany.new(object, record, relation_name, relation_result_set, mapping).map!
77
+ end
78
+ end
79
+
80
+ def map_belongs_to_relations(object, record, relations)
81
+ # TODO: Ensure that there's a setter for every relation_name before we iterate through the relations.
82
+ relations.each do |relation_name, relation_result_set|
83
+ Mapper::BelongsTo.new(object, record, relation_name, relation_result_set, mapping).map!
84
+ end
85
+ end
86
+
87
+ def coerce(field_value, options={})
88
+ return nil if field_value.nil?
89
+
90
+ if options[:to] == Integer
91
+ Integer(field_value)
92
+ else
93
+ field_value
94
+ end
95
+ end
96
+
97
+ def uncoerce(attribute_value, options={})
98
+ return "NULL" if attribute_value.nil?
99
+
100
+ if options[:to] == String
101
+ "'#{attribute_value}'"
102
+ else
103
+ attribute_value.to_s
104
+ end
105
+ end
106
+
107
+ end
108
+ end