motion_record 0.0.1

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