drive_time 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,61 @@
1
+ # Also see Global .gitignore: ~/.gitignore_global
2
+ # https://github.com/stationkeeping/osx/blob/master/git/.gitignore_global
3
+
4
+ # Environmental Variables
5
+ #########################
6
+
7
+ .env
8
+
9
+ # Rails
10
+ #########################
11
+
12
+ *.rbc
13
+ *.sassc
14
+ .sass-cache
15
+ capybara-*.html
16
+ .rspec
17
+ .rvmrc
18
+ /.bundle
19
+ /vendor/bundle
20
+ /log/*
21
+ /tmp/*
22
+ /db/*.sqlite3
23
+ /public/system/*
24
+ /coverage/
25
+ /spec/tmp/*
26
+ **.orig
27
+ rerun.txt
28
+ pickle-email-*.html
29
+ .project
30
+
31
+ # Ruby
32
+ #########################
33
+
34
+ *.gem
35
+ *.rbc
36
+ .bundle
37
+ .config
38
+ coverage
39
+ InstalledFiles
40
+ lib/bundler/man
41
+ pkg
42
+ rdoc
43
+ spec/reports
44
+ test/tmp
45
+ test/version_tmp
46
+ tmp
47
+
48
+ # YARD artifacts
49
+ .yardoc
50
+ _yardoc
51
+ doc/
52
+
53
+ # Ruby Gems
54
+ #########################
55
+
56
+ Gemfile.lock
57
+
58
+ # Project Specific
59
+ #########################
60
+
61
+ spec/fixtures/cached/**
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in drive_time.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Pedr Browne
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,240 @@
1
+ # DriveTime
2
+
3
+ Drive Time allows you to transform a one or more Google Spredsheet into a Rails model graph.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'drive_time'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install drive_time
18
+
19
+ ## Usage
20
+
21
+ ### Drive Time
22
+
23
+ Drive Time allows you to map a Google Spreadsheet to a your Rails database. Each worksheet represents a different Model class; its columns represent the model's attributes and each row represents a different instance. It is designed to make as many allowances for the person authoring the spreadsheet as possible so it can be used as a bridge between a non-technical user and yourself. Unless they are an idiot, in which case you're still screwed.
24
+
25
+ *I am using it sucessfully in two projects at the moment, however this is very much alpha and I am adding features as I need them. My main priority is to add more comprehansive tests.*
26
+
27
+ #### Installation
28
+
29
+ You know the drill. Add `gem drive_time` to your `Gemfile` and run `$ bundle`.
30
+
31
+ #### Prerequisits
32
+
33
+ Drive Time uses the [Google Drive](https://github.com/gimite/google-drive-ruby) gem, which handles the connection to Google Drive, the download and transforms the Spreadsheet and Worksheet into Ruby objects. It relies on the presence of two envs:
34
+
35
+ ```
36
+ GOOGLE_USERNAME=your.email@gmail.com
37
+ GOOGLE_PASSWORD=YourPassword
38
+ ```
39
+
40
+ You will also need a Rails project with a migrated datbase, ready to recieve the generated models.
41
+
42
+ #### Using Drive Time
43
+
44
+ Drive Time needs access to your Rails project. You can run it as part of the seeding process, or directly using `$ rails runner`.
45
+
46
+ An example of usage:
47
+
48
+ ```
49
+ include DriveTime
50
+
51
+ mappings_path = File.join(File.dirname(__FILE__),'mappings.yml')
52
+ SpreadsheetsConverter.new.load mappings_path
53
+ ```
54
+
55
+ The `mappings.yml` is crucial. It specifies the name of your spreadsheet and how you want to map your worksheets to your models.
56
+
57
+ ##### Mappings
58
+
59
+ A bare-bones mapping might look like this:
60
+
61
+ ```
62
+ spreadsheets:
63
+ - title: Business
64
+ worksheets:
65
+ - title: Company
66
+ key: name
67
+ fields:
68
+ - name: name
69
+ - name: description
70
+ associations:
71
+ - name: learning_group
72
+ ```
73
+
74
+ Drive Time will look for a spreadsheet called 'Business' and download it. It will then find a spreadsheet within called 'Company' and run through each row, generating a new model of class `Company` with attributes of `name` and `description`.
75
+
76
+ The `key` is a unique identifier which is used internally to identify the models and is used within the spreadsheets to declare associations. In this case we are declaring that we want to use the name field as the key. The crucial thing is that the key is unique amongst all models of that type. If no attributes are guarenteed to be unique, one option is to add an explicit `key` column, containing a unique identifier to the spreadsheet, however this is unwealdy and easy to lose track of. A better option is to use multiple attributes to generate the key. This can be done using a builder:
77
+
78
+ ```
79
+ worksheets:
80
+ - title: Company
81
+ key:
82
+ builder: join
83
+ from_fields: [name, city]
84
+ ```
85
+
86
+ This will result in a key made from the company name and city combined. This is great for giving models of the same type a unique key, but requires more thought if the id will be used by other models to declare associations.
87
+
88
+ *Note: Internally, keys are just downcased, underscored and whitespace-stripped versions of whater value is ussed for the key.*
89
+
90
+ *All fields values are run through a markdown parser before they are added to the model.*
91
+
92
+ ##### Associations
93
+
94
+ Below an association is declared between the `Company` and the `Product`. It is declared as a singular relationship using the `singular` mapping attribute.
95
+
96
+ Drive Time assumes that if a worksheet declares a singular attribute mapping, it will contain a column named after the model. This field should contain the key of the instance of the model that it will use to satisfy its dependency. In the example below it would contain the sku of the `Product` model.
97
+
98
+ ```
99
+ spreadsheets:
100
+ - title: Business
101
+ worksheets:
102
+ - title: Company
103
+ key: name
104
+ fields:
105
+ - name: name
106
+ - name: description
107
+ associations:
108
+ - name: Product
109
+ - singular: true
110
+ - title: Product
111
+ key: sku
112
+ fields:
113
+ - name: title
114
+ - name: sku
115
+ - name: price
116
+ ```
117
+
118
+ One-to-many relationships can be declared in two ways.
119
+
120
+ If there are only a few possible options, a column can be assigned to each option and named after the option. For example if a company had one or more Regions from either Europe, or Asia or America, and we were using the name field for the Region key, we could add three columns to the Company spreadsheet named 'Europe', 'Asia', and 'America'. If we want to add that region we would add a value of 'Yes' to the field:
121
+
122
+ ```
123
+ associations:
124
+ - name: region
125
+ builder: use_fields
126
+ field_names: [europe, asia, america]
127
+ ```
128
+
129
+ Another option, appropriate for situations where there are many possible values, is to declare an inverted relationship. for example, if you had a 'Team' that had many 'Players', rather than declaring the team's players in the team Spreadsheet, you could declare each Player's team in a column in the Player Spreadsheet. For this to work, you must declare the relationship inverse. So in the Player worksheet mapping:
130
+
131
+ ```
132
+ associations:
133
+ - name: team
134
+ inverse: true
135
+ ```
136
+
137
+ A final option is to add a comma separated list of keys and use the 'join' builder. A model wil be added for each key:
138
+
139
+ ```
140
+ associations:
141
+ - name: member
142
+ builder: multi
143
+ ```
144
+
145
+ A polymorphic association can be declared using:
146
+
147
+ ```
148
+ associations:
149
+ - name: team
150
+ polymorphic:
151
+ association: contactable
152
+ ```
153
+
154
+ It is OK to mix 'polymorphic' and 'singular' for the same association.
155
+
156
+ Using Google Spreadsheet's data validations can make things much easier on the Spreadsheet end. You can effectively add dropdowns containing values from a fixed list or from another Worksheet column, making sure that only valid values can be added.
157
+
158
+ ##### Caching
159
+
160
+ By setting an env called `CACHED_DIR` to a directory path, Drive Time will store downloaded spreadsheets there and use them on subsequent occasions. Just delete the contents of the folder or remove the env to reload new versions the next time it runs.
161
+
162
+
163
+ ```
164
+ CACHED_DIR=/Users/me/path/to/cached/directory
165
+ ```
166
+
167
+ ##### File Expansion and Text Docs
168
+
169
+ If Drive Time encounters a value surrounded by `{{` and `}}` within a database field, it will use this value to load another file from Google Drive and use its content in the place of the field value. Possible values are `expand_spreadsheet` and `expand_file` which can be used to load the content of a spreadsheet or a `.txt` file respectively:
170
+
171
+ ```
172
+ {{expand_spreadsheet}}
173
+ ```
174
+
175
+ And:
176
+
177
+ ```
178
+ {{expand_file}}
179
+ ```
180
+
181
+ In both cases, Drive Time will try to load a file named identically to the key for the model on which the field will be added. To specify a different filename, add it in hard brackets and without an extension:
182
+
183
+ ```
184
+ {{expand_spreadsheet[some_spreadsheet_file_name]}}
185
+ ```
186
+
187
+ And:
188
+
189
+ ```
190
+ {{expand_spreadsheet[some_txt_file_name]}}
191
+ ```
192
+
193
+ In the case of the text file, its contents will simply be added. In the case of a spreadsheet, it will be converted to a JSON object which will be used as the field value.
194
+
195
+ ##### Debugging
196
+
197
+ By default, Drive Time outputs a minimal set of messages. To enable a much more verbose output, set the log level to DEBUG:
198
+
199
+ ```
200
+ require "log4r"
201
+ DriveTime::log_level = Log4r::DEBUG
202
+ ```
203
+
204
+ ##### Other features
205
+
206
+ If you want to map a database field to a differently named field on a model you can use the following:
207
+
208
+ ```
209
+ fields:
210
+ - name: name
211
+ map_to: title
212
+ ```
213
+
214
+ This will map a Worksheet field named 'name' to a model attribute named 'title'.
215
+
216
+ You can also map the Worksheet title to a differently named model:
217
+
218
+ ```
219
+ worksheets:
220
+ - title: Staff
221
+ map_to_class: Employee
222
+ ```
223
+
224
+ This will use the Worksheet named 'Staff' to a model called 'Employee'.
225
+
226
+ It is sometimes useful to generate a UID for models to use in linked to an image or icon resource. You can map the model's key to a model attribute using:
227
+
228
+ ```
229
+ - title: Example
230
+ key: title
231
+ key_to: uid
232
+ ```
233
+
234
+ ## Contributing
235
+
236
+ 1. Fork it
237
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
238
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
239
+ 4. Push to the branch (`git push origin my-new-feature`)
240
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new
5
+
6
+ task :default => :spec
7
+ task :test => :spec
@@ -0,0 +1,30 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'drive_time/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "drive_time"
8
+ gem.version = DriveTime::VERSION
9
+ gem.authors = ["Pedr Browne"]
10
+ gem.email = ["pedr.browne@gmail.com"]
11
+ gem.description = %q{Convert Google Spreadsheets to Rails Models}
12
+ gem.summary = %q{Map Worksheets to Models and their columns to model attributes. Seed your database directly from Google Drive.}
13
+ gem.homepage = "https://github.com/stationkeeping/drive_time"
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+
20
+ gem.add_dependency "log4r"
21
+ gem.add_dependency "deep_end"
22
+ gem.add_dependency "google_drive"
23
+
24
+ gem.add_development_dependency 'rake'
25
+ gem.add_development_dependency 'rspec'
26
+ gem.add_development_dependency 'dotenv'
27
+ gem.add_development_dependency "activemodel", "3.2.13"
28
+ gem.add_development_dependency "activerecord", "3.2.13"
29
+ gem.add_development_dependency "activesupport", "3.2.13"
30
+ end
@@ -0,0 +1,33 @@
1
+ module DriveTime
2
+
3
+ class BiDirectionalHash
4
+
5
+ def initialize
6
+ @key_to_value = {}
7
+ @value_to_key = {}
8
+ end
9
+
10
+ def insert(key, value)
11
+ @key_to_value[key] = value;
12
+ @value_to_key[value] = key;
13
+ end
14
+
15
+ def value_for_key(key)
16
+ return @key_to_value[key]
17
+ end
18
+
19
+ def key_for_value(value)
20
+ return @value_to_key[value]
21
+ end
22
+
23
+ def has_key_for_value(value)
24
+ return !key_for_value(value).nil?
25
+ end
26
+
27
+ def has_value_for_key(key)
28
+ return !value_for_key(key).nil?
29
+ end
30
+
31
+ end
32
+
33
+ end
@@ -0,0 +1,39 @@
1
+ module DriveTime
2
+
3
+ # Take a series of name fields. Look up their values and assemble them into a single id
4
+ # For example it might build a name from a model's title and its amount
5
+ class JoinBuilder
6
+
7
+ class MissingFieldError < StandardError; end
8
+ class NoFieldsError < StandardError; end
9
+
10
+ # Fields to use for names
11
+ def build(field_keys, row_map)
12
+ values = []
13
+
14
+ field_keys.each do |field_key|
15
+ raise MissingFieldError, "No field for key #{field_key}" if !row_map.has_key? field_key
16
+ values << DriveTime.underscore_from_text(row_map[field_key]) unless row_map[field_key].empty?
17
+ end
18
+
19
+ raise NoFieldsError, 'No fields matched' if values.empty?
20
+
21
+ values.each_with_index do |value, index|
22
+
23
+ result = self.process_value value
24
+ if result
25
+ values[index] = result
26
+ end
27
+ end
28
+ values.join('_').downcase
29
+ end
30
+
31
+ protected
32
+
33
+ def process_value(value)
34
+ return value
35
+ end
36
+
37
+ end
38
+
39
+ end
@@ -0,0 +1,19 @@
1
+ module DriveTime
2
+
3
+ # Take a series of name fields. Look up their values and assemble them into a single id
4
+ class NameBuilder < JoinBuilder
5
+
6
+ protected
7
+
8
+ def process_value(value)
9
+
10
+ # Add a period to initials
11
+ if value.length == 1
12
+ return "#{value}."
13
+ end
14
+ return value
15
+ end
16
+
17
+ end
18
+
19
+ end
@@ -0,0 +1,41 @@
1
+ module DriveTime
2
+
3
+ # This is a fluid two-way map. In both directions it will return a mapping if it exists, otherwise
4
+ # it will return the value passed to it. It does not raise an exception if a mapping is not found.
5
+ class ClassNameMap
6
+
7
+ def initialize
8
+ @map = BiDirectionalHash.new
9
+ end
10
+
11
+ # Check for mapped class
12
+ def resolve_original_from_mapped(className)
13
+ if @map.has_key_for_value className
14
+ @map.key_for_value className
15
+ else
16
+ className
17
+ end
18
+ end
19
+
20
+ def resolve_mapped_from_original(className)
21
+ if @map.has_value_for_key className
22
+ @map.value_for_key className
23
+ else
24
+ className
25
+ end
26
+ end
27
+
28
+ # Accepts String versions of class names eg. ExampleClass
29
+ def save_mapping(class_name, mapped_class_name=nil)
30
+ if mapped_class_name
31
+ # Save mapping so we can look it up from both directions
32
+ @map.insert(class_name, mapped_class_name)
33
+ mapped_class_name
34
+ else
35
+ class_name
36
+ end
37
+ end
38
+
39
+ end
40
+
41
+ end
@@ -0,0 +1,125 @@
1
+ module DriveTime
2
+
3
+ class SpreadsheetsConverter
4
+
5
+ def initialize()
6
+ @dependency_graph = DeepEnd::Graph.new
7
+ @loader = DriveTime::Loader.new
8
+ @model_store = ModelStore.new(DriveTime::log_level)
9
+ @class_name_map = ClassNameMap.new
10
+ end
11
+
12
+ # Load mappings YML file
13
+ def load(mappings_path)
14
+ @mappings = ActiveSupport::HashWithIndifferentAccess.new(YAML::load File.open(mappings_path))
15
+ @namespace = @mappings[:namespace]
16
+ spreadsheets = download_spreadsheets
17
+
18
+ worksheets = []
19
+ spreadsheets.each do |spreadsheet|
20
+ # Create a map containing any class mappings
21
+ build_class_map spreadsheet
22
+ # Download and combine worksheets into single Array
23
+ worksheets.concat download_worksheets(spreadsheet)
24
+ end
25
+
26
+ worksheets = order_worksheets_by_dependencies( worksheets )
27
+
28
+ # Convert the worksheets
29
+ worksheets.each do |worksheet|
30
+ WorksheetConverter.new(@model_store, @class_name_map, @loader, @namespace).convert(worksheet)
31
+ end
32
+
33
+ @model_store.save_all
34
+ end
35
+
36
+ protected
37
+
38
+ def download_spreadsheets
39
+ spreadsheets = []
40
+ # First download the spreadsheets
41
+ @mappings[:spreadsheets].each do |spreadsheet_mapping|
42
+ spreadsheet = @loader.load_spreadsheet(spreadsheet_mapping[:title])
43
+ raise "No spreadsheet with a title: #{spreadsheet_mapping['title']} available" if spreadsheet.nil?
44
+ # Store mapping on the spreadsheet
45
+ spreadsheet.mapping = spreadsheet_mapping
46
+ spreadsheets << spreadsheet
47
+ end
48
+ return spreadsheets
49
+ end
50
+
51
+ def download_worksheets(spreadsheet)
52
+ worksheet_mappings = spreadsheet.mapping[:worksheets]
53
+ worksheets = []
54
+ if worksheet_mappings
55
+ worksheet_mappings.each do |worksheet_mapping|
56
+ title = worksheet_mapping[:title]
57
+ worksheet = @loader.load_worksheet_from_spreadsheet spreadsheet, title
58
+ raise "No worksheet with a title: #{worksheet_mapping['title']} available" if worksheet.nil?
59
+ worksheet.mapping = worksheet_mapping
60
+ worksheets << worksheet
61
+ end
62
+ return worksheets
63
+ end
64
+ end
65
+
66
+ def order_worksheets_by_dependencies(worksheets)
67
+ Logger.log_as_header "Calculating worksheet dependencies"
68
+ # Now sort their dependencies before converting them
69
+ worksheets.each do |worksheet|
70
+ Logger.debug "Worsheet named #{worksheet.title}"
71
+ associations = find_associations worksheet, worksheets
72
+ associations.each { |association| Logger.debug " - #{association.title}" }
73
+ @dependency_graph.add_dependency worksheet, associations
74
+ end
75
+ return @dependency_graph.resolved_dependencies
76
+ end
77
+
78
+ def find_associations(dependent_worksheet, worksheets)
79
+
80
+ dependent_associations_mapping = dependent_worksheet.mapping[:associations]
81
+
82
+ associations = []
83
+ if dependent_associations_mapping
84
+ # Run through each association
85
+ dependent_associations_mapping.each do |association_mapping|
86
+ # Handle possible polymorphic association
87
+ # If name is an array, we need to add each possibility as a dependency
88
+ names = []
89
+ if association_mapping[:name].is_a? Array
90
+ names = association_mapping[:name]
91
+ else
92
+ names << association_mapping[:name]
93
+ end
94
+ names.each do |name|
95
+ associations << worksheet_for_association(name, worksheets)
96
+ end
97
+ end
98
+ end
99
+ return associations
100
+ end
101
+
102
+ def worksheet_for_association(name, worksheets)
103
+ # And find the worksheet that satisfies it
104
+ worksheets.each do |worksheet|
105
+ class_name = DriveTime.class_name_from_title(worksheet.title)
106
+ resolved = @class_name_map.resolve_mapped_from_original class_name
107
+ # If the name matches we have a dependent relationship
108
+ if resolved.underscore == name
109
+ return worksheet
110
+ end
111
+ end
112
+ raise MissingAssociationError, "No worksheet #{name} to satisfy multi association"
113
+ end
114
+
115
+ def build_class_map(spreadsheet)
116
+ worksheets_mapping = spreadsheet.mapping[:worksheets]
117
+ worksheets_mapping.each do |worksheet_mapping|
118
+ class_name = DriveTime.class_name_from_title(worksheet_mapping[:title])
119
+ mapped_class_name = worksheet_mapping[:map_to_class]
120
+ @class_name_map.save_mapping(class_name, mapped_class_name)
121
+ end
122
+ end
123
+
124
+ end
125
+ end