preserves 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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