├── Rakefile ├── lib ├── motion_record │ ├── version.rb │ ├── serialization │ │ ├── default_serializer.rb │ │ ├── base_serializer.rb │ │ ├── boolean_serializer.rb │ │ ├── json_serializer.rb │ │ ├── date_serializer.rb │ │ └── time_serializer.rb │ ├── schema │ │ ├── migrator_definition.rb │ │ ├── migration.rb │ │ ├── migration_definition.rb │ │ ├── migrator.rb │ │ ├── index_definition.rb │ │ ├── table_definition.rb │ │ └── column_definition.rb │ ├── schema.rb │ ├── base.rb │ ├── scope_helpers.rb │ ├── serialization.rb │ ├── scope.rb │ ├── persistence.rb │ └── connection_adapters │ │ └── sqlite_adapter.rb └── motion_record.rb ├── Gemfile ├── CHANGELOG.md ├── .gitignore ├── motion_record.gemspec ├── LICENSE.txt └── README.md /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /lib/motion_record/version.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | VERSION = "0.0.3" 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in motion_record.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | Version changes are recorded with [GitHub releases](https://github.com/magoosh/motion_record/releases) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /lib/motion_record/serialization/default_serializer.rb: -------------------------------------------------------------------------------- 1 | # This serializer converts the object back and forth as-is 2 | module MotionRecord 3 | module Serialization 4 | class DefaultSerializer < BaseSerializer 5 | def serialize(value) 6 | value 7 | end 8 | 9 | def deserialize(value) 10 | value 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/motion_record/schema/migrator_definition.rb: -------------------------------------------------------------------------------- 1 | # DSL helper for defining migrations 2 | module MotionRecord 3 | module Schema 4 | class MigratorDefinition 5 | attr_reader :migrations 6 | 7 | def initialize 8 | @migrations = [] 9 | end 10 | 11 | def migration(version, name=nil, &block) 12 | migration_definition = Schema::MigrationDefinition.new(version, name) 13 | migration_definition.instance_eval &block 14 | @migrations << migration_definition 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/motion_record/serialization/base_serializer.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Serialization 3 | class BaseSerializer 4 | # column - a Schema::ColumnDefinition object 5 | def initialize(column) 6 | @column = column 7 | end 8 | 9 | # Override this method in a subclass to define the custom serializer 10 | def serialize(value) 11 | raise "Must be implemented" 12 | end 13 | 14 | # Override this method in a subclass to define the custom serializer 15 | def deserialize(value) 16 | raise "Must be implemented" 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/motion_record/serialization/boolean_serializer.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Serialization 3 | class BooleanSerializer < BaseSerializer 4 | 5 | def serialize(value) 6 | if @column.type == :integer 7 | value ? 1 : 0 8 | else 9 | raise "Can't serialize #{value.inspect} to #{@column.type.inspect}" 10 | end 11 | end 12 | 13 | def deserialize(value) 14 | if @column.type == :integer 15 | if value == 0 || value.nil? 16 | false 17 | else 18 | true 19 | end 20 | else 21 | raise "Can't deserialize #{value.inspect} from #{@column.type.inspect}" 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/motion_record/schema/migration.rb: -------------------------------------------------------------------------------- 1 | # MotionRecord::Schema::Migration represents versions of migrations which have 2 | # been run 3 | 4 | module MotionRecord 5 | module Schema 6 | class Migration < Base 7 | class << self 8 | def table_name 9 | "schema_migrations" 10 | end 11 | 12 | def table_exists? 13 | connection.table_exists?(table_name) 14 | end 15 | 16 | def primary_key 17 | nil 18 | end 19 | 20 | def create_table 21 | table = Schema::TableDefinition.new(table_name, id: false) 22 | table.integer :version, :null => false 23 | table.index :version, :unique => true 24 | table.execute 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/motion_record/schema/migration_definition.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Schema 3 | class MigrationDefinition 4 | attr_reader :version 5 | attr_reader :name 6 | 7 | def initialize(version, name = nil) 8 | @version = version.to_i 9 | @name = name || "Migration ##{@version}" 10 | @definitions = [] 11 | end 12 | 13 | def execute 14 | @definitions.each(&:execute) 15 | end 16 | 17 | def create_table(name, options = {}) 18 | table_definition = TableDefinition.new(name, options) 19 | 20 | if block_given? 21 | yield table_definition 22 | end 23 | 24 | @definitions << table_definition 25 | end 26 | 27 | def add_index(name, columns, options = {}) 28 | @definitions << IndexDefinition.new(name, columns, options) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /motion_record.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'motion_record/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "motion_record" 8 | spec.version = MotionRecord::VERSION 9 | spec.authors = ["Zach Millman"] 10 | spec.email = ["zach.millman@gmail.com"] 11 | spec.description = %q{Mini ActiveRecord for RubyMotion} 12 | spec.summary = %q{Miniature ActiveRecord for RubyMotion to use SQLite for storing your objects} 13 | spec.homepage = "https://github.com/magoosh/motion_record" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_development_dependency "bundler", "~> 1.3" 22 | spec.add_development_dependency "rake" 23 | end 24 | -------------------------------------------------------------------------------- /lib/motion_record/schema/migrator.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Schema 3 | class Migrator 4 | 5 | attr_reader :migrations 6 | 7 | def initialize(migrations) 8 | @migrations = migrations 9 | @migrated_versions = nil 10 | 11 | initialize_schema_table 12 | end 13 | 14 | def run 15 | pending_migrations.each do |migration| 16 | migration.execute 17 | @migrated_versions << migration.version 18 | Schema::Migration.create(version: migration.version) 19 | end 20 | end 21 | 22 | def pending_migrations 23 | @migrations.reject { |migration| migrated.include?(migration.version) } 24 | end 25 | 26 | def migrated 27 | @migrated_versions ||= Schema::Migration.pluck(:version).sort 28 | end 29 | 30 | protected 31 | 32 | def initialize_schema_table 33 | unless Schema::Migration.table_exists? 34 | Schema::Migration.create_table 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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. 23 | -------------------------------------------------------------------------------- /lib/motion_record/schema/index_definition.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Schema 3 | class IndexDefinition 4 | 5 | # Initialize the index definition 6 | # 7 | # table_name - name of the table 8 | # columns - either the String name of the column to index or an Array 9 | # of column names 10 | # options - optional Hash of options for the index 11 | # :unique - set to true to create a unique index 12 | # :name - provide a String to override the default index name 13 | def initialize(table_name, columns, options={}) 14 | @table_name = table_name 15 | @columns = columns.is_a?(Array) ? columns : [columns] 16 | 17 | @name = options[:name] || build_name_from_columns 18 | @unique = !!options[:unique] 19 | end 20 | 21 | # Add the index to the database 22 | def execute 23 | index_statement = "CREATE#{' UNIQUE' if @unique} INDEX #{@name} ON #{@table_name} (#{@columns.join ", "})" 24 | 25 | MotionRecord::Base.connection.execute index_statement 26 | end 27 | 28 | protected 29 | 30 | def build_name_from_columns 31 | "index_#{@table_name}_on_#{@columns.join "_and_"}" 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/motion_record/schema.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Schema 3 | # Define and run all pending migrations (should be done during app setup) 4 | # 5 | # options - Hash of configuration options for the SQLite connection 6 | # :file - full name of the database file, or :memory for in-memory 7 | # database files (default is "app.sqlite3" in the app's 8 | # `/Library/Application Support` folder) 9 | # :debug - set to false to turn off SQL debug logging 10 | # 11 | # Example: 12 | # 13 | # MotionRecord::Schema.up! do 14 | # migration 1, "Create events table" do 15 | # create_table "events" do |t| 16 | # t.text :name, :null => false 17 | # t.text :properties 18 | # end 19 | # end 20 | # 21 | # migration 2, "Index events table" do 22 | # add_index "events", "name", :unique => true 23 | # end 24 | # end 25 | # 26 | def self.up!(options={}, &block) 27 | ConnectionAdapters::SQLiteAdapter.configure(options) 28 | 29 | definition = Schema::MigratorDefinition.new 30 | definition.instance_eval &block 31 | 32 | Schema::Migrator.new(definition.migrations).run 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/motion_record/serialization/json_serializer.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Serialization 3 | class JSONParserError < StandardError; end 4 | 5 | class JSONSerializer < BaseSerializer 6 | 7 | def serialize(value) 8 | unless @column.type == :text 9 | raise "JSON can only be serialized to TEXT columns" 10 | end 11 | self.class.generate_json(value) 12 | end 13 | 14 | def deserialize(value) 15 | unless @column.type == :text 16 | raise "JSON can only be deserialized from TEXT columns" 17 | end 18 | self.class.parse_json(value) 19 | end 20 | 21 | # JSON generate/parse code is hoisted from BubbleWrap::JSON 22 | 23 | def self.generate_json(obj) 24 | NSJSONSerialization.dataWithJSONObject(obj, options:0, error:nil).to_str 25 | end 26 | 27 | def self.parse_json(str_data) 28 | return nil unless str_data 29 | data = str_data.respond_to?('dataUsingEncoding:') ? str_data.dataUsingEncoding(NSUTF8StringEncoding) : str_data 30 | opts = NSJSONReadingMutableContainers | NSJSONReadingMutableLeaves | NSJSONReadingAllowFragments 31 | error = Pointer.new(:id) 32 | obj = NSJSONSerialization.JSONObjectWithData(data, options:opts, error:error) 33 | raise JSONParserError, error[0].description if error[0] 34 | obj 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/motion_record.rb: -------------------------------------------------------------------------------- 1 | unless defined?(Motion::Project::Config) 2 | raise "This file must be required within a RubyMotion project Rakefile." 3 | end 4 | 5 | Motion::Project::App.setup do |app| 6 | # if Motion::Project.constants.include? :IOSConfig 7 | # # config for iOS app 8 | # end 9 | 10 | # if Motion::Project.constants.include? :AndroidConfig 11 | # # config for Android app 12 | # end 13 | 14 | dirname = File.dirname(__FILE__) 15 | 16 | serial_files = Dir.glob(File.join(dirname, 'motion_record/serialization/*.rb')) 17 | conn_files = Dir.glob(File.join(dirname, 'motion_record/connection_adapters/*.rb')) 18 | schema_files = Dir.glob(File.join(dirname, 'motion_record/schema/*.rb')) 19 | base_files = Dir.glob(File.join(dirname, 'motion_record/*.rb')) 20 | 21 | # RubyMotion for Android can't infer file dependencies so we must explicitly 22 | # declare their compilation order 23 | (base_files + schema_files + conn_files + serial_files).reverse.each do |file| 24 | app.files.unshift(file) 25 | end 26 | 27 | # Some files don't have the same dependency order and alphabetic order 28 | { 29 | "motion_record/base.rb" => "motion_record/persistence.rb", 30 | "motion_record/base.rb" => "motion_record/serialization.rb", 31 | "motion_record/base.rb" => "motion_record/scope_helpers.rb" 32 | }.each do |file, dependency| 33 | app.files_dependencies File.join(dirname, file) => File.join(dirname, dependency) 34 | end 35 | end 36 | 37 | module MotionRecord 38 | # Your code goes here... 39 | end 40 | -------------------------------------------------------------------------------- /lib/motion_record/base.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | class Base 3 | include Serialization 4 | include Persistence 5 | include ScopeHelpers 6 | 7 | def initialize(attributes={}) 8 | initialize_from_attribute_hash(attributes) 9 | end 10 | 11 | def to_attribute_hash 12 | self.class.attribute_names.each_with_object({}) do |name, hash| 13 | hash[name] = self.instance_variable_get "@#{name}" 14 | end 15 | end 16 | 17 | def connection 18 | self.class.connection 19 | end 20 | 21 | protected 22 | 23 | def initialize_from_attribute_hash(hash) 24 | self.class.attribute_defaults.merge(hash).each do |name, value| 25 | self.instance_variable_set "@#{name}", value 26 | end 27 | end 28 | 29 | class << self 30 | # Add attribute methods to the model 31 | # 32 | # name - Symobl name of the attribute 33 | # options - optional configuration Hash: 34 | # :default - default value for the attribute (nil otherwise) 35 | def define_attribute(name, options = {}) 36 | attr_accessor name 37 | self.attribute_names << name.to_sym 38 | if options[:default] 39 | self.attribute_defaults[name.to_sym] = options[:default] 40 | end 41 | end 42 | 43 | def attribute_names 44 | @attribute_names ||= [] 45 | end 46 | 47 | def attribute_defaults 48 | @attribute_defaults ||= {} 49 | end 50 | 51 | def connection 52 | ConnectionAdapters::SQLiteAdapter.instance 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/motion_record/schema/table_definition.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Schema 3 | class TableDefinition 4 | def initialize(name, options={}) 5 | @name = name 6 | @columns = [] 7 | @index_definitions = [] 8 | 9 | unless options.has_key?(:id) && !options[:id] 10 | add_default_primary_column 11 | end 12 | end 13 | 14 | def execute 15 | # Create table 16 | column_sql = @columns.map(&:to_sql_definition).join(", ") 17 | MotionRecord::Base.connection.execute "CREATE TABLE #{@name} (#{column_sql})" 18 | 19 | # Create table's indexes 20 | @index_definitions.each(&:execute) 21 | end 22 | 23 | def text(column_name, options={}) 24 | @columns << ColumnDefinition.new(:text, column_name, options) 25 | end 26 | 27 | def integer(column_name, options={}) 28 | @columns << ColumnDefinition.new(:integer, column_name, options) 29 | end 30 | 31 | def float(column_name, options={}) 32 | @columns << ColumnDefinition.new(:float, column_name, options) 33 | end 34 | 35 | def index(columns, options={}) 36 | @index_definitions << IndexDefinition.new(@name, columns, options) 37 | end 38 | 39 | # Add :created_at and :updated_at columns to the table 40 | def timestamps 41 | self.integer(:created_at) 42 | self.integer(:updated_at) 43 | end 44 | 45 | protected 46 | 47 | def add_default_primary_column 48 | @columns << ColumnDefinition.new(:integer, "id", primary: true) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/motion_record/scope_helpers.rb: -------------------------------------------------------------------------------- 1 | # These helper methods make it possible to call scope methods like `where` and 2 | # `find` directly on MotionRecord::Base classes 3 | # 4 | # Example: 5 | # 6 | # Event.where(:name => "launched").find_all 7 | # 8 | # FIXME: MotionRecord::Persistence includes this module, but the normal file 9 | # ordering breaks the dependency 10 | module MotionRecord 11 | module ScopeHelpers 12 | module ClassMethods 13 | 14 | # Read-only queries 15 | 16 | def exists? 17 | scoped.exists? 18 | end 19 | 20 | def first 21 | scoped.first 22 | end 23 | 24 | def find(id) 25 | scoped.find(id) 26 | end 27 | 28 | def find_all 29 | scoped.find_all 30 | end 31 | 32 | def pluck(attribute) 33 | scoped.pluck(attribute) 34 | end 35 | 36 | # Persistence queries 37 | 38 | def update_all(params) 39 | scoped.update_all(params) 40 | end 41 | 42 | def delete_all 43 | scoped.delete_all 44 | end 45 | 46 | # Calculations 47 | 48 | def count(column=nil) 49 | scoped.count(column) 50 | end 51 | 52 | def maximum(column) 53 | scoped.maximum(column) 54 | end 55 | 56 | def minimum(column) 57 | scoped.minimum(column) 58 | end 59 | 60 | def sum(column) 61 | scoped.sum(column) 62 | end 63 | 64 | def average(column) 65 | scoped.average(column) 66 | end 67 | 68 | # Scope building 69 | 70 | def where(conditions={}) 71 | scoped.where(conditions) 72 | end 73 | 74 | def order(ordering_term) 75 | scoped.order(ordering_term) 76 | end 77 | 78 | def limit(limit_value) 79 | scoped.limit(limit_value) 80 | end 81 | 82 | protected 83 | 84 | def scoped 85 | Scope.new(self) 86 | end 87 | end 88 | 89 | def self.included(base) 90 | base.extend(ClassMethods) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/motion_record/serialization/date_serializer.rb: -------------------------------------------------------------------------------- 1 | # This serializer stores Time objects to TEXT columns, but discards all 2 | # information except for year, month, and day. 3 | # 4 | # (Time is used because RubyMotion doesn't currently support Date objects) 5 | 6 | module MotionRecord 7 | module Serialization 8 | class DateSerializer < BaseSerializer 9 | 10 | # ISO8601 pattern that only matches date strings 11 | ISO8601_PATTERN = /\A\s* 12 | (-?\d+)-(\d\d)-(\d\d) 13 | \s*\z/ix 14 | 15 | def serialize(value) 16 | case @column.type 17 | when :text 18 | self.class.date_to_iso8601(value) 19 | else 20 | raise "Can't serialize #{value.inspect} to #{@column.type.inspect}" 21 | end 22 | end 23 | 24 | def deserialize(value) 25 | case @column.type 26 | when :text 27 | self.class.date_from_iso8601(value) 28 | else 29 | raise "Can't deserialize #{value.inspect} from #{@column.type.inspect}" 30 | end 31 | end 32 | 33 | # Convert a Time object to an ISO8601 format date string. 34 | # 35 | # time - the Time to convert 36 | # 37 | # Returns the String representation 38 | def self.date_to_iso8601(time) 39 | return nil unless time 40 | 41 | "%04d-%02d-%02d" % [time.year, time.month, time.day] 42 | end 43 | 44 | # Parse an ISO8601 format date string. 45 | # 46 | # date_str - the String date representation in ISO8601 format 47 | # 48 | # Returns a Time object 49 | def self.date_from_iso8601(date_str) 50 | return nil unless date_str 51 | 52 | if (match = ISO8601_PATTERN.match(date_str)) 53 | year = match[1].to_i 54 | mon = match[2].to_i 55 | day = match[3].to_i 56 | Time.utc(year, mon, day) 57 | else 58 | raise ArgumentError.new("invalid date: #{date_str.inspect}") 59 | end 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/motion_record/serialization/time_serializer.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Serialization 3 | class TimeSerializer < BaseSerializer 4 | 5 | # Pattern stolen from Ruby Time's xmlschema method 6 | ISO8601_PATTERN = /\A\s* 7 | (-?\d+)-(\d\d)-(\d\d) 8 | T 9 | (\d\d):(\d\d):(\d\d) 10 | (\.\d+)? 11 | (Z|[+-]\d\d:\d\d)? 12 | \s*\z/ix 13 | 14 | def serialize(value) 15 | case @column.type 16 | when :integer, :float 17 | value.to_i 18 | when :text 19 | self.class.time_to_iso8601(value) 20 | else 21 | raise "Can't serialize #{value.inspect} to #{@column.type.inspect}" 22 | end 23 | end 24 | 25 | def deserialize(value) 26 | case @column.type 27 | when :integer, :float 28 | Time.at(value) 29 | when :text 30 | self.class.time_from_iso8601(value) 31 | else 32 | raise "Can't deserialize #{value.inspect} from #{@column.type.inspect}" 33 | end 34 | end 35 | 36 | # Convert a Time object to an ISO8601 format time string 37 | # 38 | # time - the Time to convert 39 | # 40 | # Returns the String representation 41 | def self.time_to_iso8601(time) 42 | if time.utc_offset == 0 43 | zone = "Z" 44 | else 45 | offset_hours = time.utc_offset / 3600 46 | offset_minutes = (time.utc_offset - (offset_hours * 3600)) / 60 47 | zone = "%+03d:%02d" % [offset_hours, offset_minutes] 48 | end 49 | 50 | if time.usec != 0 51 | "%04d-%02d-%02dT%02d:%02d:%02d.%03d%s" % [time.year, time.month, time.day, time.hour, time.min, time.sec, time.usec, zone] 52 | else 53 | "%04d-%02d-%02dT%02d:%02d:%02d:%s" % [time.year, time.month, time.day, time.hour, time.min, time.sec, zone] 54 | end 55 | end 56 | 57 | # Parse an ISO8601 format time string 58 | # 59 | # time_str - the String time representation in ISO8601 format 60 | # 61 | # Returns a Time object 62 | def self.time_from_iso8601(time_str) 63 | # Logic stolen from Ruby Time's xmlschema method 64 | if (match = ISO8601_PATTERN.match(time_str)) 65 | year = match[1].to_i 66 | mon = match[2].to_i 67 | day = match[3].to_i 68 | hour = match[4].to_i 69 | min = match[5].to_i 70 | sec = match[6].to_i 71 | # usec = (match[7] || 0).to_i # microsecond values are discarded 72 | zone = match[8] 73 | if zone == "Z" 74 | Time.utc(year, mon, day, hour, min, sec) 75 | elsif zone 76 | Time.new(year, mon, day, hour, min, sec, zone) 77 | end 78 | else 79 | raise ArgumentError.new("invalid date: #{time_str.inspect}") 80 | end 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/motion_record/serialization.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Serialization 3 | module ClassMethods 4 | # Register a new attribute serializer 5 | # 6 | # attribute - Symbol name of the attribute 7 | # serializer_class_or_sym - One of :time, :boolean, :json, :date or a custom 8 | # subclass of Serialization::BaseSerializer 9 | def serialize(attribute, serializer_class_or_sym) 10 | if serializer_class_or_sym.is_a?(Symbol) 11 | self.serializer_classes[attribute] = case serializer_class_or_sym 12 | when :time 13 | Serialization::TimeSerializer 14 | when :date 15 | Serialization::DateSerializer 16 | when :boolean 17 | Serialization::BooleanSerializer 18 | when :json 19 | Serialization::JSONSerializer 20 | else 21 | raise "Unknown serializer #{serializer_class_or_sym.inspect}" 22 | end 23 | else 24 | self.serializer_classes[attribute] = serializer_class_or_sym 25 | end 26 | end 27 | 28 | # Deserialize a Hash of attributes from their database representation 29 | # 30 | # params - a Hash of Symbol column names to SQLite values 31 | # 32 | # Returns a Hash with all values replaced by their deserialized versions 33 | def deserialize_table_params(params) 34 | params.each_with_object({}) do |name_and_value, attributes| 35 | name, value = name_and_value 36 | attributes[name.to_sym] = serializer(name.to_sym).deserialize(value) 37 | end 38 | end 39 | 40 | # Serialize a Hash of attributes to their database representation 41 | # 42 | # params - a Hash of Symbol column names to their attribute values 43 | # 44 | # Returns a Hash with all values replaced by their serialized versions 45 | def serialize_table_params(hash) 46 | hash.each_with_object({}) do |attribute_and_value, params| 47 | attribute, value = attribute_and_value 48 | params[attribute] = serializer(attribute).serialize(value) 49 | end 50 | end 51 | 52 | protected 53 | 54 | # Internal: Get the serializer object for an attribute 55 | # 56 | # attribute - Symbol name of the attribute 57 | def serializer(attribute) 58 | @serializers ||= {} 59 | unless @serializers[attribute] 60 | @serializers[attribute] = build_serializer(attribute) 61 | end 62 | @serializers[attribute] 63 | end 64 | 65 | # Internal: Registry of serializer classes 66 | def serializer_classes 67 | @serializer_classes ||= Hash.new(Serialization::DefaultSerializer) 68 | end 69 | 70 | def build_serializer(attribute) 71 | serializer_classes[attribute].new(table_columns[attribute]) 72 | end 73 | end 74 | 75 | def self.included(base) 76 | base.extend(ClassMethods) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/motion_record/scope.rb: -------------------------------------------------------------------------------- 1 | # A model for building scoped database queries 2 | # 3 | 4 | module MotionRecord 5 | class Scope 6 | 7 | attr_reader :klass 8 | attr_reader :conditions 9 | 10 | def initialize(klass, options = {}) 11 | @klass = klass 12 | @conditions = options[:conditions] || {} # TODO: freeze? 13 | @order = options[:order] 14 | @limit = options[:limit] 15 | end 16 | 17 | # Scope builder 18 | 19 | def where(conditions={}) 20 | Scope.new(@klass, :conditions => @conditions.merge(conditions), :order => @order, :limit => @limit) 21 | end 22 | 23 | def order(ordering_term) 24 | Scope.new(@klass, :conditions => @conditions, :order => ordering_term, :limit => @limit) 25 | end 26 | 27 | def limit(limit_value) 28 | Scope.new(@klass, :conditions => @conditions, :order => @order, :limit => limit_value) 29 | end 30 | 31 | # Read-only queries 32 | 33 | def exists? 34 | count > 0 35 | end 36 | 37 | def first 38 | limit(1).find_all.first 39 | end 40 | 41 | def find(id) 42 | self.where(@klass.primary_key => id).first 43 | end 44 | 45 | def find_all 46 | connection.select(self).map do |row| 47 | record = @klass.new(@klass.deserialize_table_params(row)) 48 | record.mark_persisted! 49 | record 50 | end 51 | end 52 | 53 | def pluck(attribute) 54 | connection.select(self).map { |row| row[attribute] } 55 | end 56 | 57 | # Persistence queries 58 | 59 | def update_all(params) 60 | connection.update(self, params) 61 | end 62 | 63 | def delete_all 64 | connection.delete(self) 65 | end 66 | 67 | # Calculations 68 | 69 | def count(column=nil) 70 | calculate(:count, column) 71 | end 72 | 73 | def maximum(column) 74 | calculate(:maximum, column) 75 | end 76 | 77 | def minimum(column) 78 | calculate(:minimum, column) 79 | end 80 | 81 | def sum(column) 82 | calculate(:sum, column) 83 | end 84 | 85 | def average(column) 86 | calculate(:average, column) 87 | end 88 | 89 | # SQL helpers 90 | 91 | def predicate? 92 | predicate_segments.any? 93 | end 94 | 95 | def predicate 96 | predicate_segments.join(" ") 97 | end 98 | 99 | def predicate_values 100 | condition_columns.map { |column| @conditions[column] } 101 | end 102 | 103 | protected 104 | 105 | def calculate(method, column) 106 | connection.calculate(self, method, column) 107 | end 108 | 109 | def predicate_segments 110 | unless @predicate_segments 111 | @predicate_segments = [] 112 | if condition_columns.any? 113 | @predicate_segments << "WHERE #{condition_columns.map { |c| "#{c} = ? " }.join " AND "}" 114 | end 115 | if @order 116 | @predicate_segments << "ORDER BY #{@order}" 117 | end 118 | if @limit 119 | @predicate_segments << "LIMIT #{@limit}" 120 | end 121 | end 122 | @predicate_segments 123 | end 124 | 125 | def condition_columns 126 | @condition_columns ||= @conditions.keys 127 | end 128 | 129 | def connection 130 | Base.connection 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/motion_record/schema/column_definition.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Schema 3 | class ColumnDefinition 4 | TYPE_MAP = { 5 | :integer => "INTEGER", 6 | :text => "TEXT", 7 | :float => "REAL" 8 | } 9 | INVERSE_TYPE_MAP = { 10 | "INTEGER" => :integer, 11 | "TEXT" => :text, 12 | "REAL" => :float 13 | } 14 | 15 | attr_reader :type 16 | attr_reader :name 17 | attr_reader :options 18 | 19 | # name - name of the column 20 | # type - a Symbol representing the column type 21 | # options - Hash of constraints for the column: 22 | # :primary - set to true to configure as the primary auto-incrementing key 23 | # :null - set to false to add a "NOT NULL" constraint 24 | # :default - TODO 25 | def initialize(type, name, options) 26 | @type = type.to_sym 27 | @name = name.to_sym 28 | @options = options 29 | end 30 | 31 | def to_sql_definition 32 | [@name, sql_type, sql_options].compact.join(" ") 33 | end 34 | 35 | def default 36 | @options[:default] 37 | end 38 | 39 | # Build a new ColumnDefinition from the result of a "PRAGMA table_info()" 40 | # query 41 | # 42 | # pragma - Hash representing a row of the query's result: 43 | # :cid - column index 44 | # :name - column name 45 | # :type - column type 46 | # :notnull - integer flag for "NOT NULL" 47 | # :dflt_value - default value 48 | # :pk - integer flag for primary key 49 | # 50 | # Returns the new ColumnDefinition 51 | def self.from_pragma(pragma) 52 | type = INVERSE_TYPE_MAP[pragma[:type]] 53 | options = { 54 | :null => (pragma[:notnull] != 1), 55 | :primary => (pragma[:pk] == 1), 56 | :default => (pragma[:dflt_value]) 57 | } 58 | 59 | if options[:default] 60 | case type 61 | when :integer 62 | options[:default] = options[:default].to_i 63 | when :float 64 | options[:default] = options[:default].to_f 65 | end 66 | end 67 | 68 | self.new(type, pragma[:name], options) 69 | end 70 | 71 | protected 72 | 73 | def sql_type 74 | if TYPE_MAP[@type] 75 | TYPE_MAP[@type] 76 | else 77 | raise "Unrecognized column type: #{@type.inspect}" 78 | end 79 | end 80 | 81 | def sql_options 82 | sql_options = [] 83 | 84 | @options.each do |key, value| 85 | case key 86 | when :primary 87 | if value 88 | sql_options << "PRIMARY KEY ASC AUTOINCREMENT" 89 | end 90 | when :null 91 | if !value 92 | sql_options << "NOT NULL" 93 | end 94 | when :default 95 | if value 96 | sql_options << "DEFAULT #{value.inspect}" 97 | end 98 | else 99 | raise "Unrecognized column option: #{key.inspect}" 100 | end 101 | end 102 | 103 | if sql_options.any? 104 | sql_options.join(" ") 105 | else 106 | nil 107 | end 108 | end 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/motion_record/persistence.rb: -------------------------------------------------------------------------------- 1 | module MotionRecord 2 | module Persistence 3 | 4 | TIMESTAMP_COLUMNS = [:created_at, :updated_at] 5 | 6 | def save 7 | persist! 8 | end 9 | 10 | def destroy 11 | delete! 12 | end 13 | 14 | def persisted? 15 | !!@persisted 16 | end 17 | 18 | def mark_persisted! 19 | @persisted = true 20 | end 21 | 22 | def mark_unpersisted! 23 | @persisted = false 24 | end 25 | 26 | protected 27 | 28 | def persist! 29 | # HACK: Must ensure that attribute definitions are loaded from the table 30 | self.class.table_columns 31 | 32 | self.apply_persistence_timestamps 33 | params = self.to_attribute_hash.reject { |k, _v| k == self.class.primary_key } 34 | table_params = self.class.serialize_table_params(params) 35 | 36 | if persisted? 37 | self.class.where(primary_key_condition).update_all(table_params) 38 | else 39 | connection.insert self.class.table_name, table_params 40 | if self.class.primary_key 41 | # HACK: This assumes that primary keys are monotonically increasing 42 | newest_primary_key = self.class.maximum(self.class.primary_key) 43 | self.self.instance_variable_set "@#{self.class.primary_key}", newest_primary_key 44 | end 45 | end 46 | 47 | self.mark_persisted! 48 | end 49 | 50 | def delete! 51 | if persisted? 52 | self.class.where(primary_key_condition).delete_all 53 | else 54 | raise "Can't delete unpersisted records" 55 | end 56 | end 57 | 58 | # Update persistence auto-timestamp attributes 59 | def apply_persistence_timestamps 60 | self.updated_at = Time.now if self.class.attribute_names.include?(:updated_at) 61 | self.created_at ||= Time.now if self.class.attribute_names.include?(:created_at) 62 | end 63 | 64 | def primary_key_condition 65 | {self.class.primary_key => self.instance_variable_get("@#{self.class.primary_key}")} 66 | end 67 | 68 | module ClassMethods 69 | # Create a new record 70 | # 71 | # attributes - a Hash of attribute values 72 | # 73 | # Returns the created record 74 | def create(attributes={}) 75 | record = self.new(attributes) 76 | record.save 77 | record 78 | end 79 | 80 | # Sybmol name of the primary key column 81 | def primary_key 82 | :id 83 | end 84 | 85 | def table_name 86 | # HACK: poor-man's .pluralize 87 | self.to_s.downcase + "s" 88 | end 89 | 90 | def table_columns 91 | unless @table_columns 92 | @table_columns = get_columns_from_schema.each_with_object({}) do |column, hash| 93 | hash[column.name] = column 94 | end 95 | @table_columns.values.each do |column| 96 | define_attribute_from_column(column) 97 | end 98 | end 99 | @table_columns 100 | end 101 | 102 | protected 103 | 104 | # Internal: Fetch column definitions from the database 105 | def get_columns_from_schema 106 | pragma_columns = connection.execute "PRAGMA table_info(#{table_name});" 107 | pragma_columns.map { |p| Schema::ColumnDefinition.from_pragma(p) } 108 | end 109 | 110 | # Interal: Set up setter/getter methods to correspond with a table column 111 | def define_attribute_from_column(column) 112 | # TODO: handle options 113 | define_attribute column.name, default: column.default 114 | 115 | if TIMESTAMP_COLUMNS.include?(column.name) 116 | serialize column.name, :time 117 | end 118 | end 119 | end 120 | 121 | def self.included(base) 122 | base.extend(ClassMethods) 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/motion_record/connection_adapters/sqlite_adapter.rb: -------------------------------------------------------------------------------- 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 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | * [Installation](#installation) 10 | * [MotionRecord::Base](#motionrecordbase) 11 | * [MotionRecord::Schema](#motionrecordschema) 12 | * [MotionRecord::Scope](#motionrecordscope) 13 | * [MotionRecord::Serialization](#motionrecordserialization) 14 | * [MotionRecord::Association](#motionrecordassociation) 15 | 16 | :turtle: Android support should be [coming soon](https://github.com/magoosh/motion_record/issues/3) 17 | 18 | [![Gem Version](https://badge.fury.io/rb/motion_record.svg)](http://badge.fury.io/rb/motion_record) [![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) 19 | 20 | Installation 21 | ------------ 22 | 23 | Add this line to your Gemfile: 24 | 25 | ```ruby 26 | gem "motion_record" 27 | ``` 28 | 29 | On iOS, MotionRecord uses [motion-sqlite3](https://github.com/mattgreen/motion-sqlite3) 30 | as a wrapper for connecting to SQLite, so add these too: 31 | 32 | ```ruby 33 | gem "motion-sqlite3" 34 | # Requires the most recent unpublished version of motion.h 35 | # https://github.com/kastiglione/motion.h/issues/11 36 | gem "motion.h", :git => "https://github.com/kastiglione/motion.h" 37 | ``` 38 | 39 | And then execute: 40 | 41 | ``` 42 | $ bundle 43 | ``` 44 | 45 | MotionRecord::Base 46 | ------------------ 47 | 48 | MotionRecord::Base provides a superclass for defining objects which are stored 49 | in the database. 50 | 51 | ```ruby 52 | class Message < MotionRecord::Base 53 | # That's all! 54 | end 55 | ``` 56 | 57 | Attribute methods are inferred from the associated SQLite table definition. 58 | 59 | ```ruby 60 | message = Message.new(subject: "Welcome!") 61 | # => # 62 | ``` 63 | 64 | Manage persistence with `create`, `save`, `destroy`, and `persisted?` 65 | 66 | ```ruby 67 | message = Message.create(subject: "Welcome!") 68 | message.body = "If you have any questions, just ask us :)" 69 | message.save 70 | # SQL: UPDATE messages SET subject = ?, body = ?, ... WHERE id = ? 71 | # Params: ["Welcome!", "If you have any questions, just ask :)", ..., 1] 72 | message.destroy 73 | message.persisted? 74 | # => false 75 | ``` 76 | 77 | ### Timestamp Columns 78 | 79 | If any of the columns are named `created_at` or `updated_at` then they are 80 | automatically [serialized as Time objects](#motionrecordserialization) and set 81 | to `Time.now` when the record is created or updated. 82 | 83 | MotionRecord::Schema 84 | -------------------- 85 | 86 | Define and run all pending SQLite migrations with the `up!` DSL. 87 | 88 | ```ruby 89 | def application(application, didFinishLaunchingWithOptions:launchOptions) 90 | MotionRecord::Schema.up! do 91 | migration 1, "Create messages table" do 92 | create_table :messages do |t| 93 | t.text :subject, null: false 94 | t.text :body 95 | t.integer :read_at 96 | t.integer :remote_id 97 | t.float :satisfaction, default: 0.0 98 | t.timestamps 99 | end 100 | end 101 | 102 | migration 2, "Index messages table" do 103 | add_index :messages, :remote_id, :unique => true 104 | add_index :messages, [:subject, :read_at] 105 | end 106 | end 107 | # ... 108 | end 109 | ``` 110 | 111 | #### Schema Configuration 112 | 113 | By default, MotionRecord will print all SQL statements and use a file named 114 | `"app.sqlite3"` in the application's Application Support folder. To disable 115 | logging (for release) or change the filename, pass configuration options to `up!` 116 | 117 | ```ruby 118 | resource_file = File.join(NSBundle.mainBundle.resourcePath, "data.sqlite3") 119 | MotionRecord::Schema.up!(file: resource_file, debug: false) # ... 120 | ``` 121 | 122 | You can also specify that MotionRecord should use an in-memory SQLite database 123 | which will be cleared every time the app process is killed. 124 | 125 | ```ruby 126 | MotionRecord::Schema.up!(file: :memory) # ... 127 | ``` 128 | 129 | MotionRecord::Scope 130 | ------------------- 131 | 132 | Build scopes on MotionRecord::Base classes with `where`, `order` and `limit`. 133 | 134 | ```ruby 135 | Message.where(body: nil).order("read_at DESC").limit(3).find_all 136 | ``` 137 | 138 | Run queries on scopes with `exists?`, `first`, `find`, `find_all`, `pluck`, 139 | `update_all`, and `delete_all`. 140 | 141 | ```ruby 142 | Message.where(remote_id: 2).exists? 143 | # => false 144 | Message.find(21) 145 | # => # 146 | Message.where(read_at: nil).pluck(:subject) 147 | # => ["What's updog?", "What's updog?", "What's updog?"] 148 | Message.where(read_at: nil).find_all 149 | # => [#, #, #] 150 | Message.where(read_at: nil).update_all(read_at: Time.now.to_i) 151 | ``` 152 | 153 | Run calculations on scopes with `count`, `sum`, `maximum`, `minimum`, and 154 | `average`. 155 | 156 | ```ruby 157 | Message.where(subject: "Welcome!").count 158 | # => 1 159 | Message.where(subject: "How do you like the app?").maximum(:satisfaction) 160 | # => 10.0 161 | ``` 162 | 163 | MotionRecord::Serialization 164 | ---------------------------------- 165 | 166 | SQLite has a very limited set of datatypes (TEXT, INTEGER, and REAL), but you 167 | can easily store other objects as attributes in the database with serializers. 168 | 169 | #### Built-in Serializers 170 | 171 | MotionRecord provides a built-in serializer for Time objects to any column 172 | datatype. 173 | 174 | ```ruby 175 | class Message < MotionRecord::Base 176 | serialize :read_at, :time 177 | end 178 | 179 | Message.create(subject: "Hello!", read_at: Time.now) 180 | # SQL: INSERT INTO messages (subject, body, read_at, ...) VALUES (?, ?, ?...) 181 | # Params: ["Hello!", nil, 1420099200, ...] 182 | Message.first.read_at 183 | # => 2015-01-01 00:00:00 -0800 184 | ``` 185 | 186 | Boolean attributes can be serialized to INTEGER columns where 0 and NULL are 187 | `false` and any other value is `true`. 188 | 189 | ```ruby 190 | class Message < MotionRecord::Base 191 | serialize :satisfaction_submitted, :boolean 192 | end 193 | ``` 194 | 195 | Objects can also be stored to TEXT columns as JSON. 196 | 197 | ```ruby 198 | class Survey < MotionRecord::Base 199 | serialize :response, :json 200 | end 201 | 202 | survey = Survey.create(response: {nps: 10, what_can_we_improve: "Nothing :)"}) 203 | # SQL: INSERT INTO surveys (response) VALUES (?) 204 | # Params: ['{"nps":10, "what_can_we_improve":"Nothing :)"}'] 205 | survey 206 | # => #10, "what_can_we_improve"=>"Nothing :)"}> 207 | ``` 208 | 209 | RubyMotion doesn't have a Date class, but as long as you're okay with using Time 210 | objects with only the date attributes, you can serialize them to TEXT columns: 211 | 212 | ```ruby 213 | class User < MotionRecord::Base 214 | serialize :birthday, :date 215 | end 216 | 217 | drake = User.create(birthday: Time.new(1986, 10, 24)) 218 | # SQL: INSERT INTO users (birthday) VALUES (?) 219 | # Params: ["1986-10-24"] 220 | # => # 221 | ``` 222 | 223 | #### Custom Serializers 224 | 225 | To write a custom serializer, extend MotionRecord::Serialization::BaseSerializer 226 | and provide your class to `serialize` instead of a symbol. 227 | 228 | ```ruby 229 | class MoneySerializer < MotionRecord::Serialization::BaseSerializer 230 | def serialize(value) 231 | raise "Wrong column type!" unless @column.type == :integer 232 | value.cents 233 | end 234 | 235 | def deserialize(value) 236 | raise "Wrong column type!" unless @column.type == :integer 237 | Money.new(value) 238 | end 239 | end 240 | 241 | class Purchase < MotionRecord::Base 242 | serialize :amount_paid_cents, MoneySerializer 243 | end 244 | ``` 245 | 246 | MotionRecord::Association 247 | ------------------------- 248 | 249 | [TODO](https://github.com/magoosh/motion_record/issues/7) 250 | 251 | 252 | Contributing 253 | ------------ 254 | 255 | Please do! 256 | 257 | 1. Fork it 258 | 2. Create your feature branch (`git checkout -b my-new-feature`) 259 | 3. Commit your changes (`git commit -am 'Add some feature'`) 260 | 4. Push to the branch (`git push origin my-new-feature`) 261 | 5. Create new Pull Request 262 | --------------------------------------------------------------------------------