motion_record 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ NDdiYmE3NTlmNjMzMmVjN2UwMmJhNjk1YTRlOGFlYmQxYjVmMmNkYQ==
5
+ data.tar.gz: !binary |-
6
+ YTgzNmU2OTFhMzVhNjRiYWZlNGY1MjZiZDQ0MWI5Njk0Y2VlMTQ3Zg==
7
+ SHA512:
8
+ metadata.gz: !binary |-
9
+ NWVkNjgwMjUwYzBkNjgzY2FhNmY5OTM1YWI0N2M1Nzk4ZjQ2N2M5YWE5MTc4
10
+ NDViNWZkYmQ0NjhlZTFlMzM5NjAyOGIwNTMzYjQzNzEzMDhkNGE0OTkzZWM4
11
+ MTRkNDg4ZTA1N2NiYWJmNjBiZDcwY2I0Mjk5NTI0NzdjZWNjZmI=
12
+ data.tar.gz: !binary |-
13
+ ZDkxM2Y1ODI4NTFmYmExZjMwZjM2MzEzOTgxMjRiNWYyYWM1ODlmNWE4YWJk
14
+ MTNlNTY5ZTMyOTExZWE2ODRjYzRkYTZjODYyNDYzZGQ4ZGUxYjJjMmNhMmEy
15
+ YjMyYTRlMmRiYzhmNDIwOTUyMzEzOWM4MmRmMWI1Njk2M2U2MDY=
data/.gitignore ADDED
@@ -0,0 +1,17 @@
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
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in motion_record.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Zach Millman
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,246 @@
1
+ MotionRecord
2
+ ============
3
+
4
+ *Miniature ActiveRecord for RubyMotion*
5
+
6
+ Everything you need to start using SQLite as the datastore for your RubyMotion
7
+ app.
8
+
9
+ :turtle: Android support should be coming soon
10
+
11
+ [![Code Climate](https://codeclimate.com/github/magoosh/motion_record/badges/gpa.svg)](https://codeclimate.com/github/magoosh/motion_record) [![Test Coverage](https://codeclimate.com/github/magoosh/motion_record/badges/coverage.svg)](https://codeclimate.com/github/magoosh/motion_record)
12
+
13
+ Installation
14
+ ------------
15
+
16
+ Add this line to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "motion_record"
20
+ ```
21
+
22
+ On iOS, MotionRecord uses [motion-sqlite3](https://github.com/mattgreen/motion-sqlite3)
23
+ as a wrapper for connecting to SQLite, so add these too:
24
+
25
+ ```ruby
26
+ gem "motion-sqlite3"
27
+ # Requires the most recent unpublished version of motion.h
28
+ # https://github.com/kastiglione/motion.h/issues/11
29
+ gem "motion.h", :git => "https://github.com/kastiglione/motion.h"
30
+ ```
31
+
32
+ And then execute:
33
+
34
+ ```
35
+ $ bundle
36
+ ```
37
+
38
+ * TODO: Android???
39
+
40
+ MotionRecord::Schema
41
+ --------------------
42
+
43
+ Define and run all pending SQLite migrations with the `up!` DSL.
44
+
45
+ ```ruby
46
+ def application(application, didFinishLaunchingWithOptions:launchOptions)
47
+ MotionRecord::Schema.up! do
48
+ migration 1, "Create messages table" do
49
+ create_table :messages do |t|
50
+ t.text :subject, null: false
51
+ t.text :body
52
+ t.integer :read_at
53
+ t.integer :remote_id
54
+ t.float :satisfaction, default: 0.0
55
+ end
56
+ end
57
+
58
+ migration 2, "Index messages table" do
59
+ add_index :messages, :remote_id, :unique => true
60
+ add_index :messages, [:subject, :read_at]
61
+ end
62
+ end
63
+ # ...
64
+ end
65
+ ```
66
+
67
+ #### Schema Configuration
68
+
69
+ By default, MotionRecord will print all SQL statements and use a file named
70
+ `"app.sqlite3"` in the application's Application Support folder. To disable
71
+ logging (for release) or change the filename, pass configuration options to `up!`
72
+
73
+ ```ruby
74
+ resource_file = File.join(NSBundle.mainBundle.resourcePath, "data.sqlite3")
75
+ MotionRecord::Schema.up!(file: resource_file, debug: false) # ...
76
+ ```
77
+
78
+ You can also specify that MotionRecord should use an in-memory SQLite database
79
+ which will be cleared every time the app process is killed.
80
+
81
+ ```ruby
82
+ MotionRecord::Schema.up!(file: :memory) # ...
83
+ ```
84
+
85
+ MotionRecord::Base
86
+ ------------------
87
+
88
+ MotionRecord::Base provides a superclass for defining objects which are stored
89
+ in the database.
90
+
91
+ ```ruby
92
+ class Message < MotionRecord::Base
93
+ # That's all!
94
+ end
95
+ ```
96
+
97
+ Attribute methods are inferred from the associated SQLite table definition.
98
+
99
+ ```ruby
100
+ message = Message.new(subject: "Welcome!", body: "If you have any questions...")
101
+ # => #<Message: @id=nil @subject="Welcome!" @body="If you have any..." ...>
102
+ message.satisfaction
103
+ # => 0.0
104
+ ```
105
+
106
+ Manage persistence with `save!`, `delete!`, and `persisted?`
107
+
108
+ ```ruby
109
+ message = Message.new(subject: "Welcome!", body: "If you have any questions...")
110
+ message.save!
111
+ message.id
112
+ # => 1
113
+ message.delete!
114
+ message.persisted?
115
+ # => false
116
+ ```
117
+
118
+ * TODO: Better default inflection of class names to table names
119
+
120
+ MotionRecord::Scope
121
+ -------------------
122
+
123
+ Build scopes on MotionRecord::Base classes with `where`, `order` and `limit`.
124
+
125
+ ```ruby
126
+ Message.where(body: nil).order("read_at DESC").limit(3).find_all
127
+ ```
128
+
129
+ Run queries on scopes with `exists?`, `first`, `find`, `find_all`, `pluck`,
130
+ `update_all`, and `delete_all`.
131
+
132
+ ```ruby
133
+ Message.where(remote_id: 2).exists?
134
+ # => false
135
+ Message.find(21)
136
+ # => #<Message @id=21 @subject="What's updog?" ...>
137
+ Message.where(read_at: nil).pluck(:subject)
138
+ # => ["What's updog?", "What's updog?", "What's updog?"]
139
+ Message.where(read_at: nil).find_all
140
+ # => [#<Message @id=20 ...>, #<Message @id=21 ...>, #<Message @id=22 ...>]
141
+ Message.where(read_at: nil).update_all(read_at: Time.now.to_i)
142
+ ```
143
+
144
+ * TODO: Return "rows modified" count for update_all and delete_all
145
+
146
+ Run calculations on scopes with `count`, `sum`, `maximum`, `minimum`, and
147
+ `average`.
148
+
149
+ ```ruby
150
+ Message.where(subject: "Welcome!").count
151
+ # => 1
152
+ Message.where(subject: "How do you like the app?").maximum(:satisfaction)
153
+ # => 10.0
154
+ ```
155
+
156
+ * TODO: Predicates for comparisons other than `=`
157
+ * TODO: Handle datatype conversion in `where` and `update_all`
158
+
159
+ MotionRecord::Serialization
160
+ ----------------------------------
161
+
162
+ SQLite has a very limited set of datatypes (TEXT, INTEGER, and REAL), but you
163
+ can easily store other objects as attributes in the database with serializers.
164
+
165
+ #### Built-in Serializers
166
+
167
+ MotionRecord provides a built-in serializer for Time objects to any column
168
+ datatype.
169
+
170
+ ```ruby
171
+ class Message < MotionRecord::Base
172
+ serialize :read_at, :time
173
+ end
174
+
175
+ Message.create!(subject: "Hello!", read_at: Time.now)
176
+ # SQL: INSERT INTO messages (subject, body, read_at, ...) VALUES (?, ?, ?...)
177
+ # Params: ["Hello!", nil, 1420099200, ...]
178
+ Message.first.read_at
179
+ # => 2015-01-01 00:00:00 -0800
180
+ ```
181
+
182
+ Boolean attributes can be serialized to INTEGER columns where 0 and NULL are
183
+ `false` and any other value is `true`.
184
+
185
+ ```ruby
186
+ class Message < MotionRecord::Base
187
+ serialize :satisfaction_submitted, :boolean
188
+ end
189
+ ```
190
+
191
+ Objects can also be stored to TEXT columns as JSON.
192
+
193
+ ```ruby
194
+ class Survey < MotionRecord::Base
195
+ serialize :response, :json
196
+ end
197
+
198
+ survey = Survey.new(response: {nps: 10, what_can_we_improve: "Nothing :)"})
199
+ survey.save!
200
+ # SQL: INSERT INTO surveys (response) VALUES (?)
201
+ # Params: ['{"nps":10, "what_can_we_improve":"Nothing :)"}']
202
+ Survey.first
203
+ # => #<Survey: @id=1 @response={"nps"=>10, "what_can_we_improve"=>"Nothing :)"}>
204
+ ```
205
+
206
+ * TODO: Make JSON serializer cross-platform
207
+
208
+ #### Custom Serializers
209
+
210
+ To write a custom serializer, extend MotionRecord::Serialization::BaseSerializer
211
+ and provide your class to `serialize` instead of a symbol.
212
+
213
+ ```ruby
214
+ class MoneySerializer < MotionRecord::Serialization::BaseSerializer
215
+ def serialize(value)
216
+ raise "Wrong column type!" unless @column.type == :integer
217
+ value.cents
218
+ end
219
+
220
+ def deserialize(value)
221
+ raise "Wrong column type!" unless @column.type == :integer
222
+ Money.new(value)
223
+ end
224
+ end
225
+
226
+ class Purchase < MotionRecord::Base
227
+ serialize :amount_paid_cents, MoneySerializer
228
+ end
229
+ ```
230
+
231
+ MotionRecord::Association
232
+ -------------------------
233
+
234
+ * TODO: has_many and belongs_to
235
+
236
+
237
+ Contributing
238
+ ------------
239
+
240
+ Please do!
241
+
242
+ 1. Fork it
243
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
244
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
245
+ 4. Push to the branch (`git push origin my-new-feature`)
246
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,54 @@
1
+ module MotionRecord
2
+ class Base
3
+ include Persistence
4
+
5
+ def initialize(attributes={})
6
+ initialize_from_attribute_hash(attributes)
7
+ end
8
+
9
+ def to_attribute_hash
10
+ self.class.attribute_names.each_with_object({}) do |name, hash|
11
+ hash[name] = self.instance_variable_get "@#{name}"
12
+ end
13
+ end
14
+
15
+ def connection
16
+ self.class.connection
17
+ end
18
+
19
+ protected
20
+
21
+ def initialize_from_attribute_hash(hash)
22
+ self.class.attribute_defaults.merge(hash).each do |name, value|
23
+ self.instance_variable_set "@#{name}", value
24
+ end
25
+ end
26
+
27
+ class << self
28
+ # Add attribute methods to the model
29
+ #
30
+ # name - Symobl name of the attribute
31
+ # options - optional configuration Hash:
32
+ # :default - default value for the attribute (nil otherwise)
33
+ def define_attribute(name, options = {})
34
+ attr_accessor name
35
+ self.attribute_names << name.to_sym
36
+ if options[:default]
37
+ self.attribute_defaults[name.to_sym] = options[:default]
38
+ end
39
+ end
40
+
41
+ def attribute_names
42
+ @attribute_names ||= []
43
+ end
44
+
45
+ def attribute_defaults
46
+ @attribute_defaults ||= {}
47
+ end
48
+
49
+ def connection
50
+ ConnectionAdapters::SQLiteAdapter.instance
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,160 @@
1
+ module MotionRecord
2
+ module ConnectionAdapters
3
+ class SQLiteAdapter
4
+ class << self
5
+ # Configure the SQLite connection
6
+ #
7
+ # options - Hash of configuration options for the SQLite connection
8
+ # :file - full name of the database file, or :memory for
9
+ # in-memory database files (default is "app.sqlite3"
10
+ # in the app's `/Library/Application Support` folder)
11
+ # :debug - set to false to turn off SQL debug logging
12
+ def configure(options={})
13
+ @configuration_options = options
14
+ end
15
+
16
+ def instance
17
+ @instance ||= ConnectionAdapters::SQLiteAdapter.new(filename, debug?)
18
+ end
19
+
20
+ # Full filename of the database file
21
+ def filename
22
+ if (file = @configuration_options[:file])
23
+ if file == :memory
24
+ ":memory:"
25
+ else
26
+ file
27
+ end
28
+ else
29
+ create_default_database_file
30
+ end
31
+ end
32
+
33
+ # Returns true if debug logging is enabled for the database
34
+ def debug?
35
+ if @configuration_options.has_key?(:debug)
36
+ !!@configuration_options[:debug]
37
+ else
38
+ true
39
+ end
40
+ end
41
+
42
+ protected
43
+
44
+ # Create the default database file in `Library/Application Support` if
45
+ # it doesn't exist and return the file's full path
46
+ def create_default_database_file
47
+ fm = NSFileManager.defaultManager
48
+
49
+ support_path = fm.URLsForDirectory(NSApplicationSupportDirectory, inDomains: NSUserDomainMask).first.path
50
+ file_path = File.join(support_path, "app.sqlite3")
51
+
52
+ unless fm.fileExistsAtPath(file_path)
53
+ fm.createDirectoryAtPath(support_path, withIntermediateDirectories:true, attributes:nil, error:nil)
54
+ success = fm.createFileAtPath(file_path, contents: nil, attributes: nil)
55
+ raise "Couldn't create file #{path}" unless success
56
+ end
57
+
58
+ file_path
59
+ end
60
+ end
61
+
62
+ def initialize(file, debug=true)
63
+ @db = SQLite3::Database.new(file)
64
+ @db.logging = debug
65
+ end
66
+
67
+ def execute(command)
68
+ @db.execute(command)
69
+ end
70
+
71
+ def table_exists?(table_name)
72
+ # FIXME: This statement is totally vulnerable to SQL injection
73
+ @db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='#{table_name}'").any?
74
+ end
75
+
76
+ # Load all records for a scope
77
+ #
78
+ # scope - A MotionRecord::Scope
79
+ #
80
+ # Returns an Array of row Hashes
81
+ def select(scope)
82
+ select_statement = "SELECT * FROM #{scope.klass.table_name} #{scope.predicate}"
83
+ @db.execute(select_statement, scope.predicate_values)
84
+ end
85
+
86
+ # Add a row to a table
87
+ #
88
+ # table_name - name of the table
89
+ # params - Hash of column names to values to insert
90
+ def insert(table_name, params)
91
+ pairs = params.to_a
92
+ param_names = pairs.map(&:first)
93
+ param_values = pairs.map(&:last)
94
+ param_marks = Array.new(param_names.size, "?").join(", ")
95
+
96
+ insert_statement = "INSERT INTO #{table_name} (#{param_names.join(", ")}) VALUES (#{param_marks})"
97
+
98
+ @db.execute insert_statement, param_values
99
+ end
100
+
101
+ # Add a row to a table
102
+ #
103
+ # scope - A MotionRecord::Scope
104
+ # params - Hash of column names to values to update
105
+ def update(scope, params)
106
+ pairs = params.to_a
107
+ param_names = pairs.map(&:first)
108
+ param_values = pairs.map(&:last)
109
+ param_marks = param_names.map { |param| "#{param} = ?" }.join(", ")
110
+
111
+ update_statement = "UPDATE #{scope.klass.table_name} SET #{param_marks} #{scope.predicate}"
112
+
113
+ @db.execute update_statement, param_values + scope.predicate_values
114
+ end
115
+
116
+ # Delete rows from a table
117
+ #
118
+ # scope - MotionRecord::Scope defining the set of rows to delete
119
+ def delete(scope)
120
+ delete_statement = "DELETE FROM #{scope.klass.table_name} #{scope.predicate}"
121
+
122
+ @db.execute delete_statement, scope.predicate_values
123
+ end
124
+
125
+ # Run a calculation on a set of rows
126
+ #
127
+ # scope - MotionRecord::Scope which defines the set of rows
128
+ # method - one of :count, :maximum, :minimum, :sum, :average
129
+ # column - name of the column to run the calculation on
130
+ #
131
+ # Returns the numerical value of calculation or nil if there were no rows
132
+ # in the scope
133
+ def calculate(scope, method, column)
134
+ case method
135
+ when :count
136
+ calculation = "COUNT(#{column || "*"})"
137
+ when :maximum
138
+ calculation = "MAX(#{column})"
139
+ when :minimum
140
+ calculation = "MIN(#{column})"
141
+ when :sum
142
+ calculation = "SUM(#{column})"
143
+ when :average
144
+ calculation = "AVG(#{column})"
145
+ else
146
+ raise "Unrecognized calculation: #{method.inspect}"
147
+ end
148
+
149
+ calculate_statement = "SELECT #{calculation} AS #{method} FROM #{scope.klass.table_name} #{scope.predicate}"
150
+
151
+ if (row = @db.execute(calculate_statement, scope.predicate_values).first)
152
+ row[method]
153
+ else
154
+ nil
155
+ end
156
+ end
157
+
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,99 @@
1
+ module MotionRecord
2
+ module Persistence
3
+
4
+ def save!
5
+ persist!
6
+ end
7
+
8
+ def delete!
9
+ if persisted?
10
+ self.class.where(primary_key_condition).delete_all
11
+ else
12
+ raise "Can't delete unpersisted records"
13
+ end
14
+ end
15
+
16
+ def primary_key_condition
17
+ {self.class.primary_key => self.instance_variable_get("@#{self.class.primary_key}")}
18
+ end
19
+
20
+ def persisted?
21
+ !!@persisted
22
+ end
23
+
24
+ def mark_persisted!
25
+ @persisted = true
26
+ end
27
+
28
+ def mark_unpersisted!
29
+ @persisted = false
30
+ end
31
+
32
+ protected
33
+
34
+ def persist!
35
+ # HACK: Must ensure that attribute definitions are loaded from the table
36
+ self.class.table_columns
37
+
38
+ params = self.to_attribute_hash.reject { |k, _v| k == self.class.primary_key }
39
+ table_params = self.class.to_table_params(params)
40
+
41
+ if persisted?
42
+ self.class.where(primary_key_condition).update_all(table_params)
43
+ else
44
+ connection.insert self.class.table_name, table_params
45
+ end
46
+
47
+ self.mark_persisted!
48
+ end
49
+
50
+ module ClassMethods
51
+ include MotionRecord::ScopeHelpers::ClassMethods
52
+ include MotionRecord::Serialization::ClassMethods
53
+
54
+ def create!(attributes={})
55
+ self.new(attributes).save!
56
+ end
57
+
58
+ # Sybmol name of the primary key column
59
+ def primary_key
60
+ :id
61
+ end
62
+
63
+ def table_name
64
+ # HACK: poor-man's .pluralize
65
+ self.to_s.downcase + "s"
66
+ end
67
+
68
+ def table_columns
69
+ unless @table_columns
70
+ @table_columns = get_columns_from_schema.each_with_object({}) do |column, hash|
71
+ hash[column.name] = column
72
+ end
73
+ @table_columns.values.each do |column|
74
+ define_attribute_from_column(column)
75
+ end
76
+ end
77
+ @table_columns
78
+ end
79
+
80
+ protected
81
+
82
+ # Internal: Fetch column definitions from the database
83
+ def get_columns_from_schema
84
+ pragma_columns = connection.execute "PRAGMA table_info(#{table_name});"
85
+ pragma_columns.map { |p| Schema::ColumnDefinition.from_pragma(p) }
86
+ end
87
+
88
+ # Interal: Set up setter/getter methods to correspond with a table column
89
+ def define_attribute_from_column(column)
90
+ # TODO: handle options
91
+ define_attribute column.name, default: column.default
92
+ end
93
+ end
94
+
95
+ def self.included(base)
96
+ base.extend(ClassMethods)
97
+ end
98
+ end
99
+ end